fix: classroom review — 11 исправлений из code review

- sessions.js: endSession закрывает classroom_attendance (left_at), чистит classroom_muted
- sessions.js: joinSession восстанавливает mute-состояние при реконнекте
- strokes.js: updateStroke проверяет авторство штриха (не только canDraw)
- strokes.js: clearPage валидирует page_num как положительное целое
- strokes.js: postStrokes ограничивает массив 500 штрихами
- pages.js: duplicatePage сохраняет user_id при копировании штрихов
- pages.js: changePage валидирует page_num
- pages.js: updatePageTemplate делает INSERT OR IGNORE перед UPDATE
- permissions.js: mutePeer сохраняет в classroom_muted; добавлен unmutePeer
- permissions.js: getOnlineStudents не возвращает email
- chat.js: exportChat экранирует переводы строк в именах и сообщениях
- guestClassroom.js: санитизация имени гостя (убираем HTML-символы)
- ws-server.js: mute_peer сохраняет в БД; добавлен обработчик unmute_peer
- routes/classroom.js: rate-limit для cursor/preview/signal/strokes; маршрут DELETE /mute
- migrations/001_classroom_muted.sql: новая таблица classroom_muted
This commit is contained in:
Maxim Dolgolyov
2026-05-07 14:26:19 +03:00
parent 90f6a1d91e
commit c0f20ef020
10 changed files with 85 additions and 27 deletions
@@ -154,6 +154,7 @@ function deleteHistorySession(req, res) {
db.prepare('DELETE FROM classroom_attendance WHERE session_id=?').run(sessionId);
db.prepare('DELETE FROM classroom_notes WHERE session_id=?').run(sessionId);
db.prepare('DELETE FROM classroom_draw_permissions WHERE session_id=?').run(sessionId);
db.prepare('DELETE FROM classroom_muted WHERE session_id=?').run(sessionId);
db.prepare('DELETE FROM classroom_hands WHERE session_id=?').run(sessionId);
db.prepare('DELETE FROM classroom_invites WHERE session_id=?').run(sessionId);
db.prepare('DELETE FROM classroom_sessions WHERE id=?').run(sessionId);
+3 -1
View File
@@ -141,7 +141,9 @@ function exportChat(req, res) {
let text = `Чат урока: ${title}\nДата: ${date}\n${'─'.repeat(50)}\n\n`;
messages.forEach(m => {
const ts = m.created_at ? new Date(m.created_at).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) : '';
text += `[${ts}] ${m.user_name}: ${m.message || ''}`;
const safeUser = (m.user_name || '').replace(/[\r\n]/g, ' ');
const safeMsg = (m.message || '').replace(/[\r\n]/g, ' ');
text += `[${ts}] ${safeUser}: ${safeMsg}`;
if (m.attachment_url) text += ` [вложение]`;
text += '\n';
});
+7 -5
View File
@@ -41,8 +41,9 @@ function addPage(req, res) {
function changePage(req, res) {
const sessionId = Number(req.params.id);
const { page_num } = req.body;
if (!page_num) return res.status(400).json({ error: 'page_num required' });
const page_num = Number(req.body?.page_num);
if (!Number.isInteger(page_num) || page_num < 1)
return res.status(400).json({ error: 'page_num required (positive integer)' });
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
@@ -64,6 +65,7 @@ function updatePageTemplate(req, res) {
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
db.prepare('INSERT OR IGNORE INTO classroom_pages (session_id, page_num, template) VALUES (?,?,?)').run(sessionId, session.current_page, template);
db.prepare('UPDATE classroom_pages SET template=? WHERE session_id=? AND page_num=?').run(template, sessionId, session.current_page);
emitToSession(sessionId, { type: 'classroom_template_changed', sessionId, pageNum: session.current_page, template });
res.json({ ok: true, template });
@@ -120,9 +122,9 @@ function duplicatePage(req, res) {
db.prepare('INSERT OR IGNORE INTO classroom_pages (session_id, page_num, template, name) VALUES (?,?,?,?)').run(sessionId, newPage, srcTpl, newName);
const strokes = db.prepare('SELECT tool, data FROM classroom_strokes WHERE session_id=? AND page_num=? ORDER BY seq').all(sessionId, srcPage);
const ins = db.prepare('INSERT INTO classroom_strokes (session_id, page_num, tool, data, seq) VALUES (?,?,?,?,?)');
db.transaction(() => { strokes.forEach((s, i) => ins.run(sessionId, newPage, s.tool, s.data, i + 1)); })();
const strokes = db.prepare('SELECT tool, data, user_id FROM classroom_strokes WHERE session_id=? AND page_num=? ORDER BY seq').all(sessionId, srcPage);
const ins = db.prepare('INSERT INTO classroom_strokes (session_id, page_num, tool, data, user_id, seq) VALUES (?,?,?,?,?,?)');
db.transaction(() => { strokes.forEach((s, i) => ins.run(sessionId, newPage, s.tool, s.data, s.user_id, i + 1)); })();
db.prepare('UPDATE classroom_sessions SET current_page=? WHERE id=?').run(newPage, sessionId);
emitToSession(sessionId, { type: 'classroom_page_duplicated', sessionId, srcPage, newPage, template: srcTpl, name: newName });
@@ -37,7 +37,7 @@ function getOnlineStudents(req, res) {
if (!onlineIds.length) return res.json({ students: [] });
const placeholders = onlineIds.map(() => '?').join(',');
const students = db.prepare(
`SELECT id, name, email FROM users WHERE id IN (${placeholders}) AND role IN ('student','free_student') ORDER BY name`
`SELECT id, name FROM users WHERE id IN (${placeholders}) AND role IN ('student','free_student') ORDER BY name`
).all(...onlineIds);
res.json({ students });
}
@@ -115,10 +115,26 @@ function mutePeer(req, res) {
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
db.prepare('INSERT OR IGNORE INTO classroom_muted (session_id, user_id, muted_by) VALUES (?,?,?)').run(sessionId, user_id, req.user.id);
emitToUser(user_id, { type: 'classroom_muted', sessionId, by: req.user.id });
res.json({ ok: true });
}
function unmutePeer(req, res) {
const sessionId = Number(req.params.id);
const { user_id } = req.body;
if (!user_id) return res.status(400).json({ error: 'user_id required' });
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
db.prepare('DELETE FROM classroom_muted WHERE session_id=? AND user_id=?').run(sessionId, user_id);
emitToUser(user_id, { type: 'classroom_unmuted', sessionId, by: req.user.id });
res.json({ ok: true });
}
function screenStart(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
@@ -144,6 +160,6 @@ function screenStop(req, res) {
module.exports = {
getParticipants, getAttendance, getOnlineStudents,
raiseHand, lowerHand, getHands,
allowDraw, revokeDraw, mutePeer,
allowDraw, revokeDraw, mutePeer, unmutePeer,
screenStart, screenStop,
};
@@ -69,8 +69,10 @@ function endSession(req, res) {
return res.status(403).json({ error: 'Нет доступа' });
db.prepare(`UPDATE classroom_sessions SET status='ended', ended_at=datetime('now') WHERE id=?`).run(sessionId);
db.prepare(`UPDATE classroom_attendance SET left_at=datetime('now') WHERE session_id=? AND left_at IS NULL`).run(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_muted WHERE session_id=?').run(sessionId);
emitToSession(sessionId, { type: 'classroom_ended', sessionId });
res.json({ ok: true });
}
@@ -116,7 +118,11 @@ function joinSession(req, res) {
const drawAllowed = canDraw(sessionId, req.user.id, session) && session.teacher_id !== req.user.id;
if (drawAllowed) emitToUser(req.user.id, { type: 'classroom_draw_permitted', sessionId });
res.json({ ok: true, canDraw: drawAllowed });
const isMuted = !!db.prepare('SELECT 1 FROM classroom_muted WHERE session_id=? AND user_id=?').get(sessionId, req.user.id);
if (isMuted) emitToUser(req.user.id, { type: 'classroom_muted', sessionId, by: null });
res.json({ ok: true, canDraw: drawAllowed, muted: isMuted });
}
function leaveSession(req, res) {
+10 -5
View File
@@ -5,8 +5,8 @@ const { emitToSession, hasAccess, canDraw } = require('./_shared');
function postStrokes(req, res) {
const sessionId = Number(req.params.id);
const { strokes, page_num = 1 } = req.body;
if (!Array.isArray(strokes) || !strokes.length)
return res.status(400).json({ error: 'strokes array required' });
if (!Array.isArray(strokes) || !strokes.length || strokes.length > 500)
return res.status(400).json({ error: 'strokes array required (max 500)' });
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
@@ -60,8 +60,10 @@ function updateStroke(req, res) {
if (!canDraw(sessionId, req.user.id, session) && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
const existing = db.prepare('SELECT id, page_num FROM classroom_strokes WHERE id=? AND session_id=?').get(strokeId, sessionId);
const existing = db.prepare('SELECT id, page_num, user_id FROM classroom_strokes WHERE id=? AND session_id=?').get(strokeId, sessionId);
if (!existing) return res.status(404).json({ error: 'Штрих не найден' });
if (existing.user_id !== req.user.id && session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
db.prepare('UPDATE classroom_strokes SET data=? WHERE id=?').run(JSON.stringify(data), strokeId);
emitToSession(sessionId, { type: 'classroom_stroke_updated', sessionId, strokeId, pageNum: existing.page_num, data });
@@ -87,13 +89,16 @@ function deleteStroke(req, res) {
function clearPage(req, res) {
const sessionId = Number(req.params.id);
const { page_num = 1 } = req.body;
const pageNumInt = Number(page_num);
if (!Number.isInteger(pageNumInt) || pageNumInt < 1)
return res.status(400).json({ error: 'Некорректный номер страницы' });
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
db.prepare('DELETE FROM classroom_strokes WHERE session_id=? AND page_num=?').run(sessionId, page_num);
emitToSession(sessionId, { type: 'classroom_page_cleared', sessionId, pageNum: Number(page_num) });
db.prepare('DELETE FROM classroom_strokes WHERE session_id=? AND page_num=?').run(sessionId, pageNumInt);
emitToSession(sessionId, { type: 'classroom_page_cleared', sessionId, pageNum: pageNumInt });
res.json({ ok: true });
}
@@ -0,0 +1,7 @@
CREATE TABLE classroom_muted (
session_id INTEGER NOT NULL REFERENCES classroom_sessions(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
muted_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
muted_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (session_id, user_id)
);
+16 -11
View File
@@ -27,9 +27,13 @@ const chatUpload = multer({
const teacher = [authMiddleware, requireRole('teacher', 'admin')];
const auth = [authMiddleware];
const chatLimiter = rateLimit({ windowMs: 5_000, max: 5, message: 'Слишком много сообщений, подождите', byUser: true });
const reactionLimiter = rateLimit({ windowMs: 5_000, max: 15, message: 'Слишком много реакций, подождите', byUser: true });
const handLimiter = rateLimit({ windowMs: 5_000, max: 5, message: 'Не так часто', byUser: true });
const chatLimiter = rateLimit({ windowMs: 5_000, max: 5, message: 'Слишком много сообщений, подождите', byUser: true });
const reactionLimiter = rateLimit({ windowMs: 5_000, max: 15, message: 'Слишком много реакций, подождите', byUser: true });
const handLimiter = rateLimit({ windowMs: 5_000, max: 5, message: 'Не так часто', byUser: true });
const cursorLimiter = rateLimit({ windowMs: 2_000, max: 60, message: 'Слишком часто', byUser: true });
const previewLimiter = rateLimit({ windowMs: 2_000, max: 60, message: 'Слишком часто', byUser: true });
const signalLimiter = rateLimit({ windowMs: 10_000, max: 30, message: 'Слишком много сигналов', byUser: true });
const strokesLimiter = rateLimit({ windowMs: 5_000, max: 100, message: 'Слишком много штрихов', byUser: true });
// Template library — MUST be before /:id to avoid shadowing
router.get('/admin/active', ...teacher, c.adminGetActiveSessions);
@@ -64,14 +68,14 @@ router.post('/:id/chat/upload', ...auth, chatUpload.single('file'),
router.post('/:id/chat/:msgId/react', ...auth, reactionLimiter, c.reactToMessage);
// WebRTC signaling
router.post('/:id/signal', ...auth, c.signal);
router.post('/:id/signal', ...auth, signalLimiter, c.signal);
// Whiteboard strokes
router.post('/:id/strokes', ...auth, c.postStrokes);
router.get('/:id/strokes', ...auth, c.getStrokes);
router.delete('/:id/strokes/:strokeId', ...teacher, c.deleteStroke);
router.patch('/:id/strokes/:strokeId', ...auth, c.updateStroke);
router.post('/:id/stroke-preview', ...auth, c.previewStroke);
router.post('/:id/strokes', ...auth, strokesLimiter, c.postStrokes);
router.get('/:id/strokes', ...auth, c.getStrokes);
router.delete('/:id/strokes/:strokeId', ...teacher, c.deleteStroke);
router.patch('/:id/strokes/:strokeId', ...auth, c.updateStroke);
router.post('/:id/stroke-preview', ...auth, previewLimiter, c.previewStroke);
// Multi-page
router.get('/:id/pages', ...auth, c.getPages);
@@ -91,8 +95,9 @@ router.get('/:id/hands', ...auth, c.getHands);
// Whiteboard: clear page
router.post('/:id/clear-page', ...teacher, c.clearPage);
// WebRTC: mute peer, screen share broadcast
// WebRTC: mute/unmute peer, screen share broadcast
router.post('/:id/mute', ...teacher, c.mutePeer);
router.delete('/:id/mute', ...teacher, c.unmutePeer);
router.post('/:id/screen', ...teacher, c.screenStart);
router.delete('/:id/screen', ...teacher, c.screenStop);
@@ -104,7 +109,7 @@ router.post('/:id/sim/mode', ...teacher, c.simMode);
router.post('/:id/sim/annotate', ...teacher, c.simAnnotate);
// Cursor broadcast (all participants)
router.post('/:id/cursor', ...auth, c.broadcastCursor);
router.post('/:id/cursor', ...auth, cursorLimiter, c.broadcastCursor);
// Message pin (teacher only)
router.post('/:id/chat/:msgId/pin', ...teacher, c.pinMessage);
+1 -1
View File
@@ -64,7 +64,7 @@ router.post('/:token/join', (req, res) => {
if (session.status !== 'active')
return res.status(403).json({ error: 'Урок ещё не начался или уже завершён' });
const rawName = (req.body?.name || '').trim().slice(0, 40);
const rawName = (req.body?.name || '').trim().slice(0, 40).replace(/[<>"&]/g, '');
const name = rawName || 'Гость';
const guestId = 'g_' + crypto.randomBytes(12).toString('base64url');
+15 -1
View File
@@ -254,7 +254,21 @@ function _handleMessage(ws, msg) {
if (!isTeacher) return;
const targetId = Number(msg.targetUserId);
if (!targetId) return;
emitToUser(targetId, { type: 'classroom_muted', sessionId });
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;
}