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:
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user