Compare commits
10 Commits
fc7c4c6561
...
b4ca4f2d9e
| Author | SHA1 | Date |
|---|---|---|
|
|
b4ca4f2d9e | |
|
|
f9ff325c8a | |
|
|
2bb573b919 | |
|
|
16f6130e98 | |
|
|
8c81bac776 | |
|
|
d83399e5f9 | |
|
|
a4bae70823 | |
|
|
08ab000290 | |
|
|
c3f2746723 | |
|
|
b6036af3f1 |
|
|
@ -0,0 +1,146 @@
|
||||||
|
# Обновление: Управление админами и исправления
|
||||||
|
|
||||||
|
## ✅ Что сделано
|
||||||
|
|
||||||
|
### 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 подключения
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -52,3 +52,4 @@ pm2 logs nakama-backend
|
||||||
|
|
||||||
После перезапуска ошибок 401 быть не должно!
|
После перезапуска ошибок 401 быть не должно!
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,3 +75,4 @@ ssh root@ваш_IP
|
||||||
pm2 restart nakama-backend
|
pm2 restart nakama-backend
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -225,3 +225,4 @@ git pull
|
||||||
|
|
||||||
Все баги исправлены, приложение стабильно.
|
Все баги исправлены, приложение стабильно.
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,3 +92,4 @@ npm run build
|
||||||
|
|
||||||
**Версия**: v2.1.2 (Dark theme visibility fix)
|
**Версия**: v2.1.2 (Dark theme visibility fix)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -95,3 +95,4 @@ https://nakama.glpshchn.ru
|
||||||
|
|
||||||
🎉 ГОТОВО!
|
🎉 ГОТОВО!
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,3 +77,4 @@ npm run build
|
||||||
|
|
||||||
Все проблемы с комментариями исправлены!
|
Все проблемы с комментариями исправлены!
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -211,3 +211,4 @@ mongosh nakama --eval 'db.posts.findOne({}, {reposts: 1})'
|
||||||
|
|
||||||
После обновления на сервере всё должно работать идеально! 🚀
|
После обновления на сервере всё должно работать идеально! 🚀
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,3 +62,4 @@ npm run build
|
||||||
|
|
||||||
Комментарии больше не будут прыгать при фокусе на поле ввода!
|
Комментарии больше не будут прыгать при фокусе на поле ввода!
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,3 +46,4 @@ scp nakama-fix.tar.gz root@IP:/tmp/
|
||||||
# Далее как в UPLOAD_TO_SERVER.md
|
# Далее как в UPLOAD_TO_SERVER.md
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -108,3 +108,4 @@ update-server.sh - Автоматический скрипт
|
||||||
Следуйте 3 шагам выше и приложение заработает идеально на:
|
Следуйте 3 шагам выше и приложение заработает идеально на:
|
||||||
https://nakama.glpshchn.ru
|
https://nakama.glpshchn.ru
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -122,3 +122,4 @@ QUICKSTART.md - Быстрый старт
|
||||||
║ Успехов! 🚀🦊🎌 ║
|
║ Успехов! 🚀🦊🎌 ║
|
||||||
╚═══════════════════════════════════════════════════════════════════════╝
|
╚═══════════════════════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -117,3 +117,4 @@ FRONTEND_URL=https://nakama.glpshchn.ru
|
||||||
- [Telegram Bot API](https://core.telegram.org/bots/api)
|
- [Telegram Bot API](https://core.telegram.org/bots/api)
|
||||||
- [BotFather](https://t.me/BotFather)
|
- [BotFather](https://t.me/BotFather)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,3 +41,4 @@ npm run build
|
||||||
|
|
||||||
Backend перезапускать НЕ нужно!
|
Backend перезапускать НЕ нужно!
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,3 +89,4 @@ npm run build
|
||||||
✅ Активная кнопка - белая с синей рамкой
|
✅ Активная кнопка - белая с синей рамкой
|
||||||
✅ Всё работает идеально
|
✅ Всё работает идеально
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -198,3 +198,4 @@ mongosh nakama --eval 'db.posts.findOne()'
|
||||||
|
|
||||||
Теперь приложение работает стабильно на https://nakama.glpshchn.ru
|
Теперь приложение работает стабильно на https://nakama.glpshchn.ru
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -139,3 +139,4 @@ sudo systemctl status mongod
|
||||||
|
|
||||||
После выполнения этих шагов все исправления будут применены на https://nakama.glpshchn.ru
|
После выполнения этих шагов все исправления будут применены на https://nakama.glpshchn.ru
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,3 +55,4 @@
|
||||||
**Домен**: nakama.glpshchn.ru
|
**Домен**: nakama.glpshchn.ru
|
||||||
**Последнее обновление**: 03.11.2025
|
**Последнее обновление**: 03.11.2025
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -249,10 +249,7 @@ const handleCommand = async (message) => {
|
||||||
const command = args[0].toLowerCase();
|
const command = args[0].toLowerCase();
|
||||||
|
|
||||||
if (command === '/start') {
|
if (command === '/start') {
|
||||||
await sendMessage(
|
await sendMessage(chatId, '✅ Бот активен');
|
||||||
chatId,
|
|
||||||
'<b>NakamaHost Moderation</b>\nКоманды:\n• /load — состояние сервера\n• /admins — список админов\n• /addadmin @username — добавить админа (только владельцы)\n• /removeadmin @username — убрать админа (только владельцы)'
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -293,9 +290,7 @@ const handleCommand = async (message) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command.startsWith('/')) {
|
// Игнорируем неизвестные команды
|
||||||
await sendMessage(chatId, 'Неизвестная команда. Используйте /start, /load, /admins.');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const processUpdate = async (update) => {
|
const processUpdate = async (update) => {
|
||||||
|
|
@ -394,9 +389,31 @@ const sendChannelMediaGroup = async (files, caption) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отправить сообщение пользователю
|
||||||
|
*/
|
||||||
|
const sendMessageToUser = async (userId, message) => {
|
||||||
|
if (!TELEGRAM_API) {
|
||||||
|
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
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,3 +59,4 @@ console.log('\n💡 Для PM2 нужно использовать:');
|
||||||
console.log(' pm2 restart nakama-backend --update-env');
|
console.log(' pm2 restart nakama-backend --update-env');
|
||||||
console.log(' или добавить в ecosystem.config.js');
|
console.log(' или добавить в ecosystem.config.js');
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -122,3 +122,4 @@ module.exports = {
|
||||||
updateAllUserAvatars
|
updateAllUserAvatars
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -142,8 +142,87 @@ const requireAdmin = (req, res, next) => {
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Middleware для модерации (использует MODERATION_BOT_TOKEN)
|
||||||
|
const authenticateModeration = async (req, res, next) => {
|
||||||
|
const config = require('../config');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization || '';
|
||||||
|
let initDataRaw = null;
|
||||||
|
|
||||||
|
if (authHeader.startsWith('tma ')) {
|
||||||
|
initDataRaw = authHeader.slice(4).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!initDataRaw) {
|
||||||
|
const headerInitData = req.headers['x-telegram-init-data'];
|
||||||
|
if (headerInitData && typeof headerInitData === 'string') {
|
||||||
|
initDataRaw = headerInitData.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!initDataRaw) {
|
||||||
|
logSecurityEvent('AUTH_TOKEN_MISSING', req);
|
||||||
|
return res.status(401).json({ error: OFFICIAL_CLIENT_MESSAGE });
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use MODERATION_BOT_TOKEN for validation
|
||||||
|
payload = validateAndParseInitData(initDataRaw, config.moderationBotToken);
|
||||||
|
} catch (error) {
|
||||||
|
logSecurityEvent('INVALID_INITDATA', req, { reason: error.message });
|
||||||
|
return res.status(401).json({ error: `${error.message}. ${OFFICIAL_CLIENT_MESSAGE}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const telegramUser = payload.user;
|
||||||
|
|
||||||
|
if (!validateTelegramId(telegramUser.id)) {
|
||||||
|
logSecurityEvent('INVALID_TELEGRAM_ID', req, { telegramId: telegramUser.id });
|
||||||
|
return res.status(401).json({ error: 'Неверный ID пользователя' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = await User.findOne({ telegramId: telegramUser.id.toString() });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
user = new User({
|
||||||
|
telegramId: telegramUser.id.toString(),
|
||||||
|
username: telegramUser.username || telegramUser.first_name,
|
||||||
|
firstName: telegramUser.first_name,
|
||||||
|
lastName: telegramUser.last_name,
|
||||||
|
photoUrl: telegramUser.photo_url
|
||||||
|
});
|
||||||
|
await user.save();
|
||||||
|
} else {
|
||||||
|
user.username = telegramUser.username || telegramUser.first_name;
|
||||||
|
user.firstName = telegramUser.first_name;
|
||||||
|
user.lastName = telegramUser.last_name;
|
||||||
|
if (telegramUser.photo_url) {
|
||||||
|
user.photoUrl = telegramUser.photo_url;
|
||||||
|
}
|
||||||
|
await user.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.banned) {
|
||||||
|
return res.status(403).json({ error: 'Пользователь заблокирован' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureUserSettings(user);
|
||||||
|
await touchUserActivity(user);
|
||||||
|
|
||||||
|
req.user = user;
|
||||||
|
req.telegramUser = telegramUser;
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Ошибка авторизации модерации:', error);
|
||||||
|
res.status(401).json({ error: `Ошибка авторизации. ${OFFICIAL_CLIENT_MESSAGE}` });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
authenticate,
|
authenticate,
|
||||||
|
authenticateModeration,
|
||||||
requireModerator,
|
requireModerator,
|
||||||
requireAdmin,
|
requireAdmin,
|
||||||
touchUserActivity,
|
touchUserActivity,
|
||||||
|
|
|
||||||
|
|
@ -154,3 +154,4 @@ module.exports = {
|
||||||
validateImageUrl
|
validateImageUrl
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const AdminConfirmationSchema = new mongoose.Schema({
|
||||||
|
userId: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
index: true
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
adminNumber: {
|
||||||
|
type: Number,
|
||||||
|
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);
|
||||||
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -221,35 +221,9 @@ router.post('/oauth', strictAuthLimiter, async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Проверка авторизации и получение данных пользователя
|
|
||||||
const { authenticate } = require('../middleware/auth');
|
|
||||||
|
|
||||||
router.post('/verify', authenticate, async (req, res) => {
|
router.post('/verify', authenticate, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const user = await req.user.populate([
|
return respondWithUser(req.user, res);
|
||||||
{ path: 'followers', select: 'username firstName lastName photoUrl' },
|
|
||||||
{ path: 'following', select: 'username firstName lastName photoUrl' }
|
|
||||||
]);
|
|
||||||
|
|
||||||
const settings = normalizeUserSettings(user.settings);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
user: {
|
|
||||||
id: user._id,
|
|
||||||
telegramId: user.telegramId,
|
|
||||||
username: user.username,
|
|
||||||
firstName: user.firstName,
|
|
||||||
lastName: user.lastName,
|
|
||||||
photoUrl: user.photoUrl,
|
|
||||||
bio: user.bio,
|
|
||||||
role: user.role,
|
|
||||||
followersCount: user.followers.length,
|
|
||||||
followingCount: user.following.length,
|
|
||||||
settings,
|
|
||||||
banned: user.banned
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка verify:', error);
|
console.error('Ошибка verify:', error);
|
||||||
res.status(500).json({ error: 'Ошибка сервера' });
|
res.status(500).json({ error: 'Ошибка сервера' });
|
||||||
|
|
|
||||||
|
|
@ -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 { authenticate } = require('../middleware/auth');
|
const crypto = require('crypto');
|
||||||
|
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,
|
||||||
|
|
@ -68,7 +80,7 @@ const serializeUser = (user) => ({
|
||||||
createdAt: user.createdAt
|
createdAt: user.createdAt
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/auth/verify', authenticate, requireModerationAccess, async (req, res) => {
|
router.post('/auth/verify', authenticateModeration, requireModerationAccess, async (req, res) => {
|
||||||
const admins = await listAdmins();
|
const admins = await listAdmins();
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|
@ -85,7 +97,7 @@ router.post('/auth/verify', authenticate, requireModerationAccess, async (req, r
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/users', authenticate, requireModerationAccess, async (req, res) => {
|
router.get('/users', authenticateModeration, requireModerationAccess, async (req, res) => {
|
||||||
const { filter = 'active', page = 1, limit = 50 } = req.query;
|
const { filter = 'active', page = 1, limit = 50 } = req.query;
|
||||||
const pageNum = Math.max(parseInt(page, 10) || 1, 1);
|
const pageNum = Math.max(parseInt(page, 10) || 1, 1);
|
||||||
const limitNum = Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200);
|
const limitNum = Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200);
|
||||||
|
|
@ -125,7 +137,7 @@ router.get('/users', authenticate, requireModerationAccess, async (req, res) =>
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/users/:id/ban', authenticate, requireModerationAccess, async (req, res) => {
|
router.put('/users/:id/ban', authenticateModeration, requireModerationAccess, async (req, res) => {
|
||||||
const { banned, days } = req.body;
|
const { banned, days } = req.body;
|
||||||
|
|
||||||
const user = await User.findById(req.params.id);
|
const user = await User.findById(req.params.id);
|
||||||
|
|
@ -145,7 +157,7 @@ router.put('/users/:id/ban', authenticate, requireModerationAccess, async (req,
|
||||||
res.json({ user: serializeUser(user) });
|
res.json({ user: serializeUser(user) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/posts', authenticate, requireModerationAccess, async (req, res) => {
|
router.get('/posts', authenticateModeration, requireModerationAccess, async (req, res) => {
|
||||||
const { page = 1, limit = 20, author, tag } = req.query;
|
const { page = 1, limit = 20, author, tag } = req.query;
|
||||||
const pageNum = Math.max(parseInt(page, 10) || 1, 1);
|
const pageNum = Math.max(parseInt(page, 10) || 1, 1);
|
||||||
const limitNum = Math.min(Math.max(parseInt(limit, 10) || 20, 1), 100);
|
const limitNum = Math.min(Math.max(parseInt(limit, 10) || 20, 1), 100);
|
||||||
|
|
@ -190,7 +202,7 @@ router.get('/posts', authenticate, requireModerationAccess, async (req, res) =>
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/posts/:id', authenticate, requireModerationAccess, async (req, res) => {
|
router.put('/posts/:id', authenticateModeration, requireModerationAccess, async (req, res) => {
|
||||||
const { content, hashtags, tags, isNSFW } = req.body;
|
const { content, hashtags, tags, isNSFW } = req.body;
|
||||||
|
|
||||||
const post = await Post.findById(req.params.id);
|
const post = await Post.findById(req.params.id);
|
||||||
|
|
@ -233,7 +245,7 @@ router.put('/posts/:id', authenticate, requireModerationAccess, async (req, res)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/posts/:id', authenticate, requireModerationAccess, async (req, res) => {
|
router.delete('/posts/:id', authenticateModeration, requireModerationAccess, async (req, res) => {
|
||||||
const post = await Post.findById(req.params.id);
|
const post = await Post.findById(req.params.id);
|
||||||
if (!post) {
|
if (!post) {
|
||||||
return res.status(404).json({ error: 'Пост не найден' });
|
return res.status(404).json({ error: 'Пост не найден' });
|
||||||
|
|
@ -254,7 +266,7 @@ router.delete('/posts/:id', authenticate, requireModerationAccess, async (req, r
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/posts/:id/images/:index', authenticate, requireModerationAccess, async (req, res) => {
|
router.delete('/posts/:id/images/:index', authenticateModeration, requireModerationAccess, async (req, res) => {
|
||||||
const { id, index } = req.params;
|
const { id, index } = req.params;
|
||||||
const idx = parseInt(index, 10);
|
const idx = parseInt(index, 10);
|
||||||
|
|
||||||
|
|
@ -281,7 +293,7 @@ router.delete('/posts/:id/images/:index', authenticate, requireModerationAccess,
|
||||||
res.json({ images: post.images });
|
res.json({ images: post.images });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/posts/:id/ban', authenticate, requireModerationAccess, async (req, res) => {
|
router.post('/posts/:id/ban', authenticateModeration, requireModerationAccess, async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { days = 7 } = req.body;
|
const { days = 7 } = req.body;
|
||||||
|
|
||||||
|
|
@ -298,7 +310,7 @@ router.post('/posts/:id/ban', authenticate, requireModerationAccess, async (req,
|
||||||
res.json({ user: serializeUser(post.author) });
|
res.json({ user: serializeUser(post.author) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/reports', authenticate, requireModerationAccess, async (req, res) => {
|
router.get('/reports', authenticateModeration, requireModerationAccess, async (req, res) => {
|
||||||
const { page = 1, limit = 30, status = 'pending' } = req.query;
|
const { page = 1, limit = 30, status = 'pending' } = req.query;
|
||||||
const pageNum = Math.max(parseInt(page, 10) || 1, 1);
|
const pageNum = Math.max(parseInt(page, 10) || 1, 1);
|
||||||
const limitNum = Math.min(Math.max(parseInt(limit, 10) || 30, 1), 100);
|
const limitNum = Math.min(Math.max(parseInt(limit, 10) || 30, 1), 100);
|
||||||
|
|
@ -345,7 +357,7 @@ router.get('/reports', authenticate, requireModerationAccess, async (req, res) =
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/reports/:id', authenticate, requireModerationAccess, async (req, res) => {
|
router.put('/reports/:id', authenticateModeration, requireModerationAccess, async (req, res) => {
|
||||||
const { status = 'reviewed' } = req.body;
|
const { status = 'reviewed' } = req.body;
|
||||||
const report = await Report.findById(req.params.id);
|
const report = await Report.findById(req.params.id);
|
||||||
|
|
||||||
|
|
@ -360,20 +372,290 @@ router.put('/reports/:id', authenticate, requireModerationAccess, async (req, re
|
||||||
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'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Отправить код владельцу (req.user - это ты)
|
||||||
|
await sendMessageToUser(
|
||||||
|
req.user.telegramId,
|
||||||
|
`<b>Подтверждение назначения админом</b>\n\n` +
|
||||||
|
`Назначаете пользователя @${user.username} (${user.firstName}) админом.\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'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Отправить код владельцу (req.user - это ты)
|
||||||
|
await sendMessageToUser(
|
||||||
|
req.user.telegramId,
|
||||||
|
`<b>Подтверждение снятия админа</b>\n\n` +
|
||||||
|
`Снимаете пользователя @${admin.username} (${admin.firstName}) с должности админа.\n` +
|
||||||
|
`Номер админа: <b>${admin.adminNumber}</b>\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',
|
||||||
authenticate,
|
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()) {
|
||||||
|
|
|
||||||
|
|
@ -102,3 +102,4 @@ try {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -87,3 +87,4 @@ module.exports = {
|
||||||
normalizeUsername
|
normalizeUsername
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,60 @@
|
||||||
const { parse, isValid } = require('@telegram-apps/init-data-node');
|
const { parse, validate } = require('@telegram-apps/init-data-node');
|
||||||
|
const crypto = require('crypto');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
|
|
||||||
const MAX_AUTH_AGE_SECONDS = 5 * 60;
|
const MAX_AUTH_AGE_SECONDS = 60 * 60; // 1 час
|
||||||
|
|
||||||
function validateAndParseInitData(initDataRaw) {
|
/**
|
||||||
if (!config.telegramBotToken) {
|
* Manual validation with base64 padding fix
|
||||||
throw new Error('TELEGRAM_BOT_TOKEN не настроен');
|
* Based on: https://docs.telegram-mini-apps.com/platform/init-data
|
||||||
|
*/
|
||||||
|
function manualValidateInitData(initDataRaw, botToken) {
|
||||||
|
const params = new URLSearchParams(initDataRaw);
|
||||||
|
const hash = params.get('hash');
|
||||||
|
|
||||||
|
if (!hash) {
|
||||||
|
throw new Error('Отсутствует hash в initData');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove hash from params
|
||||||
|
params.delete('hash');
|
||||||
|
|
||||||
|
// Create data check string
|
||||||
|
const dataCheckArr = Array.from(params.entries())
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([key, value]) => `${key}=${value}`);
|
||||||
|
|
||||||
|
const dataCheckString = dataCheckArr.join('\n');
|
||||||
|
|
||||||
|
// Create secret key
|
||||||
|
const secretKey = crypto
|
||||||
|
.createHmac('sha256', 'WebAppData')
|
||||||
|
.update(botToken)
|
||||||
|
.digest();
|
||||||
|
|
||||||
|
// Create signature
|
||||||
|
const signature = crypto
|
||||||
|
.createHmac('sha256', secretKey)
|
||||||
|
.update(dataCheckString)
|
||||||
|
.digest('hex');
|
||||||
|
|
||||||
|
// Compare signatures
|
||||||
|
return signature === hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAndParseInitData(initDataRaw, botToken = null) {
|
||||||
|
const tokenToUse = botToken || config.telegramBotToken;
|
||||||
|
|
||||||
|
console.log('[Telegram] validateAndParseInitData called:', {
|
||||||
|
hasInitData: !!initDataRaw,
|
||||||
|
type: typeof initDataRaw,
|
||||||
|
length: initDataRaw?.length || 0,
|
||||||
|
preview: initDataRaw?.substring(0, 100) + '...',
|
||||||
|
usingModerationToken: !!botToken
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tokenToUse) {
|
||||||
|
throw new Error('Bot token не настроен');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!initDataRaw || typeof initDataRaw !== 'string') {
|
if (!initDataRaw || typeof initDataRaw !== 'string') {
|
||||||
|
|
@ -18,29 +67,89 @@ function validateAndParseInitData(initDataRaw) {
|
||||||
throw new Error('initData пуст');
|
throw new Error('initData пуст');
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = isValid(trimmed, config.telegramBotToken);
|
console.log('[Telegram] Validating initData with bot token...');
|
||||||
|
|
||||||
|
// Try library validation first
|
||||||
|
let valid = false;
|
||||||
|
try {
|
||||||
|
validate(trimmed, tokenToUse);
|
||||||
|
valid = true;
|
||||||
|
console.log('[Telegram] Library validation successful');
|
||||||
|
} catch (libError) {
|
||||||
|
console.log('[Telegram] Library validation failed, trying manual validation:', libError.message);
|
||||||
|
|
||||||
|
// Fallback to manual validation with base64 padding fix
|
||||||
|
try {
|
||||||
|
valid = manualValidateInitData(trimmed, tokenToUse);
|
||||||
|
if (valid) {
|
||||||
|
console.log('[Telegram] Manual validation successful');
|
||||||
|
}
|
||||||
|
} catch (manualError) {
|
||||||
|
console.error('[Telegram] Manual validation also failed:', manualError.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
|
console.error('[Telegram] All validation attempts failed');
|
||||||
throw new Error('Неверная подпись initData');
|
throw new Error('Неверная подпись initData');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[Telegram] initData validation successful, parsing...');
|
||||||
|
|
||||||
const payload = parse(trimmed);
|
const payload = parse(trimmed);
|
||||||
|
|
||||||
|
console.log('[Telegram] Parsed payload:', {
|
||||||
|
hasUser: !!payload?.user,
|
||||||
|
userId: payload?.user?.id,
|
||||||
|
auth_date: payload?.auth_date,
|
||||||
|
authDate: payload?.authDate,
|
||||||
|
allKeys: Object.keys(payload),
|
||||||
|
fullPayload: JSON.stringify(payload, null, 2)
|
||||||
|
});
|
||||||
|
|
||||||
if (!payload || !payload.user) {
|
if (!payload || !payload.user) {
|
||||||
throw new Error('Отсутствует пользователь в initData');
|
throw new Error('Отсутствует пользователь в initData');
|
||||||
}
|
}
|
||||||
|
|
||||||
const authDate = Number(payload.auth_date);
|
// Check if this is signature-based validation (Ed25519) or hash-based (HMAC-SHA256)
|
||||||
|
const hasSignature = 'signature' in payload;
|
||||||
|
const hasHash = 'hash' in payload;
|
||||||
|
|
||||||
|
console.log('[Telegram] Validation method:', {
|
||||||
|
hasSignature,
|
||||||
|
hasHash,
|
||||||
|
method: hasSignature ? 'Ed25519 (signature)' : 'HMAC-SHA256 (hash)'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only check auth_date for hash-based validation (old method)
|
||||||
|
// Signature-based validation (new method) doesn't include auth_date
|
||||||
|
if (hasHash && !hasSignature) {
|
||||||
|
const authDate = Number(payload.authDate || payload.auth_date);
|
||||||
|
|
||||||
if (!authDate) {
|
if (!authDate) {
|
||||||
|
console.error('[Telegram] Missing authDate in hash-based payload:', payload);
|
||||||
throw new Error('Отсутствует auth_date в initData');
|
throw new Error('Отсутствует auth_date в initData');
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const age = Math.abs(now - authDate);
|
||||||
|
|
||||||
if (Math.abs(now - authDate) > MAX_AUTH_AGE_SECONDS) {
|
console.log('[Telegram] Auth date check:', {
|
||||||
throw new Error('Данные авторизации устарели');
|
authDate,
|
||||||
|
now,
|
||||||
|
age,
|
||||||
|
maxAge: MAX_AUTH_AGE_SECONDS,
|
||||||
|
expired: age > MAX_AUTH_AGE_SECONDS
|
||||||
|
});
|
||||||
|
|
||||||
|
if (age > MAX_AUTH_AGE_SECONDS) {
|
||||||
|
throw new Error(`Данные авторизации устарели (возраст: ${age}с, макс: ${MAX_AUTH_AGE_SECONDS}с)`);
|
||||||
}
|
}
|
||||||
|
} else if (hasSignature) {
|
||||||
|
console.log('[Telegram] Signature-based validation detected, skipping auth_date check');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Telegram] initData validation complete');
|
||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,21 +6,8 @@
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<title>NakamaHost</title>
|
<title>NakamaHost</title>
|
||||||
<script>
|
<!-- Telegram Web App SDK - прямая загрузка -->
|
||||||
(function () {
|
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||||
if (window.Telegram && window.Telegram.WebApp) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.src = 'https://telegram.org/js/telegram-web-app.js';
|
|
||||||
script.onload = () => {
|
|
||||||
if (window.Telegram && window.Telegram.WebApp) {
|
|
||||||
window.Telegram.WebApp.ready?.();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.head.appendChild(script);
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
<style>
|
<style>
|
||||||
/* Предотвращение resize при открытии клавиатуры */
|
/* Предотвращение resize при открытии клавиатуры */
|
||||||
html, body {
|
html, body {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useState, useEffect, useRef } from 'react'
|
||||||
import { initTelegramApp } from './utils/telegram'
|
import { initTelegramApp } from './utils/telegram'
|
||||||
import { verifyAuth } from './utils/api'
|
import { verifyAuth } from './utils/api'
|
||||||
import { initTheme } from './utils/theme'
|
import { initTheme } from './utils/theme'
|
||||||
|
import { startInitDataChecker, stopInitDataChecker } from './utils/initDataChecker'
|
||||||
import Layout from './components/Layout'
|
import Layout from './components/Layout'
|
||||||
import Feed from './pages/Feed'
|
import Feed from './pages/Feed'
|
||||||
import Search from './pages/Search'
|
import Search from './pages/Search'
|
||||||
|
|
@ -28,31 +29,30 @@ function AppContent() {
|
||||||
initAppCalled.current = true
|
initAppCalled.current = true
|
||||||
initApp()
|
initApp()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Запустить проверку initData
|
||||||
|
startInitDataChecker()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopInitDataChecker()
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const waitForInitData = async () => {
|
|
||||||
const start = Date.now()
|
|
||||||
const timeout = 5000
|
|
||||||
|
|
||||||
while (Date.now() - start < timeout) {
|
|
||||||
const tg = window.Telegram?.WebApp
|
|
||||||
if (tg?.initData && tg.initData.length > 0) {
|
|
||||||
return tg
|
|
||||||
}
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100))
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Telegram не передал initData. Откройте приложение в официальном клиенте.')
|
|
||||||
}
|
|
||||||
|
|
||||||
const initApp = async () => {
|
const initApp = async () => {
|
||||||
try {
|
try {
|
||||||
initTelegramApp()
|
initTelegramApp()
|
||||||
|
|
||||||
const tg = await waitForInitData()
|
const tg = window.Telegram?.WebApp
|
||||||
|
|
||||||
|
if (!tg) {
|
||||||
|
throw new Error('Откройте приложение из Telegram.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tg.initData) {
|
||||||
|
throw new Error('Telegram не передал initData. Откройте приложение из официального клиента.')
|
||||||
|
}
|
||||||
|
|
||||||
tg.disableVerticalSwipes?.()
|
tg.disableVerticalSwipes?.()
|
||||||
tg.ready?.()
|
|
||||||
tg.expand?.()
|
tg.expand?.()
|
||||||
|
|
||||||
const userData = await verifyAuth()
|
const userData = await verifyAuth()
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,14 @@ import ReactDOM from 'react-dom/client'
|
||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
import './styles/index.css'
|
import './styles/index.css'
|
||||||
|
|
||||||
|
// Убедиться, что Telegram Web App инициализирован
|
||||||
|
if (window.Telegram?.WebApp) {
|
||||||
|
window.Telegram.WebApp.ready();
|
||||||
|
console.log('[Nakama] Telegram WebApp initialized');
|
||||||
|
} else {
|
||||||
|
console.error('[Nakama] Telegram WebApp not found!');
|
||||||
|
}
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,15 @@ const api = axios.create({
|
||||||
|
|
||||||
api.interceptors.request.use((config) => {
|
api.interceptors.request.use((config) => {
|
||||||
const initData = window.Telegram?.WebApp?.initData;
|
const initData = window.Telegram?.WebApp?.initData;
|
||||||
|
|
||||||
|
console.log('[API] Request interceptor:', {
|
||||||
|
url: config.url,
|
||||||
|
method: config.method,
|
||||||
|
hasInitData: !!initData,
|
||||||
|
initDataLength: initData?.length || 0,
|
||||||
|
initDataPreview: initData?.substring(0, 50) + '...'
|
||||||
|
});
|
||||||
|
|
||||||
if (initData) {
|
if (initData) {
|
||||||
config.headers = config.headers || {};
|
config.headers = config.headers || {};
|
||||||
if (!config.headers.Authorization) {
|
if (!config.headers.Authorization) {
|
||||||
|
|
@ -26,10 +35,43 @@ api.interceptors.request.use((config) => {
|
||||||
if (!config.headers['x-telegram-init-data']) {
|
if (!config.headers['x-telegram-init-data']) {
|
||||||
config.headers['x-telegram-init-data'] = initData;
|
config.headers['x-telegram-init-data'] = initData;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('[API] No initData available for request:', config.url);
|
||||||
}
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Response interceptor для обработки устаревших токенов
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
const status = error?.response?.status;
|
||||||
|
const errorMessage = error?.response?.data?.error || '';
|
||||||
|
|
||||||
|
// Если токен устарел или невалиден - перезагрузить приложение
|
||||||
|
if (status === 401 && (
|
||||||
|
errorMessage.includes('устарели') ||
|
||||||
|
errorMessage.includes('expired') ||
|
||||||
|
errorMessage.includes('Неверная подпись')
|
||||||
|
)) {
|
||||||
|
console.warn('[API] Auth token expired or invalid, reloading app...');
|
||||||
|
|
||||||
|
// Показать уведомление пользователю
|
||||||
|
const tg = window.Telegram?.WebApp;
|
||||||
|
if (tg?.showAlert) {
|
||||||
|
tg.showAlert('Сессия устарела. Перезагрузка...', () => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setTimeout(() => window.location.reload(), 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Auth API
|
// Auth API
|
||||||
export const signInWithTelegram = async (initData) => {
|
export const signInWithTelegram = async (initData) => {
|
||||||
const response = await api.post('/auth/signin', { initData })
|
const response = await api.post('/auth/signin', { initData })
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
/**
|
||||||
|
* Утилита для проверки свежести initData
|
||||||
|
* Предотвращает запросы с устаревшими токенами
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MAX_INIT_DATA_AGE = 55 * 60 * 1000; // 55 минут (до истечения часа на бекенде)
|
||||||
|
const CHECK_INTERVAL = 5 * 60 * 1000; // Проверять каждые 5 минут
|
||||||
|
|
||||||
|
let checkTimer = null;
|
||||||
|
let lastReloadTime = Date.now();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Извлекает auth_date из initData
|
||||||
|
*/
|
||||||
|
function extractAuthDate(initData) {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams(initData);
|
||||||
|
const authDateStr = params.get('auth_date');
|
||||||
|
return authDateStr ? parseInt(authDateStr, 10) : null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[InitDataChecker] Failed to parse auth_date:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, устарел ли initData
|
||||||
|
*/
|
||||||
|
function isInitDataExpired(initData) {
|
||||||
|
const authDate = extractAuthDate(initData);
|
||||||
|
if (!authDate) return false;
|
||||||
|
|
||||||
|
const authTime = authDate * 1000; // Конвертируем в миллисекунды
|
||||||
|
const age = Date.now() - authTime;
|
||||||
|
|
||||||
|
return age > MAX_INIT_DATA_AGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Перезагружает приложение, если initData устарел
|
||||||
|
*/
|
||||||
|
function checkAndReloadIfNeeded() {
|
||||||
|
const tg = window.Telegram?.WebApp;
|
||||||
|
const initData = tg?.initData;
|
||||||
|
|
||||||
|
if (!initData) {
|
||||||
|
console.warn('[InitDataChecker] No initData available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInitDataExpired(initData)) {
|
||||||
|
console.warn('[InitDataChecker] InitData expired, reloading...');
|
||||||
|
|
||||||
|
// Предотвращаем частые перезагрузки (не чаще раза в минуту)
|
||||||
|
const timeSinceLastReload = Date.now() - lastReloadTime;
|
||||||
|
if (timeSinceLastReload < 60 * 1000) {
|
||||||
|
console.warn('[InitDataChecker] Recent reload detected, skipping...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastReloadTime = Date.now();
|
||||||
|
|
||||||
|
if (tg?.showAlert) {
|
||||||
|
tg.showAlert('Обновление сессии...', () => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запускает периодическую проверку initData
|
||||||
|
*/
|
||||||
|
export function startInitDataChecker() {
|
||||||
|
// Проверить сразу
|
||||||
|
checkAndReloadIfNeeded();
|
||||||
|
|
||||||
|
// Проверять периодически
|
||||||
|
if (checkTimer) {
|
||||||
|
clearInterval(checkTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
checkTimer = setInterval(checkAndReloadIfNeeded, CHECK_INTERVAL);
|
||||||
|
|
||||||
|
console.log('[InitDataChecker] Started with interval:', CHECK_INTERVAL / 1000, 'seconds');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Останавливает проверку
|
||||||
|
*/
|
||||||
|
export function stopInitDataChecker() {
|
||||||
|
if (checkTimer) {
|
||||||
|
clearInterval(checkTimer);
|
||||||
|
checkTimer = null;
|
||||||
|
console.log('[InitDataChecker] Stopped');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает информацию о текущем состоянии initData
|
||||||
|
*/
|
||||||
|
export function getInitDataInfo() {
|
||||||
|
const tg = window.Telegram?.WebApp;
|
||||||
|
const initData = tg?.initData;
|
||||||
|
|
||||||
|
if (!initData) {
|
||||||
|
return { available: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const authDate = extractAuthDate(initData);
|
||||||
|
if (!authDate) {
|
||||||
|
return { available: true, valid: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const authTime = authDate * 1000;
|
||||||
|
const age = Date.now() - authTime;
|
||||||
|
const expired = age > MAX_INIT_DATA_AGE;
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
valid: true,
|
||||||
|
authDate: new Date(authTime),
|
||||||
|
age: Math.floor(age / 1000), // в секундах
|
||||||
|
expired,
|
||||||
|
remainingTime: expired ? 0 : Math.floor((MAX_INIT_DATA_AGE - age) / 1000) // в секундах
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -5,21 +5,8 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
<title>Nakama Moderation</title>
|
<title>Nakama Moderation</title>
|
||||||
<script>
|
<!-- Telegram Web App SDK - прямая загрузка -->
|
||||||
(function () {
|
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||||
if (window.Telegram && window.Telegram.WebApp) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.src = 'https://telegram.org/js/telegram-web-app.js';
|
|
||||||
script.onload = () => {
|
|
||||||
if (window.Telegram && window.Telegram.WebApp) {
|
|
||||||
window.Telegram.WebApp.ready?.();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.head.appendChild(script);
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -23,3 +23,4 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,12 @@ import {
|
||||||
banPostAuthor,
|
banPostAuthor,
|
||||||
fetchReports,
|
fetchReports,
|
||||||
updateReportStatus,
|
updateReportStatus,
|
||||||
publishToChannel
|
publishToChannel,
|
||||||
|
fetchAdmins,
|
||||||
|
initiateAddAdmin,
|
||||||
|
confirmAddAdmin,
|
||||||
|
initiateRemoveAdmin,
|
||||||
|
confirmRemoveAdmin
|
||||||
} from './utils/api';
|
} from './utils/api';
|
||||||
import { io } from 'socket.io-client';
|
import { io } from 'socket.io-client';
|
||||||
import {
|
import {
|
||||||
|
|
@ -23,13 +28,17 @@ import {
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Trash2,
|
Trash2,
|
||||||
Edit,
|
Edit,
|
||||||
Ban
|
Ban,
|
||||||
|
UserPlus,
|
||||||
|
UserMinus,
|
||||||
|
Crown
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'users', title: 'Пользователи', icon: Users },
|
{ id: 'users', title: 'Пользователи', icon: Users },
|
||||||
{ id: 'posts', title: 'Посты', icon: ImageIcon },
|
{ id: 'posts', title: 'Посты', icon: ImageIcon },
|
||||||
{ id: 'reports', title: 'Репорты', icon: ShieldCheck },
|
{ id: 'reports', title: 'Репорты', icon: ShieldCheck },
|
||||||
|
{ id: 'admins', title: 'Админы', icon: Crown },
|
||||||
{ id: 'chat', title: 'Чат', icon: MessageSquare },
|
{ id: 'chat', title: 'Чат', icon: MessageSquare },
|
||||||
{ id: 'publish', title: 'Публикация', icon: SendHorizontal }
|
{ id: 'publish', title: 'Публикация', icon: SendHorizontal }
|
||||||
];
|
];
|
||||||
|
|
@ -91,6 +100,12 @@ export default function App() {
|
||||||
});
|
});
|
||||||
const [publishing, setPublishing] = useState(false);
|
const [publishing, setPublishing] = useState(false);
|
||||||
|
|
||||||
|
// Admins
|
||||||
|
const [adminsData, setAdminsData] = useState({ admins: [] });
|
||||||
|
const [adminsLoading, setAdminsLoading] = useState(false);
|
||||||
|
const [adminModal, setAdminModal] = useState(null); // { action: 'add'|'remove', user/admin, adminNumber }
|
||||||
|
const [confirmCode, setConfirmCode] = useState('');
|
||||||
|
|
||||||
// Chat
|
// Chat
|
||||||
const [chatState, setChatState] = useState(initialChatState);
|
const [chatState, setChatState] = useState(initialChatState);
|
||||||
const [chatInput, setChatInput] = useState('');
|
const [chatInput, setChatInput] = useState('');
|
||||||
|
|
@ -100,20 +115,6 @@ export default function App() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const waitForInitData = async () => {
|
|
||||||
const start = Date.now();
|
|
||||||
const timeout = 5000;
|
|
||||||
|
|
||||||
while (Date.now() - start < timeout) {
|
|
||||||
const app = window.Telegram?.WebApp;
|
|
||||||
if (app?.initData && app.initData.length > 0) {
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
}
|
|
||||||
throw new Error('Telegram initData не передан (timeout)');
|
|
||||||
};
|
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
try {
|
try {
|
||||||
const telegramApp = window.Telegram?.WebApp;
|
const telegramApp = window.Telegram?.WebApp;
|
||||||
|
|
@ -122,12 +123,12 @@ export default function App() {
|
||||||
throw new Error('Откройте модераторский интерфейс из Telegram (бот @rbachbot).');
|
throw new Error('Откройте модераторский интерфейс из Telegram (бот @rbachbot).');
|
||||||
}
|
}
|
||||||
|
|
||||||
telegramApp.disableVerticalSwipes?.();
|
if (!telegramApp.initData) {
|
||||||
telegramApp.ready?.();
|
throw new Error('Telegram не передал initData. Откройте приложение из официального клиента.');
|
||||||
telegramApp.expand?.();
|
}
|
||||||
|
|
||||||
const app = await waitForInitData();
|
telegramApp.disableVerticalSwipes?.();
|
||||||
if (cancelled) return;
|
telegramApp.expand?.();
|
||||||
|
|
||||||
const userData = await verifyAuth();
|
const userData = await verifyAuth();
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
@ -145,10 +146,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());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -157,7 +155,6 @@ export default function App() {
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.Telegram?.WebApp?.MainButton?.hide?.();
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -168,6 +165,11 @@ export default function App() {
|
||||||
loadPosts();
|
loadPosts();
|
||||||
} else if (tab === 'reports') {
|
} else if (tab === 'reports') {
|
||||||
loadReports();
|
loadReports();
|
||||||
|
} else if (tab === 'admins') {
|
||||||
|
loadAdmins();
|
||||||
|
} else if (tab === 'publish') {
|
||||||
|
// Загрузить список админов для проверки прав публикации
|
||||||
|
loadAdmins();
|
||||||
} else if (tab === 'chat' && user) {
|
} else if (tab === 'chat' && user) {
|
||||||
initChat();
|
initChat();
|
||||||
}
|
}
|
||||||
|
|
@ -218,10 +220,77 @@ export default function App() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadAdmins = async () => {
|
||||||
|
setAdminsLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchAdmins();
|
||||||
|
setAdminsData(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setAdminsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInitiateAddAdmin = async (targetUser, adminNumber) => {
|
||||||
|
try {
|
||||||
|
const result = await initiateAddAdmin(targetUser.id, adminNumber);
|
||||||
|
alert(`Код отправлен ${result.username}. Попросите пользователя ввести код.`);
|
||||||
|
setAdminModal({ action: 'add', user: targetUser, adminNumber });
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.response?.data?.error || 'Ошибка отправки кода');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmAddAdmin = async () => {
|
||||||
|
if (!adminModal || !confirmCode) return;
|
||||||
|
try {
|
||||||
|
await confirmAddAdmin(adminModal.user.id, confirmCode);
|
||||||
|
alert(`Админ ${adminModal.user.username} добавлен!`);
|
||||||
|
setAdminModal(null);
|
||||||
|
setConfirmCode('');
|
||||||
|
loadAdmins();
|
||||||
|
loadUsers();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.response?.data?.error || 'Ошибка подтверждения');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInitiateRemoveAdmin = async (admin) => {
|
||||||
|
try {
|
||||||
|
const result = await initiateRemoveAdmin(admin.id);
|
||||||
|
alert(`Код отправлен ${result.username}. Попросите админа ввести код.`);
|
||||||
|
setAdminModal({ action: 'remove', admin });
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.response?.data?.error || 'Ошибка отправки кода');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmRemoveAdmin = async () => {
|
||||||
|
if (!adminModal || !confirmCode) return;
|
||||||
|
try {
|
||||||
|
await confirmRemoveAdmin(adminModal.admin.id, confirmCode);
|
||||||
|
alert(`Админ ${adminModal.admin.username} удалён!`);
|
||||||
|
setAdminModal(null);
|
||||||
|
setConfirmCode('');
|
||||||
|
loadAdmins();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.response?.data?.error || 'Ошибка подтверждения');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const initChat = () => {
|
const initChat = () => {
|
||||||
if (!user || chatSocketRef.current) return;
|
if (!user || chatSocketRef.current) return;
|
||||||
const socket = io('/mod-chat', {
|
|
||||||
transports: ['websocket', 'polling']
|
const API_URL = import.meta.env.VITE_API_URL || (
|
||||||
|
import.meta.env.PROD ? window.location.origin : 'http://localhost:3000'
|
||||||
|
);
|
||||||
|
|
||||||
|
const socket = io(`${API_URL}/mod-chat`, {
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionDelay: 1000,
|
||||||
|
reconnectionAttempts: 5
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('connect', () => {
|
socket.on('connect', () => {
|
||||||
|
|
@ -388,6 +457,19 @@ export default function App() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="list-item-actions">
|
<div className="list-item-actions">
|
||||||
|
{user.username === 'glpshchn00' && !u.isAdmin && (
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={() => {
|
||||||
|
const num = prompt('Введите номер админа (1-10):', '1');
|
||||||
|
if (num && !isNaN(num) && num >= 1 && num <= 10) {
|
||||||
|
handleInitiateAddAdmin(u, parseInt(num));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserPlus size={16} /> Назначить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{u.banned ? (
|
{u.banned ? (
|
||||||
<button className="btn" onClick={() => handleBanUser(u.id, false)}>
|
<button className="btn" onClick={() => handleBanUser(u.id, false)}>
|
||||||
Разблокировать
|
Разблокировать
|
||||||
|
|
@ -555,11 +637,32 @@ export default function App() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderPublish = () => (
|
const renderPublish = () => {
|
||||||
|
// Найти админа текущего пользователя
|
||||||
|
const currentAdmin = adminsData.admins.find((admin) => admin.telegramId === user.telegramId);
|
||||||
|
const canPublish = currentAdmin && currentAdmin.adminNumber >= 1 && currentAdmin.adminNumber <= 10;
|
||||||
|
|
||||||
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<h2>Публикация в @reichenbfurry</h2>
|
<h2>Публикация в @reichenbfurry</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!canPublish && (
|
||||||
|
<div style={{ padding: '16px', backgroundColor: 'var(--bg-secondary)', borderRadius: '8px', marginBottom: '16px', color: 'var(--text-secondary)' }}>
|
||||||
|
⚠️ Публиковать в канал могут только админы с номерами от 1 до 10.
|
||||||
|
{currentAdmin ? (
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
Ваш номер: <strong>#{currentAdmin.adminNumber}</strong> (доступ запрещён)
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
Вам не присвоен номер админа. Обратитесь к владельцу.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="publish-form">
|
<div className="publish-form">
|
||||||
<label>
|
<label>
|
||||||
Описание
|
Описание
|
||||||
|
|
@ -570,6 +673,7 @@ export default function App() {
|
||||||
}
|
}
|
||||||
maxLength={1024}
|
maxLength={1024}
|
||||||
placeholder="Текст поста"
|
placeholder="Текст поста"
|
||||||
|
disabled={!canPublish}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
|
|
@ -579,26 +683,26 @@ export default function App() {
|
||||||
value={publishState.tags}
|
value={publishState.tags}
|
||||||
onChange={(e) => setPublishState((prev) => ({ ...prev, tags: e.target.value }))}
|
onChange={(e) => setPublishState((prev) => ({ ...prev, tags: e.target.value }))}
|
||||||
placeholder="#furry #art"
|
placeholder="#furry #art"
|
||||||
|
disabled={!canPublish}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
{currentAdmin && (
|
||||||
Номер администратора (#a1 - #a10)
|
<div style={{ padding: '12px', backgroundColor: 'var(--bg-secondary)', borderRadius: '8px', marginBottom: '8px' }}>
|
||||||
<select
|
Ваш номер админа: <strong>#{currentAdmin.adminNumber}</strong>
|
||||||
value={publishState.slot}
|
<div style={{ fontSize: '12px', color: 'var(--text-secondary)', marginTop: '4px' }}>
|
||||||
onChange={(e) =>
|
Автоматически будет добавлен тег #a{currentAdmin.adminNumber}
|
||||||
setPublishState((prev) => ({ ...prev, slot: parseInt(e.target.value, 10) }))
|
</div>
|
||||||
}
|
</div>
|
||||||
>
|
)}
|
||||||
{slotOptions.map((option) => (
|
|
||||||
<option key={option} value={option}>
|
|
||||||
#{`a${option}`}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
<label>
|
||||||
Медиа (до 10, фото или видео)
|
Медиа (до 10, фото или видео)
|
||||||
<input type="file" accept="image/*,video/*" multiple onChange={handleFileChange} />
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*,video/*"
|
||||||
|
multiple
|
||||||
|
onChange={handleFileChange}
|
||||||
|
disabled={!canPublish}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
{publishState.files.length > 0 && (
|
{publishState.files.length > 0 && (
|
||||||
<div className="file-list">
|
<div className="file-list">
|
||||||
|
|
@ -609,13 +713,138 @@ export default function App() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button className="btn primary" disabled={publishing} onClick={handlePublish}>
|
<button
|
||||||
|
className="btn primary"
|
||||||
|
disabled={publishing || !canPublish}
|
||||||
|
onClick={handlePublish}
|
||||||
|
>
|
||||||
{publishing ? <Loader2 className="spin" size={18} /> : <SendHorizontal size={18} />}
|
{publishing ? <Loader2 className="spin" size={18} /> : <SendHorizontal size={18} />}
|
||||||
Опубликовать
|
Опубликовать
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAdmins = () => (
|
||||||
|
<div className="card">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2>Админы модерации</h2>
|
||||||
|
<button className="icon-btn" onClick={loadAdmins} disabled={adminsLoading}>
|
||||||
|
<RefreshCw size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{adminsLoading ? (
|
||||||
|
<div className="loader">
|
||||||
|
<Loader2 className="spin" size={32} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="list">
|
||||||
|
{adminsData.admins.map((admin) => (
|
||||||
|
<div key={admin.id} className="list-item">
|
||||||
|
<div className="list-item-main">
|
||||||
|
<div className="list-item-title">
|
||||||
|
<Crown size={16} color="gold" /> @{admin.username} — Номер {admin.adminNumber}
|
||||||
|
</div>
|
||||||
|
<div className="list-item-subtitle">
|
||||||
|
{admin.firstName} {admin.lastName || ''}
|
||||||
|
</div>
|
||||||
|
<div className="list-item-meta">
|
||||||
|
<span>Добавил: {admin.addedBy}</span>
|
||||||
|
<span>Дата: {formatDate(admin.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{user.username === 'glpshchn00' && (
|
||||||
|
<div className="list-item-actions">
|
||||||
|
<button
|
||||||
|
className="btn danger"
|
||||||
|
onClick={() => handleInitiateRemoveAdmin(admin)}
|
||||||
|
>
|
||||||
|
<UserMinus size={16} /> Снять
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{adminsData.admins.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '20px', color: 'var(--text-secondary)' }}>
|
||||||
|
Нет админов
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Модальное окно подтверждения */}
|
||||||
|
{adminModal && (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: 'rgba(0,0,0,0.8)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1000
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--background)',
|
||||||
|
padding: '20px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
maxWidth: '400px',
|
||||||
|
width: '90%'
|
||||||
|
}}>
|
||||||
|
<h3 style={{ marginTop: 0 }}>
|
||||||
|
{adminModal.action === 'add' ? 'Подтверждение добавления админа' : 'Подтверждение удаления админа'}
|
||||||
|
</h3>
|
||||||
|
<p>
|
||||||
|
{adminModal.action === 'add'
|
||||||
|
? `Код отправлен пользователю @${adminModal.user.username}. Введите код для подтверждения:`
|
||||||
|
: `Код отправлен админу @${adminModal.admin.username}. Введите код для подтверждения:`
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="6-значный код"
|
||||||
|
value={confirmCode}
|
||||||
|
onChange={(e) => setConfirmCode(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px',
|
||||||
|
fontSize: '16px',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: 'var(--background-secondary)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
marginBottom: '16px'
|
||||||
|
}}
|
||||||
|
maxLength={6}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={adminModal.action === 'add' ? handleConfirmAddAdmin : handleConfirmRemoveAdmin}
|
||||||
|
disabled={confirmCode.length !== 6}
|
||||||
|
>
|
||||||
|
Подтвердить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={() => {
|
||||||
|
setAdminModal(null);
|
||||||
|
setConfirmCode('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
|
|
@ -625,6 +854,8 @@ export default function App() {
|
||||||
return renderPosts();
|
return renderPosts();
|
||||||
case 'reports':
|
case 'reports':
|
||||||
return renderReports();
|
return renderReports();
|
||||||
|
case 'admins':
|
||||||
|
return renderAdmins();
|
||||||
case 'chat':
|
case 'chat':
|
||||||
return renderChat();
|
return renderChat();
|
||||||
case 'publish':
|
case 'publish':
|
||||||
|
|
@ -659,11 +890,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">
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,14 @@ import ReactDOM from 'react-dom/client';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
|
// Убедиться, что Telegram Web App инициализирован
|
||||||
|
if (window.Telegram?.WebApp) {
|
||||||
|
window.Telegram.WebApp.ready();
|
||||||
|
console.log('[Moderation] Telegram WebApp initialized');
|
||||||
|
} else {
|
||||||
|
console.error('[Moderation] Telegram WebApp not found!');
|
||||||
|
}
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|
|
||||||
|
|
@ -432,3 +432,4 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,36 @@ api.interceptors.request.use((config) => {
|
||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Response interceptor для обработки устаревших токенов
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
const status = error?.response?.status;
|
||||||
|
const errorMessage = error?.response?.data?.error || '';
|
||||||
|
|
||||||
|
// Если токен устарел или невалиден - перезагрузить приложение
|
||||||
|
if (status === 401 && (
|
||||||
|
errorMessage.includes('устарели') ||
|
||||||
|
errorMessage.includes('expired') ||
|
||||||
|
errorMessage.includes('Неверная подпись')
|
||||||
|
)) {
|
||||||
|
console.warn('[Moderation API] Auth token expired or invalid, reloading app...');
|
||||||
|
|
||||||
|
// Показать уведомление пользователю
|
||||||
|
const tg = window.Telegram?.WebApp;
|
||||||
|
if (tg?.showAlert) {
|
||||||
|
tg.showAlert('Сессия устарела. Перезагрузка...', () => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setTimeout(() => window.location.reload(), 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export const verifyAuth = () => api.post('/mod-app/auth/verify').then((res) => res.data.user)
|
export const verifyAuth = () => api.post('/mod-app/auth/verify').then((res) => res.data.user)
|
||||||
|
|
||||||
export const fetchUsers = (params = {}) =>
|
export const fetchUsers = (params = {}) =>
|
||||||
|
|
@ -59,5 +89,20 @@ export const publishToChannel = (formData) =>
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const fetchAdmins = () =>
|
||||||
|
api.get('/mod-app/admins').then((res) => res.data)
|
||||||
|
|
||||||
|
export const initiateAddAdmin = (userId, adminNumber) =>
|
||||||
|
api.post('/mod-app/admins/initiate-add', { userId, adminNumber }).then((res) => res.data)
|
||||||
|
|
||||||
|
export const confirmAddAdmin = (userId, code) =>
|
||||||
|
api.post('/mod-app/admins/confirm-add', { userId, code }).then((res) => res.data)
|
||||||
|
|
||||||
|
export const initiateRemoveAdmin = (adminId) =>
|
||||||
|
api.post('/mod-app/admins/initiate-remove', { adminId }).then((res) => res.data)
|
||||||
|
|
||||||
|
export const confirmRemoveAdmin = (adminId, code) =>
|
||||||
|
api.post('/mod-app/admins/confirm-remove', { adminId, code }).then((res) => res.data)
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,3 +23,4 @@ export default defineConfig({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@
|
||||||
"xss-clean": "^0.1.4",
|
"xss-clean": "^0.1.4",
|
||||||
"hpp": "^0.2.3",
|
"hpp": "^0.2.3",
|
||||||
"validator": "^13.11.0",
|
"validator": "^13.11.0",
|
||||||
"@telegram-apps/init-data-node": "^1.0.0"
|
"@telegram-apps/init-data-node": "^1.0.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.1",
|
"nodemon": "^3.0.1",
|
||||||
|
|
|
||||||
|
|
@ -126,3 +126,4 @@ https://nakama.glpshchn.ru
|
||||||
|
|
||||||
🎉 NakamaSpace v2.2.0!
|
🎉 NakamaSpace v2.2.0!
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,3 +81,4 @@ cd /var/www/nakama/frontend && npm run build && cd .. && pm2 restart nakama-back
|
||||||
3 минуты
|
3 минуты
|
||||||
https://nakama.glpshchn.ru
|
https://nakama.glpshchn.ru
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -126,3 +126,4 @@ pm2 restart nakama-backend
|
||||||
|
|
||||||
2 минуты
|
2 минуты
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,3 +63,4 @@ API ключ можно также добавить в переменные ок
|
||||||
|
|
||||||
2 минуты
|
2 минуты
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,3 +33,4 @@ ssh root@ваш_IP "cd /var/www/nakama/frontend && npm run build"
|
||||||
|
|
||||||
30 секунд
|
30 секунд
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -95,3 +95,4 @@ ssh root@ваш_IP "pm2 restart nakama-backend"
|
||||||
|
|
||||||
2 минуты
|
2 минуты
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -80,3 +80,4 @@ TELEGRAM_BOT_TOKEN не установлен на сервере!
|
||||||
|
|
||||||
2 минуты
|
2 минуты
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,3 +68,4 @@ ssh root@ваш_IP "cd /var/www/nakama/backend && npm install form-data && pm2 r
|
||||||
|
|
||||||
2 минуты
|
2 минуты
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,3 +79,4 @@ ssh root@ваш_IP "cd /var/www/nakama/backend && pm2 restart nakama-backend"
|
||||||
|
|
||||||
3 минуты
|
3 минуты
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,3 +79,4 @@ ssh root@ваш_IP "cd /var/www/nakama/backend && pm2 restart nakama-backend"
|
||||||
|
|
||||||
2 минуты
|
2 минуты
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -102,3 +102,4 @@
|
||||||
|
|
||||||
2 минуты
|
2 минуты
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,3 +77,4 @@ ssh root@ваш_IP "cd /var/www/nakama/frontend && npm run build"
|
||||||
|
|
||||||
2 минуты
|
2 минуты
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -80,3 +80,4 @@ ssh root@ваш_IP "cd /var/www/nakama/backend && pm2 restart nakama-backend"
|
||||||
|
|
||||||
5 минут
|
5 минут
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue