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
+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 ── */