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
161 lines
7.7 KiB
JavaScript
161 lines
7.7 KiB
JavaScript
'use strict';
|
|
const db = require('../../db/db');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const { emitToUser } = require('../../ws-server');
|
|
const { emitToSession, hasAccess } = require('./_shared');
|
|
|
|
const CHAT_UPLOADS_DIR = path.join(__dirname, '../../../uploads/chat');
|
|
if (!fs.existsSync(CHAT_UPLOADS_DIR)) fs.mkdirSync(CHAT_UPLOADS_DIR, { recursive: true });
|
|
|
|
const ALLOWED_REACTIONS = ['like', 'heart', 'question', 'idea', 'wow'];
|
|
|
|
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);
|
|
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: 'Сессия не активна' });
|
|
if (!hasAccess(session, req.user.id, req.user.role)) return res.status(403).json({ error: 'Нет доступа' });
|
|
|
|
const { lastInsertRowid } = db.prepare(
|
|
'INSERT INTO classroom_chat (session_id, user_id, message, attachment_url, attachment_type) VALUES (?,?,?,?,?)'
|
|
).run(sessionId, req.user.id, text, safeUrl, safeUrl ? (attachment_type || null) : null);
|
|
|
|
const row = db.prepare('SELECT * FROM classroom_chat WHERE id=?').get(lastInsertRowid);
|
|
emitToSession(sessionId, {
|
|
type: 'classroom_chat', sessionId,
|
|
id: row.id, userId: req.user.id, userName: req.user.name, message: text,
|
|
createdAt: row.created_at, attachmentUrl: row.attachment_url || null, attachmentType: row.attachment_type || null,
|
|
});
|
|
res.json(row);
|
|
}
|
|
|
|
function getChat(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 sinceId = Number(req.query.since_id) || 0;
|
|
const messages = sinceId
|
|
? db.prepare(`SELECT c.*, u.name AS user_name FROM classroom_chat c
|
|
JOIN users u ON u.id = c.user_id
|
|
WHERE c.session_id=? AND c.id > ? ORDER BY c.id ASC LIMIT 100`).all(sessionId, sinceId)
|
|
: db.prepare(`SELECT c.*, u.name AS user_name FROM classroom_chat c
|
|
JOIN users u ON u.id = c.user_id
|
|
WHERE c.session_id=? ORDER BY c.id DESC LIMIT 200`).all(sessionId).reverse();
|
|
|
|
if (messages.length > 0) {
|
|
const ids = messages.map(m => m.id);
|
|
const reactions = db.prepare(
|
|
`SELECT chat_id, reaction, COUNT(*) AS cnt, GROUP_CONCAT(user_id) AS uids
|
|
FROM classroom_chat_reactions
|
|
WHERE chat_id IN (${ids.map(() => '?').join(',')})
|
|
GROUP BY chat_id, reaction`
|
|
).all(...ids);
|
|
const rmap = {};
|
|
reactions.forEach(r => {
|
|
if (!rmap[r.chat_id]) rmap[r.chat_id] = {};
|
|
rmap[r.chat_id][r.reaction] = { count: r.cnt, mine: (r.uids || '').split(',').includes(String(req.user.id)) };
|
|
});
|
|
messages.forEach(m => { m.reactions = rmap[m.id] || {}; });
|
|
}
|
|
res.json({ messages });
|
|
}
|
|
|
|
function pinMessage(req, res) {
|
|
const sessionId = Number(req.params.id);
|
|
const msgId = Number(req.params.msgId);
|
|
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
|
|
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
|
|
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
|
|
return res.status(403).json({ error: 'Нет доступа' });
|
|
|
|
const msg = db.prepare('SELECT * FROM classroom_chat WHERE id=? AND session_id=?').get(msgId, sessionId);
|
|
if (!msg) return res.status(404).json({ error: 'Сообщение не найдено' });
|
|
|
|
const newPinned = msg.pinned ? 0 : 1;
|
|
db.prepare('UPDATE classroom_chat SET pinned=? WHERE id=?').run(newPinned, msgId);
|
|
emitToSession(sessionId, { type: 'classroom_message_pinned', sessionId, msgId, pinned: !!newPinned, message: msg.message });
|
|
res.json({ ok: true, pinned: !!newPinned });
|
|
}
|
|
|
|
function reactToMessage(req, res) {
|
|
const chatId = Number(req.params.msgId);
|
|
const { reaction } = req.body;
|
|
if (!ALLOWED_REACTIONS.includes(reaction)) return res.status(400).json({ error: 'Неизвестная реакция' });
|
|
|
|
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);
|
|
let added;
|
|
if (existing) {
|
|
db.prepare('DELETE FROM classroom_chat_reactions WHERE id=?').run(existing.id);
|
|
added = false;
|
|
} else {
|
|
db.prepare('INSERT INTO classroom_chat_reactions (chat_id, user_id, reaction) VALUES (?,?,?)').run(chatId, req.user.id, reaction);
|
|
added = true;
|
|
}
|
|
|
|
const counts = db.prepare(`SELECT reaction, COUNT(*) AS cnt, GROUP_CONCAT(user_id) AS uids
|
|
FROM classroom_chat_reactions WHERE chat_id=? GROUP BY reaction`).all(chatId);
|
|
const reactionsMap = {};
|
|
counts.forEach(r => { reactionsMap[r.reaction] = { count: r.cnt, uids: r.uids }; });
|
|
|
|
emitToSession(msg.session_id, { type: 'classroom_reaction', sessionId: msg.session_id, chatId, reaction, userId: req.user.id, added, reactions: reactionsMap });
|
|
res.json({ ok: true, added, reactions: reactionsMap });
|
|
}
|
|
|
|
function uploadChatAttachment(req, res) {
|
|
if (!req.file) return res.status(400).json({ error: 'Файл не получен' });
|
|
const url = `/uploads/chat/${req.file.filename}`;
|
|
const type = req.file.mimetype.startsWith('image/') ? 'image' : 'file';
|
|
res.json({ url, type, name: req.file.originalname });
|
|
}
|
|
|
|
function exportChat(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 (session.teacher_id !== req.user.id && req.user.role !== 'admin')
|
|
return res.status(403).json({ error: 'Нет доступа' });
|
|
|
|
const messages = db.prepare(`
|
|
SELECT c.created_at, u.name AS user_name, c.message, c.attachment_url
|
|
FROM classroom_chat c JOIN users u ON u.id = c.user_id
|
|
WHERE c.session_id=? ORDER BY c.id ASC
|
|
`).all(sessionId);
|
|
|
|
const title = session.title || `Урок #${sessionId}`;
|
|
const date = session.created_at ? new Date(session.created_at).toLocaleDateString('ru-RU') : '';
|
|
let text = `Чат урока: ${title}\nДата: ${date}\n${'─'.repeat(50)}\n\n`;
|
|
messages.forEach(m => {
|
|
const ts = m.created_at ? new Date(m.created_at).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) : '';
|
|
const safeUser = (m.user_name || '').replace(/[\r\n]/g, ' ');
|
|
const safeMsg = (m.message || '').replace(/[\r\n]/g, ' ');
|
|
text += `[${ts}] ${safeUser}: ${safeMsg}`;
|
|
if (m.attachment_url) text += ` [вложение]`;
|
|
text += '\n';
|
|
});
|
|
|
|
const filename = `chat_${sessionId}_${date.replace(/\./g, '-')}.txt`;
|
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
|
res.send(text);
|
|
}
|
|
|
|
module.exports = {
|
|
CHAT_UPLOADS_DIR, ALLOWED_REACTIONS,
|
|
sendChat, getChat, pinMessage, reactToMessage, uploadChatAttachment, exportChat,
|
|
};
|