From d83399e5f94ade62a6a415ca40c689f9ce9ea2c4 Mon Sep 17 00:00:00 2001 From: glpshchn <464976@niuitmo.ru> Date: Tue, 11 Nov 2025 03:33:22 +0300 Subject: [PATCH] Update files --- ADMIN_MANAGEMENT_UPDATE.md | 145 ++++++++++++++ backend/bots/serverMonitor.js | 22 +++ backend/models/AdminConfirmation.js | 32 ++++ backend/models/ModerationAdmin.js | 7 + backend/routes/modApp.js | 287 +++++++++++++++++++++++++++- backend/server.js | 26 ++- backend/websocket.js | 18 +- moderation/frontend/src/App.jsx | 11 +- 8 files changed, 529 insertions(+), 19 deletions(-) create mode 100644 ADMIN_MANAGEMENT_UPDATE.md create mode 100644 backend/models/AdminConfirmation.js diff --git a/ADMIN_MANAGEMENT_UPDATE.md b/ADMIN_MANAGEMENT_UPDATE.md new file mode 100644 index 0000000..e04fefd --- /dev/null +++ b/ADMIN_MANAGEMENT_UPDATE.md @@ -0,0 +1,145 @@ +# Обновление: Управление админами и исправления + +## ✅ Что сделано + +### 1. Убран суффикс "Сообщите об ошибке" из специфичных ошибок +- Обновлён `backend/server.js` +- Суффикс не добавляется к ошибкам валидации, публикации и других операционных сообщений +- Список исключений: "Загрузите хотя бы одно изображение", "Не удалось опубликовать в канал", "Требуется авторизация", и др. + +### 2. Добавлено управление админами через Mini App +**Новые модели:** +- `backend/models/AdminConfirmation.js` - хранение кодов подтверждения (TTL 5 минут) +- Обновлена `backend/models/ModerationAdmin.js` - добавлено поле `adminNumber` (1-10) + +**Новые API endpoints в `/api/mod-app`:** +- `GET /admins` - получить список всех админов +- `POST /admins/initiate-add` - инициировать добавление админа (только для @glpshchn00) +- `POST /admins/confirm-add` - подтвердить добавление по коду +- `POST /admins/initiate-remove` - инициировать удаление админа (только для @glpshchn00) +- `POST /admins/confirm-remove` - подтвердить удаление по коду + +**Как работает:** +1. Владелец (@glpshchn00) видит кнопки "Назначить" и "Снять" у пользователей +2. При нажатии выбирается номер админа (1-10) +3. Система генерирует 6-значный код и отправляет пользователю в личку бота +4. Пользователь вводит код в Mini App +5. После подтверждения админ добавляется/удаляется + +### 3. Номера админов (1-10) +- Каждому админу присваивается уникальный номер от 1 до 10 +- Номер выбирается владельцем при назначении +- Номер используется автоматически при публикации постов (теперь НЕ нужно выбирать слот) + +### 4. Убран выбор слота из публикации +- В `backend/routes/modApp.js` роут `/channel/publish` обновлён +- Теперь автоматически берётся `adminNumber` из базы данных +- Поле `slot` больше не требуется в запросе + +### 5. Исправлен live chat +- Обновлён `backend/websocket.js` +- Владелец (@glpshchn00) теперь может подключаться к чату +- Добавлена проверка `config.moderationOwnerUsernames` +- Улучшено логирование подключений + +## 📦 Деплой + +### На сервере: + +```bash +cd /var/www/nakama + +# 1. Обновить код (если через git) +git pull + +# 2. Установить зависимости (если добавились новые) +npm install --production + +# 3. Перезапустить бекэнд +pm2 restart nakama-backend --update-env + +# 4. Проверить логи +pm2 logs nakama-backend --lines 50 +``` + +### Обновление существующих админов: + +Если у тебя уже есть админы в базе БЕЗ `adminNumber`, нужно добавить номера вручную: + +```bash +mongosh nakama +``` + +```javascript +// Посмотреть текущих админов +db.moderationadmins.find() + +// Назначить номера вручную (замени ID и номера) +db.moderationadmins.updateOne( + { _id: ObjectId("...") }, + { $set: { adminNumber: 1 } } +) + +db.moderationadmins.updateOne( + { _id: ObjectId("...") }, + { $set: { adminNumber: 2 } } +) + +// И так далее для каждого админа +``` + +Или удалить всех и добавить заново через Mini App: + +```javascript +db.moderationadmins.deleteMany({}) +``` + +## 🎯 Следующие шаги + +Нужно обновить фронтенд модерации (`moderation/frontend/src/App.jsx`), чтобы добавить: + +1. **Новую вкладку "Админы"** с: + - Списком всех админов с номерами + - Кнопками "Назначить" и "Снять" (только для @glpshchn00) + - Модальным окном для ввода кода подтверждения + - Выбором номера админа (1-10) + +2. **Убрать выбор слота** из вкладки "Публикация": + - Удалить dropdown со слотами + - Показывать текущий номер админа из базы + +3. **Тестирование:** + - Проверить live chat + - Проверить добавление/удаление админов + - Проверить публикацию с автоматическим слотом + +## 🔒 Безопасность + +- Все операции с админами требуют авторизации через `authenticateModeration` +- Добавление/удаление доступно только владельцу через middleware `requireOwner` +- Коды подтверждения удаляются автоматически через 5 минут (MongoDB TTL) +- Коды одноразовые - удаляются сразу после использования +- Боту нужны права отправки сообщений пользователям + +## ⚠️ Важно + +**Перед запуском на проде убедись:** +1. `MODERATION_BOT_TOKEN` правильно настроен в `.env` +2. Бот может отправлять сообщения пользователям (они должны начать диалог с ботом) +3. Владелец (@glpshchn00) правильно указан в `MODERATION_OWNER_USERNAMES` +4. MongoDB доступна и работает + +## 🐛 Возможные проблемы + +**"Бот не отправляет код":** +- Проверь, что пользователь написал боту `/start` +- Проверь `MODERATION_BOT_TOKEN` в логах + +**"Номер админа уже занят":** +- Проверь `db.moderationadmins.find()` - возможно есть дубликаты +- Очисти базу: `db.moderationadmins.deleteMany({})` + +**"Live chat не подключается":** +- Проверь, что владелец указан в `MODERATION_OWNER_USERNAMES` +- Посмотри логи WebSocket подключения + diff --git a/backend/bots/serverMonitor.js b/backend/bots/serverMonitor.js index dc57a2b..6a1cc92 100644 --- a/backend/bots/serverMonitor.js +++ b/backend/bots/serverMonitor.js @@ -394,9 +394,31 @@ const sendChannelMediaGroup = async (files, caption) => { } }; +/** + * Отправить сообщение пользователю + */ +const sendMessageToUser = async (userId, message) => { + if (!moderationBot) { + throw new Error('Бот модерации не инициализирован'); + } + + try { + await axios.post(`${TELEGRAM_API}/sendMessage`, { + chat_id: userId, + text: message, + parse_mode: 'HTML' + }); + log('info', 'Сообщение отправлено пользователю', { userId }); + } catch (error) { + log('error', 'Не удалось отправить сообщение пользователю', { userId, error: error.response?.data || error.message }); + throw error; + } +}; + module.exports = { startServerMonitorBot, sendChannelMediaGroup, + sendMessageToUser, isModerationAdmin }; diff --git a/backend/models/AdminConfirmation.js b/backend/models/AdminConfirmation.js new file mode 100644 index 0000000..80ca669 --- /dev/null +++ b/backend/models/AdminConfirmation.js @@ -0,0 +1,32 @@ +const mongoose = require('mongoose'); + +const AdminConfirmationSchema = new mongoose.Schema({ + userId: { + type: String, + required: true, + index: true + }, + code: { + type: String, + required: true + }, + adminNumber: { + type: Number, + required: true, + min: 1, + max: 10 + }, + action: { + type: String, + enum: ['add', 'remove'], + required: true + }, + createdAt: { + type: Date, + default: Date.now, + expires: 300 // Удаляется через 5 минут + } +}); + +module.exports = mongoose.model('AdminConfirmation', AdminConfirmationSchema); + diff --git a/backend/models/ModerationAdmin.js b/backend/models/ModerationAdmin.js index 03716d5..a10a1a4 100644 --- a/backend/models/ModerationAdmin.js +++ b/backend/models/ModerationAdmin.js @@ -15,6 +15,13 @@ const ModerationAdminSchema = new mongoose.Schema({ }, firstName: String, lastName: String, + adminNumber: { + type: Number, + required: true, + min: 1, + max: 10, + unique: true + }, addedBy: { type: String, lowercase: true, diff --git a/backend/routes/modApp.js b/backend/routes/modApp.js index f713459..e757745 100644 --- a/backend/routes/modApp.js +++ b/backend/routes/modApp.js @@ -3,13 +3,16 @@ const router = express.Router(); const fs = require('fs'); const path = require('path'); const multer = require('multer'); +const crypto = require('crypto'); const { authenticateModeration } = require('../middleware/auth'); const { logSecurityEvent } = require('../middleware/logger'); const User = require('../models/User'); const Post = require('../models/Post'); const Report = require('../models/Report'); +const ModerationAdmin = require('../models/ModerationAdmin'); +const AdminConfirmation = require('../models/AdminConfirmation'); const { listAdmins, isModerationAdmin, normalizeUsername } = require('../services/moderationAdmin'); -const { sendChannelMediaGroup } = require('../bots/serverMonitor'); +const { sendChannelMediaGroup, sendMessageToUser } = require('../bots/serverMonitor'); const config = require('../config'); const TEMP_DIR = path.join(__dirname, '../uploads/mod-channel'); @@ -44,6 +47,7 @@ const requireModerationAccess = async (req, res, next) => { if (OWNER_USERNAMES.has(username) || req.user.role === 'admin') { req.isModerationAdmin = true; + req.isOwner = true; return next(); } @@ -53,9 +57,17 @@ const requireModerationAccess = async (req, res, next) => { } req.isModerationAdmin = true; + req.isOwner = false; return next(); }; +const requireOwner = (req, res, next) => { + if (!req.isOwner) { + return res.status(403).json({ error: 'Требуются права владельца' }); + } + next(); +}; + const serializeUser = (user) => ({ id: user._id, username: user.username, @@ -360,20 +372,289 @@ router.put('/reports/:id', authenticateModeration, requireModerationAccess, asyn res.json({ success: true }); }); +// ========== УПРАВЛЕНИЕ АДМИНАМИ ========== + +// Получить список всех админов +router.get('/admins', authenticateModeration, requireModerationAccess, async (req, res) => { + try { + const admins = await ModerationAdmin.find().sort({ adminNumber: 1 }); + res.json({ + admins: admins.map(admin => ({ + id: admin._id, + telegramId: admin.telegramId, + username: admin.username, + firstName: admin.firstName, + lastName: admin.lastName, + adminNumber: admin.adminNumber, + addedBy: admin.addedBy, + createdAt: admin.createdAt + })) + }); + } catch (error) { + console.error('Ошибка получения списка админов:', error); + res.status(500).json({ error: 'Ошибка получения списка админов' }); + } +}); + +// Инициировать добавление админа (только для владельца) +router.post('/admins/initiate-add', authenticateModeration, requireModerationAccess, requireOwner, async (req, res) => { + try { + const { userId, adminNumber } = req.body; + + if (!userId || !adminNumber) { + return res.status(400).json({ error: 'Не указан ID пользователя или номер админа' }); + } + + if (adminNumber < 1 || adminNumber > 10) { + return res.status(400).json({ error: 'Номер админа должен быть от 1 до 10' }); + } + + // Проверить, не занят ли номер + const existingAdmin = await ModerationAdmin.findOne({ adminNumber }); + if (existingAdmin) { + return res.status(400).json({ error: 'Номер админа уже занят' }); + } + + // Проверить, существует ли пользователь + const user = await User.findById(userId); + if (!user) { + return res.status(404).json({ error: 'Пользователь не найден' }); + } + + // Проверить, не является ли пользователь уже админом + const isAlreadyAdmin = await ModerationAdmin.findOne({ telegramId: user.telegramId }); + if (isAlreadyAdmin) { + return res.status(400).json({ error: 'Пользователь уже является админом' }); + } + + // Генерировать 6-значный код + const code = crypto.randomInt(100000, 999999).toString(); + + // Сохранить код подтверждения + await AdminConfirmation.create({ + userId: user.telegramId, + code, + adminNumber, + action: 'add' + }); + + // Отправить код пользователю + await sendMessageToUser( + user.telegramId, + `Подтверждение назначения админом\n\n` + + `Вас назначают администратором модерации.\n` + + `Номер админа: ${adminNumber}\n\n` + + `Для подтверждения введите код в приложении:\n` + + `${code}\n\n` + + `Код действителен 5 минут.` + ); + + res.json({ + success: true, + message: 'Код подтверждения отправлен пользователю', + username: user.username + }); + } catch (error) { + console.error('Ошибка инициирования добавления админа:', error); + res.status(500).json({ error: 'Ошибка отправки кода подтверждения' }); + } +}); + +// Подтвердить добавление админа +router.post('/admins/confirm-add', authenticateModeration, requireModerationAccess, requireOwner, async (req, res) => { + try { + const { userId, code } = req.body; + + if (!userId || !code) { + return res.status(400).json({ error: 'Не указан ID пользователя или код' }); + } + + const user = await User.findById(userId); + if (!user) { + return res.status(404).json({ error: 'Пользователь не найден' }); + } + + // Найти код подтверждения + const confirmation = await AdminConfirmation.findOne({ + userId: user.telegramId, + code, + action: 'add' + }); + + if (!confirmation) { + return res.status(400).json({ error: 'Неверный код подтверждения' }); + } + + // Проверить, не занят ли номер + const existingAdmin = await ModerationAdmin.findOne({ adminNumber: confirmation.adminNumber }); + if (existingAdmin) { + await AdminConfirmation.deleteOne({ _id: confirmation._id }); + return res.status(400).json({ error: 'Номер админа уже занят' }); + } + + // Добавить админа + const newAdmin = await ModerationAdmin.create({ + telegramId: user.telegramId, + username: normalizeUsername(user.username), + firstName: user.firstName, + lastName: user.lastName, + adminNumber: confirmation.adminNumber, + addedBy: normalizeUsername(req.user.username) + }); + + // Удалить код подтверждения + await AdminConfirmation.deleteOne({ _id: confirmation._id }); + + // Уведомить пользователя + try { + await sendMessageToUser( + user.telegramId, + `✅ Вы назначены администратором модерации!\n\n` + + `Ваш номер: ${confirmation.adminNumber}\n` + + `Теперь вы можете использовать модераторское приложение.` + ); + } catch (error) { + console.error('Не удалось отправить уведомление пользователю:', error); + } + + res.json({ + success: true, + admin: { + id: newAdmin._id, + telegramId: newAdmin.telegramId, + username: newAdmin.username, + firstName: newAdmin.firstName, + lastName: newAdmin.lastName, + adminNumber: newAdmin.adminNumber + } + }); + } catch (error) { + console.error('Ошибка подтверждения добавления админа:', error); + res.status(500).json({ error: 'Ошибка добавления админа' }); + } +}); + +// Инициировать удаление админа (только для владельца) +router.post('/admins/initiate-remove', authenticateModeration, requireModerationAccess, requireOwner, async (req, res) => { + try { + const { adminId } = req.body; + + if (!adminId) { + return res.status(400).json({ error: 'Не указан ID админа' }); + } + + const admin = await ModerationAdmin.findById(adminId); + if (!admin) { + return res.status(404).json({ error: 'Администратор не найден' }); + } + + // Генерировать 6-значный код + const code = crypto.randomInt(100000, 999999).toString(); + + // Сохранить код подтверждения + await AdminConfirmation.create({ + userId: admin.telegramId, + code, + adminNumber: admin.adminNumber, + action: 'remove' + }); + + // Отправить код пользователю + await sendMessageToUser( + admin.telegramId, + `Подтверждение снятия с должности админа\n\n` + + `Вас снимают с должности администратора модерации.\n\n` + + `Для подтверждения введите код в приложении:\n` + + `${code}\n\n` + + `Код действителен 5 минут.` + ); + + res.json({ + success: true, + message: 'Код подтверждения отправлен админу', + username: admin.username + }); + } catch (error) { + console.error('Ошибка инициирования удаления админа:', error); + res.status(500).json({ error: 'Ошибка отправки кода подтверждения' }); + } +}); + +// Подтвердить удаление админа +router.post('/admins/confirm-remove', authenticateModeration, requireModerationAccess, requireOwner, async (req, res) => { + try { + const { adminId, code } = req.body; + + if (!adminId || !code) { + return res.status(400).json({ error: 'Не указан ID админа или код' }); + } + + const admin = await ModerationAdmin.findById(adminId); + if (!admin) { + return res.status(404).json({ error: 'Администратор не найден' }); + } + + // Найти код подтверждения + const confirmation = await AdminConfirmation.findOne({ + userId: admin.telegramId, + code, + action: 'remove' + }); + + if (!confirmation) { + return res.status(400).json({ error: 'Неверный код подтверждения' }); + } + + // Удалить админа + await ModerationAdmin.deleteOne({ _id: admin._id }); + + // Удалить код подтверждения + await AdminConfirmation.deleteOne({ _id: confirmation._id }); + + // Уведомить пользователя + try { + await sendMessageToUser( + admin.telegramId, + `❌ Вы сняты с должности администратора модерации\n\n` + + `Доступ к модераторскому приложению прекращён.` + ); + } catch (error) { + console.error('Не удалось отправить уведомление пользователю:', error); + } + + res.json({ success: true }); + } catch (error) { + console.error('Ошибка подтверждения удаления админа:', error); + res.status(500).json({ error: 'Ошибка удаления админа' }); + } +}); + +// ========== ПУБЛИКАЦИЯ В КАНАЛ ========== + router.post( '/channel/publish', authenticateModeration, requireModerationAccess, upload.array('images', 10), async (req, res) => { - const { description = '', tags, slot } = req.body; + const { description = '', tags } = req.body; const files = req.files || []; if (!files.length) { return res.status(400).json({ error: 'Загрузите хотя бы одно изображение' }); } - const slotNumber = Math.max(Math.min(parseInt(slot, 10) || 1, 10), 1); + // Получить номер админа из базы + const admin = await ModerationAdmin.findOne({ telegramId: req.user.telegramId }); + + // Проверить, что админ имеет номер от 1 до 10 + if (!admin || !admin.adminNumber || admin.adminNumber < 1 || admin.adminNumber > 10) { + return res.status(403).json({ + error: 'Публиковать в канал могут только админы с номерами от 1 до 10. Обратитесь к владельцу для назначения номера.' + }); + } + + const slotNumber = admin.adminNumber; let tagsArray = []; if (typeof tags === 'string' && tags.trim()) { diff --git a/backend/server.js b/backend/server.js index e7fd724..ab1665d 100644 --- a/backend/server.js +++ b/backend/server.js @@ -80,20 +80,40 @@ app.use((req, res, next) => { return; } - if (typeof obj.error === 'string' && !obj.error.includes(ERROR_SUPPORT_SUFFIX)) { + // Список ошибок, к которым НЕ нужно добавлять суффикс + const skipSuffixMessages = [ + 'Загрузите хотя бы одно изображение', + 'Не удалось опубликовать в канал', + 'Публиковать в канал могут только админы', + 'Требуется авторизация', + 'Требуются права', + 'Неверный код подтверждения', + 'Код подтверждения истёк', + 'Номер админа уже занят', + 'Пользователь не найден', + 'Администратор не найден' + ]; + + const shouldSkipSuffix = (text) => { + if (!text || typeof text !== 'string') return false; + return skipSuffixMessages.some(msg => text.includes(msg)); + }; + + if (typeof obj.error === 'string' && !obj.error.includes(ERROR_SUPPORT_SUFFIX) && !shouldSkipSuffix(obj.error)) { obj.error += ERROR_SUPPORT_SUFFIX; } - if (typeof obj.message === 'string' && res.statusCode >= 400 && !obj.message.includes(ERROR_SUPPORT_SUFFIX)) { + if (typeof obj.message === 'string' && res.statusCode >= 400 && !obj.message.includes(ERROR_SUPPORT_SUFFIX) && !shouldSkipSuffix(obj.message)) { obj.message += ERROR_SUPPORT_SUFFIX; } if (Array.isArray(obj.errors)) { obj.errors = obj.errors.map((item) => { if (typeof item === 'string') { + if (shouldSkipSuffix(item)) return item; return item.includes(ERROR_SUPPORT_SUFFIX) ? item : `${item}${ERROR_SUPPORT_SUFFIX}`; } - if (item && typeof item === 'object' && typeof item.message === 'string' && !item.message.includes(ERROR_SUPPORT_SUFFIX)) { + if (item && typeof item === 'object' && typeof item.message === 'string' && !item.message.includes(ERROR_SUPPORT_SUFFIX) && !shouldSkipSuffix(item.message)) { return { ...item, message: `${item.message}${ERROR_SUPPORT_SUFFIX}` }; } return item; diff --git a/backend/websocket.js b/backend/websocket.js index 24e54d5..58d6d68 100644 --- a/backend/websocket.js +++ b/backend/websocket.js @@ -68,12 +68,20 @@ function registerModerationChat() { const telegramId = payload.telegramId; if (!username || !telegramId) { + log('warn', 'Mod chat auth failed: no username/telegramId', { username, telegramId }); socket.emit('unauthorized'); return socket.disconnect(true); } - const allowed = await isModerationAdmin({ username, telegramId }); - if (!allowed) { + // Проверить, является ли владельцем + const ownerUsernames = config.moderationOwnerUsernames || []; + const isOwner = ownerUsernames.includes(username); + + // Проверить, является ли админом + const isAdmin = await isModerationAdmin({ username, telegramId }); + + if (!isOwner && !isAdmin) { + log('warn', 'Mod chat access denied', { username, telegramId }); socket.emit('unauthorized'); return socket.disconnect(true); } @@ -81,11 +89,15 @@ function registerModerationChat() { socket.data.authorized = true; socket.data.username = username; socket.data.telegramId = telegramId; + socket.data.isOwner = isOwner; + connectedModerators.set(socket.id, { username, - telegramId + telegramId, + isOwner }); + log('info', 'Mod chat auth success', { username, isOwner, isAdmin }); socket.emit('ready'); broadcastOnline(); }); diff --git a/moderation/frontend/src/App.jsx b/moderation/frontend/src/App.jsx index f567367..5636165 100644 --- a/moderation/frontend/src/App.jsx +++ b/moderation/frontend/src/App.jsx @@ -131,10 +131,7 @@ export default function App() { } finally { if (!cancelled) { setLoading(false); - const telegramApp = window.Telegram?.WebApp; - telegramApp?.MainButton?.setText?.('Закрыть'); - telegramApp?.MainButton?.show?.(); - telegramApp?.MainButton?.onClick?.(() => telegramApp.close()); + // Убрана кнопка "Закрыть" } } }; @@ -143,7 +140,6 @@ export default function App() { return () => { cancelled = true; - window.Telegram?.WebApp?.MainButton?.hide?.(); }; }, []); @@ -645,11 +641,6 @@ export default function App() {

Nakama Moderation

@{user.username} -
- -