english version and minio console access
This commit is contained in:
1236
CONTRIBUTING.md
1236
CONTRIBUTING.md
File diff suppressed because it is too large
Load Diff
889
README.md
889
README.md
@@ -1,34 +1,867 @@
|
|||||||
# Ospabhost 8.1 - Server Management Platform
|
# 🚀 Ospabhost 8.1
|
||||||
|
|
||||||
Полнофункциональная платформа управления серверами на базе Proxmox VE с поддержкой LXC контейнеров.
|
Современная платформа хостинга с поддержкой VPS, S3-хранилищ и системой блогов.
|
||||||
|
|
||||||
## 📁 Project Structure
|
## 📋 Содержание
|
||||||
|
|
||||||
This is a monorepo containing:
|
- [О проекте](#о-проекте)
|
||||||
|
- [Возможности](#возможности)
|
||||||
- **`ospabhost/`** - Main application (backend + frontend)
|
- [Архитектура](#архитектура)
|
||||||
- **`Manuals/`** - Additional documentation and guides
|
- [Технологии](#технологии)
|
||||||
|
- [Быстрый старт](#быстрый-старт)
|
||||||
## 📖 Documentation
|
- [Развёртывание](#развёртывание)
|
||||||
|
- [API документация](#api-документация)
|
||||||
**All documentation is located in the `ospabhost/` directory:**
|
- [Структура проекта](#структура-проекта)
|
||||||
|
- [Разработка](#разработка)
|
||||||
- **[Main README](./ospabhost/README.md)** - Complete project documentation
|
- [Вклад в проект](#вклад-в-проект)
|
||||||
- **[Contributing Guide](./ospabhost/CONTRIBUTIONS.md)** - How to contribute
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ospabhost
|
|
||||||
```
|
|
||||||
|
|
||||||
Then follow the instructions in [ospabhost/README.md](./ospabhost/README.md).
|
|
||||||
|
|
||||||
## 📞 Support
|
|
||||||
|
|
||||||
- **Email:** support@ospab.host
|
|
||||||
- **Telegram:** @ospab_support
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**© 2025 Ospab Hosting. All rights reserved.**
|
## 🎯 О проекте
|
||||||
|
|
||||||
|
**Ospabhost 8.1** — полнофункциональная система управления хостингом с интеграцией Proxmox для VPS, MinIO для S3-хранилищ и встроенной CMS для блога.
|
||||||
|
|
||||||
|
### Ключевые особенности
|
||||||
|
|
||||||
|
- 🖥️ **VPS Management** - создание и управление виртуальными серверами через Proxmox API
|
||||||
|
- 💾 **S3 Storage** - совместимое с AWS S3 объектное хранилище на базе MinIO
|
||||||
|
- 📝 **Blog CMS** - встроенная система блогов с Rich Text редактором
|
||||||
|
- 🎫 **Ticketing** - система поддержки с авто-назначением операторов
|
||||||
|
- 💰 **Billing** - модуль управления балансом и оплатами
|
||||||
|
- 🔐 **Auth** - JWT авторизация с ролями (пользователь/оператор/администратор)
|
||||||
|
- 📊 **Admin Panel** - полнофункциональная панель администратора
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌟 Возможности
|
||||||
|
|
||||||
|
### Для пользователей
|
||||||
|
|
||||||
|
- **VPS серверы**
|
||||||
|
- Создание серверов с выбором ОС и тарифа
|
||||||
|
- Управление (старт/стоп/рестарт/удаление)
|
||||||
|
- Смена root-пароля
|
||||||
|
- Изменение ресурсов (resize)
|
||||||
|
- Создание и восстановление снапшотов
|
||||||
|
- Мониторинг статуса и статистики
|
||||||
|
|
||||||
|
- **S3 хранилища**
|
||||||
|
- Создание бакетов с выбором региона и класса хранения
|
||||||
|
- Кастомный тариф с оплатой за GB
|
||||||
|
- Загрузка/скачивание файлов
|
||||||
|
- Управление публичным доступом
|
||||||
|
- Версионирование объектов
|
||||||
|
- Presigned URL для временного доступа
|
||||||
|
- Web-консоль для управления
|
||||||
|
|
||||||
|
- **Блог**
|
||||||
|
- Чтение статей и комментирование
|
||||||
|
- Просмотр статей по URL-адресу
|
||||||
|
- Счётчик просмотров
|
||||||
|
|
||||||
|
- **Тикеты**
|
||||||
|
- Создание обращений в поддержку
|
||||||
|
- Переписка с операторами
|
||||||
|
- Загрузка скриншотов
|
||||||
|
- Отслеживание статуса
|
||||||
|
|
||||||
|
- **Баланс**
|
||||||
|
- Пополнение через загрузку чека
|
||||||
|
- История транзакций
|
||||||
|
- Автоматическая проверка чеков администратором
|
||||||
|
|
||||||
|
### Для операторов
|
||||||
|
|
||||||
|
- Просмотр и ответ на тикеты
|
||||||
|
- Автоматическое назначение новых тикетов
|
||||||
|
- Авто-снятие с тикета при закрытии пользователем
|
||||||
|
|
||||||
|
### Для администраторов
|
||||||
|
|
||||||
|
- **Управление пользователями**
|
||||||
|
- Просмотр всех пользователей
|
||||||
|
- Редактирование баланса
|
||||||
|
- Назначение роли оператора
|
||||||
|
- Блокировка/разблокировка
|
||||||
|
|
||||||
|
- **Управление тарифами**
|
||||||
|
- Редактирование цен на VPS
|
||||||
|
- Настройка S3 тарифов
|
||||||
|
- Управление кастомным тарифом (цена за GB, трафик, операции)
|
||||||
|
|
||||||
|
- **Управление блогом**
|
||||||
|
- Создание/редактирование статей
|
||||||
|
- Rich Text редактор с загрузкой изображений
|
||||||
|
- Модерация комментариев
|
||||||
|
- Управление статусами (черновик/опубликовано/архив)
|
||||||
|
|
||||||
|
- **Проверка чеков**
|
||||||
|
- Одобрение/отклонение заявок на пополнение
|
||||||
|
- Просмотр загруженных чеков
|
||||||
|
|
||||||
|
- **Тестирование**
|
||||||
|
- Отправка push-уведомлений
|
||||||
|
- Отправка email-уведомлений
|
||||||
|
- Логирование результатов
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Архитектура
|
||||||
|
|
||||||
|
### Монорепозиторий
|
||||||
|
|
||||||
|
Проект состоит из двух основных частей:
|
||||||
|
|
||||||
|
```
|
||||||
|
ospabhost/
|
||||||
|
├── backend/ # Express + TypeScript + Prisma
|
||||||
|
└── frontend/ # React + Vite + TypeScript
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend модули
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/src/modules/
|
||||||
|
├── auth/ # Авторизация и JWT
|
||||||
|
├── server/ # VPS управление (Proxmox)
|
||||||
|
├── storage/ # S3 хранилища (MinIO)
|
||||||
|
├── blog/ # Система блогов
|
||||||
|
├── ticket/ # Тикеты поддержки
|
||||||
|
├── check/ # Проверка чеков
|
||||||
|
├── notification/ # Уведомления
|
||||||
|
├── tariff/ # Тарифы
|
||||||
|
├── os/ # Операционные системы
|
||||||
|
└── admin/ # Админ панель
|
||||||
|
```
|
||||||
|
|
||||||
|
### База данных
|
||||||
|
|
||||||
|
Используется **MySQL** с ORM **Prisma**:
|
||||||
|
|
||||||
|
- `User` - пользователи с ролями
|
||||||
|
- `Server` - VPS серверы с привязкой к Proxmox
|
||||||
|
- `Tariff` - тарифные планы
|
||||||
|
- `OS` - операционные системы
|
||||||
|
- `StoragePlan` - тарифы S3 (с полями pricePerGb, bandwidthPerGb, requestsPerGb)
|
||||||
|
- `StorageBucket` - S3 бакеты пользователей
|
||||||
|
- `Post` - статьи блога
|
||||||
|
- `Comment` - комментарии к статьям
|
||||||
|
- `Ticket` - тикеты поддержки
|
||||||
|
- `TicketMessage` - сообщения в тикетах
|
||||||
|
- `Check` - чеки для пополнения
|
||||||
|
- `Notification` - уведомления пользователей
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Технологии
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- **Node.js 18+**
|
||||||
|
- **Express.js** - веб-фреймворк
|
||||||
|
- **TypeScript** - типизация
|
||||||
|
- **Prisma ORM** - работа с БД
|
||||||
|
- **MySQL** - база данных
|
||||||
|
- **JWT** - авторизация
|
||||||
|
- **Multer** - загрузка файлов
|
||||||
|
- **Axios** - HTTP клиент для Proxmox API
|
||||||
|
- **bcrypt** - хеширование паролей
|
||||||
|
- **MinIO SDK** - работа с S3
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- **React 18** - UI библиотека
|
||||||
|
- **TypeScript** - типизация
|
||||||
|
- **Vite** - сборщик
|
||||||
|
- **React Router** - маршрутизация
|
||||||
|
- **Tailwind CSS** - стилизация
|
||||||
|
- **React Icons** - иконки
|
||||||
|
- **React Quill** - Rich Text редактор
|
||||||
|
- **Axios** - HTTP клиент
|
||||||
|
|
||||||
|
### DevOps
|
||||||
|
|
||||||
|
- **PM2** - процесс-менеджер
|
||||||
|
- **Nginx** - веб-сервер и прокси
|
||||||
|
- **Git** - контроль версий
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Быстрый старт
|
||||||
|
|
||||||
|
### Требования
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- MySQL 8+
|
||||||
|
- Proxmox VE (для VPS)
|
||||||
|
- MinIO (для S3)
|
||||||
|
|
||||||
|
### Установка
|
||||||
|
|
||||||
|
1. **Клонирование репозитория**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/Ospab/ospabhost8.1.git
|
||||||
|
cd ospabhost8.1/ospabhost
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Настройка Backend**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Создайте `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL="mysql://user:password@localhost:3306/ospabhost"
|
||||||
|
JWT_SECRET="your-secret-key"
|
||||||
|
PORT=5000
|
||||||
|
|
||||||
|
# Proxmox
|
||||||
|
PROXMOX_HOST="your-proxmox-host"
|
||||||
|
PROXMOX_USER="root@pam"
|
||||||
|
PROXMOX_PASSWORD="your-password"
|
||||||
|
PROXMOX_NODE="pve"
|
||||||
|
|
||||||
|
# MinIO
|
||||||
|
MINIO_ENDPOINT="localhost"
|
||||||
|
MINIO_PORT=9000
|
||||||
|
MINIO_ACCESS_KEY="your-access-key"
|
||||||
|
MINIO_SECRET_KEY="your-secret-key"
|
||||||
|
MINIO_BUCKET_PREFIX="ospab"
|
||||||
|
```
|
||||||
|
|
||||||
|
Примените миграции:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx prisma migrate deploy
|
||||||
|
npx prisma generate
|
||||||
|
npx prisma db seed
|
||||||
|
```
|
||||||
|
|
||||||
|
Запустите:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Разработка
|
||||||
|
npm run build && npm start # Продакшн
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Настройка Frontend**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ../frontend
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Создайте `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_API_URL=http://localhost:5000
|
||||||
|
```
|
||||||
|
|
||||||
|
Запустите:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Разработка
|
||||||
|
npm run build # Сборка для продакшн
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Доступ**
|
||||||
|
|
||||||
|
- Frontend: http://localhost:5173
|
||||||
|
- Backend API: http://localhost:5000
|
||||||
|
- Панель управления: http://localhost:5173/dashboard
|
||||||
|
|
||||||
|
Первый пользователь автоматически становится администратором.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 Развёртывание
|
||||||
|
|
||||||
|
### На сервере
|
||||||
|
|
||||||
|
1. **Подготовка окружения**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Установка Node.js 18
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||||
|
sudo apt-get install -y nodejs
|
||||||
|
|
||||||
|
# Установка MySQL
|
||||||
|
sudo apt install mysql-server
|
||||||
|
|
||||||
|
# Установка PM2
|
||||||
|
sudo npm install -g pm2
|
||||||
|
|
||||||
|
# Установка Nginx
|
||||||
|
sudo apt install nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Клонирование и настройка**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www
|
||||||
|
git clone https://github.com/Ospab/ospabhost8.1.git ospab-host
|
||||||
|
cd ospab-host/ospabhost
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Backend**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
cp .env.example .env
|
||||||
|
# Отредактируйте .env
|
||||||
|
|
||||||
|
npx prisma migrate deploy
|
||||||
|
npx prisma generate
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
pm2 start dist/index.js --name ospab-backend
|
||||||
|
pm2 save
|
||||||
|
pm2 startup
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Frontend**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ../frontend
|
||||||
|
npm install
|
||||||
|
cp .env.example .env
|
||||||
|
# Отредактируйте .env
|
||||||
|
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Nginx**
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your-domain.com;
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
location / {
|
||||||
|
root /var/www/ospab-host/ospabhost/frontend/dist;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backend API
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://localhost:5000;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Uploads (чеки, изображения блога)
|
||||||
|
location /uploads {
|
||||||
|
alias /var/www/ospab-host/ospabhost/backend/uploads;
|
||||||
|
access_log off;
|
||||||
|
expires 30d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **SSL (Let's Encrypt)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install certbot python3-certbot-nginx
|
||||||
|
sudo certbot --nginx -d your-domain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Развёртывание блога
|
||||||
|
|
||||||
|
См. подробную инструкцию в [BLOG_DEPLOYMENT.md](./BLOG_DEPLOYMENT.md) и быстрый старт в [BLOG_QUICKSTART.md](./BLOG_QUICKSTART.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 API документация
|
||||||
|
|
||||||
|
### Публичные эндпоинты
|
||||||
|
|
||||||
|
#### Авторизация
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/auth/register
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"username": "user",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "password"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "password"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Блог
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/blog/posts
|
||||||
|
# Список опубликованных статей
|
||||||
|
|
||||||
|
GET /api/blog/posts/:url
|
||||||
|
# Статья по URL
|
||||||
|
|
||||||
|
POST /api/blog/posts/:postId/comments
|
||||||
|
# Добавить комментарий (требуется авторизация или имя гостя)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### S3 Storage
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/storage/plans
|
||||||
|
# Список тарифов
|
||||||
|
|
||||||
|
POST /api/storage/checkout
|
||||||
|
# Создать корзину для оплаты
|
||||||
|
```
|
||||||
|
|
||||||
|
### Защищённые эндпоинты
|
||||||
|
|
||||||
|
Все запросы требуют заголовок:
|
||||||
|
```
|
||||||
|
Authorization: Bearer YOUR_JWT_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Серверы (VPS)
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/servers
|
||||||
|
# Список серверов пользователя
|
||||||
|
|
||||||
|
POST /api/servers
|
||||||
|
# Создать сервер
|
||||||
|
|
||||||
|
POST /api/servers/:id/start
|
||||||
|
# Запустить сервер
|
||||||
|
|
||||||
|
POST /api/servers/:id/stop
|
||||||
|
# Остановить сервер
|
||||||
|
|
||||||
|
POST /api/servers/:id/restart
|
||||||
|
# Перезагрузить сервер
|
||||||
|
|
||||||
|
DELETE /api/servers/:id
|
||||||
|
# Удалить сервер
|
||||||
|
|
||||||
|
POST /api/servers/:id/change-password
|
||||||
|
# Сменить root пароль
|
||||||
|
|
||||||
|
PUT /api/servers/:id/resize
|
||||||
|
# Изменить ресурсы
|
||||||
|
|
||||||
|
POST /api/servers/:id/snapshot
|
||||||
|
# Создать снапшот
|
||||||
|
|
||||||
|
GET /api/servers/:id/snapshots
|
||||||
|
# Список снапшотов
|
||||||
|
|
||||||
|
POST /api/servers/:id/rollback
|
||||||
|
# Откатиться к снапшоту
|
||||||
|
|
||||||
|
DELETE /api/servers/:id/snapshots/:snapshotName
|
||||||
|
# Удалить снапшот
|
||||||
|
```
|
||||||
|
|
||||||
|
#### S3 Buckets
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/storage/buckets
|
||||||
|
# Список бакетов
|
||||||
|
|
||||||
|
POST /api/storage/buckets
|
||||||
|
# Создать бакет
|
||||||
|
|
||||||
|
GET /api/storage/buckets/:id
|
||||||
|
# Информация о бакете
|
||||||
|
|
||||||
|
DELETE /api/storage/buckets/:id
|
||||||
|
# Удалить бакет
|
||||||
|
|
||||||
|
PUT /api/storage/buckets/:id
|
||||||
|
# Обновить настройки
|
||||||
|
|
||||||
|
GET /api/storage/buckets/:id/objects
|
||||||
|
# Список объектов
|
||||||
|
|
||||||
|
POST /api/storage/buckets/:id/presign
|
||||||
|
# Создать presigned URL
|
||||||
|
|
||||||
|
DELETE /api/storage/buckets/:id/objects
|
||||||
|
# Удалить объекты
|
||||||
|
|
||||||
|
POST /api/storage/buckets/:id/console-credentials
|
||||||
|
# Создать учётные данные для web-консоли
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Тикеты
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/tickets
|
||||||
|
# Список тикетов
|
||||||
|
|
||||||
|
POST /api/tickets
|
||||||
|
# Создать тикет
|
||||||
|
|
||||||
|
GET /api/tickets/:id
|
||||||
|
# Информация о тикете
|
||||||
|
|
||||||
|
POST /api/tickets/:id/messages
|
||||||
|
# Отправить сообщение
|
||||||
|
|
||||||
|
PATCH /api/tickets/:id/close
|
||||||
|
# Закрыть тикет
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Администратор
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/admin/users
|
||||||
|
# Список всех пользователей
|
||||||
|
|
||||||
|
PUT /api/admin/users/:id
|
||||||
|
# Редактировать пользователя
|
||||||
|
|
||||||
|
GET /api/admin/checks
|
||||||
|
# Список всех чеков
|
||||||
|
|
||||||
|
PUT /api/admin/checks/:id
|
||||||
|
# Одобрить/отклонить чек
|
||||||
|
|
||||||
|
GET /api/blog/admin/posts
|
||||||
|
# Все статьи блога
|
||||||
|
|
||||||
|
POST /api/blog/admin/posts
|
||||||
|
# Создать статью
|
||||||
|
|
||||||
|
PUT /api/blog/admin/posts/:id
|
||||||
|
# Обновить статью
|
||||||
|
|
||||||
|
DELETE /api/blog/admin/posts/:id
|
||||||
|
# Удалить статью
|
||||||
|
|
||||||
|
POST /api/blog/admin/upload-image
|
||||||
|
# Загрузить изображение
|
||||||
|
|
||||||
|
GET /api/blog/admin/comments
|
||||||
|
# Все комментарии
|
||||||
|
|
||||||
|
PATCH /api/blog/admin/comments/:id
|
||||||
|
# Модерировать комментарий
|
||||||
|
|
||||||
|
DELETE /api/blog/admin/comments/:id
|
||||||
|
# Удалить комментарий
|
||||||
|
|
||||||
|
PUT /api/storage/plans/:id
|
||||||
|
# Редактировать тариф S3 (включая pricePerGb, bandwidthPerGb, requestsPerGb)
|
||||||
|
|
||||||
|
POST /api/admin/test/push-notification
|
||||||
|
# Тест push-уведомлений
|
||||||
|
|
||||||
|
POST /api/admin/test/email-notification
|
||||||
|
# Тест email-уведомлений
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
ospabhost8.1/
|
||||||
|
├── ospabhost/
|
||||||
|
│ ├── backend/
|
||||||
|
│ │ ├── src/
|
||||||
|
│ │ │ ├── modules/
|
||||||
|
│ │ │ │ ├── auth/
|
||||||
|
│ │ │ │ │ ├── auth.controller.ts
|
||||||
|
│ │ │ │ │ ├── auth.routes.ts
|
||||||
|
│ │ │ │ │ └── auth.middleware.ts
|
||||||
|
│ │ │ │ ├── server/
|
||||||
|
│ │ │ │ │ ├── server.controller.ts
|
||||||
|
│ │ │ │ │ ├── server.routes.ts
|
||||||
|
│ │ │ │ │ └── proxmoxApi.ts
|
||||||
|
│ │ │ │ ├── storage/
|
||||||
|
│ │ │ │ │ ├── storage.service.ts
|
||||||
|
│ │ │ │ │ └── storage.routes.ts
|
||||||
|
│ │ │ │ ├── blog/
|
||||||
|
│ │ │ │ │ ├── blog.controller.ts
|
||||||
|
│ │ │ │ │ ├── blog.routes.ts
|
||||||
|
│ │ │ │ │ └── upload.controller.ts
|
||||||
|
│ │ │ │ ├── admin/
|
||||||
|
│ │ │ │ │ ├── admin.controller.ts
|
||||||
|
│ │ │ │ │ └── admin.routes.ts
|
||||||
|
│ │ │ │ ├── ticket/
|
||||||
|
│ │ │ │ ├── check/
|
||||||
|
│ │ │ │ ├── notification/
|
||||||
|
│ │ │ │ ├── tariff/
|
||||||
|
│ │ │ │ └── os/
|
||||||
|
│ │ │ ├── prisma/
|
||||||
|
│ │ │ │ └── client.ts
|
||||||
|
│ │ │ ├── index.ts
|
||||||
|
│ │ │ └── server.ts
|
||||||
|
│ │ ├── prisma/
|
||||||
|
│ │ │ ├── schema.prisma
|
||||||
|
│ │ │ ├── migrations/
|
||||||
|
│ │ │ ├── seed.ts
|
||||||
|
│ │ │ └── seed_os.ts
|
||||||
|
│ │ ├── uploads/
|
||||||
|
│ │ │ ├── checks/
|
||||||
|
│ │ │ └── blog/
|
||||||
|
│ │ ├── package.json
|
||||||
|
│ │ └── tsconfig.json
|
||||||
|
│ │
|
||||||
|
│ └── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── pages/
|
||||||
|
│ │ │ ├── index.tsx
|
||||||
|
│ │ │ ├── login.tsx
|
||||||
|
│ │ │ ├── pricing.tsx
|
||||||
|
│ │ │ ├── blog.tsx
|
||||||
|
│ │ │ ├── blogpost.tsx
|
||||||
|
│ │ │ └── dashboard/
|
||||||
|
│ │ │ ├── mainpage.tsx
|
||||||
|
│ │ │ ├── servers.tsx
|
||||||
|
│ │ │ ├── storage.tsx
|
||||||
|
│ │ │ ├── tickets.tsx
|
||||||
|
│ │ │ ├── balance.tsx
|
||||||
|
│ │ │ ├── admin.tsx
|
||||||
|
│ │ │ └── blogadmin.tsx
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ ├── Navbar.tsx
|
||||||
|
│ │ │ ├── Footer.tsx
|
||||||
|
│ │ │ ├── AdminPricingTab.tsx
|
||||||
|
│ │ │ └── AdminTestingTab.tsx
|
||||||
|
│ │ ├── context/
|
||||||
|
│ │ │ └── authcontext.tsx
|
||||||
|
│ │ ├── hooks/
|
||||||
|
│ │ │ ├── useAuth.ts
|
||||||
|
│ │ │ └── useToast.ts
|
||||||
|
│ │ ├── utils/
|
||||||
|
│ │ │ └── apiClient.ts
|
||||||
|
│ │ ├── config/
|
||||||
|
│ │ │ └── api.ts
|
||||||
|
│ │ ├── App.tsx
|
||||||
|
│ │ └── main.tsx
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── vite.config.ts
|
||||||
|
│ └── tailwind.config.js
|
||||||
|
│
|
||||||
|
├── README.md
|
||||||
|
├── CONTRIBUTING.md
|
||||||
|
├── BLOG_DEPLOYMENT.md
|
||||||
|
└── BLOG_QUICKSTART.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 Разработка
|
||||||
|
|
||||||
|
### Установка зависимостей
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd ospabhost/backend
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd ospabhost/frontend
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Запуск в режиме разработки
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend (порт 5000)
|
||||||
|
cd backend
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Frontend (порт 5173)
|
||||||
|
cd frontend
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Линтинг и форматирование
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Frontend
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Работа с БД
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Создать миграцию
|
||||||
|
npx prisma migrate dev --name migration_name
|
||||||
|
|
||||||
|
# Применить миграции
|
||||||
|
npx prisma migrate deploy
|
||||||
|
|
||||||
|
# Открыть Prisma Studio
|
||||||
|
npx prisma studio
|
||||||
|
|
||||||
|
# Сгенерировать Prisma Client
|
||||||
|
npx prisma generate
|
||||||
|
|
||||||
|
# Заполнить БД начальными данными
|
||||||
|
npx prisma db seed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Сборка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
npm run build
|
||||||
|
npm run preview # Предпросмотр production сборки
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Известные проблемы и решения
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
**Проблема:** "post and comment are not properties of PrismaClient"
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npx prisma generate
|
||||||
|
npm run build
|
||||||
|
pm2 restart ospab-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
**Проблема:** 404 ошибки на `/api/admin/test/*`
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
- Проверьте порядок middleware в `admin.routes.ts` (test endpoints должны быть BEFORE requireAdmin)
|
||||||
|
- Перезапустите dev-server
|
||||||
|
|
||||||
|
**Проблема:** Кастомный тариф не возвращает pricePerGb
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
- Убедитесь что функция `serializePlan` в `storage.service.ts` включает поля `pricePerGb`, `bandwidthPerGb`, `requestsPerGb`
|
||||||
|
- Пересоберите backend: `npm run build`
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
**Проблема:** Не отображается вкладка "📝 Блог"
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
- Убедитесь что пользователь имеет `isAdmin: true`
|
||||||
|
- Пересоберите frontend: `npm run build`
|
||||||
|
|
||||||
|
**Проблема:** Rich Text редактор не загружается
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install react-quill quill --legacy-peer-deps
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
**Проблема:** Не могу изменить цену за GB в кастомном тарифе
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
- UI уже реализован в `AdminPricingTab.tsx`
|
||||||
|
- Backend должен возвращать эти поля через `serializePlan`
|
||||||
|
- Перезапустите оба сервиса
|
||||||
|
|
||||||
|
### S3 Storage
|
||||||
|
|
||||||
|
**Проблема:** Изображения не загружаются
|
||||||
|
|
||||||
|
**Проверьте:**
|
||||||
|
1. Права на директорию `backend/uploads/blog` (должно быть `755`)
|
||||||
|
2. Nginx раздаёт `/uploads/blog`
|
||||||
|
3. В логах backend нет ошибок multer
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Проверка прав
|
||||||
|
ls -la /var/www/ospab-host/ospabhost/backend/uploads/blog
|
||||||
|
|
||||||
|
# Проверка логов
|
||||||
|
pm2 logs ospab-backend --lines 50
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 Вклад в проект
|
||||||
|
|
||||||
|
Мы приветствуем вклад в развитие проекта! См. [CONTRIBUTING.md](CONTRIBUTING.md) для деталей.
|
||||||
|
|
||||||
|
### Процесс
|
||||||
|
|
||||||
|
1. Форкните репозиторий
|
||||||
|
2. Создайте ветку для фичи (`git checkout -b feature/AmazingFeature`)
|
||||||
|
3. Закоммитьте изменения (`git commit -m 'feat: add amazing feature'`)
|
||||||
|
4. Запушьте в ветку (`git push origin feature/AmazingFeature`)
|
||||||
|
5. Откройте Pull Request
|
||||||
|
|
||||||
|
### Стандарты коммитов
|
||||||
|
|
||||||
|
Используем [Conventional Commits](https://www.conventionalcommits.org/):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
feat(storage): add custom tariff pricing
|
||||||
|
fix(ticket): auto-unassign operator on user close
|
||||||
|
docs: update API endpoints in README
|
||||||
|
refactor(auth): remove any types from middleware
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 Лицензия
|
||||||
|
|
||||||
|
Этот проект является частной разработкой. Все права защищены.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 👥 Команда
|
||||||
|
|
||||||
|
- **Ospab** - Основатель и главный разработчик
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Контакты
|
||||||
|
|
||||||
|
- Website: [ospab.host](https://ospab.host)
|
||||||
|
- Email: support@ospab.host
|
||||||
|
- Telegram: [@ospab](https://t.me/ospab)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🙏 Благодарности
|
||||||
|
|
||||||
|
- [Proxmox VE](https://www.proxmox.com/) - виртуализация
|
||||||
|
- [MinIO](https://min.io/) - S3-совместимое хранилище
|
||||||
|
- [Prisma](https://www.prisma.io/) - ORM для Node.js
|
||||||
|
- [React](https://react.dev/) - UI библиотека
|
||||||
|
- [Tailwind CSS](https://tailwindcss.com/) - CSS фреймворк
|
||||||
|
- [Quill](https://quilljs.com/) - Rich Text редактор
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Версия:** 8.1
|
||||||
|
**Последнее обновление:** 26 ноября 2025
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
const { PrismaClient } = require('@prisma/client');
|
const { prisma } = require('./src/prisma/client');
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
async function checkTables() {
|
async function checkTables() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
BIN
ospabhost/backend/node_modules.tar.gz
Normal file
BIN
ospabhost/backend/node_modules.tar.gz
Normal file
Binary file not shown.
571
ospabhost/backend/package-lock.json
generated
571
ospabhost/backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,8 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "ts-node-dev --respawn --transpile-only ./src/index.ts",
|
"dev": "ts-node-dev --respawn --transpile-only ./index.ts",
|
||||||
"start": "node dist/src/index.js",
|
"start": "node dist/index.js",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"pm2:start": "pm2 start ecosystem.config.js --env production && pm2 save",
|
"pm2:start": "pm2 start ecosystem.config.js --env production && pm2 save",
|
||||||
"pm2:stop": "pm2 stop ospab-backend && pm2 delete ospab-backend && pm2 save",
|
"pm2:stop": "pm2 stop ospab-backend && pm2 delete ospab-backend && pm2 save",
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.16.2",
|
"@prisma/client": "^6.0.0",
|
||||||
"@types/ssh2": "^1.15.5",
|
"@types/ssh2": "^1.15.5",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
"minio": "^8.0.6",
|
"minio": "^8.0.6",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"nodemailer": "^6.9.15",
|
"nodemailer": "^6.9.15",
|
||||||
|
"nvm": "^0.0.4",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-github": "^1.1.0",
|
"passport-github": "^1.1.0",
|
||||||
"passport-google-oauth20": "^2.0.0",
|
"passport-google-oauth20": "^2.0.0",
|
||||||
@@ -52,15 +53,15 @@
|
|||||||
"@types/express-session": "^1.18.2",
|
"@types/express-session": "^1.18.2",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/nodemailer": "^6.4.15",
|
|
||||||
"@types/node": "^20.12.12",
|
"@types/node": "^20.12.12",
|
||||||
|
"@types/nodemailer": "^6.4.15",
|
||||||
"@types/passport": "^1.0.17",
|
"@types/passport": "^1.0.17",
|
||||||
"@types/passport-github": "^1.1.12",
|
"@types/passport-github": "^1.1.12",
|
||||||
"@types/passport-google-oauth20": "^2.0.16",
|
"@types/passport-google-oauth20": "^2.0.16",
|
||||||
"@types/passport-vkontakte": "^1.0.5",
|
"@types/passport-vkontakte": "^1.0.5",
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"@types/xterm": "^2.0.3",
|
"@types/xterm": "^2.0.3",
|
||||||
"prisma": "^6.16.2",
|
"prisma": "^6.0.0",
|
||||||
"ts-node-dev": "^2.0.0",
|
"ts-node-dev": "^2.0.0",
|
||||||
"typescript": "^5.4.5"
|
"typescript": "^5.4.5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { prisma } from '../src/prisma/client';
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
await prisma.operatingSystem.deleteMany({ where: { type: 'windows' } });
|
await prisma.operatingSystem.deleteMany({ where: { type: 'windows' } });
|
||||||
|
|||||||
4
ospabhost/backend/prisma/promocodes_seed.sql
Normal file
4
ospabhost/backend/prisma/promocodes_seed.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
-- Insert promo code 'START' for 99 RUB, single-use
|
||||||
|
INSERT INTO promo_code (code, amount, used, created_at, updated_at)
|
||||||
|
VALUES ('START', 99, false, NOW(), NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE code = code;
|
||||||
@@ -18,9 +18,9 @@ model User {
|
|||||||
email String @unique
|
email String @unique
|
||||||
password String
|
password String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
plans Plan[] @relation("UserPlans")
|
// plans Plan[] @relation("UserPlans")
|
||||||
operator Int @default(0)
|
operator Int @default(0)
|
||||||
isAdmin Boolean @default(false) // Админские права
|
isAdmin Boolean @default(false)
|
||||||
tickets Ticket[] @relation("UserTickets")
|
tickets Ticket[] @relation("UserTickets")
|
||||||
responses Response[] @relation("OperatorResponses")
|
responses Response[] @relation("OperatorResponses")
|
||||||
checks Check[] @relation("UserChecks")
|
checks Check[] @relation("UserChecks")
|
||||||
@@ -56,26 +56,13 @@ model Check {
|
|||||||
@@map("check")
|
@@map("check")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Plan {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
name String @unique
|
|
||||||
price Float
|
|
||||||
description String?
|
|
||||||
isCustom Boolean @default(false)
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
userId Int
|
|
||||||
owner User @relation("UserPlans", fields: [userId], references: [id])
|
|
||||||
services Service[] @relation("PlanServices")
|
|
||||||
|
|
||||||
@@map("plan")
|
|
||||||
}
|
|
||||||
|
|
||||||
model Service {
|
model Service {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String @unique
|
name String @unique
|
||||||
price Float
|
price Float
|
||||||
planId Int?
|
// planId Int?
|
||||||
plan Plan? @relation("PlanServices", fields: [planId], references: [id])
|
// plan Plan? @relation("PlanServices", fields: [planId], references: [id])
|
||||||
|
|
||||||
@@map("service")
|
@@map("service")
|
||||||
}
|
}
|
||||||
@@ -473,6 +460,9 @@ model StorageCheckoutSession {
|
|||||||
planName String
|
planName String
|
||||||
planDescription String?
|
planDescription String?
|
||||||
price Float
|
price Float
|
||||||
|
promoCodeId Int?
|
||||||
|
promoCode PromoCode? @relation(fields: [promoCodeId], references: [id])
|
||||||
|
promoDiscount Float? @default(0)
|
||||||
quotaGb Int
|
quotaGb Int
|
||||||
bandwidthGb Int
|
bandwidthGb Int
|
||||||
requestLimit String
|
requestLimit String
|
||||||
@@ -518,3 +508,18 @@ model StorageClass {
|
|||||||
|
|
||||||
@@map("storage_class")
|
@@map("storage_class")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model PromoCode {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
code String @unique
|
||||||
|
amount Float // discount amount in RUB
|
||||||
|
used Boolean @default(false)
|
||||||
|
usedBy Int?
|
||||||
|
usedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User? @relation(fields: [usedBy], references: [id])
|
||||||
|
|
||||||
|
@@map("promo_code")
|
||||||
|
}
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const tariffs = [
|
|
||||||
{ name: 'Минимальный', price: 150, description: '1 ядро, 1ГБ RAM, 20ГБ SSD' },
|
|
||||||
{ name: 'Базовый', price: 300, description: '2 ядра, 2ГБ RAM, 40ГБ SSD' },
|
|
||||||
{ name: 'Старт', price: 500, description: '2 ядра, 4ГБ RAM, 60ГБ SSD' },
|
|
||||||
{ name: 'Оптимальный', price: 700, description: '4 ядра, 4ГБ RAM, 80ГБ SSD' },
|
|
||||||
{ name: 'Профи', price: 1000, description: '4 ядра, 8ГБ RAM, 120ГБ SSD' },
|
|
||||||
{ name: 'Бизнес', price: 1500, description: '8 ядер, 16ГБ RAM, 200ГБ SSD' },
|
|
||||||
{ name: 'Корпоративный', price: 2000, description: '12 ядер, 24ГБ RAM, 300ГБ SSD' },
|
|
||||||
{ name: 'Премиум', price: 2500, description: '16 ядер, 32ГБ RAM, 400ГБ SSD' },
|
|
||||||
{ name: 'Энтерпрайз', price: 2800, description: '24 ядра, 48ГБ RAM, 500ГБ SSD' },
|
|
||||||
{ name: 'Максимум', price: 3000, description: '32 ядра, 64ГБ RAM, 1ТБ SSD' },
|
|
||||||
];
|
|
||||||
for (const t of tariffs) {
|
|
||||||
await prisma.tariff.upsert({
|
|
||||||
where: { name: t.name },
|
|
||||||
update: t,
|
|
||||||
create: t,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
console.log('Тарифы успешно добавлены!');
|
|
||||||
}
|
|
||||||
|
|
||||||
main().finally(() => prisma.$disconnect());
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const oses = [
|
|
||||||
{ name: 'Ubuntu 22.04', type: 'linux', template: 'local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst' },
|
|
||||||
{ name: 'Debian 12', type: 'linux', template: 'local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst' },
|
|
||||||
{ name: 'CentOS 9', type: 'linux', template: 'local:vztmpl/centos-9-stream-default_20240828_amd64.tar.xz' },
|
|
||||||
{ name: 'AlmaLinux 9', type: 'linux', template: 'local:vztmpl/almalinux-9-default_20240911_amd64.tar.xz' },
|
|
||||||
{ name: 'Rocky Linux 9', type: 'linux', template: 'local:vztmpl/rockylinux-9-default_20240912_amd64.tar.xz' },
|
|
||||||
{ name: 'Arch Linux', type: 'linux', template: 'local:vztmpl/archlinux-base_20240911-1_amd64.tar.zst' },
|
|
||||||
{ name: 'Fedora 41', type: 'linux', template: 'local:vztmpl/fedora-41-default_20241118_amd64.tar.xz' },
|
|
||||||
];
|
|
||||||
for (const os of oses) {
|
|
||||||
await prisma.operatingSystem.upsert({
|
|
||||||
where: { name: os.name },
|
|
||||||
update: os,
|
|
||||||
create: os,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
console.log('ОС успешно добавлены!');
|
|
||||||
}
|
|
||||||
|
|
||||||
main().finally(() => prisma.$disconnect());
|
|
||||||
@@ -70,6 +70,23 @@ app.use(express.json({ limit: '100mb' }));
|
|||||||
app.use(express.urlencoded({ limit: '100mb', extended: true }));
|
app.use(express.urlencoded({ limit: '100mb', extended: true }));
|
||||||
app.use(passport.initialize());
|
app.use(passport.initialize());
|
||||||
|
|
||||||
|
// Глобальная обработка необработанных ошибок и Promise rejection — логируем и не даём молча закрывать соединение
|
||||||
|
process.on('uncaughtException', (err) => {
|
||||||
|
try {
|
||||||
|
logger.error('[Process] uncaughtException', err);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Process] uncaughtException', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
try {
|
||||||
|
logger.error('[Process] unhandledRejection', reason);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Process] unhandledRejection', reason);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/health', (_req, res) => {
|
app.get('/health', (_req, res) => {
|
||||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
});
|
});
|
||||||
@@ -182,6 +199,34 @@ app.use('/uploads/blog', express.static(path.join(__dirname, '../uploads/blog'))
|
|||||||
app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
|
app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
|
||||||
app.use('/uploads/tickets', express.static(path.join(__dirname, '../uploads/tickets')));
|
app.use('/uploads/tickets', express.static(path.join(__dirname, '../uploads/tickets')));
|
||||||
|
|
||||||
|
// Логирование всех запросов в /api/auth (не модифицируем логику, только логируем)
|
||||||
|
app.use('/api/auth', (req, res, next) => {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
logger.info('[Audit] Auth request received', {
|
||||||
|
method: req.method,
|
||||||
|
path: req.originalUrl,
|
||||||
|
ip: req.ip || req.connection.remoteAddress,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Audit] Failed to log auth request received', err);
|
||||||
|
}
|
||||||
|
// Log when response finished
|
||||||
|
res.on('finish', () => {
|
||||||
|
try {
|
||||||
|
logger.info('[Audit] Auth request finished', {
|
||||||
|
method: req.method,
|
||||||
|
path: req.originalUrl,
|
||||||
|
ip: req.ip || req.connection.remoteAddress,
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Audit] Failed to log auth request finished', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
});
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/auth', oauthRoutes);
|
app.use('/api/auth', oauthRoutes);
|
||||||
app.use('/api/admin', adminRoutes);
|
app.use('/api/admin', adminRoutes);
|
||||||
@@ -245,6 +290,18 @@ const wss = initWebSocketServer(server);
|
|||||||
// Установка timeout для всех запросов (120 сек = 120000 мс)
|
// Установка timeout для всех запросов (120 сек = 120000 мс)
|
||||||
server.setTimeout(120000);
|
server.setTimeout(120000);
|
||||||
|
|
||||||
|
// Глобальный express error handler — логируем и возвращаем 500, не ломая сокет
|
||||||
|
app.use((err: any, _req: any, res: any, _next: any) => {
|
||||||
|
try {
|
||||||
|
logger.error('[Express] Unhandled error:', err);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Express] Unhandled error:', err);
|
||||||
|
}
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ message: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
logger.info(`${protocolLabel} сервер запущен на порту ${PORT}`);
|
logger.info(`${protocolLabel} сервер запущен на порту ${PORT}`);
|
||||||
logger.info(`База данных: ${process.env.DATABASE_URL ? 'подключена' : 'НЕ НАСТРОЕНА'}`);
|
logger.info(`База данных: ${process.env.DATABASE_URL ? 'подключена' : 'НЕ НАСТРОЕНА'}`);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
confirmAccountDeletion,
|
confirmAccountDeletion,
|
||||||
getUserInfo,
|
getUserInfo,
|
||||||
} from './account.service';
|
} from './account.service';
|
||||||
|
import { prisma } from '../../prisma/client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Получить информацию о текущем пользователе
|
* Получить информацию о текущем пользователе
|
||||||
@@ -49,8 +50,6 @@ export const requestPasswordChangeHandler = async (req: Request, res: Response)
|
|||||||
|
|
||||||
// Проверка текущего пароля
|
// Проверка текущего пароля
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const { PrismaClient } = require('@prisma/client');
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -70,7 +69,7 @@ export const requestPasswordChangeHandler = async (req: Request, res: Response)
|
|||||||
success: true,
|
success: true,
|
||||||
message: 'Код подтверждения отправлен на вашу почту'
|
message: 'Код подтверждения отправлен на вашу почту'
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Ошибка запроса смены пароля:', error);
|
console.error('Ошибка запроса смены пароля:', error);
|
||||||
res.status(500).json({ error: error.message || 'Ошибка сервера' });
|
res.status(500).json({ error: error.message || 'Ошибка сервера' });
|
||||||
}
|
}
|
||||||
@@ -98,7 +97,7 @@ export const confirmPasswordChangeHandler = async (req: Request, res: Response)
|
|||||||
success: true,
|
success: true,
|
||||||
message: 'Пароль успешно изменён'
|
message: 'Пароль успешно изменён'
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Ошибка подтверждения смены пароля:', error);
|
console.error('Ошибка подтверждения смены пароля:', error);
|
||||||
res.status(400).json({ error: error.message || 'Ошибка подтверждения' });
|
res.status(400).json({ error: error.message || 'Ошибка подтверждения' });
|
||||||
}
|
}
|
||||||
@@ -138,7 +137,7 @@ export const requestUsernameChangeHandler = async (req: Request, res: Response)
|
|||||||
success: true,
|
success: true,
|
||||||
message: 'Код подтверждения отправлен на вашу почту'
|
message: 'Код подтверждения отправлен на вашу почту'
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Ошибка запроса смены имени:', error);
|
console.error('Ошибка запроса смены имени:', error);
|
||||||
res.status(400).json({ error: error.message || 'Ошибка сервера' });
|
res.status(400).json({ error: error.message || 'Ошибка сервера' });
|
||||||
}
|
}
|
||||||
@@ -166,7 +165,7 @@ export const confirmUsernameChangeHandler = async (req: Request, res: Response)
|
|||||||
success: true,
|
success: true,
|
||||||
message: 'Имя пользователя успешно изменено'
|
message: 'Имя пользователя успешно изменено'
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Ошибка подтверждения смены имени:', error);
|
console.error('Ошибка подтверждения смены имени:', error);
|
||||||
res.status(400).json({ error: error.message || 'Ошибка подтверждения' });
|
res.status(400).json({ error: error.message || 'Ошибка подтверждения' });
|
||||||
}
|
}
|
||||||
@@ -188,7 +187,7 @@ export const requestAccountDeletionHandler = async (req: Request, res: Response)
|
|||||||
success: true,
|
success: true,
|
||||||
message: 'Код подтверждения отправлен на вашу почту. После подтверждения аккаунт будет удалён безвозвратно.'
|
message: 'Код подтверждения отправлен на вашу почту. После подтверждения аккаунт будет удалён безвозвратно.'
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Ошибка запроса удаления аккаунта:', error);
|
console.error('Ошибка запроса удаления аккаунта:', error);
|
||||||
res.status(500).json({ error: error.message || 'Ошибка сервера' });
|
res.status(500).json({ error: error.message || 'Ошибка сервера' });
|
||||||
}
|
}
|
||||||
@@ -216,8 +215,9 @@ export const confirmAccountDeletionHandler = async (req: Request, res: Response)
|
|||||||
success: true,
|
success: true,
|
||||||
message: 'Аккаунт успешно удалён'
|
message: 'Аккаунт успешно удалён'
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Ошибка подтверждения удаления аккаунта:', error);
|
console.error('Ошибка подтверждения удаления аккаунта:', error);
|
||||||
res.status(400).json({ error: error.message || 'Ошибка подтверждения' });
|
res.status(400).json({ error: error.message || 'Ошибка подтверждения' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { prisma } from '../../prisma/client';
|
||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
// Настройка транспорта для email
|
// Настройка транспорта для email
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = nodemailer.createTransport({
|
||||||
|
|||||||
@@ -105,7 +105,17 @@ export class AdminController {
|
|||||||
return res.status(404).json({ message: 'Пользователь не найден' });
|
return res.status(404).json({ message: 'Пользователь не найден' });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ status: 'success', data: user });
|
const safeUser = {
|
||||||
|
...user,
|
||||||
|
balance: typeof user.balance === 'number' ? user.balance : Number(user.balance ?? 0),
|
||||||
|
buckets: user.buckets?.map((bucket: any) => ({
|
||||||
|
...bucket,
|
||||||
|
usedBytes: typeof bucket.usedBytes === 'bigint' ? Number(bucket.usedBytes) : Number(bucket.usedBytes ?? 0),
|
||||||
|
objectCount: typeof bucket.objectCount === 'number' ? bucket.objectCount : Number(bucket.objectCount ?? 0),
|
||||||
|
})) ?? [],
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json({ status: 'success', data: safeUser });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка получения данных пользователя:', error);
|
console.error('Ошибка получения данных пользователя:', error);
|
||||||
res.status(500).json({ message: 'Ошибка получения данных' });
|
res.status(500).json({ message: 'Ошибка получения данных' });
|
||||||
@@ -307,7 +317,7 @@ export class AdminController {
|
|||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const statusMap = bucketStatusCounts.reduce<Record<string, number>>((acc, item) => {
|
const statusMap = bucketStatusCounts.reduce<Record<string, number>>((acc: Record<string, number>, item: { status: string; _count: { _all: number } }) => {
|
||||||
acc[item.status] = item._count._all;
|
acc[item.status] = item._count._all;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
@@ -426,7 +436,6 @@ export class AdminController {
|
|||||||
await tx.response.deleteMany({ where: { operatorId: userId } });
|
await tx.response.deleteMany({ where: { operatorId: userId } });
|
||||||
|
|
||||||
await tx.storageBucket.deleteMany({ where: { userId } });
|
await tx.storageBucket.deleteMany({ where: { userId } });
|
||||||
await tx.plan.deleteMany({ where: { userId } });
|
|
||||||
|
|
||||||
await tx.ticket.deleteMany({ where: { userId } });
|
await tx.ticket.deleteMany({ where: { userId } });
|
||||||
await tx.check.deleteMany({ where: { userId } });
|
await tx.check.deleteMany({ where: { userId } });
|
||||||
@@ -467,16 +476,24 @@ export class AdminController {
|
|||||||
return res.status(404).json({ error: 'Пользователь не найден' });
|
return res.status(404).json({ error: 'Пользователь не найден' });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Admin] Тест push-уведомления инициирован администратором ${user.username}`);
|
const now = new Date().toISOString();
|
||||||
|
const logMsg = `[Admin] PUSH-TEST | userId=${user.id} | username=${user.username} | email=${user.email} | time=${now}`;
|
||||||
|
console.log(logMsg);
|
||||||
|
|
||||||
// Имитируем задержку отправки
|
// Здесь должна быть реальная отправка push (имитация)
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Push-уведомление успешно отправлено',
|
message: 'Push-уведомление успешно отправлено (тест)',
|
||||||
admin: user.username,
|
details: {
|
||||||
timestamp: new Date().toISOString()
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
type: 'push',
|
||||||
|
time: now,
|
||||||
|
status: 'sent (mock)'
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Admin] Ошибка при тестировании push-уведомления:', error);
|
console.error('[Admin] Ошибка при тестировании push-уведомления:', error);
|
||||||
@@ -497,18 +514,24 @@ export class AdminController {
|
|||||||
return res.status(404).json({ error: 'Пользователь не найден' });
|
return res.status(404).json({ error: 'Пользователь не найден' });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Admin] Тест email-уведомления инициирован администратором ${user.username}`);
|
const now = new Date().toISOString();
|
||||||
console.log(`[Admin] Email для теста: ${user.email}`);
|
const logMsg = `[Admin] EMAIL-TEST | userId=${user.id} | username=${user.username} | email=${user.email} | time=${now}`;
|
||||||
|
console.log(logMsg);
|
||||||
|
|
||||||
// Имитируем задержку отправки
|
// Здесь должна быть реальная отправка email (имитация)
|
||||||
await new Promise(resolve => setTimeout(resolve, 800));
|
await new Promise(resolve => setTimeout(resolve, 800));
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Email-уведомление успешно отправлено',
|
message: 'Email-уведомление успешно отправлено (тест)',
|
||||||
admin: user.username,
|
details: {
|
||||||
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
timestamp: new Date().toISOString()
|
type: 'email',
|
||||||
|
time: now,
|
||||||
|
status: 'sent (mock)'
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Admin] Ошибка при тестировании email-уведомления:', error);
|
console.error('[Admin] Ошибка при тестировании email-уведомления:', error);
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { prisma } from '../../prisma/client';
|
||||||
import { validateTurnstileToken } from './turnstile.validator';
|
import { validateTurnstileToken } from './turnstile.validator';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
|
import { sendNewLoginEmail, sendWelcomeEmail } from '../notification/email.service';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_key';
|
const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_key';
|
||||||
|
|
||||||
export const register = async (req: Request, res: Response) => {
|
export const register = async (req: Request, res: Response) => {
|
||||||
@@ -36,7 +36,7 @@ export const register = async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
const hashedPassword = await bcrypt.hash(password, 10);
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
await prisma.user.create({
|
const newUser = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
username,
|
username,
|
||||||
email,
|
email,
|
||||||
@@ -44,6 +44,11 @@ export const register = async (req: Request, res: Response) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Отправляем приветственное письмо
|
||||||
|
sendWelcomeEmail(newUser.id).catch((err) => {
|
||||||
|
logger.error('Ошибка отправки приветственного письма:', err);
|
||||||
|
});
|
||||||
|
|
||||||
res.status(201).json({ message: 'Регистрация прошла успешно!' });
|
res.status(201).json({ message: 'Регистрация прошла успешно!' });
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -85,6 +90,18 @@ export const login = async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
const token = jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: '24h' });
|
const token = jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: '24h' });
|
||||||
|
|
||||||
|
// Отправляем уведомление о новом входе
|
||||||
|
const userAgent = req.headers['user-agent'] || '';
|
||||||
|
const ip = req.headers['x-forwarded-for'] as string || req.ip || req.connection.remoteAddress || '';
|
||||||
|
|
||||||
|
sendNewLoginEmail(user.id, {
|
||||||
|
ip: Array.isArray(ip) ? ip[0] : ip.split(',')[0].trim(),
|
||||||
|
userAgent,
|
||||||
|
time: new Date(),
|
||||||
|
}).catch((err) => {
|
||||||
|
logger.error('Ошибка отправки уведомления о входе:', err);
|
||||||
|
});
|
||||||
|
|
||||||
res.status(200).json({ token });
|
res.status(200).json({ token });
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ import passport from 'passport';
|
|||||||
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
|
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
|
||||||
import { Strategy as GitHubStrategy } from 'passport-github';
|
import { Strategy as GitHubStrategy } from 'passport-github';
|
||||||
import { Strategy as YandexStrategy } from 'passport-yandex';
|
import { Strategy as YandexStrategy } from 'passport-yandex';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { prisma } from '../../prisma/client';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_key';
|
const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_key';
|
||||||
const OAUTH_CALLBACK_URL = process.env.OAUTH_CALLBACK_URL || 'https://api.ospab.host/api/auth';
|
const OAUTH_CALLBACK_URL = process.env.OAUTH_CALLBACK_URL || 'https://api.ospab.host/api/auth';
|
||||||
|
|
||||||
|
|||||||
@@ -166,7 +166,10 @@ export const getPostByIdAdmin = async (req: Request, res: Response) => {
|
|||||||
export const createPost = async (req: Request, res: Response) => {
|
export const createPost = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { title, content, excerpt, coverImage, url, status } = req.body;
|
const { title, content, excerpt, coverImage, url, status } = req.body;
|
||||||
const authorId = req.user!.id; // user гарантированно есть после authMiddleware
|
const authorId = req.user?.id;
|
||||||
|
if (!authorId) {
|
||||||
|
return res.status(401).json({ success: false, message: 'Не авторизован' });
|
||||||
|
}
|
||||||
|
|
||||||
if (!title || !content || !url) {
|
if (!title || !content || !url) {
|
||||||
return res.status(400).json({ success: false, message: 'Заполните обязательные поля' });
|
return res.status(400).json({ success: false, message: 'Заполните обязательные поля' });
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export async function approveCheck(req: Request, res: Response) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`[Check] ✅ Чек #${checkId} подтверждён оператором ${req.user?.id}. Пользователь ${check.user.username} получил ${check.amount} ₽`);
|
logger.info(`[Check] Чек #${checkId} подтверждён оператором ${req.user?.id}. Пользователь ${check.user.username} получил ${check.amount} ₽`);
|
||||||
res.json({ success: true, message: 'Чек подтверждён, баланс пополнен' });
|
res.json({ success: true, message: 'Чек подтверждён, баланс пополнен' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[Check] Ошибка подтверждения чека:', error);
|
logger.error('[Check] Ошибка подтверждения чека:', error);
|
||||||
@@ -128,7 +128,7 @@ export async function rejectCheck(req: Request, res: Response) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`[Check] ❌ Чек #${checkId} отклонён оператором ${req.user?.id}. Пользователь: ${check.user.username}${comment ? `, причина: ${comment}` : ''}`);
|
logger.info(`[Check] Чек #${checkId} отклонён оператором ${req.user?.id}. Пользователь: ${check.user.username}${comment ? `, причина: ${comment}` : ''}`);
|
||||||
res.json({ success: true, message: 'Чек отклонён' });
|
res.json({ success: true, message: 'Чек отклонён' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[Check] Ошибка отклонения чека:', error);
|
logger.error('[Check] Ошибка отклонения чека:', error);
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { prisma } from '../../prisma/client';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
|
import {
|
||||||
|
baseEmailTemplate,
|
||||||
|
emailHeader,
|
||||||
|
emailGreeting,
|
||||||
|
emailParagraph,
|
||||||
|
emailInfoBox,
|
||||||
|
emailButton,
|
||||||
|
emailSignature,
|
||||||
|
emailSecurityNote,
|
||||||
|
emailDivider,
|
||||||
|
} from './email.templates';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
const FRONTEND_URL = process.env.FRONTEND_URL || 'https://ospab.host';
|
const FRONTEND_URL = process.env.FRONTEND_URL || 'https://ospab.host';
|
||||||
|
|
||||||
@@ -19,6 +29,9 @@ const transporter = nodemailer.createTransport({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export interface EmailNotification {
|
export interface EmailNotification {
|
||||||
to: string;
|
to: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
@@ -26,7 +39,9 @@ export interface EmailNotification {
|
|||||||
html?: string;
|
html?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SendEmailResult = { status: 'success'; messageId: string } | { status: 'skipped' | 'error'; message: string };
|
type SendEmailResult =
|
||||||
|
| { status: 'success'; messageId: string }
|
||||||
|
| { status: 'skipped' | 'error'; message: string };
|
||||||
|
|
||||||
// Отправка email уведомления
|
// Отправка email уведомления
|
||||||
export async function sendEmail(notification: EmailNotification): Promise<SendEmailResult> {
|
export async function sendEmail(notification: EmailNotification): Promise<SendEmailResult> {
|
||||||
@@ -38,15 +53,16 @@ export async function sendEmail(notification: EmailNotification): Promise<SendEm
|
|||||||
}
|
}
|
||||||
|
|
||||||
const info = await transporter.sendMail({
|
const info = await transporter.sendMail({
|
||||||
from: `"Ospab Host" <${process.env.SMTP_USER}>`,
|
from: `"ospab.host" <${process.env.SMTP_USER}>`,
|
||||||
...notification
|
...notification
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Email sent: %s', info.messageId);
|
logger.info('Email sent: %s', info.messageId);
|
||||||
return { status: 'success', messageId: info.messageId };
|
return { status: 'success', messageId: info.messageId };
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
logger.error('Error sending email:', error);
|
logger.error('Error sending email:', error);
|
||||||
return { status: 'error', message: error.message };
|
return { status: 'error', message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +91,7 @@ export async function sendNotificationEmail(params: SendGenericNotificationEmail
|
|||||||
const { to, username, title, message, actionUrl } = params;
|
const { to, username, title, message, actionUrl } = params;
|
||||||
|
|
||||||
const resolvedActionUrl = resolveActionUrl(actionUrl);
|
const resolvedActionUrl = resolveActionUrl(actionUrl);
|
||||||
const subject = `[Ospab Host] ${title}`.trim();
|
const subject = `[ospab.host] ${title}`.trim();
|
||||||
|
|
||||||
const plainTextLines = [
|
const plainTextLines = [
|
||||||
`Здравствуйте${username ? `, ${username}` : ''}!`,
|
`Здравствуйте${username ? `, ${username}` : ''}!`,
|
||||||
@@ -87,7 +103,7 @@ export async function sendNotificationEmail(params: SendGenericNotificationEmail
|
|||||||
plainTextLines.push('', `Перейти: ${resolvedActionUrl}`);
|
plainTextLines.push('', `Перейти: ${resolvedActionUrl}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
plainTextLines.push('', '— Команда Ospab Host');
|
plainTextLines.push('', '— Команда ospab.host');
|
||||||
|
|
||||||
const html = `
|
const html = `
|
||||||
<div style="font-family: Arial, sans-serif; line-height: 1.6; color: #1f2933;">
|
<div style="font-family: Arial, sans-serif; line-height: 1.6; color: #1f2933;">
|
||||||
@@ -104,7 +120,7 @@ export async function sendNotificationEmail(params: SendGenericNotificationEmail
|
|||||||
</p>
|
</p>
|
||||||
` : ''}
|
` : ''}
|
||||||
<p style="margin-top: 24px; font-size: 12px; color: #6b7280;">Это автоматическое письмо. Не отвечайте на него.</p>
|
<p style="margin-top: 24px; font-size: 12px; color: #6b7280;">Это автоматическое письмо. Не отвечайте на него.</p>
|
||||||
<p style="font-size: 12px; color: #6b7280;">— Команда Ospab Host</p>
|
<p style="font-size: 12px; color: #6b7280;">— Команда ospab.host</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -122,59 +138,80 @@ export async function sendResourceAlertEmail(userId: number, serverId: number, a
|
|||||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
if (!user) return { status: 'error', message: 'User not found' };
|
if (!user) return { status: 'error', message: 'User not found' };
|
||||||
|
|
||||||
const subject = `Предупреждение: Высокая нагрузка на сервер #${serverId}`;
|
const subject = `Высокая нагрузка на сервер #${serverId}`;
|
||||||
const html = `
|
|
||||||
<h2>Предупреждение о ресурсах сервера</h2>
|
|
||||||
<p>Здравствуйте, ${user.username}!</p>
|
|
||||||
<p>Обнаружено превышение лимитов ресурсов на вашем сервере #${serverId}:</p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Тип:</strong> ${alertType}</li>
|
|
||||||
<li><strong>Значение:</strong> ${value}</li>
|
|
||||||
</ul>
|
|
||||||
<p>Рекомендуем проверить сервер и при необходимости увеличить его ресурсы.</p>
|
|
||||||
<p>С уважением,<br>Команда Ospab Host</p>
|
|
||||||
`;
|
|
||||||
|
|
||||||
return await sendEmail({
|
const content = [
|
||||||
to: user.email,
|
emailHeader({ icon: 'alert', title: 'Предупреждение о ресурсах', subtitle: `Сервер #${serverId}` }),
|
||||||
subject,
|
emailGreeting(user.username),
|
||||||
html
|
emailParagraph('Обнаружено превышение лимитов ресурсов на вашем сервере:'),
|
||||||
|
emailInfoBox({
|
||||||
|
type: 'warning',
|
||||||
|
items: [
|
||||||
|
{ label: 'Сервер', value: `#${serverId}` },
|
||||||
|
{ label: 'Тип предупреждения', value: alertType },
|
||||||
|
{ label: 'Текущее значение', value: value },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
emailParagraph('Рекомендуем проверить сервер и при необходимости увеличить его ресурсы или оптимизировать нагрузку.'),
|
||||||
|
emailButton({ text: 'Открыть панель управления', url: `${FRONTEND_URL}/dashboard/servers` }),
|
||||||
|
emailSignature(),
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
const html = baseEmailTemplate({
|
||||||
|
title: subject,
|
||||||
|
preheader: `Обнаружена высокая нагрузка на сервер #${serverId}`,
|
||||||
|
content,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
|
||||||
|
return await sendEmail({ to: user.email, subject, html });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
logger.error('Error sending resource alert email:', error);
|
logger.error('Error sending resource alert email:', error);
|
||||||
return { status: 'error', message: error.message };
|
return { status: 'error', message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Отправка уведомления о создании сервера
|
// Отправка уведомления о создании сервера
|
||||||
export async function sendServerCreatedEmail(userId: number, serverId: number, serverDetails: any) {
|
export async function sendServerCreatedEmail(userId: number, serverId: number, serverDetails: {
|
||||||
|
tariff?: string;
|
||||||
|
os?: string;
|
||||||
|
ip?: string;
|
||||||
|
}) {
|
||||||
try {
|
try {
|
||||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
if (!user) return { status: 'error', message: 'User not found' };
|
if (!user) return { status: 'error', message: 'User not found' };
|
||||||
|
|
||||||
const subject = `Ваш сервер #${serverId} успешно создан`;
|
const subject = `Ваш сервер #${serverId} успешно создан`;
|
||||||
const html = `
|
|
||||||
<h2>Сервер успешно создан!</h2>
|
|
||||||
<p>Здравствуйте, ${user.username}!</p>
|
|
||||||
<p>Ваш новый сервер был успешно создан:</p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>ID сервера:</strong> ${serverId}</li>
|
|
||||||
<li><strong>Тариф:</strong> ${serverDetails.tariff}</li>
|
|
||||||
<li><strong>ОС:</strong> ${serverDetails.os}</li>
|
|
||||||
<li><strong>IP адрес:</strong> ${serverDetails.ip || 'Получение...'}</li>
|
|
||||||
</ul>
|
|
||||||
<p>Вы можете управлять сервером через панель управления.</p>
|
|
||||||
<p>С уважением,<br>Команда Ospab Host</p>
|
|
||||||
`;
|
|
||||||
|
|
||||||
return await sendEmail({
|
const content = [
|
||||||
to: user.email,
|
emailHeader({ icon: 'success', title: 'Сервер создан!', subtitle: 'Готов к работе' }),
|
||||||
subject,
|
emailGreeting(user.username),
|
||||||
html
|
emailParagraph('Поздравляем! Ваш новый сервер был успешно создан и готов к использованию.'),
|
||||||
|
emailInfoBox({
|
||||||
|
type: 'success',
|
||||||
|
items: [
|
||||||
|
{ label: 'ID сервера', value: `#${serverId}` },
|
||||||
|
{ label: 'Тариф', value: serverDetails.tariff || 'Стандартный' },
|
||||||
|
{ label: 'Операционная система', value: serverDetails.os || 'Linux' },
|
||||||
|
{ label: 'IP адрес', value: serverDetails.ip || 'Назначается...' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
emailParagraph('Вы можете управлять сервером через панель управления.'),
|
||||||
|
emailButton({ text: 'Перейти к серверу', url: `${FRONTEND_URL}/dashboard/servers/${serverId}` }),
|
||||||
|
emailSignature(),
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
const html = baseEmailTemplate({
|
||||||
|
title: subject,
|
||||||
|
preheader: 'Ваш новый сервер готов к работе',
|
||||||
|
content,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
|
||||||
|
return await sendEmail({ to: user.email, subject, html });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
logger.error('Error sending server created email:', error);
|
logger.error('Error sending server created email:', error);
|
||||||
return { status: 'error', message: error.message };
|
return { status: 'error', message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,22 +222,320 @@ export async function sendPaymentReminderEmail(userId: number, serverId: number,
|
|||||||
if (!user) return { status: 'error', message: 'User not found' };
|
if (!user) return { status: 'error', message: 'User not found' };
|
||||||
|
|
||||||
const subject = `Напоминание: Оплата за сервер #${serverId}`;
|
const subject = `Напоминание: Оплата за сервер #${serverId}`;
|
||||||
const html = `
|
|
||||||
<h2>Напоминание об оплате</h2>
|
|
||||||
<p>Здравствуйте, ${user.username}!</p>
|
|
||||||
<p>До окончания срока действия вашего тарифа для сервера #${serverId} осталось ${daysLeft} дней.</p>
|
|
||||||
<p>Пожалуйста, пополните баланс, чтобы избежать прерывания обслуживания.</p>
|
|
||||||
<p>Ваш текущий баланс: ${user.balance}₽</p>
|
|
||||||
<p>С уважением,<br>Команда Ospab Host</p>
|
|
||||||
`;
|
|
||||||
|
|
||||||
return await sendEmail({
|
const urgencyType = daysLeft <= 1 ? 'danger' : daysLeft <= 3 ? 'warning' : 'default';
|
||||||
to: user.email,
|
|
||||||
subject,
|
const content = [
|
||||||
html
|
emailHeader({ icon: 'payment', title: 'Напоминание об оплате', subtitle: `Осталось ${daysLeft} дн.` }),
|
||||||
|
emailGreeting(user.username),
|
||||||
|
emailParagraph(`До окончания срока действия тарифа для сервера #${serverId} осталось <strong>${daysLeft} дней</strong>.`),
|
||||||
|
emailInfoBox({
|
||||||
|
type: urgencyType as 'default' | 'warning' | 'danger',
|
||||||
|
items: [
|
||||||
|
{ label: 'Сервер', value: `#${serverId}` },
|
||||||
|
{ label: 'Дней до окончания', value: `${daysLeft}` },
|
||||||
|
{ label: 'Ваш баланс', value: `${user.balance}₽` },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
emailParagraph('Пожалуйста, пополните баланс, чтобы избежать прерывания обслуживания.'),
|
||||||
|
emailButton({ text: 'Пополнить баланс', url: `${FRONTEND_URL}/dashboard/balance` }),
|
||||||
|
emailSignature(),
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
const html = baseEmailTemplate({
|
||||||
|
title: subject,
|
||||||
|
preheader: `Осталось ${daysLeft} дней до окончания срока оплаты`,
|
||||||
|
content,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
return await sendEmail({ to: user.email, subject, html });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
logger.error('Error sending payment reminder email:', error);
|
logger.error('Error sending payment reminder email:', error);
|
||||||
return { status: 'error', message: error.message };
|
return { status: 'error', message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== S3 EMAILS =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Уведомление о создании S3-бакета
|
||||||
|
*/
|
||||||
|
export async function sendS3BucketCreatedEmail(userId: number, bucketName: string, region: string) {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
if (!user) return { status: 'error', message: 'User not found' };
|
||||||
|
|
||||||
|
const subject = `Ваш S3-бакет "${bucketName}" создан`;
|
||||||
|
|
||||||
|
const content = [
|
||||||
|
emailHeader({ icon: 'storage', title: 'Бакет создан!', subtitle: bucketName }),
|
||||||
|
emailGreeting(user.username),
|
||||||
|
emailParagraph('Поздравляем! Ваш новый S3-бакет успешно создан и готов к использованию.'),
|
||||||
|
emailInfoBox({
|
||||||
|
type: 'success',
|
||||||
|
items: [
|
||||||
|
{ label: 'Название бакета', value: bucketName },
|
||||||
|
{ label: 'Регион', value: region },
|
||||||
|
{ label: 'Endpoint', value: 's3.ospab.host' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
emailParagraph('Вы можете управлять бакетом через панель управления или использовать S3-совместимые инструменты.'),
|
||||||
|
emailButton({ text: 'Открыть хранилище', url: `${FRONTEND_URL}/dashboard/storage` }),
|
||||||
|
emailSignature(),
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
const html = baseEmailTemplate({
|
||||||
|
title: subject,
|
||||||
|
preheader: `Бакет ${bucketName} готов к использованию`,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await sendEmail({ to: user.email, subject, html });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('Error sending S3 bucket created email:', error);
|
||||||
|
return { status: 'error', message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Уведомление о превышении квоты S3-бакета
|
||||||
|
*/
|
||||||
|
export async function sendS3QuotaAlertEmail(userId: number, bucketName: string, usage: string, quota: string) {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
if (!user) return { status: 'error', message: 'User not found' };
|
||||||
|
|
||||||
|
const subject = `Превышение квоты S3-бакета "${bucketName}"`;
|
||||||
|
|
||||||
|
const content = [
|
||||||
|
emailHeader({ icon: 'alert', title: 'Превышение квоты', subtitle: bucketName }),
|
||||||
|
emailGreeting(user.username),
|
||||||
|
emailParagraph('Обнаружено превышение квоты хранилища. Загрузка новых файлов может быть ограничена.'),
|
||||||
|
emailInfoBox({
|
||||||
|
type: 'warning',
|
||||||
|
items: [
|
||||||
|
{ label: 'Бакет', value: bucketName },
|
||||||
|
{ label: 'Использовано', value: usage },
|
||||||
|
{ label: 'Квота', value: quota },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
emailParagraph('Пожалуйста, освободите место или увеличьте тариф для продолжения работы.'),
|
||||||
|
emailButton({ text: 'Управление хранилищем', url: `${FRONTEND_URL}/dashboard/storage` }),
|
||||||
|
emailSignature(),
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
const html = baseEmailTemplate({
|
||||||
|
title: subject,
|
||||||
|
preheader: `Бакет ${bucketName} превысил квоту`,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await sendEmail({ to: user.email, subject, html });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('Error sending S3 quota alert email:', error);
|
||||||
|
return { status: 'error', message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Уведомление об удалении S3-бакета
|
||||||
|
*/
|
||||||
|
export async function sendS3BucketDeletedEmail(userId: number, bucketName: string) {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
if (!user) return { status: 'error', message: 'User not found' };
|
||||||
|
|
||||||
|
const subject = `S3-бакет "${bucketName}" удалён`;
|
||||||
|
|
||||||
|
const content = [
|
||||||
|
emailHeader({ icon: 'info', title: 'Бакет удалён', subtitle: bucketName }),
|
||||||
|
emailGreeting(user.username),
|
||||||
|
emailParagraph(`Ваш S3-бакет <strong>${bucketName}</strong> был успешно удалён.`),
|
||||||
|
emailInfoBox({
|
||||||
|
type: 'default',
|
||||||
|
items: [
|
||||||
|
{ label: 'Бакет', value: bucketName },
|
||||||
|
{ label: 'Статус', value: 'Удалён' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
emailSecurityNote('Если вы не удаляли этот бакет, немедленно свяжитесь с поддержкой.'),
|
||||||
|
emailButton({ text: 'Создать новый бакет', url: `${FRONTEND_URL}/dashboard/storage` }),
|
||||||
|
emailSignature(),
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
const html = baseEmailTemplate({
|
||||||
|
title: subject,
|
||||||
|
preheader: `Бакет ${bucketName} был удалён`,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await sendEmail({ to: user.email, subject, html });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('Error sending S3 bucket deleted email:', error);
|
||||||
|
return { status: 'error', message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== SECURITY EMAILS =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Уведомление о новом входе в аккаунт
|
||||||
|
*/
|
||||||
|
export async function sendNewLoginEmail(userId: number, loginDetails: {
|
||||||
|
ip?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
location?: string;
|
||||||
|
time?: Date;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
if (!user) return { status: 'error', message: 'User not found' };
|
||||||
|
|
||||||
|
const { ip, userAgent, location, time } = loginDetails;
|
||||||
|
const loginTime = time || new Date();
|
||||||
|
const formattedTime = loginTime.toLocaleString('ru-RU', {
|
||||||
|
timeZone: 'Europe/Moscow',
|
||||||
|
dateStyle: 'long',
|
||||||
|
timeStyle: 'short',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Определяем браузер и ОС из User-Agent
|
||||||
|
let browser = 'Неизвестный браузер';
|
||||||
|
let os = 'Неизвестная ОС';
|
||||||
|
|
||||||
|
if (userAgent) {
|
||||||
|
if (userAgent.includes('Chrome')) browser = 'Chrome';
|
||||||
|
else if (userAgent.includes('Firefox')) browser = 'Firefox';
|
||||||
|
else if (userAgent.includes('Safari')) browser = 'Safari';
|
||||||
|
else if (userAgent.includes('Edge')) browser = 'Edge';
|
||||||
|
else if (userAgent.includes('Opera')) browser = 'Opera';
|
||||||
|
|
||||||
|
if (userAgent.includes('Windows')) os = 'Windows';
|
||||||
|
else if (userAgent.includes('Mac')) os = 'macOS';
|
||||||
|
else if (userAgent.includes('Linux')) os = 'Linux';
|
||||||
|
else if (userAgent.includes('Android')) os = 'Android';
|
||||||
|
else if (userAgent.includes('iPhone') || userAgent.includes('iPad')) os = 'iOS';
|
||||||
|
}
|
||||||
|
|
||||||
|
const subject = `Новый вход в ваш аккаунт`;
|
||||||
|
|
||||||
|
const content = [
|
||||||
|
emailHeader({ icon: 'shield', title: 'Новый вход в аккаунт', subtitle: 'Уведомление безопасности' }),
|
||||||
|
emailGreeting(user.username),
|
||||||
|
emailParagraph('Зафиксирован новый вход в ваш аккаунт ospab.host:'),
|
||||||
|
emailInfoBox({
|
||||||
|
type: 'default',
|
||||||
|
items: [
|
||||||
|
{ label: 'Время', value: formattedTime },
|
||||||
|
{ label: 'IP адрес', value: ip || 'Неизвестен' },
|
||||||
|
{ label: 'Браузер', value: browser },
|
||||||
|
{ label: 'Операционная система', value: os },
|
||||||
|
...(location ? [{ label: 'Местоположение', value: location }] : []),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
emailSecurityNote('Если это были вы, можете проигнорировать это письмо. Если вы не входили в аккаунт, немедленно смените пароль и свяжитесь с поддержкой.'),
|
||||||
|
emailButton({ text: 'Проверить активные сессии', url: `${FRONTEND_URL}/dashboard/settings` }),
|
||||||
|
emailDivider(),
|
||||||
|
emailParagraph('Для вашей безопасности мы рекомендуем использовать сложные пароли и не передавать данные для входа третьим лицам.'),
|
||||||
|
emailSignature(),
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
const html = baseEmailTemplate({
|
||||||
|
title: subject,
|
||||||
|
preheader: `Новый вход в аккаунт ${formattedTime}`,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await sendEmail({ to: user.email, subject, html });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('Error sending new login email:', error);
|
||||||
|
return { status: 'error', message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Уведомление о регистрации (приветственное письмо)
|
||||||
|
*/
|
||||||
|
export async function sendWelcomeEmail(userId: number) {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
if (!user) return { status: 'error', message: 'User not found' };
|
||||||
|
|
||||||
|
const subject = `Добро пожаловать в ospab.host!`;
|
||||||
|
|
||||||
|
const content = [
|
||||||
|
emailHeader({ icon: 'success', title: 'Добро пожаловать!', subtitle: 'Регистрация успешна' }),
|
||||||
|
emailGreeting(user.username),
|
||||||
|
emailParagraph('Благодарим вас за регистрацию в ospab.host! Теперь вам доступны все возможности нашего облачного хранилища.'),
|
||||||
|
emailInfoBox({
|
||||||
|
type: 'success',
|
||||||
|
items: [
|
||||||
|
{ label: 'Ваш email', value: user.email },
|
||||||
|
{ label: 'Имя пользователя', value: user.username },
|
||||||
|
{ label: 'Дата регистрации', value: new Date().toLocaleDateString('ru-RU') },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
emailParagraph('Что вы можете сделать:'),
|
||||||
|
emailParagraph('• Создать S3-совместимое хранилище<br>• Загружать и управлять файлами<br>• Использовать API для интеграции'),
|
||||||
|
emailButton({ text: 'Начать работу', url: `${FRONTEND_URL}/dashboard` }),
|
||||||
|
emailSignature(),
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
const html = baseEmailTemplate({
|
||||||
|
title: subject,
|
||||||
|
preheader: 'Ваш аккаунт успешно создан',
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await sendEmail({ to: user.email, subject, html });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('Error sending welcome email:', error);
|
||||||
|
return { status: 'error', message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Уведомление о пополнении баланса
|
||||||
|
*/
|
||||||
|
export async function sendBalanceTopUpEmail(userId: number, amount: number, newBalance: number) {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
if (!user) return { status: 'error', message: 'User not found' };
|
||||||
|
|
||||||
|
const subject = `Баланс пополнен на ${amount} руб.`;
|
||||||
|
|
||||||
|
const content = [
|
||||||
|
emailHeader({ icon: 'payment', title: 'Баланс пополнен', subtitle: `+${amount}₽` }),
|
||||||
|
emailGreeting(user.username),
|
||||||
|
emailParagraph('Ваш баланс успешно пополнен.'),
|
||||||
|
emailInfoBox({
|
||||||
|
type: 'success',
|
||||||
|
items: [
|
||||||
|
{ label: 'Сумма пополнения', value: `${amount}₽` },
|
||||||
|
{ label: 'Текущий баланс', value: `${newBalance}₽` },
|
||||||
|
{ label: 'Дата', value: new Date().toLocaleString('ru-RU') },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
emailParagraph('Средства доступны для оплаты услуг.'),
|
||||||
|
emailButton({ text: 'Перейти в панель', url: `${FRONTEND_URL}/dashboard` }),
|
||||||
|
emailSignature(),
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
const html = baseEmailTemplate({
|
||||||
|
title: subject,
|
||||||
|
preheader: `Баланс пополнен на ${amount} руб.`,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await sendEmail({ to: user.email, subject, html });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('Error sending balance top-up email:', error);
|
||||||
|
return { status: 'error', message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
352
ospabhost/backend/src/modules/notification/email.templates.ts
Normal file
352
ospabhost/backend/src/modules/notification/email.templates.ts
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
/**
|
||||||
|
* Красивые HTML-шаблоны для email уведомлений
|
||||||
|
* Стиль основан на дизайне главной страницы ospab.host
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FRONTEND_URL = process.env.FRONTEND_URL || 'https://ospab.host';
|
||||||
|
|
||||||
|
// Основные цвета бренда
|
||||||
|
const COLORS = {
|
||||||
|
primary: '#4f46e5', // ospab-primary (indigo)
|
||||||
|
accent: '#6366f1', // ospab-accent
|
||||||
|
dark: '#1f2937', // gray-800
|
||||||
|
light: '#f9fafb', // gray-50
|
||||||
|
text: '#374151', // gray-700
|
||||||
|
textLight: '#6b7280', // gray-500
|
||||||
|
border: '#e5e7eb', // gray-200
|
||||||
|
success: '#10b981', // green-500
|
||||||
|
warning: '#f59e0b', // amber-500
|
||||||
|
danger: '#ef4444', // red-500
|
||||||
|
info: '#3b82f6', // blue-500
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Базовый шаблон письма
|
||||||
|
*/
|
||||||
|
export function baseEmailTemplate(options: {
|
||||||
|
title: string;
|
||||||
|
preheader?: string;
|
||||||
|
content: string;
|
||||||
|
footerText?: string;
|
||||||
|
}): string {
|
||||||
|
const { title, preheader, content, footerText } = options;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>${title}</title>
|
||||||
|
<!--[if mso]>
|
||||||
|
<noscript>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
</noscript>
|
||||||
|
<![endif]-->
|
||||||
|
<style>
|
||||||
|
body { margin: 0; padding: 0; background-color: ${COLORS.light}; }
|
||||||
|
table { border-collapse: collapse; }
|
||||||
|
img { border: 0; display: block; }
|
||||||
|
a { color: ${COLORS.primary}; text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
.button { display: inline-block; padding: 14px 28px; background-color: ${COLORS.primary}; color: #ffffff !important; border-radius: 8px; font-weight: 600; text-decoration: none; }
|
||||||
|
.button:hover { background-color: ${COLORS.accent}; }
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
.container { width: 100% !important; padding: 0 16px !important; }
|
||||||
|
.content { padding: 24px 20px !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: ${COLORS.light}; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
|
||||||
|
${preheader ? `<div style="display: none; max-height: 0; overflow: hidden;">${preheader}</div>` : ''}
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: ${COLORS.light};">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 40px 20px;">
|
||||||
|
<table role="presentation" class="container" width="600" cellpadding="0" cellspacing="0" style="max-width: 600px;">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-bottom: 32px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td style="background: linear-gradient(135deg, ${COLORS.primary} 0%, ${COLORS.accent} 100%); padding: 16px 32px; border-radius: 12px;">
|
||||||
|
<a href="${FRONTEND_URL}" style="color: #ffffff; font-size: 24px; font-weight: 700; text-decoration: none; letter-spacing: -0.5px;">
|
||||||
|
ospab.host
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Main Content Card -->
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 16px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);">
|
||||||
|
<tr>
|
||||||
|
<td class="content" style="padding: 40px;">
|
||||||
|
${content}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding-top: 32px; text-align: center;">
|
||||||
|
<p style="margin: 0 0 16px 0; color: ${COLORS.textLight}; font-size: 14px;">
|
||||||
|
${footerText || 'Это автоматическое письмо от ospab.host'}
|
||||||
|
</p>
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" style="margin: 0 auto;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 0 8px;">
|
||||||
|
<a href="${FRONTEND_URL}" style="color: ${COLORS.textLight}; font-size: 13px;">Сайт</a>
|
||||||
|
</td>
|
||||||
|
<td style="color: ${COLORS.border};">|</td>
|
||||||
|
<td style="padding: 0 8px;">
|
||||||
|
<a href="${FRONTEND_URL}/dashboard" style="color: ${COLORS.textLight}; font-size: 13px;">Панель управления</a>
|
||||||
|
</td>
|
||||||
|
<td style="color: ${COLORS.border};">|</td>
|
||||||
|
<td style="padding: 0 8px;">
|
||||||
|
<a href="${FRONTEND_URL}/dashboard/tickets" style="color: ${COLORS.textLight}; font-size: 13px;">Поддержка</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="margin: 24px 0 0 0; color: ${COLORS.border}; font-size: 12px;">
|
||||||
|
© ${new Date().getFullYear()} ospab.host. Все права защищены.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Заголовок с иконкой
|
||||||
|
*/
|
||||||
|
export function emailHeader(options: {
|
||||||
|
icon: 'shield' | 'server' | 'storage' | 'payment' | 'alert' | 'success' | 'info';
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
}): string {
|
||||||
|
const { icon, title, subtitle } = options;
|
||||||
|
|
||||||
|
const iconColors: Record<string, string> = {
|
||||||
|
shield: COLORS.info,
|
||||||
|
server: COLORS.primary,
|
||||||
|
storage: COLORS.accent,
|
||||||
|
payment: COLORS.success,
|
||||||
|
alert: COLORS.warning,
|
||||||
|
success: COLORS.success,
|
||||||
|
info: COLORS.info,
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconSvgs: Record<string, string> = {
|
||||||
|
shield: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
|
||||||
|
server: '<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>',
|
||||||
|
storage: '<path d="M22 12H2"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
|
||||||
|
payment: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>',
|
||||||
|
alert: '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||||
|
success: '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>',
|
||||||
|
info: '<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>',
|
||||||
|
};
|
||||||
|
|
||||||
|
const color = iconColors[icon] || COLORS.primary;
|
||||||
|
const svg = iconSvgs[icon] || iconSvgs.info;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-bottom: 24px;">
|
||||||
|
<div style="width: 64px; height: 64px; background-color: ${color}15; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
${svg}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<h1 style="margin: 0 0 8px 0; font-size: 24px; font-weight: 700; color: ${COLORS.dark};">
|
||||||
|
${title}
|
||||||
|
</h1>
|
||||||
|
${subtitle ? `<p style="margin: 0; font-size: 16px; color: ${COLORS.textLight};">${subtitle}</p>` : ''}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Приветственный блок
|
||||||
|
*/
|
||||||
|
export function emailGreeting(username?: string | null): string {
|
||||||
|
return `
|
||||||
|
<p style="margin: 24px 0 0 0; font-size: 16px; color: ${COLORS.text}; line-height: 1.6;">
|
||||||
|
Здравствуйте${username ? `, <strong>${username}</strong>` : ''}!
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Текстовый параграф
|
||||||
|
*/
|
||||||
|
export function emailParagraph(text: string): string {
|
||||||
|
return `
|
||||||
|
<p style="margin: 16px 0 0 0; font-size: 16px; color: ${COLORS.text}; line-height: 1.6;">
|
||||||
|
${text}
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Информационный блок (карточка с данными)
|
||||||
|
*/
|
||||||
|
export function emailInfoBox(options: {
|
||||||
|
items: Array<{ label: string; value: string }>;
|
||||||
|
type?: 'default' | 'warning' | 'success' | 'danger';
|
||||||
|
}): string {
|
||||||
|
const { items, type = 'default' } = options;
|
||||||
|
|
||||||
|
const bgColors: Record<string, string> = {
|
||||||
|
default: '#f3f4f6',
|
||||||
|
warning: '#fef3c7',
|
||||||
|
success: '#d1fae5',
|
||||||
|
danger: '#fee2e2',
|
||||||
|
};
|
||||||
|
|
||||||
|
const borderColors: Record<string, string> = {
|
||||||
|
default: COLORS.border,
|
||||||
|
warning: '#fcd34d',
|
||||||
|
success: '#6ee7b7',
|
||||||
|
danger: '#fca5a5',
|
||||||
|
};
|
||||||
|
|
||||||
|
return `
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 24px 0 0 0; background-color: ${bgColors[type]}; border: 1px solid ${borderColors[type]}; border-radius: 12px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 20px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
${items.map((item, index) => `
|
||||||
|
<tr>
|
||||||
|
<td style="padding: ${index > 0 ? '12px' : '0'} 0 0 0;">
|
||||||
|
<span style="font-size: 13px; color: ${COLORS.textLight}; text-transform: uppercase; letter-spacing: 0.5px;">${item.label}</span>
|
||||||
|
<p style="margin: 4px 0 0 0; font-size: 15px; font-weight: 600; color: ${COLORS.dark};">${item.value}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Кнопка действия
|
||||||
|
*/
|
||||||
|
export function emailButton(options: {
|
||||||
|
text: string;
|
||||||
|
url: string;
|
||||||
|
type?: 'primary' | 'secondary';
|
||||||
|
}): string {
|
||||||
|
const { text, url, type = 'primary' } = options;
|
||||||
|
|
||||||
|
const bgColor = type === 'primary' ? COLORS.primary : 'transparent';
|
||||||
|
const textColor = type === 'primary' ? '#ffffff' : COLORS.primary;
|
||||||
|
const border = type === 'primary' ? 'none' : `2px solid ${COLORS.primary}`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 32px 0 0 0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<a href="${url}" style="display: inline-block; padding: 14px 32px; background-color: ${bgColor}; color: ${textColor} !important; border: ${border}; border-radius: 8px; font-size: 16px; font-weight: 600; text-decoration: none;">
|
||||||
|
${text}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 16px 0 0 0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<p style="margin: 0; font-size: 12px; color: ${COLORS.textLight};">
|
||||||
|
Если кнопка не работает, скопируйте ссылку:<br>
|
||||||
|
<a href="${url}" style="color: ${COLORS.primary}; word-break: break-all;">${url}</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Разделитель
|
||||||
|
*/
|
||||||
|
export function emailDivider(): string {
|
||||||
|
return `
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 24px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="border-top: 1px solid ${COLORS.border};"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подпись
|
||||||
|
*/
|
||||||
|
export function emailSignature(): string {
|
||||||
|
return `
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 32px 0 0 0;">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<p style="margin: 0; font-size: 15px; color: ${COLORS.text};">
|
||||||
|
С уважением,<br>
|
||||||
|
<strong>Команда ospab.host</strong>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Предупреждение о безопасности
|
||||||
|
*/
|
||||||
|
export function emailSecurityNote(text: string): string {
|
||||||
|
return `
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 24px 0 0 0; background-color: #fef3c7; border: 1px solid #fcd34d; border-radius: 8px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 16px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td style="vertical-align: top; padding-right: 12px;">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="${COLORS.warning}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||||
|
</svg>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<p style="margin: 0; font-size: 14px; color: ${COLORS.dark};">
|
||||||
|
${text}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -80,7 +80,8 @@ const ensureNotificationSettings = async (userId: number) => {
|
|||||||
// Получить все уведомления пользователя с пагинацией
|
// Получить все уведомления пользователя с пагинацией
|
||||||
export const getNotifications = async (req: Request, res: Response) => {
|
export const getNotifications = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.user!.id;
|
const userId = req.user?.id;
|
||||||
|
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
|
||||||
const { page = '1', limit = '20', filter = 'all' } = req.query;
|
const { page = '1', limit = '20', filter = 'all' } = req.query;
|
||||||
|
|
||||||
const skip = (parseInt(page as string) - 1) * parseInt(limit as string);
|
const skip = (parseInt(page as string) - 1) * parseInt(limit as string);
|
||||||
@@ -121,7 +122,8 @@ export const getNotifications = async (req: Request, res: Response) => {
|
|||||||
// Получить количество непрочитанных уведомлений
|
// Получить количество непрочитанных уведомлений
|
||||||
export const getUnreadCount = async (req: Request, res: Response) => {
|
export const getUnreadCount = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.user!.id;
|
const userId = req.user?.id;
|
||||||
|
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
|
||||||
|
|
||||||
const count = await prisma.notification.count({
|
const count = await prisma.notification.count({
|
||||||
where: {
|
where: {
|
||||||
@@ -140,7 +142,8 @@ export const getUnreadCount = async (req: Request, res: Response) => {
|
|||||||
// Пометить уведомление как прочитанное
|
// Пометить уведомление как прочитанное
|
||||||
export const markAsRead = async (req: Request, res: Response) => {
|
export const markAsRead = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.user!.id;
|
const userId = req.user?.id;
|
||||||
|
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
const notification = await prisma.notification.findFirst({
|
const notification = await prisma.notification.findFirst({
|
||||||
@@ -179,7 +182,8 @@ export const markAsRead = async (req: Request, res: Response) => {
|
|||||||
// Пометить все уведомления как прочитанные
|
// Пометить все уведомления как прочитанные
|
||||||
export const markAllAsRead = async (req: Request, res: Response) => {
|
export const markAllAsRead = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.user!.id;
|
const userId = req.user?.id;
|
||||||
|
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
|
||||||
|
|
||||||
await prisma.notification.updateMany({
|
await prisma.notification.updateMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -199,7 +203,8 @@ export const markAllAsRead = async (req: Request, res: Response) => {
|
|||||||
// Удалить уведомление
|
// Удалить уведомление
|
||||||
export const deleteNotification = async (req: Request, res: Response) => {
|
export const deleteNotification = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.user!.id;
|
const userId = req.user?.id;
|
||||||
|
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
const notification = await prisma.notification.findFirst({
|
const notification = await prisma.notification.findFirst({
|
||||||
@@ -237,7 +242,8 @@ export const deleteNotification = async (req: Request, res: Response) => {
|
|||||||
// Удалить все прочитанные уведомления
|
// Удалить все прочитанные уведомления
|
||||||
export const deleteAllRead = async (req: Request, res: Response) => {
|
export const deleteAllRead = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.user!.id;
|
const userId = req.user?.id;
|
||||||
|
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
|
||||||
|
|
||||||
await prisma.notification.deleteMany({
|
await prisma.notification.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -369,7 +375,8 @@ export const getVapidKey = async (req: Request, res: Response) => {
|
|||||||
// Подписаться на Push-уведомления
|
// Подписаться на Push-уведомления
|
||||||
export const subscribe = async (req: Request, res: Response) => {
|
export const subscribe = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.user!.id;
|
const userId = req.user?.id;
|
||||||
|
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
|
||||||
const { subscription } = req.body;
|
const { subscription } = req.body;
|
||||||
const userAgent = req.headers['user-agent'];
|
const userAgent = req.headers['user-agent'];
|
||||||
|
|
||||||
@@ -389,7 +396,8 @@ export const subscribe = async (req: Request, res: Response) => {
|
|||||||
// Отписаться от Push-уведомлений
|
// Отписаться от Push-уведомлений
|
||||||
export const unsubscribe = async (req: Request, res: Response) => {
|
export const unsubscribe = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.user!.id;
|
const userId = req.user?.id;
|
||||||
|
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
|
||||||
const { endpoint } = req.body;
|
const { endpoint } = req.body;
|
||||||
|
|
||||||
if (!endpoint) {
|
if (!endpoint) {
|
||||||
@@ -408,7 +416,8 @@ export const unsubscribe = async (req: Request, res: Response) => {
|
|||||||
// Тестовая отправка Push-уведомления (только для админов)
|
// Тестовая отправка Push-уведомления (только для админов)
|
||||||
export const testPushNotification = async (req: Request, res: Response) => {
|
export const testPushNotification = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.user!.id;
|
const userId = req.user?.id;
|
||||||
|
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
|
||||||
const user = req.user!;
|
const user = req.user!;
|
||||||
|
|
||||||
logger.log('[TEST PUSH] Запрос от пользователя:', { userId, username: user.username });
|
logger.log('[TEST PUSH] Запрос от пользователя:', { userId, username: user.username });
|
||||||
@@ -532,8 +541,9 @@ export const testPushNotification = async (req: Request, res: Response) => {
|
|||||||
// Тестовая отправка Email-уведомления (только для админов)
|
// Тестовая отправка Email-уведомления (только для админов)
|
||||||
export const testEmailNotification = async (req: Request, res: Response) => {
|
export const testEmailNotification = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.user!.id;
|
const user = req.user;
|
||||||
const user = req.user!;
|
if (!user) return res.status(401).json({ success: false, message: 'Не авторизован' });
|
||||||
|
const userId = user.id as number;
|
||||||
|
|
||||||
if (!user.isAdmin) {
|
if (!user.isAdmin) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
|
|||||||
@@ -67,13 +67,13 @@ class PaymentService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { bucket: updatedBucket, balanceBefore, balanceAfter } = await prisma.$transaction(async (tx) => {
|
const { updatedBucket, balanceBefore, balanceAfter } = await prisma.$transaction(async (tx) => {
|
||||||
const user = await tx.user.findUnique({ where: { id: bucket.userId } });
|
const user = await tx.user.findUnique({ where: { id: bucket.userId } });
|
||||||
if (!user) throw new Error('Пользователь не найден');
|
if (!user) throw new Error('Пользователь не найден');
|
||||||
|
|
||||||
if (user.balance < bucket.monthlyPrice) {
|
if (user.balance < bucket.monthlyPrice) {
|
||||||
// Баланс мог измениться между выборкой и транзакцией
|
// Баланс мог измениться между выборкой и транзакцией
|
||||||
return { bucket, balanceBefore: user.balance, balanceAfter: user.balance };
|
return { updatedBucket: null, balanceBefore: user.balance, balanceAfter: user.balance };
|
||||||
}
|
}
|
||||||
|
|
||||||
const newBalance = user.balance - bucket.monthlyPrice;
|
const newBalance = user.balance - bucket.monthlyPrice;
|
||||||
@@ -106,10 +106,10 @@ class PaymentService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { bucket: updated, balanceBefore: user.balance, balanceAfter: newBalance };
|
return { updatedBucket: updated, balanceBefore: user.balance, balanceAfter: newBalance };
|
||||||
});
|
});
|
||||||
|
|
||||||
if (balanceBefore === balanceAfter) {
|
if (balanceBefore === balanceAfter || !updatedBucket) {
|
||||||
// Значит баланс поменялся внутри транзакции, пересоздадим попытку в следующий цикл
|
// Значит баланс поменялся внутри транзакции, пересоздадим попытку в следующий цикл
|
||||||
await this.handleInsufficientFunds(bucket, now);
|
await this.handleInsufficientFunds(bucket, now);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -30,6 +30,18 @@ export function buildPhysicalBucketName(userId: number, logicalName: string): st
|
|||||||
return `${prefix}-${userId}-${logicalName}`.toLowerCase();
|
return `${prefix}-${userId}-${logicalName}`.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isMinioAuthError(err: unknown): boolean {
|
||||||
|
if (!err || typeof err !== 'object') return false;
|
||||||
|
const message = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
||||||
|
return message.includes('invalidaccesskeyid') || message.includes('accesskeyid') || message.includes('invalid access key') || message.includes('signaturedoesnotmatch') || message.includes('signature does not match');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMinioNoSuchBucketError(err: unknown): boolean {
|
||||||
|
if (!err || typeof err !== 'object') return false;
|
||||||
|
const message = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
||||||
|
return message.includes('nosuchbucket') || message.includes('notfound') || message.includes('no such bucket');
|
||||||
|
}
|
||||||
|
|
||||||
export async function ensureBucketExists(bucketName: string, region: string): Promise<void> {
|
export async function ensureBucketExists(bucketName: string, region: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const exists = await minioClient.bucketExists(bucketName);
|
const exists = await minioClient.bucketExists(bucketName);
|
||||||
@@ -37,6 +49,17 @@ export async function ensureBucketExists(bucketName: string, region: string): Pr
|
|||||||
await minioClient.makeBucket(bucketName, region);
|
await minioClient.makeBucket(bucketName, region);
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
if (isMinioAuthError(err)) {
|
||||||
|
// Provide a more actionable error message for auth issues
|
||||||
|
const e = new Error(`MinIO authentication failed for endpoint ${MINIO_ENDPOINT}:${MINIO_PORT}. Check MINIO_ACCESS_KEY/MINIO_SECRET_KEY environment variables.`);
|
||||||
|
(e as any).code = 'MINIO_AUTH_ERROR';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
if (isMinioNoSuchBucketError(err)) {
|
||||||
|
const e = new Error(`MinIO bucket error: bucket ${bucketName} not found or inaccessible.`);
|
||||||
|
(e as any).code = 'MINIO_BUCKET_NOT_FOUND';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ import {
|
|||||||
listStoragePlans,
|
listStoragePlans,
|
||||||
createCheckoutSession,
|
createCheckoutSession,
|
||||||
getCheckoutSession,
|
getCheckoutSession,
|
||||||
|
applyPromoToCheckoutSession,
|
||||||
markCheckoutSessionConsumed,
|
markCheckoutSessionConsumed,
|
||||||
listStorageRegions,
|
listStorageRegions,
|
||||||
listStorageClasses,
|
listStorageClasses,
|
||||||
getStorageStatus,
|
getStorageStatus,
|
||||||
generateConsoleCredentials
|
|
||||||
} from './storage.service';
|
} from './storage.service';
|
||||||
import { authMiddleware, optionalAuthMiddleware } from '../auth/auth.middleware';
|
import { authMiddleware, optionalAuthMiddleware } from '../auth/auth.middleware';
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ router.get('/status', async (_req, res) => {
|
|||||||
|
|
||||||
router.post('/checkout', optionalAuthMiddleware, async (req, res) => {
|
router.post('/checkout', optionalAuthMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req as any).user?.id ?? null;
|
const userId = req.user?.id ?? null;
|
||||||
const { planCode, planId, customGb } = req.body ?? {};
|
const { planCode, planId, customGb } = req.body ?? {};
|
||||||
|
|
||||||
const numericPlanId = typeof planId === 'number'
|
const numericPlanId = typeof planId === 'number'
|
||||||
@@ -138,7 +138,7 @@ router.use(authMiddleware);
|
|||||||
|
|
||||||
router.get('/cart/:id', async (req, res) => {
|
router.get('/cart/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req as any).user?.id;
|
const userId = req.user?.id;
|
||||||
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
|
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
|
||||||
const cartId = req.params.id;
|
const cartId = req.params.id;
|
||||||
const result = await getCheckoutSession(cartId, userId);
|
const result = await getCheckoutSession(cartId, userId);
|
||||||
@@ -149,10 +149,26 @@ router.get('/cart/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Apply promo code to a cart
|
||||||
|
router.post('/cart/:id/apply-promo', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const cartId = req.params.id;
|
||||||
|
const { promoCode } = req.body ?? {};
|
||||||
|
const userId = req.user?.id;
|
||||||
|
if (!promoCode || typeof promoCode !== 'string') return res.status(400).json({ error: 'promoCode required' });
|
||||||
|
const result = await applyPromoToCheckoutSession(cartId, promoCode.trim(), userId ?? null);
|
||||||
|
return res.json({ success: true, cart: result });
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const message = e instanceof Error ? e.message : 'Не удалось применить промокод';
|
||||||
|
return res.status(400).json({ error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Создание бакета
|
// Создание бакета
|
||||||
router.post('/buckets', async (req, res) => {
|
router.post('/buckets', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req as any).user?.id;
|
const userId = req.user?.id;
|
||||||
|
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
|
||||||
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
|
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
|
||||||
|
|
||||||
const { name, cartId, region, storageClass, public: isPublic, versioning } = req.body;
|
const { name, cartId, region, storageClass, public: isPublic, versioning } = req.body;
|
||||||
@@ -175,6 +191,7 @@ router.post('/buckets', async (req, res) => {
|
|||||||
storageClass: storageClass || 'standard',
|
storageClass: storageClass || 'standard',
|
||||||
public: !!isPublic,
|
public: !!isPublic,
|
||||||
versioning: !!versioning
|
versioning: !!versioning
|
||||||
|
, cartId
|
||||||
});
|
});
|
||||||
|
|
||||||
await markCheckoutSessionConsumed(cartId);
|
await markCheckoutSessionConsumed(cartId);
|
||||||
@@ -190,7 +207,8 @@ router.post('/buckets', async (req, res) => {
|
|||||||
// Список бакетов пользователя
|
// Список бакетов пользователя
|
||||||
router.get('/buckets', async (req, res) => {
|
router.get('/buckets', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req as any).user?.id;
|
const userId = req.user?.id;
|
||||||
|
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
|
||||||
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
|
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
|
||||||
const buckets = await listBuckets(userId);
|
const buckets = await listBuckets(userId);
|
||||||
return res.json({ buckets });
|
return res.json({ buckets });
|
||||||
@@ -199,35 +217,13 @@ router.get('/buckets', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/buckets/:id/console-credentials', async (req, res) => {
|
// Роут console-credentials удалён — используйте access keys
|
||||||
try {
|
|
||||||
const userId = (req as any).user?.id;
|
|
||||||
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
|
|
||||||
const id = Number(req.params.id);
|
|
||||||
if (!Number.isInteger(id) || id <= 0) {
|
|
||||||
return res.status(400).json({ error: 'Некорректный идентификатор бакета' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const credentials = await generateConsoleCredentials(userId, id);
|
|
||||||
return res.json({ credentials });
|
|
||||||
} catch (e: unknown) {
|
|
||||||
let message = 'Не удалось сгенерировать данные входа';
|
|
||||||
let statusCode = 400;
|
|
||||||
if (e instanceof Error) {
|
|
||||||
message = e.message;
|
|
||||||
// Check for rate limit error
|
|
||||||
if ((e as any).status === 429) {
|
|
||||||
statusCode = 429;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res.status(statusCode).json({ error: message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Детали одного бакета
|
// Детали одного бакета
|
||||||
router.get('/buckets/:id', async (req, res) => {
|
router.get('/buckets/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req as any).user?.id;
|
const userId = req.user?.id;
|
||||||
|
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
|
||||||
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
|
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const bucket = await getBucket(userId, id);
|
const bucket = await getBucket(userId, id);
|
||||||
@@ -242,7 +238,8 @@ router.get('/buckets/:id', async (req, res) => {
|
|||||||
// Обновление настроек бакета
|
// Обновление настроек бакета
|
||||||
router.patch('/buckets/:id', async (req, res) => {
|
router.patch('/buckets/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req as any).user?.id;
|
const userId = req.user?.id;
|
||||||
|
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
|
||||||
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
|
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const bucket = await updateBucketSettings(userId, id, req.body ?? {});
|
const bucket = await updateBucketSettings(userId, id, req.body ?? {});
|
||||||
@@ -257,7 +254,8 @@ router.patch('/buckets/:id', async (req, res) => {
|
|||||||
// Удаление бакета
|
// Удаление бакета
|
||||||
router.delete('/buckets/:id', async (req, res) => {
|
router.delete('/buckets/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req as any).user?.id;
|
const userId = req.user?.id;
|
||||||
|
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
|
||||||
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
|
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const force = req.query.force === 'true';
|
const force = req.query.force === 'true';
|
||||||
@@ -273,7 +271,8 @@ router.delete('/buckets/:id', async (req, res) => {
|
|||||||
// Список объектов в бакете
|
// Список объектов в бакете
|
||||||
router.get('/buckets/:id/objects', async (req, res) => {
|
router.get('/buckets/:id/objects', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req as any).user?.id;
|
const userId = req.user?.id;
|
||||||
|
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
|
||||||
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
|
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const { prefix, cursor, limit } = req.query;
|
const { prefix, cursor, limit } = req.query;
|
||||||
@@ -323,34 +322,72 @@ router.post('/buckets/:id/objects/download-from-uri', async (req, res) => {
|
|||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const { url } = req.body ?? {};
|
const { url } = req.body ?? {};
|
||||||
|
|
||||||
if (!url) return res.status(400).json({ error: 'Не указан URL' });
|
console.log(`[Storage URI Download] Начало загрузки - userId: ${userId}, bucketId: ${id}, url: ${url}`);
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
console.log('[Storage URI Download] Ошибка: URL не указан');
|
||||||
|
return res.status(400).json({ error: 'Не указан URL' });
|
||||||
|
}
|
||||||
|
|
||||||
// Проверяем что пользователь имеет доступ к бакету
|
// Проверяем что пользователь имеет доступ к бакету
|
||||||
|
console.log('[Storage URI Download] Проверка доступа к бакету...');
|
||||||
await getBucket(userId, id); // Проверка доступа
|
await getBucket(userId, id); // Проверка доступа
|
||||||
|
console.log('[Storage URI Download] Доступ к бакету подтверждён');
|
||||||
|
|
||||||
// Загружаем файл с URL с увеличенным timeout
|
// Загружаем файл с URL с увеличенным timeout
|
||||||
|
console.log(`[Storage URI Download] Загрузка файла с ${url}...`);
|
||||||
const response = await axios.get(url, {
|
const response = await axios.get(url, {
|
||||||
responseType: 'arraybuffer',
|
responseType: 'arraybuffer',
|
||||||
timeout: 120000, // 120 seconds (2 minutes)
|
timeout: 120000, // 120 seconds (2 minutes)
|
||||||
maxContentLength: 5 * 1024 * 1024 * 1024, // 5GB max
|
maxContentLength: 5 * 1024 * 1024 * 1024, // 5GB max
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const mimeType = response.headers['content-type'] || 'application/octet-stream';
|
const mimeType = response.headers['content-type'] || 'application/octet-stream';
|
||||||
const buffer = response.data;
|
const buffer = response.data;
|
||||||
|
const bufferSize = Buffer.isBuffer(buffer) ? buffer.length : (buffer as ArrayBuffer).byteLength;
|
||||||
|
|
||||||
|
console.log(`[Storage URI Download] Файл загружен успешно - размер: ${bufferSize} байт, mimeType: ${mimeType}`);
|
||||||
|
|
||||||
|
// Конвертируем в base64
|
||||||
|
const base64Data = Buffer.isBuffer(buffer) ? buffer.toString('base64') : Buffer.from(buffer).toString('base64');
|
||||||
|
console.log(`[Storage URI Download] Base64 длина: ${base64Data.length} символов`);
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
blob: buffer.toString('base64'),
|
blob: base64Data,
|
||||||
mimeType,
|
mimeType,
|
||||||
});
|
});
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
let message = 'Ошибка загрузки файла по URI';
|
let message = 'Ошибка загрузки файла по URI';
|
||||||
if (e instanceof Error) {
|
console.error('[Storage URI Download] Ошибка:', e);
|
||||||
if (e.message.includes('timeout')) {
|
|
||||||
|
if (axios.isAxiosError(e)) {
|
||||||
|
console.error('[Storage URI Download] Axios ошибка:', {
|
||||||
|
status: e.response?.status,
|
||||||
|
statusText: e.response?.statusText,
|
||||||
|
headers: e.response?.headers,
|
||||||
|
code: e.code,
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (e.code === 'ECONNABORTED' || e.message.includes('timeout')) {
|
||||||
message = 'Превышено время ожидания при загрузке файла. Попробуйте позже.';
|
message = 'Превышено время ожидания при загрузке файла. Попробуйте позже.';
|
||||||
|
} else if (e.response?.status === 403) {
|
||||||
|
message = 'Доступ к файлу запрещён (403). Проверьте, что ссылка публичная.';
|
||||||
|
} else if (e.response?.status === 404) {
|
||||||
|
message = 'Файл не найден (404). Проверьте правильность URL.';
|
||||||
|
} else if (e.response?.status) {
|
||||||
|
message = `Ошибка загрузки (${e.response.status}): ${e.response.statusText || e.message}`;
|
||||||
} else {
|
} else {
|
||||||
|
message = `Ошибка соединения: ${e.message}`;
|
||||||
|
}
|
||||||
|
} else if (e instanceof Error) {
|
||||||
message = e.message;
|
message = e.message;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
console.error('[Storage URI Download] Возвращаем ошибку клиенту:', message);
|
||||||
return res.status(400).json({ error: message });
|
return res.status(400).json({ error: message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,10 +18,76 @@ const MINIO_USE_SSL = process.env.MINIO_USE_SSL === 'true';
|
|||||||
const MINIO_ADMIN_URL = `http${MINIO_USE_SSL ? 's' : ''}://${MINIO_ENDPOINT}:${MINIO_PORT}`;
|
const MINIO_ADMIN_URL = `http${MINIO_USE_SSL ? 's' : ''}://${MINIO_ENDPOINT}:${MINIO_PORT}`;
|
||||||
|
|
||||||
// For mc CLI calls
|
// For mc CLI calls
|
||||||
const MINIO_ALIAS = 'minio';
|
// MINIO_MC_ALIAS - если указан, используем существующий alias (уже настроенный через mc alias set)
|
||||||
|
// Если не указан, создаём свой alias 'minio-ospab'
|
||||||
|
const MINIO_ALIAS = process.env.MINIO_MC_ALIAS || 'minio-ospab';
|
||||||
|
const MINIO_USE_EXISTING_ALIAS = Boolean(process.env.MINIO_MC_ALIAS); // Не перенастраивать, если указан явно
|
||||||
const MINIO_MC_ENABLED = process.env.MINIO_MC_ENABLED !== 'false'; // Enable by default
|
const MINIO_MC_ENABLED = process.env.MINIO_MC_ENABLED !== 'false'; // Enable by default
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
// Track if alias was set up in this process
|
||||||
|
let minioAliasConfigured = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape special characters for shell commands
|
||||||
|
*/
|
||||||
|
function escapeShellArg(arg: string): string {
|
||||||
|
// For Windows/PowerShell and Unix shells, use single quotes and escape single quotes inside
|
||||||
|
// But mc on Windows works better with double quotes, so we escape special chars
|
||||||
|
return arg
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/"/g, '\\"')
|
||||||
|
.replace(/\$/g, '\\$')
|
||||||
|
.replace(/`/g, '\\`')
|
||||||
|
.replace(/!/g, '\\!');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure mc alias is configured with correct credentials
|
||||||
|
* If MINIO_MC_ALIAS is set in env, uses existing alias without reconfiguration
|
||||||
|
* Otherwise creates/updates alias with credentials from env
|
||||||
|
*/
|
||||||
|
async function ensureMinioAlias(): Promise<void> {
|
||||||
|
if (minioAliasConfigured) {
|
||||||
|
return; // Already configured in this process
|
||||||
|
}
|
||||||
|
|
||||||
|
// If using existing alias from env, just mark as configured and verify it works
|
||||||
|
if (MINIO_USE_EXISTING_ALIAS) {
|
||||||
|
try {
|
||||||
|
// Quick check that alias exists and works
|
||||||
|
await execAsync(`mc admin info ${MINIO_ALIAS}`, { timeout: 10000 });
|
||||||
|
console.info(`[MinIO Admin] Using existing alias: ${MINIO_ALIAS}`);
|
||||||
|
minioAliasConfigured = true;
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[MinIO Admin] Existing alias "${MINIO_ALIAS}" not working:`, (error as Error).message);
|
||||||
|
throw new Error(`mc alias "${MINIO_ALIAS}" не настроен или не работает. Настройте вручную: mc alias set ${MINIO_ALIAS} <url> <access> <secret>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new alias with credentials from env
|
||||||
|
try {
|
||||||
|
// Remove existing alias first (ignore errors if it doesn't exist)
|
||||||
|
try {
|
||||||
|
await execAsync(`mc alias rm ${MINIO_ALIAS}`, { timeout: 5000 });
|
||||||
|
} catch {
|
||||||
|
// Ignore - alias might not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up fresh alias with current credentials - escape special characters
|
||||||
|
const escapedAccessKey = escapeShellArg(MINIO_ACCESS_KEY);
|
||||||
|
const escapedSecretKey = escapeShellArg(MINIO_SECRET_KEY);
|
||||||
|
const setupAliasCmd = `mc alias set ${MINIO_ALIAS} "${MINIO_ADMIN_URL}" "${escapedAccessKey}" "${escapedSecretKey}" --api S3v4`;
|
||||||
|
const { stdout, stderr } = await execAsync(setupAliasCmd, { timeout: 10000 });
|
||||||
|
console.info(`[MinIO Admin] Alias configured:`, stdout.trim() || stderr.trim() || 'OK');
|
||||||
|
minioAliasConfigured = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MinIO Admin] Failed to configure alias:', (error as Error).message);
|
||||||
|
throw new Error(`Не удалось настроить подключение к MinIO: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const bucketIncludeBase = {
|
const bucketIncludeBase = {
|
||||||
storagePlan: true,
|
storagePlan: true,
|
||||||
regionConfig: true,
|
regionConfig: true,
|
||||||
@@ -39,6 +105,7 @@ interface CreateBucketInput {
|
|||||||
storageClass: string;
|
storageClass: string;
|
||||||
public: boolean;
|
public: boolean;
|
||||||
versioning: boolean;
|
versioning: boolean;
|
||||||
|
cartId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateBucketInput {
|
interface UpdateBucketInput {
|
||||||
@@ -161,6 +228,15 @@ async function ensureConsoleCredentialSupport(client: any = prisma): Promise<boo
|
|||||||
consoleCredentialSupport = true;
|
consoleCredentialSupport = true;
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// If the error is a MinIO authentication error or bucket not found, surface a clear message and skip cleanup
|
||||||
|
if ((error as any)?.code === 'MINIO_AUTH_ERROR') {
|
||||||
|
console.error('[Storage] MinIO authentication error while creating bucket — check MINIO_ACCESS_KEY/MINIO_SECRET_KEY');
|
||||||
|
throw new Error('MinIO authentication failed. Пожалуйста, проверьте настройки MINIO_ACCESS_KEY и MINIO_SECRET_KEY.');
|
||||||
|
}
|
||||||
|
if ((error as any)?.code === 'MINIO_BUCKET_NOT_FOUND') {
|
||||||
|
console.warn('[Storage] MinIO reports bucket inaccessible or not found during create; skipping cleanup');
|
||||||
|
throw new Error('MinIO bucket not found or inaccessible. Проверьте доступность MinIO и права доступа.');
|
||||||
|
}
|
||||||
if (isConsoleCredentialError(error)) {
|
if (isConsoleCredentialError(error)) {
|
||||||
consoleCredentialSupport = false;
|
consoleCredentialSupport = false;
|
||||||
logConsoleWarning(error);
|
logConsoleWarning(error);
|
||||||
@@ -242,6 +318,8 @@ type CheckoutSessionRecord = {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
expiresAt: Date;
|
expiresAt: Date;
|
||||||
consumedAt?: Date | null;
|
consumedAt?: Date | null;
|
||||||
|
promoCodeId?: number | null;
|
||||||
|
promoDiscount?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function addDays(date: Date, days: number): Date {
|
function addDays(date: Date, days: number): Date {
|
||||||
@@ -307,20 +385,8 @@ async function createMinioUser(username: string, password: string): Promise<void
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Setup mc alias with explicit S3v4 signature
|
// Setup mc alias - remove first to ensure fresh credentials
|
||||||
// The key is to add the --api S3v4 flag
|
await ensureMinioAlias();
|
||||||
const setupAliasCmd = `mc alias set ${MINIO_ALIAS} "${MINIO_ADMIN_URL}" "${MINIO_ACCESS_KEY}" "${MINIO_SECRET_KEY}" --api S3v4`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { stdout, stderr } = await execAsync(setupAliasCmd, { timeout: 5000 });
|
|
||||||
console.info(`[MinIO Admin] Alias setup:`, stdout.trim() || stderr.trim());
|
|
||||||
} catch (err: unknown) {
|
|
||||||
// Alias might already exist, that's okay
|
|
||||||
const errorMsg = (err as Error).message;
|
|
||||||
if (!errorMsg.includes('exists')) {
|
|
||||||
console.warn('[MinIO Admin] Warning setting up alias:', errorMsg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create or update user
|
// Create or update user
|
||||||
const createUserCmd = `mc admin user add ${MINIO_ALIAS} "${username}" "${password}"`;
|
const createUserCmd = `mc admin user add ${MINIO_ALIAS} "${username}" "${password}"`;
|
||||||
@@ -360,6 +426,110 @@ async function createMinioUser(username: string, password: string): Promise<void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a service account (access key) in MinIO with policy restricted to a specific bucket
|
||||||
|
* Uses mc admin user add + policy assignment
|
||||||
|
*/
|
||||||
|
async function createMinioServiceAccount(accessKey: string, secretKey: string, bucketName: string): Promise<void> {
|
||||||
|
if (!MINIO_MC_ENABLED) {
|
||||||
|
console.warn(`[MinIO Admin] mc CLI disabled, skipping service account creation for ${accessKey}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Setup mc alias - ensure fresh credentials
|
||||||
|
await ensureMinioAlias();
|
||||||
|
|
||||||
|
// Create user with access key and secret key
|
||||||
|
const createUserCmd = `mc admin user add ${MINIO_ALIAS} "${accessKey}" "${secretKey}"`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout, stderr } = await execAsync(createUserCmd, { timeout: 10000 });
|
||||||
|
console.info(`[MinIO Admin] Service account ${accessKey} created:`, stdout.trim() || stderr.trim());
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMsg = (error as Record<string, any>)?.stderr || (error as Error)?.message || '';
|
||||||
|
if (!errorMsg.includes('already exists') && !errorMsg.includes('exists')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
console.warn(`[MinIO Admin] User ${accessKey} already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create bucket-specific policy JSON
|
||||||
|
const policyName = `policy-${bucketName}`;
|
||||||
|
const policyJson = JSON.stringify({
|
||||||
|
Version: '2012-10-17',
|
||||||
|
Statement: [
|
||||||
|
{
|
||||||
|
Effect: 'Allow',
|
||||||
|
Action: [
|
||||||
|
's3:GetBucketLocation',
|
||||||
|
's3:ListBucket',
|
||||||
|
's3:ListBucketMultipartUploads',
|
||||||
|
],
|
||||||
|
Resource: [`arn:aws:s3:::${bucketName}`],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Effect: 'Allow',
|
||||||
|
Action: [
|
||||||
|
's3:GetObject',
|
||||||
|
's3:PutObject',
|
||||||
|
's3:DeleteObject',
|
||||||
|
's3:ListMultipartUploadParts',
|
||||||
|
's3:AbortMultipartUpload',
|
||||||
|
],
|
||||||
|
Resource: [`arn:aws:s3:::${bucketName}/*`],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Write policy to temp file and add to MinIO
|
||||||
|
const fs = await import('fs/promises');
|
||||||
|
const os = await import('os');
|
||||||
|
const path = await import('path');
|
||||||
|
|
||||||
|
const tmpDir = os.tmpdir();
|
||||||
|
const policyFile = path.join(tmpDir, `minio-policy-${bucketName}.json`);
|
||||||
|
await fs.writeFile(policyFile, policyJson, 'utf8');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Add policy to MinIO
|
||||||
|
const addPolicyCmd = `mc admin policy create ${MINIO_ALIAS} "${policyName}" "${policyFile}"`;
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync(addPolicyCmd, { timeout: 10000 });
|
||||||
|
console.info(`[MinIO Admin] Policy ${policyName} created:`, stdout.trim());
|
||||||
|
} catch (policyError: unknown) {
|
||||||
|
const policyErrMsg = (policyError as Record<string, any>)?.stderr || (policyError as Error)?.message || '';
|
||||||
|
// Policy might already exist, try to update it
|
||||||
|
if (policyErrMsg.includes('already exists') || policyErrMsg.includes('exists')) {
|
||||||
|
console.info(`[MinIO Admin] Policy ${policyName} already exists, skipping...`);
|
||||||
|
} else {
|
||||||
|
console.warn(`[MinIO Admin] Policy creation warning:`, policyErrMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach policy to user
|
||||||
|
const attachPolicyCmd = `mc admin policy attach ${MINIO_ALIAS} "${policyName}" --user "${accessKey}"`;
|
||||||
|
const { stdout: attachOut } = await execAsync(attachPolicyCmd, { timeout: 10000 });
|
||||||
|
console.info(`[MinIO Admin] Policy ${policyName} attached to user ${accessKey}:`, attachOut.trim());
|
||||||
|
} finally {
|
||||||
|
// Cleanup temp file
|
||||||
|
try {
|
||||||
|
await fs.unlink(policyFile);
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(`[MinIO Admin] Service account ${accessKey} created with access to bucket ${bucketName}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
console.error('[MinIO Admin] Error creating service account:', error.message);
|
||||||
|
throw new Error(`Не удалось создать ключ доступа в MinIO: ${error.message}`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function generateLogicalBucketName(userId: number, username: string, requestedName: string): Promise<string> {
|
async function generateLogicalBucketName(userId: number, username: string, requestedName: string): Promise<string> {
|
||||||
const prefix = (process.env.MINIO_BUCKET_PREFIX || 'ospab').toLowerCase();
|
const prefix = (process.env.MINIO_BUCKET_PREFIX || 'ospab').toLowerCase();
|
||||||
const userSegment = String(userId);
|
const userSegment = String(userId);
|
||||||
@@ -423,6 +593,14 @@ function storageClassDelegate() {
|
|||||||
return delegate as any;
|
return delegate as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function promoCodeDelegate(client: any = prisma) {
|
||||||
|
const delegate = (client as any).promoCode;
|
||||||
|
if (!delegate) {
|
||||||
|
throw new Error('PromoCode модель недоступна. Выполните prisma generate, чтобы обновить клиент.');
|
||||||
|
}
|
||||||
|
return delegate as any;
|
||||||
|
}
|
||||||
|
|
||||||
function serializePlan(plan: StoragePlanRecord) {
|
function serializePlan(plan: StoragePlanRecord) {
|
||||||
return {
|
return {
|
||||||
id: plan.id,
|
id: plan.id,
|
||||||
@@ -591,6 +769,54 @@ export async function createCheckoutSession(params: { planCode?: string; planId?
|
|||||||
return toCheckoutPayload(session, plan);
|
return toCheckoutPayload(session, plan);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function applyPromoToCheckoutSession(cartId: string, promoCode: string, userId?: number | null) {
|
||||||
|
// Load session
|
||||||
|
const session = await checkoutSessionDelegate().findUnique({ where: { id: cartId } }) as CheckoutSessionRecord | null;
|
||||||
|
if (!session) throw new Error('Корзина не найдена');
|
||||||
|
if (session.consumedAt) throw new Error('Корзина уже использована');
|
||||||
|
if (session.expiresAt.getTime() <= Date.now()) throw new Error('Корзина просрочена');
|
||||||
|
|
||||||
|
// Find promo
|
||||||
|
const promo = await promoCodeDelegate().findUnique({ where: { code: promoCode } });
|
||||||
|
if (!promo) throw new Error('Неверный промокод');
|
||||||
|
if (promo.used) throw new Error('Промокод уже использован');
|
||||||
|
|
||||||
|
// Ensure only owner of session (if set) can apply promo
|
||||||
|
if (session.userId && userId && session.userId !== userId) {
|
||||||
|
throw new Error('Нет прав на изменение корзины');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply discount
|
||||||
|
const discountedPrice = Math.max(0, toPlainNumber(session.price) - Number(promo.amount));
|
||||||
|
|
||||||
|
const updated = await checkoutSessionDelegate().update({
|
||||||
|
where: { id: cartId },
|
||||||
|
data: {
|
||||||
|
price: discountedPrice,
|
||||||
|
promoCodeId: promo.id,
|
||||||
|
promoDiscount: Number(promo.amount),
|
||||||
|
},
|
||||||
|
}) as CheckoutSessionRecord;
|
||||||
|
|
||||||
|
return {
|
||||||
|
cartId: updated.id,
|
||||||
|
plan: {
|
||||||
|
id: updated.planId,
|
||||||
|
code: updated.planCode,
|
||||||
|
name: updated.planName,
|
||||||
|
price: updated.price,
|
||||||
|
quotaGb: updated.quotaGb,
|
||||||
|
bandwidthGb: updated.bandwidthGb,
|
||||||
|
requestLimit: updated.requestLimit,
|
||||||
|
description: updated.planDescription,
|
||||||
|
order: 0,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
price: updated.price,
|
||||||
|
expiresAt: updated.expiresAt.toISOString(),
|
||||||
|
} as CheckoutSessionPayload;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getCheckoutSession(cartId: string, userId: number): Promise<CheckoutSessionResult> {
|
export async function getCheckoutSession(cartId: string, userId: number): Promise<CheckoutSessionResult> {
|
||||||
const session = await checkoutSessionDelegate().findUnique({
|
const session = await checkoutSessionDelegate().findUnique({
|
||||||
where: { id: cartId },
|
where: { id: cartId },
|
||||||
@@ -628,9 +854,18 @@ export async function getCheckoutSession(cartId: string, userId: number): Promis
|
|||||||
|
|
||||||
export async function markCheckoutSessionConsumed(cartId: string) {
|
export async function markCheckoutSessionConsumed(cartId: string) {
|
||||||
try {
|
try {
|
||||||
await checkoutSessionDelegate().update({
|
const session = await checkoutSessionDelegate().findUnique({ where: { id: cartId } }) as CheckoutSessionRecord | null;
|
||||||
where: { id: cartId },
|
if (!session) throw new Error('Корзина не найдена');
|
||||||
data: { consumedAt: new Date() },
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
const checkoutDelegate = (tx as any).storageCheckoutSession;
|
||||||
|
if (checkoutDelegate) {
|
||||||
|
await checkoutDelegate.update({ where: { id: cartId }, data: { consumedAt: new Date() } });
|
||||||
|
}
|
||||||
|
if (session.promoCodeId) {
|
||||||
|
const promoDelegate = promoCodeDelegate(tx);
|
||||||
|
await promoDelegate.update({ where: { id: session.promoCodeId }, data: { used: true, usedBy: session.userId ?? undefined, usedAt: new Date() } });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`[Storage] Не удалось пометить корзину ${cartId} как использованную`, error);
|
console.warn(`[Storage] Не удалось пометить корзину ${cartId} как использованную`, error);
|
||||||
@@ -840,6 +1075,12 @@ export async function createBucket(data: CreateBucketInput) {
|
|||||||
}) as StoragePlanRecord | null;
|
}) as StoragePlanRecord | null;
|
||||||
if (!plan) throw new Error('Тариф не найден или отключён');
|
if (!plan) throw new Error('Тариф не найден или отключён');
|
||||||
const planPrice = toPlainNumber(plan.price);
|
const planPrice = toPlainNumber(plan.price);
|
||||||
|
// If cart price differs due to applied promo, use session price for charging
|
||||||
|
let sessionPrice = planPrice;
|
||||||
|
if (data.cartId) {
|
||||||
|
const session = await checkoutSessionDelegate().findUnique({ where: { id: data.cartId } }) as CheckoutSessionRecord | null;
|
||||||
|
if (session) sessionPrice = Number(session.price);
|
||||||
|
}
|
||||||
|
|
||||||
const regionCode = data.region.trim();
|
const regionCode = data.region.trim();
|
||||||
if (!regionCode) throw new Error('Регион обязателен');
|
if (!regionCode) throw new Error('Регион обязателен');
|
||||||
@@ -868,7 +1109,7 @@ export async function createBucket(data: CreateBucketInput) {
|
|||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { id: data.userId } });
|
const user = await prisma.user.findUnique({ where: { id: data.userId } });
|
||||||
if (!user) throw new Error('Пользователь не найден');
|
if (!user) throw new Error('Пользователь не найден');
|
||||||
if (toPlainNumber(user.balance) < planPrice) throw new Error('Недостаточно средств');
|
if (toPlainNumber(user.balance) < sessionPrice) throw new Error('Недостаточно средств');
|
||||||
const logicalName = await generateLogicalBucketName(data.userId, user.username, data.name);
|
const logicalName = await generateLogicalBucketName(data.userId, user.username, data.name);
|
||||||
const physicalName = buildPhysicalBucketName(data.userId, logicalName);
|
const physicalName = buildPhysicalBucketName(data.userId, logicalName);
|
||||||
|
|
||||||
@@ -883,20 +1124,20 @@ export async function createBucket(data: CreateBucketInput) {
|
|||||||
await ensureBucketExists(physicalName, regionCode);
|
await ensureBucketExists(physicalName, regionCode);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const createdBucket = await prisma.$transaction(async (tx) => {
|
const createdBucket = await prisma.$transaction<BucketWithPlan>(async (tx) => {
|
||||||
const reloadedUser = await tx.user.findUnique({ where: { id: data.userId } });
|
const reloadedUser = await tx.user.findUnique({ where: { id: data.userId } });
|
||||||
if (!reloadedUser) throw new Error('Пользователь не найден');
|
if (!reloadedUser) throw new Error('Пользователь не найден');
|
||||||
if (toPlainNumber(reloadedUser.balance) < planPrice) throw new Error('Недостаточно средств');
|
if (toPlainNumber(reloadedUser.balance) < planPrice) throw new Error('Недостаточно средств');
|
||||||
|
|
||||||
const updatedUser = await tx.user.update({
|
const updatedUser = await tx.user.update({
|
||||||
where: { id: data.userId },
|
where: { id: data.userId },
|
||||||
data: { balance: reloadedUser.balance - Number(plan.price) }
|
data: { balance: reloadedUser.balance - Number(sessionPrice) }
|
||||||
});
|
});
|
||||||
|
|
||||||
await tx.transaction.create({
|
await tx.transaction.create({
|
||||||
data: {
|
data: {
|
||||||
userId: data.userId,
|
userId: data.userId,
|
||||||
amount: -plan.price,
|
amount: -sessionPrice,
|
||||||
type: 'withdrawal',
|
type: 'withdrawal',
|
||||||
description: `Создание S3 бакета ${logicalName} (${plan.name})`,
|
description: `Создание S3 бакета ${logicalName} (${plan.name})`,
|
||||||
balanceBefore: reloadedUser.balance,
|
balanceBefore: reloadedUser.balance,
|
||||||
@@ -984,8 +1225,15 @@ export async function createBucket(data: CreateBucketInput) {
|
|||||||
}
|
}
|
||||||
await minioClient.removeBucket(physicalName);
|
await minioClient.removeBucket(physicalName);
|
||||||
} catch (cleanupError) {
|
} catch (cleanupError) {
|
||||||
|
// If cleanup fails due to auth or missing bucket, avoid spamming logs with stack traces
|
||||||
|
if ((cleanupError as any)?.code === 'MINIO_AUTH_ERROR') {
|
||||||
|
console.error('[Storage] Cleanup skipped due to MinIO authentication error');
|
||||||
|
} else if ((cleanupError as any)?.code === 'MINIO_BUCKET_NOT_FOUND') {
|
||||||
|
console.warn('[Storage] Cleanup skipped, bucket not found');
|
||||||
|
} else {
|
||||||
console.error('[Storage] Ошибка очистки бакета после неудачного создания', cleanupError);
|
console.error('[Storage] Ошибка очистки бакета после неудачного создания', cleanupError);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1099,7 +1347,21 @@ export async function deleteBucket(userId: number, id: number, force = false) {
|
|||||||
const bucket = await fetchBucket(userId, id);
|
const bucket = await fetchBucket(userId, id);
|
||||||
const physicalName = buildPhysicalBucketName(bucket.userId, bucket.name);
|
const physicalName = buildPhysicalBucketName(bucket.userId, bucket.name);
|
||||||
|
|
||||||
const keys = await collectObjectKeys(physicalName);
|
let keys: string[] = [];
|
||||||
|
try {
|
||||||
|
keys = await collectObjectKeys(physicalName);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if ((err as any)?.code === 'MINIO_AUTH_ERROR') {
|
||||||
|
console.error('[Storage] MinIO authentication error while deleting bucket — aborting deletion');
|
||||||
|
throw new Error('MinIO authentication failed. Пожалуйста, проверьте настройки MINIO_ACCESS_KEY и MINIO_SECRET_KEY.');
|
||||||
|
}
|
||||||
|
if ((err as any)?.code === 'MINIO_BUCKET_NOT_FOUND') {
|
||||||
|
console.warn('[Storage] Bucket not found in MinIO while attempting delete; continuing with DB cleanup');
|
||||||
|
keys = [];
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (keys.length > 0 && !force) {
|
if (keys.length > 0 && !force) {
|
||||||
throw new Error('Невозможно удалить непустой бакет. Удалите объекты или используйте force=true');
|
throw new Error('Невозможно удалить непустой бакет. Удалите объекты или используйте force=true');
|
||||||
}
|
}
|
||||||
@@ -1109,11 +1371,35 @@ export async function deleteBucket(userId: number, id: number, force = false) {
|
|||||||
keys.slice(idx * 1000, (idx + 1) * 1000)
|
keys.slice(idx * 1000, (idx + 1) * 1000)
|
||||||
);
|
);
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
|
try {
|
||||||
await minioClient.removeObjects(physicalName, chunk);
|
await minioClient.removeObjects(physicalName, chunk);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if ((err as any)?.code === 'MINIO_AUTH_ERROR') {
|
||||||
|
console.error('[Storage] MinIO authentication error while deleting objects');
|
||||||
|
throw new Error('MinIO authentication failed. Пожалуйста, проверьте настройки MINIO_ACCESS_KEY и MINIO_SECRET_KEY.');
|
||||||
|
}
|
||||||
|
if ((err as any)?.code === 'MINIO_BUCKET_NOT_FOUND') {
|
||||||
|
console.warn('[Storage] Bucket not found while deleting objects; skipping');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
await minioClient.removeBucket(physicalName);
|
await minioClient.removeBucket(physicalName);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if ((err as any)?.code === 'MINIO_AUTH_ERROR') {
|
||||||
|
console.error('[Storage] MinIO authentication error while removing bucket');
|
||||||
|
throw new Error('MinIO authentication failed. Пожалуйста, проверьте настройки MINIO_ACCESS_KEY и MINIO_SECRET_KEY.');
|
||||||
|
}
|
||||||
|
if ((err as any)?.code === 'MINIO_BUCKET_NOT_FOUND') {
|
||||||
|
console.warn('[Storage] Bucket not found when attempting to remove; continuing with DB cleanup');
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.storageAccessKey.deleteMany({ where: { bucketId: bucket.id } });
|
await prisma.storageAccessKey.deleteMany({ where: { bucketId: bucket.id } });
|
||||||
const deleted = await prisma.storageBucket.delete({ where: { id: bucket.id } });
|
const deleted = await prisma.storageBucket.delete({ where: { id: bucket.id } });
|
||||||
@@ -1289,9 +1575,23 @@ export async function deleteObjects(userId: number, id: number, keys: string[])
|
|||||||
|
|
||||||
export async function createEphemeralKey(userId: number, id: number, label?: string) {
|
export async function createEphemeralKey(userId: number, id: number, label?: string) {
|
||||||
const bucket = await fetchBucket(userId, id);
|
const bucket = await fetchBucket(userId, id);
|
||||||
|
|
||||||
|
// Проверяем, есть ли уже ключ для этого бакета (разрешён только один)
|
||||||
|
const existingKeys = await prisma.storageAccessKey.count({
|
||||||
|
where: { bucketId: bucket.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingKeys > 0) {
|
||||||
|
throw new Error('Для этого хранилища уже существует ключ доступа. Удалите существующий ключ, чтобы создать новый.');
|
||||||
|
}
|
||||||
|
|
||||||
const accessKey = `S3${crypto.randomBytes(8).toString('hex')}`;
|
const accessKey = `S3${crypto.randomBytes(8).toString('hex')}`;
|
||||||
const secretKey = crypto.randomBytes(32).toString('hex');
|
const secretKey = crypto.randomBytes(32).toString('hex');
|
||||||
|
|
||||||
|
// Создаём пользователя в MinIO с этими ключами
|
||||||
|
const physicalBucketName = buildPhysicalBucketName(bucket.userId, bucket.name);
|
||||||
|
await createMinioServiceAccount(accessKey, secretKey, physicalBucketName);
|
||||||
|
|
||||||
const record = await prisma.storageAccessKey.create({
|
const record = await prisma.storageAccessKey.create({
|
||||||
data: {
|
data: {
|
||||||
bucketId: bucket.id,
|
bucketId: bucket.id,
|
||||||
@@ -1328,10 +1628,57 @@ export async function listAccessKeys(userId: number, id: number) {
|
|||||||
|
|
||||||
export async function revokeAccessKey(userId: number, id: number, keyId: number) {
|
export async function revokeAccessKey(userId: number, id: number, keyId: number) {
|
||||||
const bucket = await fetchBucket(userId, id);
|
const bucket = await fetchBucket(userId, id);
|
||||||
await prisma.storageAccessKey.deleteMany({
|
|
||||||
|
// Получаем ключ перед удалением, чтобы знать accessKey
|
||||||
|
const keyRecord = await prisma.storageAccessKey.findFirst({
|
||||||
where: { id: keyId, bucketId: bucket.id }
|
where: { id: keyId, bucketId: bucket.id }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!keyRecord) {
|
||||||
|
throw new Error('Ключ не найден');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем пользователя из MinIO
|
||||||
|
await deleteMinioServiceAccount(keyRecord.accessKey);
|
||||||
|
|
||||||
|
// Удаляем запись из БД
|
||||||
|
await prisma.storageAccessKey.delete({
|
||||||
|
where: { id: keyId }
|
||||||
|
});
|
||||||
|
|
||||||
return { revoked: true };
|
return { revoked: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a service account (user) from MinIO
|
||||||
|
*/
|
||||||
|
async function deleteMinioServiceAccount(accessKey: string): Promise<void> {
|
||||||
|
if (!MINIO_MC_ENABLED) {
|
||||||
|
console.warn(`[MinIO Admin] mc CLI disabled, skipping service account deletion for ${accessKey}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Setup mc alias
|
||||||
|
await ensureMinioAlias();
|
||||||
|
|
||||||
|
// Remove user from MinIO
|
||||||
|
const removeUserCmd = `mc admin user rm ${MINIO_ALIAS} "${accessKey}"`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync(removeUserCmd, { timeout: 10000 });
|
||||||
|
console.info(`[MinIO Admin] Service account ${accessKey} removed:`, stdout.trim());
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMsg = (error as Record<string, any>)?.stderr || (error as Error)?.message || '';
|
||||||
|
// User might not exist, that's okay
|
||||||
|
if (!errorMsg.includes('does not exist') && !errorMsg.includes('not found')) {
|
||||||
|
console.warn(`[MinIO Admin] Warning removing user ${accessKey}:`, errorMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Non-critical - user will be orphaned in MinIO but key removed from DB
|
||||||
|
console.error('[MinIO Admin] Error deleting service account:', (error as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -150,7 +150,11 @@ export const uploadTicketFiles = multer({
|
|||||||
// Создать тикет
|
// Создать тикет
|
||||||
export async function createTicket(req: Request, res: Response) {
|
export async function createTicket(req: Request, res: Response) {
|
||||||
const { title, message, category = 'general', priority = 'normal' } = req.body;
|
const { title, message, category = 'general', priority = 'normal' } = req.body;
|
||||||
const userId = Number((req as any).user?.id);
|
const userIdRaw = req.user?.id;
|
||||||
|
if (!userIdRaw) {
|
||||||
|
return res.status(401).json({ error: 'Нет авторизации' });
|
||||||
|
}
|
||||||
|
const userId = Number(userIdRaw);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return res.status(401).json({ error: 'Нет авторизации' });
|
return res.status(401).json({ error: 'Нет авторизации' });
|
||||||
@@ -201,8 +205,10 @@ export async function createTicket(req: Request, res: Response) {
|
|||||||
|
|
||||||
// Получить тикеты (клиент — свои, оператор — все с фильтрами)
|
// Получить тикеты (клиент — свои, оператор — все с фильтрами)
|
||||||
export async function getTickets(req: Request, res: Response) {
|
export async function getTickets(req: Request, res: Response) {
|
||||||
const userId = Number((req as any).user?.id);
|
const userIdRaw = req.user?.id;
|
||||||
const isOperator = Number((req as any).user?.operator) === 1;
|
if (!userIdRaw) return res.status(401).json({ error: 'Нет авторизации' });
|
||||||
|
const userId = Number(userIdRaw);
|
||||||
|
const isOperator = Number(req.user?.operator ?? 0) === 1;
|
||||||
const {
|
const {
|
||||||
status,
|
status,
|
||||||
category,
|
category,
|
||||||
@@ -307,7 +313,7 @@ export async function getTickets(req: Request, res: Response) {
|
|||||||
|
|
||||||
const normalizedTickets = tickets.map((ticket) => serializeTicket(ticket, assignedOperatorsMap));
|
const normalizedTickets = tickets.map((ticket) => serializeTicket(ticket, assignedOperatorsMap));
|
||||||
|
|
||||||
const statusMap = statusBuckets.reduce<Record<string, number>>((acc, bucket) => {
|
const statusMap = statusBuckets.reduce<Record<string, number>>((acc: Record<string, number>, bucket: { status: string; _count: { _all: number } }) => {
|
||||||
acc[bucket.status] = bucket._count._all;
|
acc[bucket.status] = bucket._count._all;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
@@ -344,8 +350,10 @@ export async function getTickets(req: Request, res: Response) {
|
|||||||
// Получить один тикет по ID
|
// Получить один тикет по ID
|
||||||
export async function getTicketById(req: Request, res: Response) {
|
export async function getTicketById(req: Request, res: Response) {
|
||||||
const ticketId = Number(req.params.id);
|
const ticketId = Number(req.params.id);
|
||||||
const userId = Number((req as any).user?.id);
|
const userIdRaw = req.user?.id;
|
||||||
const isOperator = Number((req as any).user?.operator) === 1;
|
if (!userIdRaw) return res.status(401).json({ error: 'Нет авторизации' });
|
||||||
|
const userId = Number(userIdRaw);
|
||||||
|
const isOperator = Number(req.user?.operator ?? 0) === 1;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return res.status(401).json({ error: 'Нет авторизации' });
|
return res.status(401).json({ error: 'Нет авторизации' });
|
||||||
@@ -409,8 +417,10 @@ export async function getTicketById(req: Request, res: Response) {
|
|||||||
// Ответить на тикет (клиент или оператор)
|
// Ответить на тикет (клиент или оператор)
|
||||||
export async function respondTicket(req: Request, res: Response) {
|
export async function respondTicket(req: Request, res: Response) {
|
||||||
const { ticketId, message, isInternal = false } = req.body;
|
const { ticketId, message, isInternal = false } = req.body;
|
||||||
const actorId = Number((req as any).user?.id);
|
const actorIdRaw = req.user?.id;
|
||||||
const isOperator = Number((req as any).user?.operator) === 1;
|
if (!actorIdRaw) return res.status(401).json({ error: 'Нет авторизации' });
|
||||||
|
const actorId = Number(actorIdRaw);
|
||||||
|
const isOperator = Number(req.user?.operator ?? 0) === 1;
|
||||||
|
|
||||||
if (!actorId) {
|
if (!actorId) {
|
||||||
return res.status(401).json({ error: 'Нет авторизации' });
|
return res.status(401).json({ error: 'Нет авторизации' });
|
||||||
@@ -501,8 +511,10 @@ export async function respondTicket(req: Request, res: Response) {
|
|||||||
// Изменить статус тикета (только оператор)
|
// Изменить статус тикета (только оператор)
|
||||||
export async function updateTicketStatus(req: Request, res: Response) {
|
export async function updateTicketStatus(req: Request, res: Response) {
|
||||||
const { ticketId, status } = req.body;
|
const { ticketId, status } = req.body;
|
||||||
const userId = Number((req as any).user?.id);
|
const userIdRaw = req.user?.id;
|
||||||
const isOperator = Number((req as any).user?.operator) === 1;
|
if (!userIdRaw) return res.status(403).json({ error: 'Нет прав' });
|
||||||
|
const userId = Number(userIdRaw);
|
||||||
|
const isOperator = Number(req.user?.operator ?? 0) === 1;
|
||||||
|
|
||||||
if (!userId || !isOperator) {
|
if (!userId || !isOperator) {
|
||||||
return res.status(403).json({ error: 'Нет прав' });
|
return res.status(403).json({ error: 'Нет прав' });
|
||||||
@@ -569,8 +581,10 @@ export async function updateTicketStatus(req: Request, res: Response) {
|
|||||||
// Назначить тикет на оператора (только оператор)
|
// Назначить тикет на оператора (только оператор)
|
||||||
export async function assignTicket(req: Request, res: Response) {
|
export async function assignTicket(req: Request, res: Response) {
|
||||||
const { ticketId, operatorId } = req.body;
|
const { ticketId, operatorId } = req.body;
|
||||||
const userId = Number((req as any).user?.id);
|
const userIdRaw = req.user?.id;
|
||||||
const isOperator = Number((req as any).user?.operator) === 1;
|
if (!userIdRaw) return res.status(403).json({ error: 'Нет прав' });
|
||||||
|
const userId = Number(userIdRaw);
|
||||||
|
const isOperator = Number(req.user?.operator ?? 0) === 1;
|
||||||
|
|
||||||
if (!userId || !isOperator) {
|
if (!userId || !isOperator) {
|
||||||
return res.status(403).json({ error: 'Нет прав' });
|
return res.status(403).json({ error: 'Нет прав' });
|
||||||
@@ -638,8 +652,10 @@ export async function assignTicket(req: Request, res: Response) {
|
|||||||
// Закрыть тикет (клиент или оператор)
|
// Закрыть тикет (клиент или оператор)
|
||||||
export async function closeTicket(req: Request, res: Response) {
|
export async function closeTicket(req: Request, res: Response) {
|
||||||
const { ticketId } = req.body;
|
const { ticketId } = req.body;
|
||||||
const userId = Number((req as any).user?.id);
|
const userIdRaw = req.user?.id;
|
||||||
const isOperator = Number((req as any).user?.operator) === 1;
|
if (!userIdRaw) return res.status(401).json({ error: 'Нет авторизации' });
|
||||||
|
const userId = Number(userIdRaw);
|
||||||
|
const isOperator = Number(req.user?.operator ?? 0) === 1;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return res.status(401).json({ error: 'Нет авторизации' });
|
return res.status(401).json({ error: 'Нет авторизации' });
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import crypto from 'crypto';
|
|||||||
// Получить профиль пользователя (расширенный)
|
// Получить профиль пользователя (расширенный)
|
||||||
export const getProfile = async (req: Request, res: Response) => {
|
export const getProfile = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req as any).user.id;
|
const userId = req.user?.id;
|
||||||
|
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
@@ -32,7 +33,7 @@ export const getProfile = async (req: Request, res: Response) => {
|
|||||||
const { password, ...userWithoutPassword } = user;
|
const { password, ...userWithoutPassword } = user;
|
||||||
|
|
||||||
res.json({ success: true, data: userWithoutPassword });
|
res.json({ success: true, data: userWithoutPassword });
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Ошибка получения профиля:', error);
|
console.error('Ошибка получения профиля:', error);
|
||||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||||
}
|
}
|
||||||
@@ -41,7 +42,8 @@ export const getProfile = async (req: Request, res: Response) => {
|
|||||||
// Обновить базовый профиль
|
// Обновить базовый профиль
|
||||||
export const updateProfile = async (req: Request, res: Response) => {
|
export const updateProfile = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req as any).user.id;
|
const userId = req.user?.id;
|
||||||
|
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
|
||||||
const { username, email, phoneNumber, timezone, language } = req.body;
|
const { username, email, phoneNumber, timezone, language } = req.body;
|
||||||
|
|
||||||
// Проверка email на уникальность
|
// Проверка email на уникальность
|
||||||
@@ -84,7 +86,7 @@ export const updateProfile = async (req: Request, res: Response) => {
|
|||||||
message: 'Профиль обновлён',
|
message: 'Профиль обновлён',
|
||||||
data: { user: updatedUser, profile }
|
data: { user: updatedUser, profile }
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Ошибка обновления профиля:', error);
|
console.error('Ошибка обновления профиля:', error);
|
||||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||||
}
|
}
|
||||||
@@ -93,7 +95,8 @@ export const updateProfile = async (req: Request, res: Response) => {
|
|||||||
// Изменить пароль
|
// Изменить пароль
|
||||||
export const changePassword = async (req: Request, res: Response) => {
|
export const changePassword = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req as any).user.id;
|
const userId = req.user?.id;
|
||||||
|
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
|
||||||
const { currentPassword, newPassword } = req.body;
|
const { currentPassword, newPassword } = req.body;
|
||||||
|
|
||||||
if (!currentPassword || !newPassword) {
|
if (!currentPassword || !newPassword) {
|
||||||
@@ -128,7 +131,7 @@ export const changePassword = async (req: Request, res: Response) => {
|
|||||||
// Можно добавить логику для сохранения текущего токена
|
// Можно добавить логику для сохранения текущего токена
|
||||||
|
|
||||||
res.json({ success: true, message: 'Пароль успешно изменён' });
|
res.json({ success: true, message: 'Пароль успешно изменён' });
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Ошибка смены пароля:', error);
|
console.error('Ошибка смены пароля:', error);
|
||||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||||
}
|
}
|
||||||
@@ -137,7 +140,8 @@ export const changePassword = async (req: Request, res: Response) => {
|
|||||||
// Загрузить аватар
|
// Загрузить аватар
|
||||||
export const uploadAvatar = async (req: Request, res: Response) => {
|
export const uploadAvatar = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req as any).user.id;
|
const userId = req.user?.id;
|
||||||
|
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
|
||||||
|
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
return res.status(400).json({ success: false, message: 'Файл не загружен' });
|
return res.status(400).json({ success: false, message: 'Файл не загружен' });
|
||||||
@@ -157,7 +161,7 @@ export const uploadAvatar = async (req: Request, res: Response) => {
|
|||||||
message: 'Аватар загружен',
|
message: 'Аватар загружен',
|
||||||
data: { avatarUrl }
|
data: { avatarUrl }
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Ошибка загрузки аватара:', error);
|
console.error('Ошибка загрузки аватара:', error);
|
||||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||||
}
|
}
|
||||||
@@ -166,7 +170,8 @@ export const uploadAvatar = async (req: Request, res: Response) => {
|
|||||||
// Удалить аватар
|
// Удалить аватар
|
||||||
export const deleteAvatar = async (req: Request, res: Response) => {
|
export const deleteAvatar = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req as any).user.id;
|
const userId = req.user?.id;
|
||||||
|
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
|
||||||
|
|
||||||
await prisma.userProfile.update({
|
await prisma.userProfile.update({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
@@ -174,7 +179,7 @@ export const deleteAvatar = async (req: Request, res: Response) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
res.json({ success: true, message: 'Аватар удалён' });
|
res.json({ success: true, message: 'Аватар удалён' });
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Ошибка удаления аватара:', error);
|
console.error('Ошибка удаления аватара:', error);
|
||||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||||
}
|
}
|
||||||
@@ -183,7 +188,8 @@ export const deleteAvatar = async (req: Request, res: Response) => {
|
|||||||
// Получить активные сеансы
|
// Получить активные сеансы
|
||||||
export const getSessions = async (req: Request, res: Response) => {
|
export const getSessions = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req as any).user.id;
|
const userId = req.user?.id;
|
||||||
|
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
|
||||||
|
|
||||||
const sessions = await prisma.session.findMany({
|
const sessions = await prisma.session.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -194,7 +200,7 @@ export const getSessions = async (req: Request, res: Response) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
res.json({ success: true, data: sessions });
|
res.json({ success: true, data: sessions });
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Ошибка получения сеансов:', error);
|
console.error('Ошибка получения сеансов:', error);
|
||||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||||
}
|
}
|
||||||
@@ -203,7 +209,8 @@ export const getSessions = async (req: Request, res: Response) => {
|
|||||||
// Завершить сеанс
|
// Завершить сеанс
|
||||||
export const terminateSession = async (req: Request, res: Response) => {
|
export const terminateSession = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req as any).user.id;
|
const userId = req.user?.id;
|
||||||
|
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
|
||||||
const { sessionId } = req.params;
|
const { sessionId } = req.params;
|
||||||
|
|
||||||
// Проверяем, что сеанс принадлежит пользователю
|
// Проверяем, что сеанс принадлежит пользователю
|
||||||
@@ -221,7 +228,7 @@ export const terminateSession = async (req: Request, res: Response) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
res.json({ success: true, message: 'Сеанс завершён' });
|
res.json({ success: true, message: 'Сеанс завершён' });
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Ошибка завершения сеанса:', error);
|
console.error('Ошибка завершения сеанса:', error);
|
||||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||||
}
|
}
|
||||||
@@ -240,7 +247,7 @@ export const getLoginHistory = async (req: Request, res: Response) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
res.json({ success: true, data: history });
|
res.json({ success: true, data: history });
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Ошибка получения истории:', error);
|
console.error('Ошибка получения истории:', error);
|
||||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||||
}
|
}
|
||||||
@@ -269,7 +276,7 @@ export const getAPIKeys = async (req: Request, res: Response) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
res.json({ success: true, data: keys });
|
res.json({ success: true, data: keys });
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Ошибка получения API ключей:', error);
|
console.error('Ошибка получения API ключей:', error);
|
||||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||||
}
|
}
|
||||||
@@ -306,7 +313,7 @@ export const createAPIKey = async (req: Request, res: Response) => {
|
|||||||
message: 'API ключ создан. Сохраните его, он больше не будет показан!',
|
message: 'API ключ создан. Сохраните его, он больше не будет показан!',
|
||||||
data: { ...apiKey, fullKey: key }
|
data: { ...apiKey, fullKey: key }
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Ошибка создания API ключа:', error);
|
console.error('Ошибка создания API ключа:', error);
|
||||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||||
}
|
}
|
||||||
@@ -332,7 +339,7 @@ export const deleteAPIKey = async (req: Request, res: Response) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
res.json({ success: true, message: 'API ключ удалён' });
|
res.json({ success: true, message: 'API ключ удалён' });
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Ошибка удаления API ключа:', error);
|
console.error('Ошибка удаления API ключа:', error);
|
||||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||||
}
|
}
|
||||||
@@ -355,7 +362,7 @@ export const getNotificationSettings = async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true, data: settings });
|
res.json({ success: true, data: settings });
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Ошибка получения настроек уведомлений:', error);
|
console.error('Ошибка получения настроек уведомлений:', error);
|
||||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||||
}
|
}
|
||||||
@@ -378,7 +385,7 @@ export const updateNotificationSettings = async (req: Request, res: Response) =>
|
|||||||
message: 'Настройки уведомлений обновлены',
|
message: 'Настройки уведомлений обновлены',
|
||||||
data: updated
|
data: updated
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Ошибка обновления настроек уведомлений:', error);
|
console.error('Ошибка обновления настроек уведомлений:', error);
|
||||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||||
}
|
}
|
||||||
@@ -418,8 +425,9 @@ export const exportUserData = async (req: Request, res: Response) => {
|
|||||||
data: dataWithoutPassword,
|
data: dataWithoutPassword,
|
||||||
exportedAt: new Date().toISOString()
|
exportedAt: new Date().toISOString()
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Ошибка экспорта данных:', error);
|
console.error('Ошибка экспорта данных:', error);
|
||||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const avatarStorage = multer.diskStorage({
|
|||||||
cb(null, uploadDir);
|
cb(null, uploadDir);
|
||||||
},
|
},
|
||||||
filename: (req, file, cb) => {
|
filename: (req, file, cb) => {
|
||||||
const userId = (req as any).user.id;
|
const userId = (req as any).user?.id ?? 'anon';
|
||||||
const ext = path.extname(file.originalname);
|
const ext = path.extname(file.originalname);
|
||||||
cb(null, `avatar-${userId}-${Date.now()}${ext}`);
|
cb(null, `avatar-${userId}-${Date.now()}${ext}`);
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,7 @@ const avatarStorage = multer.diskStorage({
|
|||||||
const avatarUpload = multer({
|
const avatarUpload = multer({
|
||||||
storage: avatarStorage,
|
storage: avatarStorage,
|
||||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
|
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
|
||||||
fileFilter: (req, file, cb: any) => {
|
fileFilter: (req, file, cb) => {
|
||||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||||
if (allowedTypes.includes(file.mimetype)) {
|
if (allowedTypes.includes(file.mimetype)) {
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
|
|||||||
@@ -1,2 +1,10 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
export const prisma = new PrismaClient();
|
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line vars-on-top, no-var
|
||||||
|
var __prisma: PrismaClient | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const prisma = global.__prisma ?? new PrismaClient();
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') global.__prisma = prisma;
|
||||||
|
|||||||
21
ospabhost/backend/src/types/express.d.ts
vendored
21
ospabhost/backend/src/types/express.d.ts
vendored
@@ -1,3 +1,24 @@
|
|||||||
|
import type { User as PrismaUser, StorageBucket } from '@prisma/client';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
/**
|
||||||
|
* Augment Express.User with fields used throughout the codebase.
|
||||||
|
* Many fields are optional because some auth middleware use partial user objects.
|
||||||
|
*/
|
||||||
|
interface User extends Partial<PrismaUser> {
|
||||||
|
id?: number;
|
||||||
|
username?: string;
|
||||||
|
email?: string;
|
||||||
|
operator?: number; // 0 | 1
|
||||||
|
isAdmin?: boolean;
|
||||||
|
balance?: number | bigint;
|
||||||
|
buckets?: StorageBucket[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
// Типы для расширения Express Request
|
// Типы для расширения Express Request
|
||||||
import { User as PrismaUser } from '@prisma/client';
|
import { User as PrismaUser } from '@prisma/client';
|
||||||
|
|
||||||
|
|||||||
@@ -6,30 +6,30 @@
|
|||||||
const isDebug = process.env.NODE_ENV !== 'production';
|
const isDebug = process.env.NODE_ENV !== 'production';
|
||||||
|
|
||||||
export const logger = {
|
export const logger = {
|
||||||
log: (...args: any[]) => {
|
log: (...args: unknown[]) => {
|
||||||
if (isDebug) {
|
if (isDebug) {
|
||||||
console.log(...args);
|
console.log(...args);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
error: (...args: any[]) => {
|
error: (...args: unknown[]) => {
|
||||||
// Ошибки логируем всегда
|
// Ошибки логируем всегда
|
||||||
console.error(...args);
|
console.error(...args);
|
||||||
},
|
},
|
||||||
|
|
||||||
warn: (...args: any[]) => {
|
warn: (...args: unknown[]) => {
|
||||||
if (isDebug) {
|
if (isDebug) {
|
||||||
console.warn(...args);
|
console.warn(...args);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
info: (...args: any[]) => {
|
info: (...args: unknown[]) => {
|
||||||
if (isDebug) {
|
if (isDebug) {
|
||||||
console.info(...args);
|
console.info(...args);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
debug: (...args: any[]) => {
|
debug: (...args: unknown[]) => {
|
||||||
if (isDebug) {
|
if (isDebug) {
|
||||||
console.debug(...args);
|
console.debug(...args);
|
||||||
}
|
}
|
||||||
@@ -38,19 +38,20 @@ export const logger = {
|
|||||||
|
|
||||||
// WebSocket специфичные логи
|
// WebSocket специфичные логи
|
||||||
export const wsLogger = {
|
export const wsLogger = {
|
||||||
log: (message: string, ...args: any[]) => {
|
log: (message: string, ...args: unknown[]) => {
|
||||||
if (isDebug) {
|
if (isDebug) {
|
||||||
console.log(`[WebSocket] ${message}`, ...args);
|
console.log(`[WebSocket] ${message}`, ...args);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
error: (message: string, ...args: any[]) => {
|
error: (message: string, ...args: unknown[]) => {
|
||||||
console.error(`[WebSocket] ${message}`, ...args);
|
console.error(`[WebSocket] ${message}`, ...args);
|
||||||
},
|
},
|
||||||
|
|
||||||
warn: (message: string, ...args: any[]) => {
|
warn: (message: string, ...args: unknown[]) => {
|
||||||
if (isDebug) {
|
if (isDebug) {
|
||||||
console.warn(`[WebSocket] ${message}`, ...args);
|
console.warn(`[WebSocket] ${message}`, ...args);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,31 @@
|
|||||||
* Shared между backend и frontend
|
* Shared между backend и frontend
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { Notification, Ticket, Transaction } from '@prisma/client';
|
||||||
|
|
||||||
|
// Обобщённые типы для WebSocket событий
|
||||||
|
export type NotificationPayload = Partial<Notification>;
|
||||||
|
export type TicketPayload = Partial<Ticket>;
|
||||||
|
export type TicketResponsePayload = {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
isOperator: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
};
|
||||||
|
export type ServerStatsPayload = {
|
||||||
|
cpu: number;
|
||||||
|
memory: number;
|
||||||
|
disk: number;
|
||||||
|
network: { in: number; out: number };
|
||||||
|
};
|
||||||
|
export type ServerPayload = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
ipAddress?: string;
|
||||||
|
};
|
||||||
|
export type TransactionPayload = Partial<Transaction>;
|
||||||
|
|
||||||
// События от клиента к серверу
|
// События от клиента к серверу
|
||||||
export type ClientToServerEvents =
|
export type ClientToServerEvents =
|
||||||
| { type: 'auth'; token: string }
|
| { type: 'auth'; token: string }
|
||||||
@@ -20,18 +45,18 @@ export type ClientToServerEvents =
|
|||||||
export type ServerToClientEvents =
|
export type ServerToClientEvents =
|
||||||
| { type: 'auth:success'; userId: number }
|
| { type: 'auth:success'; userId: number }
|
||||||
| { type: 'auth:error'; message: string }
|
| { type: 'auth:error'; message: string }
|
||||||
| { type: 'notification:new'; notification: any }
|
| { type: 'notification:new'; notification: NotificationPayload }
|
||||||
| { type: 'notification:read'; notificationId: number }
|
| { type: 'notification:read'; notificationId: number }
|
||||||
| { type: 'notification:delete'; notificationId: number }
|
| { type: 'notification:delete'; notificationId: number }
|
||||||
| { type: 'notification:updated'; notificationId: number; data: any }
|
| { type: 'notification:updated'; notificationId: number; data: NotificationPayload }
|
||||||
| { type: 'notification:count'; count: number }
|
| { type: 'notification:count'; count: number }
|
||||||
| { type: 'server:created'; server: any }
|
| { type: 'server:created'; server: ServerPayload }
|
||||||
| { type: 'server:status'; serverId: number; status: string; ipAddress?: string }
|
| { type: 'server:status'; serverId: number; status: string; ipAddress?: string }
|
||||||
| { type: 'server:stats'; serverId: number; stats: any }
|
| { type: 'server:stats'; serverId: number; stats: ServerStatsPayload }
|
||||||
| { type: 'ticket:new'; ticket: any }
|
| { type: 'ticket:new'; ticket: TicketPayload }
|
||||||
| { type: 'ticket:response'; ticketId: number; response: any }
|
| { type: 'ticket:response'; ticketId: number; response: TicketResponsePayload }
|
||||||
| { type: 'ticket:status'; ticketId: number; status: string }
|
| { type: 'ticket:status'; ticketId: number; status: string }
|
||||||
| { type: 'balance:updated'; balance: number; transaction?: any }
|
| { type: 'balance:updated'; balance: number; transaction?: TransactionPayload }
|
||||||
| { type: 'check:status'; checkId: number; status: string }
|
| { type: 'check:status'; checkId: number; status: string }
|
||||||
| { type: 'pong' }
|
| { type: 'pong' }
|
||||||
| { type: 'error'; message: string };
|
| { type: 'error'; message: string };
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import WebSocket, { WebSocketServer } from 'ws';
|
import WebSocket, { WebSocketServer } from 'ws';
|
||||||
import { Server as HTTPServer } from 'http';
|
import { Server as HTTPServer } from 'http';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { prisma } from '../prisma/client';
|
||||||
import {
|
import {
|
||||||
ClientToServerEvents,
|
ClientToServerEvents,
|
||||||
ServerToClientEvents,
|
ServerToClientEvents,
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
} from './events';
|
} from './events';
|
||||||
import { wsLogger } from '../utils/logger';
|
import { wsLogger } from '../utils/logger';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'i_love_WebSockets!';
|
const JWT_SECRET = process.env.JWT_SECRET || 'i_love_WebSockets!';
|
||||||
|
|
||||||
// Хранилище аутентифицированных клиентов
|
// Хранилище аутентифицированных клиентов
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"verbatimModuleSyntax": false
|
"verbatimModuleSyntax": false
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts", "src/**/*.d.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { AuthProvider } from './context/authcontext';
|
|||||||
import { WebSocketProvider } from './context/WebSocketContext';
|
import { WebSocketProvider } from './context/WebSocketContext';
|
||||||
import ErrorBoundary from './components/ErrorBoundary';
|
import ErrorBoundary from './components/ErrorBoundary';
|
||||||
import { ToastProvider } from './components/Toast';
|
import { ToastProvider } from './components/Toast';
|
||||||
|
import { LocaleProvider } from './middleware';
|
||||||
|
|
||||||
// SEO конфиг для всех маршрутов
|
// SEO конфиг для всех маршрутов
|
||||||
const SEO_CONFIG: Record<string, {
|
const SEO_CONFIG: Record<string, {
|
||||||
@@ -193,13 +194,14 @@ function SEOUpdater() {
|
|||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
|
<LocaleProvider>
|
||||||
<SEOUpdater />
|
<SEOUpdater />
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<WebSocketProvider>
|
<WebSocketProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Обычные страницы с footer */}
|
{/* Русские маршруты (без префикса) */}
|
||||||
<Route path="/" element={<Pagetempl><Homepage /></Pagetempl>} />
|
<Route path="/" element={<Pagetempl><Homepage /></Pagetempl>} />
|
||||||
<Route path="/about" element={<Pagetempl><Aboutpage /></Pagetempl>} />
|
<Route path="/about" element={<Pagetempl><Aboutpage /></Pagetempl>} />
|
||||||
<Route path="/tariffs" element={<Pagetempl><S3PlansPage /></Pagetempl>} />
|
<Route path="/tariffs" element={<Pagetempl><S3PlansPage /></Pagetempl>} />
|
||||||
@@ -211,7 +213,19 @@ function App() {
|
|||||||
<Route path="/register" element={<Pagetempl><Registerpage /></Pagetempl>} />
|
<Route path="/register" element={<Pagetempl><Registerpage /></Pagetempl>} />
|
||||||
<Route path="/qr-login" element={<QRLoginPage />} />
|
<Route path="/qr-login" element={<QRLoginPage />} />
|
||||||
|
|
||||||
{/* Дашборд без footer */}
|
{/* Английские маршруты (с префиксом /en) */}
|
||||||
|
<Route path="/en" element={<Pagetempl><Homepage /></Pagetempl>} />
|
||||||
|
<Route path="/en/about" element={<Pagetempl><Aboutpage /></Pagetempl>} />
|
||||||
|
<Route path="/en/tariffs" element={<Pagetempl><S3PlansPage /></Pagetempl>} />
|
||||||
|
<Route path="/en/blog" element={<Pagetempl><Blog /></Pagetempl>} />
|
||||||
|
<Route path="/en/blog/:url" element={<Pagetempl><BlogPost /></Pagetempl>} />
|
||||||
|
<Route path="/en/privacy" element={<Privacy />} />
|
||||||
|
<Route path="/en/terms" element={<Terms />} />
|
||||||
|
<Route path="/en/login" element={<Pagetempl><Loginpage /></Pagetempl>} />
|
||||||
|
<Route path="/en/register" element={<Pagetempl><Registerpage /></Pagetempl>} />
|
||||||
|
<Route path="/en/qr-login" element={<QRLoginPage />} />
|
||||||
|
|
||||||
|
{/* Дашборд (русский) */}
|
||||||
<Route path="/dashboard/*" element={
|
<Route path="/dashboard/*" element={
|
||||||
<DashboardTempl>
|
<DashboardTempl>
|
||||||
<Privateroute>
|
<Privateroute>
|
||||||
@@ -220,6 +234,15 @@ function App() {
|
|||||||
</DashboardTempl>
|
</DashboardTempl>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
|
{/* Дашборд (английский) */}
|
||||||
|
<Route path="/en/dashboard/*" element={
|
||||||
|
<DashboardTempl>
|
||||||
|
<Privateroute>
|
||||||
|
<Dashboard />
|
||||||
|
</Privateroute>
|
||||||
|
</DashboardTempl>
|
||||||
|
} />
|
||||||
|
|
||||||
{/* Страницы ошибок */}
|
{/* Страницы ошибок */}
|
||||||
<Route path="/401" element={<Unauthorized />} />
|
<Route path="/401" element={<Unauthorized />} />
|
||||||
<Route path="/403" element={<Forbidden />} />
|
<Route path="/403" element={<Forbidden />} />
|
||||||
@@ -227,12 +250,19 @@ function App() {
|
|||||||
<Route path="/502" element={<BadGateway />} />
|
<Route path="/502" element={<BadGateway />} />
|
||||||
<Route path="/503" element={<ServiceUnavailable />} />
|
<Route path="/503" element={<ServiceUnavailable />} />
|
||||||
<Route path="/504" element={<GatewayTimeout />} />
|
<Route path="/504" element={<GatewayTimeout />} />
|
||||||
|
<Route path="/en/401" element={<Unauthorized />} />
|
||||||
|
<Route path="/en/403" element={<Forbidden />} />
|
||||||
|
<Route path="/en/500" element={<ServerError />} />
|
||||||
|
<Route path="/en/502" element={<BadGateway />} />
|
||||||
|
<Route path="/en/503" element={<ServiceUnavailable />} />
|
||||||
|
<Route path="/en/504" element={<GatewayTimeout />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</WebSocketProvider>
|
</WebSocketProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</LocaleProvider>
|
||||||
</Router>
|
</Router>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ export default function AdminTestingTab() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getAuthHeaders = () => {
|
const getAuthHeaders = () => {
|
||||||
const token = localStorage.getItem('token');
|
// Используем тот же ключ, что и во всём проекте (обычно 'access_token')
|
||||||
|
const token = localStorage.getItem('access_token') || localStorage.getItem('token');
|
||||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ export default function AdminTestingTab() {
|
|||||||
addLog('info', 'Начинаю отправку push-уведомления...');
|
addLog('info', 'Начинаю отправку push-уведомления...');
|
||||||
|
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`${API_URL}/admin/test/push-notification`,
|
`${API_URL}/api/admin/test/push-notification`,
|
||||||
{},
|
{},
|
||||||
{ headers: getAuthHeaders(), timeout: 15000 }
|
{ headers: getAuthHeaders(), timeout: 15000 }
|
||||||
);
|
);
|
||||||
@@ -64,7 +65,7 @@ export default function AdminTestingTab() {
|
|||||||
addLog('info', 'Начинаю отправку email-уведомления...');
|
addLog('info', 'Начинаю отправку email-уведомления...');
|
||||||
|
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`${API_URL}/admin/test/email-notification`,
|
`${API_URL}/api/admin/test/email-notification`,
|
||||||
{},
|
{},
|
||||||
{ headers: getAuthHeaders(), timeout: 15000 }
|
{ headers: getAuthHeaders(), timeout: 15000 }
|
||||||
);
|
);
|
||||||
|
|||||||
63
ospabhost/frontend/src/middleware/LocaleProvider.tsx
Normal file
63
ospabhost/frontend/src/middleware/LocaleProvider.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { LocaleContext } from './locale.context';
|
||||||
|
import type { Locale } from './locale.utils';
|
||||||
|
import { detectUserLocale, setUserLocale, shouldRedirect } from './locale.utils';
|
||||||
|
|
||||||
|
interface LocaleProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Провайдер локализации
|
||||||
|
*/
|
||||||
|
export const LocaleProvider: React.FC<LocaleProviderProps> = ({ children }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const [locale, setLocaleState] = useState<Locale>(() => {
|
||||||
|
// Если путь указывает на английский, используем его
|
||||||
|
if (location.pathname.startsWith('/en')) {
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
// Иначе определяем по настройкам
|
||||||
|
return detectUserLocale();
|
||||||
|
});
|
||||||
|
|
||||||
|
const setLocale = useCallback((newLocale: Locale) => {
|
||||||
|
setUserLocale(newLocale);
|
||||||
|
setLocaleState(newLocale);
|
||||||
|
|
||||||
|
// Редирект на новую локаль
|
||||||
|
const { newPath } = shouldRedirect(location.pathname, newLocale);
|
||||||
|
if (newPath !== location.pathname) {
|
||||||
|
navigate(newPath + location.search + location.hash, { replace: true });
|
||||||
|
}
|
||||||
|
}, [location.pathname, location.search, location.hash, navigate]);
|
||||||
|
|
||||||
|
// Функция перевода (заглушка - можно расширить)
|
||||||
|
const t = useCallback((key: string): string => {
|
||||||
|
// TODO: Подключить реальные переводы
|
||||||
|
return key;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Проверяем редирект при первой загрузке
|
||||||
|
useEffect(() => {
|
||||||
|
const { redirect, newPath } = shouldRedirect(location.pathname, locale);
|
||||||
|
if (redirect) {
|
||||||
|
navigate(newPath + location.search + location.hash, { replace: true });
|
||||||
|
}
|
||||||
|
}, [location.pathname, location.search, location.hash, locale, navigate]);
|
||||||
|
|
||||||
|
const contextValue = useMemo(() => ({
|
||||||
|
locale,
|
||||||
|
setLocale,
|
||||||
|
t,
|
||||||
|
}), [locale, setLocale, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LocaleContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</LocaleContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
13
ospabhost/frontend/src/middleware/index.ts
Normal file
13
ospabhost/frontend/src/middleware/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// Экспорт всей системы локализации из одного места
|
||||||
|
export { LocaleProvider } from './LocaleProvider';
|
||||||
|
export { LocaleContext } from './locale.context';
|
||||||
|
export { useLocale, useLocalePath } from './locale.hooks';
|
||||||
|
export {
|
||||||
|
detectUserLocale,
|
||||||
|
setUserLocale,
|
||||||
|
getLocaleFromPath,
|
||||||
|
localePath,
|
||||||
|
RUSSIAN_COUNTRIES,
|
||||||
|
EXCLUDED_PATHS,
|
||||||
|
} from './locale.utils';
|
||||||
|
export type { Locale } from './locale.utils';
|
||||||
15
ospabhost/frontend/src/middleware/locale.context.ts
Normal file
15
ospabhost/frontend/src/middleware/locale.context.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { Locale } from './locale.utils';
|
||||||
|
|
||||||
|
// Контекст локали
|
||||||
|
interface LocaleContextValue {
|
||||||
|
locale: Locale;
|
||||||
|
setLocale: (locale: Locale) => void;
|
||||||
|
t: (key: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LocaleContext = React.createContext<LocaleContextValue>({
|
||||||
|
locale: 'ru',
|
||||||
|
setLocale: () => {},
|
||||||
|
t: (key: string) => key,
|
||||||
|
});
|
||||||
20
ospabhost/frontend/src/middleware/locale.hooks.ts
Normal file
20
ospabhost/frontend/src/middleware/locale.hooks.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { LocaleContext } from './locale.context';
|
||||||
|
import type { Locale } from './locale.utils';
|
||||||
|
import { localePath } from './locale.utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хук для получения текущей локали и функции смены
|
||||||
|
*/
|
||||||
|
export const useLocale = () => React.useContext(LocaleContext);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хук для получения пути с учётом текущей локали
|
||||||
|
*/
|
||||||
|
export function useLocalePath() {
|
||||||
|
const { locale } = useLocale();
|
||||||
|
return (path: string) => localePath(path, locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Реэкспортируем типы для удобства
|
||||||
|
export type { Locale };
|
||||||
124
ospabhost/frontend/src/middleware/locale.utils.ts
Normal file
124
ospabhost/frontend/src/middleware/locale.utils.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
// Поддерживаемые локали
|
||||||
|
export type Locale = 'ru' | 'en';
|
||||||
|
|
||||||
|
// Список стран СНГ где используется русский (для будущего геоопределения)
|
||||||
|
export const RUSSIAN_COUNTRIES = [
|
||||||
|
'RU', // Россия
|
||||||
|
'BY', // Беларусь
|
||||||
|
'KZ', // Казахстан
|
||||||
|
'UA', // Украина
|
||||||
|
'UZ', // Узбекистан
|
||||||
|
'TJ', // Таджикистан
|
||||||
|
'KG', // Кыргызстан
|
||||||
|
'TM', // Туркменистан
|
||||||
|
'MD', // Молдова
|
||||||
|
'AZ', // Азербайджан
|
||||||
|
'AM', // Армения
|
||||||
|
'GE', // Грузия
|
||||||
|
];
|
||||||
|
|
||||||
|
// Страницы, которые не нуждаются в локализации (API, статика и т.д.)
|
||||||
|
export const EXCLUDED_PATHS = ['/api/', '/uploads/', '/assets/', '/favicon'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Определяет локаль пользователя
|
||||||
|
*/
|
||||||
|
export function detectUserLocale(): Locale {
|
||||||
|
// 1. Проверяем сохранённую локаль
|
||||||
|
const saved = localStorage.getItem('locale') as Locale | null;
|
||||||
|
if (saved && (saved === 'ru' || saved === 'en')) {
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Проверяем языковые настройки браузера
|
||||||
|
const browserLang = navigator.language || (navigator as unknown as { userLanguage?: string }).userLanguage;
|
||||||
|
if (browserLang) {
|
||||||
|
if (browserLang.startsWith('ru')) {
|
||||||
|
return 'ru';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. По умолчанию - английский для остального мира
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохраняет локаль пользователя
|
||||||
|
*/
|
||||||
|
export function setUserLocale(locale: Locale): void {
|
||||||
|
localStorage.setItem('locale', locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Определяет, нужен ли редирект для текущего пути
|
||||||
|
*/
|
||||||
|
export function shouldRedirect(pathname: string, locale: Locale): { redirect: boolean; newPath: string } {
|
||||||
|
// Исключаем служебные пути
|
||||||
|
for (const excluded of EXCLUDED_PATHS) {
|
||||||
|
if (pathname.startsWith(excluded)) {
|
||||||
|
return { redirect: false, newPath: pathname };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathParts = pathname.split('/').filter(Boolean);
|
||||||
|
const firstPart = pathParts[0];
|
||||||
|
|
||||||
|
// Проверяем, начинается ли путь с локали
|
||||||
|
const hasLocalePrefix = firstPart === 'en' || firstPart === 'ru';
|
||||||
|
|
||||||
|
if (locale === 'ru') {
|
||||||
|
// Русский - без префикса
|
||||||
|
if (hasLocalePrefix && firstPart === 'ru') {
|
||||||
|
// Убираем /ru/ из пути
|
||||||
|
const newPath = '/' + pathParts.slice(1).join('/');
|
||||||
|
return { redirect: true, newPath: newPath || '/' };
|
||||||
|
}
|
||||||
|
if (hasLocalePrefix && firstPart === 'en') {
|
||||||
|
// Пользователь на /en/, но локаль ru - убираем префикс
|
||||||
|
const newPath = '/' + pathParts.slice(1).join('/');
|
||||||
|
return { redirect: true, newPath: newPath || '/' };
|
||||||
|
}
|
||||||
|
// Уже на русской версии без префикса
|
||||||
|
return { redirect: false, newPath: pathname };
|
||||||
|
} else {
|
||||||
|
// Английский - с префиксом /en/
|
||||||
|
if (!hasLocalePrefix) {
|
||||||
|
// Добавляем /en/ в начало
|
||||||
|
return { redirect: true, newPath: '/en' + pathname };
|
||||||
|
}
|
||||||
|
if (firstPart === 'ru') {
|
||||||
|
// Меняем /ru/ на /en/
|
||||||
|
const newPath = '/en/' + pathParts.slice(1).join('/');
|
||||||
|
return { redirect: true, newPath };
|
||||||
|
}
|
||||||
|
// Уже на /en/
|
||||||
|
return { redirect: false, newPath: pathname };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Извлекает локаль из пути
|
||||||
|
*/
|
||||||
|
export function getLocaleFromPath(pathname: string): Locale {
|
||||||
|
const firstPart = pathname.split('/').filter(Boolean)[0];
|
||||||
|
if (firstPart === 'en') return 'en';
|
||||||
|
return 'ru';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создаёт путь с учётом локали
|
||||||
|
*/
|
||||||
|
export function localePath(path: string, locale: Locale): string {
|
||||||
|
// Убираем существующий префикс локали если есть
|
||||||
|
let cleanPath = path;
|
||||||
|
if (path.startsWith('/en/') || path.startsWith('/ru/')) {
|
||||||
|
cleanPath = path.slice(3);
|
||||||
|
} else if (path === '/en' || path === '/ru') {
|
||||||
|
cleanPath = '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locale === 'en') {
|
||||||
|
return '/en' + (cleanPath.startsWith('/') ? cleanPath : '/' + cleanPath);
|
||||||
|
}
|
||||||
|
return cleanPath.startsWith('/') ? cleanPath : '/' + cleanPath;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
FiGlobe
|
FiGlobe
|
||||||
} from 'react-icons/fi';
|
} from 'react-icons/fi';
|
||||||
import apiClient from '../../utils/apiClient';
|
import apiClient from '../../utils/apiClient';
|
||||||
|
import useAuth from '../../context/useAuth';
|
||||||
import { API_URL } from '../../config/api';
|
import { API_URL } from '../../config/api';
|
||||||
import type { StorageBucket } from './types';
|
import type { StorageBucket } from './types';
|
||||||
|
|
||||||
@@ -73,6 +74,10 @@ const Checkout: React.FC = () => {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [regions, setRegions] = useState<StorageRegionOption[]>([]);
|
const [regions, setRegions] = useState<StorageRegionOption[]>([]);
|
||||||
const [loadingRegions, setLoadingRegions] = useState<boolean>(false);
|
const [loadingRegions, setLoadingRegions] = useState<boolean>(false);
|
||||||
|
const [promoCode, setPromoCode] = useState<string>('');
|
||||||
|
const [promoApplied, setPromoApplied] = useState<boolean>(false);
|
||||||
|
const [promoError, setPromoError] = useState<string | null>(null);
|
||||||
|
const { isLoggedIn } = useAuth();
|
||||||
|
|
||||||
const fetchBalance = useCallback(async () => {
|
const fetchBalance = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -180,6 +185,19 @@ const Checkout: React.FC = () => {
|
|||||||
|
|
||||||
const balanceAfterPayment = useMemo(() => balance - planPrice, [balance, planPrice]);
|
const balanceAfterPayment = useMemo(() => balance - planPrice, [balance, planPrice]);
|
||||||
|
|
||||||
|
const handleApplyPromo = useCallback(async () => {
|
||||||
|
if (!cart) return;
|
||||||
|
setPromoError(null);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post(`${API_URL}/api/storage/cart/${cart.cartId}/apply-promo`, { promoCode });
|
||||||
|
const updated = res.data?.cart;
|
||||||
|
if (updated) setCart(updated as CartPayload);
|
||||||
|
setPromoApplied(true);
|
||||||
|
} catch (err) {
|
||||||
|
setPromoError(err instanceof Error ? err.message : 'Не удалось применить промокод');
|
||||||
|
}
|
||||||
|
}, [cart, promoCode]);
|
||||||
|
|
||||||
const formatCurrency = useCallback((amount: number) => `₽${amount.toLocaleString('ru-RU', {
|
const formatCurrency = useCallback((amount: number) => `₽${amount.toLocaleString('ru-RU', {
|
||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 2,
|
||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 2,
|
||||||
@@ -269,6 +287,16 @@ const Checkout: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="w-full md:w-1/3 mt-4 md:mt-0">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Промокод</label>
|
||||||
|
<div className="flex gap-2 mt-1">
|
||||||
|
<input value={promoCode} onChange={(e) => setPromoCode(e.target.value)} className="border rounded px-2 py-1 flex-1" placeholder="Введите промокод" />
|
||||||
|
<button onClick={handleApplyPromo} disabled={!isLoggedIn} className={`px-3 py-1 rounded ${isLoggedIn ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-600'}`}>{isLoggedIn ? 'Применить' : 'Войдите, чтобы применить'}</button>
|
||||||
|
</div>
|
||||||
|
{promoError && <div className="text-red-500 text-sm mt-1">{promoError}</div>}
|
||||||
|
{promoApplied && !promoError && <div className="text-green-600 text-sm mt-1">Промокод применён</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { isAxiosError } from 'axios';
|
import { isAxiosError } from 'axios';
|
||||||
import {
|
import {
|
||||||
FiArrowLeft,
|
FiArrowLeft,
|
||||||
@@ -14,7 +14,11 @@ import {
|
|||||||
FiHelpCircle,
|
FiHelpCircle,
|
||||||
FiSettings,
|
FiSettings,
|
||||||
FiFolder,
|
FiFolder,
|
||||||
|
FiFolderMinus,
|
||||||
|
FiFile,
|
||||||
FiBarChart2,
|
FiBarChart2,
|
||||||
|
FiChevronRight,
|
||||||
|
FiChevronDown,
|
||||||
} from 'react-icons/fi';
|
} from 'react-icons/fi';
|
||||||
import apiClient from '../../utils/apiClient';
|
import apiClient from '../../utils/apiClient';
|
||||||
import { useToast } from '../../hooks/useToast';
|
import { useToast } from '../../hooks/useToast';
|
||||||
@@ -38,6 +42,16 @@ interface CreatedKey {
|
|||||||
label?: string | null;
|
label?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Тип для дерева файлов (проводник)
|
||||||
|
interface FileTreeNode {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
isFolder: boolean;
|
||||||
|
size: number;
|
||||||
|
lastModified?: string;
|
||||||
|
children: Record<string, FileTreeNode>;
|
||||||
|
}
|
||||||
|
|
||||||
interface UploadProgress {
|
interface UploadProgress {
|
||||||
loaded: number;
|
loaded: number;
|
||||||
total: number;
|
total: number;
|
||||||
@@ -76,15 +90,164 @@ type LoadObjectsOptions = {
|
|||||||
prefix?: string;
|
prefix?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ConsoleCredentials = {
|
// Компонент для отображения дерева файлов (проводник)
|
||||||
login: string;
|
interface FileTreeViewProps {
|
||||||
password: string;
|
node: FileTreeNode;
|
||||||
url?: string | null;
|
depth: number;
|
||||||
};
|
expandedFolders: Record<string, boolean>;
|
||||||
|
toggleFolder: (path: string) => void;
|
||||||
|
selectedKeys: Record<string, boolean>;
|
||||||
|
handleToggleSelection: (key: string) => void;
|
||||||
|
handleDownloadObject: (object: StorageObject) => void;
|
||||||
|
formatBytes: (bytes: number) => string;
|
||||||
|
formatDate: (value?: string | null, withTime?: boolean) => string;
|
||||||
|
objects: StorageObject[];
|
||||||
|
}
|
||||||
|
|
||||||
type BucketLocationState = {
|
const FileTreeView: React.FC<FileTreeViewProps> = ({
|
||||||
consoleCredentials?: ConsoleCredentials;
|
node,
|
||||||
bucketName?: string;
|
depth,
|
||||||
|
expandedFolders,
|
||||||
|
toggleFolder,
|
||||||
|
selectedKeys,
|
||||||
|
handleToggleSelection,
|
||||||
|
handleDownloadObject,
|
||||||
|
formatBytes,
|
||||||
|
formatDate,
|
||||||
|
objects,
|
||||||
|
}) => {
|
||||||
|
const children = Object.values(node.children);
|
||||||
|
|
||||||
|
// Сортируем: сначала папки, потом файлы, алфавитно
|
||||||
|
const sortedChildren = children.sort((a, b) => {
|
||||||
|
if (a.isFolder && !b.isFolder) return -1;
|
||||||
|
if (!a.isFolder && b.isFolder) return 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Для корневой ноды (depth 0) отображаем только детей
|
||||||
|
if (depth === 0) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{sortedChildren.map((child) => (
|
||||||
|
<FileTreeView
|
||||||
|
key={child.path}
|
||||||
|
node={child}
|
||||||
|
depth={depth + 1}
|
||||||
|
expandedFolders={expandedFolders}
|
||||||
|
toggleFolder={toggleFolder}
|
||||||
|
selectedKeys={selectedKeys}
|
||||||
|
handleToggleSelection={handleToggleSelection}
|
||||||
|
handleDownloadObject={handleDownloadObject}
|
||||||
|
formatBytes={formatBytes}
|
||||||
|
formatDate={formatDate}
|
||||||
|
objects={objects}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isExpanded = expandedFolders[node.path] ?? false;
|
||||||
|
const paddingLeft = (depth - 1) * 20 + 12;
|
||||||
|
|
||||||
|
if (node.isFolder) {
|
||||||
|
// Считаем общий размер папки
|
||||||
|
const folderSize = objects
|
||||||
|
.filter((obj) => obj.key.startsWith(node.path + '/'))
|
||||||
|
.reduce((acc, obj) => acc + obj.size, 0);
|
||||||
|
|
||||||
|
// Считаем количество файлов в папке
|
||||||
|
const fileCount = objects.filter((obj) => obj.key.startsWith(node.path + '/')).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-4 py-2 hover:bg-gray-50 cursor-pointer text-sm"
|
||||||
|
style={{ paddingLeft }}
|
||||||
|
onClick={() => toggleFolder(node.path)}
|
||||||
|
>
|
||||||
|
<span className="w-6 flex items-center justify-center text-gray-400">
|
||||||
|
{isExpanded ? <FiChevronDown /> : <FiChevronRight />}
|
||||||
|
</span>
|
||||||
|
<span className="text-yellow-500">
|
||||||
|
<FiFolder />
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 font-medium text-gray-800">
|
||||||
|
{node.name}
|
||||||
|
<span className="ml-2 text-xs text-gray-400 font-normal">({fileCount} файл.)</span>
|
||||||
|
</span>
|
||||||
|
<span className="w-24 text-right text-xs text-gray-500">{formatBytes(folderSize)}</span>
|
||||||
|
<span className="w-40 text-right text-xs text-gray-400">—</span>
|
||||||
|
<span className="w-32"></span>
|
||||||
|
</div>
|
||||||
|
{isExpanded && (
|
||||||
|
<>
|
||||||
|
{sortedChildren.map((child) => (
|
||||||
|
<FileTreeView
|
||||||
|
key={child.path}
|
||||||
|
node={child}
|
||||||
|
depth={depth + 1}
|
||||||
|
expandedFolders={expandedFolders}
|
||||||
|
toggleFolder={toggleFolder}
|
||||||
|
selectedKeys={selectedKeys}
|
||||||
|
handleToggleSelection={handleToggleSelection}
|
||||||
|
handleDownloadObject={handleDownloadObject}
|
||||||
|
formatBytes={formatBytes}
|
||||||
|
formatDate={formatDate}
|
||||||
|
objects={objects}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Файл
|
||||||
|
const storageObject = objects.find((obj) => obj.key === node.path);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-4 py-2 hover:bg-gray-50 text-sm"
|
||||||
|
style={{ paddingLeft }}
|
||||||
|
>
|
||||||
|
<span className="w-6 flex items-center justify-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!selectedKeys[node.path]}
|
||||||
|
onChange={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleToggleSelection(node.path);
|
||||||
|
}}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400">
|
||||||
|
<FiFile />
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 font-mono text-xs text-gray-700 break-all">{node.name}</span>
|
||||||
|
<span className="w-24 text-right text-xs text-gray-600">{formatBytes(node.size)}</span>
|
||||||
|
<span className="w-40 text-right text-xs text-gray-500">
|
||||||
|
{node.lastModified ? formatDate(node.lastModified, true) : '—'}
|
||||||
|
</span>
|
||||||
|
<span className="w-32 text-right">
|
||||||
|
{storageObject && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDownloadObject(storageObject);
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 border border-gray-200 rounded text-xs text-gray-600 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<FiDownload />
|
||||||
|
Скачать
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const StorageBucketPage: React.FC = () => {
|
const StorageBucketPage: React.FC = () => {
|
||||||
@@ -92,7 +255,6 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
const bucketNumber = bucketIdParam ? Number(bucketIdParam) : NaN;
|
const bucketNumber = bucketIdParam ? Number(bucketIdParam) : NaN;
|
||||||
const bucketIdValid = Number.isInteger(bucketNumber) && bucketNumber > 0;
|
const bucketIdValid = Number.isInteger(bucketNumber) && bucketNumber > 0;
|
||||||
|
|
||||||
const location = useLocation();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
|
||||||
@@ -127,6 +289,9 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
const [resumedFiles, setResumedFiles] = useState<File[]>([]);
|
const [resumedFiles, setResumedFiles] = useState<File[]>([]);
|
||||||
const uploadAbortControllerRef = useRef<AbortController | null>(null);
|
const uploadAbortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
// Для проводника - какие папки развёрнуты
|
||||||
|
const [expandedFolders, setExpandedFolders] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const [accessKeys, setAccessKeys] = useState<StorageAccessKey[]>([]);
|
const [accessKeys, setAccessKeys] = useState<StorageAccessKey[]>([]);
|
||||||
const [accessKeysLoading, setAccessKeysLoading] = useState(false);
|
const [accessKeysLoading, setAccessKeysLoading] = useState(false);
|
||||||
const [newKeyLabel, setNewKeyLabel] = useState('');
|
const [newKeyLabel, setNewKeyLabel] = useState('');
|
||||||
@@ -141,12 +306,70 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
const selectedCount = selectedList.length;
|
const selectedCount = selectedList.length;
|
||||||
const allSelected = objects.length > 0 && objects.every((object) => selectedKeys[object.key]);
|
const allSelected = objects.length > 0 && objects.every((object) => selectedKeys[object.key]);
|
||||||
|
|
||||||
const [consoleCredentials, setConsoleCredentials] = useState<ConsoleCredentials | null>(() => {
|
// Строим дерево файлов для проводника
|
||||||
const state = location.state as BucketLocationState | undefined;
|
const fileTree = useMemo(() => {
|
||||||
return state?.consoleCredentials ?? null;
|
const root: FileTreeNode = {
|
||||||
});
|
name: '',
|
||||||
const [consoleCredentialsLoading, setConsoleCredentialsLoading] = useState(false);
|
path: '',
|
||||||
const [consoleCredentialsError, setConsoleCredentialsError] = useState<string | null>(null);
|
isFolder: true,
|
||||||
|
size: 0,
|
||||||
|
children: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const obj of objects) {
|
||||||
|
const parts = obj.key.split('/').filter(Boolean);
|
||||||
|
let current = root;
|
||||||
|
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const part = parts[i];
|
||||||
|
const isLast = i === parts.length - 1;
|
||||||
|
const currentPath = parts.slice(0, i + 1).join('/');
|
||||||
|
|
||||||
|
if (!current.children[part]) {
|
||||||
|
current.children[part] = {
|
||||||
|
name: part,
|
||||||
|
path: currentPath,
|
||||||
|
isFolder: !isLast,
|
||||||
|
size: isLast ? obj.size : 0,
|
||||||
|
lastModified: isLast ? obj.lastModified : undefined,
|
||||||
|
children: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLast) {
|
||||||
|
// Это промежуточная папка
|
||||||
|
current.children[part].isFolder = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
current = current.children[part];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}, [objects]);
|
||||||
|
|
||||||
|
const toggleFolder = useCallback((path: string) => {
|
||||||
|
setExpandedFolders((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[path]: !prev[path],
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const expandAllFolders = useCallback(() => {
|
||||||
|
const allPaths: Record<string, boolean> = {};
|
||||||
|
const collectPaths = (node: FileTreeNode) => {
|
||||||
|
if (node.isFolder && node.path) {
|
||||||
|
allPaths[node.path] = true;
|
||||||
|
}
|
||||||
|
Object.values(node.children).forEach(collectPaths);
|
||||||
|
};
|
||||||
|
collectPaths(fileTree);
|
||||||
|
setExpandedFolders(allPaths);
|
||||||
|
}, [fileTree]);
|
||||||
|
|
||||||
|
const collapseAllFolders = useCallback(() => {
|
||||||
|
setExpandedFolders({});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const dispatchBucketsRefresh = useCallback(() => {
|
const dispatchBucketsRefresh = useCallback(() => {
|
||||||
window.dispatchEvent(new Event('storageBucketsRefresh'));
|
window.dispatchEvent(new Event('storageBucketsRefresh'));
|
||||||
@@ -311,41 +534,6 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
loadObjects({ reset: true, prefix: '' });
|
loadObjects({ reset: true, prefix: '' });
|
||||||
}, [loadObjects]);
|
}, [loadObjects]);
|
||||||
|
|
||||||
const handleGenerateConsoleCredentials = useCallback(async () => {
|
|
||||||
if (!bucketIdValid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setConsoleCredentialsLoading(true);
|
|
||||||
setConsoleCredentialsError(null);
|
|
||||||
setConsoleCredentials(null);
|
|
||||||
const { data } = await apiClient.post<{ credentials?: ConsoleCredentials }>(
|
|
||||||
`/api/storage/buckets/${bucketNumber}/console-credentials`
|
|
||||||
);
|
|
||||||
|
|
||||||
const credentials = data?.credentials;
|
|
||||||
if (!credentials) {
|
|
||||||
throw new Error('Сервер не вернул данные входа');
|
|
||||||
}
|
|
||||||
|
|
||||||
setConsoleCredentials(credentials);
|
|
||||||
addToast('Создан новый пароль для MinIO Console', 'success');
|
|
||||||
await fetchBucket({ silent: true });
|
|
||||||
} catch (error) {
|
|
||||||
let message = 'Не удалось сгенерировать данные входа';
|
|
||||||
if (isAxiosError(error) && typeof error.response?.data?.error === 'string') {
|
|
||||||
message = error.response.data.error;
|
|
||||||
} else if (error instanceof Error) {
|
|
||||||
message = error.message;
|
|
||||||
}
|
|
||||||
setConsoleCredentialsError(message);
|
|
||||||
addToast(message, 'error');
|
|
||||||
} finally {
|
|
||||||
setConsoleCredentialsLoading(false);
|
|
||||||
}
|
|
||||||
}, [addToast, bucketIdValid, bucketNumber, fetchBucket]);
|
|
||||||
|
|
||||||
const handleSelectAll = useCallback(() => {
|
const handleSelectAll = useCallback(() => {
|
||||||
if (allSelected) {
|
if (allSelected) {
|
||||||
setSelectedKeys({});
|
setSelectedKeys({});
|
||||||
@@ -441,18 +629,25 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
uploadAbortControllerRef.current = abortController;
|
uploadAbortControllerRef.current = abortController;
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
|
|
||||||
|
// Подсчитываем общий размер для единого прогресса
|
||||||
|
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
|
||||||
|
let totalLoaded = 0;
|
||||||
|
const uploadStartTime = Date.now();
|
||||||
|
|
||||||
setUploadStats({ currentFile: '', completedFiles: 0, totalFiles: files.length });
|
setUploadStats({ currentFile: '', completedFiles: 0, totalFiles: files.length });
|
||||||
const progressMap: Record<string, UploadProgress> = {};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Сохраняем файлы в IndexedDB перед загрузкой
|
// Сохраняем файлы в IndexedDB перед загрузкой
|
||||||
const { saveFile } = await import('../../utils/uploadDB');
|
const { saveFile } = await import('../../utils/uploadDB');
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
|
// Используем webkitRelativePath для сохранения структуры папки
|
||||||
|
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
await saveFile({
|
await saveFile({
|
||||||
id: `${bucketNumber}_${Date.now()}_${file.name}`,
|
id: `${bucketNumber}_${Date.now()}_${relativePath}`,
|
||||||
bucketId: bucketNumber,
|
bucketId: bucketNumber,
|
||||||
name: file.name,
|
name: relativePath,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
type: file.type,
|
type: file.type,
|
||||||
data: arrayBuffer,
|
data: arrayBuffer,
|
||||||
@@ -470,50 +665,69 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const file = files[i];
|
const file = files[i];
|
||||||
setUploadStats({ currentFile: file.name, completedFiles: i, totalFiles: files.length });
|
// Используем webkitRelativePath для сохранения структуры папки
|
||||||
|
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
|
||||||
|
const displayName = relativePath.split('/').pop() || file.name;
|
||||||
|
|
||||||
progressMap[file.name] = { loaded: 0, total: file.size, speed: 0, percentage: 0 };
|
setUploadStats({ currentFile: displayName, completedFiles: i, totalFiles: files.length });
|
||||||
|
|
||||||
const key = normalizedPrefix ? `${normalizedPrefix}/${file.name}` : file.name;
|
// Формируем ключ с учётом относительного пути (структуры папки)
|
||||||
|
let key: string;
|
||||||
|
if (relativePath && relativePath !== file.name) {
|
||||||
|
// Загрузка папки - сохраняем структуру
|
||||||
|
key = normalizedPrefix ? `${normalizedPrefix}/${relativePath}` : relativePath;
|
||||||
|
} else {
|
||||||
|
// Обычная загрузка файла
|
||||||
|
key = normalizedPrefix ? `${normalizedPrefix}/${file.name}` : file.name;
|
||||||
|
}
|
||||||
const { data } = await apiClient.post<PresignResponse>(`/api/storage/buckets/${bucketNumber}/objects/presign`, {
|
const { data } = await apiClient.post<PresignResponse>(`/api/storage/buckets/${bucketNumber}/objects/presign`, {
|
||||||
key,
|
key,
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
contentType: file.type || undefined,
|
contentType: file.type || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const startTime = Date.now();
|
let fileLoaded = 0; // Отслеживаем прогресс текущего файла
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
|
|
||||||
// Track upload progress
|
// Track upload progress - единый прогресс-бар
|
||||||
xhr.upload.addEventListener('progress', (event) => {
|
xhr.upload.addEventListener('progress', (event) => {
|
||||||
if (event.lengthComputable) {
|
if (event.lengthComputable) {
|
||||||
const elapsed = (Date.now() - startTime) / 1000;
|
// Обновляем прогресс для этого файла
|
||||||
const speed = elapsed > 0 ? event.loaded / elapsed : 0;
|
const prevFileLoaded = fileLoaded;
|
||||||
const percentage = Math.round((event.loaded / event.total) * 100);
|
fileLoaded = event.loaded;
|
||||||
|
totalLoaded += (fileLoaded - prevFileLoaded);
|
||||||
|
|
||||||
progressMap[file.name] = {
|
const elapsed = (Date.now() - uploadStartTime) / 1000;
|
||||||
loaded: event.loaded,
|
const speed = elapsed > 0 ? totalLoaded / elapsed : 0;
|
||||||
total: event.total,
|
const percentage = totalSize > 0 ? Math.round((totalLoaded / totalSize) * 100) : 0;
|
||||||
|
|
||||||
|
// Единый прогресс в __total__
|
||||||
|
setUploadProgress({
|
||||||
|
__total__: {
|
||||||
|
loaded: totalLoaded,
|
||||||
|
total: totalSize,
|
||||||
speed,
|
speed,
|
||||||
percentage,
|
percentage,
|
||||||
};
|
},
|
||||||
setUploadProgress({ ...progressMap });
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
xhr.addEventListener('load', () => {
|
xhr.addEventListener('load', () => {
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
progressMap[file.name].percentage = 100;
|
// Убедимся, что файл полностью засчитан
|
||||||
setUploadProgress({ ...progressMap });
|
if (fileLoaded < file.size) {
|
||||||
|
totalLoaded += (file.size - fileLoaded);
|
||||||
|
}
|
||||||
resolve();
|
resolve();
|
||||||
} else {
|
} else {
|
||||||
reject(new Error(`Загрузка файла «${file.name}» завершилась с ошибкой (${xhr.status})`));
|
reject(new Error(`Загрузка файла «${displayName}» завершилась с ошибкой (${xhr.status})`));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
xhr.addEventListener('error', () => {
|
xhr.addEventListener('error', () => {
|
||||||
reject(new Error(`Ошибка при загрузке файла «${file.name}»`));
|
reject(new Error(`Ошибка при загрузке файла «${displayName}»`));
|
||||||
});
|
});
|
||||||
|
|
||||||
xhr.open('PUT', data.url);
|
xhr.open('PUT', data.url);
|
||||||
@@ -599,30 +813,67 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[URI Upload] Начало загрузки, URL:', uriUploadUrl);
|
||||||
setUriUploadLoading(true);
|
setUriUploadLoading(true);
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
uriUploadAbortControllerRef.current = abortController;
|
uriUploadAbortControllerRef.current = abortController;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Используем бэкенд proxy для обхода CORS с увеличенным timeout
|
// Используем бэкенд proxy для обхода CORS с увеличенным timeout
|
||||||
|
console.log('[URI Upload] Отправляем запрос на бэкенд...');
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/api/storage/buckets/${bucketNumber}/objects/download-from-uri`,
|
`/api/storage/buckets/${bucketNumber}/objects/download-from-uri`,
|
||||||
{ url: uriUploadUrl },
|
{ url: uriUploadUrl },
|
||||||
{ timeout: 120000 } // 120 seconds timeout
|
{ timeout: 120000 } // 120 seconds timeout
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log('[URI Upload] Ответ получен:', {
|
||||||
|
hasBlob: !!response.data?.blob,
|
||||||
|
blobLength: response.data?.blob?.length,
|
||||||
|
mimeType: response.data?.mimeType,
|
||||||
|
});
|
||||||
|
|
||||||
if (response.data?.blob) {
|
if (response.data?.blob) {
|
||||||
const blob = new Blob([response.data.blob], { type: response.data.mimeType || 'application/octet-stream' });
|
// Декодируем base64 в бинарные данные
|
||||||
const fileName = uriUploadUrl.split('/').pop() || 'file';
|
const base64 = response.data.blob;
|
||||||
|
console.log('[URI Upload] Декодируем base64, длина:', base64.length);
|
||||||
|
|
||||||
|
const binaryString = atob(base64);
|
||||||
|
console.log('[URI Upload] Бинарная строка длина:', binaryString.length);
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(binaryString.length);
|
||||||
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
|
bytes[i] = binaryString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
const blob = new Blob([bytes], { type: response.data.mimeType || 'application/octet-stream' });
|
||||||
|
console.log('[URI Upload] Blob создан, размер:', blob.size, 'тип:', blob.type);
|
||||||
|
|
||||||
|
// Извлекаем имя файла из URL
|
||||||
|
const urlObj = new URL(uriUploadUrl);
|
||||||
|
let fileName = urlObj.pathname.split('/').pop() || 'file';
|
||||||
|
// Убираем query-параметры из имени
|
||||||
|
if (fileName.includes('?')) {
|
||||||
|
fileName = fileName.split('?')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[URI Upload] Имя файла:', fileName);
|
||||||
const file = new File([blob], fileName, { type: blob.type });
|
const file = new File([blob], fileName, { type: blob.type });
|
||||||
|
console.log('[URI Upload] File объект создан, размер:', file.size);
|
||||||
|
|
||||||
await performUpload([file]);
|
await performUpload([file]);
|
||||||
setUriUploadUrl('');
|
setUriUploadUrl('');
|
||||||
|
addToast(`Файл "${fileName}" загружен`, 'success');
|
||||||
|
} else {
|
||||||
|
console.error('[URI Upload] Ответ не содержит blob:', response.data);
|
||||||
|
addToast('Сервер не вернул данные файла', 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('[URI Upload] Ошибка:', error);
|
||||||
let message = 'Не удалось загрузить по URI';
|
let message = 'Не удалось загрузить по URI';
|
||||||
if (error instanceof Error && error.message === 'canceled') {
|
if (error instanceof Error && error.message === 'canceled') {
|
||||||
message = 'Загрузка отменена';
|
message = 'Загрузка отменена';
|
||||||
} else if (isAxiosError(error) && error.response?.data?.error) {
|
} else if (isAxiosError(error) && error.response?.data?.error) {
|
||||||
|
console.error('[URI Upload] Ошибка от сервера:', error.response.data.error);
|
||||||
message = error.response.data.error;
|
message = error.response.data.error;
|
||||||
} else if (error instanceof Error) {
|
} else if (error instanceof Error) {
|
||||||
message = error.message;
|
message = error.message;
|
||||||
@@ -827,14 +1078,11 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
setObjectPrefix('');
|
setObjectPrefix('');
|
||||||
objectPrefixRef.current = '';
|
objectPrefixRef.current = '';
|
||||||
setLastCreatedKey(null);
|
setLastCreatedKey(null);
|
||||||
setConsoleCredentials((location.state as BucketLocationState | undefined)?.consoleCredentials ?? null);
|
|
||||||
setConsoleCredentialsError(null);
|
|
||||||
setConsoleCredentialsLoading(false);
|
|
||||||
|
|
||||||
fetchBucket();
|
fetchBucket();
|
||||||
loadObjects({ reset: true, prefix: '' });
|
loadObjects({ reset: true, prefix: '' });
|
||||||
fetchAccessKeys();
|
fetchAccessKeys();
|
||||||
}, [bucketIdValid, fetchAccessKeys, fetchBucket, loadObjects, location.state]);
|
}, [bucketIdValid, fetchAccessKeys, fetchBucket, loadObjects]);
|
||||||
|
|
||||||
const bucketUsagePercent = bucket ? getUsagePercent(bucket.usedBytes, bucket.quotaGb) : 0;
|
const bucketUsagePercent = bucket ? getUsagePercent(bucket.usedBytes, bucket.quotaGb) : 0;
|
||||||
const bucketPlanName = bucket?.planDetails?.name ?? bucket?.plan ?? '';
|
const bucketPlanName = bucket?.planDetails?.name ?? bucket?.plan ?? '';
|
||||||
@@ -844,9 +1092,6 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
const bucketPrice = typeof bucketPriceValue === 'number' && Number.isFinite(bucketPriceValue)
|
const bucketPrice = typeof bucketPriceValue === 'number' && Number.isFinite(bucketPriceValue)
|
||||||
? formatCurrency(bucketPriceValue)
|
? formatCurrency(bucketPriceValue)
|
||||||
: '—';
|
: '—';
|
||||||
const consoleLoginValue = consoleCredentials?.login ?? bucket?.consoleLogin ?? bucket?.name ?? '';
|
|
||||||
const consoleLoginDisplay = consoleLoginValue || '—';
|
|
||||||
const consoleUrl = consoleCredentials?.url ?? bucket?.consoleUrl ?? null;
|
|
||||||
|
|
||||||
const activeTabMeta = TAB_ITEMS.find((item) => item.key === activeTab);
|
const activeTabMeta = TAB_ITEMS.find((item) => item.key === activeTab);
|
||||||
|
|
||||||
@@ -1097,25 +1342,25 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
Загрузка файла {uploadStats.completedFiles + 1} из {uploadStats.totalFiles}: <span className="text-ospab-primary">{uploadStats.currentFile}</span>
|
Загрузка файла {uploadStats.completedFiles + 1} из {uploadStats.totalFiles}: <span className="text-ospab-primary">{uploadStats.currentFile}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{Object.entries(uploadProgress).map(([fileName, progress]: [string, UploadProgress]) => {
|
{uploadProgress.__total__ && (
|
||||||
const speedMB = (progress.speed / (1024 * 1024)).toFixed(2);
|
<div className="space-y-2">
|
||||||
return (
|
|
||||||
<div key={fileName} className="space-y-2">
|
|
||||||
<div className="flex justify-between items-center text-xs">
|
<div className="flex justify-between items-center text-xs">
|
||||||
<span className="text-gray-600 truncate">{fileName}</span>
|
<span className="text-gray-600">Общий прогресс</span>
|
||||||
<span className="text-ospab-primary font-semibold whitespace-nowrap">
|
<span className="text-ospab-primary font-semibold whitespace-nowrap">
|
||||||
{progress.percentage}% • {speedMB} MB/s
|
{uploadProgress.__total__.percentage}% • {((uploadProgress.__total__.speed * 8) / (1024 * 1024)).toFixed(2)} Mbit/s
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2 overflow-hidden">
|
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="bg-ospab-primary h-full transition-all duration-300"
|
className="bg-ospab-primary h-full transition-all duration-300"
|
||||||
style={{ width: `${progress.percentage}%` }}
|
style={{ width: `${uploadProgress.__total__.percentage}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 text-center">
|
||||||
|
{formatBytes(uploadProgress.__total__.loaded)} / {formatBytes(uploadProgress.__total__.total)}
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleCancelUpload}
|
onClick={handleCancelUpload}
|
||||||
className="mt-3 w-full px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-semibold hover:bg-red-700 transition"
|
className="mt-3 w-full px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-semibold hover:bg-red-700 transition"
|
||||||
@@ -1216,6 +1461,24 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
<FiTrash2 />
|
<FiTrash2 />
|
||||||
Удалить выбранные
|
Удалить выбранные
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={expandAllFolders}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-sm text-gray-600 hover:bg-gray-50 transition"
|
||||||
|
disabled={objects.length === 0}
|
||||||
|
>
|
||||||
|
<FiFolder />
|
||||||
|
Развернуть все
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={collapseAllFolders}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-sm text-gray-600 hover:bg-gray-50 transition"
|
||||||
|
disabled={objects.length === 0}
|
||||||
|
>
|
||||||
|
<FiFolderMinus />
|
||||||
|
Свернуть все
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{objectsLoading ? (
|
{objectsLoading ? (
|
||||||
@@ -1227,44 +1490,30 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
Объекты не найдены. Попробуйте изменить фильтр или загрузить новые файлы.
|
Объекты не найдены. Попробуйте изменить фильтр или загрузить новые файлы.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
{/* Заголовок проводника */}
|
||||||
<thead className="bg-gray-50">
|
<div className="bg-gray-50 px-4 py-2 border-b border-gray-200 flex items-center gap-4 text-xs font-semibold text-gray-500">
|
||||||
<tr>
|
<span className="w-6"></span>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500">Выбор</th>
|
<span className="flex-1">Имя</span>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500">Ключ</th>
|
<span className="w-24 text-right">Размер</span>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500">Размер</th>
|
<span className="w-40 text-right">Изменён</span>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500">Изменён</th>
|
<span className="w-32 text-right">Действия</span>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500">Действия</th>
|
</div>
|
||||||
</tr>
|
{/* Дерево файлов */}
|
||||||
</thead>
|
<div className="divide-y divide-gray-100">
|
||||||
<tbody className="divide-y divide-gray-100">
|
<FileTreeView
|
||||||
{objects.map((object) => (
|
node={fileTree}
|
||||||
<tr key={object.key} className="hover:bg-gray-50">
|
depth={0}
|
||||||
<td className="px-4 py-2">
|
expandedFolders={expandedFolders}
|
||||||
<input
|
toggleFolder={toggleFolder}
|
||||||
type="checkbox"
|
selectedKeys={selectedKeys}
|
||||||
checked={!!selectedKeys[object.key]}
|
handleToggleSelection={handleToggleSelection}
|
||||||
onChange={() => handleToggleSelection(object.key)}
|
handleDownloadObject={handleDownloadObject}
|
||||||
|
formatBytes={formatBytes}
|
||||||
|
formatDate={formatDate}
|
||||||
|
objects={objects}
|
||||||
/>
|
/>
|
||||||
</td>
|
</div>
|
||||||
<td className="px-4 py-2 font-mono text-xs text-gray-700 break-all">{object.key}</td>
|
|
||||||
<td className="px-4 py-2 text-gray-600">{formatBytes(object.size)}</td>
|
|
||||||
<td className="px-4 py-2 text-gray-600">{object.lastModified ? formatDate(object.lastModified, true) : '—'}</td>
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleDownloadObject(object)}
|
|
||||||
className="inline-flex items-center gap-2 px-3 py-1.5 border border-gray-200 rounded-lg text-xs text-gray-600 hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
<FiDownload />
|
|
||||||
Скачать
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1306,95 +1555,6 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border border-gray-200 rounded-xl p-4 space-y-4">
|
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-gray-800">Доступ к MinIO Console</h3>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Здесь можно получить логин и временный пароль для панели управления объектным хранилищем.
|
|
||||||
Пароль показывается только один раз после генерации.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleGenerateConsoleCredentials}
|
|
||||||
disabled={consoleCredentialsLoading || bucketActionPending || bucketLoading}
|
|
||||||
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors ${
|
|
||||||
consoleCredentialsLoading || bucketActionPending || bucketLoading
|
|
||||||
? 'cursor-not-allowed bg-gray-200 text-gray-500'
|
|
||||||
: 'bg-ospab-primary text-white hover:bg-ospab-primary/90'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{consoleCredentialsLoading ? <FiRefreshCw className="animate-spin" /> : <FiKey />}
|
|
||||||
<span>{consoleCredentialsLoading ? 'Создаём...' : 'Сгенерировать пароль'}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3 items-center">
|
|
||||||
<span className="font-mono text-xs text-gray-700">Логин: {consoleLoginDisplay}</span>
|
|
||||||
{consoleLoginValue && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleCopy(consoleLoginValue, 'Логин консоли')}
|
|
||||||
className="inline-flex items-center gap-1 px-3 py-1.5 border border-gray-200 rounded-lg text-xs font-semibold text-gray-600 hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
<FiCopy />
|
|
||||||
Копировать
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{consoleUrl && (
|
|
||||||
<a
|
|
||||||
href={consoleUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="inline-flex items-center gap-2 px-3 py-1.5 text-xs font-semibold text-ospab-primary hover:underline"
|
|
||||||
>
|
|
||||||
Открыть MinIO Console
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{consoleCredentials && (
|
|
||||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 text-sm text-green-800 space-y-2">
|
|
||||||
<div className="flex items-center gap-2 font-semibold">
|
|
||||||
<FiInfo />
|
|
||||||
Новые данные входа
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-green-700">
|
|
||||||
Скопируйте пароль сейчас. После закрытия страницы он больше не отобразится.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-3 items-center">
|
|
||||||
<span className="font-mono text-xs text-green-900">Логин: {consoleCredentials.login}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleCopy(consoleCredentials.login, 'Логин консоли')}
|
|
||||||
className="inline-flex items-center gap-1 px-3 py-1.5 border border-green-200 rounded-lg text-xs font-semibold text-green-700 hover:bg-green-100"
|
|
||||||
>
|
|
||||||
<FiCopy />
|
|
||||||
Копировать
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-3 items-center">
|
|
||||||
<span className="font-mono text-xs text-green-900">Пароль: {consoleCredentials.password}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleCopy(consoleCredentials.password, 'Пароль консоли')}
|
|
||||||
className="inline-flex items-center gap-1 px-3 py-1.5 border border-green-200 rounded-lg text-xs font-semibold text-green-700 hover:bg-green-100"
|
|
||||||
>
|
|
||||||
<FiCopy />
|
|
||||||
Копировать
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{consoleCredentialsError && (
|
|
||||||
<div className="text-xs text-red-600">
|
|
||||||
{consoleCredentialsError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="border border-gray-200 rounded-xl p-4 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div className="border border-gray-200 rounded-xl p-4 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -1483,12 +1643,13 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
<FiKey className="text-ospab-primary text-xl" />
|
<FiKey className="text-ospab-primary text-xl" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-800">Доступ по ключам</h2>
|
<h2 className="text-lg font-semibold text-gray-800">Ключ доступа S3</h2>
|
||||||
<p className="text-sm text-gray-500">Создавайте и управляйте access/secret ключами для приложений.</p>
|
<p className="text-sm text-gray-500">Access Key и Secret Key для программного доступа к хранилищу (один ключ на бакет).</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{accessKeys.length === 0 ? (
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -1507,6 +1668,23 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
{creatingKey ? 'Создаём...' : 'Создать ключ'}
|
{creatingKey ? 'Создаём...' : 'Создать ключ'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Информация о консоли MinIO */}
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
<span className="font-semibold">Веб-консоль:</span> Вы также можете управлять файлами через{' '}
|
||||||
|
<a
|
||||||
|
href="https://console.s3.ospab.host"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-ospab-primary hover:underline font-semibold"
|
||||||
|
>
|
||||||
|
console.s3.ospab.host
|
||||||
|
</a>
|
||||||
|
{' '}— используйте Access Key и Secret Key для входа.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{lastCreatedKey && (
|
{lastCreatedKey && (
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800 space-y-2">
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800 space-y-2">
|
||||||
@@ -1536,6 +1714,14 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
Копировать
|
Копировать
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-3 pt-3 border-t border-blue-200">
|
||||||
|
<p className="text-xs font-semibold mb-2">Подключение к S3:</p>
|
||||||
|
<div className="space-y-1 text-xs font-mono bg-white/50 rounded p-2">
|
||||||
|
<div>Endpoint: <span className="text-blue-700">{bucket?.endpoint || 's3.ospab.host'}</span></div>
|
||||||
|
<div>Bucket: <span className="text-blue-700">{bucket?.physicalName || bucket?.name}</span></div>
|
||||||
|
<div>Region: <span className="text-blue-700">{bucket?.region || 'ru-msk-1'}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export interface Ticket {
|
|||||||
export interface StorageBucket {
|
export interface StorageBucket {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
physicalName?: string;
|
||||||
|
endpoint?: string;
|
||||||
plan: string;
|
plan: string;
|
||||||
quotaGb: number;
|
quotaGb: number;
|
||||||
usedBytes: number;
|
usedBytes: number;
|
||||||
|
|||||||
@@ -41,9 +41,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@prisma/client": "^6.16.1",
|
"@prisma/client": "^6.0.0",
|
||||||
"mysql2": "^3.14.5",
|
"mysql2": "^3.14.5",
|
||||||
"prisma": "^6.16.1",
|
"prisma": "^6.0.0",
|
||||||
"bcrypt": "^5.1.0",
|
"bcrypt": "^5.1.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"proxmox-api": "^1.0.0",
|
"proxmox-api": "^1.0.0",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
"util": "^0.12.5"
|
"util": "^0.12.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.16.1",
|
"@prisma/client": "^6.0.0",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.2.2",
|
"dotenv": "^17.2.2",
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"nodemailer": "^7.0.9",
|
"nodemailer": "^7.0.9",
|
||||||
"prisma": "^6.16.1",
|
"prisma": "^6.0.0",
|
||||||
"proxmox-api": "^1.1.1",
|
"proxmox-api": "^1.1.1",
|
||||||
"recharts": "^3.2.1",
|
"recharts": "^3.2.1",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user