/** * WebSocket server — full real-time classroom channel. * * Client → server messages: * classroom_join { sessionId } * cursor { sessionId, x, y, pageNum } * preview { sessionId, liveId, tool, data, pageNum, cancel } * hand_raise { sessionId } * hand_lower { sessionId, targetUserId? } teacher can lower anyone's hand * page_change { sessionId, pageNum } teacher only * page_clear { sessionId, pageNum } teacher only * template_change { sessionId, pageNum, template } teacher only * allow_draw { sessionId, targetUserId } teacher only * revoke_draw { sessionId, targetUserId } teacher only * mute_peer { sessionId, targetUserId } teacher only * screen_start { sessionId } teacher only * screen_stop { sessionId } teacher only * * Server → client delivery: * All classroom events are sent via WS to connected users, * with automatic SSE fallback for users not on WS. * Use broadcastToSession() / emitToUser() from other modules. */ const { WebSocketServer } = require('ws'); const jwt = require('jsonwebtoken'); const db = require('./db/db'); const { emit, emitToGuests } = require('./sse'); /* ── Classroom connections: userId → Set ──────────────────────────── */ const _classroomConns = new Map(); function _registerUser(ws) { if (!_classroomConns.has(ws.userId)) _classroomConns.set(ws.userId, new Set()); _classroomConns.get(ws.userId).add(ws); } function _unregisterUser(ws) { const set = _classroomConns.get(ws.userId); if (!set) return; set.delete(ws); if (set.size === 0) _classroomConns.delete(ws.userId); } /* ── Delivery: WS first, SSE fallback ────────────────────────────────── */ function emitToUser(userId, data) { const sockets = _classroomConns.get(userId); if (sockets?.size) { const raw = JSON.stringify(data); for (const ws of sockets) { if (ws.readyState === 1) try { ws.send(raw); } catch {} } return; // delivered via WS } emit(userId, data); // SSE fallback } function broadcastToSession(sessionId, data, includeGuests = false) { const members = _getMembers(sessionId); if (!members) return; for (const uid of members.userIds) emitToUser(uid, data); if (includeGuests) emitToGuests(sessionId, data); } /* ── Session member cache (30s TTL — avoids DB per message) ───────────── */ const _cache = new Map(); const CACHE_TTL = 30_000; function _getMembers(sessionId) { const c = _cache.get(sessionId); if (c && Date.now() - c.ts < CACHE_TTL) return c; const session = db.prepare( "SELECT class_id, teacher_id FROM classroom_sessions WHERE id=? AND status='active'" ).get(sessionId); if (!session) { _cache.delete(sessionId); return null; } let userIds; if (session.class_id) { const rows = db.prepare('SELECT user_id FROM class_members WHERE class_id=?').all(session.class_id); userIds = [session.teacher_id, ...rows.map(r => r.user_id)]; } else { const rows = db.prepare('SELECT user_id FROM classroom_invites WHERE session_id=?').all(sessionId); userIds = [session.teacher_id, ...rows.map(r => r.user_id)]; } const entry = { teacherId: session.teacher_id, classId: session.class_id, userIds, ts: Date.now() }; _cache.set(sessionId, entry); return entry; } function _invalidateSession(sessionId) { _cache.delete(sessionId); // Cleanup draw cache entries for this session for (const key of _drawCache.keys()) { if (key.startsWith(sessionId + ':')) _drawCache.delete(key); } } /* ── Draw permission cache (10s TTL) ─────────────────────────────────── */ const _drawCache = new Map(); const DRAW_TTL = 10_000; function _canDraw(sessionId, userId, members) { if (!members) return false; if (members.teacherId === userId) return true; const key = `${sessionId}:${userId}`; const c = _drawCache.get(key); if (c && Date.now() - c.ts < DRAW_TTL) return c.allowed; const allowed = !!db.prepare( 'SELECT 1 FROM classroom_draw_permissions WHERE session_id=? AND user_id=?' ).get(sessionId, userId); _drawCache.set(key, { allowed, ts: Date.now() }); return allowed; } function _invalidateDrawCache(sessionId, userId) { _drawCache.delete(`${sessionId}:${userId}`); } /* ── Message handler ─────────────────────────────────────────────────── */ function _handleMessage(ws, msg) { const { type, sessionId } = msg; if (!sessionId || typeof sessionId !== 'number') return; const members = _getMembers(sessionId); if (!members) return; const isMember = members.userIds.includes(ws.userId); const isTeacher = members.teacherId === ws.userId; switch (type) { /* ── Register in session (sends WS events instead of SSE) ── */ case 'classroom_join': ws.classroomSessionId = sessionId; _registerUser(ws); break; /* ── Cursor position ── */ case 'cursor': { if (!isMember) return; broadcastToSession(sessionId, { type: 'classroom_cursor', sessionId, x: msg.x, y: msg.y, pageNum: msg.pageNum || 1, userId: ws.userId, userName: ws.userName, }, true); break; } /* ── Stroke live preview ── */ case 'preview': { if (!_canDraw(sessionId, ws.userId, members)) return; if (!msg.liveId && !msg.cancel) return; broadcastToSession(sessionId, { type: 'classroom_stroke_preview', sessionId, pageNum: msg.pageNum || 1, liveId: msg.liveId, tool: msg.tool, data: msg.data, cancel: msg.cancel || false, userId: ws.userId, userName: ws.userName, }, true); break; } /* ── Hand raise / lower ── */ case 'hand_raise': { if (!isMember) return; try { db.prepare('INSERT OR IGNORE INTO classroom_hands (session_id, user_id) VALUES (?,?)').run(sessionId, ws.userId); } catch { return; } broadcastToSession(sessionId, { type: 'classroom_hand_raised', sessionId, userId: ws.userId, userName: ws.userName, }); break; } case 'hand_lower': { if (!isMember) return; const targetId = (isTeacher && msg.targetUserId) ? Number(msg.targetUserId) : ws.userId; try { db.prepare('DELETE FROM classroom_hands WHERE session_id=? AND user_id=?').run(sessionId, targetId); } catch { return; } broadcastToSession(sessionId, { type: 'classroom_hand_lowered', sessionId, userId: targetId, }); break; } /* ── Page navigation (teacher) ── */ case 'page_change': { if (!isTeacher) return; const pageNum = Number(msg.pageNum); if (!pageNum || pageNum < 1) return; db.prepare('UPDATE classroom_sessions SET current_page=? WHERE id=?').run(pageNum, sessionId); broadcastToSession(sessionId, { type: 'classroom_page_changed', sessionId, pageNum, }, true); break; } /* ── Page clear (teacher) ── */ case 'page_clear': { if (!isTeacher) return; const pageNum = Number(msg.pageNum) || 1; db.prepare('DELETE FROM classroom_strokes WHERE session_id=? AND page_num=?').run(sessionId, pageNum); broadcastToSession(sessionId, { type: 'classroom_page_cleared', sessionId, pageNum, }, true); break; } /* ── Page template change (teacher) ── */ case 'template_change': { if (!isTeacher) return; const pageNum = Number(msg.pageNum) || 1; const template = (msg.template || 'blank').slice(0, 32); try { db.prepare( `INSERT INTO classroom_pages (session_id, page_num, template) VALUES (?,?,?) ON CONFLICT(session_id, page_num) DO UPDATE SET template=excluded.template` ).run(sessionId, pageNum, template); } catch { return; } broadcastToSession(sessionId, { type: 'classroom_template_changed', sessionId, pageNum, template, }, true); break; } /* ── Draw permission (teacher) ── */ case 'allow_draw': { if (!isTeacher) return; const targetId = Number(msg.targetUserId); if (!targetId) return; try { db.prepare('INSERT OR IGNORE INTO classroom_draw_permissions (session_id, user_id) VALUES (?,?)').run(sessionId, targetId); } catch { return; } _invalidateDrawCache(sessionId, targetId); emitToUser(targetId, { type: 'classroom_draw_permitted', sessionId }); break; } case 'revoke_draw': { if (!isTeacher) return; const targetId = Number(msg.targetUserId); if (!targetId) return; try { db.prepare('DELETE FROM classroom_draw_permissions WHERE session_id=? AND user_id=?').run(sessionId, targetId); } catch { return; } _invalidateDrawCache(sessionId, targetId); emitToUser(targetId, { type: 'classroom_draw_revoked', sessionId }); break; } /* ── Mute peer (teacher) ── */ case 'mute_peer': { if (!isTeacher) return; const targetId = Number(msg.targetUserId); if (!targetId) return; try { db.prepare('INSERT OR IGNORE INTO classroom_muted (session_id, user_id, muted_by) VALUES (?,?,?)').run(sessionId, targetId, ws.userId); } catch { return; } emitToUser(targetId, { type: 'classroom_muted', sessionId, by: ws.userId }); break; } case 'unmute_peer': { if (!isTeacher) return; const targetId = Number(msg.targetUserId); if (!targetId) return; try { db.prepare('DELETE FROM classroom_muted WHERE session_id=? AND user_id=?').run(sessionId, targetId); } catch { return; } emitToUser(targetId, { type: 'classroom_unmuted', sessionId, by: ws.userId }); break; } /* ── Screen share announce (teacher) ── */ case 'screen_start': { if (!isTeacher) return; broadcastToSession(sessionId, { type: 'classroom_screen_started', sessionId }); break; } case 'screen_stop': { if (!isTeacher) return; broadcastToSession(sessionId, { type: 'classroom_screen_stopped', sessionId }); break; } } } /* ── WebSocket server ──────────────────────────────────────────────────── */ let _wss = null; function attach(httpServer) { const wss = new WebSocketServer({ server: httpServer, path: '/ws' }); _wss = wss; wss.on('connection', (ws) => { ws.userId = null; ws.userName = ''; ws.isAlive = true; ws.on('pong', () => { ws.isAlive = true; }); ws._msgCount = 0; ws._msgWindowStart = Date.now(); // Close unauthenticated connections after 5s ws._authTimer = setTimeout(() => { if (!ws.userId) { try { ws.close(4001, 'Auth timeout'); } catch {} } }, 5000); ws.on('message', raw => { // Rate-limit: max 120 messages per second per connection const now = Date.now(); if (now - ws._msgWindowStart > 1000) { ws._msgCount = 0; ws._msgWindowStart = now; } if (++ws._msgCount > 120) return; let msg; try { msg = JSON.parse(raw); } catch { return; } // Pre-auth: only {type:'auth', token} accepted if (!ws.userId) { if (msg.type !== 'auth' || !msg.token) { try { ws.close(4001, 'Auth required'); } catch {} return; } try { const user = jwt.verify(msg.token, process.env.JWT_SECRET, { algorithms: ['HS256'] }); ws.userId = user.id; ws.userName = user.name || user.email || ''; clearTimeout(ws._authTimer); ws._authTimer = null; try { ws.send(JSON.stringify({ type: 'auth_ok' })); } catch {} } catch { try { ws.close(4001, 'Invalid token'); } catch {} } return; } // Post-auth: normal handler try { _handleMessage(ws, msg); } catch (e) { // swallow — never crash the WS server on bad input } }); ws.on('error', () => {}); ws.on('close', () => { if (ws._authTimer) { clearTimeout(ws._authTimer); ws._authTimer = null; } if (ws.userId) _unregisterUser(ws); }); }); /* ── Ping/pong keepalive (30s) ── */ const pingTimer = setInterval(() => { for (const ws of wss.clients) { if (!ws.isAlive) { ws.terminate(); continue; } ws.isAlive = false; try { ws.ping(); } catch {} } }, 30_000); wss.on('close', () => clearInterval(pingTimer)); return wss; } function closeAll() { if (!_wss) return; for (const ws of _wss.clients) { try { ws.close(1001, 'Server shutting down'); } catch {} } try { _wss.close(); } catch {} } module.exports = { attach, broadcastToSession, emitToUser, invalidateSession: _invalidateSession, invalidateDrawCache: _invalidateDrawCache, closeAll, };