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