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:
@@ -3,7 +3,7 @@ const path = require('path');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { emit, emitToClass, getOnlineUserIds, emitToGuests } = require('../sse');
|
const { emit, emitToClass, getOnlineUserIds, emitToGuests } = require('../sse');
|
||||||
const { emitToUser } = require('../ws-server');
|
const { emitToUser, invalidateSession } = require('../ws-server');
|
||||||
|
|
||||||
/* ── chat attachment uploads dir ─────────────────────────────────────── */
|
/* ── chat attachment uploads dir ─────────────────────────────────────── */
|
||||||
const CHAT_UPLOADS_DIR = path.join(__dirname, '../../uploads/chat');
|
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=?`)
|
db.prepare(`UPDATE classroom_sessions SET status='ended', ended_at=datetime('now') WHERE id=?`)
|
||||||
.run(sessionId);
|
.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);
|
db.prepare('DELETE FROM classroom_draw_permissions WHERE session_id=?').run(sessionId);
|
||||||
emitToSession(sessionId, { type: 'classroom_ended', sessionId });
|
emitToSession(sessionId, { type: 'classroom_ended', sessionId });
|
||||||
res.json({ ok: true });
|
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
|
ON CONFLICT(session_id, user_id) DO UPDATE SET joined_at=datetime('now'), left_at=NULL
|
||||||
`).run(sessionId, req.user.id);
|
`).run(sessionId, req.user.id);
|
||||||
|
|
||||||
|
invalidateSession(sessionId);
|
||||||
|
|
||||||
emitToSession(sessionId, {
|
emitToSession(sessionId, {
|
||||||
type: 'classroom_user_joined',
|
type: 'classroom_user_joined',
|
||||||
sessionId,
|
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
|
// 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;
|
const drawAllowed = canDraw(sessionId, req.user.id, session) && session.teacher_id !== req.user.id;
|
||||||
if (drawAllowed) {
|
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 });
|
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`)
|
WHERE session_id=? AND user_id=? AND left_at IS NULL`)
|
||||||
.run(sessionId, req.user.id);
|
.run(sessionId, req.user.id);
|
||||||
|
|
||||||
|
invalidateSession(sessionId);
|
||||||
|
|
||||||
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
|
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
|
||||||
if (session) {
|
if (session) {
|
||||||
emitToSession(sessionId, {
|
emitToSession(sessionId, {
|
||||||
@@ -360,6 +364,10 @@ function signal(req, res) {
|
|||||||
if (!hasAccess(session, req.user.id, req.user.role))
|
if (!hasAccess(session, req.user.id, req.user.role))
|
||||||
return res.status(403).json({ error: 'Нет доступа' });
|
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, {
|
emitToUser(target_user_id, {
|
||||||
type: 'classroom_signal',
|
type: 'classroom_signal',
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -434,9 +442,6 @@ function getOnlineStudents(req, res) {
|
|||||||
res.json({ students });
|
res.json({ students });
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── In-memory raised hands: sessionId -> Set<userId> ─────────────────── */
|
|
||||||
const _raisedHands = new Map();
|
|
||||||
|
|
||||||
/* POST /api/classroom/:id/pages — add a page */
|
/* POST /api/classroom/:id/pages — add a page */
|
||||||
function addPage(req, res) {
|
function addPage(req, res) {
|
||||||
const sessionId = Number(req.params.id);
|
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 */
|
/* PATCH /api/classroom/:id/board-theme — change board theme and broadcast to all */
|
||||||
function updateBoardTheme(req, res) {
|
function updateBoardTheme(req, res) {
|
||||||
const sessionId = Number(req.params.id);
|
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;
|
const { theme } = req.body;
|
||||||
if (!theme || !VALID_THEMES.has(theme)) return res.status(400).json({ error: 'invalid theme' });
|
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);
|
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))
|
if (!hasAccess(session, req.user.id, req.user.role))
|
||||||
return res.status(403).json({ error: 'Нет доступа' });
|
return res.status(403).json({ error: 'Нет доступа' });
|
||||||
|
|
||||||
if (!_raisedHands.has(sessionId)) _raisedHands.set(sessionId, new Map());
|
db.prepare('INSERT OR IGNORE INTO classroom_hands (session_id, user_id) VALUES (?,?)').run(sessionId, req.user.id);
|
||||||
_raisedHands.get(sessionId).set(req.user.id, req.user.name);
|
|
||||||
|
|
||||||
emitToSession(sessionId, {
|
emitToSession(sessionId, {
|
||||||
type: 'classroom_hand_raised',
|
type: 'classroom_hand_raised',
|
||||||
@@ -531,8 +535,7 @@ function raiseHand(req, res) {
|
|||||||
function lowerHand(req, res) {
|
function lowerHand(req, res) {
|
||||||
const sessionId = Number(req.params.id);
|
const sessionId = Number(req.params.id);
|
||||||
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=?`).get(sessionId);
|
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=?`).get(sessionId);
|
||||||
const map = _raisedHands.get(sessionId);
|
db.prepare('DELETE FROM classroom_hands WHERE session_id=? AND user_id=?').run(sessionId, req.user.id);
|
||||||
if (map) map.delete(req.user.id);
|
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
emitToSession(sessionId, { type: 'classroom_hand_lowered', sessionId, userId: req.user.id });
|
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 */
|
/* GET /api/classroom/:id/hands — get current raised hands */
|
||||||
function getHands(req, res) {
|
function getHands(req, res) {
|
||||||
const sessionId = Number(req.params.id);
|
const sessionId = Number(req.params.id);
|
||||||
const map = _raisedHands.get(sessionId);
|
const hands = db.prepare(`
|
||||||
const hands = map ? [...map.entries()].map(([userId, userName]) => ({ userId, userName })) : [];
|
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 });
|
res.json({ hands });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -289,7 +289,15 @@ function attach(httpServer) {
|
|||||||
|
|
||||||
ws.on('pong', () => { ws.isAlive = true; });
|
ws.on('pong', () => { ws.isAlive = true; });
|
||||||
|
|
||||||
|
ws._msgCount = 0;
|
||||||
|
ws._msgWindowStart = Date.now();
|
||||||
|
|
||||||
ws.on('message', raw => {
|
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;
|
let msg;
|
||||||
try { msg = JSON.parse(raw); } catch { return; }
|
try { msg = JSON.parse(raw); } catch { return; }
|
||||||
try { _handleMessage(ws, msg); } catch (e) {
|
try { _handleMessage(ws, msg); } catch (e) {
|
||||||
|
|||||||
@@ -3858,13 +3858,16 @@
|
|||||||
list.innerHTML = students.map(u => {
|
list.innerHTML = students.map(u => {
|
||||||
const initials = (u.name || '?').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('') || '?';
|
const initials = (u.name || '?').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('') || '?';
|
||||||
const sel = _selectedUsers.find(s => s.id === u.id) ? 'selected' : '';
|
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>
|
<span class="cr-online-dot"></span>
|
||||||
<div class="cr-online-avatar">${initials}</div>
|
<div class="cr-online-avatar">${initials}</div>
|
||||||
<span class="cr-online-name">${LS.esc(u.name || u.email)}</span>
|
<span class="cr-online-name">${LS.esc(u.name || u.email)}</span>
|
||||||
<span class="cr-online-check"></span>
|
<span class="cr-online-check"></span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
list.querySelectorAll('.cr-online-item').forEach(el => {
|
||||||
|
el.addEventListener('click', () => crToggleOnlineUser(Number(el.dataset.uid), el.dataset.uname));
|
||||||
|
});
|
||||||
if (window.lucide) lucide.createIcons();
|
if (window.lucide) lucide.createIcons();
|
||||||
} catch { list.innerHTML = '<div class="cr-online-empty">Ошибка загрузки</div>'; }
|
} catch { list.innerHTML = '<div class="cr-online-empty">Ошибка загрузки</div>'; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user