Update files
This commit is contained in:
parent
56bdedacac
commit
e446691b3d
|
|
@ -10,7 +10,7 @@ RUN npm ci
|
||||||
COPY moderation/frontend ./
|
COPY moderation/frontend ./
|
||||||
|
|
||||||
# Сборка проекта
|
# Сборка проекта
|
||||||
ARG VITE_API_URL
|
ARG VITE_API_URL=https://moderation.nkm.guru/api
|
||||||
ENV VITE_API_URL=$VITE_API_URL
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
||||||
|
|
@ -62,3 +62,34 @@ CACHE_TTL_POSTS=300
|
||||||
CACHE_TTL_USERS=600
|
CACHE_TTL_USERS=600
|
||||||
CACHE_TTL_SEARCH=180
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
|
@ -87,6 +87,33 @@ module.exports = {
|
||||||
publicBucket: process.env.MINIO_PUBLIC_BUCKET === 'true'
|
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',
|
isDevelopment: () => process.env.NODE_ENV === 'development',
|
||||||
isProduction: () => process.env.NODE_ENV === 'production',
|
isProduction: () => process.env.NODE_ENV === 'production',
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
module.exports = {
|
||||||
authenticate,
|
authenticate,
|
||||||
authenticateModeration,
|
authenticateModeration,
|
||||||
|
authenticateJWT,
|
||||||
|
authenticateModerationFlexible,
|
||||||
requireModerator,
|
requireModerator,
|
||||||
requireAdmin,
|
requireAdmin,
|
||||||
touchUserActivity,
|
touchUserActivity,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
@ -112,6 +112,22 @@ const UserSchema = new mongoose.Schema({
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 0
|
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: {
|
createdAt: {
|
||||||
type: Date,
|
type: Date,
|
||||||
default: Date.now
|
default: Date.now
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ const router = express.Router();
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { authenticateModeration } = require('../middleware/auth');
|
const { authenticateModerationFlexible } = require('../middleware/auth');
|
||||||
const { logSecurityEvent } = require('../middleware/logger');
|
const { logSecurityEvent } = require('../middleware/logger');
|
||||||
const { uploadChannelMedia, cleanupOnError } = require('../middleware/upload');
|
const { uploadChannelMedia, cleanupOnError } = require('../middleware/upload');
|
||||||
const { deleteFile } = require('../utils/minio');
|
const { deleteFile } = require('../utils/minio');
|
||||||
|
|
@ -14,18 +14,28 @@ const ModerationAdmin = require('../models/ModerationAdmin');
|
||||||
const AdminConfirmation = require('../models/AdminConfirmation');
|
const AdminConfirmation = require('../models/AdminConfirmation');
|
||||||
const { listAdmins, isModerationAdmin, normalizeUsername } = require('../services/moderationAdmin');
|
const { listAdmins, isModerationAdmin, normalizeUsername } = require('../services/moderationAdmin');
|
||||||
const { sendChannelMediaGroup, sendMessageToUser } = require('../bots/serverMonitor');
|
const { sendChannelMediaGroup, sendMessageToUser } = require('../bots/serverMonitor');
|
||||||
|
const { sendAdminConfirmationCode } = require('../utils/email');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
|
|
||||||
const OWNER_USERNAMES = new Set(config.moderationOwnerUsernames || []);
|
const OWNER_USERNAMES = new Set(config.moderationOwnerUsernames || []);
|
||||||
|
|
||||||
|
// Проверка доступа к модерации (только для модераторов и админов)
|
||||||
const requireModerationAccess = async (req, res, next) => {
|
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 username = normalizeUsername(req.user?.username);
|
||||||
const telegramId = req.user?.telegramId;
|
const telegramId = req.user?.telegramId;
|
||||||
|
|
||||||
if (!username || !telegramId) {
|
|
||||||
return res.status(401).json({ error: 'Требуется авторизация' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (OWNER_USERNAMES.has(username) || req.user.role === 'admin') {
|
if (OWNER_USERNAMES.has(username) || req.user.role === 'admin') {
|
||||||
req.isModerationAdmin = true;
|
req.isModerationAdmin = true;
|
||||||
req.isOwner = true;
|
req.isOwner = true;
|
||||||
|
|
@ -49,7 +59,9 @@ const requireOwner = (req, res, next) => {
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|
||||||
const serializeUser = (user) => ({
|
const serializeUser = (user) => {
|
||||||
|
if (!user) return null;
|
||||||
|
return {
|
||||||
id: user._id,
|
id: user._id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
|
|
@ -59,10 +71,13 @@ const serializeUser = (user) => ({
|
||||||
bannedUntil: user.bannedUntil,
|
bannedUntil: user.bannedUntil,
|
||||||
lastActiveAt: user.lastActiveAt,
|
lastActiveAt: user.lastActiveAt,
|
||||||
createdAt: user.createdAt,
|
createdAt: user.createdAt,
|
||||||
referralsCount: user.referralsCount || 0
|
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();
|
const admins = await listAdmins();
|
||||||
|
|
||||||
res.json({
|
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 { filter = 'active', page = 1, limit = 50 } = req.query;
|
||||||
const pageNum = Math.max(parseInt(page, 10) || 1, 1);
|
const pageNum = Math.max(parseInt(page, 10) || 1, 1);
|
||||||
const limitNum = Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200);
|
const limitNum = Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200);
|
||||||
|
|
@ -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 { banned, days } = req.body;
|
||||||
|
|
||||||
const user = await User.findById(req.params.id);
|
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) });
|
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 { page = 1, limit = 20, author, tag } = req.query;
|
||||||
const pageNum = Math.max(parseInt(page, 10) || 1, 1);
|
const pageNum = Math.max(parseInt(page, 10) || 1, 1);
|
||||||
const limitNum = Math.min(Math.max(parseInt(limit, 10) || 20, 1), 100);
|
const limitNum = Math.min(Math.max(parseInt(limit, 10) || 20, 1), 100);
|
||||||
|
|
@ -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 {
|
try {
|
||||||
const post = await Post.findById(req.params.id)
|
const post = await Post.findById(req.params.id)
|
||||||
.populate('author', 'username firstName lastName photoUrl')
|
.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 {
|
try {
|
||||||
const post = await Post.findById(req.params.postId);
|
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 { content, hashtags, tags, isNSFW, isArt } = req.body;
|
||||||
|
|
||||||
const post = await Post.findById(req.params.id).populate('author');
|
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);
|
const post = await Post.findById(req.params.id);
|
||||||
if (!post) {
|
if (!post) {
|
||||||
return res.status(404).json({ error: 'Пост не найден' });
|
return res.status(404).json({ error: 'Пост не найден' });
|
||||||
|
|
@ -376,7 +391,7 @@ router.delete('/posts/:id', authenticateModeration, requireModerationAccess, asy
|
||||||
res.json({ success: true });
|
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 { id, index } = req.params;
|
||||||
const idx = parseInt(index, 10);
|
const idx = parseInt(index, 10);
|
||||||
|
|
||||||
|
|
@ -409,7 +424,7 @@ router.delete('/posts/:id/images/:index', authenticateModeration, requireModerat
|
||||||
res.json({ images: post.images });
|
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 { id } = req.params;
|
||||||
const { days = 7 } = req.body;
|
const { days = 7 } = req.body;
|
||||||
|
|
||||||
|
|
@ -426,7 +441,7 @@ router.post('/posts/:id/ban', authenticateModeration, requireModerationAccess, a
|
||||||
res.json({ user: serializeUser(post.author) });
|
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 { page = 1, limit = 30, status = 'pending' } = req.query;
|
||||||
const pageNum = Math.max(parseInt(page, 10) || 1, 1);
|
const pageNum = Math.max(parseInt(page, 10) || 1, 1);
|
||||||
const limitNum = Math.min(Math.max(parseInt(limit, 10) || 30, 1), 100);
|
const limitNum = Math.min(Math.max(parseInt(limit, 10) || 30, 1), 100);
|
||||||
|
|
@ -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 { status = 'reviewed' } = req.body;
|
||||||
const report = await Report.findById(req.params.id);
|
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 {
|
try {
|
||||||
const admins = await ModerationAdmin.find().sort({ adminNumber: 1 });
|
const admins = await ModerationAdmin.find().sort({ adminNumber: 1 });
|
||||||
res.json({
|
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 {
|
try {
|
||||||
const { userId, adminNumber } = req.body;
|
const { userId, adminNumber } = req.body;
|
||||||
|
|
||||||
|
|
@ -554,7 +569,17 @@ router.post('/admins/initiate-add', authenticateModeration, requireModerationAcc
|
||||||
action: 'add'
|
action: 'add'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Отправить код владельцу (req.user - это ты)
|
// Отправить код на 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(
|
await sendMessageToUser(
|
||||||
req.user.telegramId,
|
req.user.telegramId,
|
||||||
`<b>Подтверждение назначения админом</b>\n\n` +
|
`<b>Подтверждение назначения админом</b>\n\n` +
|
||||||
|
|
@ -564,10 +589,14 @@ router.post('/admins/initiate-add', authenticateModeration, requireModerationAcc
|
||||||
`<code>${code}</code>\n\n` +
|
`<code>${code}</code>\n\n` +
|
||||||
`Код действителен 5 минут.`
|
`Код действителен 5 минут.`
|
||||||
);
|
);
|
||||||
|
} catch (telegramError) {
|
||||||
|
console.error('Ошибка отправки кода в Telegram:', telegramError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Код подтверждения отправлен вам в бот',
|
message: 'Код подтверждения отправлен на email владельца',
|
||||||
username: user.username
|
username: user.username
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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 {
|
try {
|
||||||
const { userId, code } = req.body;
|
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 {
|
try {
|
||||||
const { adminId } = req.body;
|
const { adminId } = req.body;
|
||||||
|
|
||||||
|
|
@ -675,7 +704,17 @@ router.post('/admins/initiate-remove', authenticateModeration, requireModeration
|
||||||
action: 'remove'
|
action: 'remove'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Отправить код владельцу (req.user - это ты)
|
// Отправить код на 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(
|
await sendMessageToUser(
|
||||||
req.user.telegramId,
|
req.user.telegramId,
|
||||||
`<b>Подтверждение снятия админа</b>\n\n` +
|
`<b>Подтверждение снятия админа</b>\n\n` +
|
||||||
|
|
@ -685,10 +724,14 @@ router.post('/admins/initiate-remove', authenticateModeration, requireModeration
|
||||||
`<code>${code}</code>\n\n` +
|
`<code>${code}</code>\n\n` +
|
||||||
`Код действителен 5 минут.`
|
`Код действителен 5 минут.`
|
||||||
);
|
);
|
||||||
|
} catch (telegramError) {
|
||||||
|
console.error('Ошибка отправки кода в Telegram:', telegramError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Код подтверждения отправлен вам в бот',
|
message: 'Код подтверждения отправлен на email владельца',
|
||||||
username: admin.username
|
username: admin.username
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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 {
|
try {
|
||||||
const { adminId, code } = req.body;
|
const { adminId, code } = req.body;
|
||||||
|
|
||||||
|
|
@ -750,7 +793,7 @@ router.post('/admins/confirm-remove', authenticateModeration, requireModerationA
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/channel/publish',
|
'/channel/publish',
|
||||||
authenticateModeration,
|
authenticateModerationFlexible,
|
||||||
requireModerationAccess,
|
requireModerationAccess,
|
||||||
uploadChannelMedia,
|
uploadChannelMedia,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const mongoose = require('mongoose');
|
const mongoose = require('mongoose');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
|
const cookieParser = require('cookie-parser');
|
||||||
const dotenv = require('dotenv');
|
const dotenv = require('dotenv');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
|
|
@ -54,6 +55,9 @@ const corsOptions = {
|
||||||
|
|
||||||
app.use(cors(corsOptions));
|
app.use(cors(corsOptions));
|
||||||
|
|
||||||
|
// Cookie parser для JWT токенов в cookies
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
// Body parsing с ограничениями
|
// Body parsing с ограничениями
|
||||||
app.use(express.json({ limit: '10mb' }));
|
app.use(express.json({ limit: '10mb' }));
|
||||||
app.use(express.urlencoded({ extended: true, 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/statistics', require('./routes/statistics'));
|
||||||
app.use('/api/bot', require('./routes/bot'));
|
app.use('/api/bot', require('./routes/bot'));
|
||||||
app.use('/api/mod-app', require('./routes/modApp'));
|
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/minio', require('./routes/minio-test'));
|
||||||
app.use('/api/tags', require('./routes/tags'));
|
app.use('/api/tags', require('./routes/tags'));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -7,7 +7,8 @@ const REFRESH_COOKIE = config.jwt.refreshCookieName;
|
||||||
const buildPayload = (user) => ({
|
const buildPayload = (user) => ({
|
||||||
userId: user._id.toString(),
|
userId: user._id.toString(),
|
||||||
telegramId: user.telegramId,
|
telegramId: user.telegramId,
|
||||||
role: user.role
|
role: user.role,
|
||||||
|
// Не включаем email или passwordHash в JWT токен для безопасности
|
||||||
});
|
});
|
||||||
|
|
||||||
const signAccessToken = (user) =>
|
const signAccessToken = (user) =>
|
||||||
|
|
|
||||||
|
|
@ -62,20 +62,87 @@ services:
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- 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:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.moderation
|
dockerfile: Dockerfile.moderation
|
||||||
args:
|
args:
|
||||||
VITE_API_URL: ${VITE_API_URL:-http://localhost:3000/api}
|
VITE_API_URL: ${VITE_MODERATION_API_URL:-https://moderation.nkm.guru/api}
|
||||||
container_name: nakama-moderation
|
container_name: nakama-moderation-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
expose:
|
||||||
- "5174:80"
|
- "80"
|
||||||
networks:
|
networks:
|
||||||
- nakama-network
|
- nakama-network
|
||||||
depends_on:
|
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 находится на удаленном сервере (103.80.87.247:27017)
|
||||||
# Локальный MongoDB контейнер не нужен
|
# Локальный MongoDB контейнер не нужен
|
||||||
|
|
|
||||||
|
|
@ -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. Проверьте логи авторизации
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
- Валидация и санитизация всех входных данных
|
||||||
|
|
||||||
|
## Поддержка
|
||||||
|
|
||||||
|
При возникновении проблем обращайтесь к владельцу системы.
|
||||||
|
|
||||||
|
|
@ -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": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
@ -2,11 +2,35 @@
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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="theme-color" content="#000000" />
|
||||||
|
<meta name="description" content="Nakama Moderation Panel - Панель модерации" />
|
||||||
<title>Nakama Moderation</title>
|
<title>Nakama Moderation</title>
|
||||||
<!-- Telegram Web App SDK - прямая загрузка -->
|
<!-- Telegram Web App SDK -->
|
||||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
verifyAuth,
|
getCurrentUser,
|
||||||
|
sendVerificationCode,
|
||||||
|
registerWithCode,
|
||||||
|
login,
|
||||||
|
loginTelegram,
|
||||||
|
logout,
|
||||||
fetchUsers,
|
fetchUsers,
|
||||||
banUser,
|
banUser,
|
||||||
fetchPosts,
|
fetchPosts,
|
||||||
|
|
@ -17,7 +22,8 @@ import {
|
||||||
initiateRemoveAdmin,
|
initiateRemoveAdmin,
|
||||||
confirmRemoveAdmin,
|
confirmRemoveAdmin,
|
||||||
getPostComments,
|
getPostComments,
|
||||||
deleteComment
|
deleteComment,
|
||||||
|
getApiUrl
|
||||||
} from './utils/api';
|
} from './utils/api';
|
||||||
import { io } from 'socket.io-client';
|
import { io } from 'socket.io-client';
|
||||||
import {
|
import {
|
||||||
|
|
@ -119,42 +125,134 @@ export default function App() {
|
||||||
const [commentsModal, setCommentsModal] = useState(null); // { postId, comments: [] }
|
const [commentsModal, setCommentsModal] = useState(null); // { postId, comments: [] }
|
||||||
const [commentsLoading, setCommentsLoading] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
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 () => {
|
const init = async () => {
|
||||||
try {
|
try {
|
||||||
const telegramApp = window.Telegram?.WebApp;
|
const telegramApp = window.Telegram?.WebApp;
|
||||||
|
|
||||||
if (!telegramApp) {
|
// Если это Telegram WebApp - попробовать авторизацию через Telegram
|
||||||
throw new Error('Откройте модераторский интерфейс из Telegram (бот @rbachbot).');
|
if (telegramApp && telegramApp.initData) {
|
||||||
}
|
|
||||||
|
|
||||||
if (!telegramApp.initData) {
|
|
||||||
throw new Error('Telegram не передал initData. Откройте приложение из официального клиента.');
|
|
||||||
}
|
|
||||||
|
|
||||||
telegramApp.disableVerticalSwipes?.();
|
telegramApp.disableVerticalSwipes?.();
|
||||||
telegramApp.expand?.();
|
telegramApp.expand?.();
|
||||||
|
|
||||||
const userData = await verifyAuth();
|
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) {
|
||||||
|
initTelegramWidget();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверить JWT токен
|
||||||
|
const jwtToken = localStorage.getItem('moderation_jwt_token');
|
||||||
|
if (jwtToken) {
|
||||||
|
try {
|
||||||
|
const userData = await getCurrentUser();
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
// Если токен невалиден - очистить
|
||||||
|
localStorage.removeItem('moderation_jwt_token');
|
||||||
|
localStorage.removeItem('moderation_token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Нет токена - показать форму входа
|
||||||
|
setLoading(false);
|
||||||
|
setError('login_required');
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
console.error('Ошибка инициализации модератора:', err);
|
console.error('Ошибка инициализации модератора:', err);
|
||||||
const message =
|
setError('login_required');
|
||||||
err?.response?.data?.error ||
|
|
||||||
err?.message ||
|
|
||||||
'Нет доступа. Убедитесь, что вы добавлены как администратор.';
|
|
||||||
setError(message);
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) {
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
// Убрана кнопка "Закрыть"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -162,6 +260,9 @@ export default function App() {
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
if (window.onTelegramAuth) {
|
||||||
|
delete window.onTelegramAuth;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -297,9 +398,18 @@ export default function App() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || (
|
// Использовать тот же API URL что и в api.js
|
||||||
import.meta.env.PROD ? '/api' : 'http://localhost:3000/api'
|
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 слушает на корне
|
// Для WebSocket убираем "/api" из base URL, т.к. socket.io слушает на корне
|
||||||
const socketBase = API_URL.replace(/\/?api\/?$/, '');
|
const socketBase = API_URL.replace(/\/?api\/?$/, '');
|
||||||
|
|
@ -508,6 +618,95 @@ export default function App() {
|
||||||
setPublishState((prev) => ({ ...prev, files }));
|
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 = () => (
|
const renderUsers = () => (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
|
|
@ -609,9 +808,19 @@ export default function App() {
|
||||||
<div className="image-grid">
|
<div className="image-grid">
|
||||||
{post.images.map((img, idx) => {
|
{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')
|
const imageUrl = img.startsWith('http')
|
||||||
? img
|
? img
|
||||||
: `${import.meta.env.VITE_API_URL || (import.meta.env.PROD ? window.location.origin : 'http://localhost:3000')}${img}`;
|
: `${getImageBaseUrl()}${img}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={idx} className="image-thumb">
|
<div key={idx} className="image-thumb">
|
||||||
|
|
@ -697,9 +906,19 @@ export default function App() {
|
||||||
{report.post.images?.length > 0 && (
|
{report.post.images?.length > 0 && (
|
||||||
<div className="image-grid" style={{ marginTop: '8px' }}>
|
<div className="image-grid" style={{ marginTop: '8px' }}>
|
||||||
{report.post.images.slice(0, 3).map((img, idx) => {
|
{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')
|
const imageUrl = img.startsWith('http')
|
||||||
? img
|
? img
|
||||||
: `${import.meta.env.VITE_API_URL || (import.meta.env.PROD ? window.location.origin : 'http://localhost:3000')}${img}`;
|
: `${getImageBaseUrl()}${img}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<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 (
|
return (
|
||||||
<div className="fullscreen-center">
|
<div className="fullscreen-center">
|
||||||
<ShieldCheck size={48} />
|
<ShieldCheck size={48} />
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
|
{error.includes('доступ') && (
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={() => {
|
||||||
|
localStorage.removeItem('moderation_token');
|
||||||
|
setError('login_required');
|
||||||
|
}}
|
||||||
|
style={{ marginTop: '16px' }}
|
||||||
|
>
|
||||||
|
Войти заново
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1063,6 +1549,16 @@ export default function App() {
|
||||||
<h1>Nakama Moderation</h1>
|
<h1>Nakama Moderation</h1>
|
||||||
<span className="subtitle">@{user.username}</span>
|
<span className="subtitle">@{user.username}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={async () => {
|
||||||
|
await logout();
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
style={{ fontSize: '14px' }}
|
||||||
|
>
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav className="tabbar">
|
<nav className="tabbar">
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,12 @@ import ReactDOM from 'react-dom/client';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
// Убедиться, что Telegram Web App инициализирован
|
// Инициализировать Telegram WebApp если доступен (для использования в Telegram)
|
||||||
if (window.Telegram?.WebApp) {
|
if (window.Telegram?.WebApp) {
|
||||||
window.Telegram.WebApp.ready();
|
window.Telegram.WebApp.ready();
|
||||||
console.log('[Moderation] Telegram WebApp initialized');
|
console.log('[Moderation] Telegram WebApp initialized');
|
||||||
} else {
|
} else {
|
||||||
console.error('[Moderation] Telegram WebApp not found!');
|
console.log('[Moderation] Running in standard browser mode');
|
||||||
}
|
}
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
|
|
||||||
|
|
@ -417,18 +417,170 @@
|
||||||
border-radius: 10px;
|
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 {
|
.app-container {
|
||||||
padding: 12px;
|
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 {
|
.list-item-actions {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
justify-content: center;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,64 @@
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
const API_URL =
|
// Определить базовый URL API
|
||||||
import.meta.env.VITE_API_URL ||
|
export const getApiUrl = () => {
|
||||||
(import.meta.env.PROD ? '/api' : 'http://localhost:3000/api')
|
// Если указан явно в 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({
|
const api = axios.create({
|
||||||
baseURL: API_URL,
|
baseURL: API_URL,
|
||||||
withCredentials: true
|
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;
|
const initData = window.Telegram?.WebApp?.initData;
|
||||||
if (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 || {};
|
config.headers = config.headers || {};
|
||||||
|
if (auth.type === 'jwt' && auth.token) {
|
||||||
|
// JWT токен
|
||||||
|
config.headers.Authorization = `Bearer ${auth.token}`;
|
||||||
|
} else if (auth.initData) {
|
||||||
|
// Telegram initData
|
||||||
if (!config.headers.Authorization) {
|
if (!config.headers.Authorization) {
|
||||||
config.headers.Authorization = `tma ${initData}`;
|
config.headers.Authorization = `tma ${auth.initData}`;
|
||||||
}
|
}
|
||||||
if (!config.headers['x-telegram-init-data']) {
|
if (!config.headers['x-telegram-init-data']) {
|
||||||
config.headers['x-telegram-init-data'] = initData;
|
config.headers['x-telegram-init-data'] = auth.initData;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
|
|
@ -30,13 +71,13 @@ api.interceptors.response.use(
|
||||||
const status = error?.response?.status;
|
const status = error?.response?.status;
|
||||||
const errorMessage = error?.response?.data?.error || '';
|
const errorMessage = error?.response?.data?.error || '';
|
||||||
|
|
||||||
// Если токен устарел или невалиден - перезагрузить приложение
|
// Если токен устарел или невалиден
|
||||||
if (status === 401 && (
|
if (status === 401) {
|
||||||
errorMessage.includes('устарели') ||
|
console.warn('[Moderation API] Auth token expired or invalid');
|
||||||
errorMessage.includes('expired') ||
|
|
||||||
errorMessage.includes('Неверная подпись')
|
// Очистить все токены из localStorage
|
||||||
)) {
|
localStorage.removeItem('moderation_token');
|
||||||
console.warn('[Moderation API] Auth token expired or invalid, reloading app...');
|
localStorage.removeItem('moderation_jwt_token');
|
||||||
|
|
||||||
// Показать уведомление пользователю
|
// Показать уведомление пользователю
|
||||||
const tg = window.Telegram?.WebApp;
|
const tg = window.Telegram?.WebApp;
|
||||||
|
|
@ -45,7 +86,8 @@ api.interceptors.response.use(
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
});
|
});
|
||||||
} else {
|
} 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 verifyAuth = () => api.post('/mod-app/auth/verify').then((res) => res.data.user)
|
||||||
|
|
||||||
export const fetchUsers = (params = {}) =>
|
export const fetchUsers = (params = {}) =>
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,11 @@ export default defineConfig({
|
||||||
port: 5174,
|
port: 5174,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: process.env.VITE_API_URL || 'http://localhost:3000',
|
target: process.env.VITE_MODERATION_API_URL || 'http://localhost:3001',
|
||||||
changeOrigin: true
|
changeOrigin: true
|
||||||
},
|
},
|
||||||
'/mod-chat': {
|
'/mod-chat': {
|
||||||
target: process.env.VITE_API_URL || 'http://localhost:3000',
|
target: process.env.VITE_MODERATION_API_URL || 'http://localhost:3001',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
ws: true
|
ws: true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
11
package.json
11
package.json
|
|
@ -10,7 +10,10 @@
|
||||||
"build": "cd frontend && npm run build",
|
"build": "cd frontend && npm run build",
|
||||||
"start": "node backend/server.js",
|
"start": "node backend/server.js",
|
||||||
"mod-client": "cd moderation/frontend && npm run dev",
|
"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"],
|
"keywords": ["telegram", "mini-app", "social-network"],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|
@ -39,7 +42,11 @@
|
||||||
"@telegram-apps/init-data-node": "^1.0.4",
|
"@telegram-apps/init-data-node": "^1.0.4",
|
||||||
"@aws-sdk/client-s3": "^3.451.0",
|
"@aws-sdk/client-s3": "^3.451.0",
|
||||||
"@aws-sdk/lib-storage": "^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": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.1",
|
"nodemon": "^3.0.1",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue