fix: ревью онлайн-урока — 8 исправлений багов, уязвимостей и улучшений

- 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) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-16 09:02:50 +03:00
parent 847e9b9b4f
commit f1e6ed7f2d
3 changed files with 32 additions and 14 deletions
+20 -13
View File
@@ -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<userId> ─────────────────── */
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 });
}
+8
View File
@@ -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) {
+4 -1
View File
@@ -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 `<div class="cr-online-item ${sel}" id="cr-oi-${u.id}" onclick="crToggleOnlineUser(${u.id}, '${(u.name||'').replace(/'/g,"\\'")}')">
return `<div class="cr-online-item ${sel}" id="cr-oi-${u.id}" data-uid="${u.id}" data-uname="${LS.esc(u.name || '')}">
<span class="cr-online-dot"></span>
<div class="cr-online-avatar">${initials}</div>
<span class="cr-online-name">${LS.esc(u.name || u.email)}</span>
<span class="cr-online-check"></span>
</div>`;
}).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 = '<div class="cr-online-empty">Ошибка загрузки</div>'; }
}