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);