Skip to content

Commit 0faaa7f

Browse files
authored
Merge pull request #49 from pixie-git/feature/PIX-58_validate-user-join
Feature/pix 58 validate user join
2 parents ee48333 + 7c73cce commit 0faaa7f

6 files changed

Lines changed: 117 additions & 100 deletions

File tree

client/src/views/PlayView.vue

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import MobileNavBar from '@/components/lobbies/MobileNavBar.vue';
88
import LobbyHeader from '@/components/lobbies/LobbyHeader.vue';
99
import { useRoute } from 'vue-router';
1010
import { getLobbyById } from '../services/api';
11+
import { onUnmounted } from 'vue';
1112
1213
// Setup Store
1314
const store = useEditorStore();
@@ -35,8 +36,6 @@ onMounted(async () => {
3536
store.init(lobbyName);
3637
});
3738
38-
import { onUnmounted } from 'vue';
39-
4039
onUnmounted(() => {
4140
store.cleanup();
4241
});

server/src/middlewares/permissionMiddleware.ts

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

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

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." });
64+
// reused logic from Service
65+
try {
66+
LobbyService.validateJoinAccess(lobby, userId);
67+
} catch (e: any) {
68+
return res.status(403).json({ error: e.message });
6769
}
6870

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

server/src/services/canvas.service.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,27 @@ export class CanvasService {
5858
return changed;
5959
}
6060

61+
// Updates multiple pixels
62+
static drawBatch(lobbyName: string, pixels: { x: number, y: number, color: number }[]): { x: number, y: number, color: number }[] {
63+
const successfulUpdates: { x: number, y: number, color: number }[] = [];
64+
let anyChanged = false;
65+
66+
for (const p of pixels) {
67+
// Basic validation
68+
if (!p || typeof p.x !== 'number' || typeof p.y !== 'number' || typeof p.color !== 'number') continue;
69+
70+
const changed = canvasStore.modifyPixelColor(lobbyName, p.x, p.y, p.color);
71+
if (changed) {
72+
// Sanitize the object we return to avoid echoing unexpected client properties
73+
successfulUpdates.push({ x: p.x, y: p.y, color: p.color });
74+
anyChanged = true;
75+
}
76+
}
77+
78+
if (anyChanged) this.scheduleSave(lobbyName);
79+
return successfulUpdates;
80+
}
81+
6182
// Schedules a DB save if one isn't already pending
6283
private static scheduleSave(lobbyName: string) {
6384
if (this.saveTimers.has(lobbyName)) {

server/src/services/lobby.service.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,18 @@ 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+
3852
}

server/src/sockets/index.ts

Lines changed: 42 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,14 @@ 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) => {
9-
// Authentication Middleware
1010
io.use((socket, next) => {
1111
const token = socket.handshake.auth.token;
12-
13-
if (!token) {
14-
return next(new Error("Authentication error: No token provided"));
15-
}
16-
12+
if (!token) return next(new Error("Authentication error: No token provided"));
1713
jwt.verify(token, CONFIG.JWT.SECRET, (err: any, decoded: any) => {
18-
if (err) {
19-
return next(new Error("Authentication error: Invalid token"));
20-
}
21-
// Attach user info to socket
14+
if (err) return next(new Error("Authentication error: Invalid token"));
2215
(socket as AuthenticatedSocket).user = decoded;
2316
socket.data.user = decoded;
2417
next();
@@ -28,111 +21,66 @@ export const setupSocket = (io: Server) => {
2821
io.on('connection', (socket: Socket) => {
2922
console.log(`[Socket] New connection: ${socket.id}`);
3023

31-
// --- EVENT: JOIN_LOBBY ---
32-
// User requests to enter a specific room
3324
socket.on(CONFIG.EVENTS.CLIENT.JOIN_LOBBY, async (lobbyName: string) => {
34-
console.log(`[Socket] ${socket.id} joining lobby: ${lobbyName}`);
25+
try {
26+
const user = (socket as AuthenticatedSocket).user;
27+
if (!user?.id) {
28+
console.error(`[Socket] User not found: ${socket.id}`);
29+
socket.emit(CONFIG.EVENTS.SERVER.ERROR, { message: "User not authenticated" });
30+
socket.disconnect(true);
31+
return;
32+
}
3533

36-
// 1. Join the Socket.io room channel
37-
socket.join(lobbyName);
34+
const lobby = await LobbyService.getByName(lobbyName);
35+
if (!lobby) return socket.emit(CONFIG.EVENTS.SERVER.ERROR, { message: "Lobby not found" });
3836

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-
}
37+
try {
38+
LobbyService.validateJoinAccess(lobby, user.id);
39+
} catch (e: any) {
40+
return socket.emit(CONFIG.EVENTS.SERVER.ERROR, { message: e.message });
41+
}
4442

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);
43+
socket.join(lobbyName); // Optimistic Join
4844

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);
53-
socket.emit(CONFIG.EVENTS.SERVER.LOBBY_USERS, users);
45+
const currentCount = getLobbyUserCount(io, lobbyName);
46+
try {
47+
LobbyService.validateCapacity(lobby, currentCount - 1);
48+
} catch (e: any) {
49+
socket.leave(lobbyName);
50+
return socket.emit(CONFIG.EVENTS.SERVER.ERROR, { message: "Lobby is full" });
51+
}
5452

55-
// 4. Get current state from Service & Send to user
56-
const state = await CanvasService.getState(lobbyName);
57-
socket.emit(CONFIG.EVENTS.SERVER.INIT_STATE, state);
53+
broadcastToOthers(socket, lobbyName, CONFIG.EVENTS.SERVER.USER_JOINED, user);
54+
socket.emit(CONFIG.EVENTS.SERVER.LOBBY_USERS, await getUsersInLobby(io, lobbyName));
55+
socket.emit(CONFIG.EVENTS.SERVER.INIT_STATE, await CanvasService.getState(lobbyName));
56+
console.log(`[Socket] ${socket.id} joined ${lobbyName}`);
5857
} catch (error) {
59-
console.error(`[Socket] Error joining lobby ${lobbyName}:`, error);
58+
console.error(`[Socket] Join Error:`, error);
6059
socket.emit(CONFIG.EVENTS.SERVER.ERROR, { message: "Failed to join lobby" });
6160
}
6261
});
6362

64-
// --- EVENT: DRAW ---
65-
// User wants to color a pixel
66-
socket.on(CONFIG.EVENTS.CLIENT.DRAW, (payload: DrawPayload) => {
67-
// Payload validation could happen here or in a DTO
68-
const { lobbyName, x, y, color } = payload;
69-
70-
if (!lobbyName) return;
71-
72-
// 1. Process Logic via Service
73-
const success = CanvasService.draw(lobbyName, x, y, color);
74-
75-
// 2. Broadcast if successful
76-
if (success) {
77-
// Send to everyone in the room INCLUDING the sender (for consistency)
78-
io.to(lobbyName).emit(CONFIG.EVENTS.SERVER.PIXEL_UPDATE, { x, y, color });
63+
socket.on(CONFIG.EVENTS.CLIENT.DRAW, ({ lobbyName, x, y, color }: DrawPayload) => {
64+
if (lobbyName && CanvasService.draw(lobbyName, x, y, color)) {
65+
broadcastToLobby(io, lobbyName, CONFIG.EVENTS.SERVER.PIXEL_UPDATE, { x, y, color });
7966
}
8067
});
8168

82-
// --- EVENT: DRAW_BATCH ---
83-
// User wants to color multiple pixels (stroke)
84-
socket.on(CONFIG.EVENTS.CLIENT.DRAW_BATCH, (payload: DrawBatchPayload) => {
85-
const { lobbyName, pixels } = payload;
86-
// pixels: { x, y, color }[]
87-
69+
socket.on(CONFIG.EVENTS.CLIENT.DRAW_BATCH, ({ lobbyName, pixels }: DrawBatchPayload) => {
8870
if (!lobbyName || !Array.isArray(pixels)) return;
89-
90-
const successfulUpdates: any[] = [];
91-
92-
// 1. Process Logic via Service for each pixel
93-
for (const p of pixels) {
94-
// Validation: ensure p is an object and has required properties
95-
if (!p || typeof p !== 'object' || typeof p.x !== 'number' || typeof p.y !== 'number' || typeof p.color !== 'number') {
96-
continue;
97-
}
98-
const { x, y, color } = p;
99-
const success = CanvasService.draw(lobbyName, x, y, color);
100-
if (success) {
101-
successfulUpdates.push({ x, y, color });
102-
}
103-
}
104-
105-
// 2. Broadcast batch if any successful
106-
if (successfulUpdates.length > 0) {
107-
// Send to everyone in the room INCLUDING the sender (for consistency)
108-
io.to(lobbyName).emit(CONFIG.EVENTS.SERVER.PIXEL_UPDATE_BATCH, { pixels: successfulUpdates });
109-
}
71+
const updates = CanvasService.drawBatch(lobbyName, pixels);
72+
if (updates.length) broadcastToLobby(io, lobbyName, CONFIG.EVENTS.SERVER.PIXEL_UPDATE_BATCH, { pixels: updates });
11073
});
11174

112-
113-
// --- DISCONNECTING ---
11475
socket.on('disconnecting', async () => {
115-
// Notify rooms that user is leaving
11676
for (const room of socket.rooms) {
117-
if (room !== socket.id) {
118-
socket.to(room).emit(CONFIG.EVENTS.SERVER.USER_LEFT, (socket as AuthenticatedSocket).user);
119-
120-
// Check if room is empty (excluding this socket)
121-
const socketsInRoom = await io.in(room).fetchSockets();
122-
const remainingUsers = socketsInRoom.length - 1; // fetchSockets includes the disconnecting socket
123-
124-
if (remainingUsers <= 0) {
125-
// Room is empty, unload from hot storage
126-
await CanvasService.unloadLobby(room);
127-
}
128-
}
77+
if (room === socket.id) continue;
78+
broadcastToOthers(socket, room, CONFIG.EVENTS.SERVER.USER_LEFT, (socket as AuthenticatedSocket).user);
79+
const users = await getUsersInLobby(io, room);
80+
if (users.length - 1 <= 0) await CanvasService.unloadLobby(room);
12981
}
13082
});
13183

132-
// --- DISCONNECT ---
133-
socket.on('disconnect', () => {
134-
// Cleanup logic if needed (e.g., updating user count)
135-
console.log(`[Socket] Disconnected: ${socket.id}`);
136-
});
84+
socket.on('disconnect', () => console.log(`[Socket] Disconnected: ${socket.id}`));
13785
});
13886
};

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)