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
3 changes: 1 addition & 2 deletions client/src/views/PlayView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import MobileNavBar from '@/components/lobbies/MobileNavBar.vue';
import LobbyHeader from '@/components/lobbies/LobbyHeader.vue';
import { useRoute } from 'vue-router';
import { getLobbyById } from '../services/api';
import { onUnmounted } from 'vue';

// Setup Store
const store = useEditorStore();
Expand Down Expand Up @@ -35,8 +36,6 @@ onMounted(async () => {
store.init(lobbyName);
});

import { onUnmounted } from 'vue';

onUnmounted(() => {
store.cleanup();
});
Expand Down
10 changes: 6 additions & 4 deletions server/src/middlewares/permissionMiddleware.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Response, NextFunction } from "express";
import { AuthRequest } from "./authMiddleware.js";
import { Lobby } from "../models/Lobby.js";
import { Types } from "mongoose";
import { LobbyService } from "../services/lobby.service.js";

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

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

// If we implement private lobbies in the future, check allowedUsers here
Expand Down
21 changes: 21 additions & 0 deletions server/src/services/canvas.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,27 @@ export class CanvasService {
return changed;
}

// Updates multiple pixels
static drawBatch(lobbyName: string, pixels: { x: number, y: number, color: number }[]): { x: number, y: number, color: number }[] {
const successfulUpdates: { x: number, y: number, color: number }[] = [];
let anyChanged = false;

for (const p of pixels) {
// Basic validation
if (!p || typeof p.x !== 'number' || typeof p.y !== 'number' || typeof p.color !== 'number') continue;

const changed = canvasStore.modifyPixelColor(lobbyName, p.x, p.y, p.color);
if (changed) {
// Sanitize the object we return to avoid echoing unexpected client properties
successfulUpdates.push({ x: p.x, y: p.y, color: p.color });
anyChanged = true;
}
}

if (anyChanged) this.scheduleSave(lobbyName);
return successfulUpdates;
}

// Schedules a DB save if one isn't already pending
private static scheduleSave(lobbyName: string) {
if (this.saveTimers.has(lobbyName)) {
Expand Down
14 changes: 14 additions & 0 deletions server/src/services/lobby.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,18 @@ export class LobbyService {
static async delete(id: string) {
return await Lobby.findByIdAndDelete(id);
}

// Common validation logic for joining/accessing a lobby
static validateJoinAccess(lobby: ILobby, userId: string): void {
if (lobby.bannedUsers.some((id: any) => id.toString() === userId)) {
throw new Error("Access denied. You are banned from this lobby.");
}
}

static validateCapacity(lobby: ILobby, currentCount: number): void {
if (currentCount >= lobby.maxCollaborators) {
throw new Error(`Lobby is full`);
}
}

}
136 changes: 42 additions & 94 deletions server/src/sockets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,14 @@ import { CONFIG } from '../config.js';
import { DrawPayload, DrawBatchPayload, AuthenticatedSocket } from './types.js';
import { LobbyService } from '../services/lobby.service.js';
import jwt from 'jsonwebtoken';
import { getLobbyUserCount, getUsersInLobby, broadcastToLobby, broadcastToOthers } from '../utils/socketUtils.js';

export const setupSocket = (io: Server) => {
// Authentication Middleware
io.use((socket, next) => {
const token = socket.handshake.auth.token;

if (!token) {
return next(new Error("Authentication error: No token provided"));
}

if (!token) return next(new Error("Authentication error: No token provided"));
jwt.verify(token, CONFIG.JWT.SECRET, (err: any, decoded: any) => {
if (err) {
return next(new Error("Authentication error: Invalid token"));
}
// Attach user info to socket
if (err) return next(new Error("Authentication error: Invalid token"));
(socket as AuthenticatedSocket).user = decoded;
socket.data.user = decoded;
next();
Expand All @@ -28,111 +21,66 @@ export const setupSocket = (io: Server) => {
io.on('connection', (socket: Socket) => {
console.log(`[Socket] New connection: ${socket.id}`);

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

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

const user = (socket as AuthenticatedSocket).user;
if (!user) {
console.error(`[Socket] Error: User not found on socket ${socket.id}`);
return;
}
try {
LobbyService.validateJoinAccess(lobby, user.id);
} catch (e: any) {
return socket.emit(CONFIG.EVENTS.SERVER.ERROR, { message: e.message });
}

try {
// 2. Broadcast to others that a new user joined (First, to avoid race conditions)
socket.to(lobbyName).emit(CONFIG.EVENTS.SERVER.USER_JOINED, user);
socket.join(lobbyName); // Optimistic Join

// 3. Send list of connected users to the new user
// We fetch sockets AFTER joining so the user is included in the list
const sockets = await io.in(lobbyName).fetchSockets();
const users = sockets.map(s => s.data.user).filter(u => u);
socket.emit(CONFIG.EVENTS.SERVER.LOBBY_USERS, users);
const currentCount = getLobbyUserCount(io, lobbyName);
try {
LobbyService.validateCapacity(lobby, currentCount - 1);
} catch (e: any) {
socket.leave(lobbyName);
return socket.emit(CONFIG.EVENTS.SERVER.ERROR, { message: "Lobby is full" });
}
Comment on lines +43 to +51
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capacity validation uses currentCount - 1 after an optimistic socket.join(), which makes the intent (checking count before vs after the join) hard to follow and easy to get wrong later. Consider computing a clearly named countBeforeJoin (or validating using the post-join count and checking > maxCollaborators) to make the off-by-one behavior explicit.

Copilot uses AI. Check for mistakes.

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

// --- EVENT: DRAW ---
// User wants to color a pixel
socket.on(CONFIG.EVENTS.CLIENT.DRAW, (payload: DrawPayload) => {
// Payload validation could happen here or in a DTO
const { lobbyName, x, y, color } = payload;

if (!lobbyName) return;

// 1. Process Logic via Service
const success = CanvasService.draw(lobbyName, x, y, color);

// 2. Broadcast if successful
if (success) {
// Send to everyone in the room INCLUDING the sender (for consistency)
io.to(lobbyName).emit(CONFIG.EVENTS.SERVER.PIXEL_UPDATE, { x, y, color });
socket.on(CONFIG.EVENTS.CLIENT.DRAW, ({ lobbyName, x, y, color }: DrawPayload) => {
if (lobbyName && CanvasService.draw(lobbyName, x, y, color)) {
broadcastToLobby(io, lobbyName, CONFIG.EVENTS.SERVER.PIXEL_UPDATE, { x, y, color });
}
});

// --- EVENT: DRAW_BATCH ---
// User wants to color multiple pixels (stroke)
socket.on(CONFIG.EVENTS.CLIENT.DRAW_BATCH, (payload: DrawBatchPayload) => {
const { lobbyName, pixels } = payload;
// pixels: { x, y, color }[]

socket.on(CONFIG.EVENTS.CLIENT.DRAW_BATCH, ({ lobbyName, pixels }: DrawBatchPayload) => {
if (!lobbyName || !Array.isArray(pixels)) return;

const successfulUpdates: any[] = [];

// 1. Process Logic via Service for each pixel
for (const p of pixels) {
// Validation: ensure p is an object and has required properties
if (!p || typeof p !== 'object' || typeof p.x !== 'number' || typeof p.y !== 'number' || typeof p.color !== 'number') {
continue;
}
const { x, y, color } = p;
const success = CanvasService.draw(lobbyName, x, y, color);
if (success) {
successfulUpdates.push({ x, y, color });
}
}

// 2. Broadcast batch if any successful
if (successfulUpdates.length > 0) {
// Send to everyone in the room INCLUDING the sender (for consistency)
io.to(lobbyName).emit(CONFIG.EVENTS.SERVER.PIXEL_UPDATE_BATCH, { pixels: successfulUpdates });
}
const updates = CanvasService.drawBatch(lobbyName, pixels);
if (updates.length) broadcastToLobby(io, lobbyName, CONFIG.EVENTS.SERVER.PIXEL_UPDATE_BATCH, { pixels: updates });
});


// --- DISCONNECTING ---
socket.on('disconnecting', async () => {
// Notify rooms that user is leaving
for (const room of socket.rooms) {
if (room !== socket.id) {
socket.to(room).emit(CONFIG.EVENTS.SERVER.USER_LEFT, (socket as AuthenticatedSocket).user);

// Check if room is empty (excluding this socket)
const socketsInRoom = await io.in(room).fetchSockets();
const remainingUsers = socketsInRoom.length - 1; // fetchSockets includes the disconnecting socket

if (remainingUsers <= 0) {
// Room is empty, unload from hot storage
await CanvasService.unloadLobby(room);
}
}
if (room === socket.id) continue;
broadcastToOthers(socket, room, CONFIG.EVENTS.SERVER.USER_LEFT, (socket as AuthenticatedSocket).user);
const users = await getUsersInLobby(io, room);
if (users.length - 1 <= 0) await CanvasService.unloadLobby(room);
}
});

// --- DISCONNECT ---
socket.on('disconnect', () => {
// Cleanup logic if needed (e.g., updating user count)
console.log(`[Socket] Disconnected: ${socket.id}`);
});
socket.on('disconnect', () => console.log(`[Socket] Disconnected: ${socket.id}`));
});
};
33 changes: 33 additions & 0 deletions server/src/utils/socketUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Server, Socket } from 'socket.io';

/**
* Gets the number of connected users in a specific lobby.
* @param io The Socket.IO server instance
* @param lobbyName The name of the lobby (room)
* @returns The number of clients in the room
*/
export const getLobbyUserCount = (io: Server, lobbyName: string): number => {
return io.sockets.adapter.rooms.get(lobbyName)?.size || 0;
};

/**
* Fetches all users currently connected to a lobby.
*/
export const getUsersInLobby = async (io: Server, lobbyName: string): Promise<any[]> => {
const sockets = await io.in(lobbyName).fetchSockets();
return sockets.map(s => s.data.user).filter(u => u);
};

/**
* Broadcasts an event to all users in a lobby (including sender).
*/
export const broadcastToLobby = (io: Server, lobbyName: string, event: string, data: any) => {
io.to(lobbyName).emit(event, data);
};

/**
* Broadcasts an event to all other users in a lobby (excluding sender).
*/
export const broadcastToOthers = (socket: Socket, lobbyName: string, event: string, data: any) => {
socket.to(lobbyName).emit(event, data);
};