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 быть не должно!

View File

@ -75,3 +75,4 @@ ssh root@ваш_IP
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)

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

View File

@ -108,3 +108,4 @@ update-server.sh - Автоматический скрипт
Следуйте 3 шагам выше и приложение заработает идеально на:
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)
- [BotFather](https://t.me/BotFather)

View File

@ -41,3 +41,4 @@ npm run build
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

View File

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

View File

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

View File

@ -249,10 +249,7 @@ const handleCommand = async (message) => {
const command = args[0].toLowerCase();
if (command === '/start') {
await sendMessage(
chatId,
'<b>NakamaHost Moderation</b>\nКоманды:\n• /load — состояние сервера\n• /admins — список админов\n• /addadmin @username — добавить админа (только владельцы)\n• /removeadmin @username — убрать админа (только владельцы)'
);
await sendMessage(chatId, '✅ Бот активен');
return;
}
@ -293,9 +290,7 @@ const handleCommand = async (message) => {
return;
}
if (command.startsWith('/')) {
await sendMessage(chatId, 'Неизвестная команда. Используйте /start, /load, /admins.');
}
// Игнорируем неизвестные команды
};
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 = {
startServerMonitorBot,
sendChannelMediaGroup,
sendMessageToUser,
isModerationAdmin
};

View File

@ -59,3 +59,4 @@ console.log('\n💡 Для PM2 нужно использовать:');
console.log(' pm2 restart nakama-backend --update-env');
console.log(' или добавить в ecosystem.config.js');

View File

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

View File

@ -142,8 +142,87 @@ const requireAdmin = (req, res, 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 = {
authenticate,
authenticateModeration,
requireModerator,
requireAdmin,
touchUserActivity,

View File

@ -154,3 +154,4 @@ module.exports = {
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,
lastName: String,
adminNumber: {
type: Number,
required: true,
min: 1,
max: 10,
unique: true
},
addedBy: {
type: String,
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) => {
try {
const user = await req.user.populate([
{ 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
}
});
return respondWithUser(req.user, res);
} catch (error) {
console.error('Ошибка verify:', error);
res.status(500).json({ error: 'Ошибка сервера' });

View File

@ -3,13 +3,16 @@ const router = express.Router();
const fs = require('fs');
const path = require('path');
const multer = require('multer');
const { authenticate } = require('../middleware/auth');
const crypto = require('crypto');
const { authenticateModeration } = require('../middleware/auth');
const { logSecurityEvent } = require('../middleware/logger');
const User = require('../models/User');
const Post = require('../models/Post');
const Report = require('../models/Report');
const ModerationAdmin = require('../models/ModerationAdmin');
const AdminConfirmation = require('../models/AdminConfirmation');
const { listAdmins, isModerationAdmin, normalizeUsername } = require('../services/moderationAdmin');
const { sendChannelMediaGroup } = require('../bots/serverMonitor');
const { sendChannelMediaGroup, sendMessageToUser } = require('../bots/serverMonitor');
const config = require('../config');
const TEMP_DIR = path.join(__dirname, '../uploads/mod-channel');
@ -44,6 +47,7 @@ const requireModerationAccess = async (req, res, next) => {
if (OWNER_USERNAMES.has(username) || req.user.role === 'admin') {
req.isModerationAdmin = true;
req.isOwner = true;
return next();
}
@ -53,9 +57,17 @@ const requireModerationAccess = async (req, res, next) => {
}
req.isModerationAdmin = true;
req.isOwner = false;
return next();
};
const requireOwner = (req, res, next) => {
if (!req.isOwner) {
return res.status(403).json({ error: 'Требуются права владельца' });
}
next();
};
const serializeUser = (user) => ({
id: user._id,
username: user.username,
@ -68,7 +80,7 @@ const serializeUser = (user) => ({
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();
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 pageNum = Math.max(parseInt(page, 10) || 1, 1);
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 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) });
});
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 pageNum = Math.max(parseInt(page, 10) || 1, 1);
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 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);
if (!post) {
return res.status(404).json({ error: 'Пост не найден' });
@ -254,7 +266,7 @@ router.delete('/posts/:id', authenticate, requireModerationAccess, async (req, r
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 idx = parseInt(index, 10);
@ -281,7 +293,7 @@ router.delete('/posts/:id/images/:index', authenticate, requireModerationAccess,
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 { days = 7 } = req.body;
@ -298,7 +310,7 @@ router.post('/posts/:id/ban', authenticate, requireModerationAccess, async (req,
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 pageNum = Math.max(parseInt(page, 10) || 1, 1);
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 report = await Report.findById(req.params.id);
@ -360,20 +372,290 @@ router.put('/reports/:id', authenticate, requireModerationAccess, async (req, re
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(
'/channel/publish',
authenticate,
authenticateModeration,
requireModerationAccess,
upload.array('images', 10),
async (req, res) => {
const { description = '', tags, slot } = req.body;
const { description = '', tags } = req.body;
const files = req.files || [];
if (!files.length) {
return res.status(400).json({ error: 'Загрузите хотя бы одно изображение' });
}
const slotNumber = Math.max(Math.min(parseInt(slot, 10) || 1, 10), 1);
// Получить номер админа из базы
const admin = await ModerationAdmin.findOne({ telegramId: req.user.telegramId });
// Проверить, что админ имеет номер от 1 до 10
if (!admin || !admin.adminNumber || admin.adminNumber < 1 || admin.adminNumber > 10) {
return res.status(403).json({
error: 'Публиковать в канал могут только админы с номерами от 1 до 10. Обратитесь к владельцу для назначения номера.'
});
}
const slotNumber = admin.adminNumber;
let tagsArray = [];
if (typeof tags === 'string' && tags.trim()) {

View File

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

View File

@ -80,20 +80,40 @@ app.use((req, res, next) => {
return;
}
if (typeof obj.error === 'string' && !obj.error.includes(ERROR_SUPPORT_SUFFIX)) {
// Список ошибок, к которым НЕ нужно добавлять суффикс
const skipSuffixMessages = [
'Загрузите хотя бы одно изображение',
'Не удалось опубликовать в канал',
'Публиковать в канал могут только админы',
'Требуется авторизация',
'Требуются права',
'Неверный код подтверждения',
'Код подтверждения истёк',
'Номер админа уже занят',
'Пользователь не найден',
'Администратор не найден'
];
const shouldSkipSuffix = (text) => {
if (!text || typeof text !== 'string') return false;
return skipSuffixMessages.some(msg => text.includes(msg));
};
if (typeof obj.error === 'string' && !obj.error.includes(ERROR_SUPPORT_SUFFIX) && !shouldSkipSuffix(obj.error)) {
obj.error += ERROR_SUPPORT_SUFFIX;
}
if (typeof obj.message === 'string' && res.statusCode >= 400 && !obj.message.includes(ERROR_SUPPORT_SUFFIX)) {
if (typeof obj.message === 'string' && res.statusCode >= 400 && !obj.message.includes(ERROR_SUPPORT_SUFFIX) && !shouldSkipSuffix(obj.message)) {
obj.message += ERROR_SUPPORT_SUFFIX;
}
if (Array.isArray(obj.errors)) {
obj.errors = obj.errors.map((item) => {
if (typeof item === 'string') {
if (shouldSkipSuffix(item)) return item;
return item.includes(ERROR_SUPPORT_SUFFIX) ? item : `${item}${ERROR_SUPPORT_SUFFIX}`;
}
if (item && typeof item === 'object' && typeof item.message === 'string' && !item.message.includes(ERROR_SUPPORT_SUFFIX)) {
if (item && typeof item === 'object' && typeof item.message === 'string' && !item.message.includes(ERROR_SUPPORT_SUFFIX) && !shouldSkipSuffix(item.message)) {
return { ...item, message: `${item.message}${ERROR_SUPPORT_SUFFIX}` };
}
return item;

View File

@ -87,3 +87,4 @@ module.exports = {
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 MAX_AUTH_AGE_SECONDS = 5 * 60;
const MAX_AUTH_AGE_SECONDS = 60 * 60; // 1 час
function validateAndParseInitData(initDataRaw) {
if (!config.telegramBotToken) {
throw new Error('TELEGRAM_BOT_TOKEN не настроен');
/**
* Manual validation with base64 padding fix
* 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') {
@ -18,29 +67,89 @@ function validateAndParseInitData(initDataRaw) {
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) {
console.error('[Telegram] All validation attempts failed');
throw new Error('Неверная подпись initData');
}
console.log('[Telegram] initData validation successful, parsing...');
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) {
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;
if (!authDate) {
throw new Error('Отсутствует auth_date в initData');
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) {
console.error('[Telegram] Missing authDate in hash-based payload:', payload);
throw new Error('Отсутствует auth_date в initData');
}
const now = Math.floor(Date.now() / 1000);
const age = Math.abs(now - authDate);
console.log('[Telegram] Auth date check:', {
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');
}
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - authDate) > MAX_AUTH_AGE_SECONDS) {
throw new Error('Данные авторизации устарели');
}
console.log('[Telegram] initData validation complete');
return payload;
}

View File

@ -68,12 +68,20 @@ function registerModerationChat() {
const telegramId = payload.telegramId;
if (!username || !telegramId) {
log('warn', 'Mod chat auth failed: no username/telegramId', { username, telegramId });
socket.emit('unauthorized');
return socket.disconnect(true);
}
const allowed = await isModerationAdmin({ username, telegramId });
if (!allowed) {
// Проверить, является ли владельцем
const ownerUsernames = config.moderationOwnerUsernames || [];
const isOwner = ownerUsernames.includes(username);
// Проверить, является ли админом
const isAdmin = await isModerationAdmin({ username, telegramId });
if (!isOwner && !isAdmin) {
log('warn', 'Mod chat access denied', { username, telegramId });
socket.emit('unauthorized');
return socket.disconnect(true);
}
@ -81,11 +89,15 @@ function registerModerationChat() {
socket.data.authorized = true;
socket.data.username = username;
socket.data.telegramId = telegramId;
socket.data.isOwner = isOwner;
connectedModerators.set(socket.id, {
username,
telegramId
telegramId,
isOwner
});
log('info', 'Mod chat auth success', { username, isOwner, isAdmin });
socket.emit('ready');
broadcastOnline();
});

View File

@ -6,21 +6,8 @@
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<title>NakamaHost</title>
<script>
(function () {
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>
<!-- Telegram Web App SDK - прямая загрузка -->
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<style>
/* Предотвращение resize при открытии клавиатуры */
html, body {

View File

@ -3,6 +3,7 @@ import { useState, useEffect, useRef } from 'react'
import { initTelegramApp } from './utils/telegram'
import { verifyAuth } from './utils/api'
import { initTheme } from './utils/theme'
import { startInitDataChecker, stopInitDataChecker } from './utils/initDataChecker'
import Layout from './components/Layout'
import Feed from './pages/Feed'
import Search from './pages/Search'
@ -28,31 +29,30 @@ function AppContent() {
initAppCalled.current = true
initApp()
}
}, [])
const waitForInitData = async () => {
const start = Date.now()
const timeout = 5000
// Запустить проверку initData
startInitDataChecker()
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))
return () => {
stopInitDataChecker()
}
throw new Error('Telegram не передал initData. Откройте приложение в официальном клиенте.')
}
}, [])
const initApp = async () => {
try {
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.ready?.()
tg.expand?.()
const userData = await verifyAuth()

View File

@ -3,6 +3,14 @@ import ReactDOM from 'react-dom/client'
import App from './App.jsx'
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(
<React.StrictMode>
<App />

View File

@ -18,6 +18,15 @@ const api = axios.create({
api.interceptors.request.use((config) => {
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) {
config.headers = config.headers || {};
if (!config.headers.Authorization) {
@ -26,10 +35,43 @@ api.interceptors.request.use((config) => {
if (!config.headers['x-telegram-init-data']) {
config.headers['x-telegram-init-data'] = initData;
}
} else {
console.warn('[API] No initData available for request:', config.url);
}
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
export const signInWithTelegram = async (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="theme-color" content="#000000" />
<title>Nakama Moderation</title>
<script>
(function () {
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>
<!-- Telegram Web App SDK - прямая загрузка -->
<script src="https://telegram.org/js/telegram-web-app.js"></script>
</head>
<body>
<div id="root"></div>

View File

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

View File

@ -10,7 +10,12 @@ import {
banPostAuthor,
fetchReports,
updateReportStatus,
publishToChannel
publishToChannel,
fetchAdmins,
initiateAddAdmin,
confirmAddAdmin,
initiateRemoveAdmin,
confirmRemoveAdmin
} from './utils/api';
import { io } from 'socket.io-client';
import {
@ -23,13 +28,17 @@ import {
RefreshCw,
Trash2,
Edit,
Ban
Ban,
UserPlus,
UserMinus,
Crown
} from 'lucide-react';
const TABS = [
{ id: 'users', title: 'Пользователи', icon: Users },
{ id: 'posts', title: 'Посты', icon: ImageIcon },
{ id: 'reports', title: 'Репорты', icon: ShieldCheck },
{ id: 'admins', title: 'Админы', icon: Crown },
{ id: 'chat', title: 'Чат', icon: MessageSquare },
{ id: 'publish', title: 'Публикация', icon: SendHorizontal }
];
@ -91,6 +100,12 @@ export default function App() {
});
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
const [chatState, setChatState] = useState(initialChatState);
const [chatInput, setChatInput] = useState('');
@ -100,20 +115,6 @@ export default function App() {
useEffect(() => {
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 () => {
try {
const telegramApp = window.Telegram?.WebApp;
@ -122,12 +123,12 @@ export default function App() {
throw new Error('Откройте модераторский интерфейс из Telegram (бот @rbachbot).');
}
telegramApp.disableVerticalSwipes?.();
telegramApp.ready?.();
telegramApp.expand?.();
if (!telegramApp.initData) {
throw new Error('Telegram не передал initData. Откройте приложение из официального клиента.');
}
const app = await waitForInitData();
if (cancelled) return;
telegramApp.disableVerticalSwipes?.();
telegramApp.expand?.();
const userData = await verifyAuth();
if (cancelled) return;
@ -145,10 +146,7 @@ export default function App() {
} finally {
if (!cancelled) {
setLoading(false);
const telegramApp = window.Telegram?.WebApp;
telegramApp?.MainButton?.setText?.('Закрыть');
telegramApp?.MainButton?.show?.();
telegramApp?.MainButton?.onClick?.(() => telegramApp.close());
// Убрана кнопка "Закрыть"
}
}
};
@ -157,7 +155,6 @@ export default function App() {
return () => {
cancelled = true;
window.Telegram?.WebApp?.MainButton?.hide?.();
};
}, []);
@ -168,6 +165,11 @@ export default function App() {
loadPosts();
} else if (tab === 'reports') {
loadReports();
} else if (tab === 'admins') {
loadAdmins();
} else if (tab === 'publish') {
// Загрузить список админов для проверки прав публикации
loadAdmins();
} else if (tab === 'chat' && user) {
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 = () => {
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', () => {
@ -388,6 +457,19 @@ export default function App() {
</div>
</div>
<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 ? (
<button className="btn" onClick={() => handleBanUser(u.id, false)}>
Разблокировать
@ -555,65 +637,212 @@ export default function App() {
</div>
);
const renderPublish = () => (
<div className="card">
<div className="section-header">
<h2>Публикация в @reichenbfurry</h2>
</div>
<div className="publish-form">
<label>
Описание
<textarea
value={publishState.description}
onChange={(e) =>
setPublishState((prev) => ({ ...prev, description: e.target.value }))
}
maxLength={1024}
placeholder="Текст поста"
/>
</label>
<label>
Теги (через пробел или запятую)
<input
type="text"
value={publishState.tags}
onChange={(e) => setPublishState((prev) => ({ ...prev, tags: e.target.value }))}
placeholder="#furry #art"
/>
</label>
<label>
Номер администратора (#a1 - #a10)
<select
value={publishState.slot}
onChange={(e) =>
setPublishState((prev) => ({ ...prev, slot: parseInt(e.target.value, 10) }))
}
>
{slotOptions.map((option) => (
<option key={option} value={option}>
#{`a${option}`}
</option>
))}
</select>
</label>
<label>
Медиа (до 10, фото или видео)
<input type="file" accept="image/*,video/*" multiple onChange={handleFileChange} />
</label>
{publishState.files.length > 0 && (
<div className="file-list">
{publishState.files.map((file, index) => (
<div key={index} className="file-item">
{file.name} ({Math.round(file.size / 1024)} KB)
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="section-header">
<h2>Публикация в @reichenbfurry</h2>
</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>
)}
<button className="btn primary" disabled={publishing} onClick={handlePublish}>
{publishing ? <Loader2 className="spin" size={18} /> : <SendHorizontal size={18} />}
Опубликовать
<div className="publish-form">
<label>
Описание
<textarea
value={publishState.description}
onChange={(e) =>
setPublishState((prev) => ({ ...prev, description: e.target.value }))
}
maxLength={1024}
placeholder="Текст поста"
disabled={!canPublish}
/>
</label>
<label>
Теги (через пробел или запятую)
<input
type="text"
value={publishState.tags}
onChange={(e) => setPublishState((prev) => ({ ...prev, tags: e.target.value }))}
placeholder="#furry #art"
disabled={!canPublish}
/>
</label>
{currentAdmin && (
<div style={{ padding: '12px', backgroundColor: 'var(--bg-secondary)', borderRadius: '8px', marginBottom: '8px' }}>
Ваш номер админа: <strong>#{currentAdmin.adminNumber}</strong>
<div style={{ fontSize: '12px', color: 'var(--text-secondary)', marginTop: '4px' }}>
Автоматически будет добавлен тег #a{currentAdmin.adminNumber}
</div>
</div>
)}
<label>
Медиа (до 10, фото или видео)
<input
type="file"
accept="image/*,video/*"
multiple
onChange={handleFileChange}
disabled={!canPublish}
/>
</label>
{publishState.files.length > 0 && (
<div className="file-list">
{publishState.files.map((file, index) => (
<div key={index} className="file-item">
{file.name} ({Math.round(file.size / 1024)} KB)
</div>
))}
</div>
)}
<button
className="btn primary"
disabled={publishing || !canPublish}
onClick={handlePublish}
>
{publishing ? <Loader2 className="spin" size={18} /> : <SendHorizontal size={18} />}
Опубликовать
</button>
</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>
);
@ -625,6 +854,8 @@ export default function App() {
return renderPosts();
case 'reports':
return renderReports();
case 'admins':
return renderAdmins();
case 'chat':
return renderChat();
case 'publish':
@ -659,11 +890,6 @@ export default function App() {
<h1>Nakama Moderation</h1>
<span className="subtitle">@{user.username}</span>
</div>
<div className="header-actions">
<button className="btn" onClick={() => window.Telegram?.WebApp?.close()}>
Закрыть
</button>
</div>
</header>
<nav className="tabbar">

View File

@ -3,6 +3,14 @@ import ReactDOM from 'react-dom/client';
import App from './App';
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(
<React.StrictMode>
<App />

View File

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

View File

@ -23,6 +23,36 @@ api.interceptors.request.use((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 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

View File

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

View File

@ -36,7 +36,7 @@
"xss-clean": "^0.1.4",
"hpp": "^0.2.3",
"validator": "^13.11.0",
"@telegram-apps/init-data-node": "^1.0.0"
"@telegram-apps/init-data-node": "^1.0.4"
},
"devDependencies": {
"nodemon": "^3.0.1",

View File

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

View File

@ -81,3 +81,4 @@ cd /var/www/nakama/frontend && npm run build && cd .. && pm2 restart nakama-back
3 минуты
https://nakama.glpshchn.ru

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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