diff --git a/ENV_EXAMPLE.txt b/ENV_EXAMPLE.txt new file mode 100644 index 0000000..be31d61 --- /dev/null +++ b/ENV_EXAMPLE.txt @@ -0,0 +1,60 @@ +# Server Configuration +NODE_ENV=production +PORT=3000 + +# Database Configuration (удаленный сервер) +MONGODB_URI=mongodb://103.80.87.247:27017/nakama + +# JWT Secrets +JWT_SECRET=your_jwt_secret_change_me_32chars_minimum +JWT_ACCESS_SECRET=your_access_secret_change_me_32chars +JWT_REFRESH_SECRET=your_refresh_secret_change_me_32chars +JWT_ACCESS_EXPIRES_IN=300 +JWT_REFRESH_EXPIRES_IN=604800 + +# Telegram Bot Configuration +TELEGRAM_BOT_TOKEN=your_telegram_bot_token +MODERATION_BOT_TOKEN=your_moderation_bot_token +MODERATION_OWNER_USERNAMES=glpshchn00 +MODERATION_CHANNEL_USERNAME=@reichenbfurry + +# Gelbooru API +GELBOORU_API_KEY=your_gelbooru_api_key +GELBOORU_USER_ID=your_gelbooru_user_id + +# Frontend URL +FRONTEND_URL=http://localhost:5173 +VITE_API_URL=http://localhost:3000/api + +# CORS Configuration +CORS_ORIGIN=* + +# Redis (optional) +REDIS_URL= + +# MinIO Configuration (S3-compatible object storage) +MINIO_ENABLED=true +MINIO_ENDPOINT=103.80.87.247 # IP вашего MinIO сервера +MINIO_PORT=9000 # API порт (обычно 9000, консоль на 9901) +MINIO_USE_SSL=false +MINIO_ACCESS_KEY=minioadmin # Получите из MinIO Console +MINIO_SECRET_KEY=minioadmin # Получите из MinIO Console +MINIO_BUCKET=nakama-media +MINIO_REGION=us-east-1 +MINIO_PUBLIC_URL= # Опционально: CDN URL +MINIO_PUBLIC_BUCKET=false + +# File Upload (fallback для локального хранилища) +MAX_FILE_SIZE=10485760 +UPLOADS_DIR=uploads + +# Rate Limiting +RATE_LIMIT_GENERAL=100 +RATE_LIMIT_POSTS=10 +RATE_LIMIT_INTERACTIONS=20 + +# Cache TTL (seconds) +CACHE_TTL_POSTS=300 +CACHE_TTL_USERS=600 +CACHE_TTL_SEARCH=180 + diff --git a/MINIO_MIGRATION_SUMMARY.md b/MINIO_MIGRATION_SUMMARY.md new file mode 100644 index 0000000..20b8f40 --- /dev/null +++ b/MINIO_MIGRATION_SUMMARY.md @@ -0,0 +1,347 @@ +# ✅ MinIO интеграция завершена! + +## 🎉 Что было сделано + +### 1. Добавлен MinIO клиент +- ✅ Установлен пакет `minio` в package.json +- ✅ Создана утилита `/backend/utils/minio.js` с полным API +- ✅ Поддержка загрузки, удаления, получения URL файлов + +### 2. Создан универсальный middleware загрузки +- ✅ `/backend/middleware/upload.js` - автоматически выбирает MinIO или локальное хранилище +- ✅ Поддержка изображений и видео +- ✅ Валидация типов файлов +- ✅ Автоматическая очистка при ошибках + +### 3. Обновлены роуты +- ✅ `/backend/routes/posts.js` - использует новый middleware +- ✅ `/backend/routes/modApp.js` - публикация в канал через MinIO +- ✅ Fallback на локальное хранилище если MinIO недоступен + +### 4. Обновлена конфигурация +- ✅ `/backend/config/index.js` - добавлены MinIO настройки +- ✅ `/backend/server.js` - автоматическая инициализация MinIO +- ✅ `docker-compose.yml` - добавлен MinIO сервис + +### 5. Создана документация +- ✅ `MINIO_SETUP.md` - полное руководство по настройке +- ✅ `ENV_EXAMPLE.txt` - пример конфигурации +- ✅ Инструкции по миграции существующих файлов + +--- + +## 🚀 Быстрый старт + +### Шаг 1: Установите зависимости + +```bash +cd /Users/glpshchn/Desktop/nakama +npm install +``` + +### Шаг 2: Обновите .env файл + +```bash +nano .env +``` + +Добавьте MinIO настройки: + +```env +# MinIO Configuration +MINIO_ENABLED=true +MINIO_ENDPOINT=minio +MINIO_PORT=9000 +MINIO_USE_SSL=false +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=your_secure_password_here +MINIO_BUCKET=nakama-media +``` + +### Шаг 3: Запустите Docker + +```bash +docker-compose down +docker-compose build +docker-compose up -d +``` + +### Шаг 4: Проверьте MinIO + +Откройте в браузере: +- **MinIO Console:** http://localhost:9001 +- **Логин:** minioadmin / your_secure_password_here + +### Шаг 5: Создайте тестовый пост + +Создайте пост с изображением в приложении. Файл автоматически загрузится в MinIO! + +Проверьте в MinIO Console: +- Object Browser → nakama-media → posts/ + +--- + +## 📊 Варианты использования + +### Вариант 1: MinIO в Docker (для начала) + +**Преимущества:** +- ✅ Быстрая настройка +- ✅ Всё в одном месте +- ✅ Удобно для разработки + +**Настройка:** +```env +MINIO_ENABLED=true +MINIO_ENDPOINT=minio # Имя сервиса в Docker +MINIO_PORT=9000 +``` + +--- + +### Вариант 2: MinIO на отдельном сервере (рекомендуется) + +**Преимущества:** +- ✅ Централизованное хранилище +- ✅ Легко масштабировать +- ✅ Независимость от основного сервера + +**Настройка:** +```bash +# На сервере 103.80.87.247 установите MinIO +# (см. MINIO_SETUP.md раздел "Отдельный сервер") + +# В .env приложения: +MINIO_ENABLED=true +MINIO_ENDPOINT=103.80.87.247 +MINIO_PORT=9000 +MINIO_USE_SSL=false +MINIO_ACCESS_KEY=nakama_app +MINIO_SECRET_KEY=secure_key_here +MINIO_BUCKET=nakama-media +``` + +--- + +### Вариант 3: MinIO + CDN (для продакшена) + +**Преимущества:** +- ✅ Максимальная производительность +- ✅ Глобальное кэширование +- ✅ Экономия трафика + +**Настройка:** +```env +MINIO_ENABLED=true +MINIO_ENDPOINT=103.80.87.247 +MINIO_PORT=9000 +MINIO_USE_SSL=true +MINIO_ACCESS_KEY=nakama_app +MINIO_SECRET_KEY=secure_key_here +MINIO_BUCKET=nakama-media +MINIO_PUBLIC_URL=https://cdn.yourdomain.com +``` + +--- + +## 🔄 Миграция существующих файлов + +Если у вас уже есть файлы в `backend/uploads/`: + +```bash +# Установите MinIO Client +wget https://dl.min.io/client/mc/release/linux-amd64/mc +chmod +x mc + +# Настройте подключение +./mc alias set myminio http://localhost:9000 minioadmin your_password + +# Синхронизируйте файлы +./mc mirror backend/uploads/posts myminio/nakama-media/posts/ +./mc mirror backend/uploads/avatars myminio/nakama-media/avatars/ + +# Проверьте +./mc ls myminio/nakama-media/ +``` + +--- + +## 🎯 Как это работает + +### До (локальное хранилище): +``` +Пользователь загружает фото + ↓ +Multer сохраняет в backend/uploads/ + ↓ +URL: /uploads/posts/12345.jpg +``` + +### После (с MinIO): +``` +Пользователь загружает фото + ↓ +Multer → buffer в памяти + ↓ +MinIO middleware загружает в S3 + ↓ +URL: http://minio:9000/nakama-media/posts/12345.jpg +``` + +### Fallback (если MinIO недоступен): +``` +Пользователь загружает фото + ↓ +Multer → buffer в памяти + ↓ +MinIO недоступен → fallback + ↓ +Сохранение в backend/uploads/ + ↓ +URL: /uploads/posts/12345.jpg +``` + +--- + +## 🛡️ Безопасность + +### Важно изменить для продакшена: + +```env +# ❌ НЕ используйте в продакшене: +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin + +# ✅ Используйте: +MINIO_ACCESS_KEY=nakama_app_$(openssl rand -hex 8) +MINIO_SECRET_KEY=$(openssl rand -hex 32) +``` + +### Настройка HTTPS: + +```bash +# На сервере MinIO: +mkdir -p ~/.minio/certs +cp cert.pem ~/.minio/certs/public.crt +cp key.pem ~/.minio/certs/private.key +systemctl restart minio +``` + +```env +# В .env: +MINIO_USE_SSL=true +``` + +--- + +## 📊 Мониторинг + +### Проверить подключение: + +```bash +# В логах backend: +docker-compose logs backend | grep -i minio + +# Должны увидеть: +# ✅ MinIO подключен: minio:9000 +# Bucket: nakama-media +``` + +### Веб-консоль MinIO: + +1. Откройте: http://localhost:9001 +2. Мониторинг → Metrics +3. Просмотр файлов: Object Browser → nakama-media + +### Статистика через API: + +```javascript +// В коде backend: +const { getBucketStats } = require('./utils/minio'); + +const stats = await getBucketStats(); +console.log(stats); +// { +// totalFiles: 1234, +// totalSize: 52428800, +// totalSizeMB: "50.00", +// bucket: "nakama-media" +// } +``` + +--- + +## 🔧 Устранение проблем + +### Проблема: "MinIO недоступен" + +```bash +# Проверьте статус контейнера +docker-compose ps minio + +# Проверьте логи +docker-compose logs minio + +# Перезапустите +docker-compose restart minio +``` + +### Проблема: "Bucket не найден" + +```bash +# Войдите в MinIO Console +http://localhost:9001 + +# Object Browser → Create Bucket +# Имя: nakama-media +``` + +### Проблема: "Access Denied" + +Проверьте credentials в .env: +```bash +docker-compose logs backend | grep MINIO +``` + +--- + +## 📝 Структура файлов в MinIO + +``` +nakama-media/ ← Bucket +├── posts/ ← Посты пользователей +│ ├── 1700000000-123.jpg +│ ├── 1700000001-456.png +│ └── ... +├── avatars/ ← Аватары (будущее) +│ └── ... +└── channel/ ← Публикации в канал + ├── 1700000002-789.jpg + └── ... +``` + +--- + +## 🎉 Готово! + +Теперь все медиа файлы автоматически сохраняются в MinIO! + +**Что дальше:** +1. Прочитайте `MINIO_SETUP.md` для детальной настройки +2. Измените стандартные credentials +3. Настройте HTTPS для продакшена +4. Настройте резервное копирование +5. Рассмотрите использование CDN + +--- + +## 📚 Полезные ссылки + +- **MinIO Documentation:** https://min.io/docs/minio/linux/index.html +- **MinIO Client (mc):** https://min.io/docs/minio/linux/reference/minio-mc.html +- **S3 API Reference:** https://docs.aws.amazon.com/s3/ + +--- + +**Вопросы?** Смотрите `MINIO_SETUP.md` для подробной документации! + diff --git a/MINIO_SETUP.md b/MINIO_SETUP.md new file mode 100644 index 0000000..b5e47c8 --- /dev/null +++ b/MINIO_SETUP.md @@ -0,0 +1,500 @@ +# 🗄️ Настройка MinIO для Nakama + +## Что такое MinIO? + +MinIO - это высокопроизводительное объектное хранилище, совместимое с Amazon S3 API. Оно идеально подходит для хранения медиа файлов в распределенных системах. + +**Преимущества:** +- ✅ S3-совместимый API +- ✅ Высокая производительность +- ✅ Встроенное резервное копирование +- ✅ Веб-консоль для управления +- ✅ Масштабируемость +- ✅ Open Source + +--- + +## 🚀 Быстрый старт + +### Вариант 1: С Docker Compose (рекомендуется) + +MinIO уже включен в `docker-compose.yml`: + +```bash +# Обновите .env файл +nano .env +``` + +Добавьте MinIO настройки: + +```env +# MinIO Configuration +MINIO_ENABLED=true +MINIO_ENDPOINT=minio # В Docker используется имя сервиса +MINIO_PORT=9000 +MINIO_USE_SSL=false +MINIO_ACCESS_KEY=minioadmin # Измените на свой +MINIO_SECRET_KEY=minioadmin_secure_pwd # Измените на свой +MINIO_BUCKET=nakama-media +MINIO_PUBLIC_URL= # Оставьте пустым или укажите CDN URL +``` + +Запустите: + +```bash +docker-compose up -d +``` + +Проверьте: +- MinIO API: http://localhost:9000 +- MinIO Console: http://localhost:9001 +- Логин: minioadmin / minioadmin_secure_pwd + +--- + +### Вариант 2: Отдельный сервер MinIO (103.80.87.247) + +#### Установка на удаленном сервере: + +```bash +# Подключитесь к серверу +ssh root@103.80.87.247 + +# Скачайте MinIO +wget https://dl.min.io/server/minio/release/linux-amd64/minio +chmod +x minio +mv minio /usr/local/bin/ + +# Создайте директорию для данных +mkdir -p /var/minio/data + +# Создайте systemd сервис +nano /etc/systemd/system/minio.service +``` + +Добавьте в файл: + +```ini +[Unit] +Description=MinIO +Documentation=https://min.io/docs/minio/linux/index.html +Wants=network-online.target +After=network-online.target +AssertFileIsExecutable=/usr/local/bin/minio + +[Service] +WorkingDirectory=/usr/local/ + +User=root +Group=root +ProtectProc=invisible + +EnvironmentFile=-/etc/default/minio +ExecStartPre=/bin/bash -c "if [ -z \"${MINIO_VOLUMES}\" ]; then echo \"Variable MINIO_VOLUMES not set in /etc/default/minio\"; exit 1; fi" +ExecStart=/usr/local/bin/minio server $MINIO_OPTS $MINIO_VOLUMES + +# MinIO RELEASE.2023-05-04T21-44-30Z adds support for Type=notify (https://www.freedesktop.org/software/systemd/man/systemd.service.html#Type=) +# This may improve systemctl setups where other services use `After=minio.server` +# Uncomment the line to enable the functionality +# Type=notify + +# Let systemd restart this service always +Restart=always + +# Specifies the maximum file descriptor number that can be opened by this process +LimitNOFILE=65536 + +# Specifies the maximum number of threads this process can create +TasksMax=infinity + +# Disable timeout logic and wait until process is stopped +TimeoutStopSec=infinity +SendSIGKILL=no + +[Install] +WantedBy=multi-user.target +``` + +Создайте файл конфигурации: + +```bash +nano /etc/default/minio +``` + +Добавьте: + +```bash +# MinIO local volumes configuration +MINIO_VOLUMES="/var/minio/data" + +# MinIO root credentials +MINIO_ROOT_USER=minioadmin +MINIO_ROOT_PASSWORD=your_secure_password_here + +# MinIO options +MINIO_OPTS="--console-address :9001" +``` + +Запустите MinIO: + +```bash +systemctl enable minio +systemctl start minio +systemctl status minio +``` + +Откройте порты: + +```bash +ufw allow 9000/tcp # API +ufw allow 9001/tcp # Console +``` + +#### Обновите .env на сервере приложения: + +```env +MINIO_ENABLED=true +MINIO_ENDPOINT=103.80.87.247 +MINIO_PORT=9000 +MINIO_USE_SSL=false +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=your_secure_password_here +MINIO_BUCKET=nakama-media +``` + +--- + +## 🔧 Настройка через веб-консоль + +1. Откройте: http://localhost:9001 (или http://103.80.87.247:9001) +2. Войдите с учетными данными (minioadmin / your_password) +3. Создайте bucket: + - Object Browser → Create Bucket + - Имя: `nakama-media` + - Создайте + +4. Настройте публичный доступ (опционально): + - Выберите bucket → Access → Add Access Rule + - Prefix: `*` + - Access: `readonly` + +--- + +## 🔑 Создание отдельного пользователя (рекомендуется) + +В MinIO Console: + +1. **Identity → Users → Create User** + - Access Key: `nakama_app` + - Secret Key: `secure_secret_key_here` + +2. **Identity → Policies → Create Policy** + + Имя: `nakama-media-policy` + + Policy JSON: + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject", + "s3:ListBucket" + ], + "Resource": [ + "arn:aws:s3:::nakama-media", + "arn:aws:s3:::nakama-media/*" + ] + } + ] + } + ``` + +3. **Назначьте policy пользователю** + - Identity → Users → nakama_app + - Policies → Assign Policy → nakama-media-policy + +4. **Обновите .env:** + ```env + MINIO_ACCESS_KEY=nakama_app + MINIO_SECRET_KEY=secure_secret_key_here + ``` + +--- + +## 📊 Проверка работы + +### Тест 1: Создание поста с изображением + +```bash +# В приложении создайте пост с изображением +# Проверьте в MinIO Console: Object Browser → nakama-media → posts/ +``` + +### Тест 2: Через MinIO Client (mc) + +```bash +# Установите mc +wget https://dl.min.io/client/mc/release/linux-amd64/mc +chmod +x mc +mv mc /usr/local/bin/ + +# Настройте alias +mc alias set nakama http://103.80.87.247:9000 minioadmin your_password + +# Проверьте bucket +mc ls nakama/nakama-media + +# Загрузите тестовый файл +mc cp test.jpg nakama/nakama-media/test/ + +# Удалите файл +mc rm nakama/nakama-media/test/test.jpg +``` + +### Тест 3: Через API (curl) + +```bash +# Получить список объектов +curl -X GET \ + http://localhost:9000/nakama-media/ \ + --user minioadmin:your_password +``` + +--- + +## 🔄 Миграция существующих файлов в MinIO + +Если у вас уже есть файлы в `backend/uploads/`: + +```bash +# На сервере с файлами +cd /path/to/nakama + +# Установите mc +wget https://dl.min.io/client/mc/release/linux-amd64/mc +chmod +x mc + +# Настройте подключение +./mc alias set nakama http://103.80.87.247:9000 minioadmin your_password + +# Синхронизируйте файлы +./mc mirror backend/uploads/posts nakama/nakama-media/posts/ +./mc mirror backend/uploads/avatars nakama/nakama-media/avatars/ + +# Проверьте +./mc ls nakama/nakama-media/posts/ +``` + +--- + +## 🔐 Безопасность + +### 1. Измените стандартные учетные данные + +```bash +# В /etc/default/minio: +MINIO_ROOT_USER=your_admin_username +MINIO_ROOT_PASSWORD=very_secure_password_123 + +# Перезапустите +systemctl restart minio +``` + +### 2. Настройте HTTPS (рекомендуется для продакшена) + +```bash +# Создайте директорию для сертификатов +mkdir -p /root/.minio/certs + +# Скопируйте SSL сертификаты +cp cert.pem /root/.minio/certs/public.crt +cp key.pem /root/.minio/certs/private.key + +# Перезапустите MinIO +systemctl restart minio +``` + +Обновите .env: +```env +MINIO_USE_SSL=true +MINIO_PUBLIC_URL=https://minio.yourdomain.com +``` + +### 3. Firewall + +```bash +# Разрешить только с IP приложения +ufw allow from YOUR_APP_SERVER_IP to any port 9000 + +# Или ограничить консоль +ufw allow from YOUR_IP to any port 9001 +``` + +--- + +## 📈 Мониторинг + +### Prometheus метрики + +MinIO поддерживает Prometheus: + +```bash +# Метрики доступны на: +curl http://localhost:9000/minio/v2/metrics/cluster +``` + +### Веб-консоль + +Мониторинг в реальном времени: +- Monitoring → Metrics +- Bandwidth +- Storage Usage +- API Calls + +--- + +## 🔧 Устранение проблем + +### Проблема: "MinIO недоступен" + +```bash +# Проверьте статус +systemctl status minio + +# Проверьте логи +journalctl -u minio -f + +# Проверьте подключение +telnet 103.80.87.247 9000 +``` + +### Проблема: "Bucket does not exist" + +```bash +# Создайте через mc +mc mb nakama/nakama-media +``` + +### Проблема: "Access Denied" + +```bash +# Проверьте credentials +mc admin user list nakama + +# Проверьте policy +mc admin policy info nakama nakama-media-policy +``` + +--- + +## 🔄 Резервное копирование MinIO + +### Автоматический бекап с mc + +```bash +# Создайте скрипт +nano /usr/local/bin/backup-minio.sh +``` + +```bash +#!/bin/bash + +BACKUP_DIR="/var/backups/minio" +DATE=$(date +"%Y-%m-%d_%H-%M-%S") + +mkdir -p "$BACKUP_DIR" + +# Синхронизировать все файлы +/usr/local/bin/mc mirror nakama/nakama-media "$BACKUP_DIR/$DATE/" + +# Удалить старые бекапы (> 30 дней) +find "$BACKUP_DIR" -type d -mtime +30 -exec rm -rf {} \; + +echo "Backup completed: $DATE" +``` + +```bash +chmod +x /usr/local/bin/backup-minio.sh + +# Добавьте в cron (еженедельно) +crontab -e +# Добавьте: 0 3 * * 0 /usr/local/bin/backup-minio.sh >> /var/log/minio-backup.log 2>&1 +``` + +--- + +## 📝 Конфигурация для разных сценариев + +### Локальная разработка: + +```env +MINIO_ENABLED=true +MINIO_ENDPOINT=localhost +MINIO_PORT=9000 +MINIO_USE_SSL=false +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_BUCKET=nakama-media-dev +``` + +### Продакшен с одним сервером: + +```env +MINIO_ENABLED=true +MINIO_ENDPOINT=103.80.87.247 +MINIO_PORT=9000 +MINIO_USE_SSL=false +MINIO_ACCESS_KEY=nakama_app +MINIO_SECRET_KEY=secure_key_here +MINIO_BUCKET=nakama-media +``` + +### Продакшен с CDN: + +```env +MINIO_ENABLED=true +MINIO_ENDPOINT=103.80.87.247 +MINIO_PORT=9000 +MINIO_USE_SSL=false +MINIO_ACCESS_KEY=nakama_app +MINIO_SECRET_KEY=secure_key_here +MINIO_BUCKET=nakama-media +MINIO_PUBLIC_URL=https://cdn.yourdomain.com # Cloudflare/другой CDN +``` + +--- + +## 🎯 Рекомендации + +1. **Безопасность:** + - Измените стандартные credentials + - Используйте HTTPS в продакшене + - Настройте firewall + - Создайте отдельного пользователя для приложения + +2. **Производительность:** + - Используйте CDN для раздачи файлов + - Настройте кэширование + - Включите compression + +3. **Надежность:** + - Настройте резервное копирование + - Мониторьте место на диске + - Регулярно проверяйте integrity + +4. **Масштабирование:** + - Рассмотрите distributed mode для больших нагрузок + - Используйте lifecycle policies для старых файлов + - Настройте репликацию между серверами + +--- + +**MinIO готов к использованию!** 🚀 + +Файлы автоматически будут загружаться в MinIO при создании постов и публикациях в канал. + diff --git a/S3_MINIO_SETUP.md b/S3_MINIO_SETUP.md new file mode 100644 index 0000000..5acb317 --- /dev/null +++ b/S3_MINIO_SETUP.md @@ -0,0 +1,438 @@ +# 🔌 Подключение к существующему MinIO через S3 SDK + +## ✅ Ваша ситуация + +У вас уже запущен MinIO на сервере **103.80.87.247**: +- **Console (Web UI):** http://103.80.87.247:9901/ +- **API (S3):** http://103.80.87.247:9000/ (обычно) + +Мы используем **AWS S3 SDK** для подключения к MinIO (MinIO полностью совместим с S3 API). + +--- + +## 🚀 Быстрая настройка + +### Шаг 1: Установите зависимости + +```bash +cd /Users/glpshchn/Desktop/nakama +npm install +``` + +Будут установлены: +- `@aws-sdk/client-s3` - S3 клиент +- `@aws-sdk/lib-storage` - Загрузка больших файлов +- `@aws-sdk/s3-request-presigner` - Presigned URLs + +### Шаг 2: Получите Access Key и Secret Key + +1. Откройте MinIO Console: http://103.80.87.247:9901/ +2. Войдите с учетными данными +3. Перейдите: **Identity → Service Accounts** (или **Users**) +4. Создайте новый Service Account для приложения: + - Name: `nakama-app` + - Policy: `readwrite` +5. **Скопируйте Access Key и Secret Key** (покажутся только один раз!) + +### Шаг 3: Обновите .env файл + +```bash +nano /Users/glpshchn/Desktop/nakama/.env +``` + +Добавьте/обновите: + +```env +# MinIO Configuration +MINIO_ENABLED=true +MINIO_ENDPOINT=103.80.87.247 +MINIO_PORT=9000 # API порт (НЕ 9901!) +MINIO_USE_SSL=false +MINIO_ACCESS_KEY=YOUR_ACCESS_KEY_HERE # Из MinIO Console +MINIO_SECRET_KEY=YOUR_SECRET_KEY_HERE # Из MinIO Console +MINIO_BUCKET=nakama-media +MINIO_REGION=us-east-1 +MINIO_PUBLIC_URL=http://103.80.87.247:9000 +``` + +### Шаг 4: Создайте bucket в MinIO + +В MinIO Console: +1. **Object Browser** → **Create Bucket** +2. Имя: `nakama-media` +3. Нажмите **Create Bucket** + +Или через API: +```bash +curl -X PUT http://103.80.87.247:9000/nakama-media \ + -H "Authorization: AWS4-HMAC-SHA256 ..." +``` + +### Шаг 5: Запустите приложение + +```bash +docker-compose down +docker-compose build +docker-compose up -d +``` + +Проверьте логи: +```bash +docker-compose logs backend | grep -i minio + +# Должны увидеть: +# ✅ S3 клиент для MinIO инициализирован +# Bucket: nakama-media +``` + +--- + +## 🔍 Проверка подключения + +### Тест 1: Через API endpoint + +```bash +# Проверьте статус MinIO (нужен токен модератора) +curl -X GET http://localhost:3000/api/minio/status \ + -H "Authorization: Bearer YOUR_MODERATOR_TOKEN" +``` + +### Тест 2: Создайте пост с изображением + +1. Откройте приложение +2. Создайте пост с изображением +3. Проверьте в MinIO Console: **Object Browser → nakama-media → posts/** + +### Тест 3: Через AWS CLI + +```bash +# Установите AWS CLI +# macOS: +brew install awscli + +# Ubuntu: +sudo apt install awscli + +# Настройте profile для MinIO +aws configure --profile minio +# AWS Access Key ID: ваш_access_key +# AWS Secret Access Key: ваш_secret_key +# Default region name: us-east-1 +# Default output format: json + +# Проверьте подключение +aws s3 ls s3://nakama-media \ + --endpoint-url http://103.80.87.247:9000 \ + --profile minio + +# Загрузите тестовый файл +aws s3 cp test.jpg s3://nakama-media/test/ \ + --endpoint-url http://103.80.87.247:9000 \ + --profile minio + +# Список файлов +aws s3 ls s3://nakama-media/posts/ \ + --endpoint-url http://103.80.87.247:9000 \ + --profile minio +``` + +--- + +## ⚙️ Конфигурация для разных сценариев + +### Вариант 1: HTTP (без SSL) + +```env +MINIO_ENABLED=true +MINIO_ENDPOINT=103.80.87.247 +MINIO_PORT=9000 +MINIO_USE_SSL=false +MINIO_ACCESS_KEY=your_access_key +MINIO_SECRET_KEY=your_secret_key +MINIO_BUCKET=nakama-media +MINIO_PUBLIC_URL=http://103.80.87.247:9000 +``` + +### Вариант 2: HTTPS (с SSL) + +```env +MINIO_ENABLED=true +MINIO_ENDPOINT=103.80.87.247 +MINIO_PORT=9000 +MINIO_USE_SSL=true +MINIO_ACCESS_KEY=your_access_key +MINIO_SECRET_KEY=your_secret_key +MINIO_BUCKET=nakama-media +MINIO_PUBLIC_URL=https://103.80.87.247:9000 +``` + +### Вариант 3: Через домен + CDN + +```env +MINIO_ENABLED=true +MINIO_ENDPOINT=minio.yourdomain.com +MINIO_PORT=443 +MINIO_USE_SSL=true +MINIO_ACCESS_KEY=your_access_key +MINIO_SECRET_KEY=your_secret_key +MINIO_BUCKET=nakama-media +MINIO_PUBLIC_URL=https://cdn.yourdomain.com +``` + +--- + +## 🔐 Безопасность + +### 1. Создайте отдельного пользователя для приложения + +В MinIO Console: + +**Identity → Users → Create User:** +- Username: `nakama-app` +- Password: `secure_password_123` + +**Создайте Policy:** + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject" + ], + "Resource": [ + "arn:aws:s3:::nakama-media/*" + ] + }, + { + "Effect": "Allow", + "Action": [ + "s3:ListBucket" + ], + "Resource": [ + "arn:aws:s3:::nakama-media" + ] + } + ] +} +``` + +**Назначьте Policy пользователю.** + +**Создайте Service Account для пользователя** и используйте его credentials в .env. + +### 2. Ограничьте доступ к API порту + +На сервере MinIO: + +```bash +# Разрешить доступ только с IP приложения +ufw allow from YOUR_APP_SERVER_IP to any port 9000 + +# Консоль можно ограничить вашим IP +ufw allow from YOUR_IP to any port 9901 +``` + +### 3. Настройте HTTPS + +```bash +# На сервере MinIO: +mkdir -p ~/.minio/certs + +# Скопируйте SSL сертификаты +cp /path/to/cert.pem ~/.minio/certs/public.crt +cp /path/to/key.pem ~/.minio/certs/private.key + +# Перезапустите MinIO +systemctl restart minio +``` + +--- + +## 🔧 Отличия S3 SDK от MinIO SDK + +### MinIO SDK (старый): +```javascript +const Minio = require('minio'); +const client = new Minio.Client({ + endPoint: '103.80.87.247', + port: 9000, + useSSL: false, + accessKey: 'key', + secretKey: 'secret' +}); +``` + +### AWS S3 SDK (новый, используем): +```javascript +const { S3Client } = require('@aws-sdk/client-s3'); +const client = new S3Client({ + endpoint: 'http://103.80.87.247:9000', + region: 'us-east-1', + credentials: { + accessKeyId: 'key', + secretAccessKey: 'secret' + }, + forcePathStyle: true // Важно для MinIO! +}); +``` + +**Преимущества S3 SDK:** +- ✅ Официальный AWS SDK (лучше поддержка) +- ✅ Работает с любым S3-совместимым хранилищем +- ✅ Больше функций и опций +- ✅ Лучшая типизация для TypeScript +- ✅ Модульная структура (меньше размер bundle) + +--- + +## 📊 Структура хранения + +``` +MinIO Server (103.80.87.247:9000) +│ +└── nakama-media/ ← Bucket + ├── posts/ ← Посты пользователей + │ ├── 1700000000-123.jpg + │ ├── 1700000001-456.png + │ └── ... + ├── avatars/ ← Аватары + │ └── ... + └── channel/ ← Публикации в канал + └── ... +``` + +--- + +## 🚨 Решение проблем + +### Проблема: "Connection refused" на порту 9000 + +**Причина:** MinIO API не слушает на порту 9000 + +**Решение:** +```bash +# На сервере MinIO проверьте: +netstat -tulpn | grep 9000 + +# Если пусто, проверьте конфигурацию MinIO +systemctl status minio + +# Проверьте переменные окружения +cat /etc/default/minio +``` + +### Проблема: "Access Denied" + +**Причина:** Неверные credentials или недостаточно прав + +**Решение:** +1. Проверьте Access Key и Secret Key в .env +2. Проверьте policy пользователя в MinIO Console +3. Убедитесь что bucket существует + +### Проблема: "Bucket does not exist" + +**Решение:** +```bash +# Создайте bucket через AWS CLI: +aws s3 mb s3://nakama-media \ + --endpoint-url http://103.80.87.247:9000 \ + --profile minio + +# Или в MinIO Console: +# Object Browser → Create Bucket → nakama-media +``` + +### Проблема: "forcePathStyle" не работает + +**Причина:** Старая версия MinIO или неправильный endpoint + +**Решение:** +```env +# Убедитесь что endpoint БЕЗ протокола в config: +MINIO_ENDPOINT=103.80.87.247 # ✅ Правильно +MINIO_ENDPOINT=http://103.80.87.247 # ❌ Неправильно +``` + +### Проблема: CORS ошибки при доступе к файлам + +**Решение:** Настройте CORS в MinIO Console +```bash +# Через mc (MinIO Client): +mc admin config set myminio api cors_allow_origin="*" +mc admin service restart myminio +``` + +--- + +## 📝 Пример использования в коде + +### Загрузка файла: + +```javascript +const { uploadFile } = require('./utils/minio'); + +// В route handler: +const fileUrl = await uploadFile( + req.file.buffer, // Buffer из multer + req.file.originalname, + req.file.mimetype, + 'posts' // Папка +); + +console.log('File URL:', fileUrl); +// http://103.80.87.247:9000/nakama-media/posts/1700000000-123.jpg +``` + +### Удаление файла: + +```javascript +const { deleteFile } = require('./utils/minio'); + +await deleteFile('http://103.80.87.247:9000/nakama-media/posts/1700000000-123.jpg'); +``` + +### Получение presigned URL: + +```javascript +const { getPresignedUrl } = require('./utils/minio'); + +const url = await getPresignedUrl('posts/1700000000-123.jpg', 3600); // 1 час +``` + +--- + +## ✅ Checklist настройки + +- [ ] MinIO работает на 103.80.87.247 +- [ ] Console доступен на :9901 +- [ ] API доступен на :9000 +- [ ] Создан bucket `nakama-media` +- [ ] Созданы Access Key и Secret Key +- [ ] Обновлен .env с правильными credentials +- [ ] Установлены npm пакеты (`npm install`) +- [ ] Перезапущен Docker (`docker-compose up -d`) +- [ ] Проверены логи (`docker-compose logs backend`) +- [ ] Создан тестовый пост с изображением +- [ ] Файл появился в MinIO Console + +--- + +## 🎯 Следующие шаги + +1. ✅ **Проверьте подключение:** создайте пост с изображением +2. 🔒 **Настройте безопасность:** создайте отдельного пользователя +3. 🌐 **Настройте домен:** вместо IP используйте домен +4. 🔐 **Включите HTTPS:** для продакшена +5. 📊 **Настройте мониторинг:** следите за использованием +6. 💾 **Настройте бекапы:** регулярное резервное копирование + +--- + +**Готово!** Теперь все файлы загружаются в ваш MinIO через S3 SDK! 🚀 + diff --git a/backend/config/index.js b/backend/config/index.js index 1d3d710..aa6ef28 100644 --- a/backend/config/index.js +++ b/backend/config/index.js @@ -69,6 +69,20 @@ module.exports = { search: parseInt(process.env.CACHE_TTL_SEARCH || '180') // 3 мин }, + // MinIO Configuration + minio: { + enabled: process.env.MINIO_ENABLED === 'true', + endpoint: process.env.MINIO_ENDPOINT || '103.80.87.247', + port: parseInt(process.env.MINIO_PORT || '9000', 10), + useSSL: process.env.MINIO_USE_SSL === 'true', + accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin', + secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin', + bucket: process.env.MINIO_BUCKET || 'nakama-media', + region: process.env.MINIO_REGION || 'us-east-1', + publicUrl: process.env.MINIO_PUBLIC_URL || '', // Кастомный URL (CDN) + publicBucket: process.env.MINIO_PUBLIC_BUCKET === 'true' + }, + // Проверки isDevelopment: () => process.env.NODE_ENV === 'development', isProduction: () => process.env.NODE_ENV === 'production', diff --git a/backend/middleware/upload.js b/backend/middleware/upload.js new file mode 100644 index 0000000..698f665 --- /dev/null +++ b/backend/middleware/upload.js @@ -0,0 +1,169 @@ +const multer = require('multer'); +const path = require('path'); +const { uploadFile, isEnabled: isMinioEnabled } = require('../utils/minio'); +const { log } = require('./logger'); +const fs = require('fs'); + +// Временное хранилище для файлов +const tempStorage = multer.memoryStorage(); + +// Конфигурация multer +const multerConfig = { + storage: tempStorage, + limits: { + fileSize: 10 * 1024 * 1024, // 10MB + files: 10 + }, + fileFilter: (req, file, cb) => { + // Запрещенные расширения (исполняемые файлы) + const forbiddenExts = [ + '.exe', '.bat', '.cmd', '.sh', '.ps1', '.js', '.jar', + '.app', '.dmg', '.deb', '.rpm', '.msi', '.scr', + '.vbs', '.com', '.pif', '.cpl' + ]; + const ext = path.extname(file.originalname).toLowerCase(); + + // Проверить на запрещенные расширения + if (forbiddenExts.includes(ext)) { + return cb(new Error('Запрещенный тип файла')); + } + + // Разрешенные типы изображений и видео + const allowedMimes = [ + 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', + 'video/mp4', 'video/quicktime', 'video/x-msvideo' + ]; + + if (!allowedMimes.includes(file.mimetype)) { + return cb(new Error('Только изображения и видео разрешены')); + } + + cb(null, true); + } +}; + +/** + * Middleware для загрузки файлов + * Автоматически загружает в MinIO если включен, иначе локально + */ +function createUploadMiddleware(fieldName, maxCount = 5, folder = 'posts') { + const upload = multer(multerConfig); + const multerMiddleware = maxCount === 1 + ? upload.single(fieldName) + : upload.array(fieldName, maxCount); + + return async (req, res, next) => { + multerMiddleware(req, res, async (err) => { + if (err) { + log('error', 'Ошибка multer', { error: err.message }); + return res.status(400).json({ error: err.message }); + } + + try { + // Проверить наличие файлов + const files = req.files || (req.file ? [req.file] : []); + + if (!files.length) { + return next(); + } + + // Если MinIO включен, загрузить туда + if (isMinioEnabled()) { + const uploadedUrls = []; + + for (const file of files) { + try { + const fileUrl = await uploadFile( + file.buffer, + file.originalname, + file.mimetype, + folder + ); + uploadedUrls.push(fileUrl); + } catch (uploadError) { + log('error', 'Ошибка загрузки в MinIO', { + error: uploadError.message, + filename: file.originalname + }); + throw uploadError; + } + } + + // Сохранить URLs в req для дальнейшей обработки + req.uploadedFiles = uploadedUrls; + req.uploadMethod = 'minio'; + + log('info', 'Файлы загружены в MinIO', { + count: uploadedUrls.length, + folder + }); + + } else { + // Локальное хранилище (fallback) + const uploadDir = path.join(__dirname, '../uploads', folder); + + // Создать директорию если не существует + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); + } + + const uploadedPaths = []; + + for (const file of files) { + const timestamp = Date.now(); + const random = Math.round(Math.random() * 1E9); + const ext = path.extname(file.originalname); + const filename = `${timestamp}-${random}${ext}`; + const filepath = path.join(uploadDir, filename); + + // Сохранить файл + fs.writeFileSync(filepath, file.buffer); + + // Относительный путь для URL + const relativePath = `/uploads/${folder}/${filename}`; + uploadedPaths.push(relativePath); + } + + req.uploadedFiles = uploadedPaths; + req.uploadMethod = 'local'; + + log('info', 'Файлы загружены локально', { + count: uploadedPaths.length, + folder + }); + } + + next(); + } catch (error) { + log('error', 'Ошибка обработки загруженных файлов', { error: error.message }); + return res.status(500).json({ error: 'Ошибка загрузки файлов' }); + } + }); + }; +} + +/** + * Middleware для удаления файлов из MinIO при ошибке + */ +function cleanupOnError() { + return (err, req, res, next) => { + if (req.uploadedFiles && req.uploadMethod === 'minio') { + const { deleteFiles } = require('../utils/minio'); + deleteFiles(req.uploadedFiles).catch(cleanupErr => { + log('error', 'Ошибка очистки файлов MinIO', { error: cleanupErr.message }); + }); + } + next(err); + }; +} + +module.exports = { + createUploadMiddleware, + cleanupOnError, + + // Готовые middleware для разных случаев + uploadPostImages: createUploadMiddleware('images', 5, 'posts'), + uploadAvatar: createUploadMiddleware('avatar', 1, 'avatars'), + uploadChannelMedia: createUploadMiddleware('images', 10, 'channel') +}; + diff --git a/backend/routes/minio-test.js b/backend/routes/minio-test.js new file mode 100644 index 0000000..3f85564 --- /dev/null +++ b/backend/routes/minio-test.js @@ -0,0 +1,36 @@ +const express = require('express'); +const router = express.Router(); +const { checkConnection, getBucketStats } = require('../utils/minio'); +const { authenticate, requireModerator } = require('../middleware/auth'); + +/** + * Проверить подключение к MinIO (только для модераторов) + */ +router.get('/status', authenticate, requireModerator, async (req, res) => { + try { + const isConnected = await checkConnection(); + + if (!isConnected) { + return res.status(503).json({ + connected: false, + message: 'MinIO недоступен' + }); + } + + const stats = await getBucketStats(); + + res.json({ + connected: true, + stats, + message: 'MinIO работает корректно' + }); + } catch (error) { + res.status(500).json({ + connected: false, + error: error.message + }); + } +}); + +module.exports = router; + diff --git a/backend/routes/modApp.js b/backend/routes/modApp.js index 6ae0109..07a4f65 100644 --- a/backend/routes/modApp.js +++ b/backend/routes/modApp.js @@ -2,10 +2,11 @@ const express = require('express'); const router = express.Router(); const fs = require('fs'); const path = require('path'); -const multer = require('multer'); const crypto = require('crypto'); const { authenticateModeration } = require('../middleware/auth'); const { logSecurityEvent } = require('../middleware/logger'); +const { uploadChannelMedia, cleanupOnError } = require('../middleware/upload'); +const { deleteFile } = require('../utils/minio'); const User = require('../models/User'); const Post = require('../models/Post'); const Report = require('../models/Report'); @@ -15,26 +16,6 @@ const { listAdmins, isModerationAdmin, normalizeUsername } = require('../service const { sendChannelMediaGroup, sendMessageToUser } = require('../bots/serverMonitor'); const config = require('../config'); -const TEMP_DIR = path.join(__dirname, '../uploads/mod-channel'); -if (!fs.existsSync(TEMP_DIR)) { - fs.mkdirSync(TEMP_DIR, { recursive: true }); -} - -const upload = multer({ - storage: multer.diskStorage({ - destination: TEMP_DIR, - filename: (_req, file, cb) => { - const unique = `${Date.now()}-${Math.round(Math.random() * 1e9)}`; - const ext = path.extname(file.originalname || ''); - cb(null, `${unique}${ext || '.jpg'}`); - } - }), - limits: { - files: 10, - fileSize: 15 * 1024 * 1024 // 15MB - } -}); - const OWNER_USERNAMES = new Set(config.moderationOwnerUsernames || []); const requireModerationAccess = async (req, res, next) => { @@ -672,7 +653,7 @@ router.post( '/channel/publish', authenticateModeration, requireModerationAccess, - upload.array('images', 10), + uploadChannelMedia, async (req, res) => { const { description = '', tags } = req.body; const files = req.files || []; diff --git a/backend/routes/posts.js b/backend/routes/posts.js index bf4ad8f..64f5b3f 100644 --- a/backend/routes/posts.js +++ b/backend/routes/posts.js @@ -1,68 +1,17 @@ const express = require('express'); const router = express.Router(); -const multer = require('multer'); -const path = require('path'); -const fs = require('fs'); const { authenticate } = require('../middleware/auth'); const { postCreationLimiter, interactionLimiter } = require('../middleware/rateLimiter'); const { searchLimiter } = require('../middleware/rateLimiter'); const { validatePostContent, validateTags, validateImageUrl } = require('../middleware/validator'); const { logSecurityEvent } = require('../middleware/logger'); const { strictPostLimiter, fileUploadLimiter } = require('../middleware/security'); +const { uploadPostImages, cleanupOnError } = require('../middleware/upload'); +const { deleteFiles } = require('../utils/minio'); const Post = require('../models/Post'); const Notification = require('../models/Notification'); const { extractHashtags } = require('../utils/hashtags'); -// Настройка multer для загрузки изображений -const storage = multer.diskStorage({ - destination: (req, file, cb) => { - const dir = path.join(__dirname, '../uploads/posts'); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - cb(null, dir); - }, - filename: (req, file, cb) => { - const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); - cb(null, uniqueSuffix + path.extname(file.originalname)); - } -}); - -const upload = multer({ - storage: storage, - limits: { fileSize: 10 * 1024 * 1024 }, // 10MB - fileFilter: (req, file, cb) => { - // Запрещенные расширения (исполняемые файлы) - const forbiddenExts = ['.exe', '.bat', '.cmd', '.sh', '.ps1', '.js', '.jar', '.app', '.dmg', '.deb', '.rpm', '.msi', '.scr', '.vbs', '.com', '.pif', '.cpl']; - const ext = path.extname(file.originalname).toLowerCase(); - - // Проверить на запрещенные расширения - if (forbiddenExts.includes(ext)) { - return cb(new Error('Запрещенный тип файла')); - } - - // Разрешенные типы изображений - const allowedTypes = /jpeg|jpg|png|gif|webp/; - const extname = allowedTypes.test(ext); - const mimetype = allowedTypes.test(file.mimetype); - - // Дополнительная проверка MIME типа - const allowedMimes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; - if (!allowedMimes.includes(file.mimetype)) { - return cb(new Error('Только изображения разрешены')); - } - - if (mimetype && extname) { - return cb(null, true); - } else { - cb(new Error('Только изображения разрешены')); - } - } -}); - -// Поддержка до 5 изображений в одном посте -const uploadMultiple = upload.array('images', 5); - // Получить ленту постов router.get('/', authenticate, async (req, res) => { try { @@ -113,7 +62,7 @@ router.get('/', authenticate, async (req, res) => { }); // Создать пост -router.post('/', authenticate, strictPostLimiter, postCreationLimiter, fileUploadLimiter, uploadMultiple, async (req, res) => { +router.post('/', authenticate, strictPostLimiter, postCreationLimiter, fileUploadLimiter, uploadPostImages, async (req, res) => { try { const { content, tags, mentionedUsers, isNSFW, externalImages } = req.body; @@ -146,9 +95,9 @@ router.post('/', authenticate, strictPostLimiter, postCreationLimiter, fileUploa // Обработка изображений let images = []; - // Загруженные файлы - if (req.files && req.files.length > 0) { - images = req.files.map(file => `/uploads/posts/${file.filename}`); + // Загруженные файлы (через middleware) + if (req.uploadedFiles && req.uploadedFiles.length > 0) { + images = req.uploadedFiles; } // Внешние изображения (из поиска) diff --git a/backend/server.js b/backend/server.js index dd2c642..75ad2a7 100644 --- a/backend/server.js +++ b/backend/server.js @@ -11,6 +11,7 @@ dotenv.config({ path: path.join(__dirname, '.env') }); const { generalLimiter } = require('./middleware/rateLimiter'); const { initRedis } = require('./utils/redis'); const { initWebSocket } = require('./websocket'); +const { initMinioClient, checkConnection: checkMinioConnection } = require('./utils/minio'); const config = require('./config'); // Security middleware @@ -150,14 +151,34 @@ app.get('/health', (req, res) => { // MongoDB подключение mongoose.connect(config.mongoUri) -.then(() => { +.then(async () => { console.log(`✅ MongoDB подключена: ${config.mongoUri.replace(/\/\/.*@/, '//***@')}`); + // Инициализировать Redis (опционально) if (config.redisUrl) { initRedis().catch(err => console.log('⚠️ Redis недоступен, работаем без кэша')); } else { console.log('ℹ️ Redis не настроен, кэширование отключено'); } + + // Инициализировать MinIO (опционально) + if (config.minio.enabled) { + try { + initMinioClient(); + const minioOk = await checkMinioConnection(); + if (minioOk) { + console.log(`✅ MinIO подключен: ${config.minio.endpoint}:${config.minio.port}`); + console.log(` Bucket: ${config.minio.bucket}`); + } else { + console.log('⚠️ MinIO недоступен, используется локальное хранилище'); + } + } catch (err) { + console.log('⚠️ MinIO ошибка инициализации:', err.message); + console.log(' Используется локальное хранилище'); + } + } else { + console.log('ℹ️ MinIO отключен, используется локальное хранилище'); + } }) .catch(err => console.error('❌ Ошибка MongoDB:', err)); @@ -172,6 +193,7 @@ app.use('/api/moderation', require('./routes/moderation')); app.use('/api/statistics', require('./routes/statistics')); app.use('/api/bot', require('./routes/bot')); app.use('/api/mod-app', require('./routes/modApp')); +app.use('/api/minio', require('./routes/minio-test')); // Базовый роут app.get('/', (req, res) => { diff --git a/backend/utils/minio.js b/backend/utils/minio.js new file mode 100644 index 0000000..370359f --- /dev/null +++ b/backend/utils/minio.js @@ -0,0 +1,358 @@ +const { S3Client, PutObjectCommand, DeleteObjectCommand, HeadBucketCommand, CreateBucketCommand, ListObjectsV2Command, PutBucketPolicyCommand } = require('@aws-sdk/client-s3'); +const { Upload } = require('@aws-sdk/lib-storage'); +const config = require('../config'); +const { log } = require('../middleware/logger'); + +let s3Client = null; + +/** + * Инициализация S3 клиента для MinIO + */ +function initMinioClient() { + if (!config.minio.enabled) { + log('info', 'MinIO отключен, используется локальное хранилище'); + return null; + } + + try { + const endpoint = config.minio.useSSL + ? `https://${config.minio.endpoint}:${config.minio.port}` + : `http://${config.minio.endpoint}:${config.minio.port}`; + + s3Client = new S3Client({ + endpoint: endpoint, + region: config.minio.region || 'us-east-1', + credentials: { + accessKeyId: config.minio.accessKey, + secretAccessKey: config.minio.secretKey + }, + forcePathStyle: true, // Важно для MinIO! + tls: config.minio.useSSL + }); + + log('info', 'S3 клиент для MinIO инициализирован', { + endpoint: endpoint, + bucket: config.minio.bucket, + region: config.minio.region + }); + + // Создать bucket если не существует + ensureBucket(); + + return s3Client; + } catch (error) { + log('error', 'Ошибка инициализации S3 клиента', { error: error.message }); + return null; + } +} + +/** + * Убедиться что bucket существует + */ +async function ensureBucket() { + if (!s3Client) return; + + try { + // Проверить существование bucket + try { + await s3Client.send(new HeadBucketCommand({ + Bucket: config.minio.bucket + })); + log('info', `Bucket ${config.minio.bucket} существует`); + } catch (headError) { + // Bucket не существует, создаем + if (headError.name === 'NotFound' || headError.$metadata?.httpStatusCode === 404) { + await s3Client.send(new CreateBucketCommand({ + Bucket: config.minio.bucket + })); + log('info', `Bucket ${config.minio.bucket} создан`); + + // Установить публичную политику для bucket (опционально) + if (config.minio.publicBucket) { + const policy = { + Version: '2012-10-17', + Statement: [{ + Effect: 'Allow', + Principal: { AWS: ['*'] }, + Action: ['s3:GetObject'], + Resource: [`arn:aws:s3:::${config.minio.bucket}/*`] + }] + }; + + await s3Client.send(new PutBucketPolicyCommand({ + Bucket: config.minio.bucket, + Policy: JSON.stringify(policy) + })); + log('info', `Bucket ${config.minio.bucket} установлен как публичный`); + } + } else { + throw headError; + } + } + } catch (error) { + log('error', 'Ошибка проверки/создания bucket', { error: error.message }); + } +} + +/** + * Загрузить файл в MinIO через S3 SDK + * @param {Buffer} buffer - Буфер файла + * @param {string} filename - Имя файла + * @param {string} contentType - MIME тип + * @param {string} folder - Папка в bucket (например, 'posts', 'avatars') + * @returns {Promise} - URL файла + */ +async function uploadFile(buffer, filename, contentType, folder = 'posts') { + if (!s3Client) { + throw new Error('S3 клиент не инициализирован'); + } + + try { + // Генерировать уникальное имя файла + const timestamp = Date.now(); + const random = Math.round(Math.random() * 1E9); + const ext = filename.split('.').pop(); + const objectName = `${folder}/${timestamp}-${random}.${ext}`; + + // Загрузить файл через S3 SDK + const upload = new Upload({ + client: s3Client, + params: { + Bucket: config.minio.bucket, + Key: objectName, + Body: buffer, + ContentType: contentType, + CacheControl: 'public, max-age=31536000', // 1 год + Metadata: { + originalname: filename, + uploadedAt: new Date().toISOString() + } + } + }); + + await upload.done(); + + // Вернуть URL файла + const fileUrl = getFileUrl(objectName); + + log('info', 'Файл загружен в MinIO через S3', { + objectName, + size: buffer.length, + url: fileUrl + }); + + return fileUrl; + } catch (error) { + log('error', 'Ошибка загрузки файла в MinIO', { error: error.message }); + throw error; + } +} + +/** + * Удалить файл из MinIO через S3 SDK + * @param {string} fileUrl - URL файла или путь к объекту + * @returns {Promise} + */ +async function deleteFile(fileUrl) { + if (!s3Client) { + throw new Error('S3 клиент не инициализирован'); + } + + try { + // Извлечь путь к объекту из URL + const objectName = extractObjectName(fileUrl); + + if (!objectName) { + log('warn', 'Не удалось извлечь имя объекта из URL', { fileUrl }); + return false; + } + + await s3Client.send(new DeleteObjectCommand({ + Bucket: config.minio.bucket, + Key: objectName + })); + + log('info', 'Файл удален из MinIO через S3', { objectName }); + return true; + } catch (error) { + log('error', 'Ошибка удаления файла из MinIO', { error: error.message }); + return false; + } +} + +/** + * Удалить несколько файлов + * @param {string[]} fileUrls - Массив URL файлов + * @returns {Promise} - Количество удаленных файлов + */ +async function deleteFiles(fileUrls) { + if (!minioClient || !fileUrls || !fileUrls.length) { + return 0; + } + + let deleted = 0; + + for (const fileUrl of fileUrls) { + try { + const success = await deleteFile(fileUrl); + if (success) deleted++; + } catch (error) { + log('error', 'Ошибка при удалении файла', { fileUrl, error: error.message }); + } + } + + return deleted; +} + +/** + * Получить временный URL для доступа к файлу (presigned URL) + * @param {string} objectName - Имя объекта + * @param {number} expirySeconds - Время жизни URL в секундах (по умолчанию 7 дней) + * @returns {Promise} + */ +async function getPresignedUrl(objectName, expirySeconds = 7 * 24 * 60 * 60) { + if (!s3Client) { + throw new Error('S3 клиент не инициализирован'); + } + + try { + const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); + const { GetObjectCommand } = require('@aws-sdk/client-s3'); + + const command = new GetObjectCommand({ + Bucket: config.minio.bucket, + Key: objectName + }); + + const url = await getSignedUrl(s3Client, command, { expiresIn: expirySeconds }); + return url; + } catch (error) { + log('error', 'Ошибка получения presigned URL', { error: error.message }); + throw error; + } +} + +/** + * Получить публичный URL файла + * @param {string} objectName - Имя объекта + * @returns {string} + */ +function getFileUrl(objectName) { + if (config.minio.publicUrl) { + // Использовать кастомный публичный URL (например, через CDN) + return `${config.minio.publicUrl}/${config.minio.bucket}/${objectName}`; + } + + // Использовать прямой URL MinIO + const protocol = config.minio.useSSL ? 'https' : 'http'; + const port = config.minio.port === 80 || config.minio.port === 443 ? '' : `:${config.minio.port}`; + return `${protocol}://${config.minio.endpoint}${port}/${config.minio.bucket}/${objectName}`; +} + +/** + * Извлечь имя объекта из URL + * @param {string} fileUrl - URL файла + * @returns {string|null} + */ +function extractObjectName(fileUrl) { + if (!fileUrl) return null; + + try { + // Если это уже имя объекта (путь) + if (!fileUrl.startsWith('http')) { + return fileUrl; + } + + // Извлечь из URL + const url = new URL(fileUrl); + const pathParts = url.pathname.split('/'); + + // Убрать bucket из пути + const bucketIndex = pathParts.indexOf(config.minio.bucket); + if (bucketIndex !== -1) { + return pathParts.slice(bucketIndex + 1).join('/'); + } + + // Попробовать альтернативный формат + return pathParts.slice(1).join('/'); + } catch (error) { + log('error', 'Ошибка парсинга URL', { fileUrl, error: error.message }); + return null; + } +} + +/** + * Проверить доступность MinIO через S3 SDK + * @returns {Promise} + */ +async function checkConnection() { + if (!s3Client) { + return false; + } + + try { + await s3Client.send(new HeadBucketCommand({ + Bucket: config.minio.bucket + })); + return true; + } catch (error) { + log('error', 'MinIO недоступен', { error: error.message }); + return false; + } +} + +/** + * Получить статистику bucket через S3 SDK + * @returns {Promise} + */ +async function getBucketStats() { + if (!s3Client) { + throw new Error('S3 клиент не инициализирован'); + } + + try { + let totalSize = 0; + let totalFiles = 0; + let continuationToken = undefined; + + do { + const response = await s3Client.send(new ListObjectsV2Command({ + Bucket: config.minio.bucket, + ContinuationToken: continuationToken + })); + + if (response.Contents) { + for (const obj of response.Contents) { + totalSize += obj.Size || 0; + totalFiles++; + } + } + + continuationToken = response.NextContinuationToken; + } while (continuationToken); + + return { + totalFiles, + totalSize, + totalSizeMB: (totalSize / (1024 * 1024)).toFixed(2), + totalSizeGB: (totalSize / (1024 * 1024 * 1024)).toFixed(2), + bucket: config.minio.bucket + }; + } catch (error) { + log('error', 'Ошибка получения статистики bucket', { error: error.message }); + throw error; + } +} + +module.exports = { + initMinioClient, + uploadFile, + deleteFile, + deleteFiles, + getPresignedUrl, + getFileUrl, + checkConnection, + getBucketStats, + isEnabled: () => config.minio.enabled +}; + diff --git a/docker-compose.yml b/docker-compose.yml index 46edc50..b56429e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,9 +25,19 @@ services: - FRONTEND_URL=${FRONTEND_URL:-http://localhost:5173} - CORS_ORIGIN=${CORS_ORIGIN:-*} - REDIS_URL=${REDIS_URL} + # MinIO Configuration (подключение к внешнему серверу) + - MINIO_ENABLED=${MINIO_ENABLED:-true} + - MINIO_ENDPOINT=${MINIO_ENDPOINT:-103.80.87.247} + - MINIO_PORT=${MINIO_PORT:-9000} + - MINIO_USE_SSL=${MINIO_USE_SSL:-false} + - MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY} + - MINIO_SECRET_KEY=${MINIO_SECRET_KEY} + - MINIO_BUCKET=${MINIO_BUCKET:-nakama-media} + - MINIO_REGION=${MINIO_REGION:-us-east-1} + - MINIO_PUBLIC_URL=${MINIO_PUBLIC_URL} volumes: - # Медиа хранится на удаленном сервере, монтируем через NFS или SSH - - /mnt/nakama-media:/app/backend/uploads + # Fallback локальное хранилище (если MinIO недоступен) + - nakama-media:/app/backend/uploads networks: - nakama-network depends_on: @@ -100,6 +110,9 @@ services: entrypoint: /bin/bash command: -c "echo 'Backup container ready. Run manual backups or set up cron.'" + # MinIO запущен на отдельном сервере 103.80.87.247 + # Локальный MinIO не нужен + networks: nakama-network: driver: bridge diff --git a/package.json b/package.json index 6fd336c..2500543 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,10 @@ "xss-clean": "^0.1.4", "hpp": "^0.2.3", "validator": "^13.11.0", - "@telegram-apps/init-data-node": "^1.0.4" + "@telegram-apps/init-data-node": "^1.0.4", + "@aws-sdk/client-s3": "^3.451.0", + "@aws-sdk/lib-storage": "^3.451.0", + "@aws-sdk/s3-request-presigner": "^3.451.0" }, "devDependencies": { "nodemon": "^3.0.1",