fix: глубокое ревью онлайн-урока — 14 исправлений (P0-P3)

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) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-16 09:22:39 +03:00
parent f1e6ed7f2d
commit 6cd0cf34d4
4 changed files with 92 additions and 36 deletions
+56 -33
View File
@@ -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);
+9
View File
@@ -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(`
+7 -1
View File
@@ -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();
+20 -2
View File
@@ -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 ── */