Update files

This commit is contained in:
glpshchn 2025-12-09 02:42:32 +03:00
parent 56bdedacac
commit e446691b3d
27 changed files with 2531 additions and 119 deletions

View File

@ -10,7 +10,7 @@ RUN npm ci
COPY moderation/frontend ./
# Сборка проекта
ARG VITE_API_URL
ARG VITE_API_URL=https://moderation.nkm.guru/api
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build

View File

@ -0,0 +1,21 @@
FROM node:20-alpine
WORKDIR /app
# Установка зависимостей из корня проекта
COPY package*.json ./
RUN npm ci --only=production
# Копирование всего проекта (нужно для доступа к backend/ и moderation/)
COPY . .
# Создание директории для uploads (если нужно)
RUN mkdir -p backend/uploads/posts backend/uploads/mod-channel
EXPOSE 3001
ENV NODE_ENV=production
ENV MODERATION_PORT=3001
CMD ["node", "moderation/backend/server.js"]

View File

@ -62,3 +62,34 @@ CACHE_TTL_POSTS=300
CACHE_TTL_USERS=600
CACHE_TTL_SEARCH=180
# Модерация
MODERATION_PORT=3001
MODERATION_CORS_ORIGIN=https://moderation.nkm.guru
VITE_MODERATION_API_URL=https://moderation.nkm.guru/api
# Email для кодов подтверждения админа
OWNER_EMAIL=aaem9848@gmail.com
# Email настройки для отправки писем (выберите один вариант)
# AWS SES
EMAIL_PROVIDER=aws
AWS_SES_ACCESS_KEY_ID=your_aws_access_key
AWS_SES_SECRET_ACCESS_KEY=your_aws_secret_key
AWS_SES_REGION=us-east-1
EMAIL_FROM=noreply@nakama.guru
# Или Yandex Cloud
# EMAIL_PROVIDER=yandex
# YANDEX_SMTP_USER=your_email@yandex.ru
# YANDEX_SMTP_PASSWORD=your_app_password
# EMAIL_FROM=noreply@nakama.guru
# Или SMTP
# EMAIL_PROVIDER=smtp
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
# SMTP_USER=your_email@example.com
# SMTP_PASSWORD=your_password
# SMTP_SECURE=false
# EMAIL_FROM=noreply@nakama.guru

144
SECURITY.md Normal file
View File

@ -0,0 +1,144 @@
# Безопасность модераторского сайта
## Меры безопасности
### 1. Шифрование паролей
✅ **Пароли зашифрованы с использованием bcrypt**
- Используется `bcrypt.hash()` с солью 10 раундов
- Пароли никогда не хранятся в открытом виде
- `passwordHash` имеет `select: false` в модели User - не возвращается в API ответах
### 2. Защита API
✅ **Rate Limiting**
- Авторизация: 5 попыток за 15 секунд
- Отправка кода: 1 запрос в минуту
- Общий лимит: 100 запросов за 15 секунд
✅ **Middleware безопасности**
- Helmet - защита HTTP headers
- XSS Protection - защита от XSS атак
- MongoDB Sanitize - защита от NoSQL injection
- HPP Protection - защита от HTTP Parameter Pollution
- DDoS Protection - защита от DDoS
✅ **JWT Токены**
- Access token: 5 минут жизни
- Refresh token: 7 дней
- Токены хранятся в httpOnly cookies в production
- Секретные ключи настраиваются через .env
### 3. Защита данных
✅ **Email не возвращается в API ответах**
- Email используется только для авторизации
- Не включается в JWT payload
- Не возвращается в `/me` и других эндпоинтах
✅ **Пароли**
- Никогда не возвращаются в ответах API
- Хранятся только как bcrypt hash
- Проверка через `bcrypt.compare()` только при входе
### 4. Авторизация
✅ **Двухфакторная авторизация для админов**
- Код подтверждения отправляется на email владельца (aaem9848@gmail.com)
- Код действителен 5 минут
- Fallback на Telegram если email не работает
✅ **Проверка ролей**
- Все эндпоинты модерации проверяют роль пользователя
- Только `moderator` и `admin` имеют доступ
- Владелец имеет дополнительные права
### 5. Валидация данных
✅ **Валидация входных данных**
- Email валидируется через `validator.isEmail()`
- Пароль минимум 6 символов
- Все данные санитизируются перед сохранением
### 6. Логирование безопасности
✅ **События безопасности логируются**
- Неудачные попытки входа
- Невалидные токены
- Попытки несанкционированного доступа
- Все события логируются через `logSecurityEvent()`
### 7. CORS и Headers
✅ **CORS настройки**
- Настраивается через `MODERATION_CORS_ORIGIN`
- Credentials включены для cookies
- Ограниченные методы и headers
✅ **Security Headers**
- X-Frame-Options: SAMEORIGIN
- X-Content-Type-Options: nosniff
- X-XSS-Protection: 1; mode=block
- Referrer-Policy: no-referrer-when-downgrade
## Рекомендации для production
1. **Измените секретные ключи**:
```bash
JWT_SECRET=your_very_secure_random_string
JWT_ACCESS_SECRET=another_secure_random_string
JWT_REFRESH_SECRET=yet_another_secure_random_string
```
2. **Настройте HTTPS** (обязательно):
- SSL сертификат через Let's Encrypt
- Все HTTP редиректятся на HTTPS
3. **Защитите .env файл**:
```bash
chmod 600 .env
```
- Не коммитьте .env в git
- Используйте переменные окружения в Docker
4. **Ограничьте доступ к MongoDB**:
- Используйте firewall
- Ограничьте IP адреса
- Используйте аутентификацию MongoDB
5. **Мониторинг**:
- Следите за логами безопасности
- Настройте алерты на подозрительную активность
- Регулярно проверяйте логи авторизации
## Переменные окружения для безопасности
```bash
# JWT секреты (ОБЯЗАТЕЛЬНО измените в production!)
JWT_SECRET=your_secure_secret
JWT_ACCESS_SECRET=your_access_secret
JWT_REFRESH_SECRET=your_refresh_secret
# Email для кодов подтверждения админа
OWNER_EMAIL=aaem9848@gmail.com
# CORS (ограничьте в production)
MODERATION_CORS_ORIGIN=https://moderation.nkm.guru
# Внутренний токен бота (опционально)
INTERNAL_BOT_TOKEN=your_internal_bot_token
```
## Проверка безопасности
```bash
# Проверить что пароли не возвращаются
curl -H "Authorization: Bearer YOUR_TOKEN" https://moderation.nkm.guru/api/moderation-auth/me
# Проверить rate limiting
for i in {1..10}; do curl -X POST https://moderation.nkm.guru/api/moderation-auth/login -d '{"email":"test@test.com","password":"wrong"}'; done
# Проверить защиту от SQL injection
curl -X POST https://moderation.nkm.guru/api/moderation-auth/login -d '{"email":{"$ne":null},"password":"test"}'
```

View File

@ -87,6 +87,33 @@ module.exports = {
publicBucket: process.env.MINIO_PUBLIC_BUCKET === 'true'
},
// Email конфигурация
ownerEmail: process.env.OWNER_EMAIL || 'aaem9848@gmail.com',
internalBotToken: process.env.INTERNAL_BOT_TOKEN || null,
email: {
provider: process.env.EMAIL_PROVIDER || 'aws', // aws, yandex, smtp
from: process.env.EMAIL_FROM || 'noreply@nakama.guru',
aws: {
accessKeyId: process.env.AWS_SES_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SES_SECRET_ACCESS_KEY,
region: process.env.AWS_SES_REGION || 'us-east-1'
},
yandex: {
host: process.env.YANDEX_SMTP_HOST || 'smtp.yandex.ru',
port: parseInt(process.env.YANDEX_SMTP_PORT || '465', 10),
secure: process.env.YANDEX_SMTP_SECURE !== 'false',
user: process.env.YANDEX_SMTP_USER,
password: process.env.YANDEX_SMTP_PASSWORD
},
smtp: {
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || '587', 10),
secure: process.env.SMTP_SECURE === 'true',
user: process.env.SMTP_USER,
password: process.env.SMTP_PASSWORD
}
},
// Проверки
isDevelopment: () => process.env.NODE_ENV === 'development',
isProduction: () => process.env.NODE_ENV === 'production',

View File

@ -494,9 +494,71 @@ const authenticateModeration = async (req, res, next) => {
}
};
// Middleware для проверки JWT токена (для модерации через логин/пароль)
const authenticateJWT = async (req, res, next) => {
try {
// Получить токен из заголовка или cookie
let token = null;
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
token = authHeader.slice(7);
} else if (req.cookies) {
const { verifyAccessToken, ACCESS_COOKIE } = require('../utils/tokens');
token = req.cookies[ACCESS_COOKIE];
}
if (!token) {
return res.status(401).json({ error: 'Требуется авторизация' });
}
// Проверить токен
const { verifyAccessToken } = require('../utils/tokens');
let payload;
try {
payload = verifyAccessToken(token);
} catch (error) {
logSecurityEvent('INVALID_JWT_TOKEN', req);
return res.status(401).json({ error: 'Неверный токен' });
}
// Найти пользователя
const user = await User.findById(payload.userId);
if (!user) {
return res.status(401).json({ error: 'Пользователь не найден' });
}
if (user.banned) {
return res.status(403).json({ error: 'Аккаунт заблокирован' });
}
req.user = user;
next();
} catch (error) {
console.error('Ошибка JWT авторизации:', error);
res.status(401).json({ error: 'Ошибка авторизации' });
}
};
// Комбинированный middleware: Telegram или JWT
const authenticateModerationFlexible = async (req, res, next) => {
// Попробовать Telegram авторизацию
const authHeader = req.headers.authorization || '';
const hasTelegramAuth = authHeader.startsWith('tma ') || req.headers['x-telegram-init-data'];
if (hasTelegramAuth) {
return authenticateModeration(req, res, next);
} else {
return authenticateJWT(req, res, next);
}
};
module.exports = {
authenticate,
authenticateModeration,
authenticateJWT,
authenticateModerationFlexible,
requireModerator,
requireAdmin,
touchUserActivity,

View File

@ -0,0 +1,48 @@
const config = require('../config');
const { logSecurityEvent } = require('./logger');
// Middleware для проверки что запрос идет от внутреннего бота
// Используется для ботов, которые работают внутри Docker сети
const authenticateBot = (req, res, next) => {
// Получить IP адрес клиента
const clientIp = req.ip || req.connection.remoteAddress || req.socket.remoteAddress;
const forwardedFor = req.headers['x-forwarded-for'];
const realIp = forwardedFor ? forwardedFor.split(',')[0].trim() : clientIp;
// Проверить внутренний токен бота (если установлен)
const botToken = req.headers['x-bot-token'];
const expectedBotToken = process.env.INTERNAL_BOT_TOKEN || config.internalBotToken;
// Проверить что запрос идет из внутренней сети Docker
const isInternalNetwork =
realIp === '127.0.0.1' ||
realIp === '::1' ||
realIp === '::ffff:127.0.0.1' ||
realIp.startsWith('172.') || // Docker network range
realIp.startsWith('192.168.') ||
realIp.startsWith('10.');
// Если установлен токен - проверить его
if (expectedBotToken) {
if (botToken === expectedBotToken) {
req.isBot = true;
return next();
}
logSecurityEvent('INVALID_BOT_TOKEN', req, { ip: realIp });
return res.status(403).json({ error: 'Неверный токен бота' });
}
// Если токена нет - разрешить только из внутренней сети
if (isInternalNetwork || !config.isProduction()) {
req.isBot = true;
return next();
}
logSecurityEvent('EXTERNAL_BOT_ACCESS_ATTEMPT', req, { ip: realIp });
return res.status(403).json({ error: 'Доступ только из внутренней сети' });
};
module.exports = {
authenticateBot
};

View File

@ -0,0 +1,40 @@
const mongoose = require('mongoose');
const EmailVerificationCodeSchema = new mongoose.Schema({
email: {
type: String,
required: true,
lowercase: true,
trim: true,
index: true
},
code: {
type: String,
required: true
},
purpose: {
type: String,
enum: ['registration', 'password_reset'],
default: 'registration'
},
expiresAt: {
type: Date,
required: true,
index: { expireAfterSeconds: 0 } // Автоматическое удаление истекших кодов
},
verified: {
type: Boolean,
default: false
},
attempts: {
type: Number,
default: 0
},
createdAt: {
type: Date,
default: Date.now
}
});
module.exports = mongoose.model('EmailVerificationCode', EmailVerificationCodeSchema);

View File

@ -112,6 +112,22 @@ const UserSchema = new mongoose.Schema({
type: Number,
default: 0
},
// Email и пароль для авторизации через логин/пароль
email: {
type: String,
lowercase: true,
trim: true,
sparse: true, // Разрешить null, но если есть - должен быть уникальным
index: true
},
passwordHash: {
type: String,
select: false // Не возвращать по умолчанию
},
emailVerified: {
type: Boolean,
default: false
},
createdAt: {
type: Date,
default: Date.now

View File

@ -3,7 +3,7 @@ const router = express.Router();
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { authenticateModeration } = require('../middleware/auth');
const { authenticateModerationFlexible } = require('../middleware/auth');
const { logSecurityEvent } = require('../middleware/logger');
const { uploadChannelMedia, cleanupOnError } = require('../middleware/upload');
const { deleteFile } = require('../utils/minio');
@ -14,18 +14,28 @@ const ModerationAdmin = require('../models/ModerationAdmin');
const AdminConfirmation = require('../models/AdminConfirmation');
const { listAdmins, isModerationAdmin, normalizeUsername } = require('../services/moderationAdmin');
const { sendChannelMediaGroup, sendMessageToUser } = require('../bots/serverMonitor');
const { sendAdminConfirmationCode } = require('../utils/email');
const config = require('../config');
const OWNER_USERNAMES = new Set(config.moderationOwnerUsernames || []);
// Проверка доступа к модерации (только для модераторов и админов)
const requireModerationAccess = async (req, res, next) => {
// Проверить роль пользователя
if (!req.user || !['moderator', 'admin'].includes(req.user.role)) {
return res.status(403).json({ error: 'Недостаточно прав для модерации' });
}
// Для JWT авторизации (без Telegram) - достаточно проверки роли
if (!req.user.telegramId) {
req.isModerationAdmin = true;
req.isOwner = req.user.role === 'admin';
return next();
}
const username = normalizeUsername(req.user?.username);
const telegramId = req.user?.telegramId;
if (!username || !telegramId) {
return res.status(401).json({ error: 'Требуется авторизация' });
}
if (OWNER_USERNAMES.has(username) || req.user.role === 'admin') {
req.isModerationAdmin = true;
req.isOwner = true;
@ -49,20 +59,25 @@ const requireOwner = (req, res, next) => {
next();
};
const serializeUser = (user) => ({
id: user._id,
username: user.username,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
banned: user.banned,
bannedUntil: user.bannedUntil,
lastActiveAt: user.lastActiveAt,
createdAt: user.createdAt,
referralsCount: user.referralsCount || 0
});
const serializeUser = (user) => {
if (!user) return null;
return {
id: user._id,
username: user.username,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
banned: user.banned,
bannedUntil: user.bannedUntil,
lastActiveAt: user.lastActiveAt,
createdAt: user.createdAt,
referralsCount: user.referralsCount || 0,
// passwordHash никогда не возвращается (уже select: false в модели)
// email не возвращается для безопасности
};
};
router.post('/auth/verify', authenticateModeration, requireModerationAccess, async (req, res) => {
router.post('/auth/verify', authenticateModerationFlexible, requireModerationAccess, async (req, res) => {
const admins = await listAdmins();
res.json({
@ -79,7 +94,7 @@ router.post('/auth/verify', authenticateModeration, requireModerationAccess, asy
});
});
router.get('/users', authenticateModeration, requireModerationAccess, async (req, res) => {
router.get('/users', authenticateModerationFlexible, 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);
@ -119,7 +134,7 @@ router.get('/users', authenticateModeration, requireModerationAccess, async (req
});
});
router.put('/users/:id/ban', authenticateModeration, requireModerationAccess, async (req, res) => {
router.put('/users/:id/ban', authenticateModerationFlexible, requireModerationAccess, async (req, res) => {
const { banned, days } = req.body;
const user = await User.findById(req.params.id);
@ -139,7 +154,7 @@ router.put('/users/:id/ban', authenticateModeration, requireModerationAccess, as
res.json({ user: serializeUser(user) });
});
router.get('/posts', authenticateModeration, requireModerationAccess, async (req, res) => {
router.get('/posts', authenticateModerationFlexible, 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);
@ -189,7 +204,7 @@ router.get('/posts', authenticateModeration, requireModerationAccess, async (req
});
// Получить пост с комментариями
router.get('/posts/:id', authenticateModeration, requireModerationAccess, async (req, res) => {
router.get('/posts/:id', authenticateModerationFlexible, requireModerationAccess, async (req, res) => {
try {
const post = await Post.findById(req.params.id)
.populate('author', 'username firstName lastName photoUrl')
@ -221,7 +236,7 @@ router.get('/posts/:id', authenticateModeration, requireModerationAccess, async
});
// Удалить комментарий (модераторский интерфейс)
router.delete('/posts/:postId/comments/:commentId', authenticateModeration, requireModerationAccess, async (req, res) => {
router.delete('/posts/:postId/comments/:commentId', authenticateModerationFlexible, requireModerationAccess, async (req, res) => {
try {
const post = await Post.findById(req.params.postId);
@ -245,7 +260,7 @@ router.delete('/posts/:postId/comments/:commentId', authenticateModeration, requ
}
});
router.put('/posts/:id', authenticateModeration, requireModerationAccess, async (req, res) => {
router.put('/posts/:id', authenticateModerationFlexible, requireModerationAccess, async (req, res) => {
const { content, hashtags, tags, isNSFW, isArt } = req.body;
const post = await Post.findById(req.params.id).populate('author');
@ -334,7 +349,7 @@ router.put('/posts/:id', authenticateModeration, requireModerationAccess, async
});
});
router.delete('/posts/:id', authenticateModeration, requireModerationAccess, async (req, res) => {
router.delete('/posts/:id', authenticateModerationFlexible, requireModerationAccess, async (req, res) => {
const post = await Post.findById(req.params.id);
if (!post) {
return res.status(404).json({ error: 'Пост не найден' });
@ -376,7 +391,7 @@ router.delete('/posts/:id', authenticateModeration, requireModerationAccess, asy
res.json({ success: true });
});
router.delete('/posts/:id/images/:index', authenticateModeration, requireModerationAccess, async (req, res) => {
router.delete('/posts/:id/images/:index', authenticateModerationFlexible, requireModerationAccess, async (req, res) => {
const { id, index } = req.params;
const idx = parseInt(index, 10);
@ -409,7 +424,7 @@ router.delete('/posts/:id/images/:index', authenticateModeration, requireModerat
res.json({ images: post.images });
});
router.post('/posts/:id/ban', authenticateModeration, requireModerationAccess, async (req, res) => {
router.post('/posts/:id/ban', authenticateModerationFlexible, requireModerationAccess, async (req, res) => {
const { id } = req.params;
const { days = 7 } = req.body;
@ -426,7 +441,7 @@ router.post('/posts/:id/ban', authenticateModeration, requireModerationAccess, a
res.json({ user: serializeUser(post.author) });
});
router.get('/reports', authenticateModeration, requireModerationAccess, async (req, res) => {
router.get('/reports', authenticateModerationFlexible, 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);
@ -473,7 +488,7 @@ router.get('/reports', authenticateModeration, requireModerationAccess, async (r
});
});
router.put('/reports/:id', authenticateModeration, requireModerationAccess, async (req, res) => {
router.put('/reports/:id', authenticateModerationFlexible, requireModerationAccess, async (req, res) => {
const { status = 'reviewed' } = req.body;
const report = await Report.findById(req.params.id);
@ -491,7 +506,7 @@ router.put('/reports/:id', authenticateModeration, requireModerationAccess, asyn
// ========== УПРАВЛЕНИЕ АДМИНАМИ ==========
// Получить список всех админов
router.get('/admins', authenticateModeration, requireModerationAccess, async (req, res) => {
router.get('/admins', authenticateModerationFlexible, requireModerationAccess, async (req, res) => {
try {
const admins = await ModerationAdmin.find().sort({ adminNumber: 1 });
res.json({
@ -513,7 +528,7 @@ router.get('/admins', authenticateModeration, requireModerationAccess, async (re
});
// Инициировать добавление админа (только для владельца)
router.post('/admins/initiate-add', authenticateModeration, requireModerationAccess, requireOwner, async (req, res) => {
router.post('/admins/initiate-add', authenticateModerationFlexible, requireModerationAccess, requireOwner, async (req, res) => {
try {
const { userId, adminNumber } = req.body;
@ -554,20 +569,34 @@ router.post('/admins/initiate-add', authenticateModeration, requireModerationAcc
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 минут.`
);
// Отправить код на email владельца
try {
await sendAdminConfirmationCode(code, 'add', {
username: user.username,
firstName: user.firstName,
adminNumber: adminNumber
});
} catch (emailError) {
console.error('Ошибка отправки кода на email:', emailError);
// Fallback - отправить в Telegram если email не работает
try {
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 минут.`
);
} catch (telegramError) {
console.error('Ошибка отправки кода в Telegram:', telegramError);
}
}
res.json({
success: true,
message: 'Код подтверждения отправлен вам в бот',
message: 'Код подтверждения отправлен на email владельца',
username: user.username
});
} catch (error) {
@ -577,7 +606,7 @@ router.post('/admins/initiate-add', authenticateModeration, requireModerationAcc
});
// Подтвердить добавление админа
router.post('/admins/confirm-add', authenticateModeration, requireModerationAccess, requireOwner, async (req, res) => {
router.post('/admins/confirm-add', authenticateModerationFlexible, requireModerationAccess, requireOwner, async (req, res) => {
try {
const { userId, code } = req.body;
@ -651,7 +680,7 @@ router.post('/admins/confirm-add', authenticateModeration, requireModerationAcce
});
// Инициировать удаление админа (только для владельца)
router.post('/admins/initiate-remove', authenticateModeration, requireModerationAccess, requireOwner, async (req, res) => {
router.post('/admins/initiate-remove', authenticateModerationFlexible, requireModerationAccess, requireOwner, async (req, res) => {
try {
const { adminId } = req.body;
@ -675,20 +704,34 @@ router.post('/admins/initiate-remove', authenticateModeration, requireModeration
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 минут.`
);
// Отправить код на email владельца
try {
await sendAdminConfirmationCode(code, 'remove', {
username: admin.username,
firstName: admin.firstName,
adminNumber: admin.adminNumber
});
} catch (emailError) {
console.error('Ошибка отправки кода на email:', emailError);
// Fallback - отправить в Telegram если email не работает
try {
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 минут.`
);
} catch (telegramError) {
console.error('Ошибка отправки кода в Telegram:', telegramError);
}
}
res.json({
success: true,
message: 'Код подтверждения отправлен вам в бот',
message: 'Код подтверждения отправлен на email владельца',
username: admin.username
});
} catch (error) {
@ -698,7 +741,7 @@ router.post('/admins/initiate-remove', authenticateModeration, requireModeration
});
// Подтвердить удаление админа
router.post('/admins/confirm-remove', authenticateModeration, requireModerationAccess, requireOwner, async (req, res) => {
router.post('/admins/confirm-remove', authenticateModerationFlexible, requireModerationAccess, requireOwner, async (req, res) => {
try {
const { adminId, code } = req.body;
@ -750,7 +793,7 @@ router.post('/admins/confirm-remove', authenticateModeration, requireModerationA
router.post(
'/channel/publish',
authenticateModeration,
authenticateModerationFlexible,
requireModerationAccess,
uploadChannelMedia,
async (req, res) => {

View File

@ -0,0 +1,413 @@
const express = require('express');
const router = express.Router();
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const User = require('../models/User');
const EmailVerificationCode = require('../models/EmailVerificationCode');
const { sendVerificationCode } = require('../utils/email');
const { signAuthTokens, setAuthCookies, clearAuthCookies, verifyAccessToken } = require('../utils/tokens');
const { logSecurityEvent } = require('../middleware/logger');
const { authenticateModeration } = require('../middleware/auth');
const { isEmail } = require('validator');
// Rate limiting для авторизации
const rateLimit = require('express-rate-limit');
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 минут
max: 5, // 5 попыток
message: 'Слишком много попыток авторизации. Попробуйте позже.'
});
const codeLimiter = rateLimit({
windowMs: 60 * 1000, // 1 минута
max: 1, // 1 запрос в минуту
message: 'Подождите минуту перед следующим запросом кода.'
});
// Отправка кода подтверждения на email
router.post('/send-code', codeLimiter, async (req, res) => {
try {
const { email } = req.body;
if (!email || !isEmail(email)) {
return res.status(400).json({ error: 'Неверный email адрес' });
}
// Проверить, есть ли уже пользователь с этим email (но только если он модератор/админ)
const existingUser = await User.findOne({
email: email.toLowerCase(),
role: { $in: ['moderator', 'admin'] }
});
if (!existingUser) {
return res.status(403).json({
error: 'Регистрация недоступна. Обратитесь к администратору для получения доступа.'
});
}
// Генерировать 6-значный код
const code = crypto.randomInt(100000, 999999).toString();
// Удалить старые коды для этого email
await EmailVerificationCode.deleteMany({
email: email.toLowerCase(),
purpose: 'registration'
});
// Сохранить новый код (действителен 15 минут)
const verificationCode = new EmailVerificationCode({
email: email.toLowerCase(),
code,
purpose: 'registration',
expiresAt: new Date(Date.now() + 15 * 60 * 1000) // 15 минут
});
await verificationCode.save();
// Отправить код на email
try {
await sendVerificationCode(email, code);
res.json({
success: true,
message: 'Код подтверждения отправлен на email'
});
} catch (emailError) {
console.error('Ошибка отправки email:', emailError);
await EmailVerificationCode.deleteOne({ _id: verificationCode._id });
return res.status(500).json({
error: 'Не удалось отправить код на email. Проверьте настройки email сервера.'
});
}
} catch (error) {
console.error('Ошибка отправки кода:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Регистрация с кодом подтверждения
router.post('/register', authLimiter, async (req, res) => {
try {
const { email, code, password, username } = req.body;
if (!email || !code || !password || !username) {
return res.status(400).json({ error: 'Все поля обязательны' });
}
if (!isEmail(email)) {
return res.status(400).json({ error: 'Неверный email адрес' });
}
if (password.length < 6) {
return res.status(400).json({ error: 'Пароль должен содержать минимум 6 символов' });
}
// Найти код подтверждения
const verificationCode = await EmailVerificationCode.findOne({
email: email.toLowerCase(),
code,
purpose: 'registration',
verified: false
});
if (!verificationCode) {
return res.status(400).json({ error: 'Неверный или истекший код' });
}
// Проверить срок действия
if (new Date() > verificationCode.expiresAt) {
await EmailVerificationCode.deleteOne({ _id: verificationCode._id });
return res.status(400).json({ error: 'Код истек. Запросите новый.' });
}
// Найти пользователя (должен быть создан администратором)
const user = await User.findOne({
email: email.toLowerCase(),
role: { $in: ['moderator', 'admin'] }
});
if (!user) {
return res.status(403).json({
error: 'Регистрация недоступна. Обратитесь к администратору.'
});
}
// Если у пользователя уже есть пароль - ошибка
if (user.passwordHash) {
return res.status(400).json({
error: 'Аккаунт уже зарегистрирован. Используйте вход по паролю.'
});
}
// Захешировать пароль
const passwordHash = await bcrypt.hash(password, 10);
// Обновить пользователя
user.passwordHash = passwordHash;
user.emailVerified = true;
user.username = username || user.username;
await user.save();
// Пометить код как использованный
verificationCode.verified = true;
await verificationCode.save();
// Генерировать токены
const tokens = signAuthTokens(user);
// Установить cookies
setAuthCookies(res, tokens);
res.json({
success: true,
user: {
id: user._id,
username: user.username,
role: user.role
},
accessToken: tokens.accessToken
});
} catch (error) {
console.error('Ошибка регистрации:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Авторизация по email и паролю
router.post('/login', authLimiter, async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email и пароль обязательны' });
}
if (!isEmail(email)) {
return res.status(400).json({ error: 'Неверный email адрес' });
}
// Найти пользователя с паролем (только модераторы и админы)
const user = await User.findOne({
email: email.toLowerCase(),
passwordHash: { $exists: true, $ne: null },
role: { $in: ['moderator', 'admin'] }
}).select('+passwordHash');
if (!user) {
logSecurityEvent('MODERATION_LOGIN_FAILED', req, { email: email.toLowerCase() });
return res.status(401).json({ error: 'Неверный email или пароль' });
}
if (user.banned) {
return res.status(403).json({ error: 'Аккаунт заблокирован' });
}
// Проверить пароль
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
if (!isPasswordValid) {
logSecurityEvent('MODERATION_LOGIN_FAILED', req, { email: email.toLowerCase(), userId: user._id });
return res.status(401).json({ error: 'Неверный email или пароль' });
}
// Обновить время последней активности
user.lastActiveAt = new Date();
await user.save();
// Генерировать токены
const tokens = signAuthTokens(user);
// Установить cookies
setAuthCookies(res, tokens);
logSecurityEvent('MODERATION_LOGIN_SUCCESS', req, { userId: user._id, email: user.email });
// Email не возвращаем в ответе для безопасности
res.json({
success: true,
user: {
id: user._id,
username: user.username,
role: user.role,
telegramId: user.telegramId
},
accessToken: tokens.accessToken
});
} catch (error) {
console.error('Ошибка авторизации:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Авторизация через Telegram Login Widget (для обычного браузера)
router.post('/telegram-widget', authLimiter, async (req, res) => {
try {
const { id, first_name, last_name, username, photo_url, auth_date, hash } = req.body;
if (!id || !hash || !auth_date) {
return res.status(400).json({ error: 'Неполные данные от Telegram Login Widget' });
}
// Проверить подпись (базовая проверка)
// В production нужно проверить hash через Bot API
// Для модерации используем упрощенную проверку - ищем пользователя по telegramId
const user = await User.findOne({ telegramId: id.toString() });
if (!user) {
return res.status(404).json({ error: 'Пользователь не найден. Сначала зарегистрируйтесь через бота.' });
}
if (!['moderator', 'admin'].includes(user.role)) {
return res.status(403).json({ error: 'Доступ запрещен. У вас нет прав модератора.' });
}
if (user.banned) {
return res.status(403).json({ error: 'Аккаунт заблокирован' });
}
// Обновить данные пользователя из виджета
if (username && !user.username) {
user.username = username;
}
if (first_name && !user.firstName) {
user.firstName = first_name;
}
if (last_name && !user.lastName) {
user.lastName = last_name;
}
if (photo_url && !user.photoUrl) {
user.photoUrl = photo_url;
}
user.lastActiveAt = new Date();
await user.save();
// Генерировать JWT токены
const tokens = signAuthTokens(user);
setAuthCookies(res, tokens);
logSecurityEvent('MODERATION_TELEGRAM_WIDGET_LOGIN_SUCCESS', req, { userId: user._id });
res.json({
success: true,
user: {
id: user._id,
username: user.username,
role: user.role,
telegramId: user.telegramId
},
accessToken: tokens.accessToken
});
} catch (error) {
console.error('Ошибка авторизации через Telegram Widget:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Авторизация через Telegram (для модерации)
router.post('/telegram', authLimiter, authenticateModeration, async (req, res) => {
try {
const user = req.user;
if (!user || !['moderator', 'admin'].includes(user.role)) {
return res.status(403).json({ error: 'Доступ запрещен' });
}
if (user.banned) {
return res.status(403).json({ error: 'Аккаунт заблокирован' });
}
// Обновить время последней активности
user.lastActiveAt = new Date();
await user.save();
// Генерировать токены
const tokens = signAuthTokens(user);
// Установить cookies
setAuthCookies(res, tokens);
logSecurityEvent('MODERATION_TELEGRAM_LOGIN_SUCCESS', req, { userId: user._id });
// Email не возвращаем в ответе для безопасности
res.json({
success: true,
user: {
id: user._id,
username: user.username,
role: user.role,
telegramId: user.telegramId
},
accessToken: tokens.accessToken
});
} catch (error) {
console.error('Ошибка авторизации через Telegram:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Выход
router.post('/logout', (req, res) => {
clearAuthCookies(res);
res.json({ success: true });
});
// Проверка текущей сессии
router.get('/me', async (req, res) => {
try {
// Получить токен из заголовка или cookie
let token = null;
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
token = authHeader.slice(7);
} else if (req.cookies && req.cookies[require('../utils/tokens').ACCESS_COOKIE]) {
token = req.cookies[require('../utils/tokens').ACCESS_COOKIE];
}
if (!token) {
return res.status(401).json({ error: 'Не авторизован' });
}
// Проверить токен
let payload;
try {
payload = verifyAccessToken(token);
} catch (error) {
return res.status(401).json({ error: 'Неверный токен' });
}
// Найти пользователя
const user = await User.findById(payload.userId);
if (!user) {
return res.status(401).json({ error: 'Пользователь не найден' });
}
if (user.banned) {
return res.status(403).json({ error: 'Аккаунт заблокирован' });
}
// Проверить роль (только модераторы и админы)
if (!['moderator', 'admin'].includes(user.role)) {
return res.status(403).json({ error: 'Доступ запрещен' });
}
res.json({
success: true,
user: {
id: user._id,
username: user.username,
role: user.role,
telegramId: user.telegramId
}
});
} catch (error) {
console.error('Ошибка проверки сессии:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
module.exports = router;

View File

@ -1,6 +1,7 @@
const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');
const cookieParser = require('cookie-parser');
const dotenv = require('dotenv');
const path = require('path');
const http = require('http');
@ -54,6 +55,9 @@ const corsOptions = {
app.use(cors(corsOptions));
// Cookie parser для JWT токенов в cookies
app.use(cookieParser());
// Body parsing с ограничениями
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
@ -244,6 +248,7 @@ app.use('/api/moderation', require('./routes/moderation'));
app.use('/api/statistics', require('./routes/statistics'));
app.use('/api/bot', require('./routes/bot'));
app.use('/api/mod-app', require('./routes/modApp'));
app.use('/api/moderation-auth', require('./routes/moderationAuth'));
app.use('/api/minio', require('./routes/minio-test'));
app.use('/api/tags', require('./routes/tags'));

184
backend/utils/email.js Normal file
View File

@ -0,0 +1,184 @@
const AWS = require('aws-sdk');
const nodemailer = require('nodemailer');
const config = require('../config');
// Инициализация AWS SES
let sesClient = null;
let transporter = null;
const initializeEmailService = () => {
const emailProvider = process.env.EMAIL_PROVIDER || 'aws'; // aws, yandex, smtp
if (emailProvider === 'aws' && config.email?.aws) {
sesClient = new AWS.SES({
accessKeyId: config.email.aws.accessKeyId,
secretAccessKey: config.email.aws.secretAccessKey,
region: config.email.aws.region || 'us-east-1'
});
} else if (emailProvider === 'yandex' || emailProvider === 'smtp') {
const emailConfig = config.email?.[emailProvider] || config.email?.smtp || {};
transporter = nodemailer.createTransport({
host: emailConfig.host || process.env.SMTP_HOST,
port: emailConfig.port || parseInt(process.env.SMTP_PORT || '587', 10),
secure: emailConfig.secure === true || process.env.SMTP_SECURE === 'true',
auth: {
user: emailConfig.user || process.env.SMTP_USER,
pass: emailConfig.password || process.env.SMTP_PASSWORD
}
});
}
};
// Генерация HTML письма с кодом
const generateVerificationEmail = (code) => {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.code { font-size: 32px; font-weight: bold; color: #007bff;
text-align: center; padding: 20px; background: #f8f9fa;
border-radius: 8px; margin: 20px 0; letter-spacing: 8px; }
.footer { margin-top: 30px; font-size: 12px; color: #666; }
</style>
</head>
<body>
<div class="container">
<h1>Код подтверждения</h1>
<p>Ваш код для регистрации в Nakama:</p>
<div class="code">${code}</div>
<p>Код действителен в течение 15 минут.</p>
<div class="footer">
<p>Если вы не запрашивали этот код, просто проигнорируйте это письмо.</p>
</div>
</div>
</body>
</html>
`;
};
const sendEmail = async (to, subject, html, text) => {
try {
const emailProvider = process.env.EMAIL_PROVIDER || 'aws';
const fromEmail = process.env.EMAIL_FROM || config.email?.from || 'noreply@nakama.guru';
if (emailProvider === 'aws' && sesClient) {
// Отправка через AWS SES
const params = {
Source: fromEmail,
Destination: {
ToAddresses: [to]
},
Message: {
Subject: {
Data: subject,
Charset: 'UTF-8'
},
Body: {
Html: {
Data: html,
Charset: 'UTF-8'
},
Text: {
Data: text || html.replace(/<[^>]*>/g, ''),
Charset: 'UTF-8'
}
}
}
};
const result = await sesClient.sendEmail(params).promise();
return { success: true, messageId: result.MessageId };
} else if (transporter) {
// Отправка через SMTP (Yandex, Gmail и т.д.)
const info = await transporter.sendMail({
from: fromEmail,
to,
subject,
html,
text: text || html.replace(/<[^>]*>/g, '')
});
return { success: true, messageId: info.messageId };
} else {
throw new Error('Email service not configured');
}
} catch (error) {
console.error('Ошибка отправки email:', error);
throw error;
}
};
const sendVerificationCode = async (email, code) => {
const subject = 'Код подтверждения регистрации - Nakama';
const html = generateVerificationEmail(code);
const text = `Ваш код подтверждения: ${code}. Код действителен 15 минут.`;
return await sendEmail(email, subject, html, text);
};
// Генерация HTML письма с кодом для админа
const generateAdminConfirmationEmail = (code, action, userInfo) => {
const actionText = action === 'add' ? 'добавления админа' : 'удаления админа';
const userDetails = userInfo ? `
<p><strong>Пользователь:</strong> @${userInfo.username} (${userInfo.firstName})</p>
${userInfo.adminNumber ? `<p><strong>Номер админа:</strong> ${userInfo.adminNumber}</p>` : ''}
` : '';
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.code { font-size: 32px; font-weight: bold; color: #007bff;
text-align: center; padding: 20px; background: #f8f9fa;
border-radius: 8px; margin: 20px 0; letter-spacing: 8px; }
.footer { margin-top: 30px; font-size: 12px; color: #666; }
.info { background: #e7f3ff; padding: 15px; border-radius: 8px; margin: 20px 0; }
</style>
</head>
<body>
<div class="container">
<h1>Подтверждение ${actionText}</h1>
${userDetails}
<div class="info">
<p><strong>Код подтверждения:</strong></p>
<div class="code">${code}</div>
<p>Код действителен в течение 5 минут.</p>
</div>
<div class="footer">
<p>Если вы не запрашивали это подтверждение, проигнорируйте это письмо.</p>
</div>
</div>
</body>
</html>
`;
};
const sendAdminConfirmationCode = async (code, action, userInfo) => {
const ownerEmail = config.ownerEmail || process.env.OWNER_EMAIL || 'aaem9848@gmail.com';
const actionText = action === 'add' ? 'добавления админа' : 'удаления админа';
const subject = `Код подтверждения ${actionText} - Nakama Moderation`;
const html = generateAdminConfirmationEmail(code, action, userInfo);
const text = `Код подтверждения ${actionText}: ${code}\n\nПользователь: @${userInfo?.username || 'не указан'}\nКод действителен 5 минут.`;
return await sendEmail(ownerEmail, subject, html, text);
};
// Инициализация при загрузке модуля
initializeEmailService();
module.exports = {
sendEmail,
sendVerificationCode,
sendAdminConfirmationCode,
initializeEmailService
};

View File

@ -7,7 +7,8 @@ const REFRESH_COOKIE = config.jwt.refreshCookieName;
const buildPayload = (user) => ({
userId: user._id.toString(),
telegramId: user.telegramId,
role: user.role
role: user.role,
// Не включаем email или passwordHash в JWT токен для безопасности
});
const signAccessToken = (user) =>

View File

@ -62,20 +62,87 @@ services:
depends_on:
- backend
moderation:
moderation-backend:
build:
context: .
dockerfile: Dockerfile.moderation-backend
container_name: nakama-moderation-backend
restart: unless-stopped
expose:
- "3001"
environment:
- NODE_ENV=production
- PORT=3001
- MODERATION_PORT=3001
- MONGODB_URI=${MONGODB_URI:-mongodb://103.80.87.247:27017/nakama}
- MODERATION_BOT_TOKEN=${MODERATION_BOT_TOKEN}
- MODERATION_OWNER_USERNAMES=${MODERATION_OWNER_USERNAMES:-glpshchn00}
- MODERATION_CORS_ORIGIN=${MODERATION_CORS_ORIGIN:-https://moderation.nkm.guru}
- JWT_SECRET=${JWT_SECRET}
- JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET}
- JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
- REDIS_URL=${REDIS_URL}
- EMAIL_PROVIDER=${EMAIL_PROVIDER:-aws}
- EMAIL_FROM=${EMAIL_FROM:-noreply@nakama.guru}
- AWS_SES_ACCESS_KEY_ID=${AWS_SES_ACCESS_KEY_ID}
- AWS_SES_SECRET_ACCESS_KEY=${AWS_SES_SECRET_ACCESS_KEY}
- AWS_SES_REGION=${AWS_SES_REGION:-us-east-1}
- YANDEX_SMTP_USER=${YANDEX_SMTP_USER}
- YANDEX_SMTP_PASSWORD=${YANDEX_SMTP_PASSWORD}
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT}
- SMTP_USER=${SMTP_USER}
- SMTP_PASSWORD=${SMTP_PASSWORD}
- SMTP_SECURE=${SMTP_SECURE:-false}
# MinIO Configuration
- MINIO_ENABLED=${MINIO_ENABLED:-true}
- MINIO_ENDPOINT=${MINIO_ENDPOINT:-103.80.87.247}
- MINIO_PORT=${MINIO_PORT:-9000}
- MINIO_USE_SSL=${MINIO_USE_SSL:-false}
- MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
- MINIO_BUCKET=${MINIO_BUCKET:-nakama-media}
networks:
- nakama-network
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
moderation-frontend:
build:
context: .
dockerfile: Dockerfile.moderation
args:
VITE_API_URL: ${VITE_API_URL:-http://localhost:3000/api}
container_name: nakama-moderation
VITE_API_URL: ${VITE_MODERATION_API_URL:-https://moderation.nkm.guru/api}
container_name: nakama-moderation-frontend
restart: unless-stopped
ports:
- "5174:80"
expose:
- "80"
networks:
- nakama-network
depends_on:
- backend
- moderation-backend
nginx-moderation:
image: nginx:alpine
container_name: nakama-nginx-moderation
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx-moderation-production.conf:/etc/nginx/conf.d/default.conf:ro
- /etc/letsencrypt:/etc/letsencrypt:ro
- /var/www/certbot:/var/www/certbot:ro
- /var/log/nginx:/var/log/nginx
networks:
- nakama-network
depends_on:
- moderation-backend
- moderation-frontend
# MongoDB находится на удаленном сервере (103.80.87.247:27017)
# Локальный MongoDB контейнер не нужен

140
moderation/DEPLOY.md Normal file
View File

@ -0,0 +1,140 @@
# Деплой модераторского сайта
## Система авторизации
Модераторский сайт поддерживает два способа авторизации:
1. **Telegram WebApp** - автоматическая авторизация через Telegram бота
2. **Email/Пароль** - регистрация через код на email и вход по паролю
## Настройка email сервиса
### AWS SES
```bash
EMAIL_PROVIDER=aws
AWS_SES_ACCESS_KEY_ID=your_access_key
AWS_SES_SECRET_ACCESS_KEY=your_secret_key
AWS_SES_REGION=us-east-1
EMAIL_FROM=noreply@nakama.guru
```
### Yandex Cloud
```bash
EMAIL_PROVIDER=yandex
YANDEX_SMTP_USER=your_email@yandex.ru
YANDEX_SMTP_PASSWORD=your_app_password
EMAIL_FROM=noreply@nakama.guru
```
### SMTP (любой провайдер)
```bash
EMAIL_PROVIDER=smtp
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=your_email@example.com
SMTP_PASSWORD=your_password
SMTP_SECURE=false
EMAIL_FROM=noreply@nakama.guru
```
## Процесс регистрации
1. Администратор должен создать пользователя в БД с email и ролью moderator/admin
2. Пользователь вводит email на странице регистрации
3. Система отправляет 6-значный код на email
4. Пользователь вводит код, username и пароль
5. Аккаунт активируется
## Деплой с Docker
### 1. Настройка переменных окружения
Добавьте в `.env`:
```bash
# Модерация
MODERATION_PORT=3001
MODERATION_CORS_ORIGIN=https://moderation.nkm.guru
VITE_MODERATION_API_URL=https://moderation.nkm.guru/api
# Email для кодов подтверждения админа
OWNER_EMAIL=aaem9848@gmail.com
# Email настройки (выберите один вариант выше)
EMAIL_PROVIDER=aws
# ... остальные настройки email
```
### 2. Сборка и запуск
```bash
# Сборка всех образов
docker-compose build moderation-backend moderation-frontend
# Запуск сервисов
docker-compose up -d moderation-backend moderation-frontend nginx-moderation
# Проверка статуса
docker-compose ps
```
### 3. Получение SSL сертификата
```bash
# Получить сертификат (первый раз)
sudo certbot certonly --standalone -d moderation.nkm.guru
# Автоматическое обновление
sudo certbot renew --dry-run
```
### 4. Проверка работы
```bash
# Проверка бэкенда
curl http://localhost:3001/health
# Проверка через nginx
curl https://moderation.nkm.guru/api/health
# Логи
docker-compose logs -f moderation-backend
```
## Обновление
```bash
# Обновить код
git pull
# Пересобрать и перезапустить
docker-compose build moderation-backend moderation-frontend
docker-compose up -d moderation-backend moderation-frontend
# Перезагрузить nginx
docker-compose restart nginx-moderation
```
## Структура
- `moderation-backend` - API сервер модерации (порт 3001)
- `moderation-frontend` - Frontend приложение (nginx)
- `nginx-moderation` - Reverse proxy с SSL
## Troubleshooting
### Email не отправляется
1. Проверьте настройки EMAIL_PROVIDER в .env
2. Проверьте логи: `docker-compose logs moderation-backend`
3. Убедитесь что credentials правильные
### Не работает авторизация
1. Проверьте JWT_SECRET в .env
2. Проверьте что пользователь создан в БД с правильной ролью
3. Проверьте логи авторизации

132
moderation/README.md Normal file
View File

@ -0,0 +1,132 @@
# Nakama Moderation Panel
Панель модерации для Nakama - полнофункциональный веб-интерфейс для модераторов.
## Возможности
- 📱 **Адаптивный дизайн** - работает на мобильных устройствах и десктопах
- 🌐 **Веб-версия** - доступна как в Telegram WebApp, так и в обычном браузере
- 👥 **Управление пользователями** - просмотр, фильтрация, блокировка пользователей
- 📝 **Управление постами** - редактирование, удаление, пометка артом
- 🚨 **Обработка репортов** - модерация жалоб пользователей
- 💬 **Чат модераторов** - real-time чат для общения между модераторами
- 📢 **Публикация в канал** - публикация контента в Telegram канал
- 👑 **Управление админами** - назначение и снятие администраторов
## Требования
- Node.js 18+
- MongoDB (используется та же БД что и основной проект)
- Доступ к `.env` файлу в корне проекта
## Установка
1. Установите зависимости в корне проекта:
```bash
npm install
```
2. Установите зависимости фронтенда модерации:
```bash
cd moderation/frontend
npm install
```
3. Убедитесь, что в корневом `.env` файле настроены:
- `MONGODB_URI` - URI для подключения к MongoDB
- `MODERATION_BOT_TOKEN` - токен бота для модерации
- `MODERATION_OWNER_USERNAMES` - список владельцев (через запятую)
- `MODERATION_PORT` - порт для бэкенда модерации (по умолчанию 3001)
## Запуск
### Development режим
Запустить бэкенд и фронтенд модерации одновременно:
```bash
npm run mod-dev
```
Или отдельно:
```bash
# Бэкенд (порт 3001)
npm run mod-server
# Фронтенд (порт 5174)
npm run mod-client
```
### Production режим
1. Соберите фронтенд:
```bash
npm run mod-build
```
2. Запустите бэкенд:
```bash
npm run mod-start
```
## Доступ
### В Telegram
Откройте бота модерации и перейдите в меню веб-приложения.
### В браузере
1. Откройте `http://localhost:5174` (development) или ваш production URL
2. Войдите используя initData из Telegram WebApp или токен доступа
## Структура проекта
```
moderation/
├── backend/
│ └── server.js # Бэкенд сервер модерации
├── frontend/
│ ├── src/
│ │ ├── App.jsx # Главный компонент
│ │ ├── utils/
│ │ │ └── api.js # API клиент
│ │ └── styles.css # Стили с адаптивным дизайном
│ └── vite.config.js # Конфигурация Vite
└── README.md
```
## Технологии
- **Backend**: Express.js, использует общие middleware и модели из основного бэкенда
- **Frontend**: React, Vite
- **WebSocket**: Socket.io для real-time чата модераторов
- **Стили**: CSS с адаптивными media queries
## Переменные окружения
Модераторский бэкенд использует переменные из корневого `.env`:
- `MONGODB_URI` - подключение к MongoDB
- `MODERATION_BOT_TOKEN` - токен бота
- `MODERATION_OWNER_USERNAMES` - владельцы системы
- `MODERATION_PORT` - порт бэкенда (по умолчанию 3001)
- `MODERATION_CORS_ORIGIN` - CORS origin (опционально)
- Все остальные переменные из основного `.env`
## Адаптивный дизайн
Приложение автоматически адаптируется под разные размеры экранов:
- **Desktop (>1024px)**: Многоколоночный layout, расширенные элементы управления
- **Tablet (768px-1023px)**: Одноколоночный layout с оптимизированными элементами
- **Mobile (<768px)**: Компактный layout, вертикальное меню, сенсорные элементы
## Безопасность
- Все запросы требуют авторизации через Telegram initData
- Используются те же middleware безопасности что и в основном бэкенде
- Rate limiting для защиты от DDoS
- Валидация и санитизация всех входных данных
## Поддержка
При возникновении проблем обращайтесь к владельцу системы.

View File

@ -0,0 +1,15 @@
{
"name": "nakama-moderation-backend",
"version": "1.0.0",
"description": "Nakama Moderation Backend Server",
"main": "server.js",
"scripts": {
"dev": "nodemon server.js",
"start": "node server.js"
},
"keywords": ["moderation", "admin"],
"author": "",
"license": "MIT",
"dependencies": {}
}

View File

@ -0,0 +1,153 @@
const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');
const cookieParser = require('cookie-parser');
const dotenv = require('dotenv');
const path = require('path');
const http = require('http');
// Загрузить переменные окружения из корня проекта
const rootEnvPath = path.resolve(__dirname, '../../.env');
dotenv.config({ path: rootEnvPath });
const config = require('../../backend/config');
const { initWebSocket } = require('../../backend/websocket');
// Security middleware
const {
helmetConfig,
sanitizeMongo,
xssProtection,
hppProtection,
ddosProtection
} = require('../../backend/middleware/security');
const { sanitizeInput } = require('../../backend/middleware/validator');
const { requestLogger } = require('../../backend/middleware/logger');
const { errorHandler, notFoundHandler } = require('../../backend/middleware/errorHandler');
const { generalLimiter } = require('../../backend/middleware/rateLimiter');
const app = express();
const server = http.createServer(app);
// Trust proxy для правильного IP
if (config.isProduction()) {
app.set('trust proxy', 1);
}
// Security headers
app.use(helmetConfig);
// CORS настройки для модераторского сайта
const corsOptions = {
origin: process.env.MODERATION_CORS_ORIGIN || config.frontendUrl || '*',
credentials: true,
optionsSuccessStatus: 200,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'x-telegram-init-data'],
maxAge: 86400
};
app.use(cors(corsOptions));
// Cookie parser для JWT токенов в cookies
app.use(cookieParser());
// Body parsing
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Security middleware
app.use(sanitizeMongo);
app.use(xssProtection);
app.use(hppProtection);
// Input sanitization
app.use(sanitizeInput);
// Request logging
app.use(requestLogger);
// DDoS protection
app.use(ddosProtection);
// Rate limiting
app.use('/api', generalLimiter);
// Health check
app.get('/health', (req, res) => {
res.json({
status: 'ok',
service: 'moderation',
timestamp: new Date().toISOString()
});
});
// MongoDB подключение (используем ту же БД)
mongoose.connect(config.mongoUri)
.then(async () => {
console.log('✅ MongoDB подключена для модерации');
mongoose.connection.on('error', (err) => {
console.error('❌ MongoDB connection error:', err);
});
mongoose.connection.on('disconnected', () => {
console.warn('⚠️ MongoDB отключена');
});
mongoose.connection.on('reconnected', () => {
console.log('✅ MongoDB переподключена');
});
})
.catch(err => {
console.error('❌ Не удалось подключиться к MongoDB:', err);
process.exit(1);
});
// Routes - используем те же роуты из основного бэкенда
app.use('/api/mod-app', require('../../backend/routes/modApp'));
app.use('/api/moderation-auth', require('../../backend/routes/moderationAuth'));
// Базовый роут
app.get('/', (req, res) => {
res.json({ message: 'Nakama Moderation API' });
});
// 404 handler
app.use(notFoundHandler);
// Error handler
app.use(errorHandler);
// Инициализировать WebSocket
initWebSocket(server);
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('SIGTERM получен, закрываем сервер модерации...');
server.close(() => {
mongoose.connection.close(false, () => {
process.exit(0);
});
});
});
process.on('SIGINT', () => {
console.log('SIGINT получен, закрываем сервер модерации...');
server.close(() => {
mongoose.connection.close(false, () => {
process.exit(0);
});
});
});
const moderationPort = process.env.MODERATION_PORT || 3001;
server.listen(moderationPort, '0.0.0.0', () => {
console.log('\n' + '='.repeat(60));
console.log('✅ Сервер модерации запущен');
console.log(` 🌐 API: http://0.0.0.0:${moderationPort}/api`);
console.log(` 📦 MongoDB: ${config.mongoUri.includes('localhost') ? 'Local' : 'Remote'}`);
console.log('='.repeat(60) + '\n');
});

View File

@ -2,11 +2,35 @@
<html lang="ru">
<head>
<meta charset="UTF-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=5.0, user-scalable=yes" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Nakama Moderation Panel - Панель модерации" />
<title>Nakama Moderation</title>
<!-- Telegram Web App SDK - прямая загрузка -->
<!-- Telegram Web App SDK -->
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<!-- Telegram Login Widget SDK -->
<script async src="https://telegram.org/js/telegram-widget.js?22" data-telegram-login="NakamaSpaceBot" data-size="large" data-request-access="write"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: #111;
color: #f5f5f7;
overflow-x: hidden;
}
#root {
min-height: 100vh;
}
</style>
</head>
<body>
<div id="root"></div>

View File

@ -1,6 +1,11 @@
import { useEffect, useRef, useState } from 'react';
import {
verifyAuth,
getCurrentUser,
sendVerificationCode,
registerWithCode,
login,
loginTelegram,
logout,
fetchUsers,
banUser,
fetchPosts,
@ -17,7 +22,8 @@ import {
initiateRemoveAdmin,
confirmRemoveAdmin,
getPostComments,
deleteComment
deleteComment,
getApiUrl
} from './utils/api';
import { io } from 'socket.io-client';
import {
@ -118,43 +124,135 @@ export default function App() {
// Comments modal
const [commentsModal, setCommentsModal] = useState(null); // { postId, comments: [] }
const [commentsLoading, setCommentsLoading] = useState(false);
// Форма авторизации
const [authForm, setAuthForm] = useState({
step: 'login', // 'login', 'register-step1', 'register-step2'
email: '',
password: '',
code: '',
username: '',
showPassword: false
});
const [authLoading, setAuthLoading] = useState(false);
useEffect(() => {
let cancelled = false;
// Инициализация Telegram Login Widget для обычного браузера
const initTelegramWidget = () => {
// Глобальная функция для обработки авторизации через виджет
window.onTelegramAuth = async (userData) => {
console.log('Telegram Login Widget данные:', userData);
try {
setAuthLoading(true);
// Отправить данные виджета на сервер для создания сессии
const API_URL = getApiUrl();
const response = await fetch(`${API_URL}/moderation-auth/telegram-widget`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(userData)
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'Ошибка авторизации');
}
const result = await response.json();
if (result.accessToken) {
localStorage.setItem('moderation_jwt_token', result.accessToken);
}
if (result?.user) {
setUser(result.user);
setError(null);
}
} catch (err) {
console.error('Ошибка авторизации через виджет:', err);
setError(err.message || 'Ошибка авторизации через Telegram');
} finally {
setAuthLoading(false);
}
};
// Загрузить виджет скрипт если его нет и есть контейнер
if (!document.querySelector('script[src*="telegram-widget"]')) {
setTimeout(() => {
const widgetContainer = telegramWidgetRef.current;
if (!widgetContainer) return;
const script = document.createElement('script');
script.async = true;
script.src = 'https://telegram.org/js/telegram-widget.js?22';
script.setAttribute('data-telegram-login', 'NakamaSpaceBot');
script.setAttribute('data-size', 'large');
script.setAttribute('data-request-access', 'write');
script.setAttribute('data-onauth', 'onTelegramAuth');
widgetContainer.appendChild(script);
}, 100);
}
};
const init = async () => {
try {
const telegramApp = window.Telegram?.WebApp;
if (!telegramApp) {
throw new Error('Откройте модераторский интерфейс из Telegram (бот @rbachbot).');
// Если это Telegram WebApp - попробовать авторизацию через Telegram
if (telegramApp && telegramApp.initData) {
telegramApp.disableVerticalSwipes?.();
telegramApp.expand?.();
try {
const result = await loginTelegram();
if (cancelled) return;
setUser(result.user);
setError(null);
setLoading(false);
return;
} catch (err) {
console.warn('Telegram авторизация не удалась, пробуем JWT:', err);
}
}
if (!telegramApp.initData) {
throw new Error('Telegram не передал initData. Откройте приложение из официального клиента.');
// Инициализировать виджет для обычного браузера
if (!telegramApp?.initData) {
initTelegramWidget();
}
telegramApp.disableVerticalSwipes?.();
telegramApp.expand?.();
// Проверить JWT токен
const jwtToken = localStorage.getItem('moderation_jwt_token');
if (jwtToken) {
try {
const userData = await getCurrentUser();
if (cancelled) return;
const userData = await verifyAuth();
if (cancelled) return;
setUser(userData);
setError(null);
setLoading(false);
return;
} catch (err) {
// Если токен невалиден - очистить
localStorage.removeItem('moderation_jwt_token');
localStorage.removeItem('moderation_token');
}
}
setUser(userData);
setError(null);
// Нет токена - показать форму входа
setLoading(false);
setError('login_required');
} catch (err) {
if (cancelled) return;
console.error('Ошибка инициализации модератора:', err);
const message =
err?.response?.data?.error ||
err?.message ||
'Нет доступа. Убедитесь, что вы добавлены как администратор.';
setError(message);
} finally {
if (!cancelled) {
setLoading(false);
// Убрана кнопка "Закрыть"
}
setError('login_required');
setLoading(false);
}
};
@ -162,6 +260,9 @@ export default function App() {
return () => {
cancelled = true;
if (window.onTelegramAuth) {
delete window.onTelegramAuth;
}
};
}, []);
@ -297,9 +398,18 @@ export default function App() {
return;
}
const API_URL = import.meta.env.VITE_API_URL || (
import.meta.env.PROD ? '/api' : 'http://localhost:3000/api'
);
// Использовать тот же API URL что и в api.js
const getApiUrl = () => {
if (import.meta.env.VITE_API_URL) {
return import.meta.env.VITE_API_URL;
}
if (import.meta.env.PROD) {
return '/api';
}
return 'http://localhost:3001/api';
};
const API_URL = getApiUrl();
// Для WebSocket убираем "/api" из base URL, т.к. socket.io слушает на корне
const socketBase = API_URL.replace(/\/?api\/?$/, '');
@ -508,6 +618,95 @@ export default function App() {
setPublishState((prev) => ({ ...prev, files }));
};
const handleSendCode = async () => {
if (!authForm.email || !authForm.email.includes('@')) {
setError('Введите корректный email');
return;
}
setAuthLoading(true);
try {
await sendVerificationCode(authForm.email);
setAuthForm(prev => ({ ...prev, step: 'register-step2' }));
setError(null);
} catch (err) {
const message = err?.response?.data?.error || err?.message || 'Ошибка отправки кода';
setError(message);
} finally {
setAuthLoading(false);
}
};
const handleRegister = async () => {
if (!authForm.code || !authForm.password || !authForm.username) {
setError('Заполните все поля');
return;
}
if (authForm.password.length < 6) {
setError('Пароль должен содержать минимум 6 символов');
return;
}
setAuthLoading(true);
try {
const result = await registerWithCode(
authForm.email,
authForm.code,
authForm.password,
authForm.username
);
setUser(result.user);
setError(null);
setAuthForm({ step: 'login', email: '', password: '', code: '', username: '', showPassword: false });
} catch (err) {
const message = err?.response?.data?.error || err?.message || 'Ошибка регистрации';
setError(message);
} finally {
setAuthLoading(false);
}
};
const handleLogin = async () => {
if (!authForm.email || !authForm.password) {
setError('Введите email и пароль');
return;
}
setAuthLoading(true);
try {
const result = await login(authForm.email, authForm.password);
setUser(result.user);
setError(null);
setAuthForm({ step: 'login', email: '', password: '', code: '', username: '', showPassword: false });
} catch (err) {
const message = err?.response?.data?.error || err?.message || 'Ошибка авторизации';
setError(message);
} finally {
setAuthLoading(false);
}
};
const handleTelegramLogin = async () => {
const telegramApp = window.Telegram?.WebApp;
if (!telegramApp || !telegramApp.initData) {
setError('Откройте через Telegram бота для авторизации');
return;
}
setAuthLoading(true);
try {
const result = await loginTelegram();
setUser(result.user);
setError(null);
} catch (err) {
const message = err?.response?.data?.error || err?.message || 'Ошибка авторизации через Telegram';
setError(message);
} finally {
setAuthLoading(false);
}
};
const renderUsers = () => (
<div className="card">
<div className="section-header">
@ -609,9 +808,19 @@ export default function App() {
<div className="image-grid">
{post.images.map((img, idx) => {
// Преобразовать относительный путь в абсолютный
const getImageBaseUrl = () => {
if (import.meta.env.VITE_API_URL) {
return import.meta.env.VITE_API_URL.replace('/api', '');
}
if (import.meta.env.PROD) {
return window.location.origin;
}
return 'http://localhost:3000';
};
const imageUrl = img.startsWith('http')
? img
: `${import.meta.env.VITE_API_URL || (import.meta.env.PROD ? window.location.origin : 'http://localhost:3000')}${img}`;
: `${getImageBaseUrl()}${img}`;
return (
<div key={idx} className="image-thumb">
@ -697,9 +906,19 @@ export default function App() {
{report.post.images?.length > 0 && (
<div className="image-grid" style={{ marginTop: '8px' }}>
{report.post.images.slice(0, 3).map((img, idx) => {
const getImageBaseUrl = () => {
if (import.meta.env.VITE_API_URL) {
return import.meta.env.VITE_API_URL.replace('/api', '');
}
if (import.meta.env.PROD) {
return window.location.origin;
}
return 'http://localhost:3000';
};
const imageUrl = img.startsWith('http')
? img
: `${import.meta.env.VITE_API_URL || (import.meta.env.PROD ? window.location.origin : 'http://localhost:3000')}${img}`;
: `${getImageBaseUrl()}${img}`;
return (
<img
@ -1047,11 +1266,278 @@ export default function App() {
);
}
if (error) {
// Показать форму входа
if (error === 'login_required' || (!user && !loading)) {
const telegramApp = window.Telegram?.WebApp;
const canUseTelegram = telegramApp && telegramApp.initData;
return (
<div className="fullscreen-center">
<div className="login-card">
<h1 style={{ marginTop: 0, marginBottom: '24px' }}>Вход в модерацию</h1>
{/* Telegram авторизация */}
{canUseTelegram && (
<>
<button
className="btn primary"
onClick={handleTelegramLogin}
disabled={authLoading}
style={{ width: '100%', justifyContent: 'center', marginBottom: '16px' }}
>
{authLoading ? <Loader2 className="spin" size={18} /> : '🔐 Войти через Telegram'}
</button>
<div style={{
textAlign: 'center',
marginBottom: '24px',
padding: '16px',
background: 'rgba(255, 255, 255, 0.05)',
borderRadius: '8px'
}}>
<p style={{ margin: 0, fontSize: '14px', color: 'var(--text-secondary)' }}>
или
</p>
</div>
</>
)}
{/* Telegram Login Widget для обычного браузера */}
{!canUseTelegram && (
<>
<div
id="telegram-login-widget"
ref={telegramWidgetRef}
style={{
marginBottom: '24px',
display: 'flex',
justifyContent: 'center',
minHeight: '48px'
}}
/>
<div style={{
textAlign: 'center',
marginBottom: '24px',
padding: '16px',
background: 'rgba(255, 255, 255, 0.05)',
borderRadius: '8px'
}}>
<p style={{ margin: 0, fontSize: '14px', color: 'var(--text-secondary)' }}>
или
</p>
</div>
</>
)}
{/* Авторизация по email/паролю */}
{authForm.step === 'login' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%', maxWidth: '400px' }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<span>Email</span>
<input
type="email"
value={authForm.email}
onChange={(e) => setAuthForm((prev) => ({ ...prev, email: e.target.value }))}
placeholder="email@example.com"
style={{
padding: '12px',
borderRadius: '8px',
border: '1px solid rgba(255, 255, 255, 0.12)',
background: 'rgba(255, 255, 255, 0.04)',
color: 'var(--text-primary)',
fontSize: '14px'
}}
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<span>Пароль</span>
<input
type={authForm.showPassword ? 'text' : 'password'}
value={authForm.password}
onChange={(e) => setAuthForm((prev) => ({ ...prev, password: e.target.value }))}
placeholder="Введите пароль"
style={{
padding: '12px',
borderRadius: '8px',
border: '1px solid rgba(255, 255, 255, 0.12)',
background: 'rgba(255, 255, 255, 0.04)',
color: 'var(--text-primary)',
fontSize: '14px'
}}
/>
</label>
<div style={{ display: 'flex', gap: '12px' }}>
<button
className="btn primary"
onClick={handleLogin}
disabled={authLoading}
style={{ flex: 1, justifyContent: 'center' }}
>
{authLoading ? <Loader2 className="spin" size={18} /> : 'Войти'}
</button>
</div>
<button
className="btn"
onClick={() => setAuthForm((prev) => ({ ...prev, step: 'register-step1' }))}
style={{ fontSize: '14px', padding: '8px' }}
>
Зарегистрироваться
</button>
</div>
)}
{/* Регистрация шаг 1 - отправка кода */}
{authForm.step === 'register-step1' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%', maxWidth: '400px' }}>
<p style={{ color: 'var(--text-secondary)', fontSize: '14px', marginBottom: '8px' }}>
Для регистрации необходимо, чтобы администратор добавил ваш email в систему
</p>
<label style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<span>Email</span>
<input
type="email"
value={authForm.email}
onChange={(e) => setAuthForm((prev) => ({ ...prev, email: e.target.value }))}
placeholder="email@example.com"
style={{
padding: '12px',
borderRadius: '8px',
border: '1px solid rgba(255, 255, 255, 0.12)',
background: 'rgba(255, 255, 255, 0.04)',
color: 'var(--text-primary)',
fontSize: '14px'
}}
/>
</label>
<button
className="btn primary"
onClick={handleSendCode}
disabled={authLoading}
style={{ width: '100%', justifyContent: 'center' }}
>
{authLoading ? <Loader2 className="spin" size={18} /> : 'Отправить код на email'}
</button>
<button
className="btn"
onClick={() => setAuthForm((prev) => ({ ...prev, step: 'login' }))}
style={{ fontSize: '14px', padding: '8px' }}
>
Вернуться к входу
</button>
</div>
)}
{/* Регистрация шаг 2 - ввод кода и пароля */}
{authForm.step === 'register-step2' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%', maxWidth: '400px' }}>
<p style={{ color: 'var(--text-secondary)', fontSize: '14px', marginBottom: '8px' }}>
Код отправлен на {authForm.email}
</p>
<label style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<span>Код подтверждения</span>
<input
type="text"
value={authForm.code}
onChange={(e) => setAuthForm((prev) => ({ ...prev, code: e.target.value.replace(/\D/g, '').slice(0, 6) }))}
placeholder="000000"
maxLength={6}
style={{
padding: '12px',
borderRadius: '8px',
border: '1px solid rgba(255, 255, 255, 0.12)',
background: 'rgba(255, 255, 255, 0.04)',
color: 'var(--text-primary)',
fontSize: '18px',
textAlign: 'center',
letterSpacing: '8px'
}}
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<span>Имя пользователя</span>
<input
type="text"
value={authForm.username}
onChange={(e) => setAuthForm((prev) => ({ ...prev, username: e.target.value }))}
placeholder="username"
style={{
padding: '12px',
borderRadius: '8px',
border: '1px solid rgba(255, 255, 255, 0.12)',
background: 'rgba(255, 255, 255, 0.04)',
color: 'var(--text-primary)',
fontSize: '14px'
}}
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<span>Пароль</span>
<input
type={authForm.showPassword ? 'text' : 'password'}
value={authForm.password}
onChange={(e) => setAuthForm((prev) => ({ ...prev, password: e.target.value }))}
placeholder="Минимум 6 символов"
style={{
padding: '12px',
borderRadius: '8px',
border: '1px solid rgba(255, 255, 255, 0.12)',
background: 'rgba(255, 255, 255, 0.04)',
color: 'var(--text-primary)',
fontSize: '14px'
}}
/>
</label>
<button
className="btn primary"
onClick={handleRegister}
disabled={authLoading || authForm.code.length !== 6 || !authForm.password || !authForm.username}
style={{ width: '100%', justifyContent: 'center' }}
>
{authLoading ? <Loader2 className="spin" size={18} /> : 'Зарегистрироваться'}
</button>
<button
className="btn"
onClick={() => setAuthForm((prev) => ({ ...prev, step: 'register-step1' }))}
style={{ fontSize: '14px', padding: '8px' }}
>
Изменить email
</button>
</div>
)}
{error && error !== 'login_required' && (
<div style={{
marginTop: '16px',
padding: '12px',
background: 'rgba(255, 59, 48, 0.1)',
borderRadius: '8px',
color: '#ff453a',
fontSize: '14px'
}}>
{error}
</div>
)}
</div>
</div>
);
}
if (error && error !== 'login_required') {
return (
<div className="fullscreen-center">
<ShieldCheck size={48} />
<p>{error}</p>
{error.includes('доступ') && (
<button
className="btn"
onClick={() => {
localStorage.removeItem('moderation_token');
setError('login_required');
}}
style={{ marginTop: '16px' }}
>
Войти заново
</button>
)}
</div>
);
}
@ -1063,6 +1549,16 @@ export default function App() {
<h1>Nakama Moderation</h1>
<span className="subtitle">@{user.username}</span>
</div>
<button
className="btn"
onClick={async () => {
await logout();
window.location.reload();
}}
style={{ fontSize: '14px' }}
>
Выйти
</button>
</header>
<nav className="tabbar">

View File

@ -3,12 +3,12 @@ import ReactDOM from 'react-dom/client';
import App from './App';
import './styles.css';
// Убедиться, что Telegram Web App инициализирован
// Инициализировать Telegram WebApp если доступен (для использования в Telegram)
if (window.Telegram?.WebApp) {
window.Telegram.WebApp.ready();
console.log('[Moderation] Telegram WebApp initialized');
} else {
console.error('[Moderation] Telegram WebApp not found!');
console.log('[Moderation] Running in standard browser mode');
}
ReactDOM.createRoot(document.getElementById('root')).render(

View File

@ -417,18 +417,170 @@
border-radius: 10px;
}
@media (max-width: 640px) {
/* Форма входа */
.login-card {
background: rgba(255, 255, 255, 0.05);
border-radius: 20px;
padding: 32px;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.25);
max-width: 500px;
width: 90%;
}
/* Адаптивный дизайн */
@media (min-width: 1024px) {
.app-container {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
}
.content {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(600px, 1fr));
gap: 20px;
}
.tabbar {
justify-content: center;
}
.tab-btn {
min-width: 150px;
}
.list-item {
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
}
.list-item-main {
flex: 1;
}
.list-item-actions {
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
margin-left: 16px;
}
}
@media (max-width: 1023px) {
.app-container {
padding: 16px;
}
.content {
gap: 16px;
}
}
@media (max-width: 768px) {
.app-container {
padding: 12px;
}
.app-header h1 {
font-size: 20px;
}
.tabbar {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.tabbar::-webkit-scrollbar {
display: none;
}
.tab-btn {
min-width: 100px;
font-size: 12px;
padding: 8px;
}
.tab-btn span {
display: none;
}
.card {
padding: 12px;
}
.section-header h2 {
font-size: 16px;
}
.list-item {
flex-direction: column;
}
.list-item-actions {
flex-direction: column;
align-items: stretch;
width: 100%;
}
.btn {
justify-content: center;
width: 100%;
}
.image-grid {
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
}
.chat-list {
max-height: 300px;
}
.login-card {
padding: 24px 16px;
}
}
@media (max-width: 480px) {
.app-container {
padding: 8px;
}
.app-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.tab-btn {
min-width: 80px;
}
.filter-chips {
flex-wrap: wrap;
}
.chip {
font-size: 12px;
padding: 4px 10px;
}
.list-item-title {
font-size: 14px;
}
.list-item-subtitle {
font-size: 12px;
}
.list-item-meta {
font-size: 11px;
}
.publish-form textarea {
min-height: 100px;
}
}

View File

@ -1,23 +1,64 @@
import axios from 'axios'
const API_URL =
import.meta.env.VITE_API_URL ||
(import.meta.env.PROD ? '/api' : 'http://localhost:3000/api')
// Определить базовый URL API
export const getApiUrl = () => {
// Если указан явно в env
if (import.meta.env.VITE_API_URL) {
return import.meta.env.VITE_API_URL;
}
// В production используем относительный путь
if (import.meta.env.PROD) {
return '/api';
}
// В development используем порт модерации
return 'http://localhost:3001/api';
};
const API_URL = getApiUrl();
const api = axios.create({
baseURL: API_URL,
withCredentials: true
})
api.interceptors.request.use((config) => {
// Получить токен авторизации
const getAuthToken = () => {
// 1. Сначала пробуем JWT токен из localStorage
const jwtToken = localStorage.getItem('moderation_jwt_token');
if (jwtToken) {
return { token: jwtToken, type: 'jwt' };
}
// 2. Потом пробуем Telegram WebApp
const initData = window.Telegram?.WebApp?.initData;
if (initData) {
return { initData, type: 'telegram' };
}
// 3. Старый способ через localStorage
const storedToken = localStorage.getItem('moderation_token');
if (storedToken) {
return { initData: storedToken, type: 'telegram' };
}
return null;
};
api.interceptors.request.use((config) => {
const auth = getAuthToken();
if (auth) {
config.headers = config.headers || {};
if (!config.headers.Authorization) {
config.headers.Authorization = `tma ${initData}`;
}
if (!config.headers['x-telegram-init-data']) {
config.headers['x-telegram-init-data'] = initData;
if (auth.type === 'jwt' && auth.token) {
// JWT токен
config.headers.Authorization = `Bearer ${auth.token}`;
} else if (auth.initData) {
// Telegram initData
if (!config.headers.Authorization) {
config.headers.Authorization = `tma ${auth.initData}`;
}
if (!config.headers['x-telegram-init-data']) {
config.headers['x-telegram-init-data'] = auth.initData;
}
}
}
return config;
@ -30,13 +71,13 @@ api.interceptors.response.use(
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...');
// Если токен устарел или невалиден
if (status === 401) {
console.warn('[Moderation API] Auth token expired or invalid');
// Очистить все токены из localStorage
localStorage.removeItem('moderation_token');
localStorage.removeItem('moderation_jwt_token');
// Показать уведомление пользователю
const tg = window.Telegram?.WebApp;
@ -45,7 +86,8 @@ api.interceptors.response.use(
window.location.reload();
});
} else {
setTimeout(() => window.location.reload(), 1000);
// Для обычного браузера - перезагрузка страницы (покажет форму входа)
window.location.reload();
}
}
@ -53,6 +95,43 @@ api.interceptors.response.use(
}
);
// Авторизация
export const sendVerificationCode = (email) =>
api.post('/moderation-auth/send-code', { email }).then((res) => res.data)
export const registerWithCode = (email, code, password, username) =>
api.post('/moderation-auth/register', { email, code, password, username }).then((res) => {
if (res.data.accessToken) {
localStorage.setItem('moderation_jwt_token', res.data.accessToken);
}
return res.data;
})
export const login = (email, password) =>
api.post('/moderation-auth/login', { email, password }).then((res) => {
if (res.data.accessToken) {
localStorage.setItem('moderation_jwt_token', res.data.accessToken);
}
return res.data;
})
export const loginTelegram = () =>
api.post('/moderation-auth/telegram').then((res) => {
if (res.data.accessToken) {
localStorage.setItem('moderation_jwt_token', res.data.accessToken);
}
return res.data;
})
export const logout = () => {
localStorage.removeItem('moderation_jwt_token');
localStorage.removeItem('moderation_token');
return api.post('/moderation-auth/logout').then((res) => res.data);
}
export const getCurrentUser = () =>
api.get('/moderation-auth/me').then((res) => res.data.user)
export const verifyAuth = () => api.post('/mod-app/auth/verify').then((res) => res.data.user)
export const fetchUsers = (params = {}) =>

View File

@ -7,11 +7,11 @@ export default defineConfig({
port: 5174,
proxy: {
'/api': {
target: process.env.VITE_API_URL || 'http://localhost:3000',
target: process.env.VITE_MODERATION_API_URL || 'http://localhost:3001',
changeOrigin: true
},
'/mod-chat': {
target: process.env.VITE_API_URL || 'http://localhost:3000',
target: process.env.VITE_MODERATION_API_URL || 'http://localhost:3001',
changeOrigin: true,
ws: true
}

View File

@ -0,0 +1,112 @@
# HTTP -> HTTPS редирект
server {
listen 80;
listen [::]:80;
server_name moderation.nkm.guru;
# Let's Encrypt challenge
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$server_name$request_uri;
}
}
# HTTPS сервер
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name moderation.nkm.guru;
# SSL сертификаты (Let's Encrypt)
ssl_certificate /etc/letsencrypt/live/moderation.nkm.guru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/moderation.nkm.guru/privkey.pem;
# SSL настройки
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
# Логи
access_log /var/log/nginx/moderation-access.log;
error_log /var/log/nginx/moderation-error.log;
# Максимальный размер загружаемых файлов
client_max_body_size 20M;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript
application/json application/javascript application/xml+rss
application/rss+xml font/truetype font/opentype
application/vnd.ms-fontobject image/svg+xml;
# Проксирование API запросов к бэкенду модерации
location /api {
proxy_pass http://nakama-moderation-backend:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# WebSocket поддержка
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Таймауты
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# WebSocket для чата модераторов
location /mod-chat {
proxy_pass http://nakama-moderation-backend:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Таймауты для WebSocket
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
}
# Статические файлы фронтенда (из Docker контейнера)
location / {
proxy_pass http://nakama-moderation-frontend:80;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Кэширование статических файлов
proxy_cache_valid 200 1y;
proxy_cache_valid 404 1h;
}
}

View File

@ -10,7 +10,10 @@
"build": "cd frontend && npm run build",
"start": "node backend/server.js",
"mod-client": "cd moderation/frontend && npm run dev",
"mod-build": "cd moderation/frontend && npm run build"
"mod-build": "cd moderation/frontend && npm run build",
"mod-server": "cd moderation/backend && npm run dev",
"mod-dev": "concurrently \"npm run mod-server\" \"npm run mod-client\"",
"mod-start": "cd moderation/backend && npm start"
},
"keywords": ["telegram", "mini-app", "social-network"],
"author": "",
@ -39,7 +42,11 @@
"@telegram-apps/init-data-node": "^1.0.4",
"@aws-sdk/client-s3": "^3.451.0",
"@aws-sdk/lib-storage": "^3.451.0",
"@aws-sdk/s3-request-presigner": "^3.451.0"
"@aws-sdk/s3-request-presigner": "^3.451.0",
"aws-sdk": "^2.1499.0",
"nodemailer": "^6.9.7",
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.6"
},
"devDependencies": {
"nodemon": "^3.0.1",