Skip to content

Commit 2425a90

Browse files
committed
PIX-58 validate lobby access
1 parent ee48333 commit 2425a90

4 files changed

Lines changed: 97 additions & 22 deletions

File tree

server/src/middlewares/permissionMiddleware.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Response, NextFunction } from "express";
22
import { AuthRequest } from "./authMiddleware.js";
33
import { Lobby } from "../models/Lobby.js";
44
import { Types } from "mongoose";
5+
import { LobbyService } from "../services/lobby.service.js";
56

67
/**
78
* Middleware to check if the user is a System Administrator.
@@ -61,9 +62,11 @@ export const requireLobbyAccess = async (req: AuthRequest, res: Response, next:
6162
return res.status(404).json({ error: "Lobby not found" });
6263
}
6364

64-
// Check if user is banned
65-
if (lobby.bannedUsers.some((id: Types.ObjectId) => id.toString() === userId)) {
66-
return res.status(403).json({ error: "Access denied. You are banned from this lobby." });
65+
// reused logic from Service
66+
try {
67+
LobbyService.validateJoinAccess(lobby, userId);
68+
} catch (e: any) {
69+
return res.status(403).json({ error: e.message });
6770
}
6871

6972
// If we implement private lobbies in the future, check allowedUsers here

server/src/services/lobby.service.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,32 @@ export class LobbyService {
3535
static async delete(id: string) {
3636
return await Lobby.findByIdAndDelete(id);
3737
}
38+
39+
// Common validation logic for joining/accessing a lobby
40+
static validateJoinAccess(lobby: ILobby, userId: string): void {
41+
if (lobby.bannedUsers.some((id: any) => id.toString() === userId)) {
42+
throw new Error("Access denied. You are banned from this lobby.");
43+
}
44+
}
45+
46+
static validateCapacity(lobby: ILobby, currentCount: number): void {
47+
if (currentCount >= lobby.maxCollaborators) {
48+
throw new Error(`Lobby is full`);
49+
}
50+
}
51+
52+
static async verifyCanJoin(lobbyName: string, userId: string, currentCount: number) {
53+
const lobby = await this.getByName(lobbyName);
54+
if (!lobby) {
55+
throw new Error("Lobby not found");
56+
}
57+
58+
// 1. Check Banned
59+
this.validateJoinAccess(lobby, userId);
60+
61+
// 2. Check Capacity
62+
this.validateCapacity(lobby, currentCount);
63+
64+
return lobby;
65+
}
3866
}

server/src/sockets/index.ts

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { CONFIG } from '../config.js';
44
import { DrawPayload, DrawBatchPayload, AuthenticatedSocket } from './types.js';
55
import { LobbyService } from '../services/lobby.service.js';
66
import jwt from 'jsonwebtoken';
7+
import { getLobbyUserCount, getUsersInLobby, broadcastToLobby, broadcastToOthers } from '../utils/socketUtils.js';
78

89
export const setupSocket = (io: Server) => {
910
// Authentication Middleware
@@ -33,26 +34,36 @@ export const setupSocket = (io: Server) => {
3334
socket.on(CONFIG.EVENTS.CLIENT.JOIN_LOBBY, async (lobbyName: string) => {
3435
console.log(`[Socket] ${socket.id} joining lobby: ${lobbyName}`);
3536

36-
// 1. Join the Socket.io room channel
37-
socket.join(lobbyName);
37+
try {
38+
// 1. Get User
39+
const user = (socket as AuthenticatedSocket).user;
40+
if (!user || !user.id) {
41+
console.error(`[Socket] Error: User not found on socket ${socket.id}`);
42+
return;
43+
}
3844

39-
const user = (socket as AuthenticatedSocket).user;
40-
if (!user) {
41-
console.error(`[Socket] Error: User not found on socket ${socket.id}`);
42-
return;
43-
}
45+
// 2. Validate Join (Service checks existence, ban status, and capacity)
46+
const currentCount = getLobbyUserCount(io, lobbyName);
4447

45-
try {
46-
// 2. Broadcast to others that a new user joined (First, to avoid race conditions)
47-
socket.to(lobbyName).emit(CONFIG.EVENTS.SERVER.USER_JOINED, user);
48+
try {
49+
await LobbyService.verifyCanJoin(lobbyName, user.id, currentCount);
50+
} catch (e: any) {
51+
console.error(`[Socket] Join blocked: ${e.message}`);
52+
socket.emit(CONFIG.EVENTS.SERVER.ERROR, { message: e.message });
53+
return;
54+
}
55+
56+
// 3. Join the Socket.io room channel
57+
socket.join(lobbyName);
58+
59+
// 4. Broadcast to others that a new user joined
60+
broadcastToOthers(socket, lobbyName, CONFIG.EVENTS.SERVER.USER_JOINED, user);
4861

49-
// 3. Send list of connected users to the new user
50-
// We fetch sockets AFTER joining so the user is included in the list
51-
const sockets = await io.in(lobbyName).fetchSockets();
52-
const users = sockets.map(s => s.data.user).filter(u => u);
62+
// 5. Send list of connected users to the new user
63+
const users = await getUsersInLobby(io, lobbyName);
5364
socket.emit(CONFIG.EVENTS.SERVER.LOBBY_USERS, users);
5465

55-
// 4. Get current state from Service & Send to user
66+
// 6. Get current state from Service & Send to user
5667
const state = await CanvasService.getState(lobbyName);
5768
socket.emit(CONFIG.EVENTS.SERVER.INIT_STATE, state);
5869
} catch (error) {
@@ -75,7 +86,7 @@ export const setupSocket = (io: Server) => {
7586
// 2. Broadcast if successful
7687
if (success) {
7788
// Send to everyone in the room INCLUDING the sender (for consistency)
78-
io.to(lobbyName).emit(CONFIG.EVENTS.SERVER.PIXEL_UPDATE, { x, y, color });
89+
broadcastToLobby(io, lobbyName, CONFIG.EVENTS.SERVER.PIXEL_UPDATE, { x, y, color });
7990
}
8091
});
8192

@@ -105,7 +116,7 @@ export const setupSocket = (io: Server) => {
105116
// 2. Broadcast batch if any successful
106117
if (successfulUpdates.length > 0) {
107118
// Send to everyone in the room INCLUDING the sender (for consistency)
108-
io.to(lobbyName).emit(CONFIG.EVENTS.SERVER.PIXEL_UPDATE_BATCH, { pixels: successfulUpdates });
119+
broadcastToLobby(io, lobbyName, CONFIG.EVENTS.SERVER.PIXEL_UPDATE_BATCH, { pixels: successfulUpdates });
109120
}
110121
});
111122

@@ -115,10 +126,10 @@ export const setupSocket = (io: Server) => {
115126
// Notify rooms that user is leaving
116127
for (const room of socket.rooms) {
117128
if (room !== socket.id) {
118-
socket.to(room).emit(CONFIG.EVENTS.SERVER.USER_LEFT, (socket as AuthenticatedSocket).user);
129+
broadcastToOthers(socket, room, CONFIG.EVENTS.SERVER.USER_LEFT, (socket as AuthenticatedSocket).user);
119130

120131
// Check if room is empty (excluding this socket)
121-
const socketsInRoom = await io.in(room).fetchSockets();
132+
const socketsInRoom = await getUsersInLobby(io, room);
122133
const remainingUsers = socketsInRoom.length - 1; // fetchSockets includes the disconnecting socket
123134

124135
if (remainingUsers <= 0) {

server/src/utils/socketUtils.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { Server, Socket } from 'socket.io';
2+
3+
/**
4+
* Gets the number of connected users in a specific lobby.
5+
* @param io The Socket.IO server instance
6+
* @param lobbyName The name of the lobby (room)
7+
* @returns The number of clients in the room
8+
*/
9+
export const getLobbyUserCount = (io: Server, lobbyName: string): number => {
10+
return io.sockets.adapter.rooms.get(lobbyName)?.size || 0;
11+
};
12+
13+
/**
14+
* Fetches all users currently connected to a lobby.
15+
*/
16+
export const getUsersInLobby = async (io: Server, lobbyName: string): Promise<any[]> => {
17+
const sockets = await io.in(lobbyName).fetchSockets();
18+
return sockets.map(s => s.data.user).filter(u => u);
19+
};
20+
21+
/**
22+
* Broadcasts an event to all users in a lobby (including sender).
23+
*/
24+
export const broadcastToLobby = (io: Server, lobbyName: string, event: string, data: any) => {
25+
io.to(lobbyName).emit(event, data);
26+
};
27+
28+
/**
29+
* Broadcasts an event to all other users in a lobby (excluding sender).
30+
*/
31+
export const broadcastToOthers = (socket: Socket, lobbyName: string, event: string, data: any) => {
32+
socket.to(lobbyName).emit(event, data);
33+
};

0 commit comments

Comments
 (0)