From f5c16a350db6ebe84171e1e5e04562d99c24698f Mon Sep 17 00:00:00 2001 From: glpshchn <464976@niuitmo.ru> Date: Mon, 15 Dec 2025 02:45:41 +0300 Subject: [PATCH] Update files --- backend/routes/moderationAuth.js | 13 +- backend/utils/email.js | 64 ++- moderation/PYTHON_BACKEND.md | 215 +++++++ moderation/backend-py/.dockerignore | 19 + moderation/backend-py/.gitignore | 17 + moderation/backend-py/DOCKER.md | 273 +++++++++ moderation/backend-py/DOCKER_QUICK.txt | 109 ++++ moderation/backend-py/Dockerfile | 28 + moderation/backend-py/INSTALL.md | 165 ++++++ moderation/backend-py/MIGRATION.md | 141 +++++ moderation/backend-py/QUICKSTART.md | 118 ++++ moderation/backend-py/README.md | 208 +++++++ moderation/backend-py/RUN_THIS.sh | 44 ++ moderation/backend-py/START_HERE.md | 122 ++++ moderation/backend-py/config.py | 108 ++++ moderation/backend-py/database.py | 94 +++ .../docker-compose.moderation-py.yml | 50 ++ moderation/backend-py/docker-start.sh | 59 ++ moderation/backend-py/main.py | 101 ++++ moderation/backend-py/middleware.py | 73 +++ moderation/backend-py/models.py | 157 +++++ moderation/backend-py/requirements.txt | 40 ++ moderation/backend-py/routes/__init__.py | 2 + moderation/backend-py/routes/mod_app.py | 538 ++++++++++++++++++ .../backend-py/routes/moderation_auth.py | 439 ++++++++++++++ moderation/backend-py/run.py | 19 + moderation/backend-py/start.sh | 31 + moderation/backend-py/test_email.py | 56 ++ moderation/backend-py/utils/__init__.py | 2 + moderation/backend-py/utils/auth.py | 186 ++++++ moderation/backend-py/utils/email_service.py | 154 +++++ moderation/backend-py/utils/minio_client.py | 68 +++ moderation/backend-py/websocket_server.py | 127 +++++ 33 files changed, 3832 insertions(+), 8 deletions(-) create mode 100644 moderation/PYTHON_BACKEND.md create mode 100644 moderation/backend-py/.dockerignore create mode 100644 moderation/backend-py/.gitignore create mode 100644 moderation/backend-py/DOCKER.md create mode 100644 moderation/backend-py/DOCKER_QUICK.txt create mode 100644 moderation/backend-py/Dockerfile create mode 100644 moderation/backend-py/INSTALL.md create mode 100644 moderation/backend-py/MIGRATION.md create mode 100644 moderation/backend-py/QUICKSTART.md create mode 100644 moderation/backend-py/README.md create mode 100755 moderation/backend-py/RUN_THIS.sh create mode 100644 moderation/backend-py/START_HERE.md create mode 100644 moderation/backend-py/config.py create mode 100644 moderation/backend-py/database.py create mode 100644 moderation/backend-py/docker-compose.moderation-py.yml create mode 100755 moderation/backend-py/docker-start.sh create mode 100644 moderation/backend-py/main.py create mode 100644 moderation/backend-py/middleware.py create mode 100644 moderation/backend-py/models.py create mode 100644 moderation/backend-py/requirements.txt create mode 100644 moderation/backend-py/routes/__init__.py create mode 100644 moderation/backend-py/routes/mod_app.py create mode 100644 moderation/backend-py/routes/moderation_auth.py create mode 100755 moderation/backend-py/run.py create mode 100755 moderation/backend-py/start.sh create mode 100755 moderation/backend-py/test_email.py create mode 100644 moderation/backend-py/utils/__init__.py create mode 100644 moderation/backend-py/utils/auth.py create mode 100644 moderation/backend-py/utils/email_service.py create mode 100644 moderation/backend-py/utils/minio_client.py create mode 100644 moderation/backend-py/websocket_server.py diff --git a/backend/routes/moderationAuth.js b/backend/routes/moderationAuth.js index f816cbe..59cc4cc 100644 --- a/backend/routes/moderationAuth.js +++ b/backend/routes/moderationAuth.js @@ -100,8 +100,19 @@ router.post('/send-code', codeLimiter, async (req, res) => { } catch (emailError) { console.error('Ошибка отправки email:', emailError); 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({ - error: 'Не удалось отправить код на email. Проверьте настройки email сервера.' + error: errorMessage }); } } catch (error) { diff --git a/backend/utils/email.js b/backend/utils/email.js index 707be5f..f819c10 100644 --- a/backend/utils/email.js +++ b/backend/utils/email.js @@ -53,15 +53,55 @@ const initializeEmailService = () => { } else if (emailProvider === 'yandex' || emailProvider === 'smtp') { const emailConfig = config.email?.[emailProvider] || config.email?.smtp || {}; - transporter = nodemailer.createTransport({ - host: emailConfig.host || process.env.SMTP_HOST, - port: emailConfig.port || parseInt(process.env.SMTP_PORT || '587', 10), - secure: emailConfig.secure === true || process.env.SMTP_SECURE === 'true', - auth: { - user: emailConfig.user || process.env.SMTP_USER, - pass: emailConfig.password || process.env.SMTP_PASSWORD + const smtpHost = emailConfig.host || process.env.SMTP_HOST || process.env.YANDEX_SMTP_HOST; + const smtpPort = emailConfig.port || parseInt(process.env.SMTP_PORT || process.env.YANDEX_SMTP_PORT || '587', 10); + const smtpSecure = emailConfig.secure !== undefined ? emailConfig.secure : + (process.env.SMTP_SECURE === 'true' || process.env.YANDEX_SMTP_SECURE === 'true' || smtpPort === 465); + const smtpUser = emailConfig.user || process.env.SMTP_USER || process.env.YANDEX_SMTP_USER; + const smtpPassword = emailConfig.password || process.env.SMTP_PASSWORD || process.env.YANDEX_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) { 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; } }; diff --git a/moderation/PYTHON_BACKEND.md b/moderation/PYTHON_BACKEND.md new file mode 100644 index 0000000..9c630c6 --- /dev/null +++ b/moderation/PYTHON_BACKEND.md @@ -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 полностью совместимо + +Фронтенд работает без изменений! + diff --git a/moderation/backend-py/.dockerignore b/moderation/backend-py/.dockerignore new file mode 100644 index 0000000..2e487aa --- /dev/null +++ b/moderation/backend-py/.dockerignore @@ -0,0 +1,19 @@ +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +*.so +*.egg +*.egg-info +dist +build +.env +.venv +venv +env +.git +.gitignore +*.md +.DS_Store + diff --git a/moderation/backend-py/.gitignore b/moderation/backend-py/.gitignore new file mode 100644 index 0000000..a71bb98 --- /dev/null +++ b/moderation/backend-py/.gitignore @@ -0,0 +1,17 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +env/ +ENV/ +.venv +*.egg +*.egg-info/ +dist/ +build/ +.env +.DS_Store +*.log + diff --git a/moderation/backend-py/DOCKER.md b/moderation/backend-py/DOCKER.md new file mode 100644 index 0000000..d31281b --- /dev/null +++ b/moderation/backend-py/DOCKER.md @@ -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`** 🚀 + diff --git a/moderation/backend-py/DOCKER_QUICK.txt b/moderation/backend-py/DOCKER_QUICK.txt new file mode 100644 index 0000000..ab6b0c0 --- /dev/null +++ b/moderation/backend-py/DOCKER_QUICK.txt @@ -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 + + +═══════════════════════════════════════════════════════════════ +Готово! 🎉 +═══════════════════════════════════════════════════════════════ + diff --git a/moderation/backend-py/Dockerfile b/moderation/backend-py/Dockerfile new file mode 100644 index 0000000..970d3f3 --- /dev/null +++ b/moderation/backend-py/Dockerfile @@ -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"] + diff --git a/moderation/backend-py/INSTALL.md b/moderation/backend-py/INSTALL.md new file mode 100644 index 0000000..236992f --- /dev/null +++ b/moderation/backend-py/INSTALL.md @@ -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` для подробностей. + diff --git a/moderation/backend-py/MIGRATION.md b/moderation/backend-py/MIGRATION.md new file mode 100644 index 0000000..ecaac68 --- /dev/null +++ b/moderation/backend-py/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 +``` + +### 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 ` + +## Производительность + +Python версия с uvicorn и 4 workers показывает сопоставимую или лучшую производительность по сравнению с Node.js. + +## Поддержка + +При проблемах создайте issue или обратитесь к администратору. + diff --git a/moderation/backend-py/QUICKSTART.md b/moderation/backend-py/QUICKSTART.md new file mode 100644 index 0000000..c8b6cb5 --- /dev/null +++ b/moderation/backend-py/QUICKSTART.md @@ -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` для полных инструкций по развертыванию. + diff --git a/moderation/backend-py/README.md b/moderation/backend-py/README.md new file mode 100644 index 0000000..1f49af7 --- /dev/null +++ b/moderation/backend-py/README.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` для инструкций по развертыванию. + diff --git a/moderation/backend-py/RUN_THIS.sh b/moderation/backend-py/RUN_THIS.sh new file mode 100755 index 0000000..6ce2ad0 --- /dev/null +++ b/moderation/backend-py/RUN_THIS.sh @@ -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 + diff --git a/moderation/backend-py/START_HERE.md b/moderation/backend-py/START_HERE.md new file mode 100644 index 0000000..583a6c3 --- /dev/null +++ b/moderation/backend-py/START_HERE.md @@ -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 совместимо с фронтендом. + diff --git a/moderation/backend-py/config.py b/moderation/backend-py/config.py new file mode 100644 index 0000000..d6f0f5d --- /dev/null +++ b/moderation/backend-py/config.py @@ -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() + diff --git a/moderation/backend-py/database.py b/moderation/backend-py/database.py new file mode 100644 index 0000000..6531e25 --- /dev/null +++ b/moderation/backend-py/database.py @@ -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') + diff --git a/moderation/backend-py/docker-compose.moderation-py.yml b/moderation/backend-py/docker-compose.moderation-py.yml new file mode 100644 index 0000000..5ad164b --- /dev/null +++ b/moderation/backend-py/docker-compose.moderation-py.yml @@ -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 + diff --git a/moderation/backend-py/docker-start.sh b/moderation/backend-py/docker-start.sh new file mode 100755 index 0000000..e32b9d8 --- /dev/null +++ b/moderation/backend-py/docker-start.sh @@ -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 + diff --git a/moderation/backend-py/main.py b/moderation/backend-py/main.py new file mode 100644 index 0000000..d53ce17 --- /dev/null +++ b/moderation/backend-py/main.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" + ) + diff --git a/moderation/backend-py/middleware.py b/moderation/backend-py/middleware.py new file mode 100644 index 0000000..ea36f55 --- /dev/null +++ b/moderation/backend-py/middleware.py @@ -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) + diff --git a/moderation/backend-py/models.py b/moderation/backend-py/models.py new file mode 100644 index 0000000..8d1b70e --- /dev/null +++ b/moderation/backend-py/models.py @@ -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 + diff --git a/moderation/backend-py/requirements.txt b/moderation/backend-py/requirements.txt new file mode 100644 index 0000000..ab0a5fd --- /dev/null +++ b/moderation/backend-py/requirements.txt @@ -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 + diff --git a/moderation/backend-py/routes/__init__.py b/moderation/backend-py/routes/__init__.py new file mode 100644 index 0000000..fb42c31 --- /dev/null +++ b/moderation/backend-py/routes/__init__.py @@ -0,0 +1,2 @@ +# Routes package + diff --git a/moderation/backend-py/routes/mod_app.py b/moderation/backend-py/routes/mod_app.py new file mode 100644 index 0000000..0572dd6 --- /dev/null +++ b/moderation/backend-py/routes/mod_app.py @@ -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="Ошибка сервера" + ) + diff --git a/moderation/backend-py/routes/moderation_auth.py b/moderation/backend-py/routes/moderation_auth.py new file mode 100644 index 0000000..feff51d --- /dev/null +++ b/moderation/backend-py/routes/moderation_auth.py @@ -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') + } + } + diff --git a/moderation/backend-py/run.py b/moderation/backend-py/run.py new file mode 100755 index 0000000..938586d --- /dev/null +++ b/moderation/backend-py/run.py @@ -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 + ) + diff --git a/moderation/backend-py/start.sh b/moderation/backend-py/start.sh new file mode 100755 index 0000000..6217094 --- /dev/null +++ b/moderation/backend-py/start.sh @@ -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 + diff --git a/moderation/backend-py/test_email.py b/moderation/backend-py/test_email.py new file mode 100755 index 0000000..735c671 --- /dev/null +++ b/moderation/backend-py/test_email.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()) + diff --git a/moderation/backend-py/utils/__init__.py b/moderation/backend-py/utils/__init__.py new file mode 100644 index 0000000..13fa07c --- /dev/null +++ b/moderation/backend-py/utils/__init__.py @@ -0,0 +1,2 @@ +# Utils package + diff --git a/moderation/backend-py/utils/auth.py b/moderation/backend-py/utils/auth.py new file mode 100644 index 0000000..e572074 --- /dev/null +++ b/moderation/backend-py/utils/auth.py @@ -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 + diff --git a/moderation/backend-py/utils/email_service.py b/moderation/backend-py/utils/email_service.py new file mode 100644 index 0000000..6109b5e --- /dev/null +++ b/moderation/backend-py/utils/email_service.py @@ -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""" + + + + + + + +
+

Код подтверждения

+

Ваш код для регистрации в Nakama:

+
{code}
+

Код действителен в течение 15 минут.

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

Подтверждение {action_text}

+

Пользователь: @{user_info.get('username', 'не указан')} ({user_info.get('firstName', '')})

+ {f"

Номер админа: {user_info['adminNumber']}

" if 'adminNumber' in user_info else ''} +
+

Код подтверждения:

+
{code}
+

Код действителен в течение 5 минут.

+
+ +
+ + + """ + + text = f"""Код подтверждения {action_text}: {code} + +Пользователь: @{user_info.get('username', 'не указан')} +Код действителен 5 минут.""" + + return await send_email_smtp(settings.OWNER_EMAIL, subject, html, text) + diff --git a/moderation/backend-py/utils/minio_client.py b/moderation/backend-py/utils/minio_client.py new file mode 100644 index 0000000..4eb6fd8 --- /dev/null +++ b/moderation/backend-py/utils/minio_client.py @@ -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 + diff --git a/moderation/backend-py/websocket_server.py b/moderation/backend-py/websocket_server.py new file mode 100644 index 0000000..8e9e26e --- /dev/null +++ b/moderation/backend-py/websocket_server.py @@ -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) +