From 6cd0cf34d4eccf50d527e6af7fb00d02b55e35fd Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Thu, 16 Apr 2026 09:22:39 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=B3=D0=BB=D1=83=D0=B1=D0=BE=D0=BA?= =?UTF-8?q?=D0=BE=D0=B5=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20=D0=BE=D0=BD?= =?UTF-8?q?=D0=BB=D0=B0=D0=B9=D0=BD-=D1=83=D1=80=D0=BE=D0=BA=D0=B0=20?= =?UTF-8?q?=E2=80=94=2014=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B9=20(P0-P3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 — краши: - CREATE TABLE classroom_hands в migrate.js (отсутствовала) - emit→emitToUser для allowDraw/revokeDraw/mutePeer (WS доставка) - deleteHistorySession обёрнут в db.transaction() + добавлена очистка hands/invites P1 — гонки и безопасность: - deletePage: 4 SQL в транзакции (race при параллельной записи) - postStrokes: MAX(seq) внутрь транзакции (дубли seq) - duplicatePage: добавлен seq в INSERT (NOT NULL crash) - hasAccess для lowerHand/getHands/reactToMessage (утечка данных) - loadTemplate: проверка owner шаблона - attachment_url: только /uploads/* (XSS через javascript:/data: URI) - wbFlushBatch: backoff при ошибке (было 12.5 req/s retry) - pagehide leave: keepalive fetch для гарантированной доставки - _wbOwnIds: cap 2000 (утечка памяти на длинных уроках) P2-P3: - simState: лимит 64KB (предотвращает OOM broadcast) - ws-server кеши: cleanup drawCache при invalidateSession Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/controllers/classroomController.js | 89 ++++++++++++------- backend/src/db/migrate.js | 9 ++ backend/src/ws-server.js | 8 +- frontend/classroom.html | 22 ++++- 4 files changed, 92 insertions(+), 36 deletions(-) diff --git a/backend/src/controllers/classroomController.js b/backend/src/controllers/classroomController.js index 38cea34..9bf12b2 100644 --- a/backend/src/controllers/classroomController.js +++ b/backend/src/controllers/classroomController.js @@ -2,7 +2,7 @@ const db = require('../db/db'); const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); -const { emit, emitToClass, getOnlineUserIds, emitToGuests } = require('../sse'); +const { getOnlineUserIds } = require('../sse'); const { emitToUser, invalidateSession } = require('../ws-server'); /* ── chat attachment uploads dir ─────────────────────────────────────── */ @@ -233,7 +233,10 @@ function sendChat(req, res) { const sessionId = Number(req.params.id); const { message = '', attachment_url, attachment_type } = req.body; const text = message.trim().slice(0, 2000); - if (!text && !attachment_url) return res.status(400).json({ error: 'Пустое сообщение' }); + // Validate attachment_url: only allow local upload paths (prevent XSS via javascript:/data: URIs) + const safeUrl = attachment_url && typeof attachment_url === 'string' && attachment_url.startsWith('/uploads/') + ? attachment_url : null; + if (!text && !safeUrl) 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: 'Сессия не активна' }); @@ -243,7 +246,7 @@ function sendChat(req, res) { const { lastInsertRowid } = db.prepare( 'INSERT INTO classroom_chat (session_id, user_id, message, attachment_url, attachment_type) VALUES (?,?,?,?,?)' - ).run(sessionId, req.user.id, text, attachment_url || null, attachment_type || null); + ).run(sessionId, req.user.id, text, safeUrl, safeUrl ? (attachment_type || null) : null); const row = db.prepare('SELECT * FROM classroom_chat WHERE id=?').get(lastInsertRowid); @@ -535,17 +538,23 @@ 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); - db.prepare('DELETE FROM classroom_hands WHERE session_id=? AND user_id=?').run(sessionId, req.user.id); + if (!session) return res.status(404).json({ error: 'Не найдено' }); + if (!hasAccess(session, req.user.id, req.user.role)) + return res.status(403).json({ error: 'Нет доступа' }); - if (session) { - emitToSession(sessionId, { type: 'classroom_hand_lowered', sessionId, userId: req.user.id }); - } + db.prepare('DELETE FROM classroom_hands WHERE session_id=? AND user_id=?').run(sessionId, req.user.id); + emitToSession(sessionId, { type: 'classroom_hand_lowered', sessionId, userId: req.user.id }); res.json({ ok: true }); } /* GET /api/classroom/:id/hands — get current raised hands */ function getHands(req, res) { const sessionId = Number(req.params.id); + const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId); + if (!session) return res.status(404).json({ error: 'Не найдено' }); + if (!hasAccess(session, req.user.id, req.user.role)) + return res.status(403).json({ error: 'Нет доступа' }); + const hands = db.prepare(` SELECT h.user_id AS userId, u.name AS userName FROM classroom_hands h @@ -567,18 +576,16 @@ function postStrokes(req, res) { if (!canDraw(sessionId, req.user.id, session) && req.user.role !== 'admin') return res.status(403).json({ error: 'Нет доступа' }); - // Get current max seq for this session+page - const maxSeq = db.prepare( - 'SELECT COALESCE(MAX(seq), 0) AS m FROM classroom_strokes WHERE session_id=? AND page_num=?' - ).get(sessionId, page_num).m; - const insert = db.prepare( 'INSERT INTO classroom_strokes (session_id, page_num, user_id, tool, data, seq) VALUES (?,?,?,?,?,?)' ); + const getMaxSeq = db.prepare( + 'SELECT COALESCE(MAX(seq), 0) AS m FROM classroom_strokes WHERE session_id=? AND page_num=?' + ); const saved = []; - let seq = maxSeq; const insertMany = db.transaction(() => { + let seq = getMaxSeq.get(sessionId, page_num).m; for (const s of strokes) { seq++; const { lastInsertRowid } = insert.run(sessionId, page_num, req.user.id, s.tool || 'pencil', JSON.stringify(s.data), seq); @@ -748,8 +755,8 @@ 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) VALUES (?,?,?,?)'); - db.transaction(() => { strokes.forEach(s => ins.run(sessionId, newPage, s.tool, s.data)); })(); + 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)); })(); 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 }); @@ -771,10 +778,12 @@ function deletePage(req, res) { const total = Math.max(session.current_page, maxS, maxP, 1); if (total <= 1) return res.status(400).json({ error: 'Нельзя удалить единственную страницу' }); - db.prepare('DELETE FROM classroom_strokes WHERE session_id=? AND page_num=?').run(sessionId, pageNum); - db.prepare('DELETE FROM classroom_pages WHERE session_id=? AND page_num=?').run(sessionId, pageNum); - db.prepare('UPDATE classroom_strokes SET page_num=page_num-1 WHERE session_id=? AND page_num>?').run(sessionId, pageNum); - db.prepare('UPDATE classroom_pages SET page_num=page_num-1 WHERE session_id=? AND page_num>?').run(sessionId, pageNum); + db.transaction(() => { + db.prepare('DELETE FROM classroom_strokes WHERE session_id=? AND page_num=?').run(sessionId, pageNum); + db.prepare('DELETE FROM classroom_pages WHERE session_id=? AND page_num=?').run(sessionId, pageNum); + db.prepare('UPDATE classroom_strokes SET page_num=page_num-1 WHERE session_id=? AND page_num>?').run(sessionId, pageNum); + db.prepare('UPDATE classroom_pages SET page_num=page_num-1 WHERE session_id=? AND page_num>?').run(sessionId, pageNum); + })(); let newCurrent = session.current_page; if (newCurrent > pageNum) newCurrent--; @@ -795,7 +804,7 @@ function mutePeer(req, res) { if (session.teacher_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Нет доступа' }); - emit(user_id, { type: 'classroom_muted', sessionId, by: req.user.id }); + emitToUser(user_id, { type: 'classroom_muted', sessionId, by: req.user.id }); res.json({ ok: true }); } @@ -862,7 +871,7 @@ function allowDraw(req, res) { 'INSERT OR IGNORE INTO classroom_draw_permissions (session_id, user_id) VALUES (?,?)' ).run(sessionId, targetId); - emit(targetId, { type: 'classroom_draw_permitted', sessionId }); + emitToUser(targetId, { type: 'classroom_draw_permitted', sessionId }); res.json({ ok: true }); } @@ -879,7 +888,7 @@ function revokeDraw(req, res) { 'DELETE FROM classroom_draw_permissions WHERE session_id=? AND user_id=?' ).run(sessionId, targetId); - emit(targetId, { type: 'classroom_draw_revoked', sessionId }); + emitToUser(targetId, { type: 'classroom_draw_revoked', sessionId }); res.json({ ok: true }); } @@ -953,6 +962,9 @@ function simState(req, res) { const { state } = req.body; if (!state || typeof state !== 'object') return res.status(400).json({ error: 'Нет state' }); + // Limit state size to prevent OOM on broadcast + const stateStr = JSON.stringify(state); + if (stateStr.length > 64_000) return res.status(413).json({ error: 'State слишком большой' }); emitToSession(sessionId, { type: 'classroom_sim_state', sessionId, state }); res.json({ ok: true }); @@ -1017,6 +1029,10 @@ function reactToMessage(req, res) { const msg = db.prepare('SELECT * FROM classroom_chat WHERE id=?').get(chatId); if (!msg) return res.status(404).json({ error: 'Сообщение не найдено' }); + const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(msg.session_id); + if (!session || !hasAccess(session, req.user.id, req.user.role)) + return res.status(403).json({ error: 'Нет доступа' }); + const existing = db.prepare( 'SELECT id FROM classroom_chat_reactions WHERE chat_id=? AND user_id=? AND reaction=?' ).get(chatId, req.user.id, reaction); @@ -1279,14 +1295,19 @@ function deleteHistorySession(req, res) { if (session.teacher_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Нет доступа' }); - db.prepare('DELETE FROM classroom_chat_reactions WHERE chat_id IN (SELECT id FROM classroom_chat WHERE session_id=?)').run(sessionId); - db.prepare('DELETE FROM classroom_chat WHERE session_id=?').run(sessionId); - db.prepare('DELETE FROM classroom_strokes WHERE session_id=?').run(sessionId); - db.prepare('DELETE FROM classroom_pages WHERE session_id=?').run(sessionId); - 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_sessions WHERE id=?').run(sessionId); + const deleteAll = db.transaction(() => { + db.prepare('DELETE FROM classroom_chat_reactions WHERE chat_id IN (SELECT id FROM classroom_chat WHERE session_id=?)').run(sessionId); + db.prepare('DELETE FROM classroom_chat WHERE session_id=?').run(sessionId); + db.prepare('DELETE FROM classroom_strokes WHERE session_id=?').run(sessionId); + db.prepare('DELETE FROM classroom_pages WHERE session_id=?').run(sessionId); + 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_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); + }); + deleteAll(); res.json({ ok: true }); } @@ -1459,10 +1480,12 @@ function loadTemplate(req, res) { if (session.teacher_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Нет доступа' }); - const tmpl = db.prepare('SELECT * FROM classroom_templates WHERE id=?').get(template_id); - if (!tmpl) return res.status(404).json({ error: 'Шаблон не найден' }); + const tmpl = db.prepare('SELECT * FROM classroom_templates WHERE id=? AND teacher_id=?').get(template_id, req.user.id); + if (!tmpl && req.user.role !== 'admin') return res.status(404).json({ error: 'Шаблон не найден' }); + const tmplFallback = tmpl || db.prepare('SELECT * FROM classroom_templates WHERE id=?').get(template_id); + if (!tmplFallback) return res.status(404).json({ error: 'Шаблон не найден' }); - const pagesData = JSON.parse(tmpl.pages_data || '[]'); + const pagesData = JSON.parse(tmplFallback.pages_data || '[]'); // Clear current session data db.prepare('DELETE FROM classroom_strokes WHERE session_id=?').run(sessionId); diff --git a/backend/src/db/migrate.js b/backend/src/db/migrate.js index 0f53ff3..01dce61 100644 --- a/backend/src/db/migrate.js +++ b/backend/src/db/migrate.js @@ -2870,6 +2870,15 @@ db.exec(` ) `); +// Raised hands (persisted — survives server restart) +db.exec(` + CREATE TABLE IF NOT EXISTS classroom_hands ( + session_id INTEGER NOT NULL REFERENCES classroom_sessions(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + PRIMARY KEY (session_id, user_id) + ) +`); + // ── Geometry (Planimetry) ──────────────────────────────────────────────────── // Saved geometry constructions (teacher-created tasks/templates) db.exec(` diff --git a/backend/src/ws-server.js b/backend/src/ws-server.js index c3d75b2..2d6b46e 100644 --- a/backend/src/ws-server.js +++ b/backend/src/ws-server.js @@ -88,7 +88,13 @@ function _getMembers(sessionId) { return entry; } -function _invalidateSession(sessionId) { _cache.delete(sessionId); } +function _invalidateSession(sessionId) { + _cache.delete(sessionId); + // Cleanup draw cache entries for this session + for (const key of _drawCache.keys()) { + if (key.startsWith(sessionId + ':')) _drawCache.delete(key); + } +} /* ── Draw permission cache (10s TTL) ─────────────────────────────────── */ const _drawCache = new Map(); diff --git a/frontend/classroom.html b/frontend/classroom.html index b927f39..b5d2ca8 100644 --- a/frontend/classroom.html +++ b/frontend/classroom.html @@ -3180,6 +3180,7 @@ let _wbInitializing = false; // true while loading initial strokes from server let _wbPendingSSE = []; // SSE strokes buffered during initialization const _wbOwnIds = new Set(); // server-assigned IDs of our own sent strokes (to skip in SSE) + const _WB_OWN_IDS_MAX = 2000; // cap to prevent unbounded growth on long lessons /* ── WebSocket (low-latency cursor + preview) ── */ let _crWs = null; // WebSocket instance @@ -4388,25 +4389,33 @@ } } + let _wbFlushFails = 0; async function wbFlushBatch() { if (!_sessionId || _wbBatch.length === 0) return; + // Backoff: skip ticks after consecutive failures (max ~5s pause) + if (_wbFlushFails > 0) { + const skipTicks = Math.min(60, Math.pow(2, _wbFlushFails)); // 2,4,8,16...60 + if (Math.random() > 1 / skipTicks) return; + } const toSend = _wbBatch.splice(0, _wbBatch.length); // drain queue try { const res = await LS.post(`/api/classroom/${_sessionId}/strokes`, { page_num: _wbCurrentPage, strokes: toSend.map(s => ({ tool: s.tool, data: s.data })), }); + _wbFlushFails = 0; // update local (negative) ids to server-assigned ids; track own ids to skip in SSE if (res.strokes && _wb) { res.strokes.forEach((saved, i) => { if (toSend[i]) { _wbOwnIds.add(saved.id); // register BEFORE confirmStroke calls render + if (_wbOwnIds.size > _WB_OWN_IDS_MAX) { const it = _wbOwnIds.values().next().value; _wbOwnIds.delete(it); } _wb.confirmStroke(toSend[i].id, saved.id); } }); } } catch { - // put back on failure + _wbFlushFails++; _wbBatch.unshift(...toSend); } } @@ -7485,7 +7494,16 @@ stopPolling(); if (_timerHandle) { clearInterval(_timerHandle); _timerHandle = null; } if (_rtc) { _rtc.destroy(); _rtc = null; } - if (_sessionId) LS.post(`/api/classroom/${_sessionId}/leave`).catch(() => {}); + if (_sessionId) { + const url = `/api/classroom/${_sessionId}/leave`; + const token = localStorage.getItem('ls_token'); + // keepalive: true ensures the request completes even if the page is unloading + fetch(url, { + method: 'POST', keepalive: true, + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, + body: '{}', + }).catch(() => {}); + } }); /* ── run ── */