english version and minio console access

This commit is contained in:
Georgiy Syralev
2025-12-13 12:53:28 +03:00
parent 753696cc93
commit b799f278a4
47 changed files with 4386 additions and 1264 deletions

File diff suppressed because it is too large Load Diff

889
README.md
View File

@@ -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

View File

@@ -1,5 +1,4 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
const { prisma } = require('./src/prisma/client');
async function checkTables() {
try {

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}

View File

@@ -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' } });

View 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;

View File

@@ -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")
}

View File

@@ -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());

View File

@@ -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());

View File

@@ -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 ? 'подключена' : 'НЕ НАСТРОЕНА'}`);

View File

@@ -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 || 'Ошибка подтверждения' });
}
};

View File

@@ -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({

View File

@@ -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,
email: user.email,
timestamp: new Date().toISOString()
message: 'Email-уведомление успешно отправлено (тест)',
details: {
userId: user.id,
username: user.username,
email: user.email,
type: 'email',
time: now,
status: 'sent (mock)'
}
});
} catch (error) {
console.error('[Admin] Ошибка при тестировании email-уведомления:', error);

View File

@@ -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) {

View File

@@ -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';

View File

@@ -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: 'Заполните обязательные поля' });

View File

@@ -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);

View File

@@ -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 };
}
}

View 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>
`;
}

View File

@@ -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({

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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;
message = `Ошибка соединения: ${e.message}`;
}
} else if (e instanceof Error) {
message = e.message;
}
console.error('[Storage URI Download] Возвращаем ошибку клиенту:', message);
return res.status(400).json({ error: message });
}
});

View File

@@ -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,7 +1225,14 @@ export async function createBucket(data: CreateBucketInput) {
}
await minioClient.removeBucket(physicalName);
} catch (cleanupError) {
console.error('[Storage] Ошибка очистки бакета после неудачного создания', 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) {
await minioClient.removeObjects(physicalName, chunk);
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;
}
}
}
await minioClient.removeBucket(physicalName);
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);
}
}

View File

@@ -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: 'Нет авторизации' });

View File

@@ -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: 'Ошибка сервера' });
}
};

View File

@@ -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);

View File

@@ -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;

View File

@@ -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';

View File

@@ -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);
}
}
};

View File

@@ -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 };

View File

@@ -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!';
// Хранилище аутентифицированных клиентов

View File

@@ -13,7 +13,7 @@
"outDir": "./dist",
"verbatimModuleSyntax": false
},
"include": ["src/**/*.ts"],
"include": ["src/**/*.ts", "src/**/*.d.ts"],
"exclude": ["node_modules"]
}

View File

@@ -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,46 +194,75 @@ function SEOUpdater() {
function App() {
return (
<Router>
<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>} />
<Route path="/blog" element={<Pagetempl><Blog /></Pagetempl>} />
<Route path="/blog/:url" element={<Pagetempl><BlogPost /></Pagetempl>} />
<Route path="/privacy" element={<Privacy />} />
<Route path="/terms" element={<Terms />} />
<Route path="/login" element={<Pagetempl><Loginpage /></Pagetempl>} />
<Route path="/register" element={<Pagetempl><Registerpage /></Pagetempl>} />
<Route path="/qr-login" element={<QRLoginPage />} />
<LocaleProvider>
<SEOUpdater />
<AuthProvider>
<WebSocketProvider>
<ToastProvider>
<ErrorBoundary>
<Routes>
{/* Русские маршруты (без префикса) */}
<Route path="/" element={<Pagetempl><Homepage /></Pagetempl>} />
<Route path="/about" element={<Pagetempl><Aboutpage /></Pagetempl>} />
<Route path="/tariffs" element={<Pagetempl><S3PlansPage /></Pagetempl>} />
<Route path="/blog" element={<Pagetempl><Blog /></Pagetempl>} />
<Route path="/blog/:url" element={<Pagetempl><BlogPost /></Pagetempl>} />
<Route path="/privacy" element={<Privacy />} />
<Route path="/terms" element={<Terms />} />
<Route path="/login" element={<Pagetempl><Loginpage /></Pagetempl>} />
<Route path="/register" element={<Pagetempl><Registerpage /></Pagetempl>} />
<Route path="/qr-login" element={<QRLoginPage />} />
{/* Дашборд без footer */}
<Route path="/dashboard/*" element={
<DashboardTempl>
<Privateroute>
<Dashboard />
</Privateroute>
</DashboardTempl>
} />
{/* Английские маршруты (с префиксом /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="/401" element={<Unauthorized />} />
<Route path="/403" element={<Forbidden />} />
<Route path="/500" element={<ServerError />} />
<Route path="/502" element={<BadGateway />} />
<Route path="/503" element={<ServiceUnavailable />} />
<Route path="/504" element={<GatewayTimeout />} />
<Route path="*" element={<NotFound />} />
</Routes>
</ErrorBoundary>
</ToastProvider>
</WebSocketProvider>
</AuthProvider>
{/* Дашборд (русский) */}
<Route path="/dashboard/*" element={
<DashboardTempl>
<Privateroute>
<Dashboard />
</Privateroute>
</DashboardTempl>
} />
{/* Дашборд (английский) */}
<Route path="/en/dashboard/*" element={
<DashboardTempl>
<Privateroute>
<Dashboard />
</Privateroute>
</DashboardTempl>
} />
{/* Страницы ошибок */}
<Route path="/401" element={<Unauthorized />} />
<Route path="/403" element={<Forbidden />} />
<Route path="/500" element={<ServerError />} />
<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>
);
}

View File

@@ -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 }
);

View 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>
);
};

View 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';

View 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,
});

View 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 };

View 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;
}

View File

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

View File

@@ -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,
speed,
percentage,
};
setUploadProgress({ ...progressMap });
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,
},
});
}
});
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">
<div className="flex justify-between items-center text-xs">
<span className="text-gray-600 truncate">{fileName}</span>
<span className="text-ospab-primary font-semibold whitespace-nowrap">
{progress.percentage}% {speedMB} MB/s
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2 overflow-hidden">
<div
className="bg-ospab-primary h-full transition-all duration-300"
style={{ width: `${progress.percentage}%` }}
/>
</div>
{uploadProgress.__total__ && (
<div className="space-y-2">
<div className="flex justify-between items-center text-xs">
<span className="text-gray-600">Общий прогресс</span>
<span className="text-ospab-primary font-semibold whitespace-nowrap">
{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-3 overflow-hidden">
<div
className="bg-ospab-primary h-full transition-all duration-300"
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)}
/>
</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 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}
/>
</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,29 +1643,47 @@ 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>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<input
type="text"
value={newKeyLabel}
onChange={(event) => setNewKeyLabel(event.target.value)}
placeholder="Название или назначение ключа"
className="flex-1 rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-ospab-primary focus:outline-none"
/>
<button
type="button"
onClick={handleCreateAccessKey}
className="inline-flex items-center gap-2 px-4 py-2 bg-ospab-primary text-white rounded-lg text-sm font-semibold hover:bg-ospab-primary/90 transition"
disabled={creatingKey}
>
<FiKey />
{creatingKey ? 'Создаём...' : 'Создать ключ'}
</button>
{accessKeys.length === 0 ? (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<input
type="text"
value={newKeyLabel}
onChange={(event) => setNewKeyLabel(event.target.value)}
placeholder="Название или назначение ключа"
className="flex-1 rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-ospab-primary focus:outline-none"
/>
<button
type="button"
onClick={handleCreateAccessKey}
className="inline-flex items-center gap-2 px-4 py-2 bg-ospab-primary text-white rounded-lg text-sm font-semibold hover:bg-ospab-primary/90 transition"
disabled={creatingKey}
>
<FiKey />
{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 && (
@@ -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>
)}

View File

@@ -22,6 +22,8 @@ export interface Ticket {
export interface StorageBucket {
id: number;
name: string;
physicalName?: string;
endpoint?: string;
plan: string;
quotaGb: number;
usedBytes: number;

View File

@@ -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",

View File

@@ -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",