c0f20ef020
- sessions.js: endSession закрывает classroom_attendance (left_at), чистит classroom_muted - sessions.js: joinSession восстанавливает mute-состояние при реконнекте - strokes.js: updateStroke проверяет авторство штриха (не только canDraw) - strokes.js: clearPage валидирует page_num как положительное целое - strokes.js: postStrokes ограничивает массив 500 штрихами - pages.js: duplicatePage сохраняет user_id при копировании штрихов - pages.js: changePage валидирует page_num - pages.js: updatePageTemplate делает INSERT OR IGNORE перед UPDATE - permissions.js: mutePeer сохраняет в classroom_muted; добавлен unmutePeer - permissions.js: getOnlineStudents не возвращает email - chat.js: exportChat экранирует переводы строк в именах и сообщениях - guestClassroom.js: санитизация имени гостя (убираем HTML-символы) - ws-server.js: mute_peer сохраняет в БД; добавлен обработчик unmute_peer - routes/classroom.js: rate-limit для cursor/preview/signal/strokes; маршрут DELETE /mute - migrations/001_classroom_muted.sql: новая таблица classroom_muted
169 lines
6.2 KiB
JavaScript
169 lines
6.2 KiB
JavaScript
/**
|
|
* Public (no-auth) guest classroom routes.
|
|
* Guests access the whiteboard read-only via a token in the URL.
|
|
*
|
|
* GET /api/classroom/guest/:token — session info (pre-join)
|
|
* POST /api/classroom/guest/:token/join — choose display name, get guestId
|
|
* GET /api/classroom/guest/:token/strokes — strokes for ?page_num=N
|
|
* GET /api/classroom/guest/:token/stream — SSE stream (?guestId=X)
|
|
* POST /api/classroom/guest/:token/leave — notify departure (optional)
|
|
*/
|
|
|
|
const router = require('express').Router();
|
|
const crypto = require('crypto');
|
|
const db = require('../db/db');
|
|
const { addGuestClient, removeGuestClient, emitToGuests, emit } = require('../sse');
|
|
|
|
/* ── In-memory guest registry (cleared on server restart — fine for guests) */
|
|
const guests = new Map(); // guestId → { name, sessionId, connectedAt }
|
|
|
|
/* Helper: look up session by token (active only) */
|
|
function sessionByToken(token) {
|
|
return db.prepare(
|
|
"SELECT id, title, status, current_page, teacher_id FROM classroom_sessions WHERE guest_token=?"
|
|
).get(token);
|
|
}
|
|
|
|
/* Helper: emit guest-joined/left to the real participants of the session */
|
|
function notifySession(sessionId, data) {
|
|
const session = db.prepare('SELECT class_id, teacher_id FROM classroom_sessions WHERE id=?').get(sessionId);
|
|
if (!session) return;
|
|
const { emit: _emit, emitToClass } = require('../sse');
|
|
if (session.class_id) {
|
|
emitToClass(session.class_id, data);
|
|
_emit(session.teacher_id, data);
|
|
} else {
|
|
_emit(session.teacher_id, data);
|
|
const invites = db.prepare('SELECT user_id FROM classroom_invites WHERE session_id=?').all(sessionId);
|
|
for (const { user_id } of invites) _emit(user_id, data);
|
|
}
|
|
}
|
|
|
|
/* ── GET /api/classroom/guest/:token ─── session info (pre-join screen) */
|
|
router.get('/:token', (req, res) => {
|
|
const session = sessionByToken(req.params.token);
|
|
if (!session) return res.status(404).json({ error: 'Ссылка недействительна' });
|
|
|
|
const pageCount = db.prepare(
|
|
'SELECT COUNT(*) AS c FROM classroom_pages WHERE session_id=?'
|
|
).get(session.id).c || 1;
|
|
|
|
res.json({
|
|
id: session.id,
|
|
title: session.title || 'Онлайн-урок',
|
|
status: session.status,
|
|
current_page: session.current_page || 1,
|
|
page_count: pageCount,
|
|
});
|
|
});
|
|
|
|
/* ── POST /api/classroom/guest/:token/join ─── choose name, get guestId */
|
|
router.post('/:token/join', (req, res) => {
|
|
const session = sessionByToken(req.params.token);
|
|
if (!session) return res.status(404).json({ error: 'Ссылка недействительна' });
|
|
if (session.status !== 'active')
|
|
return res.status(403).json({ error: 'Урок ещё не начался или уже завершён' });
|
|
|
|
const rawName = (req.body?.name || '').trim().slice(0, 40).replace(/[<>"&]/g, '');
|
|
const name = rawName || 'Гость';
|
|
|
|
const guestId = 'g_' + crypto.randomBytes(12).toString('base64url');
|
|
guests.set(guestId, { name, sessionId: session.id, connectedAt: Date.now() });
|
|
|
|
// Notify real participants that a guest joined
|
|
notifySession(session.id, {
|
|
type: 'classroom_guest_joined',
|
|
sessionId: session.id,
|
|
guestId,
|
|
guestName: name,
|
|
});
|
|
|
|
const pageCount = db.prepare(
|
|
'SELECT COUNT(*) AS c FROM classroom_pages WHERE session_id=?'
|
|
).get(session.id).c || 1;
|
|
|
|
res.json({
|
|
guestId,
|
|
sessionId: session.id,
|
|
title: session.title || 'Онлайн-урок',
|
|
current_page: session.current_page || 1,
|
|
page_count: pageCount,
|
|
});
|
|
});
|
|
|
|
/* ── GET /api/classroom/guest/:token/strokes ─── whiteboard strokes */
|
|
router.get('/:token/strokes', (req, res) => {
|
|
const session = sessionByToken(req.params.token);
|
|
if (!session) return res.status(404).json({ error: 'Ссылка недействительна' });
|
|
if (session.status !== 'active')
|
|
return res.status(403).json({ error: 'Урок не активен' });
|
|
|
|
const pageNum = Math.max(1, Number(req.query.page_num) || 1);
|
|
const sinceSeq = Number(req.query.since_seq) || 0;
|
|
|
|
const strokes = sinceSeq > 0
|
|
? db.prepare(
|
|
'SELECT id, tool, data, seq FROM classroom_strokes WHERE session_id=? AND page_num=? AND seq > ? ORDER BY seq'
|
|
).all(session.id, pageNum, sinceSeq)
|
|
: db.prepare(
|
|
'SELECT id, tool, data, seq FROM classroom_strokes WHERE session_id=? AND page_num=? ORDER BY seq'
|
|
).all(session.id, pageNum);
|
|
|
|
const pageRow = db.prepare(
|
|
'SELECT template, name FROM classroom_pages WHERE session_id=? AND page_num=?'
|
|
).get(session.id, pageNum);
|
|
|
|
const parsed = strokes.map(s => ({ ...s, data: JSON.parse(s.data || '{}') }));
|
|
res.json({ strokes: parsed, template: pageRow?.template || 'blank', seq: parsed.at(-1)?.seq || 0 });
|
|
});
|
|
|
|
/* ── GET /api/classroom/guest/:token/stream ─── SSE */
|
|
router.get('/:token/stream', (req, res) => {
|
|
const session = sessionByToken(req.params.token);
|
|
if (!session) return res.status(404).end();
|
|
if (session.status !== 'active') return res.status(403).end();
|
|
|
|
const guestId = req.query.guestId;
|
|
const guest = guestId ? guests.get(guestId) : null;
|
|
|
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
res.setHeader('Cache-Control', 'no-cache');
|
|
res.setHeader('Connection', 'keep-alive');
|
|
res.setHeader('X-Accel-Buffering', 'no'); // nginx: disable buffering
|
|
res.flushHeaders();
|
|
res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`);
|
|
|
|
addGuestClient(session.id, res);
|
|
|
|
req.on('close', () => {
|
|
removeGuestClient(session.id, res);
|
|
if (guest) {
|
|
guests.delete(guestId);
|
|
notifySession(session.id, {
|
|
type: 'classroom_guest_left',
|
|
sessionId: session.id,
|
|
guestId,
|
|
guestName: guest.name,
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
/* ── POST /api/classroom/guest/:token/leave ─── explicit goodbye */
|
|
router.post('/:token/leave', (req, res) => {
|
|
const guestId = req.body?.guestId;
|
|
const guest = guestId ? guests.get(guestId) : null;
|
|
if (guest) {
|
|
guests.delete(guestId);
|
|
notifySession(guest.sessionId, {
|
|
type: 'classroom_guest_left',
|
|
sessionId: guest.sessionId,
|
|
guestId,
|
|
guestName: guest.name,
|
|
});
|
|
}
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
module.exports = router;
|