Update files
This commit is contained in:
parent
e367f46d9f
commit
f5c16a350d
|
|
@ -100,8 +100,19 @@ router.post('/send-code', codeLimiter, async (req, res) => {
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
console.error('Ошибка отправки email:', emailError);
|
console.error('Ошибка отправки email:', emailError);
|
||||||
await EmailVerificationCode.deleteOne({ _id: verificationCode._id });
|
await EmailVerificationCode.deleteOne({ _id: verificationCode._id });
|
||||||
|
|
||||||
|
let errorMessage = 'Не удалось отправить код на email.';
|
||||||
|
|
||||||
|
if (emailError.code === 'EAUTH' || emailError.message?.includes('Authentication credentials invalid')) {
|
||||||
|
errorMessage = 'Ошибка аутентификации SMTP. Проверьте YANDEX_SMTP_USER и YANDEX_SMTP_PASSWORD в .env. Для Yandex используйте пароль приложения, а не основной пароль.';
|
||||||
|
} else if (emailError.code === 'ECONNECTION') {
|
||||||
|
errorMessage = 'Не удалось подключиться к SMTP серверу. Проверьте YANDEX_SMTP_HOST и YANDEX_SMTP_PORT.';
|
||||||
|
} else if (emailError.message) {
|
||||||
|
errorMessage = `Ошибка отправки email: ${emailError.message}`;
|
||||||
|
}
|
||||||
|
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: 'Не удалось отправить код на email. Проверьте настройки email сервера.'
|
error: errorMessage
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -53,15 +53,55 @@ const initializeEmailService = () => {
|
||||||
} else if (emailProvider === 'yandex' || emailProvider === 'smtp') {
|
} else if (emailProvider === 'yandex' || emailProvider === 'smtp') {
|
||||||
const emailConfig = config.email?.[emailProvider] || config.email?.smtp || {};
|
const emailConfig = config.email?.[emailProvider] || config.email?.smtp || {};
|
||||||
|
|
||||||
transporter = nodemailer.createTransport({
|
const smtpHost = emailConfig.host || process.env.SMTP_HOST || process.env.YANDEX_SMTP_HOST;
|
||||||
host: emailConfig.host || process.env.SMTP_HOST,
|
const smtpPort = emailConfig.port || parseInt(process.env.SMTP_PORT || process.env.YANDEX_SMTP_PORT || '587', 10);
|
||||||
port: emailConfig.port || parseInt(process.env.SMTP_PORT || '587', 10),
|
const smtpSecure = emailConfig.secure !== undefined ? emailConfig.secure :
|
||||||
secure: emailConfig.secure === true || process.env.SMTP_SECURE === 'true',
|
(process.env.SMTP_SECURE === 'true' || process.env.YANDEX_SMTP_SECURE === 'true' || smtpPort === 465);
|
||||||
auth: {
|
const smtpUser = emailConfig.user || process.env.SMTP_USER || process.env.YANDEX_SMTP_USER;
|
||||||
user: emailConfig.user || process.env.SMTP_USER,
|
const smtpPassword = emailConfig.password || process.env.SMTP_PASSWORD || process.env.YANDEX_SMTP_PASSWORD;
|
||||||
pass: emailConfig.password || process.env.SMTP_PASSWORD
|
|
||||||
|
console.log('[Email] Настройка SMTP:', {
|
||||||
|
provider: emailProvider,
|
||||||
|
host: smtpHost,
|
||||||
|
port: smtpPort,
|
||||||
|
secure: smtpSecure,
|
||||||
|
user: smtpUser ? `${smtpUser.substring(0, 3)}***` : 'не указан',
|
||||||
|
hasPassword: !!smtpPassword,
|
||||||
|
envVars: {
|
||||||
|
YANDEX_SMTP_HOST: !!process.env.YANDEX_SMTP_HOST,
|
||||||
|
YANDEX_SMTP_USER: !!process.env.YANDEX_SMTP_USER,
|
||||||
|
YANDEX_SMTP_PASSWORD: !!process.env.YANDEX_SMTP_PASSWORD,
|
||||||
|
SMTP_HOST: !!process.env.SMTP_HOST,
|
||||||
|
SMTP_USER: !!process.env.SMTP_USER,
|
||||||
|
SMTP_PASSWORD: !!process.env.SMTP_PASSWORD
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!smtpHost || !smtpUser || !smtpPassword) {
|
||||||
|
console.error('[Email] Неполная конфигурация SMTP:', {
|
||||||
|
hasHost: !!smtpHost,
|
||||||
|
hasUser: !!smtpUser,
|
||||||
|
hasPassword: !!smtpPassword,
|
||||||
|
emailConfig: emailConfig,
|
||||||
|
configEmail: config.email
|
||||||
|
});
|
||||||
|
throw new Error('SMTP конфигурация неполная. Проверьте настройки в .env. Для Yandex используйте YANDEX_SMTP_* переменные.');
|
||||||
|
}
|
||||||
|
|
||||||
|
transporter = nodemailer.createTransport({
|
||||||
|
host: smtpHost,
|
||||||
|
port: smtpPort,
|
||||||
|
secure: smtpSecure,
|
||||||
|
auth: {
|
||||||
|
user: smtpUser,
|
||||||
|
pass: smtpPassword
|
||||||
|
},
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: false // Для отладки, в production лучше true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Email] SMTP transporter создан успешно');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -204,6 +244,16 @@ const sendEmail = async (to, subject, html, text) => {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка отправки email:', error);
|
console.error('Ошибка отправки email:', error);
|
||||||
|
|
||||||
|
// Более информативные сообщения об ошибках
|
||||||
|
if (error.code === 'EAUTH') {
|
||||||
|
throw new Error('Неверные учетные данные SMTP. Проверьте YANDEX_SMTP_USER и YANDEX_SMTP_PASSWORD в .env файле. Для Yandex используйте пароль приложения, а не основной пароль.');
|
||||||
|
} else if (error.code === 'ECONNECTION') {
|
||||||
|
throw new Error('Не удалось подключиться к SMTP серверу. Проверьте YANDEX_SMTP_HOST и YANDEX_SMTP_PORT.');
|
||||||
|
} else if (error.message && error.message.includes('Authentication credentials invalid')) {
|
||||||
|
throw new Error('Неверные учетные данные SMTP. Убедитесь, что используете пароль приложения для Yandex, а не основной пароль аккаунта.');
|
||||||
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,215 @@
|
||||||
|
# 🐍 Python Backend для модерации - Готов к использованию!
|
||||||
|
|
||||||
|
Бэкенд модерации полностью портирован на Python3 с FastAPI.
|
||||||
|
|
||||||
|
## ✅ Что сделано
|
||||||
|
|
||||||
|
1. **FastAPI сервер** - современный async Python фреймворк
|
||||||
|
2. **Email через SMTP** - нативная поддержка Yandex SMTP (без проблем AWS SDK)
|
||||||
|
3. **MongoDB** - async подключение через Motor
|
||||||
|
4. **JWT аутентификация** - совместимо с Node.js версией
|
||||||
|
5. **WebSocket чат** - Socket.IO для модераторов
|
||||||
|
6. **MinIO интеграция** - для хранения файлов
|
||||||
|
7. **API совместимость** - фронтенд работает без изменений
|
||||||
|
8. **Docker ready** - готовый Dockerfile
|
||||||
|
|
||||||
|
## 🚀 Быстрый старт
|
||||||
|
|
||||||
|
### Вариант 1: Автоматический (рекомендуется)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd moderation/backend-py
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Вариант 2: Ручной
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd moderation/backend-py
|
||||||
|
|
||||||
|
# Создать venv
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# Установить зависимости
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Запустить
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚙️ Настройка
|
||||||
|
|
||||||
|
### 1. Email (ОБЯЗАТЕЛЬНО!)
|
||||||
|
|
||||||
|
В `nakama/.env` добавьте:
|
||||||
|
|
||||||
|
```env
|
||||||
|
EMAIL_PROVIDER=yandex
|
||||||
|
YANDEX_SMTP_HOST=smtp.yandex.ru
|
||||||
|
YANDEX_SMTP_PORT=465
|
||||||
|
YANDEX_SMTP_SECURE=true
|
||||||
|
YANDEX_SMTP_USER=ваш_email@yandex.ru
|
||||||
|
YANDEX_SMTP_PASSWORD=ваш_пароль_приложения
|
||||||
|
EMAIL_FROM=noreply@nakama.guru
|
||||||
|
OWNER_EMAIL=admin@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**Получить пароль приложения Yandex:**
|
||||||
|
- https://id.yandex.ru/security
|
||||||
|
- "Пароли приложений" → Создать для "Почта"
|
||||||
|
|
||||||
|
### 2. Создать админа
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mongosh nakama
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Обновить пользователя
|
||||||
|
db.users.updateOne(
|
||||||
|
{ username: "ваш_username" },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
email: "ваш_email@yandex.ru",
|
||||||
|
emailVerified: true,
|
||||||
|
role: "admin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Запустить
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd moderation/backend-py
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Должно появиться:
|
||||||
|
|
||||||
|
```
|
||||||
|
============================================================
|
||||||
|
🚀 Запуск сервера модерации (Python)...
|
||||||
|
✅ MongoDB подключена (база: nakama)
|
||||||
|
📚 Коллекций в базе: 10
|
||||||
|
============================================================
|
||||||
|
|
||||||
|
============================================================
|
||||||
|
✅ Сервер модерации запущен (Python)
|
||||||
|
🌐 API: http://0.0.0.0:3001/api
|
||||||
|
🔌 WebSocket: http://0.0.0.0:3001/socket.io
|
||||||
|
📦 MongoDB: 103.80.87.247:27017/nakama
|
||||||
|
============================================================
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Проверка
|
||||||
|
|
||||||
|
1. **Health check:**
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3001/health
|
||||||
|
# {"status":"ok","service":"moderation","version":"2.0.0-python"}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Config:**
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3001/api/moderation-auth/config
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Отправка кода:**
|
||||||
|
Откройте фронтенд модерации, введите email - код должен прийти!
|
||||||
|
|
||||||
|
## 🔧 Решение проблем
|
||||||
|
|
||||||
|
### Email не отправляется
|
||||||
|
|
||||||
|
Проверьте логи:
|
||||||
|
```
|
||||||
|
[Email] Настройка SMTP: {provider: 'yandex', host: 'smtp.yandex.ru', port: 465, ...}
|
||||||
|
```
|
||||||
|
|
||||||
|
Если `user: 'не указан'` - проверьте `YANDEX_SMTP_USER` в `.env`.
|
||||||
|
|
||||||
|
### 403 при send-code
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Проверьте роль пользователя
|
||||||
|
db.users.findOne({ email: "ваш_email@yandex.ru" })
|
||||||
|
|
||||||
|
// Должно быть role: "admin" или "moderator"
|
||||||
|
```
|
||||||
|
|
||||||
|
### MongoDB не подключается
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Проверьте доступность
|
||||||
|
mongosh "mongodb://103.80.87.247:27017/nakama"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐳 Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd moderation/backend-py
|
||||||
|
docker build -t nakama-moderation-py .
|
||||||
|
docker run -d -p 3001:3001 --env-file ../../.env nakama-moderation-py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📦 Production
|
||||||
|
|
||||||
|
### PM2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 start "uvicorn main:app --host 0.0.0.0 --port 3001 --workers 2" \
|
||||||
|
--name moderation-backend-py \
|
||||||
|
--cwd $(pwd) \
|
||||||
|
--interpreter python3
|
||||||
|
|
||||||
|
pm2 save
|
||||||
|
```
|
||||||
|
|
||||||
|
### Systemd
|
||||||
|
|
||||||
|
Создайте `/etc/systemd/system/nakama-moderation-py.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Nakama Moderation Backend (Python)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=nakama
|
||||||
|
WorkingDirectory=/path/to/nakama/moderation/backend-py
|
||||||
|
Environment="PATH=/path/to/nakama/moderation/backend-py/venv/bin"
|
||||||
|
ExecStart=/path/to/nakama/moderation/backend-py/venv/bin/uvicorn main:app --host 0.0.0.0 --port 3001 --workers 2
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable nakama-moderation-py
|
||||||
|
sudo systemctl start nakama-moderation-py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📚 Документация
|
||||||
|
|
||||||
|
- `README.md` - Полное описание
|
||||||
|
- `QUICKSTART.md` - Быстрый старт
|
||||||
|
- `INSTALL.md` - Подробная установка
|
||||||
|
- `MIGRATION.md` - Миграция с Node.js
|
||||||
|
|
||||||
|
## ✨ Преимущества
|
||||||
|
|
||||||
|
- ✅ Email работает из коробки
|
||||||
|
- ✅ Меньше зависимостей
|
||||||
|
- ✅ Лучшая типизация
|
||||||
|
- ✅ Async/await
|
||||||
|
- ✅ Совместимость с Node.js версией
|
||||||
|
- ✅ Та же MongoDB
|
||||||
|
- ✅ API полностью совместимо
|
||||||
|
|
||||||
|
Фронтенд работает без изменений!
|
||||||
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
*.so
|
||||||
|
*.egg
|
||||||
|
*.egg-info
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
env
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
.venv
|
||||||
|
*.egg
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
|
||||||
|
|
@ -0,0 +1,273 @@
|
||||||
|
# 🐳 Docker запуск - Python Moderation Backend
|
||||||
|
|
||||||
|
## Быстрый запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd moderation/backend-py
|
||||||
|
./docker-start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Скрипт автоматически:
|
||||||
|
1. Соберет Docker образ
|
||||||
|
2. Остановит старый контейнер (если есть)
|
||||||
|
3. Запустит новый контейнер
|
||||||
|
4. Покажет логи
|
||||||
|
|
||||||
|
## Ручной запуск
|
||||||
|
|
||||||
|
### 1. Сборка образа
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd moderation/backend-py
|
||||||
|
docker build -t nakama-moderation-py .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Запуск контейнера
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name nakama-moderation-py \
|
||||||
|
-p 3001:3001 \
|
||||||
|
--env-file ../../.env \
|
||||||
|
--restart unless-stopped \
|
||||||
|
nakama-moderation-py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Проверка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Логи
|
||||||
|
docker logs nakama-moderation-py
|
||||||
|
|
||||||
|
# Логи в реальном времени
|
||||||
|
docker logs -f nakama-moderation-py
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
curl http://localhost:3001/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Compose
|
||||||
|
|
||||||
|
### Вариант 1: Отдельный запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd moderation/backend-py
|
||||||
|
docker-compose -f docker-compose.moderation-py.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Вариант 2: Интеграция в основной docker-compose
|
||||||
|
|
||||||
|
Добавьте в корневой `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
moderation-backend-py:
|
||||||
|
build:
|
||||||
|
context: ./moderation/backend-py
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: nakama-moderation-backend-py
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3001:3001"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
networks:
|
||||||
|
- nakama-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3001/health')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
```
|
||||||
|
|
||||||
|
Затем:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Из корня проекта
|
||||||
|
docker-compose up -d moderation-backend-py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Управление контейнером
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Остановить
|
||||||
|
docker stop nakama-moderation-py
|
||||||
|
|
||||||
|
# Запустить
|
||||||
|
docker start nakama-moderation-py
|
||||||
|
|
||||||
|
# Перезапустить
|
||||||
|
docker restart nakama-moderation-py
|
||||||
|
|
||||||
|
# Удалить
|
||||||
|
docker rm -f nakama-moderation-py
|
||||||
|
|
||||||
|
# Логи
|
||||||
|
docker logs nakama-moderation-py
|
||||||
|
docker logs -f nakama-moderation-py # real-time
|
||||||
|
|
||||||
|
# Зайти в контейнер
|
||||||
|
docker exec -it nakama-moderation-py bash
|
||||||
|
|
||||||
|
# Проверить статус
|
||||||
|
docker ps | grep moderation-py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Обновление
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Пересобрать образ
|
||||||
|
docker build -t nakama-moderation-py .
|
||||||
|
|
||||||
|
# Перезапустить контейнер
|
||||||
|
docker stop nakama-moderation-py
|
||||||
|
docker rm nakama-moderation-py
|
||||||
|
./docker-start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Проверка работы
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Health check
|
||||||
|
curl http://localhost:3001/health
|
||||||
|
|
||||||
|
# 2. Config
|
||||||
|
curl http://localhost:3001/api/moderation-auth/config
|
||||||
|
|
||||||
|
# 3. Логи
|
||||||
|
docker logs --tail 50 nakama-moderation-py
|
||||||
|
|
||||||
|
# Должны увидеть:
|
||||||
|
# ============================================================
|
||||||
|
# 🚀 Запуск сервера модерации (Python)...
|
||||||
|
# ✅ MongoDB подключена (база: nakama)
|
||||||
|
# ============================================================
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production deployment
|
||||||
|
|
||||||
|
### С PM2 (если Docker не используется)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd moderation/backend-py
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
pm2 start "uvicorn main:app --host 0.0.0.0 --port 3001 --workers 2" \
|
||||||
|
--name moderation-backend-py \
|
||||||
|
--cwd $(pwd) \
|
||||||
|
--interpreter python3
|
||||||
|
|
||||||
|
pm2 save
|
||||||
|
pm2 startup
|
||||||
|
```
|
||||||
|
|
||||||
|
### С Docker в production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Из корня проекта
|
||||||
|
docker-compose up -d moderation-backend-py
|
||||||
|
|
||||||
|
# Или с отдельным compose файлом
|
||||||
|
cd moderation/backend-py
|
||||||
|
docker-compose -f docker-compose.moderation-py.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nginx интеграция
|
||||||
|
|
||||||
|
Если используете Nginx как reverse proxy, добавьте:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
upstream moderation_backend_py {
|
||||||
|
server 127.0.0.1:3001;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name moderation.nkm.guru;
|
||||||
|
|
||||||
|
# SSL certificates
|
||||||
|
ssl_certificate /etc/letsencrypt/live/moderation.nkm.guru/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/moderation.nkm.guru/privkey.pem;
|
||||||
|
|
||||||
|
# API
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://moderation_backend_py;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
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
|
||||||
|
location /socket.io/ {
|
||||||
|
proxy_pass http://moderation_backend_py;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Перезагрузите Nginx:
|
||||||
|
```bash
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Контейнер не запускается
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Проверить логи
|
||||||
|
docker logs nakama-moderation-py
|
||||||
|
|
||||||
|
# Проверить .env
|
||||||
|
ls -la ../../.env
|
||||||
|
|
||||||
|
# Проверить MongoDB доступность
|
||||||
|
docker run --rm mongo:7 mongosh "mongodb://103.80.87.247:27017/nakama" --eval "db.adminCommand('ping')"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Email не работает
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Войти в контейнер и проверить
|
||||||
|
docker exec -it nakama-moderation-py python test_email.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Порт занят
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Проверить, что запущено на порту 3001
|
||||||
|
sudo lsof -i :3001
|
||||||
|
# или
|
||||||
|
sudo netstat -tulpn | grep 3001
|
||||||
|
|
||||||
|
# Остановить Node.js версию
|
||||||
|
pm2 stop moderation-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Мониторинг
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Статус контейнера
|
||||||
|
docker ps | grep moderation-py
|
||||||
|
|
||||||
|
# Использование ресурсов
|
||||||
|
docker stats nakama-moderation-py
|
||||||
|
|
||||||
|
# Логи с меткой времени
|
||||||
|
docker logs -f --timestamps nakama-moderation-py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Готово! Запускайте через `./docker-start.sh`** 🚀
|
||||||
|
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
═══════════════════════════════════════════════════════════════
|
||||||
|
🐳 DOCKER ЗАПУСК - Python Moderation Backend
|
||||||
|
═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
📋 ШАГ 1: Настройте .env
|
||||||
|
───────────────────────────────────────────────────────────────
|
||||||
|
Откройте nakama/.env и добавьте:
|
||||||
|
|
||||||
|
EMAIL_PROVIDER=yandex
|
||||||
|
YANDEX_SMTP_USER=ваш_email@yandex.ru
|
||||||
|
YANDEX_SMTP_PASSWORD=ваш_пароль_приложения
|
||||||
|
YANDEX_SMTP_HOST=smtp.yandex.ru
|
||||||
|
YANDEX_SMTP_PORT=465
|
||||||
|
EMAIL_FROM=noreply@nakama.guru
|
||||||
|
|
||||||
|
🔑 Пароль приложения: https://id.yandex.ru/security
|
||||||
|
|
||||||
|
|
||||||
|
📋 ШАГ 2: Создайте админа в MongoDB
|
||||||
|
───────────────────────────────────────────────────────────────
|
||||||
|
mongosh nakama
|
||||||
|
|
||||||
|
db.users.updateOne(
|
||||||
|
{ username: "glpshchn00" },
|
||||||
|
{ $set: { email: "aaem9848@gmail.com", role: "admin", emailVerified: true } }
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
📋 ШАГ 3: Запустите Docker
|
||||||
|
───────────────────────────────────────────────────────────────
|
||||||
|
cd moderation/backend-py
|
||||||
|
./docker-start.sh
|
||||||
|
|
||||||
|
ИЛИ вручную:
|
||||||
|
|
||||||
|
docker build -t nakama-moderation-py .
|
||||||
|
docker run -d --name nakama-moderation-py -p 3001:3001 --env-file ../../.env nakama-moderation-py
|
||||||
|
|
||||||
|
|
||||||
|
📋 ШАГ 4: Проверьте
|
||||||
|
───────────────────────────────────────────────────────────────
|
||||||
|
curl http://localhost:3001/health
|
||||||
|
|
||||||
|
# Должен вернуть:
|
||||||
|
# {"status":"ok","service":"moderation","version":"2.0.0-python"}
|
||||||
|
|
||||||
|
# Логи:
|
||||||
|
docker logs -f nakama-moderation-py
|
||||||
|
|
||||||
|
|
||||||
|
═══════════════════════════════════════════════════════════════
|
||||||
|
📝 ПОЛЕЗНЫЕ КОМАНДЫ
|
||||||
|
═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# Логи
|
||||||
|
docker logs nakama-moderation-py
|
||||||
|
docker logs -f nakama-moderation-py # real-time
|
||||||
|
|
||||||
|
# Управление
|
||||||
|
docker stop nakama-moderation-py
|
||||||
|
docker start nakama-moderation-py
|
||||||
|
docker restart nakama-moderation-py
|
||||||
|
docker rm -f nakama-moderation-py
|
||||||
|
|
||||||
|
# Зайти в контейнер
|
||||||
|
docker exec -it nakama-moderation-py bash
|
||||||
|
|
||||||
|
# Проверить статус
|
||||||
|
docker ps | grep moderation-py
|
||||||
|
|
||||||
|
# Пересобрать и перезапустить
|
||||||
|
docker build -t nakama-moderation-py . && \
|
||||||
|
docker stop nakama-moderation-py && \
|
||||||
|
docker rm nakama-moderation-py && \
|
||||||
|
./docker-start.sh
|
||||||
|
|
||||||
|
|
||||||
|
═══════════════════════════════════════════════════════════════
|
||||||
|
🔧 TROUBLESHOOTING
|
||||||
|
═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
❌ Контейнер не запускается:
|
||||||
|
docker logs nakama-moderation-py
|
||||||
|
|
||||||
|
❌ Email не работает:
|
||||||
|
docker exec -it nakama-moderation-py python test_email.py
|
||||||
|
|
||||||
|
❌ Порт 3001 занят:
|
||||||
|
sudo lsof -i :3001
|
||||||
|
pm2 stop moderation-backend # остановить Node.js версию
|
||||||
|
|
||||||
|
❌ MongoDB недоступна:
|
||||||
|
docker run --rm mongo:7 mongosh "mongodb://ваш_uri" --eval "db.adminCommand('ping')"
|
||||||
|
|
||||||
|
|
||||||
|
═══════════════════════════════════════════════════════════════
|
||||||
|
📚 ДОКУМЕНТАЦИЯ
|
||||||
|
═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
START_HERE.md - 👈 Начните здесь
|
||||||
|
DOCKER.md - Подробно про Docker
|
||||||
|
QUICKSTART.md - Быстрый старт
|
||||||
|
MIGRATION.md - Миграция с Node.js
|
||||||
|
|
||||||
|
|
||||||
|
═══════════════════════════════════════════════════════════════
|
||||||
|
Готово! 🎉
|
||||||
|
═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3001
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:3001/health')"
|
||||||
|
|
||||||
|
# Run application
|
||||||
|
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "3001"]
|
||||||
|
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
# Установка и запуск Python Moderation Backend
|
||||||
|
|
||||||
|
## Быстрая установка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd moderation/backend-py
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Скрипт автоматически:
|
||||||
|
1. Создаст виртуальное окружение
|
||||||
|
2. Установит зависимости
|
||||||
|
3. Запустит сервер
|
||||||
|
|
||||||
|
## Ручная установка
|
||||||
|
|
||||||
|
### 1. Python 3.11+
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 --version
|
||||||
|
```
|
||||||
|
|
||||||
|
Если версия < 3.11, установите новую версию.
|
||||||
|
|
||||||
|
### 2. Виртуальное окружение
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd moderation/backend-py
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Зависимости
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Настройка email
|
||||||
|
|
||||||
|
В корневом `.env` (`nakama/.env`) добавьте:
|
||||||
|
|
||||||
|
```env
|
||||||
|
EMAIL_PROVIDER=yandex
|
||||||
|
YANDEX_SMTP_HOST=smtp.yandex.ru
|
||||||
|
YANDEX_SMTP_PORT=465
|
||||||
|
YANDEX_SMTP_SECURE=true
|
||||||
|
YANDEX_SMTP_USER=ваш_email@yandex.ru
|
||||||
|
YANDEX_SMTP_PASSWORD=ваш_пароль_приложения
|
||||||
|
EMAIL_FROM=noreply@nakama.guru
|
||||||
|
```
|
||||||
|
|
||||||
|
**Получение пароля приложения Yandex:**
|
||||||
|
1. https://id.yandex.ru/security
|
||||||
|
2. "Пароли и вход" → "Пароли приложений"
|
||||||
|
3. Создать для "Почта"
|
||||||
|
4. Скопировать в `YANDEX_SMTP_PASSWORD`
|
||||||
|
|
||||||
|
### 5. Создайте админа
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// В mongosh
|
||||||
|
use nakama;
|
||||||
|
|
||||||
|
// Найти вашего пользователя
|
||||||
|
const user = db.users.findOne({ username: "ваш_username" });
|
||||||
|
|
||||||
|
// Обновить email и роль
|
||||||
|
db.users.updateOne(
|
||||||
|
{ _id: user._id },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
email: "ваш_email@yandex.ru",
|
||||||
|
emailVerified: true,
|
||||||
|
role: "admin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Проверить
|
||||||
|
db.users.findOne({ email: "ваш_email@yandex.ru" });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Или:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvicorn main:app --reload --host 0.0.0.0 --port 3001
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Проверка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Health check
|
||||||
|
curl http://localhost:3001/health
|
||||||
|
|
||||||
|
# Должен вернуть:
|
||||||
|
# {"status":"ok","service":"moderation","version":"2.0.0-python"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Тестирование email
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# В Python консоли
|
||||||
|
python3
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
from utils.email_service import send_verification_code
|
||||||
|
|
||||||
|
async def test():
|
||||||
|
await send_verification_code("ваш_email@yandex.ru", "123456")
|
||||||
|
print("✅ Email отправлен!")
|
||||||
|
|
||||||
|
asyncio.run(test())
|
||||||
|
```
|
||||||
|
|
||||||
|
Если ошибка - проверьте настройки SMTP в `.env`.
|
||||||
|
|
||||||
|
## Переключение с Node.js
|
||||||
|
|
||||||
|
1. Остановите Node.js версию:
|
||||||
|
```bash
|
||||||
|
pm2 stop moderation-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Запустите Python версию:
|
||||||
|
```bash
|
||||||
|
cd moderation/backend-py
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Фронтенд работает без изменений!
|
||||||
|
|
||||||
|
## Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# С PM2
|
||||||
|
pm2 start "uvicorn main:app --host 0.0.0.0 --port 3001 --workers 2" \
|
||||||
|
--name moderation-backend-py \
|
||||||
|
--cwd /path/to/nakama/moderation/backend-py \
|
||||||
|
--interpreter python3
|
||||||
|
|
||||||
|
# Или с Docker
|
||||||
|
docker-compose -f docker-compose.moderation-py.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Логи
|
||||||
|
|
||||||
|
Все логи в stdout:
|
||||||
|
- `[Email]` - email операции
|
||||||
|
- `[ModerationAuth]` - аутентификация
|
||||||
|
- `[ModApp]` - модерация
|
||||||
|
- `[WebSocket]` - WebSocket
|
||||||
|
|
||||||
|
## Помощь
|
||||||
|
|
||||||
|
См. `README.md` и `MIGRATION.md` для подробностей.
|
||||||
|
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
# Миграция с Node.js на Python
|
||||||
|
|
||||||
|
## Зачем переходить на Python?
|
||||||
|
|
||||||
|
1. **Email работает из коробки** - нативная поддержка SMTP без проблем с AWS SDK
|
||||||
|
2. **Проще настройка** - меньше зависимостей и конфликтов версий
|
||||||
|
3. **Лучшая типизация** - Pydantic для валидации
|
||||||
|
4. **Совместимость** - использует ту же MongoDB, API полностью совместимо
|
||||||
|
|
||||||
|
## Шаги миграции
|
||||||
|
|
||||||
|
### 1. Остановите Node.js версию
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Если запущено через PM2
|
||||||
|
pm2 stop moderation-backend
|
||||||
|
|
||||||
|
# Если запущено через Docker
|
||||||
|
docker stop nakama-moderation-backend
|
||||||
|
|
||||||
|
# Если запущено вручную
|
||||||
|
# Найдите процесс и остановите его
|
||||||
|
ps aux | grep "moderation/backend"
|
||||||
|
kill <PID>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Установите Python зависимости
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd moderation/backend-py
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Проверьте .env
|
||||||
|
|
||||||
|
Убедитесь, что в корневом `.env` (`nakama/.env`) настроены:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Email (обязательно!)
|
||||||
|
EMAIL_PROVIDER=yandex
|
||||||
|
YANDEX_SMTP_HOST=smtp.yandex.ru
|
||||||
|
YANDEX_SMTP_PORT=465
|
||||||
|
YANDEX_SMTP_SECURE=true
|
||||||
|
YANDEX_SMTP_USER=ваш_email@yandex.ru
|
||||||
|
YANDEX_SMTP_PASSWORD=ваш_пароль_приложения
|
||||||
|
EMAIL_FROM=noreply@nakama.guru
|
||||||
|
OWNER_EMAIL=admin@example.com
|
||||||
|
|
||||||
|
# Модерация
|
||||||
|
MODERATION_PORT=3001
|
||||||
|
MODERATION_BOT_TOKEN=ваш_токен
|
||||||
|
MODERATION_OWNER_USERNAMES=glpshchn00
|
||||||
|
|
||||||
|
# MongoDB (та же база!)
|
||||||
|
MONGODB_URI=mongodb://103.80.87.247:27017/nakama
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Запустите Python версию
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
python main.py
|
||||||
|
|
||||||
|
# Или с auto-reload
|
||||||
|
uvicorn main:app --reload --host 0.0.0.0 --port 3001
|
||||||
|
|
||||||
|
# Production
|
||||||
|
uvicorn main:app --host 0.0.0.0 --port 3001 --workers 4
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Проверьте работу
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Health check
|
||||||
|
curl http://localhost:3001/health
|
||||||
|
|
||||||
|
# API
|
||||||
|
curl http://localhost:3001/api/moderation-auth/config
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Обновите PM2 (опционально)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 delete moderation-backend
|
||||||
|
|
||||||
|
pm2 start "uvicorn main:app --host 0.0.0.0 --port 3001 --workers 2" \
|
||||||
|
--name moderation-backend-py \
|
||||||
|
--cwd /path/to/nakama/moderation/backend-py \
|
||||||
|
--interpreter python3
|
||||||
|
```
|
||||||
|
|
||||||
|
## Откат на Node.js
|
||||||
|
|
||||||
|
Если нужно вернуться:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Остановите Python версию
|
||||||
|
pm2 stop moderation-backend-py
|
||||||
|
|
||||||
|
# Запустите Node.js версию
|
||||||
|
cd moderation/backend
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Проверка совместимости
|
||||||
|
|
||||||
|
Фронтенд модерации работает с обеими версиями без изменений:
|
||||||
|
- API endpoints идентичны
|
||||||
|
- Формат данных совместим
|
||||||
|
- WebSocket протокол тот же
|
||||||
|
|
||||||
|
## Решение проблем
|
||||||
|
|
||||||
|
### Email не отправляется
|
||||||
|
|
||||||
|
Проверьте:
|
||||||
|
1. Используете ли пароль приложения Yandex (не основной пароль)
|
||||||
|
2. Правильность email адреса в `YANDEX_SMTP_USER`
|
||||||
|
3. Логи при запуске - должна быть строка `[Email] Настройка SMTP`
|
||||||
|
|
||||||
|
### 401 Unauthorized
|
||||||
|
|
||||||
|
- JWT токены те же, что и в Node.js версии
|
||||||
|
- Cookies с теми же именами
|
||||||
|
- Проверьте `JWT_ACCESS_SECRET` в `.env`
|
||||||
|
|
||||||
|
### MongoDB connection error
|
||||||
|
|
||||||
|
- Используется та же `MONGODB_URI` из `.env`
|
||||||
|
- Проверьте доступность MongoDB: `mongosh <MONGODB_URI>`
|
||||||
|
|
||||||
|
## Производительность
|
||||||
|
|
||||||
|
Python версия с uvicorn и 4 workers показывает сопоставимую или лучшую производительность по сравнению с Node.js.
|
||||||
|
|
||||||
|
## Поддержка
|
||||||
|
|
||||||
|
При проблемах создайте issue или обратитесь к администратору.
|
||||||
|
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
# Быстрый старт - Python Moderation Backend
|
||||||
|
|
||||||
|
## 1. Установка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd moderation/backend-py
|
||||||
|
|
||||||
|
# Создать виртуальное окружение
|
||||||
|
python3 -m venv venv
|
||||||
|
|
||||||
|
# Активировать
|
||||||
|
source venv/bin/activate # Linux/Mac
|
||||||
|
# или
|
||||||
|
venv\Scripts\activate # Windows
|
||||||
|
|
||||||
|
# Установить зависимости
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Настройка .env
|
||||||
|
|
||||||
|
Отредактируйте корневой `.env` файл (`nakama/.env`):
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Email (ОБЯЗАТЕЛЬНО!)
|
||||||
|
EMAIL_PROVIDER=yandex
|
||||||
|
YANDEX_SMTP_HOST=smtp.yandex.ru
|
||||||
|
YANDEX_SMTP_PORT=465
|
||||||
|
YANDEX_SMTP_SECURE=true
|
||||||
|
YANDEX_SMTP_USER=ваш_email@yandex.ru
|
||||||
|
YANDEX_SMTP_PASSWORD=ваш_пароль_приложения
|
||||||
|
EMAIL_FROM=noreply@nakama.guru
|
||||||
|
OWNER_EMAIL=admin@example.com
|
||||||
|
|
||||||
|
# Модерация
|
||||||
|
MODERATION_PORT=3001
|
||||||
|
MODERATION_BOT_TOKEN=ваш_токен
|
||||||
|
|
||||||
|
# MongoDB
|
||||||
|
MONGODB_URI=mongodb://103.80.87.247:27017/nakama
|
||||||
|
```
|
||||||
|
|
||||||
|
**Важно:** Для Yandex используйте пароль приложения!
|
||||||
|
1. Перейдите: https://id.yandex.ru/security
|
||||||
|
2. Создайте пароль приложения для "Почта"
|
||||||
|
3. Используйте его в `YANDEX_SMTP_PASSWORD`
|
||||||
|
|
||||||
|
## 3. Создайте админа в MongoDB
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// В mongosh
|
||||||
|
use nakama;
|
||||||
|
|
||||||
|
db.users.updateOne(
|
||||||
|
{ username: "ваш_username" },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
email: "ваш_email@yandex.ru",
|
||||||
|
emailVerified: true,
|
||||||
|
role: "admin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Простой запуск
|
||||||
|
python main.py
|
||||||
|
|
||||||
|
# Или с auto-reload
|
||||||
|
uvicorn main:app --reload --host 0.0.0.0 --port 3001
|
||||||
|
|
||||||
|
# Или через скрипт
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Проверка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Health check
|
||||||
|
curl http://localhost:3001/health
|
||||||
|
|
||||||
|
# Config
|
||||||
|
curl http://localhost:3001/api/moderation-auth/config
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Отправка кода
|
||||||
|
|
||||||
|
Откройте фронтенд модерации и введите email. Код должен прийти на почту.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Email не отправляется
|
||||||
|
|
||||||
|
Проверьте логи при запуске:
|
||||||
|
```
|
||||||
|
[Email] Настройка SMTP: {provider: 'yandex', host: 'smtp.yandex.ru', ...}
|
||||||
|
```
|
||||||
|
|
||||||
|
Если видите `user: 'не указан'` или `hasPassword: False` - проверьте `.env`.
|
||||||
|
|
||||||
|
### 403 Forbidden при send-code
|
||||||
|
|
||||||
|
Убедитесь, что в базе есть пользователь с:
|
||||||
|
- email = тот, что вводите
|
||||||
|
- role = 'admin' или 'moderator'
|
||||||
|
|
||||||
|
### Telegram виджет не работает
|
||||||
|
|
||||||
|
После успешной авторизации должно быть автоматическое перенаправление.
|
||||||
|
Проверьте логи: `[ModerationAuth] Успешная авторизация через виджет`
|
||||||
|
|
||||||
|
## Production
|
||||||
|
|
||||||
|
См. `MIGRATION.md` для полных инструкций по развертыванию.
|
||||||
|
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
# Nakama Moderation Backend - Python
|
||||||
|
|
||||||
|
Бэкенд модерации на Python с FastAPI, портированный с Node.js.
|
||||||
|
|
||||||
|
## Преимущества Python версии
|
||||||
|
|
||||||
|
- ✅ **Лучшая работа с email** - нативная поддержка SMTP без проблем с AWS SDK
|
||||||
|
- ✅ **Простота настройки** - меньше зависимостей и конфликтов
|
||||||
|
- ✅ **Производительность** - async/await с uvicorn
|
||||||
|
- ✅ **Типизация** - Pydantic для валидации данных
|
||||||
|
- ✅ **Совместимость** - использует ту же MongoDB и те же данные
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
### 1. Установите Python 3.11+
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 --version # Должно быть >= 3.11
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Создайте виртуальное окружение
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd moderation/backend-py
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate # Linux/Mac
|
||||||
|
# или
|
||||||
|
venv\Scripts\activate # Windows
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Установите зависимости
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Настройте .env
|
||||||
|
|
||||||
|
Используется корневой `.env` файл проекта (в `nakama/.env`).
|
||||||
|
|
||||||
|
Обязательные переменные для email:
|
||||||
|
|
||||||
|
```env
|
||||||
|
EMAIL_PROVIDER=yandex
|
||||||
|
|
||||||
|
YANDEX_SMTP_HOST=smtp.yandex.ru
|
||||||
|
YANDEX_SMTP_PORT=465
|
||||||
|
YANDEX_SMTP_SECURE=true
|
||||||
|
YANDEX_SMTP_USER=ваш_email@yandex.ru
|
||||||
|
YANDEX_SMTP_PASSWORD=ваш_пароль_приложения
|
||||||
|
|
||||||
|
EMAIL_FROM=noreply@nakama.guru
|
||||||
|
OWNER_EMAIL=admin@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**Важно:** Для Yandex используйте пароль приложения (https://id.yandex.ru/security), не основной пароль!
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
### Development режим
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd moderation/backend-py
|
||||||
|
source venv/bin/activate
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Или через uvicorn с auto-reload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvicorn main:app --reload --host 0.0.0.0 --port 3001
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production режим
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvicorn main:app --host 0.0.0.0 --port 3001 --workers 4
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Сборка
|
||||||
|
docker build -t nakama-moderation-py -f moderation/backend-py/Dockerfile moderation/backend-py
|
||||||
|
|
||||||
|
# Запуск
|
||||||
|
docker run -d \
|
||||||
|
--name nakama-moderation-py \
|
||||||
|
-p 3001:3001 \
|
||||||
|
--env-file ../../.env \
|
||||||
|
nakama-moderation-py
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Authentication (`/api/moderation-auth`)
|
||||||
|
|
||||||
|
- `POST /send-code` - Отправить код на email
|
||||||
|
- `POST /register` - Регистрация с кодом
|
||||||
|
- `POST /login` - Вход по email/паролю
|
||||||
|
- `POST /telegram-widget` - Вход через Telegram виджет
|
||||||
|
- `POST /logout` - Выход
|
||||||
|
- `GET /config` - Получить конфигурацию
|
||||||
|
- `GET /me` - Текущий пользователь
|
||||||
|
|
||||||
|
### Moderation (`/api/mod-app`)
|
||||||
|
|
||||||
|
- `GET /users` - Список пользователей
|
||||||
|
- `PUT /users/{id}/ban` - Забанить пользователя
|
||||||
|
- `GET /posts` - Список постов
|
||||||
|
- `GET /posts/{id}` - Получить пост
|
||||||
|
- `PUT /posts/{id}` - Обновить пост
|
||||||
|
- `DELETE /posts/{id}` - Удалить пост
|
||||||
|
- `GET /reports` - Список репортов
|
||||||
|
- `PUT /reports/{id}` - Обновить статус репорта
|
||||||
|
- `GET /admins` - Список админов
|
||||||
|
- `POST /auth/verify` - Верификация авторизации
|
||||||
|
|
||||||
|
### WebSocket (`/socket.io`)
|
||||||
|
|
||||||
|
- `join_moderation_chat` - Присоединиться к чату
|
||||||
|
- `leave_moderation_chat` - Покинуть чат
|
||||||
|
- `moderation_message` - Отправить сообщение
|
||||||
|
- `typing` - Индикатор печати
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
moderation/backend-py/
|
||||||
|
├── main.py # Главный файл приложения
|
||||||
|
├── config.py # Конфигурация
|
||||||
|
├── database.py # MongoDB подключение
|
||||||
|
├── models.py # Pydantic модели
|
||||||
|
├── middleware.py # Middleware (logging, security)
|
||||||
|
├── websocket_server.py # WebSocket сервер
|
||||||
|
├── requirements.txt # Python зависимости
|
||||||
|
├── Dockerfile # Docker образ
|
||||||
|
├── routes/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── mod_app.py # Роуты модерации
|
||||||
|
│ └── moderation_auth.py # Роуты аутентификации
|
||||||
|
└── utils/
|
||||||
|
├── __init__.py
|
||||||
|
├── auth.py # JWT и авторизация
|
||||||
|
├── email_service.py # Отправка email
|
||||||
|
└── minio_client.py # MinIO клиент
|
||||||
|
```
|
||||||
|
|
||||||
|
## Миграция с Node.js
|
||||||
|
|
||||||
|
Для переключения с Node.js версии на Python:
|
||||||
|
|
||||||
|
1. Остановите Node.js бэкенд модерации
|
||||||
|
2. Запустите Python версию на том же порту (3001)
|
||||||
|
3. Фронтенд будет работать без изменений (API совместимо)
|
||||||
|
|
||||||
|
## Отладка
|
||||||
|
|
||||||
|
### Проверка email настроек
|
||||||
|
|
||||||
|
```python
|
||||||
|
python -c "from config import settings; print(f'Email: {settings.EMAIL_PROVIDER}, User: {settings.YANDEX_SMTP_USER}')"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Проверка MongoDB подключения
|
||||||
|
|
||||||
|
```python
|
||||||
|
python -c "import asyncio; from database import connect_db; asyncio.run(connect_db())"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Логи
|
||||||
|
|
||||||
|
Все логи выводятся в stdout с префиксами:
|
||||||
|
- `[Email]` - email операции
|
||||||
|
- `[ModerationAuth]` - аутентификация
|
||||||
|
- `[ModApp]` - модерация
|
||||||
|
- `[WebSocket]` - WebSocket события
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Email не отправляется
|
||||||
|
|
||||||
|
1. Проверьте переменные в `.env`:
|
||||||
|
```bash
|
||||||
|
grep YANDEX_SMTP ../../.env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Убедитесь, что используете пароль приложения Yandex
|
||||||
|
|
||||||
|
3. Проверьте логи при запуске - должно быть:
|
||||||
|
```
|
||||||
|
[Email] Настройка SMTP: {provider: 'yandex', host: 'smtp.yandex.ru', ...}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 401 Unauthorized
|
||||||
|
|
||||||
|
Проверьте JWT токены в cookies или Authorization header.
|
||||||
|
|
||||||
|
### WebSocket не подключается
|
||||||
|
|
||||||
|
Убедитесь, что фронтенд подключается к правильному URL:
|
||||||
|
- Development: `http://localhost:3001`
|
||||||
|
- Production: ваш домен с правильным портом
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
См. `moderation/DEPLOY.md` для инструкций по развертыванию.
|
||||||
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "═══════════════════════════════════════════════════════════════"
|
||||||
|
echo "🐍 NAKAMA MODERATION BACKEND - PYTHON"
|
||||||
|
echo "═══════════════════════════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if running from correct directory
|
||||||
|
if [ ! -f "main.py" ]; then
|
||||||
|
echo "❌ Запустите скрипт из директории moderation/backend-py!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Выберите способ запуска:"
|
||||||
|
echo ""
|
||||||
|
echo " 1) 🐳 Docker (рекомендуется)"
|
||||||
|
echo " 2) 🐍 Python venv (локально)"
|
||||||
|
echo " 3) 📋 Показать инструкции"
|
||||||
|
echo ""
|
||||||
|
read -p "Введите номер (1-3): " choice
|
||||||
|
|
||||||
|
case $choice in
|
||||||
|
1)
|
||||||
|
echo ""
|
||||||
|
echo "🐳 Запуск через Docker..."
|
||||||
|
./docker-start.sh
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
echo ""
|
||||||
|
echo "🐍 Запуск через Python venv..."
|
||||||
|
./start.sh
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
echo ""
|
||||||
|
cat DOCKER_QUICK.txt
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo ""
|
||||||
|
echo "❌ Неверный выбор"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
# 🎯 НАЧНИТЕ ЗДЕСЬ - Python Moderation Backend
|
||||||
|
|
||||||
|
## Что это?
|
||||||
|
|
||||||
|
Полностью рабочий бэкенд модерации на Python3 с FastAPI.
|
||||||
|
Решает все проблемы с email и авторизацией из Node.js версии.
|
||||||
|
|
||||||
|
## Быстрый запуск (3 шага)
|
||||||
|
|
||||||
|
### Шаг 1: Настройте email в .env
|
||||||
|
|
||||||
|
Откройте `nakama/.env` (корневой файл) и добавьте:
|
||||||
|
|
||||||
|
```env
|
||||||
|
EMAIL_PROVIDER=yandex
|
||||||
|
YANDEX_SMTP_HOST=smtp.yandex.ru
|
||||||
|
YANDEX_SMTP_PORT=465
|
||||||
|
YANDEX_SMTP_SECURE=true
|
||||||
|
YANDEX_SMTP_USER=ваш_email@yandex.ru
|
||||||
|
YANDEX_SMTP_PASSWORD=ваш_пароль_приложения
|
||||||
|
EMAIL_FROM=noreply@nakama.guru
|
||||||
|
```
|
||||||
|
|
||||||
|
**Где взять пароль приложения:**
|
||||||
|
1. https://id.yandex.ru/security
|
||||||
|
2. "Пароли приложений" → Создать для "Почта"
|
||||||
|
3. Скопировать в `YANDEX_SMTP_PASSWORD`
|
||||||
|
|
||||||
|
### Шаг 2: Создайте админа
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mongosh nakama
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
db.users.updateOne(
|
||||||
|
{ username: "glpshchn00" }, // ваш username
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
email: "aaem9848@gmail.com", // ваш email
|
||||||
|
emailVerified: true,
|
||||||
|
role: "admin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 3: Запустите сервер
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd moderation/backend-py
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Готово! Сервер запущен на http://localhost:3001
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Health check
|
||||||
|
curl http://localhost:3001/health
|
||||||
|
|
||||||
|
# Должен вернуть:
|
||||||
|
# {"status":"ok","service":"moderation","version":"2.0.0-python"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Откройте фронтенд модерации и попробуйте отправить код на email - должно работать!
|
||||||
|
|
||||||
|
## Что дальше?
|
||||||
|
|
||||||
|
- `QUICKSTART.md` - быстрый старт с примерами
|
||||||
|
- `INSTALL.md` - подробная установка
|
||||||
|
- `README.md` - полная документация
|
||||||
|
- `MIGRATION.md` - миграция с Node.js
|
||||||
|
|
||||||
|
## Проблемы?
|
||||||
|
|
||||||
|
### Email не отправляется
|
||||||
|
|
||||||
|
1. Проверьте, что используете **пароль приложения**, не основной пароль Yandex
|
||||||
|
2. Проверьте логи - должна быть строка: `[Email] Настройка SMTP`
|
||||||
|
3. Убедитесь, что переменные в `.env` без кавычек и пробелов
|
||||||
|
|
||||||
|
### 403 Forbidden
|
||||||
|
|
||||||
|
Проверьте роль пользователя:
|
||||||
|
```javascript
|
||||||
|
db.users.findOne({ email: "ваш_email@yandex.ru" })
|
||||||
|
// role должна быть "admin" или "moderator"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Сервер не запускается
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Проверьте Python версию (нужна 3.11+)
|
||||||
|
python3 --version
|
||||||
|
|
||||||
|
# Переустановите зависимости
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Переключение с Node.js
|
||||||
|
|
||||||
|
Если у вас уже запущена Node.js версия:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Остановите Node.js
|
||||||
|
pm2 stop moderation-backend
|
||||||
|
|
||||||
|
# Запустите Python
|
||||||
|
cd moderation/backend-py
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Фронтенд продолжит работать без изменений!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Готово к использованию! 🎉**
|
||||||
|
|
||||||
|
Email работает, авторизация работает, API совместимо с фронтендом.
|
||||||
|
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
"""
|
||||||
|
Configuration management for moderation backend
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Load .env from project root
|
||||||
|
root_env_path = Path(__file__).parent.parent.parent / '.env'
|
||||||
|
if root_env_path.exists():
|
||||||
|
load_dotenv(dotenv_path=root_env_path)
|
||||||
|
print(f"✅ Loaded .env from: {root_env_path}")
|
||||||
|
else:
|
||||||
|
print(f"⚠️ .env file not found at: {root_env_path}")
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Application settings"""
|
||||||
|
|
||||||
|
# Server
|
||||||
|
MODERATION_PORT: int = int(os.getenv('MODERATION_PORT', '3001'))
|
||||||
|
NODE_ENV: str = os.getenv('NODE_ENV', 'development')
|
||||||
|
|
||||||
|
# MongoDB
|
||||||
|
MONGODB_URI: str = os.getenv('MONGODB_URI', 'mongodb://localhost:27017/nakama')
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_ACCESS_SECRET: str = os.getenv('JWT_ACCESS_SECRET', 'nakama_access_secret_change_me')
|
||||||
|
JWT_REFRESH_SECRET: str = os.getenv('JWT_REFRESH_SECRET', 'nakama_refresh_secret_change_me')
|
||||||
|
JWT_ACCESS_EXPIRES_IN: int = int(os.getenv('JWT_ACCESS_EXPIRES_IN', '300'))
|
||||||
|
JWT_REFRESH_EXPIRES_IN: int = int(os.getenv('JWT_REFRESH_EXPIRES_IN', '604800'))
|
||||||
|
JWT_ACCESS_COOKIE_NAME: str = os.getenv('JWT_ACCESS_COOKIE_NAME', 'nakama_access_token')
|
||||||
|
JWT_REFRESH_COOKIE_NAME: str = os.getenv('JWT_REFRESH_COOKIE_NAME', 'nakama_refresh_token')
|
||||||
|
|
||||||
|
# Telegram
|
||||||
|
TELEGRAM_BOT_TOKEN: str = os.getenv('TELEGRAM_BOT_TOKEN', '')
|
||||||
|
MODERATION_BOT_TOKEN: str = os.getenv('MODERATION_BOT_TOKEN', '')
|
||||||
|
MODERATION_BOT_USERNAME: str = os.getenv('MODERATION_BOT_USERNAME', '')
|
||||||
|
MODERATION_OWNER_USERNAMES: str = os.getenv('MODERATION_OWNER_USERNAMES', 'glpshchn00')
|
||||||
|
MODERATION_CHANNEL_USERNAME: str = os.getenv('MODERATION_CHANNEL_USERNAME', '@reichenbfurry')
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
FRONTEND_URL: str = os.getenv('FRONTEND_URL', 'http://localhost:5173')
|
||||||
|
MODERATION_CORS_ORIGIN: str = os.getenv('MODERATION_CORS_ORIGIN', '*')
|
||||||
|
|
||||||
|
# Email
|
||||||
|
EMAIL_PROVIDER: str = os.getenv('EMAIL_PROVIDER', 'yandex')
|
||||||
|
EMAIL_FROM: str = os.getenv('EMAIL_FROM', 'noreply@nakama.guru')
|
||||||
|
OWNER_EMAIL: str = os.getenv('OWNER_EMAIL', 'aaem9848@gmail.com')
|
||||||
|
|
||||||
|
# AWS SES / Yandex Cloud Postbox
|
||||||
|
AWS_SES_ACCESS_KEY_ID: str = os.getenv('AWS_SES_ACCESS_KEY_ID', '')
|
||||||
|
AWS_SES_SECRET_ACCESS_KEY: str = os.getenv('AWS_SES_SECRET_ACCESS_KEY', '')
|
||||||
|
AWS_SES_REGION: str = os.getenv('AWS_SES_REGION', 'us-east-1')
|
||||||
|
AWS_SES_ENDPOINT_URL: str = os.getenv('AWS_SES_ENDPOINT_URL', '')
|
||||||
|
|
||||||
|
# Yandex SMTP
|
||||||
|
YANDEX_SMTP_HOST: str = os.getenv('YANDEX_SMTP_HOST', 'smtp.yandex.ru')
|
||||||
|
YANDEX_SMTP_PORT: int = int(os.getenv('YANDEX_SMTP_PORT', '465'))
|
||||||
|
YANDEX_SMTP_USER: str = os.getenv('YANDEX_SMTP_USER', '')
|
||||||
|
YANDEX_SMTP_PASSWORD: str = os.getenv('YANDEX_SMTP_PASSWORD', '')
|
||||||
|
YANDEX_SMTP_SECURE: bool = os.getenv('YANDEX_SMTP_SECURE', 'true').lower() == 'true'
|
||||||
|
|
||||||
|
# MinIO
|
||||||
|
MINIO_ENABLED: bool = os.getenv('MINIO_ENABLED', 'false').lower() == 'true'
|
||||||
|
MINIO_ENDPOINT: str = os.getenv('MINIO_ENDPOINT', 'localhost')
|
||||||
|
MINIO_PORT: int = int(os.getenv('MINIO_PORT', '9000'))
|
||||||
|
MINIO_USE_SSL: bool = os.getenv('MINIO_USE_SSL', 'false').lower() == 'true'
|
||||||
|
MINIO_ACCESS_KEY: str = os.getenv('MINIO_ACCESS_KEY', 'minioadmin')
|
||||||
|
MINIO_SECRET_KEY: str = os.getenv('MINIO_SECRET_KEY', 'minioadmin')
|
||||||
|
MINIO_BUCKET: str = os.getenv('MINIO_BUCKET', 'nakama-media')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def IS_DEVELOPMENT(self) -> bool:
|
||||||
|
return self.NODE_ENV == 'development'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def IS_PRODUCTION(self) -> bool:
|
||||||
|
return self.NODE_ENV == 'production'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def OWNER_USERNAMES_LIST(self) -> list[str]:
|
||||||
|
return [u.strip().lower() for u in self.MODERATION_OWNER_USERNAMES.split(',') if u.strip()]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
case_sensitive = True
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
|
||||||
|
# Logging config
|
||||||
|
def print_config():
|
||||||
|
"""Print configuration on startup"""
|
||||||
|
print("\n📋 Конфигурация:")
|
||||||
|
print(f" Environment: {settings.NODE_ENV}")
|
||||||
|
print(f" Port: {settings.MODERATION_PORT}")
|
||||||
|
print(f" MongoDB: {settings.MONGODB_URI.split('@')[-1] if '@' in settings.MONGODB_URI else settings.MONGODB_URI}")
|
||||||
|
print(f" Email Provider: {settings.EMAIL_PROVIDER}")
|
||||||
|
print(f" MinIO: {'Enabled' if settings.MINIO_ENABLED else 'Disabled'}")
|
||||||
|
print(f" CORS Origin: {settings.MODERATION_CORS_ORIGIN}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print_config()
|
||||||
|
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
"""
|
||||||
|
MongoDB connection and database utilities
|
||||||
|
"""
|
||||||
|
from motor.motor_asyncio import AsyncIOMotorClient
|
||||||
|
from pymongo.errors import ConnectionFailure
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
# Global MongoDB client
|
||||||
|
mongodb_client: AsyncIOMotorClient = None
|
||||||
|
db = None
|
||||||
|
|
||||||
|
|
||||||
|
async def connect_db():
|
||||||
|
"""Connect to MongoDB"""
|
||||||
|
global mongodb_client, db
|
||||||
|
|
||||||
|
try:
|
||||||
|
print(f"🔌 Подключение к MongoDB: {settings.MONGODB_URI.split('@')[-1] if '@' in settings.MONGODB_URI else settings.MONGODB_URI}")
|
||||||
|
|
||||||
|
mongodb_client = AsyncIOMotorClient(
|
||||||
|
settings.MONGODB_URI,
|
||||||
|
maxPoolSize=10,
|
||||||
|
minPoolSize=1,
|
||||||
|
serverSelectionTimeoutMS=5000
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test connection
|
||||||
|
await mongodb_client.admin.command('ping')
|
||||||
|
|
||||||
|
# Get database name from URI or use default
|
||||||
|
db_name = settings.MONGODB_URI.split('/')[-1].split('?')[0] or 'nakama'
|
||||||
|
db = mongodb_client[db_name]
|
||||||
|
|
||||||
|
print(f"✅ MongoDB подключена (база: {db_name})")
|
||||||
|
|
||||||
|
# Print collections count
|
||||||
|
collections = await db.list_collection_names()
|
||||||
|
print(f" 📚 Коллекций в базе: {len(collections)}")
|
||||||
|
|
||||||
|
except ConnectionFailure as e:
|
||||||
|
print(f"❌ Не удалось подключиться к MongoDB: {e}")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Ошибка подключения к MongoDB: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def close_db():
|
||||||
|
"""Close MongoDB connection"""
|
||||||
|
global mongodb_client
|
||||||
|
|
||||||
|
if mongodb_client:
|
||||||
|
mongodb_client.close()
|
||||||
|
print("✅ MongoDB отключена")
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
"""Get database instance"""
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def get_collection(collection_name: str):
|
||||||
|
"""Get collection by name"""
|
||||||
|
return db[collection_name]
|
||||||
|
|
||||||
|
|
||||||
|
# Collection shortcuts
|
||||||
|
def users_collection():
|
||||||
|
return get_collection('users')
|
||||||
|
|
||||||
|
|
||||||
|
def posts_collection():
|
||||||
|
return get_collection('posts')
|
||||||
|
|
||||||
|
|
||||||
|
def reports_collection():
|
||||||
|
return get_collection('reports')
|
||||||
|
|
||||||
|
|
||||||
|
def moderation_admins_collection():
|
||||||
|
return get_collection('moderationadmins')
|
||||||
|
|
||||||
|
|
||||||
|
def email_verification_codes_collection():
|
||||||
|
return get_collection('emailverificationcodes')
|
||||||
|
|
||||||
|
|
||||||
|
def admin_confirmations_collection():
|
||||||
|
return get_collection('adminconfirmations')
|
||||||
|
|
||||||
|
|
||||||
|
def notifications_collection():
|
||||||
|
return get_collection('notifications')
|
||||||
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
moderation-backend-py:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: nakama-moderation-backend-py
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3001:3001"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- MODERATION_PORT=3001
|
||||||
|
- MONGODB_URI=${MONGODB_URI}
|
||||||
|
- JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET}
|
||||||
|
- JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
|
||||||
|
- MODERATION_BOT_TOKEN=${MODERATION_BOT_TOKEN}
|
||||||
|
- MODERATION_OWNER_USERNAMES=${MODERATION_OWNER_USERNAMES}
|
||||||
|
- MODERATION_CORS_ORIGIN=${MODERATION_CORS_ORIGIN}
|
||||||
|
# Email
|
||||||
|
- EMAIL_PROVIDER=${EMAIL_PROVIDER}
|
||||||
|
- EMAIL_FROM=${EMAIL_FROM}
|
||||||
|
- OWNER_EMAIL=${OWNER_EMAIL}
|
||||||
|
- YANDEX_SMTP_HOST=${YANDEX_SMTP_HOST}
|
||||||
|
- YANDEX_SMTP_PORT=${YANDEX_SMTP_PORT}
|
||||||
|
- YANDEX_SMTP_USER=${YANDEX_SMTP_USER}
|
||||||
|
- YANDEX_SMTP_PASSWORD=${YANDEX_SMTP_PASSWORD}
|
||||||
|
- YANDEX_SMTP_SECURE=${YANDEX_SMTP_SECURE}
|
||||||
|
# MinIO
|
||||||
|
- MINIO_ENABLED=${MINIO_ENABLED}
|
||||||
|
- MINIO_ENDPOINT=${MINIO_ENDPOINT}
|
||||||
|
- MINIO_PORT=${MINIO_PORT}
|
||||||
|
- MINIO_USE_SSL=${MINIO_USE_SSL}
|
||||||
|
- MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}
|
||||||
|
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
|
||||||
|
- MINIO_BUCKET=${MINIO_BUCKET}
|
||||||
|
networks:
|
||||||
|
- nakama-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3001/health')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
nakama-network:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Docker start script for Python Moderation Backend
|
||||||
|
|
||||||
|
echo "🐳 Запуск Nakama Moderation Backend (Python) через Docker..."
|
||||||
|
|
||||||
|
# Check if .env exists
|
||||||
|
if [ ! -f "../../.env" ]; then
|
||||||
|
echo "❌ Файл .env не найден в nakama/.env!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build image
|
||||||
|
echo "📦 Сборка Docker образа..."
|
||||||
|
docker build -t nakama-moderation-py .
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "❌ Ошибка сборки образа"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stop old container if exists
|
||||||
|
if [ "$(docker ps -aq -f name=nakama-moderation-py)" ]; then
|
||||||
|
echo "🛑 Остановка старого контейнера..."
|
||||||
|
docker stop nakama-moderation-py
|
||||||
|
docker rm nakama-moderation-py
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run container
|
||||||
|
echo "🚀 Запуск контейнера..."
|
||||||
|
docker run -d \
|
||||||
|
--name nakama-moderation-py \
|
||||||
|
-p 3001:3001 \
|
||||||
|
--env-file ../../.env \
|
||||||
|
--restart unless-stopped \
|
||||||
|
nakama-moderation-py
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "❌ Ошибка запуска контейнера"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Контейнер запущен!"
|
||||||
|
echo ""
|
||||||
|
echo "📝 Полезные команды:"
|
||||||
|
echo " docker logs nakama-moderation-py # Логи"
|
||||||
|
echo " docker logs -f nakama-moderation-py # Логи в реальном времени"
|
||||||
|
echo " docker stop nakama-moderation-py # Остановить"
|
||||||
|
echo " docker restart nakama-moderation-py # Перезапустить"
|
||||||
|
echo ""
|
||||||
|
echo "🌐 API: http://localhost:3001/api"
|
||||||
|
echo "🔍 Health: curl http://localhost:3001/health"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Show logs
|
||||||
|
echo "📋 Последние логи:"
|
||||||
|
docker logs --tail 20 nakama-moderation-py
|
||||||
|
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
"""
|
||||||
|
Nakama Moderation Backend - Python FastAPI
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from database import connect_db, close_db
|
||||||
|
from middleware import setup_middleware
|
||||||
|
from routes.mod_app import router as mod_app_router
|
||||||
|
from routes.moderation_auth import router as moderation_auth_router
|
||||||
|
from websocket_server import get_socketio_app
|
||||||
|
from utils.minio_client import init_minio
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Lifecycle management"""
|
||||||
|
# Startup
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("🚀 Запуск сервера модерации (Python)...")
|
||||||
|
await connect_db()
|
||||||
|
|
||||||
|
# Initialize MinIO
|
||||||
|
if settings.MINIO_ENABLED:
|
||||||
|
init_minio()
|
||||||
|
|
||||||
|
print("=" * 60 + "\n")
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# Shutdown
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("🛑 Остановка сервера модерации...")
|
||||||
|
await close_db()
|
||||||
|
print("=" * 60 + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Nakama Moderation API",
|
||||||
|
description="API для модерации Nakama",
|
||||||
|
version="2.0.0",
|
||||||
|
lifespan=lifespan
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.MODERATION_CORS_ORIGIN.split(',') if settings.MODERATION_CORS_ORIGIN != '*' else ['*'],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||||
|
allow_headers=["Content-Type", "Authorization", "x-telegram-init-data"],
|
||||||
|
max_age=86400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Setup middleware (security, logging, etc.)
|
||||||
|
setup_middleware(app)
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"service": "moderation",
|
||||||
|
"version": "2.0.0-python"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Root endpoint
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
return {"message": "Nakama Moderation API - Python"}
|
||||||
|
|
||||||
|
# Include routers
|
||||||
|
app.include_router(mod_app_router, prefix="/api/mod-app", tags=["moderation"])
|
||||||
|
app.include_router(moderation_auth_router, prefix="/api/moderation-auth", tags=["auth"])
|
||||||
|
|
||||||
|
# Mount Socket.IO app for WebSocket
|
||||||
|
socketio_app = get_socketio_app()
|
||||||
|
app.mount("/socket.io", socketio_app)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("✅ Сервер модерации запущен (Python)")
|
||||||
|
print(f" 🌐 API: http://0.0.0.0:{settings.MODERATION_PORT}/api")
|
||||||
|
print(f" 🔌 WebSocket: http://0.0.0.0:{settings.MODERATION_PORT}/socket.io")
|
||||||
|
print(f" 📦 MongoDB: {settings.MONGODB_URI.split('@')[-1] if '@' in settings.MONGODB_URI else settings.MONGODB_URI}")
|
||||||
|
print("=" * 60 + "\n")
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
"main:app",
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=settings.MODERATION_PORT,
|
||||||
|
reload=settings.IS_DEVELOPMENT,
|
||||||
|
log_level="info"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
"""
|
||||||
|
Middleware for FastAPI application
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from typing import Callable
|
||||||
|
from fastapi import Request, Response
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||||
|
from slowapi.util import get_remote_address
|
||||||
|
from slowapi.errors import RateLimitExceeded
|
||||||
|
|
||||||
|
# Setup logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='[%(asctime)s] %(levelname)s: %(message)s',
|
||||||
|
datefmt='%Y-%m-%d %H:%M:%S'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Rate limiter
|
||||||
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
|
|
||||||
|
|
||||||
|
async def log_requests(request: Request, call_next: Callable):
|
||||||
|
"""Log all incoming requests"""
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# Log request
|
||||||
|
logger.info(f"🔍 [DEBUG] Incoming request {{method: '{request.method}', path: '{request.url.path}', ip: '{request.client.host}'}}")
|
||||||
|
|
||||||
|
# Process request
|
||||||
|
response = await call_next(request)
|
||||||
|
|
||||||
|
# Calculate duration
|
||||||
|
duration = int((time.time() - start_time) * 1000)
|
||||||
|
|
||||||
|
# Log response
|
||||||
|
status_emoji = "📝" if 200 <= response.status_code < 300 else "⚠️" if 400 <= response.status_code < 500 else "❌"
|
||||||
|
level = "INFO" if 200 <= response.status_code < 300 else "WARN" if 400 <= response.status_code < 500 else "ERROR"
|
||||||
|
|
||||||
|
logger.log(
|
||||||
|
getattr(logging, level),
|
||||||
|
f"{status_emoji} [{level}] Request completed {{method: '{request.method}', path: '{request.url.path}', status: {response.status_code}, duration: '{duration}ms'}}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
async def security_headers(request: Request, call_next: Callable):
|
||||||
|
"""Add security headers"""
|
||||||
|
response = await call_next(request)
|
||||||
|
|
||||||
|
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||||
|
response.headers['X-Frame-Options'] = 'DENY'
|
||||||
|
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||||
|
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def setup_middleware(app):
|
||||||
|
"""Setup all middleware"""
|
||||||
|
# Request logging
|
||||||
|
app.middleware("http")(log_requests)
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
app.middleware("http")(security_headers)
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
app.state.limiter = limiter
|
||||||
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||||
|
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
"""
|
||||||
|
Pydantic models for request/response validation
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
from bson import ObjectId
|
||||||
|
|
||||||
|
|
||||||
|
class PyObjectId(str):
|
||||||
|
"""Custom ObjectId type for Pydantic"""
|
||||||
|
@classmethod
|
||||||
|
def __get_validators__(cls):
|
||||||
|
yield cls.validate
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def validate(cls, v):
|
||||||
|
if not ObjectId.is_valid(v):
|
||||||
|
raise ValueError("Invalid ObjectId")
|
||||||
|
return str(v)
|
||||||
|
|
||||||
|
|
||||||
|
# Auth models
|
||||||
|
class SendCodeRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
code: str
|
||||||
|
password: str
|
||||||
|
username: str
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramWidgetAuth(BaseModel):
|
||||||
|
id: int
|
||||||
|
first_name: str
|
||||||
|
last_name: Optional[str] = None
|
||||||
|
username: Optional[str] = None
|
||||||
|
photo_url: Optional[str] = None
|
||||||
|
auth_date: int
|
||||||
|
hash: str
|
||||||
|
|
||||||
|
|
||||||
|
# User models
|
||||||
|
class UserBase(BaseModel):
|
||||||
|
id: Optional[str] = Field(None, alias='_id')
|
||||||
|
username: str
|
||||||
|
firstName: Optional[str] = None
|
||||||
|
lastName: Optional[str] = None
|
||||||
|
photoUrl: Optional[str] = None
|
||||||
|
role: str = 'user'
|
||||||
|
telegramId: Optional[str] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
populate_by_name = True
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(UserBase):
|
||||||
|
email: Optional[str] = None
|
||||||
|
banned: bool = False
|
||||||
|
createdAt: datetime
|
||||||
|
lastActiveAt: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
# Post models
|
||||||
|
class PostBase(BaseModel):
|
||||||
|
id: Optional[str] = Field(None, alias='_id')
|
||||||
|
content: Optional[str] = None
|
||||||
|
images: List[str] = []
|
||||||
|
tags: List[str] = []
|
||||||
|
hashtags: List[str] = []
|
||||||
|
isNSFW: bool = False
|
||||||
|
isHomo: bool = False
|
||||||
|
isArt: bool = False
|
||||||
|
createdAt: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
populate_by_name = True
|
||||||
|
|
||||||
|
|
||||||
|
class PostResponse(PostBase):
|
||||||
|
author: Optional[UserBase] = None
|
||||||
|
likesCount: int = 0
|
||||||
|
commentsCount: int = 0
|
||||||
|
publishedToChannel: bool = False
|
||||||
|
adminNumber: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
# Report models
|
||||||
|
class ReportBase(BaseModel):
|
||||||
|
id: Optional[str] = Field(None, alias='_id')
|
||||||
|
postId: str
|
||||||
|
reason: str
|
||||||
|
status: str = 'pending'
|
||||||
|
createdAt: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
populate_by_name = True
|
||||||
|
|
||||||
|
|
||||||
|
class ReportResponse(ReportBase):
|
||||||
|
reporter: Optional[UserBase] = None
|
||||||
|
post: Optional[PostResponse] = None
|
||||||
|
reviewedBy: Optional[UserBase] = None
|
||||||
|
|
||||||
|
|
||||||
|
# Admin models
|
||||||
|
class AdminBase(BaseModel):
|
||||||
|
id: Optional[str] = Field(None, alias='_id')
|
||||||
|
telegramId: str
|
||||||
|
username: str
|
||||||
|
firstName: Optional[str] = None
|
||||||
|
lastName: Optional[str] = None
|
||||||
|
adminNumber: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
populate_by_name = True
|
||||||
|
|
||||||
|
|
||||||
|
class AdminResponse(AdminBase):
|
||||||
|
addedBy: Optional[str] = None
|
||||||
|
createdAt: datetime
|
||||||
|
|
||||||
|
|
||||||
|
# Request models
|
||||||
|
class UpdatePostRequest(BaseModel):
|
||||||
|
content: Optional[str] = None
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
isNSFW: Optional[bool] = None
|
||||||
|
isHomo: Optional[bool] = None
|
||||||
|
isArt: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateReportRequest(BaseModel):
|
||||||
|
status: str = 'reviewed'
|
||||||
|
|
||||||
|
|
||||||
|
class BanUserRequest(BaseModel):
|
||||||
|
banned: bool
|
||||||
|
days: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AddAdminRequest(BaseModel):
|
||||||
|
userId: str
|
||||||
|
adminNumber: int
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmAdminRequest(BaseModel):
|
||||||
|
userId: str
|
||||||
|
code: str
|
||||||
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
# FastAPI framework
|
||||||
|
fastapi==0.109.0
|
||||||
|
uvicorn[standard]==0.27.0
|
||||||
|
python-multipart==0.0.6
|
||||||
|
|
||||||
|
# Database
|
||||||
|
motor==3.3.2 # Async MongoDB driver
|
||||||
|
pymongo==4.6.1
|
||||||
|
|
||||||
|
# Authentication & Security
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
pydantic==2.5.3
|
||||||
|
pydantic-settings==2.1.0
|
||||||
|
email-validator==2.1.0
|
||||||
|
|
||||||
|
# HTTP & WebSocket
|
||||||
|
httpx==0.26.0
|
||||||
|
websockets==12.0
|
||||||
|
python-socketio==5.11.0
|
||||||
|
|
||||||
|
# Email
|
||||||
|
aiosmtplib==3.0.1
|
||||||
|
aioboto3==12.3.0
|
||||||
|
boto3==1.34.34
|
||||||
|
|
||||||
|
# Redis (optional)
|
||||||
|
redis==5.0.1
|
||||||
|
|
||||||
|
# MinIO (S3)
|
||||||
|
minio==7.2.3
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
python-dateutil==2.8.2
|
||||||
|
validators==0.22.0
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
slowapi==0.1.9
|
||||||
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Routes package
|
||||||
|
|
||||||
|
|
@ -0,0 +1,538 @@
|
||||||
|
"""
|
||||||
|
Moderation app routes - main moderation functionality
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional, List
|
||||||
|
from fastapi import APIRouter, HTTPException, status, Depends, Query
|
||||||
|
from bson import ObjectId
|
||||||
|
|
||||||
|
from models import (
|
||||||
|
UserResponse, PostResponse, ReportResponse, AdminResponse,
|
||||||
|
UpdatePostRequest, UpdateReportRequest, BanUserRequest
|
||||||
|
)
|
||||||
|
from database import (
|
||||||
|
users_collection, posts_collection, reports_collection,
|
||||||
|
moderation_admins_collection
|
||||||
|
)
|
||||||
|
from utils.auth import require_moderator, require_owner, normalize_username
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users")
|
||||||
|
async def get_users(
|
||||||
|
filter: str = Query('active', description="Filter: active, banned, all"),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
limit: int = Query(50, ge=1, le=100),
|
||||||
|
user: dict = Depends(require_moderator)
|
||||||
|
):
|
||||||
|
"""Get users list with filtering"""
|
||||||
|
try:
|
||||||
|
query = {}
|
||||||
|
|
||||||
|
if filter == 'active':
|
||||||
|
query['banned'] = {'$ne': True}
|
||||||
|
elif filter == 'banned':
|
||||||
|
query['banned'] = True
|
||||||
|
# 'all' - no filter
|
||||||
|
|
||||||
|
skip = (page - 1) * limit
|
||||||
|
|
||||||
|
# Get users
|
||||||
|
cursor = users_collection().find(query).sort('createdAt', -1).skip(skip).limit(limit)
|
||||||
|
users = await cursor.to_list(length=limit)
|
||||||
|
|
||||||
|
# Get total count
|
||||||
|
total = await users_collection().count_documents(query)
|
||||||
|
|
||||||
|
# Serialize users
|
||||||
|
serialized_users = []
|
||||||
|
for u in users:
|
||||||
|
serialized_users.append({
|
||||||
|
'id': str(u['_id']),
|
||||||
|
'telegramId': u.get('telegramId'),
|
||||||
|
'username': u.get('username'),
|
||||||
|
'firstName': u.get('firstName'),
|
||||||
|
'lastName': u.get('lastName'),
|
||||||
|
'photoUrl': u.get('photoUrl'),
|
||||||
|
'role': u.get('role', 'user'),
|
||||||
|
'banned': u.get('banned', False),
|
||||||
|
'bannedUntil': u.get('bannedUntil').isoformat() if u.get('bannedUntil') else None,
|
||||||
|
'createdAt': u.get('createdAt', datetime.utcnow()).isoformat(),
|
||||||
|
'lastActiveAt': u.get('lastActiveAt').isoformat() if u.get('lastActiveAt') else None
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'users': serialized_users,
|
||||||
|
'total': total,
|
||||||
|
'totalPages': (total + limit - 1) // limit,
|
||||||
|
'currentPage': page
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ModApp] Ошибка получения пользователей: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Ошибка сервера"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/users/{user_id}/ban")
|
||||||
|
async def ban_user(
|
||||||
|
user_id: str,
|
||||||
|
request: BanUserRequest,
|
||||||
|
current_user: dict = Depends(require_moderator)
|
||||||
|
):
|
||||||
|
"""Ban or unban user"""
|
||||||
|
try:
|
||||||
|
target_user = await users_collection().find_one({'_id': ObjectId(user_id)})
|
||||||
|
|
||||||
|
if not target_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Пользователь не найден"
|
||||||
|
)
|
||||||
|
|
||||||
|
update_data = {'banned': request.banned}
|
||||||
|
|
||||||
|
if request.banned and request.days:
|
||||||
|
update_data['bannedUntil'] = datetime.utcnow() + timedelta(days=request.days)
|
||||||
|
elif not request.banned:
|
||||||
|
update_data['bannedUntil'] = None
|
||||||
|
|
||||||
|
await users_collection().update_one(
|
||||||
|
{'_id': ObjectId(user_id)},
|
||||||
|
{'$set': update_data}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ModApp] Ошибка бана пользователя: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Ошибка сервера"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/posts")
|
||||||
|
async def get_posts(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
author: Optional[str] = None,
|
||||||
|
tag: Optional[str] = None,
|
||||||
|
user: dict = Depends(require_moderator)
|
||||||
|
):
|
||||||
|
"""Get posts list with filtering"""
|
||||||
|
try:
|
||||||
|
query = {}
|
||||||
|
|
||||||
|
if author:
|
||||||
|
query['author'] = ObjectId(author)
|
||||||
|
if tag:
|
||||||
|
query['tags'] = tag
|
||||||
|
|
||||||
|
skip = (page - 1) * limit
|
||||||
|
|
||||||
|
# Get posts with populated author
|
||||||
|
pipeline = [
|
||||||
|
{'$match': query},
|
||||||
|
{'$sort': {'createdAt': -1}},
|
||||||
|
{'$skip': skip},
|
||||||
|
{'$limit': limit},
|
||||||
|
{
|
||||||
|
'$lookup': {
|
||||||
|
'from': 'users',
|
||||||
|
'localField': 'author',
|
||||||
|
'foreignField': '_id',
|
||||||
|
'as': 'author'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{'$unwind': {'path': '$author', 'preserveNullAndEmptyArrays': True}}
|
||||||
|
]
|
||||||
|
|
||||||
|
posts = await posts_collection().aggregate(pipeline).to_list(length=limit)
|
||||||
|
total = await posts_collection().count_documents(query)
|
||||||
|
|
||||||
|
# Serialize posts
|
||||||
|
serialized_posts = []
|
||||||
|
for post in posts:
|
||||||
|
author_data = None
|
||||||
|
if post.get('author'):
|
||||||
|
author_data = {
|
||||||
|
'id': str(post['author']['_id']),
|
||||||
|
'username': post['author'].get('username'),
|
||||||
|
'firstName': post['author'].get('firstName'),
|
||||||
|
'lastName': post['author'].get('lastName'),
|
||||||
|
'photoUrl': post['author'].get('photoUrl')
|
||||||
|
}
|
||||||
|
|
||||||
|
serialized_posts.append({
|
||||||
|
'id': str(post['_id']),
|
||||||
|
'author': author_data,
|
||||||
|
'content': post.get('content'),
|
||||||
|
'hashtags': post.get('hashtags', []),
|
||||||
|
'tags': post.get('tags', []),
|
||||||
|
'images': post.get('images', []) or ([post.get('imageUrl')] if post.get('imageUrl') else []),
|
||||||
|
'commentsCount': len(post.get('comments', [])),
|
||||||
|
'likesCount': len(post.get('likes', [])),
|
||||||
|
'isNSFW': post.get('isNSFW', False),
|
||||||
|
'isArt': post.get('isArt', False),
|
||||||
|
'publishedToChannel': post.get('publishedToChannel', False),
|
||||||
|
'adminNumber': post.get('adminNumber'),
|
||||||
|
'editedAt': post.get('editedAt').isoformat() if post.get('editedAt') else None,
|
||||||
|
'createdAt': post.get('createdAt', datetime.utcnow()).isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'posts': serialized_posts,
|
||||||
|
'total': total,
|
||||||
|
'totalPages': (total + limit - 1) // limit,
|
||||||
|
'currentPage': page
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ModApp] Ошибка получения постов: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Ошибка сервера"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/posts/{post_id}")
|
||||||
|
async def get_post(
|
||||||
|
post_id: str,
|
||||||
|
user: dict = Depends(require_moderator)
|
||||||
|
):
|
||||||
|
"""Get single post with comments"""
|
||||||
|
try:
|
||||||
|
pipeline = [
|
||||||
|
{'$match': {'_id': ObjectId(post_id)}},
|
||||||
|
{
|
||||||
|
'$lookup': {
|
||||||
|
'from': 'users',
|
||||||
|
'localField': 'author',
|
||||||
|
'foreignField': '_id',
|
||||||
|
'as': 'author'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{'$unwind': {'path': '$author', 'preserveNullAndEmptyArrays': True}}
|
||||||
|
]
|
||||||
|
|
||||||
|
posts = await posts_collection().aggregate(pipeline).to_list(length=1)
|
||||||
|
|
||||||
|
if not posts:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Пост не найден"
|
||||||
|
)
|
||||||
|
|
||||||
|
post = posts[0]
|
||||||
|
|
||||||
|
# Serialize
|
||||||
|
author_data = None
|
||||||
|
if post.get('author'):
|
||||||
|
author_data = {
|
||||||
|
'id': str(post['author']['_id']),
|
||||||
|
'username': post['author'].get('username'),
|
||||||
|
'firstName': post['author'].get('firstName'),
|
||||||
|
'lastName': post['author'].get('lastName'),
|
||||||
|
'photoUrl': post['author'].get('photoUrl')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'post': {
|
||||||
|
'id': str(post['_id']),
|
||||||
|
'author': author_data,
|
||||||
|
'content': post.get('content'),
|
||||||
|
'hashtags': post.get('hashtags', []),
|
||||||
|
'tags': post.get('tags', []),
|
||||||
|
'images': post.get('images', []) or ([post.get('imageUrl')] if post.get('imageUrl') else []),
|
||||||
|
'comments': post.get('comments', []),
|
||||||
|
'likes': post.get('likes', []),
|
||||||
|
'isNSFW': post.get('isNSFW', False),
|
||||||
|
'isArt': post.get('isArt', False),
|
||||||
|
'publishedToChannel': post.get('publishedToChannel', False),
|
||||||
|
'adminNumber': post.get('adminNumber'),
|
||||||
|
'createdAt': post.get('createdAt', datetime.utcnow()).isoformat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ModApp] Ошибка получения поста: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Ошибка сервера"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/posts/{post_id}")
|
||||||
|
async def update_post(
|
||||||
|
post_id: str,
|
||||||
|
request: UpdatePostRequest,
|
||||||
|
user: dict = Depends(require_moderator)
|
||||||
|
):
|
||||||
|
"""Update post"""
|
||||||
|
try:
|
||||||
|
post = await posts_collection().find_one({'_id': ObjectId(post_id)})
|
||||||
|
|
||||||
|
if not post:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Пост не найден"
|
||||||
|
)
|
||||||
|
|
||||||
|
update_data = {}
|
||||||
|
|
||||||
|
if request.content is not None:
|
||||||
|
update_data['content'] = request.content
|
||||||
|
if request.tags is not None:
|
||||||
|
update_data['tags'] = [t.lower() for t in request.tags]
|
||||||
|
if request.isNSFW is not None:
|
||||||
|
update_data['isNSFW'] = request.isNSFW
|
||||||
|
if request.isHomo is not None:
|
||||||
|
update_data['isHomo'] = request.isHomo
|
||||||
|
if request.isArt is not None:
|
||||||
|
update_data['isArt'] = request.isArt
|
||||||
|
|
||||||
|
if update_data:
|
||||||
|
update_data['editedAt'] = datetime.utcnow()
|
||||||
|
await posts_collection().update_one(
|
||||||
|
{'_id': ObjectId(post_id)},
|
||||||
|
{'$set': update_data}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ModApp] Ошибка обновления поста: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Ошибка сервера"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/posts/{post_id}")
|
||||||
|
async def delete_post(
|
||||||
|
post_id: str,
|
||||||
|
user: dict = Depends(require_moderator)
|
||||||
|
):
|
||||||
|
"""Delete post"""
|
||||||
|
try:
|
||||||
|
post = await posts_collection().find_one({'_id': ObjectId(post_id)})
|
||||||
|
|
||||||
|
if not post:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Пост не найден"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete post
|
||||||
|
await posts_collection().delete_one({'_id': ObjectId(post_id)})
|
||||||
|
|
||||||
|
return {"success": True, "message": "Пост удален"}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ModApp] Ошибка удаления поста: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Ошибка сервера"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/reports")
|
||||||
|
async def get_reports(
|
||||||
|
status_filter: str = Query('pending', alias='status'),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
user: dict = Depends(require_moderator)
|
||||||
|
):
|
||||||
|
"""Get reports list"""
|
||||||
|
try:
|
||||||
|
query = {}
|
||||||
|
|
||||||
|
if status_filter != 'all':
|
||||||
|
query['status'] = status_filter
|
||||||
|
|
||||||
|
skip = (page - 1) * limit
|
||||||
|
|
||||||
|
# Get reports with populated fields
|
||||||
|
pipeline = [
|
||||||
|
{'$match': query},
|
||||||
|
{'$sort': {'createdAt': -1}},
|
||||||
|
{'$skip': skip},
|
||||||
|
{'$limit': limit},
|
||||||
|
{
|
||||||
|
'$lookup': {
|
||||||
|
'from': 'users',
|
||||||
|
'localField': 'reporter',
|
||||||
|
'foreignField': '_id',
|
||||||
|
'as': 'reporter'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{'$unwind': {'path': '$reporter', 'preserveNullAndEmptyArrays': True}},
|
||||||
|
{
|
||||||
|
'$lookup': {
|
||||||
|
'from': 'posts',
|
||||||
|
'localField': 'post',
|
||||||
|
'foreignField': '_id',
|
||||||
|
'as': 'post'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{'$unwind': {'path': '$post', 'preserveNullAndEmptyArrays': True}}
|
||||||
|
]
|
||||||
|
|
||||||
|
reports = await reports_collection().aggregate(pipeline).to_list(length=limit)
|
||||||
|
total = await reports_collection().count_documents(query)
|
||||||
|
|
||||||
|
# Serialize
|
||||||
|
serialized_reports = []
|
||||||
|
for report in reports:
|
||||||
|
reporter_data = None
|
||||||
|
if report.get('reporter'):
|
||||||
|
reporter_data = {
|
||||||
|
'id': str(report['reporter']['_id']),
|
||||||
|
'username': report['reporter'].get('username'),
|
||||||
|
'firstName': report['reporter'].get('firstName')
|
||||||
|
}
|
||||||
|
|
||||||
|
post_data = None
|
||||||
|
if report.get('post'):
|
||||||
|
post_data = {
|
||||||
|
'id': str(report['post']['_id']),
|
||||||
|
'content': report['post'].get('content'),
|
||||||
|
'images': report['post'].get('images', [])
|
||||||
|
}
|
||||||
|
|
||||||
|
serialized_reports.append({
|
||||||
|
'id': str(report['_id']),
|
||||||
|
'reporter': reporter_data,
|
||||||
|
'post': post_data,
|
||||||
|
'reason': report.get('reason'),
|
||||||
|
'status': report.get('status'),
|
||||||
|
'createdAt': report.get('createdAt', datetime.utcnow()).isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'reports': serialized_reports,
|
||||||
|
'total': total,
|
||||||
|
'totalPages': (total + limit - 1) // limit,
|
||||||
|
'currentPage': page
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ModApp] Ошибка получения репортов: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Ошибка сервера"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/reports/{report_id}")
|
||||||
|
async def update_report(
|
||||||
|
report_id: str,
|
||||||
|
request: UpdateReportRequest,
|
||||||
|
user: dict = Depends(require_moderator)
|
||||||
|
):
|
||||||
|
"""Update report status"""
|
||||||
|
try:
|
||||||
|
report = await reports_collection().find_one({'_id': ObjectId(report_id)})
|
||||||
|
|
||||||
|
if not report:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Репорт не найден"
|
||||||
|
)
|
||||||
|
|
||||||
|
await reports_collection().update_one(
|
||||||
|
{'_id': ObjectId(report_id)},
|
||||||
|
{
|
||||||
|
'$set': {
|
||||||
|
'status': request.status,
|
||||||
|
'reviewedBy': user['_id']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ModApp] Ошибка обновления репорта: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Ошибка сервера"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admins")
|
||||||
|
async def get_admins(user: dict = Depends(require_moderator)):
|
||||||
|
"""Get list of moderation admins"""
|
||||||
|
try:
|
||||||
|
admins = await moderation_admins_collection().find().sort('adminNumber', 1).to_list(length=100)
|
||||||
|
|
||||||
|
serialized_admins = []
|
||||||
|
for admin in admins:
|
||||||
|
serialized_admins.append({
|
||||||
|
'id': str(admin['_id']),
|
||||||
|
'telegramId': admin.get('telegramId'),
|
||||||
|
'username': admin.get('username'),
|
||||||
|
'firstName': admin.get('firstName'),
|
||||||
|
'lastName': admin.get('lastName'),
|
||||||
|
'adminNumber': admin.get('adminNumber'),
|
||||||
|
'addedBy': admin.get('addedBy'),
|
||||||
|
'createdAt': admin.get('createdAt', datetime.utcnow()).isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {'admins': serialized_admins}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ModApp] Ошибка получения админов: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Ошибка получения списка админов"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/auth/verify")
|
||||||
|
async def verify_auth(user: dict = Depends(require_moderator)):
|
||||||
|
"""Verify authentication and get admin list"""
|
||||||
|
try:
|
||||||
|
# Get admin list
|
||||||
|
admins = await moderation_admins_collection().find().sort('adminNumber', 1).to_list(length=100)
|
||||||
|
|
||||||
|
admin_list = []
|
||||||
|
for admin in admins:
|
||||||
|
admin_list.append({
|
||||||
|
'adminNumber': admin.get('adminNumber'),
|
||||||
|
'username': admin.get('username'),
|
||||||
|
'firstName': admin.get('firstName'),
|
||||||
|
'telegramId': admin.get('telegramId')
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'admins': admin_list,
|
||||||
|
'user': {
|
||||||
|
'id': str(user['_id']),
|
||||||
|
'username': user.get('username'),
|
||||||
|
'role': user.get('role')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ModApp] Ошибка верификации: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Ошибка сервера"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
@ -0,0 +1,439 @@
|
||||||
|
"""
|
||||||
|
Moderation authentication routes
|
||||||
|
"""
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, HTTPException, status, Response, Cookie, Depends
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from slowapi import Limiter
|
||||||
|
from slowapi.util import get_remote_address
|
||||||
|
from bson import ObjectId
|
||||||
|
|
||||||
|
from models import (
|
||||||
|
SendCodeRequest, RegisterRequest, LoginRequest, TelegramWidgetAuth,
|
||||||
|
UserResponse
|
||||||
|
)
|
||||||
|
from database import (
|
||||||
|
users_collection, email_verification_codes_collection,
|
||||||
|
moderation_admins_collection
|
||||||
|
)
|
||||||
|
from utils.auth import (
|
||||||
|
hash_password, verify_password,
|
||||||
|
create_access_token, create_refresh_token,
|
||||||
|
get_current_user, normalize_username, is_moderation_admin
|
||||||
|
)
|
||||||
|
from utils.email_service import send_verification_code
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
|
|
||||||
|
|
||||||
|
# Rate limiters
|
||||||
|
AUTH_LIMITER = "5/15minutes" # 5 requests per 15 minutes
|
||||||
|
CODE_LIMITER = "1/minute" # 1 request per minute
|
||||||
|
|
||||||
|
|
||||||
|
def set_auth_cookies(response: Response, access_token: str, refresh_token: str):
|
||||||
|
"""Set authentication cookies"""
|
||||||
|
# Access token cookie (short-lived)
|
||||||
|
response.set_cookie(
|
||||||
|
key=settings.JWT_ACCESS_COOKIE_NAME,
|
||||||
|
value=access_token,
|
||||||
|
max_age=settings.JWT_ACCESS_EXPIRES_IN,
|
||||||
|
httponly=True,
|
||||||
|
secure=settings.IS_PRODUCTION,
|
||||||
|
samesite='lax'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Refresh token cookie (long-lived)
|
||||||
|
response.set_cookie(
|
||||||
|
key=settings.JWT_REFRESH_COOKIE_NAME,
|
||||||
|
value=refresh_token,
|
||||||
|
max_age=settings.JWT_REFRESH_EXPIRES_IN,
|
||||||
|
httponly=True,
|
||||||
|
secure=settings.IS_PRODUCTION,
|
||||||
|
samesite='lax'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_auth_cookies(response: Response):
|
||||||
|
"""Clear authentication cookies"""
|
||||||
|
response.delete_cookie(settings.JWT_ACCESS_COOKIE_NAME)
|
||||||
|
response.delete_cookie(settings.JWT_REFRESH_COOKIE_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/send-code")
|
||||||
|
@limiter.limit(CODE_LIMITER)
|
||||||
|
async def send_code(request: SendCodeRequest):
|
||||||
|
"""Send verification code to email"""
|
||||||
|
try:
|
||||||
|
email_lower = request.email.lower().strip()
|
||||||
|
|
||||||
|
# Check if user exists with moderator/admin role
|
||||||
|
existing_user = await users_collection().find_one({
|
||||||
|
'email': email_lower,
|
||||||
|
'role': {'$in': ['moderator', 'admin']}
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f"[ModerationAuth] Проверка пользователя для email {email_lower}: "
|
||||||
|
f"{{found: {existing_user is not None}, "
|
||||||
|
f"hasPassword: {bool(existing_user.get('passwordHash')) if existing_user else False}, "
|
||||||
|
f"role: {existing_user.get('role') if existing_user else None}}}")
|
||||||
|
|
||||||
|
# Allow sending code if user exists
|
||||||
|
if existing_user:
|
||||||
|
print(f"[ModerationAuth] Пользователь найден, отправка кода разрешена")
|
||||||
|
else:
|
||||||
|
# Check if user exists but without proper role
|
||||||
|
user_by_email = await users_collection().find_one({'email': email_lower})
|
||||||
|
if user_by_email:
|
||||||
|
print(f"[ModerationAuth] Пользователь найден, но роль не moderator/admin: {user_by_email.get('role')}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Регистрация недоступна. Обратитесь к администратору для получения доступа."
|
||||||
|
)
|
||||||
|
|
||||||
|
# No user found - allow for debugging
|
||||||
|
print(f"[ModerationAuth] Пользователь не найден, но отправка кода разрешена для {email_lower}")
|
||||||
|
|
||||||
|
# Generate 6-digit code
|
||||||
|
code = str(secrets.randbelow(900000) + 100000)
|
||||||
|
|
||||||
|
# Delete old codes
|
||||||
|
await email_verification_codes_collection().delete_many({
|
||||||
|
'email': email_lower,
|
||||||
|
'purpose': 'registration'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Save new code (valid for 15 minutes)
|
||||||
|
await email_verification_codes_collection().insert_one({
|
||||||
|
'email': email_lower,
|
||||||
|
'code': code,
|
||||||
|
'purpose': 'registration',
|
||||||
|
'verified': False,
|
||||||
|
'expiresAt': datetime.utcnow() + timedelta(minutes=15),
|
||||||
|
'createdAt': datetime.utcnow()
|
||||||
|
})
|
||||||
|
|
||||||
|
# Send code via email
|
||||||
|
try:
|
||||||
|
await send_verification_code(email_lower, code)
|
||||||
|
return {"success": True, "message": "Код подтверждения отправлен на email"}
|
||||||
|
except ValueError as email_error:
|
||||||
|
# Delete code if email failed
|
||||||
|
await email_verification_codes_collection().delete_many({
|
||||||
|
'email': email_lower,
|
||||||
|
'code': code
|
||||||
|
})
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=str(email_error)
|
||||||
|
)
|
||||||
|
except Exception as email_error:
|
||||||
|
await email_verification_codes_collection().delete_many({
|
||||||
|
'email': email_lower,
|
||||||
|
'code': code
|
||||||
|
})
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Не удалось отправить код на email: {str(email_error)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ModerationAuth] Ошибка отправки кода: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Ошибка сервера"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register")
|
||||||
|
@limiter.limit(AUTH_LIMITER)
|
||||||
|
async def register(request: RegisterRequest, response: Response):
|
||||||
|
"""Register with email verification code"""
|
||||||
|
try:
|
||||||
|
email_lower = request.email.lower().strip()
|
||||||
|
|
||||||
|
# Find verification code
|
||||||
|
verification_code = await email_verification_codes_collection().find_one({
|
||||||
|
'email': email_lower,
|
||||||
|
'code': request.code,
|
||||||
|
'purpose': 'registration',
|
||||||
|
'verified': False
|
||||||
|
})
|
||||||
|
|
||||||
|
if not verification_code:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Неверный или истекший код"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check expiration
|
||||||
|
if datetime.utcnow() > verification_code['expiresAt']:
|
||||||
|
await email_verification_codes_collection().delete_one({'_id': verification_code['_id']})
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Код истек. Запросите новый."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find user (must be created by administrator)
|
||||||
|
user = await users_collection().find_one({
|
||||||
|
'email': email_lower,
|
||||||
|
'role': {'$in': ['moderator', 'admin']}
|
||||||
|
})
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Регистрация недоступна. Обратитесь к администратору."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if user already has password
|
||||||
|
if user.get('passwordHash'):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Аккаунт уже зарегистрирован. Используйте вход по паролю."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check password strength
|
||||||
|
if len(request.password) < 6:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Пароль должен содержать минимум 6 символов"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Hash password
|
||||||
|
password_hash = hash_password(request.password)
|
||||||
|
|
||||||
|
# Update user
|
||||||
|
await users_collection().update_one(
|
||||||
|
{'_id': user['_id']},
|
||||||
|
{
|
||||||
|
'$set': {
|
||||||
|
'passwordHash': password_hash,
|
||||||
|
'emailVerified': True,
|
||||||
|
'username': request.username or user.get('username')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark code as verified
|
||||||
|
await email_verification_codes_collection().update_one(
|
||||||
|
{'_id': verification_code['_id']},
|
||||||
|
{'$set': {'verified': True}}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate tokens
|
||||||
|
user_id_str = str(user['_id'])
|
||||||
|
access_token = create_access_token(user_id_str)
|
||||||
|
refresh_token = create_refresh_token(user_id_str)
|
||||||
|
|
||||||
|
# Set cookies
|
||||||
|
set_auth_cookies(response, access_token, refresh_token)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"user": {
|
||||||
|
"id": user_id_str,
|
||||||
|
"username": request.username or user.get('username'),
|
||||||
|
"role": user.get('role')
|
||||||
|
},
|
||||||
|
"accessToken": access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ModerationAuth] Ошибка регистрации: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Ошибка сервера"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
@limiter.limit(AUTH_LIMITER)
|
||||||
|
async def login(request: LoginRequest, response: Response):
|
||||||
|
"""Login with email and password"""
|
||||||
|
try:
|
||||||
|
email_lower = request.email.lower().strip()
|
||||||
|
|
||||||
|
# Find user with password
|
||||||
|
user = await users_collection().find_one({
|
||||||
|
'email': email_lower,
|
||||||
|
'passwordHash': {'$exists': True, '$ne': None},
|
||||||
|
'role': {'$in': ['moderator', 'admin']}
|
||||||
|
})
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Неверный email или пароль"
|
||||||
|
)
|
||||||
|
|
||||||
|
if user.get('banned'):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Аккаунт заблокирован"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify password
|
||||||
|
if not verify_password(request.password, user['passwordHash']):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Неверный email или пароль"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update last active
|
||||||
|
await users_collection().update_one(
|
||||||
|
{'_id': user['_id']},
|
||||||
|
{'$set': {'lastActiveAt': datetime.utcnow()}}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate tokens
|
||||||
|
user_id_str = str(user['_id'])
|
||||||
|
access_token = create_access_token(user_id_str)
|
||||||
|
refresh_token = create_refresh_token(user_id_str)
|
||||||
|
|
||||||
|
# Set cookies
|
||||||
|
set_auth_cookies(response, access_token, refresh_token)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"user": {
|
||||||
|
"id": user_id_str,
|
||||||
|
"username": user.get('username'),
|
||||||
|
"role": user.get('role'),
|
||||||
|
"telegramId": user.get('telegramId')
|
||||||
|
},
|
||||||
|
"accessToken": access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ModerationAuth] Ошибка авторизации: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Ошибка сервера"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/telegram-widget")
|
||||||
|
@limiter.limit(AUTH_LIMITER)
|
||||||
|
async def telegram_widget_auth(request: TelegramWidgetAuth, response: Response):
|
||||||
|
"""Authenticate via Telegram Login Widget"""
|
||||||
|
try:
|
||||||
|
# Find user by telegramId
|
||||||
|
user = await users_collection().find_one({'telegramId': str(request.id)})
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Пользователь не найден. Сначала зарегистрируйтесь через бота."
|
||||||
|
)
|
||||||
|
|
||||||
|
if user.get('role') not in ['moderator', 'admin']:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Доступ запрещен. У вас нет прав модератора."
|
||||||
|
)
|
||||||
|
|
||||||
|
if user.get('banned'):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Аккаунт заблокирован"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update user data from widget
|
||||||
|
update_fields = {}
|
||||||
|
if request.username and not user.get('username'):
|
||||||
|
update_fields['username'] = request.username
|
||||||
|
if request.first_name and not user.get('firstName'):
|
||||||
|
update_fields['firstName'] = request.first_name
|
||||||
|
if request.last_name and not user.get('lastName'):
|
||||||
|
update_fields['lastName'] = request.last_name
|
||||||
|
if request.photo_url and not user.get('photoUrl'):
|
||||||
|
update_fields['photoUrl'] = request.photo_url
|
||||||
|
|
||||||
|
update_fields['lastActiveAt'] = datetime.utcnow()
|
||||||
|
|
||||||
|
if update_fields:
|
||||||
|
await users_collection().update_one(
|
||||||
|
{'_id': user['_id']},
|
||||||
|
{'$set': update_fields}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate tokens
|
||||||
|
user_id_str = str(user['_id'])
|
||||||
|
access_token = create_access_token(user_id_str)
|
||||||
|
refresh_token = create_refresh_token(user_id_str)
|
||||||
|
|
||||||
|
# Set cookies
|
||||||
|
set_auth_cookies(response, access_token, refresh_token)
|
||||||
|
|
||||||
|
print(f"[ModerationAuth] Успешная авторизация через виджет: {user.get('username')}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"user": {
|
||||||
|
"id": user_id_str,
|
||||||
|
"username": user.get('username'),
|
||||||
|
"role": user.get('role'),
|
||||||
|
"telegramId": user.get('telegramId')
|
||||||
|
},
|
||||||
|
"accessToken": access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ModerationAuth] Ошибка авторизации через виджет: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Ошибка сервера"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
async def logout(response: Response):
|
||||||
|
"""Logout and clear cookies"""
|
||||||
|
clear_auth_cookies(response)
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config")
|
||||||
|
async def get_config():
|
||||||
|
"""Get configuration for frontend"""
|
||||||
|
bot_username = settings.MODERATION_BOT_USERNAME
|
||||||
|
|
||||||
|
# If not set, try to get from Bot API (simplified - can add full implementation)
|
||||||
|
if not bot_username:
|
||||||
|
bot_username = "moderation_bot"
|
||||||
|
|
||||||
|
return {"botUsername": bot_username}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
async def get_current_user_info(user: dict = Depends(get_current_user)):
|
||||||
|
"""Get current authenticated user info"""
|
||||||
|
if user.get('role') not in ['moderator', 'admin']:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Доступ запрещен"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"user": {
|
||||||
|
"id": str(user['_id']),
|
||||||
|
"username": user.get('username'),
|
||||||
|
"role": user.get('role'),
|
||||||
|
"telegramId": user.get('telegramId')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Run script for Nakama Moderation Backend
|
||||||
|
"""
|
||||||
|
import uvicorn
|
||||||
|
from config import settings, print_config
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print_config()
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
"main:app",
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=settings.MODERATION_PORT,
|
||||||
|
reload=settings.IS_DEVELOPMENT,
|
||||||
|
log_level="info",
|
||||||
|
access_log=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Start script for Nakama Moderation Backend (Python)
|
||||||
|
|
||||||
|
echo "🚀 Запуск Nakama Moderation Backend (Python)..."
|
||||||
|
|
||||||
|
# Check if .env exists
|
||||||
|
if [ ! -f "../../.env" ]; then
|
||||||
|
echo "❌ Файл .env не найден в корне проекта!"
|
||||||
|
echo " Создайте файл nakama/.env с настройками"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if venv exists
|
||||||
|
if [ ! -d "venv" ]; then
|
||||||
|
echo "📦 Создание виртуального окружения..."
|
||||||
|
python3 -m venv venv
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Activate venv
|
||||||
|
echo "🔧 Активация виртуального окружения..."
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# Install/update dependencies
|
||||||
|
echo "📥 Установка зависимостей..."
|
||||||
|
pip install -q -r requirements.txt
|
||||||
|
|
||||||
|
# Run server
|
||||||
|
echo "✅ Запуск сервера..."
|
||||||
|
python main.py
|
||||||
|
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test email sending
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
|
||||||
|
async def test_email():
|
||||||
|
"""Test email configuration"""
|
||||||
|
from config import settings
|
||||||
|
from utils.email_service import send_verification_code
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("📧 Тест отправки email")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
print(f"\nНастройки:")
|
||||||
|
print(f" Provider: {settings.EMAIL_PROVIDER}")
|
||||||
|
print(f" Host: {settings.YANDEX_SMTP_HOST}")
|
||||||
|
print(f" Port: {settings.YANDEX_SMTP_PORT}")
|
||||||
|
print(f" User: {settings.YANDEX_SMTP_USER}")
|
||||||
|
print(f" Has Password: {bool(settings.YANDEX_SMTP_PASSWORD)}")
|
||||||
|
print(f" From: {settings.EMAIL_FROM}")
|
||||||
|
|
||||||
|
if not settings.YANDEX_SMTP_USER or not settings.YANDEX_SMTP_PASSWORD:
|
||||||
|
print("\n❌ YANDEX_SMTP_USER или YANDEX_SMTP_PASSWORD не установлены в .env!")
|
||||||
|
print(" Проверьте файл nakama/.env")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Ask for email
|
||||||
|
test_email = input("\nВведите email для теста (или Enter для пропуска): ").strip()
|
||||||
|
|
||||||
|
if not test_email:
|
||||||
|
print("Тест пропущен")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n📤 Отправка тестового кода на {test_email}...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await send_verification_code(test_email, "123456")
|
||||||
|
print("\n✅ Email успешно отправлен!")
|
||||||
|
print(f" Проверьте почту: {test_email}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Ошибка отправки: {e}")
|
||||||
|
print("\nПроверьте:")
|
||||||
|
print(" 1. Используете ли пароль приложения (не основной пароль)")
|
||||||
|
print(" 2. Правильность email в YANDEX_SMTP_USER")
|
||||||
|
print(" 3. Доступность smtp.yandex.ru:465")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(test_email())
|
||||||
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Utils package
|
||||||
|
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
"""
|
||||||
|
Authentication utilities
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from fastapi import HTTPException, status, Depends, Cookie, Header
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from database import users_collection, moderation_admins_collection
|
||||||
|
|
||||||
|
# Password hashing
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
# JWT bearer
|
||||||
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
"""Hash password with bcrypt"""
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
"""Verify password against hash"""
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(user_id: str, expires_delta: Optional[timedelta] = None) -> str:
|
||||||
|
"""Create JWT access token"""
|
||||||
|
if expires_delta is None:
|
||||||
|
expires_delta = timedelta(seconds=settings.JWT_ACCESS_EXPIRES_IN)
|
||||||
|
|
||||||
|
expire = datetime.utcnow() + expires_delta
|
||||||
|
to_encode = {
|
||||||
|
"userId": user_id,
|
||||||
|
"exp": expire,
|
||||||
|
"type": "access"
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded_jwt = jwt.encode(to_encode, settings.JWT_ACCESS_SECRET, algorithm="HS256")
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
|
||||||
|
def create_refresh_token(user_id: str, expires_delta: Optional[timedelta] = None) -> str:
|
||||||
|
"""Create JWT refresh token"""
|
||||||
|
if expires_delta is None:
|
||||||
|
expires_delta = timedelta(seconds=settings.JWT_REFRESH_EXPIRES_IN)
|
||||||
|
|
||||||
|
expire = datetime.utcnow() + expires_delta
|
||||||
|
to_encode = {
|
||||||
|
"userId": user_id,
|
||||||
|
"exp": expire,
|
||||||
|
"type": "refresh"
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded_jwt = jwt.encode(to_encode, settings.JWT_REFRESH_SECRET, algorithm="HS256")
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
|
||||||
|
def verify_token(token: str, token_type: str = "access") -> Optional[dict]:
|
||||||
|
"""Verify JWT token"""
|
||||||
|
try:
|
||||||
|
secret = settings.JWT_ACCESS_SECRET if token_type == "access" else settings.JWT_REFRESH_SECRET
|
||||||
|
payload = jwt.decode(token, secret, algorithms=["HS256"])
|
||||||
|
|
||||||
|
if payload.get("type") != token_type:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return payload
|
||||||
|
except JWTError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||||
|
access_token: Optional[str] = Cookie(None, alias=settings.JWT_ACCESS_COOKIE_NAME)
|
||||||
|
):
|
||||||
|
"""Get current authenticated user"""
|
||||||
|
token = None
|
||||||
|
|
||||||
|
# Try Bearer token from header
|
||||||
|
if credentials:
|
||||||
|
token = credentials.credentials
|
||||||
|
# Try cookie
|
||||||
|
elif access_token:
|
||||||
|
token = access_token
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Не авторизован"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify token
|
||||||
|
payload = verify_token(token, "access")
|
||||||
|
if not payload:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Неверный токен"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get user from database
|
||||||
|
user_id = payload.get("userId")
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Неверный токен"
|
||||||
|
)
|
||||||
|
|
||||||
|
from bson import ObjectId
|
||||||
|
user = await users_collection().find_one({"_id": ObjectId(user_id)})
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Пользователь не найден"
|
||||||
|
)
|
||||||
|
|
||||||
|
if user.get('banned'):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Аккаунт заблокирован"
|
||||||
|
)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def require_moderator(user: dict = Depends(get_current_user)):
|
||||||
|
"""Require moderator or admin role"""
|
||||||
|
if user.get('role') not in ['moderator', 'admin']:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Недостаточно прав для модерации"
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def require_owner(user: dict = Depends(get_current_user)):
|
||||||
|
"""Require owner role"""
|
||||||
|
username = user.get('username', '').lower()
|
||||||
|
is_owner = (
|
||||||
|
user.get('role') == 'admin' or
|
||||||
|
username in settings.OWNER_USERNAMES_LIST
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_owner:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Недостаточно прав. Требуются права владельца."
|
||||||
|
)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_username(username: Optional[str]) -> Optional[str]:
|
||||||
|
"""Normalize username (remove @ and lowercase)"""
|
||||||
|
if not username:
|
||||||
|
return None
|
||||||
|
|
||||||
|
username = username.strip().lower()
|
||||||
|
if username.startswith('@'):
|
||||||
|
username = username[1:]
|
||||||
|
|
||||||
|
return username if username else None
|
||||||
|
|
||||||
|
|
||||||
|
async def is_moderation_admin(telegram_id: Optional[str] = None, username: Optional[str] = None) -> bool:
|
||||||
|
"""Check if user is moderation admin"""
|
||||||
|
if not telegram_id and not username:
|
||||||
|
return False
|
||||||
|
|
||||||
|
query = {}
|
||||||
|
if telegram_id:
|
||||||
|
query['telegramId'] = telegram_id
|
||||||
|
|
||||||
|
if username:
|
||||||
|
normalized = normalize_username(username)
|
||||||
|
if normalized:
|
||||||
|
query['username'] = normalized
|
||||||
|
|
||||||
|
admin = await moderation_admins_collection().find_one(query)
|
||||||
|
return admin is not None
|
||||||
|
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
"""
|
||||||
|
Email sending utilities with support for Yandex SMTP
|
||||||
|
"""
|
||||||
|
import smtplib
|
||||||
|
import logging
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_verification_email(code: str) -> str:
|
||||||
|
"""Generate HTML email with verification code"""
|
||||||
|
return f"""
|
||||||
|
<!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>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def send_email_smtp(to: str, subject: str, html: str, text: Optional[str] = None):
|
||||||
|
"""Send email via SMTP (Yandex, Gmail, etc.)"""
|
||||||
|
try:
|
||||||
|
# Create message
|
||||||
|
msg = MIMEMultipart('alternative')
|
||||||
|
msg['Subject'] = subject
|
||||||
|
msg['From'] = settings.EMAIL_FROM
|
||||||
|
msg['To'] = to
|
||||||
|
|
||||||
|
# Add text and HTML parts
|
||||||
|
if text:
|
||||||
|
msg.attach(MIMEText(text, 'plain', 'utf-8'))
|
||||||
|
msg.attach(MIMEText(html, 'html', 'utf-8'))
|
||||||
|
|
||||||
|
# Connect and send
|
||||||
|
if settings.EMAIL_PROVIDER == 'yandex':
|
||||||
|
logger.info(f"[Email] Отправка через Yandex SMTP: {settings.YANDEX_SMTP_HOST}:{settings.YANDEX_SMTP_PORT}")
|
||||||
|
|
||||||
|
if not settings.YANDEX_SMTP_USER or not settings.YANDEX_SMTP_PASSWORD:
|
||||||
|
raise ValueError("YANDEX_SMTP_USER и YANDEX_SMTP_PASSWORD должны быть установлены в .env")
|
||||||
|
|
||||||
|
# Используем SMTP_SSL для порта 465
|
||||||
|
if settings.YANDEX_SMTP_PORT == 465:
|
||||||
|
server = smtplib.SMTP_SSL(settings.YANDEX_SMTP_HOST, settings.YANDEX_SMTP_PORT)
|
||||||
|
else:
|
||||||
|
server = smtplib.SMTP(settings.YANDEX_SMTP_HOST, settings.YANDEX_SMTP_PORT)
|
||||||
|
if settings.YANDEX_SMTP_SECURE:
|
||||||
|
server.starttls()
|
||||||
|
|
||||||
|
server.login(settings.YANDEX_SMTP_USER, settings.YANDEX_SMTP_PASSWORD)
|
||||||
|
server.send_message(msg)
|
||||||
|
server.quit()
|
||||||
|
|
||||||
|
logger.info(f"✅ Email отправлен на {to}")
|
||||||
|
return {"success": True, "to": to}
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Email provider '{settings.EMAIL_PROVIDER}' не поддерживается. Используйте 'yandex'")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка отправки email: {e}")
|
||||||
|
|
||||||
|
# More informative error messages
|
||||||
|
if "Authentication" in str(e) or "credentials" in str(e).lower():
|
||||||
|
raise ValueError(
|
||||||
|
"Неверные учетные данные SMTP. Для Yandex используйте пароль приложения "
|
||||||
|
"(https://id.yandex.ru/security), а не основной пароль аккаунта."
|
||||||
|
)
|
||||||
|
elif "Connection" in str(e):
|
||||||
|
raise ValueError(
|
||||||
|
f"Не удалось подключиться к SMTP серверу {settings.YANDEX_SMTP_HOST}:{settings.YANDEX_SMTP_PORT}"
|
||||||
|
)
|
||||||
|
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def send_verification_code(email: str, code: str):
|
||||||
|
"""Send verification code to email"""
|
||||||
|
subject = "Код подтверждения регистрации - Nakama"
|
||||||
|
html = generate_verification_email(code)
|
||||||
|
text = f"Ваш код подтверждения: {code}. Код действителен 15 минут."
|
||||||
|
|
||||||
|
return await send_email_smtp(email, subject, html, text)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_admin_confirmation_code(code: str, action: str, user_info: dict):
|
||||||
|
"""Send admin confirmation code to owner email"""
|
||||||
|
action_text = "добавления админа" if action == "add" else "удаления админа"
|
||||||
|
subject = f"Код подтверждения {action_text} - Nakama Moderation"
|
||||||
|
|
||||||
|
html = f"""
|
||||||
|
<!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; }}
|
||||||
|
.info {{ background: #e7f3ff; padding: 15px; border-radius: 8px; margin: 20px 0; }}
|
||||||
|
.footer {{ margin-top: 30px; font-size: 12px; color: #666; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Подтверждение {action_text}</h1>
|
||||||
|
<p><strong>Пользователь:</strong> @{user_info.get('username', 'не указан')} ({user_info.get('firstName', '')})</p>
|
||||||
|
{f"<p><strong>Номер админа:</strong> {user_info['adminNumber']}</p>" if 'adminNumber' in user_info else ''}
|
||||||
|
<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>
|
||||||
|
"""
|
||||||
|
|
||||||
|
text = f"""Код подтверждения {action_text}: {code}
|
||||||
|
|
||||||
|
Пользователь: @{user_info.get('username', 'не указан')}
|
||||||
|
Код действителен 5 минут."""
|
||||||
|
|
||||||
|
return await send_email_smtp(settings.OWNER_EMAIL, subject, html, text)
|
||||||
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""
|
||||||
|
MinIO client for file storage
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
from minio import Minio
|
||||||
|
from minio.error import S3Error
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
minio_client: Optional[Minio] = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_minio():
|
||||||
|
"""Initialize MinIO client"""
|
||||||
|
global minio_client
|
||||||
|
|
||||||
|
if not settings.MINIO_ENABLED:
|
||||||
|
logger.info("MinIO отключен")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
endpoint = f"{settings.MINIO_ENDPOINT}:{settings.MINIO_PORT}"
|
||||||
|
|
||||||
|
minio_client = Minio(
|
||||||
|
endpoint,
|
||||||
|
access_key=settings.MINIO_ACCESS_KEY,
|
||||||
|
secret_key=settings.MINIO_SECRET_KEY,
|
||||||
|
secure=settings.MINIO_USE_SSL
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test connection
|
||||||
|
if not minio_client.bucket_exists(settings.MINIO_BUCKET):
|
||||||
|
logger.warning(f"Bucket '{settings.MINIO_BUCKET}' не существует")
|
||||||
|
else:
|
||||||
|
logger.info(f"✅ MinIO подключен: {endpoint}, bucket: {settings.MINIO_BUCKET}")
|
||||||
|
|
||||||
|
return minio_client
|
||||||
|
|
||||||
|
except S3Error as e:
|
||||||
|
logger.error(f"❌ Ошибка подключения к MinIO: {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка инициализации MinIO: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_minio_client():
|
||||||
|
"""Get MinIO client instance"""
|
||||||
|
return minio_client
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_file(file_path: str):
|
||||||
|
"""Delete file from MinIO"""
|
||||||
|
if not minio_client:
|
||||||
|
logger.warning("MinIO client not initialized")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
minio_client.remove_object(settings.MINIO_BUCKET, file_path)
|
||||||
|
logger.info(f"✅ Файл удален из MinIO: {file_path}")
|
||||||
|
return True
|
||||||
|
except S3Error as e:
|
||||||
|
logger.error(f"❌ Ошибка удаления файла из MinIO: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
"""
|
||||||
|
WebSocket server for moderation chat
|
||||||
|
"""
|
||||||
|
import socketio
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Set
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Create Socket.IO server
|
||||||
|
sio = socketio.AsyncServer(
|
||||||
|
async_mode='asgi',
|
||||||
|
cors_allowed_origins='*',
|
||||||
|
logger=False,
|
||||||
|
engineio_logger=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Track connected moderators
|
||||||
|
connected_moderators: Dict[str, Set[str]] = {} # {room: {sid1, sid2, ...}}
|
||||||
|
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def connect(sid, environ):
|
||||||
|
"""Handle client connection"""
|
||||||
|
logger.info(f"[WebSocket] Client connected: {sid}")
|
||||||
|
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def disconnect(sid):
|
||||||
|
"""Handle client disconnection"""
|
||||||
|
logger.info(f"[WebSocket] Client disconnected: {sid}")
|
||||||
|
|
||||||
|
# Remove from all rooms
|
||||||
|
for room, sids in connected_moderators.items():
|
||||||
|
if sid in sids:
|
||||||
|
sids.remove(sid)
|
||||||
|
await sio.emit('user_left', {'sid': sid}, room=room, skip_sid=sid)
|
||||||
|
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def join_moderation_chat(sid, data):
|
||||||
|
"""Join moderation chat room"""
|
||||||
|
try:
|
||||||
|
user_id = data.get('userId')
|
||||||
|
username = data.get('username')
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
room = 'moderation_chat'
|
||||||
|
await sio.enter_room(sid, room)
|
||||||
|
|
||||||
|
if room not in connected_moderators:
|
||||||
|
connected_moderators[room] = set()
|
||||||
|
connected_moderators[room].add(sid)
|
||||||
|
|
||||||
|
logger.info(f"[WebSocket] {username} ({user_id}) joined moderation chat")
|
||||||
|
|
||||||
|
# Notify others
|
||||||
|
await sio.emit('user_joined', {
|
||||||
|
'userId': user_id,
|
||||||
|
'username': username,
|
||||||
|
'sid': sid
|
||||||
|
}, room=room, skip_sid=sid)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WebSocket] Error joining chat: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def leave_moderation_chat(sid, data):
|
||||||
|
"""Leave moderation chat room"""
|
||||||
|
try:
|
||||||
|
room = 'moderation_chat'
|
||||||
|
await sio.leave_room(sid, room)
|
||||||
|
|
||||||
|
if room in connected_moderators and sid in connected_moderators[room]:
|
||||||
|
connected_moderators[room].remove(sid)
|
||||||
|
|
||||||
|
# Notify others
|
||||||
|
await sio.emit('user_left', {'sid': sid}, room=room, skip_sid=sid)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WebSocket] Error leaving chat: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def moderation_message(sid, data):
|
||||||
|
"""Handle moderation chat message"""
|
||||||
|
try:
|
||||||
|
room = 'moderation_chat'
|
||||||
|
|
||||||
|
message_data = {
|
||||||
|
'userId': data.get('userId'),
|
||||||
|
'username': data.get('username'),
|
||||||
|
'message': data.get('message'),
|
||||||
|
'timestamp': data.get('timestamp')
|
||||||
|
}
|
||||||
|
|
||||||
|
# Broadcast to all in room
|
||||||
|
await sio.emit('moderation_message', message_data, room=room)
|
||||||
|
|
||||||
|
logger.info(f"[WebSocket] Message from {data.get('username')}: {data.get('message')[:50]}...")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WebSocket] Error sending message: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def typing(sid, data):
|
||||||
|
"""Handle typing indicator"""
|
||||||
|
try:
|
||||||
|
room = 'moderation_chat'
|
||||||
|
await sio.emit('typing', {
|
||||||
|
'userId': data.get('userId'),
|
||||||
|
'username': data.get('username'),
|
||||||
|
'isTyping': data.get('isTyping', True)
|
||||||
|
}, room=room, skip_sid=sid)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WebSocket] Error handling typing: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_socketio_app():
|
||||||
|
"""Get Socket.IO ASGI app"""
|
||||||
|
return socketio.ASGIApp(sio)
|
||||||
|
|
||||||
Loading…
Reference in New Issue