diff --git a/backend/src/controllers/classroom/admin.js b/backend/src/controllers/classroom/admin.js index 1c529af..831184d 100644 --- a/backend/src/controllers/classroom/admin.js +++ b/backend/src/controllers/classroom/admin.js @@ -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); diff --git a/backend/src/controllers/classroom/chat.js b/backend/src/controllers/classroom/chat.js index 75267d1..359d37b 100644 --- a/backend/src/controllers/classroom/chat.js +++ b/backend/src/controllers/classroom/chat.js @@ -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'; }); diff --git a/backend/src/controllers/classroom/pages.js b/backend/src/controllers/classroom/pages.js index a38ff15..6777b26 100644 --- a/backend/src/controllers/classroom/pages.js +++ b/backend/src/controllers/classroom/pages.js @@ -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 }); diff --git a/backend/src/controllers/classroom/permissions.js b/backend/src/controllers/classroom/permissions.js index 5f61617..e604b1e 100644 --- a/backend/src/controllers/classroom/permissions.js +++ b/backend/src/controllers/classroom/permissions.js @@ -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, }; diff --git a/backend/src/controllers/classroom/sessions.js b/backend/src/controllers/classroom/sessions.js index c6c183a..160a6d2 100644 --- a/backend/src/controllers/classroom/sessions.js +++ b/backend/src/controllers/classroom/sessions.js @@ -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) { diff --git a/backend/src/controllers/classroom/strokes.js b/backend/src/controllers/classroom/strokes.js index b4b3b08..4c3be11 100644 --- a/backend/src/controllers/classroom/strokes.js +++ b/backend/src/controllers/classroom/strokes.js @@ -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 }); } diff --git a/backend/src/db/migrations/001_classroom_muted.sql b/backend/src/db/migrations/001_classroom_muted.sql new file mode 100644 index 0000000..7e25373 --- /dev/null +++ b/backend/src/db/migrations/001_classroom_muted.sql @@ -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) +); diff --git a/backend/src/routes/classroom.js b/backend/src/routes/classroom.js index 060f383..373df60 100644 --- a/backend/src/routes/classroom.js +++ b/backend/src/routes/classroom.js @@ -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); diff --git a/backend/src/routes/guestClassroom.js b/backend/src/routes/guestClassroom.js index 45497ef..0a50b63 100644 --- a/backend/src/routes/guestClassroom.js +++ b/backend/src/routes/guestClassroom.js @@ -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'); diff --git a/backend/src/ws-server.js b/backend/src/ws-server.js index 9da1a37..30ce0fb 100644 --- a/backend/src/ws-server.js +++ b/backend/src/ws-server.js @@ -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; }