Update files

This commit is contained in:
glpshchn 2025-11-11 03:33:22 +03:00
parent a4bae70823
commit d83399e5f9
8 changed files with 529 additions and 19 deletions

145
ADMIN_MANAGEMENT_UPDATE.md Normal file
View File

@ -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 подключения

View File

@ -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 = { module.exports = {
startServerMonitorBot, startServerMonitorBot,
sendChannelMediaGroup, sendChannelMediaGroup,
sendMessageToUser,
isModerationAdmin isModerationAdmin
}; };

View File

@ -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);

View File

@ -15,6 +15,13 @@ const ModerationAdminSchema = new mongoose.Schema({
}, },
firstName: String, firstName: String,
lastName: String, lastName: String,
adminNumber: {
type: Number,
required: true,
min: 1,
max: 10,
unique: true
},
addedBy: { addedBy: {
type: String, type: String,
lowercase: true, lowercase: true,

View File

@ -3,13 +3,16 @@ const router = express.Router();
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const multer = require('multer'); const multer = require('multer');
const crypto = require('crypto');
const { authenticateModeration } = require('../middleware/auth'); const { authenticateModeration } = require('../middleware/auth');
const { logSecurityEvent } = require('../middleware/logger'); const { logSecurityEvent } = require('../middleware/logger');
const User = require('../models/User'); const User = require('../models/User');
const Post = require('../models/Post'); const Post = require('../models/Post');
const Report = require('../models/Report'); const Report = require('../models/Report');
const ModerationAdmin = require('../models/ModerationAdmin');
const AdminConfirmation = require('../models/AdminConfirmation');
const { listAdmins, isModerationAdmin, normalizeUsername } = require('../services/moderationAdmin'); const { listAdmins, isModerationAdmin, normalizeUsername } = require('../services/moderationAdmin');
const { sendChannelMediaGroup } = require('../bots/serverMonitor'); const { sendChannelMediaGroup, sendMessageToUser } = require('../bots/serverMonitor');
const config = require('../config'); const config = require('../config');
const TEMP_DIR = path.join(__dirname, '../uploads/mod-channel'); 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') { if (OWNER_USERNAMES.has(username) || req.user.role === 'admin') {
req.isModerationAdmin = true; req.isModerationAdmin = true;
req.isOwner = true;
return next(); return next();
} }
@ -53,9 +57,17 @@ const requireModerationAccess = async (req, res, next) => {
} }
req.isModerationAdmin = true; req.isModerationAdmin = true;
req.isOwner = false;
return next(); return next();
}; };
const requireOwner = (req, res, next) => {
if (!req.isOwner) {
return res.status(403).json({ error: 'Требуются права владельца' });
}
next();
};
const serializeUser = (user) => ({ const serializeUser = (user) => ({
id: user._id, id: user._id,
username: user.username, username: user.username,
@ -360,20 +372,289 @@ router.put('/reports/:id', authenticateModeration, requireModerationAccess, asyn
res.json({ success: true }); 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,
`<b>Подтверждение назначения админом</b>\n\n` +
`Вас назначают администратором модерации.\n` +
`Номер админа: <b>${adminNumber}</b>\n\n` +
`Для подтверждения введите код в приложении:\n` +
`<code>${code}</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,
`<b>✅ Вы назначены администратором модерации!</b>\n\n` +
`Ваш номер: <b>${confirmation.adminNumber}</b>\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,
`<b>Подтверждение снятия с должности админа</b>\n\n` +
`Вас снимают с должности администратора модерации.\n\n` +
`Для подтверждения введите код в приложении:\n` +
`<code>${code}</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,
`<b>❌ Вы сняты с должности администратора модерации</b>\n\n` +
`Доступ к модераторскому приложению прекращён.`
);
} catch (error) {
console.error('Не удалось отправить уведомление пользователю:', error);
}
res.json({ success: true });
} catch (error) {
console.error('Ошибка подтверждения удаления админа:', error);
res.status(500).json({ error: 'Ошибка удаления админа' });
}
});
// ========== ПУБЛИКАЦИЯ В КАНАЛ ==========
router.post( router.post(
'/channel/publish', '/channel/publish',
authenticateModeration, authenticateModeration,
requireModerationAccess, requireModerationAccess,
upload.array('images', 10), upload.array('images', 10),
async (req, res) => { async (req, res) => {
const { description = '', tags, slot } = req.body; const { description = '', tags } = req.body;
const files = req.files || []; const files = req.files || [];
if (!files.length) { if (!files.length) {
return res.status(400).json({ error: 'Загрузите хотя бы одно изображение' }); 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 = []; let tagsArray = [];
if (typeof tags === 'string' && tags.trim()) { if (typeof tags === 'string' && tags.trim()) {

View File

@ -80,20 +80,40 @@ app.use((req, res, next) => {
return; 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; 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; obj.message += ERROR_SUPPORT_SUFFIX;
} }
if (Array.isArray(obj.errors)) { if (Array.isArray(obj.errors)) {
obj.errors = obj.errors.map((item) => { obj.errors = obj.errors.map((item) => {
if (typeof item === 'string') { if (typeof item === 'string') {
if (shouldSkipSuffix(item)) return item;
return item.includes(ERROR_SUPPORT_SUFFIX) ? item : `${item}${ERROR_SUPPORT_SUFFIX}`; 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, message: `${item.message}${ERROR_SUPPORT_SUFFIX}` };
} }
return item; return item;

View File

@ -68,12 +68,20 @@ function registerModerationChat() {
const telegramId = payload.telegramId; const telegramId = payload.telegramId;
if (!username || !telegramId) { if (!username || !telegramId) {
log('warn', 'Mod chat auth failed: no username/telegramId', { username, telegramId });
socket.emit('unauthorized'); socket.emit('unauthorized');
return socket.disconnect(true); 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'); socket.emit('unauthorized');
return socket.disconnect(true); return socket.disconnect(true);
} }
@ -81,11 +89,15 @@ function registerModerationChat() {
socket.data.authorized = true; socket.data.authorized = true;
socket.data.username = username; socket.data.username = username;
socket.data.telegramId = telegramId; socket.data.telegramId = telegramId;
socket.data.isOwner = isOwner;
connectedModerators.set(socket.id, { connectedModerators.set(socket.id, {
username, username,
telegramId telegramId,
isOwner
}); });
log('info', 'Mod chat auth success', { username, isOwner, isAdmin });
socket.emit('ready'); socket.emit('ready');
broadcastOnline(); broadcastOnline();
}); });

View File

@ -131,10 +131,7 @@ export default function App() {
} finally { } finally {
if (!cancelled) { if (!cancelled) {
setLoading(false); 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 () => { return () => {
cancelled = true; cancelled = true;
window.Telegram?.WebApp?.MainButton?.hide?.();
}; };
}, []); }, []);
@ -645,11 +641,6 @@ export default function App() {
<h1>Nakama Moderation</h1> <h1>Nakama Moderation</h1>
<span className="subtitle">@{user.username}</span> <span className="subtitle">@{user.username}</span>
</div> </div>
<div className="header-actions">
<button className="btn" onClick={() => window.Telegram?.WebApp?.close()}>
Закрыть
</button>
</div>
</header> </header>
<nav className="tabbar"> <nav className="tabbar">