Compare commits

...

10 Commits

Author SHA1 Message Date
glpshchn b4ca4f2d9e Update files 2025-11-20 23:50:14 +03:00
glpshchn f9ff325c8a Update files 2025-11-11 03:54:39 +03:00
glpshchn 2bb573b919 Update files 2025-11-11 03:49:07 +03:00
glpshchn 16f6130e98 Update files 2025-11-11 03:46:01 +03:00
glpshchn 8c81bac776 Update files 2025-11-11 03:40:29 +03:00
glpshchn d83399e5f9 Update files 2025-11-11 03:33:22 +03:00
glpshchn a4bae70823 Update files 2025-11-11 02:22:34 +03:00
glpshchn 08ab000290 Update files 2025-11-11 02:16:43 +03:00
glpshchn c3f2746723 Update files 2025-11-11 02:11:33 +03:00
glpshchn b6036af3f1 Update files 2025-11-11 02:04:30 +03:00
58 changed files with 1351 additions and 202 deletions

146
ADMIN_MANAGEMENT_UPDATE.md Normal file
View File

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

View File

@ -52,3 +52,4 @@ pm2 logs nakama-backend
После перезапуска ошибок 401 быть не должно! После перезапуска ошибок 401 быть не должно!

View File

@ -75,3 +75,4 @@ ssh root@ваш_IP
pm2 restart nakama-backend pm2 restart nakama-backend
``` ```

View File

@ -225,3 +225,4 @@ git pull
Все баги исправлены, приложение стабильно. Все баги исправлены, приложение стабильно.

View File

@ -92,3 +92,4 @@ npm run build
**Версия**: v2.1.2 (Dark theme visibility fix) **Версия**: v2.1.2 (Dark theme visibility fix)

View File

@ -95,3 +95,4 @@ https://nakama.glpshchn.ru
🎉 ГОТОВО! 🎉 ГОТОВО!

View File

@ -77,3 +77,4 @@ npm run build
Все проблемы с комментариями исправлены! Все проблемы с комментариями исправлены!

View File

@ -211,3 +211,4 @@ mongosh nakama --eval 'db.posts.findOne({}, {reposts: 1})'
После обновления на сервере всё должно работать идеально! 🚀 После обновления на сервере всё должно работать идеально! 🚀

View File

@ -62,3 +62,4 @@ npm run build
Комментарии больше не будут прыгать при фокусе на поле ввода! Комментарии больше не будут прыгать при фокусе на поле ввода!

View File

@ -46,3 +46,4 @@ scp nakama-fix.tar.gz root@IP:/tmp/
# Далее как в UPLOAD_TO_SERVER.md # Далее как в UPLOAD_TO_SERVER.md
``` ```

View File

@ -108,3 +108,4 @@ update-server.sh - Автоматический скрипт
Следуйте 3 шагам выше и приложение заработает идеально на: Следуйте 3 шагам выше и приложение заработает идеально на:
https://nakama.glpshchn.ru https://nakama.glpshchn.ru

View File

@ -122,3 +122,4 @@ QUICKSTART.md - Быстрый старт
║ Успехов! 🚀🦊🎌 ║ ║ Успехов! 🚀🦊🎌 ║
╚═══════════════════════════════════════════════════════════════════════╝ ╚═══════════════════════════════════════════════════════════════════════╝

View File

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

View File

@ -41,3 +41,4 @@ npm run build
Backend перезапускать НЕ нужно! Backend перезапускать НЕ нужно!

View File

@ -89,3 +89,4 @@ npm run build
✅ Активная кнопка - белая с синей рамкой ✅ Активная кнопка - белая с синей рамкой
✅ Всё работает идеально ✅ Всё работает идеально

View File

@ -198,3 +198,4 @@ mongosh nakama --eval 'db.posts.findOne()'
Теперь приложение работает стабильно на https://nakama.glpshchn.ru Теперь приложение работает стабильно на https://nakama.glpshchn.ru

View File

@ -139,3 +139,4 @@ sudo systemctl status mongod
После выполнения этих шагов все исправления будут применены на https://nakama.glpshchn.ru После выполнения этих шагов все исправления будут применены на https://nakama.glpshchn.ru

View File

@ -55,3 +55,4 @@
**Домен**: nakama.glpshchn.ru **Домен**: nakama.glpshchn.ru
**Последнее обновление**: 03.11.2025 **Последнее обновление**: 03.11.2025

View File

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

View File

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

View File

@ -122,3 +122,4 @@ module.exports = {
updateAllUserAvatars updateAllUserAvatars
}; };

View File

@ -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,

View File

@ -154,3 +154,4 @@ module.exports = {
validateImageUrl validateImageUrl
}; };

View File

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

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

@ -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: 'Ошибка сервера' });

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 { 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()) {

View File

@ -102,3 +102,4 @@ try {
process.exit(1); process.exit(1);
} }

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

@ -87,3 +87,4 @@ module.exports = {
normalizeUsername normalizeUsername
}; };

View File

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

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

@ -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 {

View File

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

View File

@ -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 />

View File

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

View File

@ -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) // в секундах
};
}

View File

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

View File

@ -23,3 +23,4 @@
} }
} }

View File

@ -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">

View File

@ -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 />

View File

@ -432,3 +432,4 @@
} }
} }

View File

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

View File

@ -23,3 +23,4 @@ export default defineConfig({
} }
}); });

View File

@ -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",

View File

@ -126,3 +126,4 @@ https://nakama.glpshchn.ru
🎉 NakamaSpace v2.2.0! 🎉 NakamaSpace v2.2.0!

View File

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

View File

@ -126,3 +126,4 @@ pm2 restart nakama-backend
2 минуты 2 минуты

View File

@ -63,3 +63,4 @@ API ключ можно также добавить в переменные ок
2 минуты 2 минуты

View File

@ -33,3 +33,4 @@ ssh root@ваш_IP "cd /var/www/nakama/frontend && npm run build"
30 секунд 30 секунд

View File

@ -95,3 +95,4 @@ ssh root@ваш_IP "pm2 restart nakama-backend"
2 минуты 2 минуты

View File

@ -80,3 +80,4 @@ TELEGRAM_BOT_TOKEN не установлен на сервере!
2 минуты 2 минуты

View File

@ -68,3 +68,4 @@ ssh root@ваш_IP "cd /var/www/nakama/backend && npm install form-data && pm2 r
2 минуты 2 минуты

View File

@ -79,3 +79,4 @@ ssh root@ваш_IP "cd /var/www/nakama/backend && pm2 restart nakama-backend"
3 минуты 3 минуты

View File

@ -79,3 +79,4 @@ ssh root@ваш_IP "cd /var/www/nakama/backend && pm2 restart nakama-backend"
2 минуты 2 минуты

View File

@ -102,3 +102,4 @@
2 минуты 2 минуты

View File

@ -77,3 +77,4 @@ ssh root@ваш_IP "cd /var/www/nakama/frontend && npm run build"
2 минуты 2 минуты

View File

@ -80,3 +80,4 @@ ssh root@ваш_IP "cd /var/www/nakama/backend && pm2 restart nakama-backend"
5 минут 5 минут