From f1e6ed7f2d287b89c2c254af87f4fef8b53842d6 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Thu, 16 Apr 2026 09:02:50 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20=D0=BE?= =?UTF-8?q?=D0=BD=D0=BB=D0=B0=D0=B9=D0=BD-=D1=83=D1=80=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=20=E2=80=94=208=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=B1=D0=B0=D0=B3=D0=BE=D0=B2,=20?= =?UTF-8?q?=D1=83=D1=8F=D0=B7=D0=B2=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D0=B5?= =?UTF-8?q?=D0=B9=20=D0=B8=20=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - draw_permitted: emit→emitToUser (WS доставка вместо SSE-only) - raised hands: убран in-memory Map, единый источник — таблица classroom_hands - endSession: очистка classroom_hands при завершении сессии - VALID_THEMES: исправлен список (добавлен corkboard, убраны dark/grid/dots) - XSS: crLoadOnlineStudents — inline onclick заменён на data-* + addEventListener - signal(): проверка что target_user_id является участником сессии - WS rate-limit: 120 msg/sec per connection - invalidateSession при join/leave для мгновенной видимости новых участников Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/controllers/classroomController.js | 33 +++++++++++-------- backend/src/ws-server.js | 8 +++++ frontend/classroom.html | 5 ++- 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/backend/src/controllers/classroomController.js b/backend/src/controllers/classroomController.js index a757817..38cea34 100644 --- a/backend/src/controllers/classroomController.js +++ b/backend/src/controllers/classroomController.js @@ -3,7 +3,7 @@ const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const { emit, emitToClass, getOnlineUserIds, emitToGuests } = require('../sse'); -const { emitToUser } = require('../ws-server'); +const { emitToUser, invalidateSession } = require('../ws-server'); /* ── chat attachment uploads dir ─────────────────────────────────────── */ const CHAT_UPLOADS_DIR = path.join(__dirname, '../../uploads/chat'); @@ -136,7 +136,7 @@ function endSession(req, res) { db.prepare(`UPDATE classroom_sessions SET status='ended', ended_at=datetime('now') WHERE id=?`) .run(sessionId); - _raisedHands.delete(sessionId); + db.prepare('DELETE FROM classroom_hands WHERE session_id=?').run(sessionId); db.prepare('DELETE FROM classroom_draw_permissions WHERE session_id=?').run(sessionId); emitToSession(sessionId, { type: 'classroom_ended', sessionId }); res.json({ ok: true }); @@ -189,6 +189,8 @@ function joinSession(req, res) { ON CONFLICT(session_id, user_id) DO UPDATE SET joined_at=datetime('now'), left_at=NULL `).run(sessionId, req.user.id); + invalidateSession(sessionId); + emitToSession(sessionId, { type: 'classroom_user_joined', sessionId, @@ -199,7 +201,7 @@ function joinSession(req, res) { // If this user already has draw permission (e.g. they rejoined after a page refresh), notify them const drawAllowed = canDraw(sessionId, req.user.id, session) && session.teacher_id !== req.user.id; if (drawAllowed) { - emit(req.user.id, { type: 'classroom_draw_permitted', sessionId }); + emitToUser(req.user.id, { type: 'classroom_draw_permitted', sessionId }); } res.json({ ok: true, canDraw: drawAllowed }); @@ -212,6 +214,8 @@ function leaveSession(req, res) { WHERE session_id=? AND user_id=? AND left_at IS NULL`) .run(sessionId, req.user.id); + invalidateSession(sessionId); + const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId); if (session) { emitToSession(sessionId, { @@ -360,6 +364,10 @@ function signal(req, res) { if (!hasAccess(session, req.user.id, req.user.role)) return res.status(403).json({ error: 'Нет доступа' }); + // Verify target is also a participant of this session + if (!hasAccess(session, target_user_id, 'student')) + return res.status(403).json({ error: 'Цель не является участником сессии' }); + emitToUser(target_user_id, { type: 'classroom_signal', sessionId, @@ -434,9 +442,6 @@ function getOnlineStudents(req, res) { res.json({ students }); } -/* ── In-memory raised hands: sessionId -> Set ─────────────────── */ -const _raisedHands = new Map(); - /* POST /api/classroom/:id/pages — add a page */ function addPage(req, res) { const sessionId = Number(req.params.id); @@ -495,7 +500,7 @@ function updatePageTemplate(req, res) { /* PATCH /api/classroom/:id/board-theme — change board theme and broadcast to all */ function updateBoardTheme(req, res) { const sessionId = Number(req.params.id); - const VALID_THEMES = new Set(['chalkboard', 'blackboard', 'whiteboard', 'dark', 'grid', 'dots']); + const VALID_THEMES = new Set(['chalkboard', 'blackboard', 'corkboard', 'whiteboard']); const { theme } = req.body; if (!theme || !VALID_THEMES.has(theme)) return res.status(400).json({ error: 'invalid theme' }); const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId); @@ -515,8 +520,7 @@ function raiseHand(req, res) { if (!hasAccess(session, req.user.id, req.user.role)) return res.status(403).json({ error: 'Нет доступа' }); - if (!_raisedHands.has(sessionId)) _raisedHands.set(sessionId, new Map()); - _raisedHands.get(sessionId).set(req.user.id, req.user.name); + db.prepare('INSERT OR IGNORE INTO classroom_hands (session_id, user_id) VALUES (?,?)').run(sessionId, req.user.id); emitToSession(sessionId, { type: 'classroom_hand_raised', @@ -531,8 +535,7 @@ function raiseHand(req, res) { function lowerHand(req, res) { const sessionId = Number(req.params.id); const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=?`).get(sessionId); - const map = _raisedHands.get(sessionId); - if (map) map.delete(req.user.id); + db.prepare('DELETE FROM classroom_hands WHERE session_id=? AND user_id=?').run(sessionId, req.user.id); if (session) { emitToSession(sessionId, { type: 'classroom_hand_lowered', sessionId, userId: req.user.id }); @@ -543,8 +546,12 @@ function lowerHand(req, res) { /* GET /api/classroom/:id/hands — get current raised hands */ function getHands(req, res) { const sessionId = Number(req.params.id); - const map = _raisedHands.get(sessionId); - const hands = map ? [...map.entries()].map(([userId, userName]) => ({ userId, userName })) : []; + const hands = db.prepare(` + SELECT h.user_id AS userId, u.name AS userName + FROM classroom_hands h + JOIN users u ON u.id = h.user_id + WHERE h.session_id=? + `).all(sessionId); res.json({ hands }); } diff --git a/backend/src/ws-server.js b/backend/src/ws-server.js index d88cccf..c3d75b2 100644 --- a/backend/src/ws-server.js +++ b/backend/src/ws-server.js @@ -289,7 +289,15 @@ function attach(httpServer) { ws.on('pong', () => { ws.isAlive = true; }); + ws._msgCount = 0; + ws._msgWindowStart = Date.now(); + 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; } try { _handleMessage(ws, msg); } catch (e) { diff --git a/frontend/classroom.html b/frontend/classroom.html index 324819d..b927f39 100644 --- a/frontend/classroom.html +++ b/frontend/classroom.html @@ -3858,13 +3858,16 @@ list.innerHTML = students.map(u => { const initials = (u.name || '?').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('') || '?'; const sel = _selectedUsers.find(s => s.id === u.id) ? 'selected' : ''; - return `
+ return `
${initials}
${LS.esc(u.name || u.email)}
`; }).join(''); + list.querySelectorAll('.cr-online-item').forEach(el => { + el.addEventListener('click', () => crToggleOnlineUser(Number(el.dataset.uid), el.dataset.uname)); + }); if (window.lucide) lucide.createIcons(); } catch { list.innerHTML = '
Ошибка загрузки
'; } }