Files
Maxim Dolgolyov 952a54f97c security+perf: полное ревью — 17 фиксов P0/P1 (XSS, IDOR, race conditions, rate limits, TURN, WAL)
## P0
- admin.html:2608, red-book-ecosystem.html:489-495 — XSS: u.name/node.name_ru/description обернуты в LS.esc()
- classController.js getAnnouncements — добавлена проверка teacher_id (B14: учитель A не может читать объявления класса B)

## P1 — auth & validation
- authController.js — минимум пароля 6→8 символов (register + change password + login.html)
- gamificationController adminAward — валидация max XP/coins (1M), Number coercion
- shopController adminAwardCoins — валидация max + проверка changes>0

## P1 — race conditions
- petController.buyBg — atomic UPDATE WHERE coins>=? (race-safe)
- shopController.purchaseItem — atomic conditional UPDATE
- liveController — добавлен question_id в live_answers (миграция с пересозданием таблицы), история ответов сохраняется при смене вопроса учителем
- ws-server: invalidateDrawCache экспортирован, classroomController grant/revoke вызывают его → permission revoke применяется мгновенно (раньше до 10s stale)

## P1 — rate limits & retry
- rateLimit middleware: новый параметр byUser=true (использует req.user.id вместо IP — не блокирует пользователей за NAT)
- routes/classroom.js: reactionLimiter (15/5s) на /chat/:msgId/react, handLimiter (5/5s) на raise/lower hand
- api.js sendAnswer — retry 3x с exp backoff (300/1200/2700ms), не повторяет на 4xx (F5)

## P1 — performance
- classroomController.getStrokes — LIMIT 5000 + флаг hasMore (защита от OOM на 10K+ strokes)
- whiteboard.js _liveStrokes — TTL 1.5s на каждый live preview (auto-cleanup при крашe ремоут юзера)

## Infrastructure
- config.js: TURN_URL/USER/PASS env vars
- server.js: GET /api/ice-servers возвращает STUN + опциональный TURN из env
- classroom-rtc.js: фетчит /api/ice-servers вместо хардкода (поддержка TURN для NAT/CGNAT школьных сетей)
- .env.example: документация TURN
- db.js: PRAGMA synchronous=NORMAL (5x быстрее с WAL), cache_size 16MB, temp_store=MEMORY
- ws-server.js closeAll() + server.js shutdown — graceful WS shutdown при SIGTERM

## False positives (не баги, агенты ошиблись)
- assignmentController FK на tests — на самом деле users (migrate.js:317-318)
- .env в git — gitignore корректно исключает
- admin.html без requireAuth — есть LS.initPage() который вызывает requireAuth
- submissionsController IDOR — обе ручки уже проверяют teacher_id
- screenSender = null inside try/catch — на самом деле снаружи
- SSE без backoff — есть exponential 2s→30s
- sessionController NOT IN на пустом массиве — есть guard usedIds.length>0
- getChat без LIMIT — есть LIMIT 100/200
- trust proxy — установлен на server.js:105

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 12:16:08 +03:00

437 lines
16 KiB
JavaScript

/**
* ClassroomRTC — WebRTC mesh manager for audio + screen sharing.
*
* Topology: full mesh (each peer ↔ each peer).
* Audio: Opus ~30 Kbit/s per connection.
* Screen: teacher → renegotiation adds video track to existing connections.
*
* Usage:
* const rtc = new ClassroomRTC({
* sessionId, userId,
* onSignal(targetId, payload) — POST /signal
* onScreenStream(stream|null) — remote screen arrived / stopped
* onMicActive(userId, active) — visual mic indicator
* });
* await rtc.startAudio();
* await rtc.connectTo([uid1, uid2]); // new joiner sends offers
* rtc.handleSignal(fromUid, payload); // called from SSE
* rtc.addPeerForScreenShare(uid); // if teacher, offer screen to new joiner
* rtc.removePeer(uid);
* rtc.destroy();
*/
class ClassroomRTC {
constructor({ sessionId, userId, onSignal, onScreenStream, onMicActive, onMicLevel, vadThreshold }) {
this._sid = sessionId;
this._uid = userId;
this._onSignal = onSignal; // fn(targetUid, payload)
this._onScreen = onScreenStream; // fn(stream | null)
this._onMicActive = onMicActive; // fn(uid, bool) — optional
this._onMicLevel = onMicLevel; // fn(uid, level 0-100) — optional, fires every tick
this._vadThreshold = vadThreshold ?? 12;
this._peers = new Map(); // uid → PeerState
this._localStream = null; // mic audio
this._screenStream = null; // outbound screen (teacher)
this._muted = false;
this._vadTimers = new Map(); // uid → {ctx, timer} for VAD
this._iceServers = null; // populated lazily before first peer
// Pre-fetch ICE servers in background so first peer creation has them.
this._fetchIceServers().then(s => { this._iceServers = s; }).catch(() => {});
}
/* ── Audio ──────────────────────────────────────────────────────────── */
async startAudio() {
try {
// Make sure ICE servers are loaded before we accept incoming connections
if (!this._iceServers) this._iceServers = await this._fetchIceServers();
this._localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
this._localStream.getAudioTracks().forEach(t => { t.enabled = !this._muted; });
this._startVAD(this._uid, this._localStream);
console.log('[RTC] startAudio OK, tracks:', this._localStream.getAudioTracks().map(t => `${t.label} enabled=${t.enabled}`));
return true;
} catch (e) {
console.warn('[RTC] startAudio FAILED', e);
return false;
}
}
/* ── Voice Activity Detection ────────────────────────────────────────── */
_startVAD(uid, stream) {
if (!this._onMicActive || !window.AudioContext) return;
this._stopVAD(uid);
try {
const ctx = new AudioContext();
const src = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.4;
src.connect(analyser);
const buf = new Uint8Array(analyser.frequencyBinCount);
let speaking = false;
const THRESHOLD = this._vadThreshold;
const timer = setInterval(() => {
analyser.getByteFrequencyData(buf);
const avg = buf.reduce((a, b) => a + b, 0) / buf.length;
const level = Math.min(100, Math.round((avg / 64) * 100));
const now = avg > THRESHOLD;
if (now !== speaking) {
speaking = now;
try { if (this._onMicActive) this._onMicActive(uid, speaking); } catch {}
}
try { if (this._onMicLevel) this._onMicLevel(uid, level); } catch {}
}, 120);
this._vadTimers.set(uid, { ctx, timer });
} catch {}
}
_stopVAD(uid) {
const vad = this._vadTimers.get(uid);
if (!vad) return;
clearInterval(vad.timer);
try { vad.ctx.close(); } catch {}
this._vadTimers.delete(uid);
}
_stopAllVAD() {
for (const uid of [...this._vadTimers.keys()]) this._stopVAD(uid);
}
stopAudio() {
this._stopVAD(this._uid);
if (!this._localStream) return;
this._localStream.getTracks().forEach(t => t.stop());
this._localStream = null;
}
toggleMute() {
this._muted = !this._muted;
if (this._localStream) {
this._localStream.getAudioTracks().forEach(t => { t.enabled = !this._muted; });
}
return this._muted;
}
isMuted() { return this._muted; }
forceMute() {
this._muted = true;
if (this._localStream) {
this._localStream.getAudioTracks().forEach(t => { t.enabled = false; });
}
}
/* ── Connections ─────────────────────────────────────────────────────── */
/** New joiner calls this to send offers to all existing participants. */
async connectTo(userIds) {
for (const uid of userIds) {
if (uid !== this._uid) await this._sendOffer(uid);
}
}
/** Called when a new peer joins (existing participants call this to handle
* teacher screen-share renegotiation if needed). */
async addPeerForScreenShare(uid) {
if (!this._screenStream) return;
// New person joined while we are sharing screen — renegotiate
const peer = this._getPeer(uid);
if (peer) {
const vt = this._screenStream.getVideoTracks()[0];
if (vt && !peer.screenSender) {
peer.screenSender = peer.pc.addTrack(vt, this._screenStream);
// onnegotiationneeded will fire and renegotiate automatically
}
}
}
removePeer(uid) {
const peer = this._peers.get(uid);
if (!peer) return;
this._stopVAD(uid);
if (peer.audioEl) { peer.audioEl.srcObject = null; peer.audioEl.remove(); }
try { peer.pc.close(); } catch {}
this._peers.delete(uid);
}
/**
* Called after SSE reconnect: re-connect peers whose ICE connection failed.
* Peers that are still connected are left alone.
* @param {number[]} currentUserIds — list of user ids currently in session (excluding self)
*/
async recoverPeers(currentUserIds) {
// Close and remove peers that left during the SSE gap
for (const uid of [...this._peers.keys()]) {
if (!currentUserIds.includes(uid)) this.removePeer(uid);
}
// Re-offer peers with failed/closed ICE
for (const uid of currentUserIds) {
if (uid === this._uid) continue;
const peer = this._peers.get(uid);
if (!peer) {
// Completely new peer (joined while SSE was down) — we act as initiator
await this._sendOffer(uid);
} else {
const state = peer.pc.iceConnectionState;
if (state === 'failed' || state === 'closed' || state === 'disconnected') {
this.removePeer(uid);
await this._sendOffer(uid);
}
// else: 'connected' or 'completed' → leave intact
}
}
}
/* ── Signaling ───────────────────────────────────────────────────────── */
async handleSignal(fromUid, payload) {
const { kind } = payload;
console.log(`[RTC] signal ← ${kind} from uid=${fromUid}`, 'myUid=', this._uid);
try {
if (kind === 'offer') await this._handleOffer(fromUid, payload);
else if (kind === 'answer') await this._handleAnswer(fromUid, payload);
else if (kind === 'candidate') await this._handleCandidate(fromUid, payload);
} catch (e) {
console.warn('[RTC] handleSignal error', kind, e);
}
}
async _handleOffer(fromUid, payload) {
const peer = this._getOrCreate(fromUid);
// Perfect Negotiation: resolve offer collision (both peers sending offers simultaneously).
// Polite peer (higher uid) yields — rolls back its own offer and handles the incoming one.
// Impolite peer (lower uid) ignores the collision offer and waits for the answer to its own.
const polite = this._uid > fromUid;
const collision = peer.pc.signalingState !== 'stable' || peer.negotiating;
if (collision) {
if (!polite) return;
// Polite: explicit rollback for older browsers; modern Chrome handles it implicitly
await peer.pc.setLocalDescription({ type: 'rollback' }).catch(() => {});
}
await peer.pc.setRemoteDescription({ type: 'offer', sdp: payload.sdp });
peer.remoteSet = true;
await this._flushCandidates(peer);
// Add own audio track if not already present
if (this._localStream) {
const existing = peer.pc.getSenders().map(s => s.track);
this._localStream.getAudioTracks().forEach(t => {
if (!existing.includes(t)) peer.pc.addTrack(t, this._localStream);
});
}
const answer = await peer.pc.createAnswer();
await peer.pc.setLocalDescription(answer);
this._onSignal(fromUid, { kind: 'answer', sdp: answer.sdp });
}
async _handleAnswer(fromUid, payload) {
const peer = this._peers.get(fromUid);
if (!peer) return;
await peer.pc.setRemoteDescription({ type: 'answer', sdp: payload.sdp });
peer.remoteSet = true;
await this._flushCandidates(peer);
}
async _handleCandidate(fromUid, payload) {
const peer = this._peers.get(fromUid);
if (!peer) return;
if (peer.remoteSet) {
try { await peer.pc.addIceCandidate(payload.candidate); } catch {}
} else {
peer.pendingCandidates.push(payload.candidate);
}
}
async _flushCandidates(peer) {
for (const c of peer.pendingCandidates) {
try { await peer.pc.addIceCandidate(c); } catch {}
}
peer.pendingCandidates = [];
}
async _sendOffer(targetUid) {
const peer = this._getOrCreate(targetUid);
console.log(`[RTC] _sendOffer → uid=${targetUid} localStream=${!!this._localStream}`);
if (this._localStream) {
const existing = peer.pc.getSenders().map(s => s.track);
this._localStream.getAudioTracks().forEach(t => {
if (!existing.includes(t)) peer.pc.addTrack(t, this._localStream);
});
}
if (this._screenStream) {
const vt = this._screenStream.getVideoTracks()[0];
if (vt && !peer.screenSender) {
peer.screenSender = peer.pc.addTrack(vt, this._screenStream);
}
}
const offer = await peer.pc.createOffer();
await peer.pc.setLocalDescription(offer);
this._onSignal(targetUid, { kind: 'offer', sdp: offer.sdp });
}
_getOrCreate(uid) {
if (this._peers.has(uid)) return this._peers.get(uid);
return this._createPeer(uid);
}
_getPeer(uid) { return this._peers.get(uid) || null; }
// Cached promise for ICE servers (fetched once per page load).
// Server returns STUN + optional TURN from env (TURN_URL/USER/PASS).
static _iceServersPromise = null;
_fetchIceServers() {
if (!ClassroomRTC._iceServersPromise) {
ClassroomRTC._iceServersPromise = fetch('/api/ice-servers', {
headers: { Authorization: 'Bearer ' + (localStorage.getItem('ls_token') || '') },
})
.then(r => r.ok ? r.json() : null)
.then(d => d?.iceServers || [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
])
.catch(() => [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
]);
}
return ClassroomRTC._iceServersPromise;
}
_createPeer(uid) {
// Use cached server-side ICE config if loaded; otherwise fall back to STUN-only synchronously.
// (RTCPeerConnection constructor needs config at create-time, so we use whatever is ready.)
const ICE = { iceServers: this._iceServers || [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
]};
const pc = new RTCPeerConnection(ICE);
const peer = { pc, audioEl: null, screenSender: null, remoteSet: false, pendingCandidates: [], negotiating: false };
this._peers.set(uid, peer);
pc.onicecandidate = e => {
if (e.candidate) this._onSignal(uid, { kind: 'candidate', candidate: e.candidate.toJSON() });
};
pc.oniceconnectionstatechange = () => {
console.log(`[RTC] ICE uid=${uid}${pc.iceConnectionState}`);
};
pc.onconnectionstatechange = () => {
console.log(`[RTC] conn uid=${uid}${pc.connectionState}`);
};
/* Renegotiation: fires when tracks are added/removed (e.g. screen share) */
pc.onnegotiationneeded = async () => {
if (peer.negotiating || !peer.remoteSet || pc.signalingState !== 'stable') return;
peer.negotiating = true;
try {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
this._onSignal(uid, { kind: 'offer', sdp: offer.sdp });
} catch {}
finally { peer.negotiating = false; }
};
pc.ontrack = e => {
const track = e.track;
const stream = e.streams[0] || new MediaStream([track]);
console.log(`[RTC] ontrack uid=${uid} kind=${track.kind} readyState=${track.readyState} streams=${e.streams.length}`);
if (track.kind === 'audio') {
if (!peer.audioEl) {
peer.audioEl = document.createElement('audio');
peer.audioEl.autoplay = true;
peer.audioEl.style.display = 'none';
document.body.appendChild(peer.audioEl);
}
peer.audioEl.srcObject = stream;
peer.audioEl.play()
.then(() => console.log(`[RTC] audio play() OK uid=${uid}`))
.catch(err => console.warn(`[RTC] audio play() BLOCKED uid=${uid}`, err));
this._startVAD(uid, stream);
} else if (track.kind === 'video') {
if (this._onScreen) this._onScreen(stream);
track.onended = () => { if (this._onScreen) this._onScreen(null); };
}
};
return peer;
}
/* ── Screen sharing (teacher) ────────────────────────────────────────── */
async startScreenShare(constraints = {}) {
try {
this._screenStream = await navigator.mediaDevices.getDisplayMedia({
video: { cursor: 'always', ...constraints.video },
audio: constraints.audio ?? false,
});
} catch { return null; }
const vt = this._screenStream.getVideoTracks()[0];
vt.onended = () => this.stopScreenShare();
// Add to all existing peer connections (onnegotiationneeded will renegotiate)
for (const [, peer] of this._peers) {
if (!peer.screenSender) {
peer.screenSender = peer.pc.addTrack(vt, this._screenStream);
}
}
return this._screenStream;
}
async stopScreenShare() {
if (!this._screenStream) return;
this._screenStream.getTracks().forEach(t => t.stop());
this._screenStream = null;
for (const [, peer] of this._peers) {
if (peer.screenSender) {
try { peer.pc.removeTrack(peer.screenSender); } catch {}
peer.screenSender = null;
}
}
}
/** Take an already-acquired MediaStream (e.g. from a pre-picker) and share it. */
async useExistingScreenStream(stream) {
if (this._screenStream) {
this._screenStream.getTracks().forEach(t => t.stop());
this._screenStream = null;
}
this._screenStream = stream;
const vt = this._screenStream.getVideoTracks()[0];
if (!vt) return stream;
for (const [, peer] of this._peers) {
if (!peer.screenSender) {
peer.screenSender = peer.pc.addTrack(vt, this._screenStream);
}
}
return stream;
}
isSharing() { return !!this._screenStream; }
/* ── Cleanup ─────────────────────────────────────────────────────────── */
destroy() {
this._stopAllVAD();
for (const uid of [...this._peers.keys()]) this.removePeer(uid);
this.stopAudio();
if (this._screenStream) {
this._screenStream.getTracks().forEach(t => t.stop());
this._screenStream = null;
}
}
}
if (typeof module !== 'undefined') module.exports = ClassroomRTC;