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
|
||||
|
||||
**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
|
||||
- [О проекте](#о-проекте)
|
||||
- [Возможности](#возможности)
|
||||
- [Архитектура](#архитектура)
|
||||
- [Технологии](#технологии)
|
||||
- [Быстрый старт](#быстрый-старт)
|
||||
- [Развёртывание](#развёртывание)
|
||||
- [API документация](#api-документация)
|
||||
- [Структура проекта](#структура-проекта)
|
||||
- [Разработка](#разработка)
|
||||
- [Вклад в проект](#вклад-в-проект)
|
||||
|
||||
---
|
||||
|
||||
**© 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 = new PrismaClient();
|
||||
const { prisma } = require('./src/prisma/client');
|
||||
|
||||
async function checkTables() {
|
||||
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": "",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "ts-node-dev --respawn --transpile-only ./src/index.ts",
|
||||
"start": "node dist/src/index.js",
|
||||
"dev": "ts-node-dev --respawn --transpile-only ./index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"build": "tsc",
|
||||
"pm2:start": "pm2 start ecosystem.config.js --env production && pm2 save",
|
||||
"pm2:stop": "pm2 stop ospab-backend && pm2 delete ospab-backend && pm2 save",
|
||||
@@ -18,7 +18,7 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.16.2",
|
||||
"@prisma/client": "^6.0.0",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@types/ws": "^8.18.1",
|
||||
"axios": "^1.12.2",
|
||||
@@ -33,6 +33,7 @@
|
||||
"minio": "^8.0.6",
|
||||
"multer": "^2.0.2",
|
||||
"nodemailer": "^6.9.15",
|
||||
"nvm": "^0.0.4",
|
||||
"passport": "^0.7.0",
|
||||
"passport-github": "^1.1.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
@@ -52,15 +53,15 @@
|
||||
"@types/express-session": "^1.18.2",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/nodemailer": "^6.4.15",
|
||||
"@types/node": "^20.12.12",
|
||||
"@types/nodemailer": "^6.4.15",
|
||||
"@types/passport": "^1.0.17",
|
||||
"@types/passport-github": "^1.1.12",
|
||||
"@types/passport-google-oauth20": "^2.0.16",
|
||||
"@types/passport-vkontakte": "^1.0.5",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@types/xterm": "^2.0.3",
|
||||
"prisma": "^6.16.2",
|
||||
"prisma": "^6.0.0",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
const prisma = new PrismaClient();
|
||||
import { prisma } from '../src/prisma/client';
|
||||
|
||||
async function main() {
|
||||
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
|
||||
password String
|
||||
createdAt DateTime @default(now())
|
||||
plans Plan[] @relation("UserPlans")
|
||||
// plans Plan[] @relation("UserPlans")
|
||||
operator Int @default(0)
|
||||
isAdmin Boolean @default(false) // Админские права
|
||||
isAdmin Boolean @default(false)
|
||||
tickets Ticket[] @relation("UserTickets")
|
||||
responses Response[] @relation("OperatorResponses")
|
||||
checks Check[] @relation("UserChecks")
|
||||
@@ -56,26 +56,13 @@ model 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 {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
price Float
|
||||
planId Int?
|
||||
plan Plan? @relation("PlanServices", fields: [planId], references: [id])
|
||||
// planId Int?
|
||||
// plan Plan? @relation("PlanServices", fields: [planId], references: [id])
|
||||
|
||||
@@map("service")
|
||||
}
|
||||
@@ -473,6 +460,9 @@ model StorageCheckoutSession {
|
||||
planName String
|
||||
planDescription String?
|
||||
price Float
|
||||
promoCodeId Int?
|
||||
promoCode PromoCode? @relation(fields: [promoCodeId], references: [id])
|
||||
promoDiscount Float? @default(0)
|
||||
quotaGb Int
|
||||
bandwidthGb Int
|
||||
requestLimit String
|
||||
@@ -518,3 +508,18 @@ model StorageClass {
|
||||
|
||||
@@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(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) => {
|
||||
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/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', oauthRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
@@ -245,6 +290,18 @@ const wss = initWebSocketServer(server);
|
||||
// Установка timeout для всех запросов (120 сек = 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, () => {
|
||||
logger.info(`${protocolLabel} сервер запущен на порту ${PORT}`);
|
||||
logger.info(`База данных: ${process.env.DATABASE_URL ? 'подключена' : 'НЕ НАСТРОЕНА'}`);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
confirmAccountDeletion,
|
||||
getUserInfo,
|
||||
} 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 { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
@@ -70,7 +69,7 @@ export const requestPasswordChangeHandler = async (req: Request, res: Response)
|
||||
success: true,
|
||||
message: 'Код подтверждения отправлен на вашу почту'
|
||||
});
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Ошибка запроса смены пароля:', error);
|
||||
res.status(500).json({ error: error.message || 'Ошибка сервера' });
|
||||
}
|
||||
@@ -98,7 +97,7 @@ export const confirmPasswordChangeHandler = async (req: Request, res: Response)
|
||||
success: true,
|
||||
message: 'Пароль успешно изменён'
|
||||
});
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Ошибка подтверждения смены пароля:', error);
|
||||
res.status(400).json({ error: error.message || 'Ошибка подтверждения' });
|
||||
}
|
||||
@@ -138,7 +137,7 @@ export const requestUsernameChangeHandler = async (req: Request, res: Response)
|
||||
success: true,
|
||||
message: 'Код подтверждения отправлен на вашу почту'
|
||||
});
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Ошибка запроса смены имени:', error);
|
||||
res.status(400).json({ error: error.message || 'Ошибка сервера' });
|
||||
}
|
||||
@@ -166,7 +165,7 @@ export const confirmUsernameChangeHandler = async (req: Request, res: Response)
|
||||
success: true,
|
||||
message: 'Имя пользователя успешно изменено'
|
||||
});
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Ошибка подтверждения смены имени:', error);
|
||||
res.status(400).json({ error: error.message || 'Ошибка подтверждения' });
|
||||
}
|
||||
@@ -188,7 +187,7 @@ export const requestAccountDeletionHandler = async (req: Request, res: Response)
|
||||
success: true,
|
||||
message: 'Код подтверждения отправлен на вашу почту. После подтверждения аккаунт будет удалён безвозвратно.'
|
||||
});
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Ошибка запроса удаления аккаунта:', error);
|
||||
res.status(500).json({ error: error.message || 'Ошибка сервера' });
|
||||
}
|
||||
@@ -216,8 +215,9 @@ export const confirmAccountDeletionHandler = async (req: Request, res: Response)
|
||||
success: true,
|
||||
message: 'Аккаунт успешно удалён'
|
||||
});
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Ошибка подтверждения удаления аккаунта:', error);
|
||||
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 crypto from 'crypto';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Настройка транспорта для email
|
||||
const transporter = nodemailer.createTransport({
|
||||
|
||||
@@ -105,7 +105,17 @@ export class AdminController {
|
||||
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) {
|
||||
console.error('Ошибка получения данных пользователя:', error);
|
||||
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;
|
||||
return acc;
|
||||
}, {});
|
||||
@@ -426,7 +436,6 @@ export class AdminController {
|
||||
await tx.response.deleteMany({ where: { operatorId: userId } });
|
||||
|
||||
await tx.storageBucket.deleteMany({ where: { userId } });
|
||||
await tx.plan.deleteMany({ where: { userId } });
|
||||
|
||||
await tx.ticket.deleteMany({ where: { userId } });
|
||||
await tx.check.deleteMany({ where: { userId } });
|
||||
@@ -467,16 +476,24 @@ export class AdminController {
|
||||
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));
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Push-уведомление успешно отправлено',
|
||||
admin: user.username,
|
||||
timestamp: new Date().toISOString()
|
||||
message: 'Push-уведомление успешно отправлено (тест)',
|
||||
details: {
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
type: 'push',
|
||||
time: now,
|
||||
status: 'sent (mock)'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Admin] Ошибка при тестировании push-уведомления:', error);
|
||||
@@ -497,18 +514,24 @@ export class AdminController {
|
||||
return res.status(404).json({ error: 'Пользователь не найден' });
|
||||
}
|
||||
|
||||
console.log(`[Admin] Тест email-уведомления инициирован администратором ${user.username}`);
|
||||
console.log(`[Admin] Email для теста: ${user.email}`);
|
||||
const now = new Date().toISOString();
|
||||
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));
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Email-уведомление успешно отправлено',
|
||||
admin: user.username,
|
||||
message: 'Email-уведомление успешно отправлено (тест)',
|
||||
details: {
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
timestamp: new Date().toISOString()
|
||||
type: 'email',
|
||||
time: now,
|
||||
status: 'sent (mock)'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Admin] Ошибка при тестировании email-уведомления:', error);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { prisma } from '../../prisma/client';
|
||||
import { validateTurnstileToken } from './turnstile.validator';
|
||||
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';
|
||||
|
||||
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);
|
||||
|
||||
await prisma.user.create({
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
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: 'Регистрация прошла успешно!' });
|
||||
|
||||
} 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 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 });
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -5,9 +5,7 @@ import passport from 'passport';
|
||||
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
|
||||
import { Strategy as GitHubStrategy } from 'passport-github';
|
||||
import { Strategy as YandexStrategy } from 'passport-yandex';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
import { prisma } from '../../prisma/client';
|
||||
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';
|
||||
|
||||
|
||||
@@ -166,7 +166,10 @@ export const getPostByIdAdmin = async (req: Request, res: Response) => {
|
||||
export const createPost = async (req: Request, res: Response) => {
|
||||
try {
|
||||
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) {
|
||||
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: 'Чек подтверждён, баланс пополнен' });
|
||||
} catch (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: 'Чек отклонён' });
|
||||
} catch (error) {
|
||||
logger.error('[Check] Ошибка отклонения чека:', error);
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { prisma } from '../../prisma/client';
|
||||
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';
|
||||
|
||||
@@ -19,6 +29,9 @@ const transporter = nodemailer.createTransport({
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
export interface EmailNotification {
|
||||
to: string;
|
||||
subject: string;
|
||||
@@ -26,7 +39,9 @@ export interface EmailNotification {
|
||||
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 уведомления
|
||||
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({
|
||||
from: `"Ospab Host" <${process.env.SMTP_USER}>`,
|
||||
from: `"ospab.host" <${process.env.SMTP_USER}>`,
|
||||
...notification
|
||||
});
|
||||
|
||||
logger.info('Email sent: %s', 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);
|
||||
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 resolvedActionUrl = resolveActionUrl(actionUrl);
|
||||
const subject = `[Ospab Host] ${title}`.trim();
|
||||
const subject = `[ospab.host] ${title}`.trim();
|
||||
|
||||
const plainTextLines = [
|
||||
`Здравствуйте${username ? `, ${username}` : ''}!`,
|
||||
@@ -87,7 +103,7 @@ export async function sendNotificationEmail(params: SendGenericNotificationEmail
|
||||
plainTextLines.push('', `Перейти: ${resolvedActionUrl}`);
|
||||
}
|
||||
|
||||
plainTextLines.push('', '— Команда Ospab Host');
|
||||
plainTextLines.push('', '— Команда ospab.host');
|
||||
|
||||
const html = `
|
||||
<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 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>
|
||||
`;
|
||||
|
||||
@@ -122,59 +138,80 @@ export async function sendResourceAlertEmail(userId: number, serverId: number, a
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) return { status: 'error', message: 'User not found' };
|
||||
|
||||
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>
|
||||
`;
|
||||
const subject = `Высокая нагрузка на сервер #${serverId}`;
|
||||
|
||||
return await sendEmail({
|
||||
to: user.email,
|
||||
subject,
|
||||
html
|
||||
const content = [
|
||||
emailHeader({ icon: 'alert', title: 'Предупреждение о ресурсах', subtitle: `Сервер #${serverId}` }),
|
||||
emailGreeting(user.username),
|
||||
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);
|
||||
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 {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) return { status: 'error', message: 'User not found' };
|
||||
|
||||
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({
|
||||
to: user.email,
|
||||
subject,
|
||||
html
|
||||
const content = [
|
||||
emailHeader({ icon: 'success', title: 'Сервер создан!', subtitle: 'Готов к работе' }),
|
||||
emailGreeting(user.username),
|
||||
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);
|
||||
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' };
|
||||
|
||||
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({
|
||||
to: user.email,
|
||||
subject,
|
||||
html
|
||||
const urgencyType = daysLeft <= 1 ? 'danger' : daysLeft <= 3 ? 'warning' : 'default';
|
||||
|
||||
const content = [
|
||||
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);
|
||||
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) => {
|
||||
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 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) => {
|
||||
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({
|
||||
where: {
|
||||
@@ -140,7 +142,8 @@ export const getUnreadCount = async (req: Request, res: Response) => {
|
||||
// Пометить уведомление как прочитанное
|
||||
export const markAsRead = async (req: Request, res: Response) => {
|
||||
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 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) => {
|
||||
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({
|
||||
where: {
|
||||
@@ -199,7 +203,8 @@ export const markAllAsRead = async (req: Request, res: Response) => {
|
||||
// Удалить уведомление
|
||||
export const deleteNotification = async (req: Request, res: Response) => {
|
||||
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 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) => {
|
||||
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({
|
||||
where: {
|
||||
@@ -369,7 +375,8 @@ export const getVapidKey = async (req: Request, res: Response) => {
|
||||
// Подписаться на Push-уведомления
|
||||
export const subscribe = async (req: Request, res: Response) => {
|
||||
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 userAgent = req.headers['user-agent'];
|
||||
|
||||
@@ -389,7 +396,8 @@ export const subscribe = async (req: Request, res: Response) => {
|
||||
// Отписаться от Push-уведомлений
|
||||
export const unsubscribe = async (req: Request, res: Response) => {
|
||||
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;
|
||||
|
||||
if (!endpoint) {
|
||||
@@ -408,7 +416,8 @@ export const unsubscribe = async (req: Request, res: Response) => {
|
||||
// Тестовая отправка Push-уведомления (только для админов)
|
||||
export const testPushNotification = async (req: Request, res: Response) => {
|
||||
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!;
|
||||
|
||||
logger.log('[TEST PUSH] Запрос от пользователя:', { userId, username: user.username });
|
||||
@@ -532,8 +541,9 @@ export const testPushNotification = async (req: Request, res: Response) => {
|
||||
// Тестовая отправка Email-уведомления (только для админов)
|
||||
export const testEmailNotification = async (req: Request, res: Response) => {
|
||||
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) {
|
||||
return res.status(403).json({
|
||||
|
||||
@@ -67,13 +67,13 @@ class PaymentService {
|
||||
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 } });
|
||||
if (!user) throw new Error('Пользователь не найден');
|
||||
|
||||
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;
|
||||
@@ -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);
|
||||
return;
|
||||
|
||||
@@ -30,6 +30,18 @@ export function buildPhysicalBucketName(userId: number, logicalName: string): st
|
||||
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> {
|
||||
try {
|
||||
const exists = await minioClient.bucketExists(bucketName);
|
||||
@@ -37,6 +49,17 @@ export async function ensureBucketExists(bucketName: string, region: string): Pr
|
||||
await minioClient.makeBucket(bucketName, region);
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,11 @@ import {
|
||||
listStoragePlans,
|
||||
createCheckoutSession,
|
||||
getCheckoutSession,
|
||||
applyPromoToCheckoutSession,
|
||||
markCheckoutSessionConsumed,
|
||||
listStorageRegions,
|
||||
listStorageClasses,
|
||||
getStorageStatus,
|
||||
generateConsoleCredentials
|
||||
} from './storage.service';
|
||||
import { authMiddleware, optionalAuthMiddleware } from '../auth/auth.middleware';
|
||||
|
||||
@@ -110,7 +110,7 @@ router.get('/status', async (_req, res) => {
|
||||
|
||||
router.post('/checkout', optionalAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const userId = (req as any).user?.id ?? null;
|
||||
const userId = req.user?.id ?? null;
|
||||
const { planCode, planId, customGb } = req.body ?? {};
|
||||
|
||||
const numericPlanId = typeof planId === 'number'
|
||||
@@ -138,7 +138,7 @@ router.use(authMiddleware);
|
||||
|
||||
router.get('/cart/:id', async (req, res) => {
|
||||
try {
|
||||
const userId = (req as any).user?.id;
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
|
||||
const cartId = req.params.id;
|
||||
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) => {
|
||||
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 { name, cartId, region, storageClass, public: isPublic, versioning } = req.body;
|
||||
@@ -175,6 +191,7 @@ router.post('/buckets', async (req, res) => {
|
||||
storageClass: storageClass || 'standard',
|
||||
public: !!isPublic,
|
||||
versioning: !!versioning
|
||||
, cartId
|
||||
});
|
||||
|
||||
await markCheckoutSessionConsumed(cartId);
|
||||
@@ -190,7 +207,8 @@ router.post('/buckets', async (req, res) => {
|
||||
// Список бакетов пользователя
|
||||
router.get('/buckets', async (req, res) => {
|
||||
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 buckets = await listBuckets(userId);
|
||||
return res.json({ buckets });
|
||||
@@ -199,35 +217,13 @@ router.get('/buckets', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/buckets/:id/console-credentials', async (req, res) => {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
// Роут console-credentials удалён — используйте access keys
|
||||
|
||||
// Детали одного бакета
|
||||
router.get('/buckets/:id', async (req, res) => {
|
||||
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 id = Number(req.params.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) => {
|
||||
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 id = Number(req.params.id);
|
||||
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) => {
|
||||
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 id = Number(req.params.id);
|
||||
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) => {
|
||||
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 id = Number(req.params.id);
|
||||
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 { 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); // Проверка доступа
|
||||
console.log('[Storage URI Download] Доступ к бакету подтверждён');
|
||||
|
||||
// Загружаем файл с URL с увеличенным timeout
|
||||
console.log(`[Storage URI Download] Загрузка файла с ${url}...`);
|
||||
const response = await axios.get(url, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 120000, // 120 seconds (2 minutes)
|
||||
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 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({
|
||||
blob: buffer.toString('base64'),
|
||||
blob: base64Data,
|
||||
mimeType,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
let message = 'Ошибка загрузки файла по URI';
|
||||
if (e instanceof Error) {
|
||||
if (e.message.includes('timeout')) {
|
||||
console.error('[Storage URI Download] Ошибка:', e);
|
||||
|
||||
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 = 'Превышено время ожидания при загрузке файла. Попробуйте позже.';
|
||||
} 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 {
|
||||
message = `Ошибка соединения: ${e.message}`;
|
||||
}
|
||||
} else if (e instanceof Error) {
|
||||
message = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
console.error('[Storage URI Download] Возвращаем ошибку клиенту:', 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}`;
|
||||
|
||||
// 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 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 = {
|
||||
storagePlan: true,
|
||||
regionConfig: true,
|
||||
@@ -39,6 +105,7 @@ interface CreateBucketInput {
|
||||
storageClass: string;
|
||||
public: boolean;
|
||||
versioning: boolean;
|
||||
cartId?: string;
|
||||
}
|
||||
|
||||
interface UpdateBucketInput {
|
||||
@@ -161,6 +228,15 @@ async function ensureConsoleCredentialSupport(client: any = prisma): Promise<boo
|
||||
consoleCredentialSupport = true;
|
||||
return true;
|
||||
} 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)) {
|
||||
consoleCredentialSupport = false;
|
||||
logConsoleWarning(error);
|
||||
@@ -242,6 +318,8 @@ type CheckoutSessionRecord = {
|
||||
createdAt: Date;
|
||||
expiresAt: Date;
|
||||
consumedAt?: Date | null;
|
||||
promoCodeId?: number | null;
|
||||
promoDiscount?: number | null;
|
||||
};
|
||||
|
||||
function addDays(date: Date, days: number): Date {
|
||||
@@ -307,20 +385,8 @@ async function createMinioUser(username: string, password: string): Promise<void
|
||||
}
|
||||
|
||||
try {
|
||||
// Setup mc alias with explicit S3v4 signature
|
||||
// The key is to add the --api S3v4 flag
|
||||
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);
|
||||
}
|
||||
}
|
||||
// Setup mc alias - remove first to ensure fresh credentials
|
||||
await ensureMinioAlias();
|
||||
|
||||
// Create or update user
|
||||
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> {
|
||||
const prefix = (process.env.MINIO_BUCKET_PREFIX || 'ospab').toLowerCase();
|
||||
const userSegment = String(userId);
|
||||
@@ -423,6 +593,14 @@ function storageClassDelegate() {
|
||||
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) {
|
||||
return {
|
||||
id: plan.id,
|
||||
@@ -591,6 +769,54 @@ export async function createCheckoutSession(params: { planCode?: string; planId?
|
||||
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> {
|
||||
const session = await checkoutSessionDelegate().findUnique({
|
||||
where: { id: cartId },
|
||||
@@ -628,9 +854,18 @@ export async function getCheckoutSession(cartId: string, userId: number): Promis
|
||||
|
||||
export async function markCheckoutSessionConsumed(cartId: string) {
|
||||
try {
|
||||
await checkoutSessionDelegate().update({
|
||||
where: { id: cartId },
|
||||
data: { consumedAt: new Date() },
|
||||
const session = await checkoutSessionDelegate().findUnique({ where: { id: cartId } }) as CheckoutSessionRecord | null;
|
||||
if (!session) throw new Error('Корзина не найдена');
|
||||
|
||||
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) {
|
||||
console.warn(`[Storage] Не удалось пометить корзину ${cartId} как использованную`, error);
|
||||
@@ -840,6 +1075,12 @@ export async function createBucket(data: CreateBucketInput) {
|
||||
}) as StoragePlanRecord | null;
|
||||
if (!plan) throw new Error('Тариф не найден или отключён');
|
||||
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();
|
||||
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 } });
|
||||
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 physicalName = buildPhysicalBucketName(data.userId, logicalName);
|
||||
|
||||
@@ -883,20 +1124,20 @@ export async function createBucket(data: CreateBucketInput) {
|
||||
await ensureBucketExists(physicalName, regionCode);
|
||||
|
||||
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 } });
|
||||
if (!reloadedUser) throw new Error('Пользователь не найден');
|
||||
if (toPlainNumber(reloadedUser.balance) < planPrice) throw new Error('Недостаточно средств');
|
||||
|
||||
const updatedUser = await tx.user.update({
|
||||
where: { id: data.userId },
|
||||
data: { balance: reloadedUser.balance - Number(plan.price) }
|
||||
data: { balance: reloadedUser.balance - Number(sessionPrice) }
|
||||
});
|
||||
|
||||
await tx.transaction.create({
|
||||
data: {
|
||||
userId: data.userId,
|
||||
amount: -plan.price,
|
||||
amount: -sessionPrice,
|
||||
type: 'withdrawal',
|
||||
description: `Создание S3 бакета ${logicalName} (${plan.name})`,
|
||||
balanceBefore: reloadedUser.balance,
|
||||
@@ -984,8 +1225,15 @@ export async function createBucket(data: CreateBucketInput) {
|
||||
}
|
||||
await minioClient.removeBucket(physicalName);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1099,7 +1347,21 @@ export async function deleteBucket(userId: number, id: number, force = false) {
|
||||
const bucket = await fetchBucket(userId, id);
|
||||
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) {
|
||||
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)
|
||||
);
|
||||
for (const chunk of chunks) {
|
||||
try {
|
||||
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);
|
||||
} 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 } });
|
||||
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) {
|
||||
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 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({
|
||||
data: {
|
||||
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) {
|
||||
const bucket = await fetchBucket(userId, id);
|
||||
await prisma.storageAccessKey.deleteMany({
|
||||
|
||||
// Получаем ключ перед удалением, чтобы знать accessKey
|
||||
const keyRecord = await prisma.storageAccessKey.findFirst({
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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) {
|
||||
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) {
|
||||
const userId = Number((req as any).user?.id);
|
||||
const isOperator = Number((req as any).user?.operator) === 1;
|
||||
const userIdRaw = req.user?.id;
|
||||
if (!userIdRaw) return res.status(401).json({ error: 'Нет авторизации' });
|
||||
const userId = Number(userIdRaw);
|
||||
const isOperator = Number(req.user?.operator ?? 0) === 1;
|
||||
const {
|
||||
status,
|
||||
category,
|
||||
@@ -307,7 +313,7 @@ export async function getTickets(req: Request, res: Response) {
|
||||
|
||||
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;
|
||||
return acc;
|
||||
}, {});
|
||||
@@ -344,8 +350,10 @@ export async function getTickets(req: Request, res: Response) {
|
||||
// Получить один тикет по ID
|
||||
export async function getTicketById(req: Request, res: Response) {
|
||||
const ticketId = Number(req.params.id);
|
||||
const userId = Number((req as any).user?.id);
|
||||
const isOperator = Number((req as any).user?.operator) === 1;
|
||||
const userIdRaw = req.user?.id;
|
||||
if (!userIdRaw) return res.status(401).json({ error: 'Нет авторизации' });
|
||||
const userId = Number(userIdRaw);
|
||||
const isOperator = Number(req.user?.operator ?? 0) === 1;
|
||||
|
||||
if (!userId) {
|
||||
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) {
|
||||
const { ticketId, message, isInternal = false } = req.body;
|
||||
const actorId = Number((req as any).user?.id);
|
||||
const isOperator = Number((req as any).user?.operator) === 1;
|
||||
const actorIdRaw = req.user?.id;
|
||||
if (!actorIdRaw) return res.status(401).json({ error: 'Нет авторизации' });
|
||||
const actorId = Number(actorIdRaw);
|
||||
const isOperator = Number(req.user?.operator ?? 0) === 1;
|
||||
|
||||
if (!actorId) {
|
||||
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) {
|
||||
const { ticketId, status } = req.body;
|
||||
const userId = Number((req as any).user?.id);
|
||||
const isOperator = Number((req as any).user?.operator) === 1;
|
||||
const userIdRaw = req.user?.id;
|
||||
if (!userIdRaw) return res.status(403).json({ error: 'Нет прав' });
|
||||
const userId = Number(userIdRaw);
|
||||
const isOperator = Number(req.user?.operator ?? 0) === 1;
|
||||
|
||||
if (!userId || !isOperator) {
|
||||
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) {
|
||||
const { ticketId, operatorId } = req.body;
|
||||
const userId = Number((req as any).user?.id);
|
||||
const isOperator = Number((req as any).user?.operator) === 1;
|
||||
const userIdRaw = req.user?.id;
|
||||
if (!userIdRaw) return res.status(403).json({ error: 'Нет прав' });
|
||||
const userId = Number(userIdRaw);
|
||||
const isOperator = Number(req.user?.operator ?? 0) === 1;
|
||||
|
||||
if (!userId || !isOperator) {
|
||||
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) {
|
||||
const { ticketId } = req.body;
|
||||
const userId = Number((req as any).user?.id);
|
||||
const isOperator = Number((req as any).user?.operator) === 1;
|
||||
const userIdRaw = req.user?.id;
|
||||
if (!userIdRaw) return res.status(401).json({ error: 'Нет авторизации' });
|
||||
const userId = Number(userIdRaw);
|
||||
const isOperator = Number(req.user?.operator ?? 0) === 1;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Нет авторизации' });
|
||||
|
||||
@@ -6,7 +6,8 @@ import crypto from 'crypto';
|
||||
// Получить профиль пользователя (расширенный)
|
||||
export const getProfile = async (req: Request, res: Response) => {
|
||||
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({
|
||||
where: { id: userId },
|
||||
@@ -32,7 +33,7 @@ export const getProfile = async (req: Request, res: Response) => {
|
||||
const { password, ...userWithoutPassword } = user;
|
||||
|
||||
res.json({ success: true, data: userWithoutPassword });
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Ошибка получения профиля:', error);
|
||||
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) => {
|
||||
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;
|
||||
|
||||
// Проверка email на уникальность
|
||||
@@ -84,7 +86,7 @@ export const updateProfile = async (req: Request, res: Response) => {
|
||||
message: 'Профиль обновлён',
|
||||
data: { user: updatedUser, profile }
|
||||
});
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Ошибка обновления профиля:', error);
|
||||
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) => {
|
||||
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;
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
@@ -128,7 +131,7 @@ export const changePassword = async (req: Request, res: Response) => {
|
||||
// Можно добавить логику для сохранения текущего токена
|
||||
|
||||
res.json({ success: true, message: 'Пароль успешно изменён' });
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Ошибка смены пароля:', error);
|
||||
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) => {
|
||||
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) {
|
||||
return res.status(400).json({ success: false, message: 'Файл не загружен' });
|
||||
@@ -157,7 +161,7 @@ export const uploadAvatar = async (req: Request, res: Response) => {
|
||||
message: 'Аватар загружен',
|
||||
data: { avatarUrl }
|
||||
});
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Ошибка загрузки аватара:', error);
|
||||
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) => {
|
||||
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({
|
||||
where: { userId },
|
||||
@@ -174,7 +179,7 @@ export const deleteAvatar = async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'Аватар удалён' });
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Ошибка удаления аватара:', error);
|
||||
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) => {
|
||||
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({
|
||||
where: {
|
||||
@@ -194,7 +200,7 @@ export const getSessions = async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
res.json({ success: true, data: sessions });
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Ошибка получения сеансов:', error);
|
||||
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) => {
|
||||
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;
|
||||
|
||||
// Проверяем, что сеанс принадлежит пользователю
|
||||
@@ -221,7 +228,7 @@ export const terminateSession = async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'Сеанс завершён' });
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Ошибка завершения сеанса:', error);
|
||||
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 });
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Ошибка получения истории:', error);
|
||||
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 });
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Ошибка получения API ключей:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
@@ -306,7 +313,7 @@ export const createAPIKey = async (req: Request, res: Response) => {
|
||||
message: 'API ключ создан. Сохраните его, он больше не будет показан!',
|
||||
data: { ...apiKey, fullKey: key }
|
||||
});
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Ошибка создания API ключа:', error);
|
||||
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 ключ удалён' });
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Ошибка удаления API ключа:', error);
|
||||
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 });
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Ошибка получения настроек уведомлений:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
@@ -378,7 +385,7 @@ export const updateNotificationSettings = async (req: Request, res: Response) =>
|
||||
message: 'Настройки уведомлений обновлены',
|
||||
data: updated
|
||||
});
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Ошибка обновления настроек уведомлений:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
@@ -418,8 +425,9 @@ export const exportUserData = async (req: Request, res: Response) => {
|
||||
data: dataWithoutPassword,
|
||||
exportedAt: new Date().toISOString()
|
||||
});
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Ошибка экспорта данных:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ const avatarStorage = multer.diskStorage({
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
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);
|
||||
cb(null, `avatar-${userId}-${Date.now()}${ext}`);
|
||||
}
|
||||
@@ -44,7 +44,7 @@ const avatarStorage = multer.diskStorage({
|
||||
const avatarUpload = multer({
|
||||
storage: avatarStorage,
|
||||
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'];
|
||||
if (allowedTypes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
|
||||
@@ -1,2 +1,10 @@
|
||||
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
|
||||
import { User as PrismaUser } from '@prisma/client';
|
||||
|
||||
|
||||
@@ -6,30 +6,30 @@
|
||||
const isDebug = process.env.NODE_ENV !== 'production';
|
||||
|
||||
export const logger = {
|
||||
log: (...args: any[]) => {
|
||||
log: (...args: unknown[]) => {
|
||||
if (isDebug) {
|
||||
console.log(...args);
|
||||
}
|
||||
},
|
||||
|
||||
error: (...args: any[]) => {
|
||||
error: (...args: unknown[]) => {
|
||||
// Ошибки логируем всегда
|
||||
console.error(...args);
|
||||
},
|
||||
|
||||
warn: (...args: any[]) => {
|
||||
warn: (...args: unknown[]) => {
|
||||
if (isDebug) {
|
||||
console.warn(...args);
|
||||
}
|
||||
},
|
||||
|
||||
info: (...args: any[]) => {
|
||||
info: (...args: unknown[]) => {
|
||||
if (isDebug) {
|
||||
console.info(...args);
|
||||
}
|
||||
},
|
||||
|
||||
debug: (...args: any[]) => {
|
||||
debug: (...args: unknown[]) => {
|
||||
if (isDebug) {
|
||||
console.debug(...args);
|
||||
}
|
||||
@@ -38,19 +38,20 @@ export const logger = {
|
||||
|
||||
// WebSocket специфичные логи
|
||||
export const wsLogger = {
|
||||
log: (message: string, ...args: any[]) => {
|
||||
log: (message: string, ...args: unknown[]) => {
|
||||
if (isDebug) {
|
||||
console.log(`[WebSocket] ${message}`, ...args);
|
||||
}
|
||||
},
|
||||
|
||||
error: (message: string, ...args: any[]) => {
|
||||
error: (message: string, ...args: unknown[]) => {
|
||||
console.error(`[WebSocket] ${message}`, ...args);
|
||||
},
|
||||
|
||||
warn: (message: string, ...args: any[]) => {
|
||||
warn: (message: string, ...args: unknown[]) => {
|
||||
if (isDebug) {
|
||||
console.warn(`[WebSocket] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,31 @@
|
||||
* 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 =
|
||||
| { type: 'auth'; token: string }
|
||||
@@ -20,18 +45,18 @@ export type ClientToServerEvents =
|
||||
export type ServerToClientEvents =
|
||||
| { type: 'auth:success'; userId: number }
|
||||
| { type: 'auth:error'; message: string }
|
||||
| { type: 'notification:new'; notification: any }
|
||||
| { type: 'notification:new'; notification: NotificationPayload }
|
||||
| { type: 'notification:read'; 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: 'server:created'; server: any }
|
||||
| { type: 'server:created'; server: ServerPayload }
|
||||
| { type: 'server:status'; serverId: number; status: string; ipAddress?: string }
|
||||
| { type: 'server:stats'; serverId: number; stats: any }
|
||||
| { type: 'ticket:new'; ticket: any }
|
||||
| { type: 'ticket:response'; ticketId: number; response: any }
|
||||
| { type: 'server:stats'; serverId: number; stats: ServerStatsPayload }
|
||||
| { type: 'ticket:new'; ticket: TicketPayload }
|
||||
| { type: 'ticket:response'; ticketId: number; response: TicketResponsePayload }
|
||||
| { 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: 'pong' }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
import { Server as HTTPServer } from 'http';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { prisma } from '../prisma/client';
|
||||
import {
|
||||
ClientToServerEvents,
|
||||
ServerToClientEvents,
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from './events';
|
||||
import { wsLogger } from '../utils/logger';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'i_love_WebSockets!';
|
||||
|
||||
// Хранилище аутентифицированных клиентов
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"outDir": "./dist",
|
||||
"verbatimModuleSyntax": false
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import { AuthProvider } from './context/authcontext';
|
||||
import { WebSocketProvider } from './context/WebSocketContext';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import { ToastProvider } from './components/Toast';
|
||||
import { LocaleProvider } from './middleware';
|
||||
|
||||
// SEO конфиг для всех маршрутов
|
||||
const SEO_CONFIG: Record<string, {
|
||||
@@ -193,13 +194,14 @@ function SEOUpdater() {
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<LocaleProvider>
|
||||
<SEOUpdater />
|
||||
<AuthProvider>
|
||||
<WebSocketProvider>
|
||||
<ToastProvider>
|
||||
<ErrorBoundary>
|
||||
<Routes>
|
||||
{/* Обычные страницы с footer */}
|
||||
{/* Русские маршруты (без префикса) */}
|
||||
<Route path="/" element={<Pagetempl><Homepage /></Pagetempl>} />
|
||||
<Route path="/about" element={<Pagetempl><Aboutpage /></Pagetempl>} />
|
||||
<Route path="/tariffs" element={<Pagetempl><S3PlansPage /></Pagetempl>} />
|
||||
@@ -211,7 +213,19 @@ function App() {
|
||||
<Route path="/register" element={<Pagetempl><Registerpage /></Pagetempl>} />
|
||||
<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={
|
||||
<DashboardTempl>
|
||||
<Privateroute>
|
||||
@@ -220,6 +234,15 @@ function App() {
|
||||
</DashboardTempl>
|
||||
} />
|
||||
|
||||
{/* Дашборд (английский) */}
|
||||
<Route path="/en/dashboard/*" element={
|
||||
<DashboardTempl>
|
||||
<Privateroute>
|
||||
<Dashboard />
|
||||
</Privateroute>
|
||||
</DashboardTempl>
|
||||
} />
|
||||
|
||||
{/* Страницы ошибок */}
|
||||
<Route path="/401" element={<Unauthorized />} />
|
||||
<Route path="/403" element={<Forbidden />} />
|
||||
@@ -227,12 +250,19 @@ function App() {
|
||||
<Route path="/502" element={<BadGateway />} />
|
||||
<Route path="/503" element={<ServiceUnavailable />} />
|
||||
<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 />} />
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
</ToastProvider>
|
||||
</WebSocketProvider>
|
||||
</AuthProvider>
|
||||
</LocaleProvider>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ export default function AdminTestingTab() {
|
||||
};
|
||||
|
||||
const getAuthHeaders = () => {
|
||||
const token = localStorage.getItem('token');
|
||||
// Используем тот же ключ, что и во всём проекте (обычно 'access_token')
|
||||
const token = localStorage.getItem('access_token') || localStorage.getItem('token');
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
};
|
||||
|
||||
@@ -35,7 +36,7 @@ export default function AdminTestingTab() {
|
||||
addLog('info', 'Начинаю отправку push-уведомления...');
|
||||
|
||||
const response = await axios.post(
|
||||
`${API_URL}/admin/test/push-notification`,
|
||||
`${API_URL}/api/admin/test/push-notification`,
|
||||
{},
|
||||
{ headers: getAuthHeaders(), timeout: 15000 }
|
||||
);
|
||||
@@ -64,7 +65,7 @@ export default function AdminTestingTab() {
|
||||
addLog('info', 'Начинаю отправку email-уведомления...');
|
||||
|
||||
const response = await axios.post(
|
||||
`${API_URL}/admin/test/email-notification`,
|
||||
`${API_URL}/api/admin/test/email-notification`,
|
||||
{},
|
||||
{ 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
|
||||
} from 'react-icons/fi';
|
||||
import apiClient from '../../utils/apiClient';
|
||||
import useAuth from '../../context/useAuth';
|
||||
import { API_URL } from '../../config/api';
|
||||
import type { StorageBucket } from './types';
|
||||
|
||||
@@ -73,6 +74,10 @@ const Checkout: React.FC = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [regions, setRegions] = useState<StorageRegionOption[]>([]);
|
||||
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 () => {
|
||||
try {
|
||||
@@ -180,6 +185,19 @@ const Checkout: React.FC = () => {
|
||||
|
||||
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', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
@@ -269,6 +287,16 @@ const Checkout: React.FC = () => {
|
||||
</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="space-y-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 { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { isAxiosError } from 'axios';
|
||||
import {
|
||||
FiArrowLeft,
|
||||
@@ -14,7 +14,11 @@ import {
|
||||
FiHelpCircle,
|
||||
FiSettings,
|
||||
FiFolder,
|
||||
FiFolderMinus,
|
||||
FiFile,
|
||||
FiBarChart2,
|
||||
FiChevronRight,
|
||||
FiChevronDown,
|
||||
} from 'react-icons/fi';
|
||||
import apiClient from '../../utils/apiClient';
|
||||
import { useToast } from '../../hooks/useToast';
|
||||
@@ -38,6 +42,16 @@ interface CreatedKey {
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
// Тип для дерева файлов (проводник)
|
||||
interface FileTreeNode {
|
||||
name: string;
|
||||
path: string;
|
||||
isFolder: boolean;
|
||||
size: number;
|
||||
lastModified?: string;
|
||||
children: Record<string, FileTreeNode>;
|
||||
}
|
||||
|
||||
interface UploadProgress {
|
||||
loaded: number;
|
||||
total: number;
|
||||
@@ -76,15 +90,164 @@ type LoadObjectsOptions = {
|
||||
prefix?: string;
|
||||
};
|
||||
|
||||
type ConsoleCredentials = {
|
||||
login: string;
|
||||
password: string;
|
||||
url?: string | null;
|
||||
};
|
||||
// Компонент для отображения дерева файлов (проводник)
|
||||
interface FileTreeViewProps {
|
||||
node: FileTreeNode;
|
||||
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 = {
|
||||
consoleCredentials?: ConsoleCredentials;
|
||||
bucketName?: string;
|
||||
const FileTreeView: React.FC<FileTreeViewProps> = ({
|
||||
node,
|
||||
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 = () => {
|
||||
@@ -92,7 +255,6 @@ const StorageBucketPage: React.FC = () => {
|
||||
const bucketNumber = bucketIdParam ? Number(bucketIdParam) : NaN;
|
||||
const bucketIdValid = Number.isInteger(bucketNumber) && bucketNumber > 0;
|
||||
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { addToast } = useToast();
|
||||
|
||||
@@ -127,6 +289,9 @@ const StorageBucketPage: React.FC = () => {
|
||||
const [resumedFiles, setResumedFiles] = useState<File[]>([]);
|
||||
const uploadAbortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Для проводника - какие папки развёрнуты
|
||||
const [expandedFolders, setExpandedFolders] = useState<Record<string, boolean>>({});
|
||||
|
||||
const [accessKeys, setAccessKeys] = useState<StorageAccessKey[]>([]);
|
||||
const [accessKeysLoading, setAccessKeysLoading] = useState(false);
|
||||
const [newKeyLabel, setNewKeyLabel] = useState('');
|
||||
@@ -141,12 +306,70 @@ const StorageBucketPage: React.FC = () => {
|
||||
const selectedCount = selectedList.length;
|
||||
const allSelected = objects.length > 0 && objects.every((object) => selectedKeys[object.key]);
|
||||
|
||||
const [consoleCredentials, setConsoleCredentials] = useState<ConsoleCredentials | null>(() => {
|
||||
const state = location.state as BucketLocationState | undefined;
|
||||
return state?.consoleCredentials ?? null;
|
||||
});
|
||||
const [consoleCredentialsLoading, setConsoleCredentialsLoading] = useState(false);
|
||||
const [consoleCredentialsError, setConsoleCredentialsError] = useState<string | null>(null);
|
||||
// Строим дерево файлов для проводника
|
||||
const fileTree = useMemo(() => {
|
||||
const root: FileTreeNode = {
|
||||
name: '',
|
||||
path: '',
|
||||
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(() => {
|
||||
window.dispatchEvent(new Event('storageBucketsRefresh'));
|
||||
@@ -311,41 +534,6 @@ const StorageBucketPage: React.FC = () => {
|
||||
loadObjects({ reset: true, prefix: '' });
|
||||
}, [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(() => {
|
||||
if (allSelected) {
|
||||
setSelectedKeys({});
|
||||
@@ -441,18 +629,25 @@ const StorageBucketPage: React.FC = () => {
|
||||
const abortController = new AbortController();
|
||||
uploadAbortControllerRef.current = abortController;
|
||||
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 });
|
||||
const progressMap: Record<string, UploadProgress> = {};
|
||||
|
||||
try {
|
||||
// Сохраняем файлы в IndexedDB перед загрузкой
|
||||
const { saveFile } = await import('../../utils/uploadDB');
|
||||
for (const file of files) {
|
||||
// Используем webkitRelativePath для сохранения структуры папки
|
||||
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
await saveFile({
|
||||
id: `${bucketNumber}_${Date.now()}_${file.name}`,
|
||||
id: `${bucketNumber}_${Date.now()}_${relativePath}`,
|
||||
bucketId: bucketNumber,
|
||||
name: file.name,
|
||||
name: relativePath,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
data: arrayBuffer,
|
||||
@@ -470,50 +665,69 @@ const StorageBucketPage: React.FC = () => {
|
||||
}
|
||||
|
||||
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`, {
|
||||
key,
|
||||
method: 'PUT',
|
||||
contentType: file.type || undefined,
|
||||
});
|
||||
|
||||
const startTime = Date.now();
|
||||
let fileLoaded = 0; // Отслеживаем прогресс текущего файла
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
// Track upload progress
|
||||
// Track upload progress - единый прогресс-бар
|
||||
xhr.upload.addEventListener('progress', (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const elapsed = (Date.now() - startTime) / 1000;
|
||||
const speed = elapsed > 0 ? event.loaded / elapsed : 0;
|
||||
const percentage = Math.round((event.loaded / event.total) * 100);
|
||||
// Обновляем прогресс для этого файла
|
||||
const prevFileLoaded = fileLoaded;
|
||||
fileLoaded = event.loaded;
|
||||
totalLoaded += (fileLoaded - prevFileLoaded);
|
||||
|
||||
progressMap[file.name] = {
|
||||
loaded: event.loaded,
|
||||
total: event.total,
|
||||
const elapsed = (Date.now() - uploadStartTime) / 1000;
|
||||
const speed = elapsed > 0 ? totalLoaded / elapsed : 0;
|
||||
const percentage = totalSize > 0 ? Math.round((totalLoaded / totalSize) * 100) : 0;
|
||||
|
||||
// Единый прогресс в __total__
|
||||
setUploadProgress({
|
||||
__total__: {
|
||||
loaded: totalLoaded,
|
||||
total: totalSize,
|
||||
speed,
|
||||
percentage,
|
||||
};
|
||||
setUploadProgress({ ...progressMap });
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
progressMap[file.name].percentage = 100;
|
||||
setUploadProgress({ ...progressMap });
|
||||
// Убедимся, что файл полностью засчитан
|
||||
if (fileLoaded < file.size) {
|
||||
totalLoaded += (file.size - fileLoaded);
|
||||
}
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Загрузка файла «${file.name}» завершилась с ошибкой (${xhr.status})`));
|
||||
reject(new Error(`Загрузка файла «${displayName}» завершилась с ошибкой (${xhr.status})`));
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
reject(new Error(`Ошибка при загрузке файла «${file.name}»`));
|
||||
reject(new Error(`Ошибка при загрузке файла «${displayName}»`));
|
||||
});
|
||||
|
||||
xhr.open('PUT', data.url);
|
||||
@@ -599,30 +813,67 @@ const StorageBucketPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[URI Upload] Начало загрузки, URL:', uriUploadUrl);
|
||||
setUriUploadLoading(true);
|
||||
const abortController = new AbortController();
|
||||
uriUploadAbortControllerRef.current = abortController;
|
||||
|
||||
try {
|
||||
// Используем бэкенд proxy для обхода CORS с увеличенным timeout
|
||||
console.log('[URI Upload] Отправляем запрос на бэкенд...');
|
||||
const response = await apiClient.post(
|
||||
`/api/storage/buckets/${bucketNumber}/objects/download-from-uri`,
|
||||
{ url: uriUploadUrl },
|
||||
{ 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) {
|
||||
const blob = new Blob([response.data.blob], { type: response.data.mimeType || 'application/octet-stream' });
|
||||
const fileName = uriUploadUrl.split('/').pop() || 'file';
|
||||
// Декодируем base64 в бинарные данные
|
||||
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 });
|
||||
console.log('[URI Upload] File объект создан, размер:', file.size);
|
||||
|
||||
await performUpload([file]);
|
||||
setUriUploadUrl('');
|
||||
addToast(`Файл "${fileName}" загружен`, 'success');
|
||||
} else {
|
||||
console.error('[URI Upload] Ответ не содержит blob:', response.data);
|
||||
addToast('Сервер не вернул данные файла', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[URI Upload] Ошибка:', error);
|
||||
let message = 'Не удалось загрузить по URI';
|
||||
if (error instanceof Error && error.message === 'canceled') {
|
||||
message = 'Загрузка отменена';
|
||||
} else if (isAxiosError(error) && error.response?.data?.error) {
|
||||
console.error('[URI Upload] Ошибка от сервера:', error.response.data.error);
|
||||
message = error.response.data.error;
|
||||
} else if (error instanceof Error) {
|
||||
message = error.message;
|
||||
@@ -827,14 +1078,11 @@ const StorageBucketPage: React.FC = () => {
|
||||
setObjectPrefix('');
|
||||
objectPrefixRef.current = '';
|
||||
setLastCreatedKey(null);
|
||||
setConsoleCredentials((location.state as BucketLocationState | undefined)?.consoleCredentials ?? null);
|
||||
setConsoleCredentialsError(null);
|
||||
setConsoleCredentialsLoading(false);
|
||||
|
||||
fetchBucket();
|
||||
loadObjects({ reset: true, prefix: '' });
|
||||
fetchAccessKeys();
|
||||
}, [bucketIdValid, fetchAccessKeys, fetchBucket, loadObjects, location.state]);
|
||||
}, [bucketIdValid, fetchAccessKeys, fetchBucket, loadObjects]);
|
||||
|
||||
const bucketUsagePercent = bucket ? getUsagePercent(bucket.usedBytes, bucket.quotaGb) : 0;
|
||||
const bucketPlanName = bucket?.planDetails?.name ?? bucket?.plan ?? '';
|
||||
@@ -844,9 +1092,6 @@ const StorageBucketPage: React.FC = () => {
|
||||
const bucketPrice = typeof bucketPriceValue === 'number' && Number.isFinite(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);
|
||||
|
||||
@@ -1097,25 +1342,25 @@ const StorageBucketPage: React.FC = () => {
|
||||
Загрузка файла {uploadStats.completedFiles + 1} из {uploadStats.totalFiles}: <span className="text-ospab-primary">{uploadStats.currentFile}</span>
|
||||
</div>
|
||||
)}
|
||||
{Object.entries(uploadProgress).map(([fileName, progress]: [string, UploadProgress]) => {
|
||||
const speedMB = (progress.speed / (1024 * 1024)).toFixed(2);
|
||||
return (
|
||||
<div key={fileName} className="space-y-2">
|
||||
{uploadProgress.__total__ && (
|
||||
<div className="space-y-2">
|
||||
<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">
|
||||
{progress.percentage}% • {speedMB} MB/s
|
||||
{uploadProgress.__total__.percentage}% • {((uploadProgress.__total__.speed * 8) / (1024 * 1024)).toFixed(2)} Mbit/s
|
||||
</span>
|
||||
</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
|
||||
className="bg-ospab-primary h-full transition-all duration-300"
|
||||
style={{ width: `${progress.percentage}%` }}
|
||||
style={{ width: `${uploadProgress.__total__.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 text-center">
|
||||
{formatBytes(uploadProgress.__total__.loaded)} / {formatBytes(uploadProgress.__total__.total)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
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"
|
||||
@@ -1216,6 +1461,24 @@ const StorageBucketPage: React.FC = () => {
|
||||
<FiTrash2 />
|
||||
Удалить выбранные
|
||||
</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>
|
||||
|
||||
{objectsLoading ? (
|
||||
@@ -1227,44 +1490,30 @@ const StorageBucketPage: React.FC = () => {
|
||||
Объекты не найдены. Попробуйте изменить фильтр или загрузить новые файлы.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500">Выбор</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500">Ключ</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500">Размер</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500">Изменён</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{objects.map((object) => (
|
||||
<tr key={object.key} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!selectedKeys[object.key]}
|
||||
onChange={() => handleToggleSelection(object.key)}
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
{/* Заголовок проводника */}
|
||||
<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">
|
||||
<span className="w-6"></span>
|
||||
<span className="flex-1">Имя</span>
|
||||
<span className="w-24 text-right">Размер</span>
|
||||
<span className="w-40 text-right">Изменён</span>
|
||||
<span className="w-32 text-right">Действия</span>
|
||||
</div>
|
||||
{/* Дерево файлов */}
|
||||
<div className="divide-y divide-gray-100">
|
||||
<FileTreeView
|
||||
node={fileTree}
|
||||
depth={0}
|
||||
expandedFolders={expandedFolders}
|
||||
toggleFolder={toggleFolder}
|
||||
selectedKeys={selectedKeys}
|
||||
handleToggleSelection={handleToggleSelection}
|
||||
handleDownloadObject={handleDownloadObject}
|
||||
formatBytes={formatBytes}
|
||||
formatDate={formatDate}
|
||||
objects={objects}
|
||||
/>
|
||||
</td>
|
||||
<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 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="border border-gray-200 rounded-xl p-4 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
@@ -1483,12 +1643,13 @@ const StorageBucketPage: React.FC = () => {
|
||||
<FiKey className="text-ospab-primary text-xl" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-800">Доступ по ключам</h2>
|
||||
<p className="text-sm text-gray-500">Создавайте и управляйте access/secret ключами для приложений.</p>
|
||||
<h2 className="text-lg font-semibold text-gray-800">Ключ доступа S3</h2>
|
||||
<p className="text-sm text-gray-500">Access Key и Secret Key для программного доступа к хранилищу (один ключ на бакет).</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{accessKeys.length === 0 ? (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<input
|
||||
type="text"
|
||||
@@ -1507,6 +1668,23 @@ const StorageBucketPage: React.FC = () => {
|
||||
{creatingKey ? 'Создаём...' : 'Создать ключ'}
|
||||
</button>
|
||||
</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 && (
|
||||
<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>
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface Ticket {
|
||||
export interface StorageBucket {
|
||||
id: number;
|
||||
name: string;
|
||||
physicalName?: string;
|
||||
endpoint?: string;
|
||||
plan: string;
|
||||
quotaGb: number;
|
||||
usedBytes: number;
|
||||
|
||||
@@ -41,9 +41,9 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@prisma/client": "^6.16.1",
|
||||
"@prisma/client": "^6.0.0",
|
||||
"mysql2": "^3.14.5",
|
||||
"prisma": "^6.16.1",
|
||||
"prisma": "^6.0.0",
|
||||
"bcrypt": "^5.1.0",
|
||||
"cors": "^2.8.5",
|
||||
"proxmox-api": "^1.0.0",
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"util": "^0.12.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.16.1",
|
||||
"@prisma/client": "^6.0.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.2",
|
||||
@@ -30,7 +30,7 @@
|
||||
"helmet": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nodemailer": "^7.0.9",
|
||||
"prisma": "^6.16.1",
|
||||
"prisma": "^6.0.0",
|
||||
"proxmox-api": "^1.1.1",
|
||||
"recharts": "^3.2.1",
|
||||
"socket.io": "^4.8.1",
|
||||
|
||||
Reference in New Issue
Block a user