BIG_UPDATE deleted vps, added s3 infrastructure.
This commit is contained in:
5
ospabhost/backend/.gitignore
vendored
5
ospabhost/backend/.gitignore
vendored
@@ -10,6 +10,11 @@ npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# PM2
|
||||
logs/
|
||||
.pm2/
|
||||
pm2-*.log
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
|
||||
132
ospabhost/backend/PM2_CHEATSHEET.md
Normal file
132
ospabhost/backend/PM2_CHEATSHEET.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# PM2 Шпаргалка
|
||||
|
||||
## 🚀 Основные команды
|
||||
|
||||
```bash
|
||||
# Запуск
|
||||
pm2 start ecosystem.config.js --env production
|
||||
|
||||
# Остановка
|
||||
pm2 stop ospab-backend
|
||||
|
||||
# Перезапуск (без даунтайма)
|
||||
pm2 reload ospab-backend
|
||||
|
||||
# Полный перезапуск
|
||||
pm2 restart ospab-backend
|
||||
|
||||
# Удаление из PM2
|
||||
pm2 delete ospab-backend
|
||||
|
||||
# Список процессов
|
||||
pm2 list
|
||||
|
||||
# Детальная информация
|
||||
pm2 show ospab-backend
|
||||
```
|
||||
|
||||
## 📊 Мониторинг
|
||||
|
||||
```bash
|
||||
# Логи в реальном времени
|
||||
pm2 logs ospab-backend
|
||||
|
||||
# Последние 100 строк
|
||||
pm2 logs ospab-backend --lines 100
|
||||
|
||||
# Только ошибки
|
||||
pm2 logs ospab-backend --err
|
||||
|
||||
# Очистка логов
|
||||
pm2 flush
|
||||
|
||||
# Интерактивный мониторинг
|
||||
pm2 monit
|
||||
```
|
||||
|
||||
## 💾 Сохранение и автозапуск
|
||||
|
||||
```bash
|
||||
# Сохранить текущую конфигурацию
|
||||
pm2 save
|
||||
|
||||
# Настроить автозапуск при перезагрузке
|
||||
pm2 startup
|
||||
|
||||
# Отменить автозапуск
|
||||
pm2 unstartup
|
||||
|
||||
# Удалить сохранённую конфигурацию
|
||||
pm2 kill
|
||||
```
|
||||
|
||||
## 🔧 Управление через npm
|
||||
|
||||
```bash
|
||||
npm run pm2:start # Запуск
|
||||
npm run pm2:stop # Остановка
|
||||
npm run pm2:restart # Перезапуск
|
||||
npm run pm2:logs # Логи
|
||||
npm run pm2:monit # Мониторинг
|
||||
npm run pm2:status # Статус
|
||||
```
|
||||
|
||||
## 📦 Обновление PM2
|
||||
|
||||
```bash
|
||||
# Обновить PM2
|
||||
npm install -g pm2@latest
|
||||
|
||||
# Обновить процессы PM2
|
||||
pm2 update
|
||||
```
|
||||
|
||||
## 🐛 Отладка
|
||||
|
||||
```bash
|
||||
# Показать переменные окружения
|
||||
pm2 env 0
|
||||
|
||||
# Информация о системе
|
||||
pm2 info ospab-backend
|
||||
|
||||
# Метрики
|
||||
pm2 describe ospab-backend
|
||||
```
|
||||
|
||||
## ⚡ Быстрые сценарии
|
||||
|
||||
### Деплой нового кода
|
||||
```bash
|
||||
git pull origin main
|
||||
cd backend
|
||||
npm install
|
||||
npm run build
|
||||
pm2 reload ospab-backend
|
||||
pm2 save
|
||||
```
|
||||
|
||||
### Полный перезапуск системы
|
||||
```bash
|
||||
pm2 kill
|
||||
pm2 start ecosystem.config.js --env production
|
||||
pm2 save
|
||||
pm2 startup # Выполнить команду, которую выведет
|
||||
```
|
||||
|
||||
### Проверка статуса
|
||||
```bash
|
||||
pm2 list
|
||||
pm2 logs ospab-backend --lines 50
|
||||
curl http://localhost:5000
|
||||
```
|
||||
|
||||
## 🎯 Текущая конфигурация
|
||||
|
||||
- **Название**: ospab-backend
|
||||
- **Экземпляры**: 4
|
||||
- **Режим**: cluster
|
||||
- **Порт**: 5000
|
||||
- **Логи**: ./logs/pm2-error.log, ./logs/pm2-out.log
|
||||
- **Автоперезапуск**: Да
|
||||
- **Лимит памяти**: 500 MB/процесс
|
||||
186
ospabhost/backend/PM2_QUICKSTART.md
Normal file
186
ospabhost/backend/PM2_QUICKSTART.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# 🚀 Быстрый старт PM2
|
||||
|
||||
## Запуск Backend в 4 экземплярах
|
||||
|
||||
### Через npm scripts (рекомендуется):
|
||||
|
||||
```bash
|
||||
# 1. Сборка проекта
|
||||
npm run build
|
||||
|
||||
# 2. Запуск PM2
|
||||
npm run pm2:start
|
||||
|
||||
# 3. Проверка статуса
|
||||
npm run pm2:status
|
||||
```
|
||||
|
||||
### Через скрипты:
|
||||
|
||||
```bash
|
||||
# Дать права на выполнение (только один раз)
|
||||
chmod +x start-pm2.sh restart-pm2.sh stop-pm2.sh
|
||||
|
||||
# Запуск
|
||||
./start-pm2.sh
|
||||
|
||||
# Перезапуск
|
||||
./restart-pm2.sh
|
||||
|
||||
# Перезапуск с пересборкой
|
||||
./restart-pm2.sh --build
|
||||
|
||||
# Перезапуск с обновлением из Git
|
||||
./restart-pm2.sh --update
|
||||
|
||||
# Остановка
|
||||
./stop-pm2.sh
|
||||
```
|
||||
|
||||
### Через PM2 напрямую:
|
||||
|
||||
```bash
|
||||
# Запуск
|
||||
pm2 start ecosystem.config.js --env production
|
||||
|
||||
# Сохранение конфигурации
|
||||
pm2 save
|
||||
|
||||
# Настройка автозапуска
|
||||
pm2 startup
|
||||
# Выполните команду, которую выведет pm2 startup
|
||||
```
|
||||
|
||||
## ⚙️ Настройка автозапуска
|
||||
|
||||
Чтобы backend автоматически запускался при перезагрузке сервера:
|
||||
|
||||
```bash
|
||||
# 1. Запустить процесс
|
||||
npm run pm2:start
|
||||
|
||||
# 2. Настроить автозапуск
|
||||
pm2 startup
|
||||
|
||||
# 3. Выполнить команду, которую выведет pm2 startup
|
||||
# Например:
|
||||
# sudo env PATH=$PATH:/usr/bin pm2 startup systemd -u root --hp /root
|
||||
|
||||
# 4. Сохранить текущую конфигурацию
|
||||
pm2 save
|
||||
```
|
||||
|
||||
## 📊 Мониторинг
|
||||
|
||||
```bash
|
||||
# Просмотр логов
|
||||
npm run pm2:logs
|
||||
|
||||
# Интерактивный мониторинг
|
||||
npm run pm2:monit
|
||||
|
||||
# Статус всех процессов
|
||||
npm run pm2:status
|
||||
|
||||
# Детальная информация
|
||||
pm2 show ospab-backend
|
||||
```
|
||||
|
||||
## 🔄 Обновление кода
|
||||
|
||||
```bash
|
||||
# Вариант 1: Вручную
|
||||
git pull origin main
|
||||
npm install
|
||||
npm run build
|
||||
npm run pm2:restart
|
||||
|
||||
# Вариант 2: Через скрипт
|
||||
./restart-pm2.sh --update
|
||||
```
|
||||
|
||||
## 🛑 Остановка
|
||||
|
||||
```bash
|
||||
# Через npm
|
||||
npm run pm2:stop
|
||||
|
||||
# Через скрипт
|
||||
./stop-pm2.sh
|
||||
|
||||
# Напрямую
|
||||
pm2 stop ospab-backend
|
||||
pm2 delete ospab-backend
|
||||
pm2 save
|
||||
```
|
||||
|
||||
## 📝 Полезные команды
|
||||
|
||||
```bash
|
||||
# Логи в реальном времени
|
||||
pm2 logs ospab-backend --lines 100
|
||||
|
||||
# Очистка логов
|
||||
pm2 flush
|
||||
|
||||
# Перезапуск без даунтайма
|
||||
pm2 reload ospab-backend
|
||||
|
||||
# Обновление PM2
|
||||
npm install -g pm2@latest
|
||||
pm2 update
|
||||
|
||||
# Резервная копия конфигурации
|
||||
pm2 save --force
|
||||
```
|
||||
|
||||
## 🔍 Проверка работы
|
||||
|
||||
После запуска проверьте:
|
||||
|
||||
```bash
|
||||
# 1. Статус процессов (должно быть 4 инстанса "online")
|
||||
pm2 list
|
||||
|
||||
# 2. Backend доступен
|
||||
curl http://localhost:5000
|
||||
|
||||
# 3. Логи без ошибок
|
||||
pm2 logs ospab-backend --lines 50
|
||||
```
|
||||
|
||||
## ⚠️ Устранение проблем
|
||||
|
||||
### PM2 не запускается
|
||||
|
||||
```bash
|
||||
# Проверить версию Node.js
|
||||
node -v
|
||||
|
||||
# Переустановить PM2
|
||||
npm install -g pm2@latest
|
||||
|
||||
# Удалить старую конфигурацию
|
||||
pm2 kill
|
||||
rm -rf ~/.pm2
|
||||
|
||||
# Запустить заново
|
||||
npm run pm2:start
|
||||
```
|
||||
|
||||
### Процессы крашатся
|
||||
|
||||
```bash
|
||||
# Посмотреть ошибки
|
||||
pm2 logs ospab-backend --err
|
||||
|
||||
# Увеличить лимит памяти в ecosystem.config.js
|
||||
# max_memory_restart: '1G'
|
||||
|
||||
# Уменьшить количество инстансов
|
||||
# instances: 2
|
||||
```
|
||||
|
||||
## 📚 Подробная документация
|
||||
|
||||
См. [PM2_SETUP.md](./PM2_SETUP.md) для детальной информации.
|
||||
257
ospabhost/backend/PM2_SETUP.md
Normal file
257
ospabhost/backend/PM2_SETUP.md
Normal file
@@ -0,0 +1,257 @@
|
||||
# Настройка PM2 для Backend
|
||||
|
||||
## 📦 Установка PM2 (если ещё не установлен)
|
||||
|
||||
```bash
|
||||
# Глобальная установка PM2
|
||||
npm install -g pm2
|
||||
|
||||
# Проверка версии
|
||||
pm2 -v
|
||||
```
|
||||
|
||||
## 🚀 Запуск Backend в 4 экземплярах
|
||||
|
||||
### Шаг 1: Сборка проекта
|
||||
|
||||
```bash
|
||||
cd /var/www/ospab-host/backend
|
||||
# или локально:
|
||||
cd backend
|
||||
|
||||
# Установка зависимостей (если нужно)
|
||||
npm install
|
||||
|
||||
# Компиляция TypeScript
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Шаг 2: Создание папки для логов
|
||||
|
||||
```bash
|
||||
mkdir -p logs
|
||||
```
|
||||
|
||||
### Шаг 3: Запуск с помощью PM2
|
||||
|
||||
```bash
|
||||
# Запуск 4 экземпляров согласно ecosystem.config.js
|
||||
pm2 start ecosystem.config.js --env production
|
||||
|
||||
# Или напрямую (без конфига):
|
||||
pm2 start dist/src/index.js -i 4 --name ospab-backend
|
||||
```
|
||||
|
||||
### Шаг 4: Сохранение конфигурации для автозапуска
|
||||
|
||||
```bash
|
||||
# Сохранить текущий список процессов
|
||||
pm2 save
|
||||
|
||||
# Настроить автозапуск при перезагрузке сервера
|
||||
pm2 startup
|
||||
|
||||
# Выполните команду, которую выведет pm2 startup
|
||||
# Обычно это что-то вроде:
|
||||
# sudo env PATH=$PATH:/usr/bin pm2 startup systemd -u root --hp /root
|
||||
```
|
||||
|
||||
## 📊 Управление процессами
|
||||
|
||||
### Просмотр статуса
|
||||
|
||||
```bash
|
||||
# Список всех процессов
|
||||
pm2 list
|
||||
|
||||
# Детальная информация о процессе
|
||||
pm2 show ospab-backend
|
||||
|
||||
# Мониторинг в реальном времени
|
||||
pm2 monit
|
||||
```
|
||||
|
||||
### Логи
|
||||
|
||||
```bash
|
||||
# Все логи
|
||||
pm2 logs
|
||||
|
||||
# Логи конкретного процесса
|
||||
pm2 logs ospab-backend
|
||||
|
||||
# Последние 100 строк
|
||||
pm2 logs ospab-backend --lines 100
|
||||
|
||||
# Очистка логов
|
||||
pm2 flush
|
||||
```
|
||||
|
||||
### Перезапуск
|
||||
|
||||
```bash
|
||||
# Перезапуск без даунтайма (рекомендуется)
|
||||
pm2 reload ospab-backend
|
||||
|
||||
# Полный перезапуск (с кратковременным даунтаймом)
|
||||
pm2 restart ospab-backend
|
||||
|
||||
# Остановка
|
||||
pm2 stop ospab-backend
|
||||
|
||||
# Удаление из PM2
|
||||
pm2 delete ospab-backend
|
||||
```
|
||||
|
||||
## 🔧 Обновление кода (деплой)
|
||||
|
||||
### Вариант 1: Автоматический деплой через PM2
|
||||
|
||||
```bash
|
||||
# Из корня проекта
|
||||
pm2 deploy ecosystem.config.js production
|
||||
```
|
||||
|
||||
### Вариант 2: Ручной деплой
|
||||
|
||||
```bash
|
||||
cd /var/www/ospab-host
|
||||
|
||||
# Обновление кода
|
||||
git pull origin main
|
||||
|
||||
# Переход в backend
|
||||
cd backend
|
||||
|
||||
# Установка зависимостей
|
||||
npm install
|
||||
|
||||
# Сборка
|
||||
npm run build
|
||||
|
||||
# Перезапуск без даунтайма
|
||||
pm2 reload ospab-backend
|
||||
|
||||
# Сохранение конфигурации
|
||||
pm2 save
|
||||
```
|
||||
|
||||
## 📈 Мониторинг и отладка
|
||||
|
||||
### Проверка памяти и CPU
|
||||
|
||||
```bash
|
||||
pm2 monit
|
||||
```
|
||||
|
||||
### Веб-интерфейс (опционально)
|
||||
|
||||
```bash
|
||||
# Установка PM2 Plus (бесплатно для мониторинга)
|
||||
pm2 link <secret_key> <public_key>
|
||||
|
||||
# Или просто веб-дашборд
|
||||
pm2 web
|
||||
# Откройте http://localhost:9615
|
||||
```
|
||||
|
||||
## ⚙️ Текущая конфигурация
|
||||
|
||||
- **Экземпляры**: 4 процесса в кластерном режиме
|
||||
- **Порт**: 5000 (балансировка внутри PM2)
|
||||
- **Автоперезапуск**: Да
|
||||
- **Лимит памяти**: 500 MB на процесс
|
||||
- **Логи**: `./logs/pm2-error.log` и `./logs/pm2-out.log`
|
||||
|
||||
## 🔍 Проверка автозапуска
|
||||
|
||||
После перезагрузки сервера:
|
||||
|
||||
```bash
|
||||
# Проверить, что PM2 запустился
|
||||
pm2 list
|
||||
|
||||
# Должен показать 4 экземпляра ospab-backend в статусе "online"
|
||||
```
|
||||
|
||||
## 🛠️ Устранение проблем
|
||||
|
||||
### PM2 не запускается при загрузке
|
||||
|
||||
```bash
|
||||
# Удалить старую конфигурацию
|
||||
pm2 unstartup
|
||||
|
||||
# Создать новую
|
||||
pm2 startup
|
||||
# Выполните команду, которую выведет
|
||||
|
||||
# Сохранить
|
||||
pm2 save
|
||||
```
|
||||
|
||||
### Процессы крашатся
|
||||
|
||||
```bash
|
||||
# Проверить логи ошибок
|
||||
pm2 logs ospab-backend --err
|
||||
|
||||
# Увеличить лимит памяти в ecosystem.config.js
|
||||
# max_memory_restart: '1G'
|
||||
|
||||
# Перезапустить
|
||||
pm2 reload ecosystem.config.js
|
||||
```
|
||||
|
||||
### Высокая нагрузка на CPU
|
||||
|
||||
```bash
|
||||
# Уменьшить количество экземпляров
|
||||
# В ecosystem.config.js:
|
||||
instances: 2
|
||||
|
||||
# Перезапустить
|
||||
pm2 reload ecosystem.config.js
|
||||
```
|
||||
|
||||
## 📝 Полезные команды
|
||||
|
||||
```bash
|
||||
# Обновить PM2 до последней версии
|
||||
npm install -g pm2@latest
|
||||
pm2 update
|
||||
|
||||
# Резервная копия конфигурации PM2
|
||||
pm2 save --force
|
||||
|
||||
# Информация о системе
|
||||
pm2 info ospab-backend
|
||||
|
||||
# Метрики производительности
|
||||
pm2 describe ospab-backend
|
||||
```
|
||||
|
||||
## 🎯 Быстрый старт (TL;DR)
|
||||
|
||||
```bash
|
||||
# 1. Перейти в папку backend
|
||||
cd backend
|
||||
|
||||
# 2. Собрать проект
|
||||
npm run build
|
||||
|
||||
# 3. Создать папку логов
|
||||
mkdir -p logs
|
||||
|
||||
# 4. Запустить PM2
|
||||
pm2 start ecosystem.config.js --env production
|
||||
|
||||
# 5. Сохранить и настроить автозапуск
|
||||
pm2 save
|
||||
pm2 startup
|
||||
|
||||
# 6. Проверить статус
|
||||
pm2 list
|
||||
```
|
||||
|
||||
Готово! Backend запущен в 4 экземплярах и будет автоматически запускаться при перезагрузке сервера. 🚀
|
||||
@@ -30,16 +30,16 @@ async function checkProxmox() {
|
||||
console.log('---');
|
||||
|
||||
// 1. Проверка версии
|
||||
console.log('\n1️⃣ Проверка версии Proxmox...');
|
||||
console.log('\n[1] Проверка версии Proxmox...');
|
||||
const versionRes = await axios.get(`${PROXMOX_API_URL}/version`, {
|
||||
headers: getProxmoxHeaders(),
|
||||
timeout: 10000,
|
||||
httpsAgent
|
||||
});
|
||||
console.log('✅ Версия:', versionRes.data?.data?.version);
|
||||
console.log('[OK] Версия:', versionRes.data?.data?.version);
|
||||
|
||||
// 2. Проверка storage
|
||||
console.log('\n2️⃣ Получение списка storage на узле ' + PROXMOX_NODE + '...');
|
||||
console.log('\n[2] Получение списка storage на узле ' + PROXMOX_NODE + '...');
|
||||
const storageRes = await axios.get(
|
||||
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/storage`,
|
||||
{
|
||||
@@ -50,14 +50,14 @@ async function checkProxmox() {
|
||||
);
|
||||
|
||||
if (storageRes.data?.data) {
|
||||
console.log('✅ Доступные storage:');
|
||||
console.log('[OK] Доступные storage:');
|
||||
storageRes.data.data.forEach((storage: any) => {
|
||||
console.log(` - ${storage.storage} (type: ${storage.type}, enabled: ${storage.enabled ? 'да' : 'нет'})`);
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Проверка контейнеров
|
||||
console.log('\n3️⃣ Получение списка контейнеров...');
|
||||
console.log('\n[3] Получение списка контейнеров...');
|
||||
const containersRes = await axios.get(
|
||||
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc`,
|
||||
{
|
||||
@@ -68,24 +68,24 @@ async function checkProxmox() {
|
||||
);
|
||||
|
||||
if (containersRes.data?.data) {
|
||||
console.log(`✅ Найдено контейнеров: ${containersRes.data.data.length}`);
|
||||
console.log(`[OK] Найдено контейнеров: ${containersRes.data.data.length}`);
|
||||
containersRes.data.data.slice(0, 3).forEach((ct: any) => {
|
||||
console.log(` - VMID ${ct.vmid}: ${ct.name} (${ct.status})`);
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Проверка VMID
|
||||
console.log('\n4️⃣ Получение следующего VMID...');
|
||||
console.log('\n[4] Получение следующего VMID...');
|
||||
const vmidRes = await axios.get(`${PROXMOX_API_URL}/cluster/nextid`, {
|
||||
headers: getProxmoxHeaders(),
|
||||
timeout: 10000,
|
||||
httpsAgent
|
||||
});
|
||||
console.log('✅ Следующий VMID:', vmidRes.data?.data);
|
||||
console.log('[OK] Следующий VMID:', vmidRes.data?.data);
|
||||
|
||||
console.log('\n✅ Все проверки пройдены успешно!');
|
||||
console.log('\n[SUCCESS] Все проверки пройдены успешно!');
|
||||
} catch (error: any) {
|
||||
console.error('\n❌ Ошибка:', error.message);
|
||||
console.error('\n[ERROR] Ошибка:', error.message);
|
||||
console.error('Code:', error.code);
|
||||
console.error('Status:', error.response?.status);
|
||||
if (error.response?.data?.errors) {
|
||||
|
||||
31
ospabhost/backend/check_tables.js
Normal file
31
ospabhost/backend/check_tables.js
Normal file
@@ -0,0 +1,31 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function checkTables() {
|
||||
try {
|
||||
console.log('Проверка таблиц блога...\n');
|
||||
|
||||
// Проверка таблицы Post
|
||||
try {
|
||||
const postCount = await prisma.post.count();
|
||||
console.log('[OK] Таблица Post существует. Записей:', postCount);
|
||||
} catch (error) {
|
||||
console.log('[ERROR] Таблица Post НЕ существует:', error.message);
|
||||
}
|
||||
|
||||
// Проверка таблицы Comment
|
||||
try {
|
||||
const commentCount = await prisma.comment.count();
|
||||
console.log('[OK] Таблица Comment существует. Записей:', commentCount);
|
||||
} catch (error) {
|
||||
console.log('[ERROR] Таблица Comment НЕ существует:', error.message);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Общая ошибка:', error);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
checkTables();
|
||||
39
ospabhost/backend/deploy-prod.sh
Normal file
39
ospabhost/backend/deploy-prod.sh
Normal file
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Скрипт деплоя backend на production
|
||||
# Выполнять на сервере в директории /var/www/ospab-host/backend
|
||||
|
||||
echo "🚀 Начинаем деплой backend..."
|
||||
|
||||
# 1. Останавливаем backend
|
||||
echo "⏸️ Останавливаем backend..."
|
||||
pm2 stop ospab-backend
|
||||
|
||||
# 2. Создаем директорию для аватаров, если её нет
|
||||
echo "📁 Создаем директорию для аватаров..."
|
||||
mkdir -p uploads/avatars
|
||||
|
||||
# 3. Генерируем Prisma Client (с новыми моделями)
|
||||
echo "🔧 Генерируем Prisma Client..."
|
||||
npx prisma generate
|
||||
|
||||
# 4. Применяем миграции к базе данных
|
||||
echo "💾 Применяем миграции к БД..."
|
||||
npx prisma db push
|
||||
|
||||
# 5. Собираем TypeScript
|
||||
echo "🔨 Собираем TypeScript..."
|
||||
npm run build
|
||||
|
||||
# 6. Перезапускаем backend
|
||||
echo "▶️ Перезапускаем backend..."
|
||||
pm2 restart ospab-backend1
|
||||
|
||||
# 7. Проверяем статус
|
||||
echo "✅ Проверяем статус..."
|
||||
pm2 status ospab-backend1
|
||||
|
||||
echo "🎉 Деплой завершён!"
|
||||
echo ""
|
||||
echo "📝 Проверьте логи: pm2 logs ospab-backend"
|
||||
echo "🔍 Если есть ошибки, проверьте: pm2 logs ospab-backend --err"
|
||||
46
ospabhost/backend/ecosystem.config.js
Normal file
46
ospabhost/backend/ecosystem.config.js
Normal file
@@ -0,0 +1,46 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'ospab-backend',
|
||||
script: './dist/index.js',
|
||||
instances: 4, // 4 экземпляра для балансировки нагрузки
|
||||
exec_mode: 'cluster', // Кластерный режим
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
max_memory_restart: '500M',
|
||||
kill_timeout: 5000, // Время на graceful shutdown
|
||||
wait_ready: false, // Не ждать сигнал готовности
|
||||
listen_timeout: 10000, // Таймаут ожидания ready
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
PORT: 5000
|
||||
},
|
||||
env_development: {
|
||||
NODE_ENV: 'development',
|
||||
PORT: 5000
|
||||
},
|
||||
error_file: './logs/pm2-error.log',
|
||||
out_file: './logs/pm2-out.log',
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||
merge_logs: true,
|
||||
time: true,
|
||||
// Политика перезапуска при крашах
|
||||
min_uptime: '10s',
|
||||
max_restarts: 10,
|
||||
restart_delay: 4000,
|
||||
// Мониторинг
|
||||
pmx: true,
|
||||
automation: false
|
||||
}
|
||||
],
|
||||
deploy: {
|
||||
production: {
|
||||
user: 'root',
|
||||
host: 'ospab.host',
|
||||
ref: 'origin/main',
|
||||
repo: 'git@github.com:Ospab/ospabhost8.1.git',
|
||||
path: '/var/www/ospab-host',
|
||||
'post-deploy': 'cd backend && npm install && npm run build && pm2 reload ecosystem.config.js --env production && pm2 save'
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -24,5 +24,5 @@ console.log('2. Добавьте в ospabhost8.1/backend/.env:');
|
||||
console.log(` SSO_SECRET_KEY=${ssoSecret}`);
|
||||
console.log('\n3. Добавьте ЭТОТ ЖЕ ключ в панель управления (ospab-panel/.env):');
|
||||
console.log(` SSO_SECRET_KEY=${ssoSecret}`);
|
||||
console.log('\n⚠️ ВАЖНО: Ключ должен быть ОДИНАКОВЫМ на обоих сайтах!');
|
||||
console.log('\nВАЖНО: Ключ должен быть ОДИНАКОВЫМ на обоих сайтах!');
|
||||
console.log('═══════════════════════════════════════════════════════════════\n');
|
||||
|
||||
132
ospabhost/backend/manual-migration.sql
Normal file
132
ospabhost/backend/manual-migration.sql
Normal file
@@ -0,0 +1,132 @@
|
||||
-- Manual migration SQL для добавления новых таблиц
|
||||
-- Выполнить в MySQL базе данных ospabhost
|
||||
|
||||
-- 1. Таблица сеансов (Sessions)
|
||||
CREATE TABLE IF NOT EXISTS `session` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`userId` INT NOT NULL,
|
||||
`token` VARCHAR(500) NOT NULL,
|
||||
`ipAddress` VARCHAR(255) NULL,
|
||||
`userAgent` TEXT NULL,
|
||||
`device` VARCHAR(255) NULL,
|
||||
`browser` VARCHAR(255) NULL,
|
||||
`location` VARCHAR(255) NULL,
|
||||
`lastActivity` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`expiresAt` DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE INDEX `session_token_key` (`token`),
|
||||
INDEX `session_userId_idx` (`userId`),
|
||||
CONSTRAINT `session_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 2. Таблица истории входов (Login History)
|
||||
CREATE TABLE IF NOT EXISTS `login_history` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`userId` INT NOT NULL,
|
||||
`ipAddress` VARCHAR(255) NOT NULL,
|
||||
`userAgent` TEXT NULL,
|
||||
`device` VARCHAR(255) NULL,
|
||||
`browser` VARCHAR(255) NULL,
|
||||
`location` VARCHAR(255) NULL,
|
||||
`success` BOOLEAN NOT NULL DEFAULT true,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `login_history_userId_idx` (`userId`),
|
||||
INDEX `login_history_createdAt_idx` (`createdAt`),
|
||||
CONSTRAINT `login_history_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 3. Таблица SSH ключей
|
||||
CREATE TABLE IF NOT EXISTS `ssh_key` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`userId` INT NOT NULL,
|
||||
`name` VARCHAR(255) NOT NULL,
|
||||
`publicKey` TEXT NOT NULL,
|
||||
`fingerprint` VARCHAR(255) NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`lastUsed` DATETIME(3) NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `ssh_key_userId_idx` (`userId`),
|
||||
CONSTRAINT `ssh_key_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 4. Таблица API ключей
|
||||
CREATE TABLE IF NOT EXISTS `api_key` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`userId` INT NOT NULL,
|
||||
`name` VARCHAR(255) NOT NULL,
|
||||
`key` VARCHAR(64) NOT NULL,
|
||||
`prefix` VARCHAR(16) NOT NULL,
|
||||
`permissions` TEXT NULL,
|
||||
`lastUsed` DATETIME(3) NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`expiresAt` DATETIME(3) NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE INDEX `api_key_key_key` (`key`),
|
||||
INDEX `api_key_userId_idx` (`userId`),
|
||||
INDEX `api_key_key_idx` (`key`),
|
||||
CONSTRAINT `api_key_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 5. Таблица настроек уведомлений
|
||||
CREATE TABLE IF NOT EXISTS `notification_settings` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`userId` INT NOT NULL,
|
||||
`emailServerCreated` BOOLEAN NOT NULL DEFAULT true,
|
||||
`emailServerStopped` BOOLEAN NOT NULL DEFAULT true,
|
||||
`emailBalanceLow` BOOLEAN NOT NULL DEFAULT true,
|
||||
`emailPaymentCharged` BOOLEAN NOT NULL DEFAULT true,
|
||||
`emailTicketReply` BOOLEAN NOT NULL DEFAULT true,
|
||||
`emailNewsletter` BOOLEAN NOT NULL DEFAULT false,
|
||||
`pushServerCreated` BOOLEAN NOT NULL DEFAULT true,
|
||||
`pushServerStopped` BOOLEAN NOT NULL DEFAULT true,
|
||||
`pushBalanceLow` BOOLEAN NOT NULL DEFAULT true,
|
||||
`pushPaymentCharged` BOOLEAN NOT NULL DEFAULT true,
|
||||
`pushTicketReply` BOOLEAN NOT NULL DEFAULT true,
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE INDEX `notification_settings_userId_key` (`userId`),
|
||||
CONSTRAINT `notification_settings_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 6. Таблица профиля пользователя
|
||||
CREATE TABLE IF NOT EXISTS `user_profile` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`userId` INT NOT NULL,
|
||||
`avatarUrl` VARCHAR(255) NULL,
|
||||
`phoneNumber` VARCHAR(255) NULL,
|
||||
`timezone` VARCHAR(255) NULL DEFAULT 'Europe/Moscow',
|
||||
`language` VARCHAR(255) NULL DEFAULT 'ru',
|
||||
`profilePublic` BOOLEAN NOT NULL DEFAULT false,
|
||||
`showEmail` BOOLEAN NOT NULL DEFAULT false,
|
||||
`twoFactorEnabled` BOOLEAN NOT NULL DEFAULT false,
|
||||
`twoFactorSecret` TEXT NULL,
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE INDEX `user_profile_userId_key` (`userId`),
|
||||
CONSTRAINT `user_profile_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 7. Добавить поле passwordChangedAt в таблицу server (для скрытия пароля через 30 минут)
|
||||
ALTER TABLE `server` ADD COLUMN `passwordChangedAt` DATETIME(3) NULL AFTER `rootPassword`;
|
||||
|
||||
-- Готово! Теперь выполните на сервере:
|
||||
-- После выполнения этих запросов запустите:
|
||||
-- npx prisma generate
|
||||
-- npm run build
|
||||
-- pm2 restart ospab-backend
|
||||
|
||||
-- ========================================
|
||||
-- ОБНОВЛЕНИЕ: Добавление поля passwordChangedAt в таблицу server
|
||||
-- ========================================
|
||||
|
||||
-- Добавляем поле для отслеживания времени изменения пароля
|
||||
ALTER TABLE `server`
|
||||
ADD COLUMN `passwordChangedAt` DATETIME(3) NULL AFTER `rootPassword`;
|
||||
|
||||
-- Устанавливаем текущую дату для существующих серверов
|
||||
UPDATE `server`
|
||||
SET `passwordChangedAt` = `createdAt`
|
||||
WHERE `passwordChangedAt` IS NULL AND `rootPassword` IS NOT NULL;
|
||||
|
||||
1996
ospabhost/backend/package-lock.json
generated
1996
ospabhost/backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,15 @@
|
||||
"description": "",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
||||
"dev": "ts-node-dev --respawn --transpile-only ./src/index.ts",
|
||||
"start": "node dist/src/index.js",
|
||||
"build": "tsc"
|
||||
"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",
|
||||
"pm2:restart": "pm2 reload ecosystem.config.js --env production && pm2 save",
|
||||
"pm2:logs": "pm2 logs ospab-backend",
|
||||
"pm2:monit": "pm2 monit",
|
||||
"pm2:status": "pm2 list"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -24,13 +30,16 @@
|
||||
"express-session": "^1.18.2",
|
||||
"helmet": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"minio": "^8.0.6",
|
||||
"multer": "^2.0.2",
|
||||
"nodemailer": "^6.9.15",
|
||||
"passport": "^0.7.0",
|
||||
"passport-github": "^1.1.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-yandex": "^0.0.5",
|
||||
"proxmox-api": "^1.1.1",
|
||||
"ssh2": "^1.17.0",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.18.3",
|
||||
"xterm": "^5.3.0"
|
||||
},
|
||||
@@ -42,10 +51,12 @@
|
||||
"@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/passport": "^1.0.17",
|
||||
"@types/passport-github": "^1.1.12",
|
||||
"@types/passport-google-oauth20": "^2.0.16",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@types/xterm": "^2.0.3",
|
||||
"prisma": "^6.16.2",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
|
||||
65
ospabhost/backend/prisma/FOREIGN_KEY_FIX.md
Normal file
65
ospabhost/backend/prisma/FOREIGN_KEY_FIX.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Решение проблемы Foreign Key при удалении тарифов
|
||||
|
||||
## Проблема
|
||||
При попытке удалить тариф через Prisma Studio появляется ошибка:
|
||||
```
|
||||
Foreign key constraint violated on the fields: (`tariffId`)
|
||||
```
|
||||
|
||||
## Причина
|
||||
Тариф используется серверами. MySQL не позволяет удалить тариф, если на него ссылаются записи в таблице `server`.
|
||||
|
||||
## Решение
|
||||
|
||||
### Способ 1: Безопасный (рекомендуется)
|
||||
Удаляет только неиспользуемые тарифы, сохраняет серверы.
|
||||
|
||||
```bash
|
||||
mysql -u root -p ospabhost < backend/prisma/safe_tariff_migration.sql
|
||||
```
|
||||
|
||||
### Способ 2: Полная очистка (только для dev!)
|
||||
Удаляет ВСЕ серверы и тарифы.
|
||||
|
||||
```bash
|
||||
# Сначала бэкап!
|
||||
mysqldump -u root -p ospabhost > backup.sql
|
||||
|
||||
# Потом очистка
|
||||
mysql -u root -p ospabhost < backend/prisma/clean_slate_migration.sql
|
||||
```
|
||||
|
||||
### Способ 3: Ручное удаление через SQL
|
||||
|
||||
```sql
|
||||
-- 1. Найти тарифы без серверов
|
||||
SELECT t.id, t.name, COUNT(s.id) as servers
|
||||
FROM tariff t
|
||||
LEFT JOIN server s ON s.tariffId = t.id
|
||||
GROUP BY t.id
|
||||
HAVING servers = 0;
|
||||
|
||||
-- 2. Удалить только неиспользуемые
|
||||
DELETE FROM tariff WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT t.id FROM tariff t
|
||||
LEFT JOIN server s ON s.tariffId = t.id
|
||||
GROUP BY t.id
|
||||
HAVING COUNT(s.id) = 0
|
||||
) as unused
|
||||
);
|
||||
|
||||
-- 3. Добавить категорию
|
||||
ALTER TABLE tariff ADD COLUMN category VARCHAR(50) NOT NULL DEFAULT 'vps';
|
||||
|
||||
-- 4. Добавить новые тарифы (см. safe_tariff_migration.sql)
|
||||
```
|
||||
|
||||
## Перезапуск backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm start
|
||||
```
|
||||
|
||||
Готово! 🎉
|
||||
43
ospabhost/backend/prisma/add_tariff_categories.sql
Normal file
43
ospabhost/backend/prisma/add_tariff_categories.sql
Normal file
@@ -0,0 +1,43 @@
|
||||
-- Добавление поля category в таблицу tariff
|
||||
ALTER TABLE `tariff` ADD COLUMN `category` VARCHAR(50) NOT NULL DEFAULT 'vps' AFTER `description`;
|
||||
|
||||
-- Удаление старых тарифов (если нужно)
|
||||
-- DELETE FROM `tariff`;
|
||||
|
||||
-- ============================================
|
||||
-- VPS/VDS Тарифы
|
||||
-- ============================================
|
||||
|
||||
INSERT INTO `tariff` (`name`, `price`, `description`, `category`) VALUES
|
||||
('VPS Starter', 299, '1 vCore, 1 GB RAM, 10 GB SSD, 1 TB трафик', 'vps'),
|
||||
('VPS Basic', 499, '2 vCore, 2 GB RAM, 20 GB SSD, 2 TB трафик', 'vps'),
|
||||
('VPS Standard', 899, '4 vCore, 4 GB RAM, 40 GB SSD, 3 TB трафик', 'vps'),
|
||||
('VPS Advanced', 1499, '6 vCore, 8 GB RAM, 80 GB SSD, 5 TB трафик', 'vps'),
|
||||
('VPS Pro', 2499, '8 vCore, 16 GB RAM, 160 GB SSD, Unlimited трафик', 'vps'),
|
||||
('VPS Enterprise', 4999, '16 vCore, 32 GB RAM, 320 GB SSD, Unlimited трафик', 'vps');
|
||||
|
||||
-- ============================================
|
||||
-- Хостинг для сайтов
|
||||
-- ============================================
|
||||
|
||||
INSERT INTO `tariff` (`name`, `price`, `description`, `category`) VALUES
|
||||
('Хостинг Lite', 149, '1 сайт, 5 GB SSD, 10 GB трафик, 1 БД MySQL', 'hosting'),
|
||||
('Хостинг Start', 249, '3 сайта, 10 GB SSD, 50 GB трафик, 3 БД MySQL', 'hosting'),
|
||||
('Хостинг Plus', 449, '10 сайтов, 25 GB SSD, 100 GB трафик, 10 БД MySQL', 'hosting'),
|
||||
('Хостинг Business', 799, 'Безлимит сайтов, 50 GB SSD, 500 GB трафик, Безлимит БД', 'hosting'),
|
||||
('Хостинг Premium', 1299, 'Безлимит сайтов, 100 GB SSD, Unlimited трафик, Безлимит БД, SSL', 'hosting');
|
||||
|
||||
-- ============================================
|
||||
-- S3 Хранилище
|
||||
-- ============================================
|
||||
|
||||
INSERT INTO `tariff` (`name`, `price`, `description`, `category`) VALUES
|
||||
('S3 Mini', 99, '10 GB хранилище, 50 GB трафик, API доступ', 's3'),
|
||||
('S3 Basic', 199, '50 GB хранилище, 200 GB трафик, API доступ', 's3'),
|
||||
('S3 Standard', 399, '200 GB хранилище, 1 TB трафик, API доступ, CDN', 's3'),
|
||||
('S3 Advanced', 799, '500 GB хранилище, 3 TB трафик, API доступ, CDN', 's3'),
|
||||
('S3 Pro', 1499, '1 TB хранилище, 10 TB трафик, API доступ, CDN, Priority Support', 's3'),
|
||||
('S3 Enterprise', 2999, '5 TB хранилище, Unlimited трафик, API доступ, CDN, Priority Support', 's3');
|
||||
|
||||
-- Проверка добавленных тарифов
|
||||
SELECT * FROM `tariff` ORDER BY `category`, `price`;
|
||||
57
ospabhost/backend/prisma/apply-migration.ts
Normal file
57
ospabhost/backend/prisma/apply-migration.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { prisma } from '../src/prisma/client';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
async function applyMigration() {
|
||||
try {
|
||||
const sqlPath = path.join(__dirname, 'migrations_manual', 'add_sessions_qr_tickets_features.sql');
|
||||
const sql = fs.readFileSync(sqlPath, 'utf-8');
|
||||
|
||||
// Удаляем комментарии и разделяем по точке с запятой
|
||||
const cleaned = sql
|
||||
.split('\n')
|
||||
.filter(line => !line.trim().startsWith('--'))
|
||||
.join('\n');
|
||||
|
||||
const statements = cleaned
|
||||
.split(';')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0);
|
||||
|
||||
console.log(`🚀 Применяю миграцию: ${statements.length} запросов...`);
|
||||
|
||||
for (let i = 0; i < statements.length; i++) {
|
||||
const statement = statements[i];
|
||||
const preview = statement.replace(/\s+/g, ' ').substring(0, 150);
|
||||
console.log(`\n[${i + 1}/${statements.length}] Выполняю:`);
|
||||
console.log(preview + '...');
|
||||
|
||||
try {
|
||||
await prisma.$executeRawUnsafe(statement);
|
||||
console.log('✅ Успешно');
|
||||
} catch (error: any) {
|
||||
// Игнорируем ошибки "duplicate column" и "table already exists"
|
||||
if (
|
||||
error.message.includes('Duplicate column') ||
|
||||
error.message.includes('already exists') ||
|
||||
error.message.includes('Duplicate key')
|
||||
) {
|
||||
console.log('⚠️ Уже существует, пропускаю...');
|
||||
} else {
|
||||
console.error('❌ Ошибка:', error.message);
|
||||
// Не выбрасываем ошибку, продолжаем выполнение
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n✅ Миграция завершена!');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Критическая ошибка:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
applyMigration();
|
||||
73
ospabhost/backend/prisma/clean_slate_migration.sql
Normal file
73
ospabhost/backend/prisma/clean_slate_migration.sql
Normal file
@@ -0,0 +1,73 @@
|
||||
-- ============================================
|
||||
-- ВНИМАНИЕ! Этот скрипт удалит ВСЕ серверы и тарифы
|
||||
-- Используйте только если хотите начать с чистого листа
|
||||
-- ============================================
|
||||
|
||||
-- Шаг 1: Проверяем, что будет удалено
|
||||
SELECT 'Servers to delete:' as info, COUNT(*) as count FROM `server`;
|
||||
SELECT 'Tariffs to delete:' as info, COUNT(*) as count FROM `tariff`;
|
||||
|
||||
-- Шаг 2: Удаляем все связанные данные (в правильном порядке)
|
||||
|
||||
-- Удаляем метрики серверов
|
||||
DELETE FROM `server_metrics`;
|
||||
|
||||
-- Удаляем платежи
|
||||
DELETE FROM `payment`;
|
||||
|
||||
-- Удаляем серверы (это разрешит удаление тарифов)
|
||||
DELETE FROM `server`;
|
||||
|
||||
-- Удаляем все тарифы
|
||||
DELETE FROM `tariff`;
|
||||
|
||||
-- Шаг 3: Добавляем поле category
|
||||
ALTER TABLE `tariff`
|
||||
ADD COLUMN IF NOT EXISTS `category` VARCHAR(50) NOT NULL DEFAULT 'vps'
|
||||
AFTER `description`;
|
||||
|
||||
-- Шаг 4: Добавляем новые тарифы
|
||||
-- ============================================
|
||||
-- VPS/VDS Тарифы
|
||||
-- ============================================
|
||||
|
||||
INSERT INTO `tariff` (`name`, `price`, `description`, `category`) VALUES
|
||||
('VPS Starter', 299, '1 vCore, 1 GB RAM, 10 GB SSD, 1 TB трафик', 'vps'),
|
||||
('VPS Basic', 499, '2 vCore, 2 GB RAM, 20 GB SSD, 2 TB трафик', 'vps'),
|
||||
('VPS Standard', 899, '4 vCore, 4 GB RAM, 40 GB SSD, 3 TB трафик', 'vps'),
|
||||
('VPS Advanced', 1499, '6 vCore, 8 GB RAM, 80 GB SSD, 5 TB трафик', 'vps'),
|
||||
('VPS Pro', 2499, '8 vCore, 16 GB RAM, 160 GB SSD, Unlimited трафик', 'vps'),
|
||||
('VPS Enterprise', 4999, '16 vCore, 32 GB RAM, 320 GB SSD, Unlimited трафик', 'vps');
|
||||
|
||||
-- ============================================
|
||||
-- Хостинг для сайтов
|
||||
-- ============================================
|
||||
|
||||
INSERT INTO `tariff` (`name`, `price`, `description`, `category`) VALUES
|
||||
('Хостинг Lite', 149, '1 сайт, 5 GB SSD, 10 GB трафик, 1 БД MySQL', 'hosting'),
|
||||
('Хостинг Start', 249, '3 сайта, 10 GB SSD, 50 GB трафик, 3 БД MySQL', 'hosting'),
|
||||
('Хостинг Plus', 449, '10 сайтов, 25 GB SSD, 100 GB трафик, 10 БД MySQL', 'hosting'),
|
||||
('Хостинг Business', 799, 'Безлимит сайтов, 50 GB SSD, 500 GB трафик, Безлимит БД', 'hosting'),
|
||||
('Хостинг Premium', 1299, 'Безлимит сайтов, 100 GB SSD, Unlimited трафик, Безлимит БД, SSL', 'hosting');
|
||||
|
||||
-- ============================================
|
||||
-- S3 Хранилище
|
||||
-- ============================================
|
||||
|
||||
INSERT INTO `tariff` (`name`, `price`, `description`, `category`) VALUES
|
||||
('S3 Mini', 99, '10 GB хранилище, 50 GB трафик, API доступ', 's3'),
|
||||
('S3 Basic', 199, '50 GB хранилище, 200 GB трафик, API доступ', 's3'),
|
||||
('S3 Standard', 399, '200 GB хранилище, 1 TB трафик, API доступ, CDN', 's3'),
|
||||
('S3 Advanced', 799, '500 GB хранилище, 3 TB трафик, API доступ, CDN', 's3'),
|
||||
('S3 Pro', 1499, '1 TB хранилище, 10 TB трафик, API доступ, CDN, Priority Support', 's3'),
|
||||
('S3 Enterprise', 2999, '5 TB хранилище, Unlimited трафик, API доступ, CDN, Priority Support', 's3');
|
||||
|
||||
-- Шаг 5: Проверка
|
||||
SELECT 'New tariffs:' as info;
|
||||
SELECT * FROM `tariff` ORDER BY `category`, `price`;
|
||||
|
||||
-- Сброс AUTO_INCREMENT (опционально)
|
||||
ALTER TABLE `tariff` AUTO_INCREMENT = 1;
|
||||
ALTER TABLE `server` AUTO_INCREMENT = 1;
|
||||
ALTER TABLE `payment` AUTO_INCREMENT = 1;
|
||||
ALTER TABLE `server_metrics` AUTO_INCREMENT = 1;
|
||||
18
ospabhost/backend/prisma/manual_migration_category.sql
Normal file
18
ospabhost/backend/prisma/manual_migration_category.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- ============================================
|
||||
-- Миграция: Добавление category к тарифам
|
||||
-- Дата: 8 ноября 2025
|
||||
-- ============================================
|
||||
|
||||
-- Шаг 1: Добавление поля category (если ещё не существует)
|
||||
ALTER TABLE `tariff`
|
||||
ADD COLUMN IF NOT EXISTS `category` VARCHAR(50) NOT NULL DEFAULT 'vps'
|
||||
AFTER `description`;
|
||||
|
||||
-- Шаг 2: Обновление существующих тарифов (если есть)
|
||||
UPDATE `tariff` SET `category` = 'vps' WHERE `category` IS NULL OR `category` = '';
|
||||
|
||||
-- Проверка структуры таблицы
|
||||
DESCRIBE `tariff`;
|
||||
|
||||
-- Показать текущие тарифы
|
||||
SELECT * FROM `tariff` ORDER BY `category`, `price`;
|
||||
25
ospabhost/backend/prisma/migrations/add_server_metrics.sql
Normal file
25
ospabhost/backend/prisma/migrations/add_server_metrics.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- Миграция для добавления таблицы метрик серверов
|
||||
|
||||
CREATE TABLE `server_metrics` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`serverId` INT NOT NULL,
|
||||
`cpuUsage` DOUBLE NOT NULL DEFAULT 0,
|
||||
`memoryUsage` DOUBLE NOT NULL DEFAULT 0,
|
||||
`memoryUsed` BIGINT NOT NULL DEFAULT 0,
|
||||
`memoryMax` BIGINT NOT NULL DEFAULT 0,
|
||||
`diskUsage` DOUBLE NOT NULL DEFAULT 0,
|
||||
`diskUsed` BIGINT NOT NULL DEFAULT 0,
|
||||
`diskMax` BIGINT NOT NULL DEFAULT 0,
|
||||
`networkIn` BIGINT NOT NULL DEFAULT 0,
|
||||
`networkOut` BIGINT NOT NULL DEFAULT 0,
|
||||
`status` VARCHAR(191) NOT NULL DEFAULT 'unknown',
|
||||
`uptime` BIGINT NOT NULL DEFAULT 0,
|
||||
`timestamp` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `server_metrics_serverId_timestamp_idx` (`serverId`, `timestamp`),
|
||||
|
||||
CONSTRAINT `server_metrics_serverId_fkey`
|
||||
FOREIGN KEY (`serverId`) REFERENCES `server`(`id`)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,125 @@
|
||||
-- Миграция: Добавление системы сессий, QR-авторизации и улучшенных тикетов
|
||||
-- Дата: 2025-11-09
|
||||
|
||||
-- 1. Обновление таблицы ticket (добавление новых полей)
|
||||
ALTER TABLE `ticket`
|
||||
MODIFY COLUMN `message` TEXT NOT NULL,
|
||||
ADD COLUMN `priority` VARCHAR(20) DEFAULT 'normal' AFTER `status`,
|
||||
ADD COLUMN `category` VARCHAR(50) DEFAULT 'general' AFTER `priority`,
|
||||
ADD COLUMN `assignedTo` INT NULL AFTER `category`,
|
||||
ADD COLUMN `closedAt` DATETIME NULL AFTER `updatedAt`;
|
||||
|
||||
-- 2. Обновление таблицы response
|
||||
ALTER TABLE `response`
|
||||
MODIFY COLUMN `message` TEXT NOT NULL,
|
||||
ADD COLUMN `isInternal` BOOLEAN DEFAULT FALSE AFTER `message`;
|
||||
|
||||
-- 3. Создание таблицы для прикреплённых файлов к тикетам
|
||||
CREATE TABLE IF NOT EXISTS `ticket_attachment` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`ticketId` INT NOT NULL,
|
||||
`filename` VARCHAR(255) NOT NULL,
|
||||
`fileUrl` VARCHAR(500) NOT NULL,
|
||||
`fileSize` INT NOT NULL,
|
||||
`mimeType` VARCHAR(100) NOT NULL,
|
||||
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `ticketId_idx` (`ticketId`),
|
||||
CONSTRAINT `ticket_attachment_ticketId_fkey`
|
||||
FOREIGN KEY (`ticketId`)
|
||||
REFERENCES `ticket`(`id`)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 4. Создание таблицы для прикреплённых файлов к ответам
|
||||
CREATE TABLE IF NOT EXISTS `response_attachment` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`responseId` INT NOT NULL,
|
||||
`filename` VARCHAR(255) NOT NULL,
|
||||
`fileUrl` VARCHAR(500) NOT NULL,
|
||||
`fileSize` INT NOT NULL,
|
||||
`mimeType` VARCHAR(100) NOT NULL,
|
||||
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `responseId_idx` (`responseId`),
|
||||
CONSTRAINT `response_attachment_responseId_fkey`
|
||||
FOREIGN KEY (`responseId`)
|
||||
REFERENCES `response`(`id`)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 5. Создание таблицы QR-авторизации
|
||||
CREATE TABLE IF NOT EXISTS `qr_login_request` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`code` VARCHAR(128) NOT NULL,
|
||||
`userId` INT NULL,
|
||||
`status` VARCHAR(20) DEFAULT 'pending',
|
||||
`ipAddress` VARCHAR(45) NULL,
|
||||
`userAgent` TEXT NULL,
|
||||
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`expiresAt` DATETIME NOT NULL,
|
||||
`confirmedAt` DATETIME NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `code_unique` (`code`),
|
||||
INDEX `code_idx` (`code`),
|
||||
INDEX `status_expiresAt_idx` (`status`, `expiresAt`),
|
||||
INDEX `userId_idx` (`userId`),
|
||||
CONSTRAINT `qr_login_request_userId_fkey`
|
||||
FOREIGN KEY (`userId`)
|
||||
REFERENCES `user`(`id`)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 6. Проверка и создание таблицы session (если не существует)
|
||||
CREATE TABLE IF NOT EXISTS `session` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`userId` INT NOT NULL,
|
||||
`token` VARCHAR(500) NOT NULL,
|
||||
`ipAddress` VARCHAR(45) NULL,
|
||||
`userAgent` TEXT NULL,
|
||||
`device` VARCHAR(50) NULL,
|
||||
`browser` VARCHAR(50) NULL,
|
||||
`location` VARCHAR(200) NULL,
|
||||
`lastActivity` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`expiresAt` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `token_unique` (`token`),
|
||||
INDEX `userId_idx` (`userId`),
|
||||
CONSTRAINT `session_userId_fkey`
|
||||
FOREIGN KEY (`userId`)
|
||||
REFERENCES `user`(`id`)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 7. Проверка и создание таблицы login_history (если не существует)
|
||||
CREATE TABLE IF NOT EXISTS `login_history` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`userId` INT NOT NULL,
|
||||
`ipAddress` VARCHAR(45) NOT NULL,
|
||||
`userAgent` TEXT NULL,
|
||||
`device` VARCHAR(50) NULL,
|
||||
`browser` VARCHAR(50) NULL,
|
||||
`location` VARCHAR(200) NULL,
|
||||
`success` BOOLEAN DEFAULT TRUE,
|
||||
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `userId_idx` (`userId`),
|
||||
INDEX `createdAt_idx` (`createdAt`),
|
||||
CONSTRAINT `login_history_userId_fkey`
|
||||
FOREIGN KEY (`userId`)
|
||||
REFERENCES `user`(`id`)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 8. Обновление статусов тикетов для существующих записей
|
||||
UPDATE `ticket` SET `priority` = 'normal' WHERE `priority` IS NULL;
|
||||
UPDATE `ticket` SET `category` = 'general' WHERE `category` IS NULL;
|
||||
|
||||
-- Готово!
|
||||
SELECT 'Migration completed successfully!' as status;
|
||||
86
ospabhost/backend/prisma/safe_tariff_migration.sql
Normal file
86
ospabhost/backend/prisma/safe_tariff_migration.sql
Normal file
@@ -0,0 +1,86 @@
|
||||
-- ============================================
|
||||
-- Безопасная миграция тарифов
|
||||
-- Удаление старых тарифов с учётом foreign key
|
||||
-- ============================================
|
||||
|
||||
-- Шаг 1: Проверяем, какие тарифы используются серверами
|
||||
SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
COUNT(s.id) as servers_count
|
||||
FROM `tariff` t
|
||||
LEFT JOIN `server` s ON s.tariffId = t.id
|
||||
GROUP BY t.id, t.name
|
||||
ORDER BY t.name;
|
||||
|
||||
-- Шаг 2: Добавляем поле category (если ещё нет)
|
||||
ALTER TABLE `tariff`
|
||||
ADD COLUMN IF NOT EXISTS `category` VARCHAR(50) NOT NULL DEFAULT 'vps'
|
||||
AFTER `description`;
|
||||
|
||||
-- Шаг 3: ВАРИАНТ А - Обновить существующие тарифы вместо удаления
|
||||
-- Присваиваем категории существующим тарифам
|
||||
UPDATE `tariff` SET `category` = 'vps' WHERE `category` IS NULL OR `category` = '';
|
||||
|
||||
-- Шаг 4: ВАРИАНТ Б - Удалить только неиспользуемые тарифы
|
||||
-- Создаём временную таблицу с ID используемых тарифов
|
||||
CREATE TEMPORARY TABLE used_tariffs AS
|
||||
SELECT DISTINCT tariffId FROM `server`;
|
||||
|
||||
-- Удаляем только те тарифы, которые НЕ используются
|
||||
DELETE FROM `tariff`
|
||||
WHERE id NOT IN (SELECT tariffId FROM used_tariffs);
|
||||
|
||||
-- Удаляем временную таблицу
|
||||
DROP TEMPORARY TABLE used_tariffs;
|
||||
|
||||
-- Шаг 5: Добавляем новые тарифы
|
||||
-- ============================================
|
||||
-- VPS/VDS Тарифы
|
||||
-- ============================================
|
||||
|
||||
INSERT INTO `tariff` (`name`, `price`, `description`, `category`) VALUES
|
||||
('VPS Starter', 299, '1 vCore, 1 GB RAM, 10 GB SSD, 1 TB трафик', 'vps'),
|
||||
('VPS Basic', 499, '2 vCore, 2 GB RAM, 20 GB SSD, 2 TB трафик', 'vps'),
|
||||
('VPS Standard', 899, '4 vCore, 4 GB RAM, 40 GB SSD, 3 TB трафик', 'vps'),
|
||||
('VPS Advanced', 1499, '6 vCore, 8 GB RAM, 80 GB SSD, 5 TB трафик', 'vps'),
|
||||
('VPS Pro', 2499, '8 vCore, 16 GB RAM, 160 GB SSD, Unlimited трафик', 'vps'),
|
||||
('VPS Enterprise', 4999, '16 vCore, 32 GB RAM, 320 GB SSD, Unlimited трафик', 'vps');
|
||||
|
||||
-- ============================================
|
||||
-- Хостинг для сайтов
|
||||
-- ============================================
|
||||
|
||||
INSERT INTO `tariff` (`name`, `price`, `description`, `category`) VALUES
|
||||
('Хостинг Lite', 149, '1 сайт, 5 GB SSD, 10 GB трафик, 1 БД MySQL', 'hosting'),
|
||||
('Хостинг Start', 249, '3 сайта, 10 GB SSD, 50 GB трафик, 3 БД MySQL', 'hosting'),
|
||||
('Хостинг Plus', 449, '10 сайтов, 25 GB SSD, 100 GB трафик, 10 БД MySQL', 'hosting'),
|
||||
('Хостинг Business', 799, 'Безлимит сайтов, 50 GB SSD, 500 GB трафик, Безлимит БД', 'hosting'),
|
||||
('Хостинг Premium', 1299, 'Безлимит сайтов, 100 GB SSD, Unlimited трафик, Безлимит БД, SSL', 'hosting');
|
||||
|
||||
-- ============================================
|
||||
-- S3 Хранилище
|
||||
-- ============================================
|
||||
|
||||
INSERT INTO `tariff` (`name`, `price`, `description`, `category`) VALUES
|
||||
('S3 Mini', 99, '10 GB хранилище, 50 GB трафик, API доступ', 's3'),
|
||||
('S3 Basic', 199, '50 GB хранилище, 200 GB трафик, API доступ', 's3'),
|
||||
('S3 Standard', 399, '200 GB хранилище, 1 TB трафик, API доступ, CDN', 's3'),
|
||||
('S3 Advanced', 799, '500 GB хранилище, 3 TB трафик, API доступ, CDN', 's3'),
|
||||
('S3 Pro', 1499, '1 TB хранилище, 10 TB трафик, API доступ, CDN, Priority Support', 's3'),
|
||||
('S3 Enterprise', 2999, '5 TB хранилище, Unlimited трафик, API доступ, CDN, Priority Support', 's3');
|
||||
|
||||
-- Проверка добавленных тарифов
|
||||
SELECT * FROM `tariff` ORDER BY `category`, `price`;
|
||||
|
||||
-- Показать количество серверов для каждого тарифа
|
||||
SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.category,
|
||||
t.price,
|
||||
COUNT(s.id) as active_servers
|
||||
FROM `tariff` t
|
||||
LEFT JOIN `server` s ON s.tariffId = t.id
|
||||
GROUP BY t.id, t.name, t.category, t.price
|
||||
ORDER BY t.category, t.price;
|
||||
@@ -10,71 +10,7 @@ datasource db {
|
||||
}
|
||||
|
||||
// This is your Prisma schema file,
|
||||
model Tariff {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
price Float
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
servers Server[]
|
||||
|
||||
@@map("tariff")
|
||||
}
|
||||
|
||||
|
||||
|
||||
model OperatingSystem {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
type String // linux, windows, etc
|
||||
template String? // путь к шаблону для контейнера
|
||||
createdAt DateTime @default(now())
|
||||
servers Server[]
|
||||
|
||||
@@map("operatingsystem")
|
||||
}
|
||||
|
||||
model Server {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
tariffId Int
|
||||
osId Int
|
||||
status String @default("creating") // creating, running, stopped, suspended, error
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
tariff Tariff @relation(fields: [tariffId], references: [id])
|
||||
os OperatingSystem @relation(fields: [osId], references: [id])
|
||||
|
||||
// Proxmox данные
|
||||
node String?
|
||||
diskTemplate String?
|
||||
proxmoxId Int?
|
||||
|
||||
// Сетевые настройки
|
||||
ipAddress String? // Локальный IP адрес
|
||||
macAddress String? // MAC адрес
|
||||
|
||||
// Доступы
|
||||
rootPassword String? // Зашифрованный root пароль
|
||||
sshPublicKey String? // SSH публичный ключ (опционально)
|
||||
|
||||
// Мониторинг
|
||||
lastPing DateTime?
|
||||
cpuUsage Float? @default(0)
|
||||
memoryUsage Float? @default(0)
|
||||
diskUsage Float? @default(0)
|
||||
networkIn Float? @default(0)
|
||||
networkOut Float? @default(0)
|
||||
|
||||
// Автоматические платежи
|
||||
nextPaymentDate DateTime? // Дата следующего списания
|
||||
autoRenew Boolean @default(true) // Автопродление
|
||||
|
||||
payments Payment[]
|
||||
|
||||
@@map("server")
|
||||
}
|
||||
// VPS/Server models removed - moving to S3 storage
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
@@ -89,10 +25,20 @@ model User {
|
||||
responses Response[] @relation("OperatorResponses")
|
||||
checks Check[] @relation("UserChecks")
|
||||
balance Float @default(0)
|
||||
servers Server[]
|
||||
notifications Notification[]
|
||||
payments Payment[]
|
||||
pushSubscriptions PushSubscription[]
|
||||
transactions Transaction[] // История всех транзакций
|
||||
posts Post[] @relation("PostAuthor") // Статьи блога
|
||||
comments Comment[] @relation("UserComments") // Комментарии
|
||||
buckets StorageBucket[] // S3 хранилища пользователя
|
||||
|
||||
// Новые relations для расширенных настроек
|
||||
sessions Session[]
|
||||
loginHistory LoginHistory[]
|
||||
apiKeys APIKey[]
|
||||
notificationSettings NotificationSettings?
|
||||
profile UserProfile?
|
||||
qrLoginRequests QrLoginRequest[]
|
||||
|
||||
@@map("user")
|
||||
}
|
||||
@@ -136,55 +82,86 @@ model Service {
|
||||
model Ticket {
|
||||
id Int @id @default(autoincrement())
|
||||
title String
|
||||
message String
|
||||
message String @db.Text
|
||||
userId Int
|
||||
status String @default("open")
|
||||
status String @default("open") // open, in_progress, awaiting_reply, resolved, closed
|
||||
priority String @default("normal") // low, normal, high, urgent
|
||||
category String @default("general") // general, technical, billing, other
|
||||
assignedTo Int? // ID оператора, которому назначен тикет
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
closedAt DateTime?
|
||||
responses Response[] @relation("TicketResponses")
|
||||
attachments TicketAttachment[]
|
||||
user User? @relation("UserTickets", fields: [userId], references: [id])
|
||||
|
||||
@@map("ticket")
|
||||
@@map("ticket")
|
||||
}
|
||||
|
||||
model Response {
|
||||
id Int @id @default(autoincrement())
|
||||
ticketId Int
|
||||
operatorId Int
|
||||
message String
|
||||
message String @db.Text
|
||||
isInternal Boolean @default(false) // Внутренний комментарий (виден только операторам)
|
||||
createdAt DateTime @default(now())
|
||||
ticket Ticket @relation("TicketResponses", fields: [ticketId], references: [id])
|
||||
ticket Ticket @relation("TicketResponses", fields: [ticketId], references: [id], onDelete: Cascade)
|
||||
operator User @relation("OperatorResponses", fields: [operatorId], references: [id])
|
||||
attachments ResponseAttachment[]
|
||||
|
||||
@@map("response")
|
||||
|
||||
|
||||
}
|
||||
model Notification {
|
||||
id Int @id @default(autoincrement())
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
title String
|
||||
message String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("notification")
|
||||
@@map("response")
|
||||
}
|
||||
|
||||
// Автоматические платежи за серверы
|
||||
model Payment {
|
||||
// Прикреплённые файлы к тикетам
|
||||
model TicketAttachment {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
serverId Int
|
||||
amount Float
|
||||
status String @default("pending") // pending, success, failed
|
||||
type String // subscription, manual
|
||||
ticketId Int
|
||||
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
||||
|
||||
filename String
|
||||
fileUrl String
|
||||
fileSize Int // Размер в байтах
|
||||
mimeType String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
processedAt DateTime?
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("ticket_attachment")
|
||||
}
|
||||
|
||||
@@map("payment")
|
||||
// Прикреплённые файлы к ответам
|
||||
model ResponseAttachment {
|
||||
id Int @id @default(autoincrement())
|
||||
responseId Int
|
||||
response Response @relation(fields: [responseId], references: [id], onDelete: Cascade)
|
||||
|
||||
filename String
|
||||
fileUrl String
|
||||
fileSize Int
|
||||
mimeType String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("response_attachment")
|
||||
}
|
||||
|
||||
// QR-код авторизация (как в Telegram Web)
|
||||
model QrLoginRequest {
|
||||
id Int @id @default(autoincrement())
|
||||
code String @unique @db.VarChar(128) // Уникальный код QR
|
||||
userId Int? // После подтверждения - ID пользователя
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
status String @default("pending") // pending, confirmed, expired, rejected
|
||||
ipAddress String?
|
||||
userAgent String? @db.Text
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime // Через 60 секунд
|
||||
confirmedAt DateTime?
|
||||
|
||||
@@index([code])
|
||||
@@index([status, expiresAt])
|
||||
@@map("qr_login_request")
|
||||
}
|
||||
|
||||
// История всех транзакций (пополнения, списания, возвраты)
|
||||
@@ -201,4 +178,242 @@ model Transaction {
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@map("transaction")
|
||||
}
|
||||
|
||||
// Блог
|
||||
model Post {
|
||||
id Int @id @default(autoincrement())
|
||||
title String
|
||||
content String @db.Text // Rich text content (HTML)
|
||||
excerpt String? @db.Text // Краткое описание для ленты
|
||||
coverImage String? // URL обложки
|
||||
url String @unique // Пользовательский URL (blog_name)
|
||||
status String @default("draft") // draft, published, archived
|
||||
authorId Int
|
||||
author User @relation("PostAuthor", fields: [authorId], references: [id])
|
||||
views Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
publishedAt DateTime?
|
||||
comments Comment[]
|
||||
|
||||
@@map("post")
|
||||
}
|
||||
|
||||
// Комментарии к статьям блога
|
||||
model Comment {
|
||||
id Int @id @default(autoincrement())
|
||||
postId Int
|
||||
userId Int? // null если комментарий от гостя
|
||||
authorName String? // Имя автора (для гостей)
|
||||
content String @db.Text
|
||||
status String @default("pending") // pending, approved, rejected
|
||||
createdAt DateTime @default(now())
|
||||
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||
user User? @relation("UserComments", fields: [userId], references: [id])
|
||||
|
||||
@@map("comment")
|
||||
}
|
||||
|
||||
// Модель для уведомлений
|
||||
model Notification {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
type String // server_created, payment_charged, tariff_expiring, ticket_reply, payment_received, balance_low
|
||||
title String
|
||||
message String @db.Text
|
||||
|
||||
// Связанные сущности (опционально)
|
||||
ticketId Int?
|
||||
checkId Int?
|
||||
|
||||
// Метаданные
|
||||
actionUrl String? // URL для перехода при клике
|
||||
icon String? // Иконка (emoji или path)
|
||||
color String? // Цвет (green, blue, orange, red, purple)
|
||||
|
||||
isRead Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId, isRead])
|
||||
@@index([userId, createdAt])
|
||||
@@map("notification")
|
||||
}
|
||||
|
||||
// Модель для Push-подписок (Web Push API)
|
||||
model PushSubscription {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
endpoint String @db.VarChar(512)
|
||||
p256dh String @db.Text // Публичный ключ для шифрования
|
||||
auth String @db.Text // Токен аутентификации
|
||||
|
||||
userAgent String? @db.Text // Браузер/устройство
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
lastUsed DateTime @default(now())
|
||||
|
||||
@@unique([userId, endpoint])
|
||||
@@index([userId])
|
||||
@@map("push_subscription")
|
||||
}
|
||||
|
||||
// Активные сеансы пользователя
|
||||
model Session {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
token String @unique @db.VarChar(500) // JWT refresh token
|
||||
ipAddress String?
|
||||
userAgent String? @db.Text
|
||||
device String? // Desktop, Mobile, Tablet
|
||||
browser String? // Chrome, Firefox, Safari, etc.
|
||||
location String? // Город/страна по IP
|
||||
|
||||
lastActivity DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
|
||||
@@index([userId])
|
||||
@@map("session")
|
||||
}
|
||||
|
||||
// История входов
|
||||
model LoginHistory {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
ipAddress String
|
||||
userAgent String? @db.Text
|
||||
device String?
|
||||
browser String?
|
||||
location String?
|
||||
|
||||
success Boolean @default(true) // true = успешный вход, false = неудачная попытка
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
@@index([createdAt])
|
||||
@@map("login_history")
|
||||
}
|
||||
|
||||
// API ключи для разработчиков
|
||||
model APIKey {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
name String // Название (например, "Production API")
|
||||
key String @unique @db.VarChar(64) // Сам API ключ
|
||||
prefix String @db.VarChar(16) // Префикс для отображения (ospab_xxxx)
|
||||
|
||||
permissions String? @db.Text // JSON массив разрешений ["servers:read", "servers:create", etc.]
|
||||
|
||||
lastUsed DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime?
|
||||
|
||||
@@index([userId])
|
||||
@@index([key])
|
||||
@@map("api_key")
|
||||
}
|
||||
|
||||
// Настройки уведомлений пользователя
|
||||
model NotificationSettings {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int @unique
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Email уведомления
|
||||
emailBalanceLow Boolean @default(true)
|
||||
emailPaymentCharged Boolean @default(true)
|
||||
emailTicketReply Boolean @default(true)
|
||||
emailNewsletter Boolean @default(false)
|
||||
|
||||
// Push уведомления
|
||||
pushBalanceLow Boolean @default(true)
|
||||
pushPaymentCharged Boolean @default(true)
|
||||
pushTicketReply Boolean @default(true)
|
||||
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("notification_settings")
|
||||
}
|
||||
|
||||
// Настройки профиля
|
||||
model UserProfile {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int @unique
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
avatarUrl String? // Путь к аватару
|
||||
phoneNumber String?
|
||||
timezone String? @default("Europe/Moscow")
|
||||
language String? @default("ru")
|
||||
|
||||
// Настройки приватности
|
||||
profilePublic Boolean @default(false)
|
||||
showEmail Boolean @default(false)
|
||||
|
||||
// 2FA
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
twoFactorSecret String? @db.Text
|
||||
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("user_profile")
|
||||
}
|
||||
|
||||
// S3 Bucket модель
|
||||
model StorageBucket {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
name String // Уникальное имя бакета в рамках пользователя
|
||||
plan String // Выбранный тариф (basic, standard, plus, pro, enterprise)
|
||||
quotaGb Int // Лимит включённого объёма в GB
|
||||
usedBytes BigInt @default(0) // Текущий объём хранения в байтах
|
||||
objectCount Int @default(0)
|
||||
storageClass String @default("standard") // standard, infrequent, archive
|
||||
region String @default("ru-central-1")
|
||||
public Boolean @default(false)
|
||||
versioning Boolean @default(false)
|
||||
status String @default("active") // active, grace, suspended
|
||||
monthlyPrice Float
|
||||
nextBillingDate DateTime?
|
||||
lastBilledAt DateTime?
|
||||
autoRenew Boolean @default(true)
|
||||
usageSyncedAt DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
accessKeys StorageAccessKey[]
|
||||
|
||||
@@index([userId])
|
||||
@@unique([userId, name]) // Имя уникально в рамках пользователя
|
||||
@@map("storage_bucket")
|
||||
}
|
||||
|
||||
model StorageAccessKey {
|
||||
id Int @id @default(autoincrement())
|
||||
bucketId Int
|
||||
bucket StorageBucket @relation(fields: [bucketId], references: [id], onDelete: Cascade)
|
||||
|
||||
accessKey String @unique
|
||||
secretKey String // хранится в зашифрованном виде
|
||||
label String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
lastUsedAt DateTime?
|
||||
|
||||
@@index([bucketId])
|
||||
@@map("storage_access_key")
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// Импорт и экспорт функций для работы с Proxmox
|
||||
export * from './proxmoxApi';
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Router } from 'express';
|
||||
import { createContainer } from './proxmoxApi';
|
||||
|
||||
const router = Router();
|
||||
|
||||
|
||||
// Маршрут для создания контейнера
|
||||
router.post('/container', async (req, res) => {
|
||||
try {
|
||||
const { vmid, hostname, password, ostemplate, storage, cores, memory, rootfsSize } = req.body;
|
||||
const result = await createContainer({ vmid, hostname, password, ostemplate, storage, cores, memory, rootfsSize });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : err });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,46 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
const PROXMOX_API = `https://${process.env.PROXMOX_HOST}:${process.env.PROXMOX_PORT}/api2/json`;
|
||||
|
||||
function getProxmoxHeaders() {
|
||||
return {
|
||||
Authorization: `PVEAPIToken=${process.env.PROXMOX_API_TOKEN_ID}=${process.env.PROXMOX_API_TOKEN_SECRET}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Создание контейнера (LXC) из шаблона
|
||||
export interface CreateContainerParams {
|
||||
vmid: number;
|
||||
hostname: string;
|
||||
password: string;
|
||||
ostemplate: string;
|
||||
storage: string;
|
||||
cores: number;
|
||||
memory: number;
|
||||
rootfsSize: number;
|
||||
}
|
||||
|
||||
export async function createContainer({ vmid, hostname, password, ostemplate, storage, cores, memory, rootfsSize }: CreateContainerParams) {
|
||||
const url = `${PROXMOX_API}/nodes/${process.env.PROXMOX_NODE}/lxc`;
|
||||
const data = {
|
||||
vmid,
|
||||
hostname,
|
||||
password,
|
||||
ostemplate, // например: 'local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst'
|
||||
storage, // например: 'local'
|
||||
cores, // количество ядер
|
||||
memory, // RAM в МБ
|
||||
rootfs: `${storage}:${rootfsSize}`, // например: 'local:8'
|
||||
net0: 'name=eth0,bridge=vmbr0,ip=dhcp',
|
||||
// Дополнительные параметры по необходимости
|
||||
};
|
||||
try {
|
||||
const res = await axios.post(url, data, { headers: getProxmoxHeaders() });
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
throw new Error('Ошибка создания контейнера: ' + (err instanceof Error ? err.message : err));
|
||||
}
|
||||
}
|
||||
|
||||
48
ospabhost/backend/restart-pm2.sh
Normal file
48
ospabhost/backend/restart-pm2.sh
Normal file
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Цвета для вывода
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${GREEN}=== Перезапуск Backend (PM2) ===${NC}"
|
||||
|
||||
# Проверка, запущен ли процесс
|
||||
if ! pm2 list | grep -q "ospab-backend"; then
|
||||
echo -e "${RED}Процесс ospab-backend не найден! Используйте ./start-pm2.sh${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Обновление кода (если нужно)
|
||||
if [ "$1" = "--update" ]; then
|
||||
echo -e "${YELLOW}Обновление кода из Git...${NC}"
|
||||
cd ..
|
||||
git pull origin main
|
||||
cd backend
|
||||
fi
|
||||
|
||||
# Сборка проекта
|
||||
if [ "$1" = "--build" ] || [ "$1" = "--update" ]; then
|
||||
echo -e "${YELLOW}Сборка проекта...${NC}"
|
||||
npm run build
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED}Ошибка сборки!${NC}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Перезапуск без даунтайма
|
||||
echo -e "${GREEN}Перезапускаем процесс без даунтайма...${NC}"
|
||||
pm2 reload ecosystem.config.js --env production
|
||||
|
||||
# Сохранение конфигурации
|
||||
pm2 save
|
||||
|
||||
# Показать статус
|
||||
echo -e "\n${GREEN}=== Статус процессов ===${NC}"
|
||||
pm2 list
|
||||
|
||||
echo -e "\n${GREEN}✅ Backend успешно перезапущен!${NC}"
|
||||
echo -e "${YELLOW}Используйте './restart-pm2.sh --build' для пересборки перед перезапуском${NC}"
|
||||
echo -e "${YELLOW}Используйте './restart-pm2.sh --update' для обновления из Git и пересборки${NC}"
|
||||
@@ -1,6 +0,0 @@
|
||||
import { checkProxmoxConnection } from './modules/server/proxmoxApi';
|
||||
|
||||
(async () => {
|
||||
const result = await checkProxmoxConnection();
|
||||
console.log('Проверка соединения с Proxmox:', result);
|
||||
})();
|
||||
@@ -1,4 +1,5 @@
|
||||
import paymentService from '../modules/payment/payment.service';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* Cron-задача для обработки автоматических платежей
|
||||
@@ -6,19 +7,19 @@ import paymentService from '../modules/payment/payment.service';
|
||||
*/
|
||||
export function startPaymentCron() {
|
||||
// Запускаем сразу при старте
|
||||
console.log('[Payment Cron] Запуск обработки автоматических платежей...');
|
||||
logger.info('[Payment Cron] Запуск обработки автоматических платежей...');
|
||||
paymentService.processAutoPayments().catch((err: any) => {
|
||||
console.error('[Payment Cron] Ошибка при обработке платежей:', err);
|
||||
logger.error('[Payment Cron] Ошибка при обработке платежей:', err);
|
||||
});
|
||||
|
||||
// Затем каждые 6 часов
|
||||
setInterval(async () => {
|
||||
console.log('[Payment Cron] Запуск обработки автоматических платежей...');
|
||||
logger.info('[Payment Cron] Запуск обработки автоматических платежей...');
|
||||
try {
|
||||
await paymentService.processAutoPayments();
|
||||
console.log('[Payment Cron] Обработка завершена');
|
||||
logger.info('[Payment Cron] Обработка завершена');
|
||||
} catch (error) {
|
||||
console.error('[Payment Cron] Ошибка при обработке платежей:', error);
|
||||
logger.error('[Payment Cron] Ошибка при обработке платежей:', error);
|
||||
}
|
||||
}, 6 * 60 * 60 * 1000); // 6 часов в миллисекундах
|
||||
}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import http from 'http';
|
||||
import passport from './modules/auth/passport.config';
|
||||
import authRoutes from './modules/auth/auth.routes';
|
||||
import oauthRoutes from './modules/auth/oauth.routes';
|
||||
import adminRoutes from './modules/admin/admin.routes';
|
||||
import ticketRoutes from './modules/ticket/ticket.routes';
|
||||
import checkRoutes from './modules/check/check.routes';
|
||||
import proxmoxRoutes from '../proxmox/proxmox.routes';
|
||||
import tariffRoutes from './modules/tariff';
|
||||
import osRoutes from './modules/os';
|
||||
import serverRoutes from './modules/server';
|
||||
import blogRoutes from './modules/blog/blog.routes';
|
||||
import notificationRoutes from './modules/notification/notification.routes';
|
||||
import userRoutes from './modules/user/user.routes';
|
||||
import sessionRoutes from './modules/session/session.routes';
|
||||
import qrAuthRoutes from './modules/qr-auth/qr-auth.routes';
|
||||
import storageRoutes from './modules/storage/storage.routes';
|
||||
import { logger } from './utils/logger';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -20,33 +27,27 @@ app.use(cors({
|
||||
'https://ospab.host'
|
||||
],
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization']
|
||||
}));
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
app.use((req, res, next) => {
|
||||
console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`);
|
||||
next();
|
||||
});
|
||||
|
||||
import { checkProxmoxConnection } from './modules/server/proxmoxApi';
|
||||
app.use(passport.initialize());
|
||||
|
||||
app.get('/', async (req, res) => {
|
||||
let proxmoxStatus;
|
||||
try {
|
||||
proxmoxStatus = await checkProxmoxConnection();
|
||||
} catch (err) {
|
||||
proxmoxStatus = { status: 'fail', message: 'Ошибка проверки Proxmox', error: err };
|
||||
}
|
||||
// Статистика WebSocket
|
||||
const wsConnectedUsers = getConnectedUsersCount();
|
||||
const wsRoomsStats = getRoomsStats();
|
||||
|
||||
res.json({
|
||||
message: 'Сервер ospab.host запущен!',
|
||||
timestamp: new Date().toISOString(),
|
||||
port: PORT,
|
||||
database: process.env.DATABASE_URL ? 'подключена' : 'НЕ НАСТРОЕНА',
|
||||
proxmox: proxmoxStatus
|
||||
websocket: {
|
||||
connected_users: wsConnectedUsers,
|
||||
rooms: wsRoomsStats
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,21 +56,24 @@ app.get('/sitemap.xml', (req, res) => {
|
||||
const baseUrl = 'https://ospab.host';
|
||||
|
||||
const staticPages = [
|
||||
{ loc: '/', priority: '1.0', changefreq: 'weekly' },
|
||||
{ loc: '/about', priority: '0.9', changefreq: 'monthly' },
|
||||
{ loc: '/tariffs', priority: '0.95', changefreq: 'weekly' },
|
||||
{ loc: '/login', priority: '0.7', changefreq: 'monthly' },
|
||||
{ loc: '/register', priority: '0.8', changefreq: 'monthly' },
|
||||
{ loc: '/terms', priority: '0.5', changefreq: 'yearly' },
|
||||
{ loc: '/privacy', priority: '0.5', changefreq: 'yearly' },
|
||||
{ loc: '/', priority: '1.0', changefreq: 'weekly', description: 'Ospab Host - Облачное S3 хранилище и хостинг сайтов' },
|
||||
{ loc: '/about', priority: '0.9', changefreq: 'monthly', description: 'О компании - Современная платформа хранения данных' },
|
||||
{ loc: '/login', priority: '0.7', changefreq: 'monthly', description: 'Вход в панель управления с QR-аутентификацией' },
|
||||
{ loc: '/register', priority: '0.8', changefreq: 'monthly', description: 'Регистрация аккаунта - Начните за 2 минуты' },
|
||||
{ loc: '/blog', priority: '0.85', changefreq: 'daily', description: 'Блог о S3 хранилище и хостинге' },
|
||||
{ loc: '/terms', priority: '0.5', changefreq: 'yearly', description: 'Условия использования сервиса' },
|
||||
{ loc: '/privacy', priority: '0.5', changefreq: 'yearly', description: 'Политика конфиденциальности и защита данных' },
|
||||
];
|
||||
|
||||
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
||||
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n';
|
||||
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">\n';
|
||||
|
||||
const lastmod = new Date().toISOString().split('T')[0];
|
||||
|
||||
for (const page of staticPages) {
|
||||
xml += ' <url>\n';
|
||||
xml += ` <loc>${baseUrl}${page.loc}</loc>\n`;
|
||||
xml += ` <lastmod>${lastmod}</lastmod>\n`;
|
||||
xml += ` <priority>${page.priority}</priority>\n`;
|
||||
xml += ` <changefreq>${page.changefreq}</changefreq>\n`;
|
||||
xml += ' </url>\n';
|
||||
@@ -83,64 +87,122 @@ app.get('/sitemap.xml', (req, res) => {
|
||||
|
||||
// ==================== ROBOTS.TXT ====================
|
||||
app.get('/robots.txt', (req, res) => {
|
||||
const robots = `User-agent: *
|
||||
const robots = `# ospab Host - Облачное S3 хранилище и хостинг
|
||||
# Хранение данных, техподдержка 24/7
|
||||
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Allow: /about
|
||||
Allow: /tariffs
|
||||
Allow: /login
|
||||
Allow: /register
|
||||
Allow: /blog
|
||||
Allow: /blog/*
|
||||
Allow: /terms
|
||||
Allow: /privacy
|
||||
Allow: /uploads/blog
|
||||
|
||||
# Запрет индексации приватных разделов
|
||||
Disallow: /dashboard
|
||||
Disallow: /dashboard/*
|
||||
Disallow: /api/
|
||||
Disallow: /qr-login
|
||||
Disallow: /admin
|
||||
Disallow: /private
|
||||
Disallow: /admin/*
|
||||
Disallow: /uploads/avatars
|
||||
Disallow: /uploads/tickets
|
||||
Disallow: /uploads/checks
|
||||
|
||||
Sitemap: https://ospab.host/sitemap.xml
|
||||
|
||||
# Google
|
||||
# Поисковые роботы
|
||||
User-agent: Googlebot
|
||||
Allow: /
|
||||
Crawl-delay: 0
|
||||
|
||||
# Yandex
|
||||
User-agent: Yandexbot
|
||||
Allow: /
|
||||
Crawl-delay: 0`;
|
||||
Crawl-delay: 0
|
||||
|
||||
User-agent: Bingbot
|
||||
Allow: /
|
||||
Crawl-delay: 0
|
||||
|
||||
User-agent: Mail.RU_Bot
|
||||
Allow: /
|
||||
Crawl-delay: 1`;
|
||||
|
||||
res.header('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.send(robots);
|
||||
});
|
||||
|
||||
import path from 'path';
|
||||
app.use('/uploads/checks', express.static(path.join(__dirname, '../uploads/checks')));
|
||||
|
||||
// Публичный доступ к блогу, аватарам и файлам тикетов
|
||||
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')));
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/auth', oauthRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/ticket', ticketRoutes);
|
||||
app.use('/api/check', checkRoutes);
|
||||
app.use('/api/proxmox', proxmoxRoutes);
|
||||
app.use('/api/tariff', tariffRoutes);
|
||||
app.use('/api/os', osRoutes);
|
||||
app.use('/api/server', serverRoutes);
|
||||
app.use('/api/blog', blogRoutes);
|
||||
app.use('/api/notifications', notificationRoutes);
|
||||
app.use('/api/user', userRoutes);
|
||||
app.use('/api/sessions', sessionRoutes);
|
||||
app.use('/api/qr-auth', qrAuthRoutes);
|
||||
app.use('/api/storage', storageRoutes);
|
||||
|
||||
const PORT = process.env.PORT || 5000;
|
||||
|
||||
import { setupConsoleWSS } from './modules/server/server.console';
|
||||
import { initWebSocketServer, getConnectedUsersCount, getRoomsStats } from './websocket/server';
|
||||
import https from 'https';
|
||||
import fs from 'fs';
|
||||
|
||||
// ИСПРАВЛЕНО: используйте fullchain сертификат
|
||||
const sslOptions = {
|
||||
key: fs.readFileSync('/etc/apache2/ssl/ospab.host.key'),
|
||||
cert: fs.readFileSync('/etc/apache2/ssl/ospab.host.fullchain.crt'),
|
||||
};
|
||||
const keyPath = process.env.SSL_KEY_PATH ?? '/etc/apache2/ssl/ospab.host.key';
|
||||
const certPath = process.env.SSL_CERT_PATH ?? '/etc/apache2/ssl/ospab.host.fullchain.crt';
|
||||
|
||||
const httpsServer = https.createServer(sslOptions, app);
|
||||
setupConsoleWSS(httpsServer);
|
||||
const shouldUseHttps = process.env.NODE_ENV === 'production';
|
||||
|
||||
httpsServer.listen(PORT, () => {
|
||||
console.log(`🚀 HTTPS сервер запущен на порту ${PORT}`);
|
||||
console.log(`📊 База данных: ${process.env.DATABASE_URL ? 'подключена' : 'НЕ НАСТРОЕНА'}`);
|
||||
console.log(`📍 Sitemap доступен: https://ospab.host:${PORT}/sitemap.xml`);
|
||||
console.log(`🤖 Robots.txt доступен: https://ospab.host:${PORT}/robots.txt`);
|
||||
let server: http.Server | https.Server;
|
||||
let protocolLabel = 'HTTP';
|
||||
|
||||
if (shouldUseHttps) {
|
||||
const missingPaths: string[] = [];
|
||||
|
||||
if (!fs.existsSync(keyPath)) {
|
||||
missingPaths.push(keyPath);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(certPath)) {
|
||||
missingPaths.push(certPath);
|
||||
}
|
||||
|
||||
if (missingPaths.length > 0) {
|
||||
console.error('[Server] SSL режим включён, но сертификаты не найдены:', missingPaths.join(', '));
|
||||
console.error('[Server] Укажите корректные пути в переменных SSL_KEY_PATH и SSL_CERT_PATH. Сервер остановлен.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sslOptions = {
|
||||
key: fs.readFileSync(keyPath),
|
||||
cert: fs.readFileSync(certPath)
|
||||
};
|
||||
|
||||
server = https.createServer(sslOptions, app);
|
||||
protocolLabel = 'HTTPS';
|
||||
} else {
|
||||
server = http.createServer(app);
|
||||
}
|
||||
|
||||
// Инициализация основного WebSocket сервера для real-time обновлений
|
||||
const wss = initWebSocketServer(server);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
logger.info(`${protocolLabel} сервер запущен на порту ${PORT}`);
|
||||
logger.info(`База данных: ${process.env.DATABASE_URL ? 'подключена' : 'НЕ НАСТРОЕНА'}`);
|
||||
logger.info(`WebSocket доступен: ${protocolLabel === 'HTTPS' ? 'wss' : 'ws'}://ospab.host:${PORT}/ws`);
|
||||
logger.info(`Sitemap доступен: ${protocolLabel === 'HTTPS' ? 'https' : 'http'}://ospab.host:${PORT}/sitemap.xml`);
|
||||
logger.info(`Robots.txt доступен: ${protocolLabel === 'HTTPS' ? 'https' : 'http'}://ospab.host:${PORT}/robots.txt`);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { prisma } from '../prisma/client';
|
||||
import path from 'path';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* Middleware для проверки доступа к файлам чеков
|
||||
* Доступ имеют только: владелец чека или оператор
|
||||
*/
|
||||
export async function checkFileAccessMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
const isOperator = Number(req.user?.operator) === 1;
|
||||
|
||||
// Извлекаем имя файла из URL
|
||||
const filename = path.basename(req.path);
|
||||
|
||||
if (!userId) {
|
||||
logger.warn(`[CheckFile] Попытка доступа к ${filename} без авторизации`);
|
||||
return res.status(401).json({ error: 'Требуется авторизация' });
|
||||
}
|
||||
|
||||
// Операторы имеют доступ ко всем чекам
|
||||
if (isOperator) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Для обычных пользователей - проверяем владение чеком
|
||||
const check = await prisma.check.findFirst({
|
||||
where: {
|
||||
fileUrl: {
|
||||
contains: filename
|
||||
}
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
fileUrl: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!check) {
|
||||
logger.warn(`[CheckFile] Чек с файлом ${filename} не найден в БД`);
|
||||
return res.status(404).json({ error: 'Файл не найден' });
|
||||
}
|
||||
|
||||
if (check.userId !== userId) {
|
||||
logger.warn(`[CheckFile] Пользователь ${userId} попытался получить доступ к чужому чеку ${filename} (владелец: ${check.userId})`);
|
||||
return res.status(403).json({ error: 'Нет доступа к этому файлу' });
|
||||
}
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('[CheckFile] Ошибка проверки доступа:', error);
|
||||
res.status(500).json({ error: 'Ошибка проверки доступа' });
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcrypt';
|
||||
import crypto from 'crypto';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -94,7 +95,7 @@ async function sendVerificationEmail(
|
||||
</div>
|
||||
<p><strong>Код действителен в течение 15 минут.</strong></p>
|
||||
<div class="warning">
|
||||
<strong>⚠️ Важно:</strong> Если вы не запрашивали это действие, проигнорируйте это письмо и немедленно смените пароль.
|
||||
<strong>Важно:</strong> Если вы не запрашивали это действие, проигнорируйте это письмо и немедленно смените пароль.
|
||||
</div>
|
||||
<p>С уважением,<br>Команда ospab.host</p>
|
||||
</div>
|
||||
@@ -271,14 +272,77 @@ export async function confirmAccountDeletion(userId: number, code: string): Prom
|
||||
throw new Error('Код истёк');
|
||||
}
|
||||
|
||||
// Удаляем все связанные данные пользователя
|
||||
await prisma.$transaction([
|
||||
prisma.ticket.deleteMany({ where: { userId } }),
|
||||
prisma.check.deleteMany({ where: { userId } }),
|
||||
prisma.server.deleteMany({ where: { userId } }),
|
||||
prisma.notification.deleteMany({ where: { userId } }),
|
||||
prisma.user.delete({ where: { id: userId } }),
|
||||
]);
|
||||
logger.info(`[ACCOUNT DELETE] Начинаем полное удаление пользователя ${userId}...`);
|
||||
|
||||
try {
|
||||
// Каскадное удаление всех связанных данных пользователя в правильном порядке
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// 1. Удаляем ответы в тикетах где пользователь является оператором
|
||||
const responses = await tx.response.deleteMany({
|
||||
where: { operatorId: userId }
|
||||
});
|
||||
logger.log(` Удалено ответов оператора: ${responses.count}`);
|
||||
|
||||
// 2. Удаляем тикеты
|
||||
const tickets = await tx.ticket.deleteMany({
|
||||
where: { userId }
|
||||
});
|
||||
logger.log(`Удалено тикетов: ${tickets.count}`);
|
||||
|
||||
// 3. Удаляем чеки
|
||||
const checks = await tx.check.deleteMany({
|
||||
where: { userId }
|
||||
});
|
||||
logger.log(`Удалено чеков: ${checks.count}`);
|
||||
|
||||
// 4. Удаляем S3 бакеты пользователя
|
||||
const buckets = await tx.storageBucket.deleteMany({
|
||||
where: { userId }
|
||||
});
|
||||
logger.info(`Удалено S3 бакетов: ${buckets.count}`);
|
||||
|
||||
// 5. Удаляем уведомления
|
||||
const notifications = await tx.notification.deleteMany({
|
||||
where: { userId }
|
||||
});
|
||||
logger.info(` Удалено уведомлений: ${notifications.count}`);
|
||||
|
||||
// 6. Удаляем Push-подписки
|
||||
const pushSubscriptions = await tx.pushSubscription.deleteMany({
|
||||
where: { userId }
|
||||
});
|
||||
logger.info(`Удалено Push-подписок: ${pushSubscriptions.count}`);
|
||||
|
||||
// 7. Удаляем транзакции
|
||||
const transactions = await tx.transaction.deleteMany({
|
||||
where: { userId }
|
||||
});
|
||||
logger.info(`Удалено транзакций: ${transactions.count}`);
|
||||
|
||||
// 8. Удаляем сессии
|
||||
const sessions = await tx.session.deleteMany({
|
||||
where: { userId }
|
||||
});
|
||||
logger.info(`Удалено сессий: ${sessions.count}`);
|
||||
|
||||
// 9. Удаляем историю входов
|
||||
const loginHistory = await tx.loginHistory.deleteMany({
|
||||
where: { userId }
|
||||
});
|
||||
logger.info(`Удалено записей истории входов: ${loginHistory.count}`);
|
||||
|
||||
// 10. Наконец, удаляем самого пользователя
|
||||
await tx.user.delete({
|
||||
where: { id: userId }
|
||||
});
|
||||
logger.info(`Пользователь ${userId} удалён из БД`);
|
||||
});
|
||||
|
||||
logger.info(`[ACCOUNT DELETE] Пользователь ${userId} полностью удалён`);
|
||||
} catch (error) {
|
||||
logger.error(`[ACCOUNT DELETE] Ошибка при удалении пользователя ${userId}:`, error);
|
||||
throw new Error('Ошибка при удалении аккаунта');
|
||||
}
|
||||
|
||||
verificationCodes.delete(`delete_${userId}`);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { prisma } from '../../prisma/client';
|
||||
import { createNotification } from '../notification/notification.controller';
|
||||
|
||||
/**
|
||||
* Middleware для проверки прав администратора
|
||||
@@ -44,7 +45,7 @@ export class AdminController {
|
||||
createdAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
servers: true,
|
||||
buckets: true,
|
||||
tickets: true
|
||||
}
|
||||
}
|
||||
@@ -71,11 +72,8 @@ export class AdminController {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
servers: {
|
||||
include: {
|
||||
tariff: true,
|
||||
os: true
|
||||
}
|
||||
buckets: {
|
||||
orderBy: { createdAt: 'desc' }
|
||||
},
|
||||
checks: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@@ -139,16 +137,18 @@ export class AdminController {
|
||||
balanceAfter,
|
||||
adminId
|
||||
}
|
||||
}),
|
||||
prisma.notification.create({
|
||||
data: {
|
||||
userId,
|
||||
title: 'Пополнение баланса',
|
||||
message: `На ваш счёт зачислено ${amount}₽. ${description || ''}`
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
// Создаём уведомление через новую систему
|
||||
await createNotification({
|
||||
userId,
|
||||
type: 'balance_deposit',
|
||||
title: 'Пополнение баланса',
|
||||
message: `На ваш счёт зачислено ${amount}₽. ${description || ''}`,
|
||||
color: 'green'
|
||||
});
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
message: `Баланс пополнен на ${amount}₽`,
|
||||
@@ -200,16 +200,18 @@ export class AdminController {
|
||||
balanceAfter,
|
||||
adminId
|
||||
}
|
||||
}),
|
||||
prisma.notification.create({
|
||||
data: {
|
||||
userId,
|
||||
title: 'Списание с баланса',
|
||||
message: `С вашего счёта списано ${amount}₽. ${description || ''}`
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
// Создаём уведомление через новую систему
|
||||
await createNotification({
|
||||
userId,
|
||||
type: 'balance_withdrawal',
|
||||
title: 'Списание с баланса',
|
||||
message: `С вашего счёта списано ${amount}₽. ${description || ''}`,
|
||||
color: 'red'
|
||||
});
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
message: `Списано ${amount}₽`,
|
||||
@@ -222,47 +224,41 @@ export class AdminController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить сервер пользователя
|
||||
* Удалить S3 бакет пользователя
|
||||
*/
|
||||
async deleteServer(req: Request, res: Response) {
|
||||
async deleteBucket(req: Request, res: Response) {
|
||||
try {
|
||||
const serverId = parseInt(req.params.serverId);
|
||||
const bucketId = parseInt(req.params.bucketId);
|
||||
const { reason } = req.body;
|
||||
const adminId = (req as any).user?.id;
|
||||
|
||||
const server = await prisma.server.findUnique({
|
||||
where: { id: serverId },
|
||||
include: { user: true, tariff: true }
|
||||
const bucket = await prisma.storageBucket.findUnique({
|
||||
where: { id: bucketId },
|
||||
include: { user: true }
|
||||
});
|
||||
|
||||
if (!server) {
|
||||
return res.status(404).json({ message: 'Сервер не найден' });
|
||||
if (!bucket) {
|
||||
return res.status(404).json({ message: 'Бакет не найден' });
|
||||
}
|
||||
|
||||
// Удаляем сервер из Proxmox (если есть proxmoxId)
|
||||
// TODO: Добавить вызов proxmoxApi.deleteContainer(server.proxmoxId)
|
||||
await prisma.storageBucket.delete({
|
||||
where: { id: bucketId }
|
||||
});
|
||||
|
||||
// Удаляем из БД
|
||||
await prisma.$transaction([
|
||||
prisma.server.delete({
|
||||
where: { id: serverId }
|
||||
}),
|
||||
prisma.notification.create({
|
||||
data: {
|
||||
userId: server.userId,
|
||||
title: 'Сервер удалён',
|
||||
message: `Ваш сервер #${serverId} был удалён администратором. ${reason ? `Причина: ${reason}` : ''}`
|
||||
}
|
||||
})
|
||||
]);
|
||||
await createNotification({
|
||||
userId: bucket.userId,
|
||||
type: 'storage_bucket_deleted',
|
||||
title: 'Бакет удалён',
|
||||
message: `Ваш бакет «${bucket.name}» был удалён администратором. ${reason ? `Причина: ${reason}` : ''}`,
|
||||
color: 'red'
|
||||
});
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
message: `Сервер #${serverId} удалён`
|
||||
message: `Бакет «${bucket.name}» удалён`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Ошибка удаления сервера:', error);
|
||||
res.status(500).json({ message: 'Ошибка удаления сервера' });
|
||||
console.error('Ошибка удаления бакета:', error);
|
||||
res.status(500).json({ message: 'Ошибка удаления бакета' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,20 +269,26 @@ export class AdminController {
|
||||
try {
|
||||
const [
|
||||
totalUsers,
|
||||
totalServers,
|
||||
activeServers,
|
||||
suspendedServers,
|
||||
totalBuckets,
|
||||
publicBuckets,
|
||||
totalBalance,
|
||||
pendingChecks,
|
||||
openTickets
|
||||
openTickets,
|
||||
bucketsAggregates
|
||||
] = await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.server.count(),
|
||||
prisma.server.count({ where: { status: 'running' } }),
|
||||
prisma.server.count({ where: { status: 'suspended' } }),
|
||||
prisma.storageBucket.count(),
|
||||
prisma.storageBucket.count({ where: { public: true } }),
|
||||
prisma.user.aggregate({ _sum: { balance: true } }),
|
||||
prisma.check.count({ where: { status: 'pending' } }),
|
||||
prisma.ticket.count({ where: { status: 'open' } })
|
||||
prisma.ticket.count({ where: { status: 'open' } }),
|
||||
prisma.storageBucket.aggregate({
|
||||
_sum: {
|
||||
usedBytes: true,
|
||||
objectCount: true,
|
||||
quotaGb: true
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
// Получаем последние транзакции
|
||||
@@ -310,10 +312,12 @@ export class AdminController {
|
||||
users: {
|
||||
total: totalUsers
|
||||
},
|
||||
servers: {
|
||||
total: totalServers,
|
||||
active: activeServers,
|
||||
suspended: suspendedServers
|
||||
storage: {
|
||||
total: totalBuckets,
|
||||
public: publicBuckets,
|
||||
objects: bucketsAggregates._sum.objectCount ?? 0,
|
||||
usedBytes: bucketsAggregates._sum.usedBytes ?? 0,
|
||||
quotaGb: bucketsAggregates._sum.quotaGb ?? 0
|
||||
},
|
||||
balance: {
|
||||
total: totalBalance._sum.balance || 0
|
||||
|
||||
@@ -18,7 +18,7 @@ router.post('/users/:userId/balance/add', adminController.addBalance.bind(adminC
|
||||
router.post('/users/:userId/balance/withdraw', adminController.withdrawBalance.bind(adminController));
|
||||
router.patch('/users/:userId/role', adminController.updateUserRole.bind(adminController));
|
||||
|
||||
// Управление серверами
|
||||
router.delete('/servers/:serverId', adminController.deleteServer.bind(adminController));
|
||||
// Управление S3 бакетами
|
||||
router.delete('/buckets/:bucketId', adminController.deleteBucket.bind(adminController));
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -3,6 +3,7 @@ import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { validateTurnstileToken } from './turnstile.validator';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_key';
|
||||
@@ -46,7 +47,7 @@ export const register = async (req: Request, res: Response) => {
|
||||
res.status(201).json({ message: 'Регистрация прошла успешно!' });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ошибка при регистрации:', error);
|
||||
logger.error('Ошибка при регистрации:', error);
|
||||
res.status(500).json({ message: 'Ошибка сервера.' });
|
||||
}
|
||||
};
|
||||
@@ -87,7 +88,7 @@ export const login = async (req: Request, res: Response) => {
|
||||
res.status(200).json({ token });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ошибка при входе:', error);
|
||||
logger.error('Ошибка при входе:', error);
|
||||
res.status(500).json({ message: 'Ошибка сервера.' });
|
||||
}
|
||||
};
|
||||
@@ -108,38 +109,33 @@ export const getMe = async (req: Request, res: Response) => {
|
||||
operator: true,
|
||||
isAdmin: true,
|
||||
balance: true,
|
||||
servers: {
|
||||
buckets: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
name: true,
|
||||
plan: true,
|
||||
quotaGb: true,
|
||||
usedBytes: true,
|
||||
objectCount: true,
|
||||
storageClass: true,
|
||||
region: true,
|
||||
public: true,
|
||||
versioning: true,
|
||||
createdAt: true,
|
||||
ipAddress: true,
|
||||
nextPaymentDate: true,
|
||||
autoRenew: true,
|
||||
tariff: {
|
||||
select: {
|
||||
name: true,
|
||||
price: true,
|
||||
},
|
||||
},
|
||||
os: {
|
||||
select: {
|
||||
name: true,
|
||||
type: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
updatedAt: true
|
||||
}
|
||||
},
|
||||
tickets: true,
|
||||
},
|
||||
});
|
||||
console.log('API /api/auth/me user:', user);
|
||||
logger.debug('API /api/auth/me user:', user);
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'Пользователь не найден.' });
|
||||
return res.status(401).json({ message: 'Сессия недействительна. Выполните вход заново.' });
|
||||
}
|
||||
res.status(200).json({ user });
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении данных пользователя:', error);
|
||||
res.status(500).json({ message: 'Ошибка сервера.' });
|
||||
logger.error('Ошибка при получении данных пользователя:', error);
|
||||
res.status(503).json({ message: 'Не удалось загрузить профиль. Попробуйте позже.' });
|
||||
}
|
||||
};
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
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';
|
||||
|
||||
export const authMiddleware = async (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -19,14 +17,66 @@ export const authMiddleware = async (req: Request, res: Response, next: NextFunc
|
||||
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as { id: number };
|
||||
const user = await prisma.user.findUnique({ where: { id: decoded.id } });
|
||||
if (!user) return res.status(401).json({ message: 'Пользователь не найден.' });
|
||||
|
||||
if (!user) {
|
||||
console.warn(`[Auth] Пользователь с ID ${decoded.id} не найден, токен отклонён`);
|
||||
return res.status(401).json({ message: 'Сессия недействительна. Авторизуйтесь снова.' });
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
next();
|
||||
return next();
|
||||
} catch (error) {
|
||||
console.error('Ошибка в мидлваре аутентификации:', error);
|
||||
if (error instanceof jwt.JsonWebTokenError) {
|
||||
return res.status(401).json({ message: 'Неверный или просроченный токен.' });
|
||||
}
|
||||
res.status(500).json({ message: 'Ошибка сервера.' });
|
||||
return res.status(503).json({ message: 'Авторизация временно недоступна. Попробуйте позже.' });
|
||||
}
|
||||
};
|
||||
|
||||
// Middleware для проверки прав администратора
|
||||
export const adminMiddleware = (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user;
|
||||
|
||||
if (!user || !user.isAdmin) {
|
||||
return res.status(403).json({ message: 'Доступ запрещён. Требуются права администратора.' });
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
// Опциональный middleware - проверяет токен если он есть, но не требует авторизации
|
||||
export const optionalAuthMiddleware = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
// Если нет токена - просто пропускаем дальше (для гостей)
|
||||
if (!authHeader) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
if (!token) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Если токен есть - проверяем и добавляем пользователя
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as { id: number };
|
||||
const user = await prisma.user.findUnique({ where: { id: decoded.id } });
|
||||
if (!user) {
|
||||
console.warn(`[Auth][optional] Пользователь с ID ${decoded.id} не найден, токен отклонён`);
|
||||
return res.status(401).json({ message: 'Сессия недействительна. Авторизуйтесь снова.' });
|
||||
}
|
||||
req.user = user;
|
||||
} catch (err) {
|
||||
console.warn('[Auth][optional] Ошибка проверки токена:', err);
|
||||
return res.status(401).json({ message: 'Неверный или просроченный токен.' });
|
||||
}
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
console.error('Ошибка в optionalAuthMiddleware:', error);
|
||||
return res.status(503).json({ message: 'Авторизация временно недоступна. Попробуйте позже.' });
|
||||
}
|
||||
};
|
||||
@@ -6,6 +6,12 @@ const router = Router();
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_key';
|
||||
const FRONTEND_URL = process.env.FRONTEND_URL || 'https://ospab.host';
|
||||
|
||||
interface AuthenticatedUser {
|
||||
id: number;
|
||||
email: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
// Google OAuth
|
||||
router.get('/google', passport.authenticate('google', { scope: ['profile', 'email'] }));
|
||||
|
||||
@@ -13,7 +19,7 @@ router.get(
|
||||
'/google/callback',
|
||||
passport.authenticate('google', { session: false, failureRedirect: `${FRONTEND_URL}/login?error=auth_failed` }),
|
||||
(req: Request, res: Response) => {
|
||||
const user = req.user as any;
|
||||
const user = req.user as AuthenticatedUser;
|
||||
const token = jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: '24h' });
|
||||
res.redirect(`${FRONTEND_URL}/login?token=${token}`);
|
||||
}
|
||||
@@ -26,7 +32,7 @@ router.get(
|
||||
'/github/callback',
|
||||
passport.authenticate('github', { session: false, failureRedirect: `${FRONTEND_URL}/login?error=auth_failed` }),
|
||||
(req: Request, res: Response) => {
|
||||
const user = req.user as any;
|
||||
const user = req.user as AuthenticatedUser;
|
||||
const token = jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: '24h' });
|
||||
res.redirect(`${FRONTEND_URL}/login?token=${token}`);
|
||||
}
|
||||
@@ -39,7 +45,7 @@ router.get(
|
||||
'/yandex/callback',
|
||||
passport.authenticate('yandex', { session: false, failureRedirect: `${FRONTEND_URL}/login?error=auth_failed` }),
|
||||
(req: Request, res: Response) => {
|
||||
const user = req.user as any;
|
||||
const user = req.user as AuthenticatedUser;
|
||||
const token = jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: '24h' });
|
||||
res.redirect(`${FRONTEND_URL}/login?token=${token}`);
|
||||
}
|
||||
|
||||
323
ospabhost/backend/src/modules/blog/blog.controller.ts
Normal file
323
ospabhost/backend/src/modules/blog/blog.controller.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { prisma } from '../../prisma/client';
|
||||
|
||||
// Получить все опубликованные посты (публичный доступ)
|
||||
export const getAllPosts = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const posts = await prisma.post.findMany({
|
||||
where: { status: 'published' },
|
||||
include: {
|
||||
author: {
|
||||
select: { id: true, username: true }
|
||||
},
|
||||
_count: {
|
||||
select: { comments: true }
|
||||
}
|
||||
},
|
||||
orderBy: { publishedAt: 'desc' }
|
||||
});
|
||||
|
||||
res.json({ success: true, data: posts });
|
||||
} catch (error) {
|
||||
console.error('Ошибка получения постов:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// Получить один пост по URL (публичный доступ)
|
||||
export const getPostByUrl = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { url } = req.params;
|
||||
|
||||
const post = await prisma.post.findUnique({
|
||||
where: { url },
|
||||
include: {
|
||||
author: {
|
||||
select: { id: true, username: true }
|
||||
},
|
||||
comments: {
|
||||
where: { status: 'approved' },
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, username: true }
|
||||
}
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
return res.status(404).json({ success: false, message: 'Статья не найдена' });
|
||||
}
|
||||
|
||||
// Увеличить счетчик просмотров
|
||||
await prisma.post.update({
|
||||
where: { id: post.id },
|
||||
data: { views: { increment: 1 } }
|
||||
});
|
||||
|
||||
res.json({ success: true, data: post });
|
||||
} catch (error) {
|
||||
console.error('Ошибка получения поста:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// Добавить комментарий (публичный доступ)
|
||||
export const addComment = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { postId } = req.params;
|
||||
const { content, authorName } = req.body;
|
||||
const userId = req.user?.id; // Если пользователь авторизован
|
||||
|
||||
if (!content || content.trim().length === 0) {
|
||||
return res.status(400).json({ success: false, message: 'Содержимое комментария не может быть пустым' });
|
||||
}
|
||||
|
||||
if (!userId && (!authorName || authorName.trim().length === 0)) {
|
||||
return res.status(400).json({ success: false, message: 'Укажите ваше имя' });
|
||||
}
|
||||
|
||||
// Проверяем, существует ли пост
|
||||
const post = await prisma.post.findUnique({
|
||||
where: { id: parseInt(postId) }
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
return res.status(404).json({ success: false, message: 'Пост не найден' });
|
||||
}
|
||||
|
||||
const comment = await prisma.comment.create({
|
||||
data: {
|
||||
postId: parseInt(postId),
|
||||
userId: userId || null,
|
||||
authorName: !userId ? authorName.trim() : null,
|
||||
content: content.trim(),
|
||||
status: 'pending' // Комментарии требуют модерации
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, username: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ success: true, data: comment, message: 'Комментарий отправлен на модерацию' });
|
||||
} catch (error) {
|
||||
console.error('Ошибка добавления комментария:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// === ADMIN ENDPOINTS ===
|
||||
|
||||
// Получить все посты (включая черновики) - только для админов
|
||||
export const getAllPostsAdmin = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const posts = await prisma.post.findMany({
|
||||
include: {
|
||||
author: {
|
||||
select: { id: true, username: true }
|
||||
},
|
||||
_count: {
|
||||
select: { comments: true }
|
||||
}
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
res.json({ success: true, data: posts });
|
||||
} catch (error) {
|
||||
console.error('Ошибка получения постов:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// Получить один пост по ID - только для админов
|
||||
export const getPostByIdAdmin = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const post = await prisma.post.findUnique({
|
||||
where: { id: parseInt(id) },
|
||||
include: {
|
||||
author: {
|
||||
select: { id: true, username: true }
|
||||
},
|
||||
_count: {
|
||||
select: { comments: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
return res.status(404).json({ success: false, message: 'Пост не найден' });
|
||||
}
|
||||
|
||||
res.json({ success: true, data: post });
|
||||
} catch (error) {
|
||||
console.error('Ошибка получения поста:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// Создать пост - только для админов
|
||||
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
|
||||
|
||||
if (!title || !content || !url) {
|
||||
return res.status(400).json({ success: false, message: 'Заполните обязательные поля' });
|
||||
}
|
||||
|
||||
// Проверка уникальности URL
|
||||
const existingPost = await prisma.post.findUnique({ where: { url } });
|
||||
if (existingPost) {
|
||||
return res.status(400).json({ success: false, message: 'URL уже используется' });
|
||||
}
|
||||
|
||||
const post = await prisma.post.create({
|
||||
data: {
|
||||
title,
|
||||
content,
|
||||
excerpt,
|
||||
coverImage,
|
||||
url,
|
||||
status: status || 'draft',
|
||||
authorId,
|
||||
publishedAt: status === 'published' ? new Date() : null
|
||||
},
|
||||
include: {
|
||||
author: {
|
||||
select: { id: true, username: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ success: true, data: post });
|
||||
} catch (error) {
|
||||
console.error('Ошибка создания поста:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// Обновить пост - только для админов
|
||||
export const updatePost = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { title, content, excerpt, coverImage, url, status } = req.body;
|
||||
|
||||
// Проверка уникальности URL (если изменился)
|
||||
if (url) {
|
||||
const existingPost = await prisma.post.findUnique({ where: { url } });
|
||||
if (existingPost && existingPost.id !== parseInt(id)) {
|
||||
return res.status(400).json({ success: false, message: 'URL уже используется' });
|
||||
}
|
||||
}
|
||||
|
||||
const currentPost = await prisma.post.findUnique({ where: { id: parseInt(id) } });
|
||||
const wasPublished = currentPost?.status === 'published';
|
||||
const nowPublished = status === 'published';
|
||||
|
||||
const post = await prisma.post.update({
|
||||
where: { id: parseInt(id) },
|
||||
data: {
|
||||
title,
|
||||
content,
|
||||
excerpt,
|
||||
coverImage,
|
||||
url,
|
||||
status,
|
||||
publishedAt: !wasPublished && nowPublished ? new Date() : currentPost?.publishedAt
|
||||
},
|
||||
include: {
|
||||
author: {
|
||||
select: { id: true, username: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ success: true, data: post });
|
||||
} catch (error) {
|
||||
console.error('Ошибка обновления поста:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// Удалить пост - только для админов
|
||||
export const deletePost = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
await prisma.post.delete({
|
||||
where: { id: parseInt(id) }
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'Пост удалён' });
|
||||
} catch (error) {
|
||||
console.error('Ошибка удаления поста:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// Получить все комментарии (для модерации) - только для админов
|
||||
export const getAllComments = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const comments = await prisma.comment.findMany({
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, username: true }
|
||||
},
|
||||
post: {
|
||||
select: { id: true, title: true }
|
||||
}
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
res.json({ success: true, data: comments });
|
||||
} catch (error) {
|
||||
console.error('Ошибка получения комментариев:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// Модерация комментария - только для админов
|
||||
export const moderateComment = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { status } = req.body; // approved, rejected
|
||||
|
||||
if (!['approved', 'rejected'].includes(status)) {
|
||||
return res.status(400).json({ success: false, message: 'Неверный статус' });
|
||||
}
|
||||
|
||||
const comment = await prisma.comment.update({
|
||||
where: { id: parseInt(id) },
|
||||
data: { status }
|
||||
});
|
||||
|
||||
res.json({ success: true, data: comment });
|
||||
} catch (error) {
|
||||
console.error('Ошибка модерации комментария:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// Удалить комментарий - только для админов
|
||||
export const deleteComment = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
await prisma.comment.delete({
|
||||
where: { id: parseInt(id) }
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'Комментарий удалён' });
|
||||
} catch (error) {
|
||||
console.error('Ошибка удаления комментария:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
70
ospabhost/backend/src/modules/blog/blog.routes.ts
Normal file
70
ospabhost/backend/src/modules/blog/blog.routes.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import {
|
||||
getAllPosts,
|
||||
getPostByUrl,
|
||||
addComment,
|
||||
getAllPostsAdmin,
|
||||
getPostByIdAdmin,
|
||||
createPost,
|
||||
updatePost,
|
||||
deletePost,
|
||||
getAllComments,
|
||||
moderateComment,
|
||||
deleteComment
|
||||
} from './blog.controller';
|
||||
import { uploadImage, deleteImage } from './upload.controller';
|
||||
import { authMiddleware, adminMiddleware, optionalAuthMiddleware } from '../auth/auth.middleware';
|
||||
|
||||
// Конфигурация multer для загрузки изображений
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
cb(null, path.join(__dirname, '../../../uploads/blog'));
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
cb(null, uniqueSuffix + path.extname(file.originalname));
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024 // 10MB
|
||||
},
|
||||
fileFilter: function (req, file, cb) {
|
||||
const allowedTypes = /jpeg|jpg|png|gif|webp/;
|
||||
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
|
||||
const mimetype = allowedTypes.test(file.mimetype);
|
||||
|
||||
if (mimetype && extname) {
|
||||
return cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Разрешены только изображения (jpeg, jpg, png, gif, webp)'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Публичные маршруты
|
||||
router.get('/posts', getAllPosts);
|
||||
router.get('/posts/:url', getPostByUrl);
|
||||
router.post('/posts/:postId/comments', optionalAuthMiddleware, addComment); // Гости и авторизованные могут комментировать
|
||||
|
||||
// Админские маршруты
|
||||
router.post('/admin/upload-image', authMiddleware, adminMiddleware, upload.single('image'), uploadImage);
|
||||
router.delete('/admin/images/:filename', authMiddleware, adminMiddleware, deleteImage);
|
||||
|
||||
router.get('/admin/posts', authMiddleware, adminMiddleware, getAllPostsAdmin);
|
||||
router.get('/admin/posts/:id', authMiddleware, adminMiddleware, getPostByIdAdmin);
|
||||
router.post('/admin/posts', authMiddleware, adminMiddleware, createPost);
|
||||
router.put('/admin/posts/:id', authMiddleware, adminMiddleware, updatePost);
|
||||
router.delete('/admin/posts/:id', authMiddleware, adminMiddleware, deletePost);
|
||||
|
||||
router.get('/admin/comments', authMiddleware, adminMiddleware, getAllComments);
|
||||
router.patch('/admin/comments/:id', authMiddleware, adminMiddleware, moderateComment);
|
||||
router.delete('/admin/comments/:id', authMiddleware, adminMiddleware, deleteComment);
|
||||
|
||||
export default router;
|
||||
67
ospabhost/backend/src/modules/blog/upload.controller.ts
Normal file
67
ospabhost/backend/src/modules/blog/upload.controller.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
// backend/src/modules/blog/upload.controller.ts
|
||||
import { Request, Response } from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
export const uploadImage = async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Файл не загружен'
|
||||
});
|
||||
}
|
||||
|
||||
// Генерируем URL для доступа к изображению
|
||||
const imageUrl = `/uploads/blog/${req.file.filename}`;
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
url: `https://ospab.host:5000${imageUrl}`,
|
||||
filename: req.file.filename
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки изображения:', error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'Ошибка загрузки изображения'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteImage = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { filename } = req.params;
|
||||
|
||||
if (!filename) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Имя файла не указано'
|
||||
});
|
||||
}
|
||||
|
||||
const filePath = path.join(__dirname, '../../../uploads/blog', filename);
|
||||
|
||||
// Проверяем существование файла
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Изображение удалено'
|
||||
});
|
||||
} else {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Файл не найден'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка удаления изображения:', error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'Ошибка удаления изображения'
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,10 +1,9 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { prisma } from '../../prisma/client';
|
||||
import { Request, Response } from 'express';
|
||||
import { Multer } from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
// Тип расширенного запроса с Multer
|
||||
interface MulterRequest extends Request {
|
||||
@@ -38,29 +37,194 @@ export async function getChecks(req: Request, res: Response) {
|
||||
res.json(checks);
|
||||
}
|
||||
|
||||
// Подтвердить чек и пополнить баланс
|
||||
// Подтвердить чек и пополнить баланс (только оператор)
|
||||
export async function approveCheck(req: Request, res: Response) {
|
||||
const { checkId } = req.body;
|
||||
// Найти чек
|
||||
const check = await prisma.check.findUnique({ where: { id: checkId } });
|
||||
if (!check) return res.status(404).json({ error: 'Чек не найден' });
|
||||
// Обновить статус
|
||||
await prisma.check.update({ where: { id: checkId }, data: { status: 'approved' } });
|
||||
// Пополнить баланс пользователя
|
||||
await prisma.user.update({
|
||||
where: { id: check.userId },
|
||||
data: {
|
||||
balance: {
|
||||
increment: check.amount
|
||||
}
|
||||
try {
|
||||
const { checkId } = req.body;
|
||||
const isOperator = Number(req.user?.operator) === 1;
|
||||
|
||||
// Проверка прав оператора
|
||||
if (!isOperator) {
|
||||
logger.warn(`[Check] Попытка подтверждения чека #${checkId} не оператором (userId: ${req.user?.id})`);
|
||||
return res.status(403).json({ error: 'Нет прав. Только операторы могут подтверждать чеки' });
|
||||
}
|
||||
});
|
||||
res.json({ success: true });
|
||||
|
||||
// Найти чек
|
||||
const check = await prisma.check.findUnique({
|
||||
where: { id: checkId },
|
||||
include: { user: true }
|
||||
});
|
||||
|
||||
if (!check) {
|
||||
return res.status(404).json({ error: 'Чек не найден' });
|
||||
}
|
||||
|
||||
// Проверка что чек ещё не обработан
|
||||
if (check.status !== 'pending') {
|
||||
return res.status(400).json({
|
||||
error: `Чек уже обработан (статус: ${check.status})`
|
||||
});
|
||||
}
|
||||
|
||||
// Обновить статус чека
|
||||
await prisma.check.update({
|
||||
where: { id: checkId },
|
||||
data: { status: 'approved' }
|
||||
});
|
||||
|
||||
// Пополнить баланс пользователя
|
||||
await prisma.user.update({
|
||||
where: { id: check.userId },
|
||||
data: {
|
||||
balance: {
|
||||
increment: 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);
|
||||
res.status(500).json({ error: 'Ошибка подтверждения чека' });
|
||||
}
|
||||
}
|
||||
|
||||
// Отклонить чек
|
||||
// Отклонить чек (только оператор)
|
||||
export async function rejectCheck(req: Request, res: Response) {
|
||||
const { checkId } = req.body;
|
||||
await prisma.check.update({ where: { id: checkId }, data: { status: 'rejected' } });
|
||||
res.json({ success: true });
|
||||
try {
|
||||
const { checkId, comment } = req.body;
|
||||
const isOperator = Number(req.user?.operator) === 1;
|
||||
|
||||
// Проверка прав оператора
|
||||
if (!isOperator) {
|
||||
logger.warn(`[Check] Попытка отклонения чека #${checkId} не оператором (userId: ${req.user?.id})`);
|
||||
return res.status(403).json({ error: 'Нет прав. Только операторы могут отклонять чеки' });
|
||||
}
|
||||
|
||||
// Найти чек
|
||||
const check = await prisma.check.findUnique({
|
||||
where: { id: checkId },
|
||||
include: { user: true }
|
||||
});
|
||||
|
||||
if (!check) {
|
||||
return res.status(404).json({ error: 'Чек не найден' });
|
||||
}
|
||||
|
||||
// Проверка что чек ещё не обработан
|
||||
if (check.status !== 'pending') {
|
||||
return res.status(400).json({
|
||||
error: `Чек уже обработан (статус: ${check.status})`
|
||||
});
|
||||
}
|
||||
|
||||
// Обновить статус чека
|
||||
await prisma.check.update({
|
||||
where: { id: checkId },
|
||||
data: {
|
||||
status: 'rejected',
|
||||
// Можно добавить поле comment в модель Check для хранения причины отклонения
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`[Check] ❌ Чек #${checkId} отклонён оператором ${req.user?.id}. Пользователь: ${check.user.username}${comment ? `, причина: ${comment}` : ''}`);
|
||||
res.json({ success: true, message: 'Чек отклонён' });
|
||||
} catch (error) {
|
||||
logger.error('[Check] Ошибка отклонения чека:', error);
|
||||
res.status(500).json({ error: 'Ошибка отклонения чека' });
|
||||
}
|
||||
}
|
||||
|
||||
// Получить историю чеков текущего пользователя
|
||||
export async function getUserChecks(req: Request, res: Response) {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ error: 'Не авторизован' });
|
||||
|
||||
const checks = await prisma.check.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50 // Последние 50 чеков
|
||||
});
|
||||
|
||||
res.json({ status: 'success', data: checks });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Ошибка получения истории чеков' });
|
||||
}
|
||||
}
|
||||
|
||||
// Просмотреть конкретный чек (изображение)
|
||||
export async function viewCheck(req: Request, res: Response) {
|
||||
try {
|
||||
const checkId = Number(req.params.id);
|
||||
const userId = req.user?.id;
|
||||
const isOperator = Number(req.user?.operator) === 1;
|
||||
|
||||
const check = await prisma.check.findUnique({ where: { id: checkId } });
|
||||
|
||||
if (!check) {
|
||||
return res.status(404).json({ error: 'Чек не найден' });
|
||||
}
|
||||
|
||||
// Проверка прав доступа (только владелец или оператор)
|
||||
if (check.userId !== userId && !isOperator) {
|
||||
return res.status(403).json({ error: 'Нет доступа к этому чеку' });
|
||||
}
|
||||
|
||||
res.json({ status: 'success', data: check });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Ошибка получения чека' });
|
||||
}
|
||||
}
|
||||
|
||||
// Получить файл изображения чека с авторизацией
|
||||
export async function getCheckFile(req: Request, res: Response) {
|
||||
try {
|
||||
const filename = req.params.filename;
|
||||
const userId = req.user?.id;
|
||||
const isOperator = Number(req.user?.operator) === 1;
|
||||
|
||||
logger.debug(`[CheckFile] Запрос файла ${filename} от пользователя ${userId}, оператор: ${isOperator}`);
|
||||
|
||||
// Операторы имеют доступ ко всем файлам
|
||||
if (!isOperator) {
|
||||
// Для обычных пользователей проверяем владение
|
||||
const check = await prisma.check.findFirst({
|
||||
where: {
|
||||
fileUrl: {
|
||||
contains: filename
|
||||
}
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!check) {
|
||||
logger.warn(`[CheckFile] Чек с файлом ${filename} не найден в БД`);
|
||||
return res.status(404).json({ error: 'Файл не найден' });
|
||||
}
|
||||
|
||||
if (check.userId !== userId) {
|
||||
logger.warn(`[CheckFile] Пользователь ${userId} попытался получить доступ к чужому чеку (владелец: ${check.userId})`);
|
||||
return res.status(403).json({ error: 'Нет доступа к этому файлу' });
|
||||
}
|
||||
}
|
||||
|
||||
// Путь к файлу
|
||||
const filePath = path.join(__dirname, '../../../uploads/checks', filename);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
logger.warn(`[CheckFile] Файл ${filename} не найден на диске`);
|
||||
return res.status(404).json({ error: 'Файл не найден на сервере' });
|
||||
}
|
||||
|
||||
logger.debug(`[CheckFile] Доступ разрешён, отправка файла ${filename}`);
|
||||
res.sendFile(filePath);
|
||||
} catch (error) {
|
||||
logger.error('[CheckFile] Ошибка получения файла:', error);
|
||||
res.status(500).json({ error: 'Ошибка получения файла' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router } from 'express';
|
||||
import { uploadCheck, getChecks, approveCheck, rejectCheck } from './check.controller';
|
||||
import { uploadCheck, getChecks, approveCheck, rejectCheck, getUserChecks, viewCheck, getCheckFile } from './check.controller';
|
||||
import { authMiddleware } from '../auth/auth.middleware';
|
||||
import multer, { MulterError } from 'multer';
|
||||
import path from 'path';
|
||||
@@ -48,7 +48,10 @@ const upload = multer({
|
||||
router.use(authMiddleware);
|
||||
|
||||
router.post('/upload', upload.single('file'), uploadCheck);
|
||||
router.get('/', getChecks);
|
||||
router.get('/', getChecks); // Для операторов - все чеки
|
||||
router.get('/my', getUserChecks); // Для пользователей - свои чеки
|
||||
router.get('/file/:filename', getCheckFile); // Получение файла чека с авторизацией
|
||||
router.get('/:id', viewCheck); // Просмотр конкретного чека
|
||||
router.post('/approve', approveCheck);
|
||||
router.post('/reject', rejectCheck);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -28,7 +29,7 @@ export async function sendEmail(notification: EmailNotification) {
|
||||
try {
|
||||
// Проверяем наличие конфигурации SMTP
|
||||
if (!process.env.SMTP_USER || !process.env.SMTP_PASS) {
|
||||
console.log('SMTP not configured, skipping email notification');
|
||||
logger.debug('SMTP not configured, skipping email notification');
|
||||
return { status: 'skipped', message: 'SMTP not configured' };
|
||||
}
|
||||
|
||||
@@ -37,10 +38,10 @@ export async function sendEmail(notification: EmailNotification) {
|
||||
...notification
|
||||
});
|
||||
|
||||
console.log('Email sent: %s', info.messageId);
|
||||
logger.info('Email sent: %s', info.messageId);
|
||||
return { status: 'success', messageId: info.messageId };
|
||||
} catch (error: any) {
|
||||
console.error('Error sending email:', error);
|
||||
logger.error('Error sending email:', error);
|
||||
return { status: 'error', message: error.message };
|
||||
}
|
||||
}
|
||||
@@ -70,7 +71,7 @@ export async function sendResourceAlertEmail(userId: number, serverId: number, a
|
||||
html
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error sending resource alert email:', error);
|
||||
logger.error('Error sending resource alert email:', error);
|
||||
return { status: 'error', message: error.message };
|
||||
}
|
||||
}
|
||||
@@ -102,7 +103,7 @@ export async function sendServerCreatedEmail(userId: number, serverId: number, s
|
||||
html
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error sending server created email:', error);
|
||||
logger.error('Error sending server created email:', error);
|
||||
return { status: 'error', message: error.message };
|
||||
}
|
||||
}
|
||||
@@ -129,7 +130,7 @@ export async function sendPaymentReminderEmail(userId: number, serverId: number,
|
||||
html
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error sending payment reminder email:', error);
|
||||
logger.error('Error sending payment reminder email:', error);
|
||||
return { status: 'error', message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { prisma } from '../../prisma/client';
|
||||
import { subscribePush, unsubscribePush, getVapidPublicKey, sendPushNotification } from './push.service';
|
||||
import { broadcastToUser } from '../../websocket/server';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
// Получить все уведомления пользователя с пагинацией
|
||||
export const getNotifications = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
const { page = '1', limit = '20', filter = 'all' } = req.query;
|
||||
|
||||
const skip = (parseInt(page as string) - 1) * parseInt(limit as string);
|
||||
const take = parseInt(limit as string);
|
||||
|
||||
const where: { userId: number; isRead?: boolean } = { userId };
|
||||
|
||||
if (filter === 'unread') {
|
||||
where.isRead = false;
|
||||
}
|
||||
|
||||
const [notifications, total] = await Promise.all([
|
||||
prisma.notification.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take
|
||||
}),
|
||||
prisma.notification.count({ where })
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: notifications,
|
||||
pagination: {
|
||||
page: parseInt(page as string),
|
||||
limit: parseInt(limit as string),
|
||||
total,
|
||||
totalPages: Math.ceil(total / take)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Ошибка получения уведомлений:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// Получить количество непрочитанных уведомлений
|
||||
export const getUnreadCount = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
|
||||
const count = await prisma.notification.count({
|
||||
where: {
|
||||
userId,
|
||||
isRead: false
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ success: true, count });
|
||||
} catch (error) {
|
||||
console.error('Ошибка подсчета непрочитанных:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// Пометить уведомление как прочитанное
|
||||
export const markAsRead = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
const { id } = req.params;
|
||||
|
||||
const notification = await prisma.notification.findFirst({
|
||||
where: {
|
||||
id: parseInt(id),
|
||||
userId
|
||||
}
|
||||
});
|
||||
|
||||
if (!notification) {
|
||||
return res.status(404).json({ success: false, message: 'Уведомление не найдено' });
|
||||
}
|
||||
|
||||
await prisma.notification.update({
|
||||
where: { id: parseInt(id) },
|
||||
data: { isRead: true }
|
||||
});
|
||||
|
||||
// Отправляем через WebSocket
|
||||
try {
|
||||
broadcastToUser(userId, 'notifications', {
|
||||
type: 'notification:read',
|
||||
notificationId: parseInt(id)
|
||||
});
|
||||
} catch (wsError) {
|
||||
logger.warn('[WS] Ошибка отправки через WebSocket:', wsError);
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'Отмечено как прочитанное' });
|
||||
} catch (error) {
|
||||
console.error('Ошибка отметки уведомления:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// Пометить все уведомления как прочитанные
|
||||
export const markAllAsRead = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
|
||||
await prisma.notification.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
isRead: false
|
||||
},
|
||||
data: { isRead: true }
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'Все уведомления прочитаны' });
|
||||
} catch (error) {
|
||||
console.error('Ошибка отметки всех уведомлений:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// Удалить уведомление
|
||||
export const deleteNotification = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
const { id } = req.params;
|
||||
|
||||
const notification = await prisma.notification.findFirst({
|
||||
where: {
|
||||
id: parseInt(id),
|
||||
userId
|
||||
}
|
||||
});
|
||||
|
||||
if (!notification) {
|
||||
return res.status(404).json({ success: false, message: 'Уведомление не найдено' });
|
||||
}
|
||||
|
||||
await prisma.notification.delete({
|
||||
where: { id: parseInt(id) }
|
||||
});
|
||||
|
||||
// Отправляем через WebSocket
|
||||
try {
|
||||
broadcastToUser(userId, 'notifications', {
|
||||
type: 'notification:delete',
|
||||
notificationId: parseInt(id)
|
||||
});
|
||||
} catch (wsError) {
|
||||
logger.warn('[WS] Ошибка отправки через WebSocket:', wsError);
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'Уведомление удалено' });
|
||||
} catch (error) {
|
||||
console.error('Ошибка удаления уведомления:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// Удалить все прочитанные уведомления
|
||||
export const deleteAllRead = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
|
||||
await prisma.notification.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
isRead: true
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'Прочитанные уведомления удалены' });
|
||||
} catch (error) {
|
||||
console.error('Ошибка удаления прочитанных:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// Функция-хелпер для создания уведомления
|
||||
interface CreateNotificationParams {
|
||||
userId: number;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
ticketId?: number;
|
||||
checkId?: number;
|
||||
actionUrl?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export async function createNotification(params: CreateNotificationParams) {
|
||||
try {
|
||||
const notification = await prisma.notification.create({
|
||||
data: {
|
||||
userId: params.userId,
|
||||
type: params.type,
|
||||
title: params.title,
|
||||
message: params.message,
|
||||
ticketId: params.ticketId,
|
||||
checkId: params.checkId,
|
||||
actionUrl: params.actionUrl,
|
||||
icon: params.icon,
|
||||
color: params.color
|
||||
}
|
||||
});
|
||||
|
||||
// Отправляем через WebSocket всем подключенным клиентам пользователя
|
||||
try {
|
||||
broadcastToUser(params.userId, 'notifications', {
|
||||
type: 'notification:new',
|
||||
notification
|
||||
});
|
||||
logger.log(`[WS] Уведомление отправлено пользователю ${params.userId} через WebSocket`);
|
||||
} catch (wsError) {
|
||||
logger.warn('[WS] Ошибка отправки через WebSocket:', wsError);
|
||||
// Не прерываем выполнение
|
||||
}
|
||||
|
||||
// Отправляем Push-уведомление если есть подписки
|
||||
try {
|
||||
await sendPushNotification(params.userId, {
|
||||
title: params.title,
|
||||
body: params.message,
|
||||
icon: params.icon,
|
||||
data: {
|
||||
notificationId: notification.id,
|
||||
type: params.type,
|
||||
actionUrl: params.actionUrl
|
||||
}
|
||||
});
|
||||
} catch (pushError) {
|
||||
console.error('Ошибка отправки Push:', pushError);
|
||||
// Не прерываем выполнение если Push не отправился
|
||||
}
|
||||
|
||||
return notification;
|
||||
} catch (error) {
|
||||
console.error('Ошибка создания уведомления:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Получить публичный VAPID ключ для настройки Push на клиенте
|
||||
export const getVapidKey = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const publicKey = getVapidPublicKey();
|
||||
res.json({ success: true, publicKey });
|
||||
} catch (error) {
|
||||
console.error('Ошибка получения VAPID ключа:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// Подписаться на Push-уведомления
|
||||
export const subscribe = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
const { subscription } = req.body;
|
||||
const userAgent = req.headers['user-agent'];
|
||||
|
||||
if (!subscription || !subscription.endpoint || !subscription.keys) {
|
||||
return res.status(400).json({ success: false, message: 'Некорректные данные подписки' });
|
||||
}
|
||||
|
||||
await subscribePush(userId, subscription, userAgent);
|
||||
|
||||
res.json({ success: true, message: 'Push-уведомления подключены' });
|
||||
} catch (error) {
|
||||
console.error('Ошибка подписки на Push:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// Отписаться от Push-уведомлений
|
||||
export const unsubscribe = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
const { endpoint } = req.body;
|
||||
|
||||
if (!endpoint) {
|
||||
return res.status(400).json({ success: false, message: 'Endpoint не указан' });
|
||||
}
|
||||
|
||||
await unsubscribePush(userId, endpoint);
|
||||
|
||||
res.json({ success: true, message: 'Push-уведомления отключены' });
|
||||
} catch (error) {
|
||||
console.error('Ошибка отписки от Push:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// Тестовая отправка Push-уведомления (только для админов)
|
||||
export const testPushNotification = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
const user = req.user!;
|
||||
|
||||
logger.log('[TEST PUSH] Запрос от пользователя:', { userId, username: user.username });
|
||||
|
||||
// Проверяем права администратора
|
||||
if (!user.isAdmin) {
|
||||
logger.log('[TEST PUSH] Отказано в доступе - пользователь не админ');
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Только администраторы могут отправлять тестовые уведомления'
|
||||
});
|
||||
}
|
||||
|
||||
logger.log('[TEST PUSH] Пользователь является админом, продолжаем...');
|
||||
|
||||
// Проверяем наличие подписок
|
||||
const subscriptions = await prisma.pushSubscription.findMany({
|
||||
where: { userId }
|
||||
});
|
||||
|
||||
logger.log(`[TEST PUSH] Найдено подписок для пользователя ${userId}:`, subscriptions.length);
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
logger.log('[TEST PUSH] Нет активных подписок');
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'У вас нет активных Push-подписок. Включите уведомления на странице уведомлений.'
|
||||
});
|
||||
}
|
||||
|
||||
// Выводим информацию о подписках
|
||||
subscriptions.forEach((sub, index) => {
|
||||
logger.log(` Подписка ${index + 1}:`, {
|
||||
id: sub.id,
|
||||
endpoint: sub.endpoint.substring(0, 50) + '...',
|
||||
userAgent: sub.userAgent,
|
||||
createdAt: sub.createdAt,
|
||||
lastUsed: sub.lastUsed
|
||||
});
|
||||
});
|
||||
|
||||
// Создаём тестовое уведомление в БД
|
||||
logger.log('[TEST PUSH] Создаём тестовое уведомление в БД...');
|
||||
const notification = await prisma.notification.create({
|
||||
data: {
|
||||
userId,
|
||||
type: 'test',
|
||||
title: 'Тестовое уведомление',
|
||||
message: 'Это тестовое Push-уведомление. Если вы его видите — всё работает отлично!',
|
||||
icon: 'test',
|
||||
color: 'purple',
|
||||
actionUrl: '/dashboard/notifications'
|
||||
}
|
||||
});
|
||||
|
||||
logger.log('[TEST PUSH] Уведомление создано в БД:', notification.id);
|
||||
|
||||
// Отправляем Push-уведомление
|
||||
logger.log('[TEST PUSH] Отправляем Push-уведомление...');
|
||||
|
||||
try {
|
||||
await sendPushNotification(userId, {
|
||||
title: 'Тестовое уведомление',
|
||||
body: 'Это тестовое Push-уведомление. Если вы его видите — всё работает отлично!',
|
||||
icon: '/logo192.png',
|
||||
badge: '/favicon.svg',
|
||||
data: {
|
||||
notificationId: notification.id,
|
||||
actionUrl: '/dashboard/notifications'
|
||||
}
|
||||
});
|
||||
|
||||
logger.log('[TEST PUSH] Push-уведомление успешно отправлено!');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Тестовое Push-уведомление отправлено! Проверьте браузер.',
|
||||
data: {
|
||||
notificationId: notification.id,
|
||||
subscriptionsCount: subscriptions.length
|
||||
}
|
||||
});
|
||||
} catch (pushError) {
|
||||
logger.error('[TEST PUSH] Ошибка при отправке Push:', pushError);
|
||||
|
||||
// Детальная информация об ошибке
|
||||
if (pushError && typeof pushError === 'object') {
|
||||
logger.error(' Детали ошибки:', {
|
||||
name: (pushError as Error).name,
|
||||
message: (pushError as Error).message,
|
||||
stack: (pushError as Error).stack?.split('\n').slice(0, 3)
|
||||
});
|
||||
|
||||
if ('statusCode' in pushError) {
|
||||
logger.error(' HTTP статус код:', (pushError as { statusCode: number }).statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Уведомление создано в БД, но ошибка при отправке Push. Проверьте консоль сервера.',
|
||||
error: pushError instanceof Error ? pushError.message : 'Неизвестная ошибка'
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[TEST PUSH] Критическая ошибка:', error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
logger.error(' Стек ошибки:', error.stack);
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Критическая ошибка при отправке тестового уведомления',
|
||||
error: error instanceof Error ? error.message : 'Неизвестная ошибка'
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,9 +1,51 @@
|
||||
import { Router } from 'express';
|
||||
import { getNotifications } from './notification.service';
|
||||
import {
|
||||
getNotifications,
|
||||
getUnreadCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
deleteNotification,
|
||||
deleteAllRead,
|
||||
getVapidKey,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
testPushNotification
|
||||
} from './notification.controller';
|
||||
import { authMiddleware } from '../auth/auth.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', authMiddleware, getNotifications);
|
||||
// Все роуты требуют авторизации
|
||||
router.use(authMiddleware);
|
||||
|
||||
// Получить уведомления с пагинацией и фильтрами
|
||||
router.get('/', getNotifications);
|
||||
|
||||
// Получить количество непрочитанных
|
||||
router.get('/unread-count', getUnreadCount);
|
||||
|
||||
// Получить публичный VAPID ключ для Push-уведомлений
|
||||
router.get('/vapid-key', getVapidKey);
|
||||
|
||||
// Подписаться на Push-уведомления
|
||||
router.post('/subscribe-push', subscribe);
|
||||
|
||||
// Отписаться от Push-уведомлений
|
||||
router.delete('/unsubscribe-push', unsubscribe);
|
||||
|
||||
// Тестовая отправка Push-уведомления (только для админов)
|
||||
router.post('/test-push', testPushNotification);
|
||||
|
||||
// Пометить уведомление как прочитанное
|
||||
router.post('/:id/read', markAsRead);
|
||||
|
||||
// Пометить все как прочитанные
|
||||
router.post('/read-all', markAllAsRead);
|
||||
|
||||
// Удалить уведомление
|
||||
router.delete('/:id', deleteNotification);
|
||||
|
||||
// Удалить все прочитанные
|
||||
router.delete('/read/all', deleteAllRead);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { prisma } from '../../prisma/client';
|
||||
|
||||
export const getNotifications = async (req: Request, res: Response) => {
|
||||
try {
|
||||
// @ts-ignore
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ error: 'Нет авторизации' });
|
||||
const notifications = await prisma.notification.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
res.json({ notifications });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Ошибка получения уведомлений' });
|
||||
}
|
||||
};
|
||||
|
||||
export const createNotification = async (userId: number, title: string, message: string) => {
|
||||
return prisma.notification.create({
|
||||
data: {
|
||||
userId,
|
||||
title,
|
||||
message,
|
||||
},
|
||||
});
|
||||
};
|
||||
148
ospabhost/backend/src/modules/notification/push.service.ts
Normal file
148
ospabhost/backend/src/modules/notification/push.service.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import webpush from 'web-push';
|
||||
import { prisma } from '../../prisma/client';
|
||||
|
||||
// VAPID ключи (нужно сгенерировать один раз и сохранить в .env)
|
||||
// Для генерации: npx web-push generate-vapid-keys
|
||||
const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY || '';
|
||||
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY || '';
|
||||
const VAPID_SUBJECT = process.env.VAPID_SUBJECT || 'mailto:support@ospab.host';
|
||||
|
||||
// Настройка web-push
|
||||
if (VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY) {
|
||||
webpush.setVapidDetails(
|
||||
VAPID_SUBJECT,
|
||||
VAPID_PUBLIC_KEY,
|
||||
VAPID_PRIVATE_KEY
|
||||
);
|
||||
}
|
||||
|
||||
// Сохранить Push-подписку пользователя
|
||||
export async function subscribePush(userId: number, subscription: {
|
||||
endpoint: string;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
}, userAgent?: string) {
|
||||
try {
|
||||
// Проверяем, существует ли уже такая подписка
|
||||
const existing = await prisma.pushSubscription.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
endpoint: subscription.endpoint
|
||||
}
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// Обновляем lastUsed
|
||||
await prisma.pushSubscription.update({
|
||||
where: { id: existing.id },
|
||||
data: { lastUsed: new Date() }
|
||||
});
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Создаём новую подписку
|
||||
const pushSubscription = await prisma.pushSubscription.create({
|
||||
data: {
|
||||
userId,
|
||||
endpoint: subscription.endpoint,
|
||||
p256dh: subscription.keys.p256dh,
|
||||
auth: subscription.keys.auth,
|
||||
userAgent
|
||||
}
|
||||
});
|
||||
|
||||
return pushSubscription;
|
||||
} catch (error) {
|
||||
console.error('Ошибка сохранения Push-подписки:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Удалить Push-подписку
|
||||
export async function unsubscribePush(userId: number, endpoint: string) {
|
||||
try {
|
||||
await prisma.pushSubscription.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
endpoint
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Ошибка удаления Push-подписки:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Отправить Push-уведомление конкретному пользователю
|
||||
export async function sendPushNotification(
|
||||
userId: number,
|
||||
payload: {
|
||||
title: string;
|
||||
body: string;
|
||||
icon?: string;
|
||||
badge?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
) {
|
||||
try {
|
||||
// Получаем все подписки пользователя
|
||||
const subscriptions = await prisma.pushSubscription.findMany({
|
||||
where: { userId }
|
||||
});
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
return; // Нет подписок
|
||||
}
|
||||
|
||||
// Отправляем на все устройства параллельно
|
||||
const promises = subscriptions.map(async (sub) => {
|
||||
try {
|
||||
const pushSubscription = {
|
||||
endpoint: sub.endpoint,
|
||||
keys: {
|
||||
p256dh: sub.p256dh,
|
||||
auth: sub.auth
|
||||
}
|
||||
};
|
||||
|
||||
await webpush.sendNotification(
|
||||
pushSubscription,
|
||||
JSON.stringify({
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
icon: payload.icon || '/logo192.png',
|
||||
badge: payload.badge || '/logo192.png',
|
||||
data: payload.data || {}
|
||||
})
|
||||
);
|
||||
|
||||
// Обновляем lastUsed
|
||||
await prisma.pushSubscription.update({
|
||||
where: { id: sub.id },
|
||||
data: { lastUsed: new Date() }
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
// Если подписка устарела (410 Gone), удаляем её
|
||||
if (error && typeof error === 'object' && 'statusCode' in error && error.statusCode === 410) {
|
||||
await prisma.pushSubscription.delete({
|
||||
where: { id: sub.id }
|
||||
});
|
||||
} else {
|
||||
console.error(`Ошибка отправки Push на ${sub.endpoint}:`, error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
} catch (error) {
|
||||
console.error('Ошибка отправки Push-уведомлений:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Получить публичный VAPID ключ (для frontend)
|
||||
export function getVapidPublicKey() {
|
||||
return VAPID_PUBLIC_KEY;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
import osRoutes from './os.routes';
|
||||
export default osRoutes;
|
||||
@@ -1,22 +0,0 @@
|
||||
|
||||
import { Router } from 'express';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { authMiddleware } from '../auth/auth.middleware';
|
||||
|
||||
const router = Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
// GET /api/os — получить все ОС (только для авторизованных)
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const oses = await prisma.operatingSystem.findMany();
|
||||
res.json(oses);
|
||||
} catch (err) {
|
||||
console.error('Ошибка получения ОС:', err);
|
||||
res.status(500).json({ error: 'Ошибка получения ОС' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,164 +1,177 @@
|
||||
import { prisma } from '../../prisma/client';
|
||||
import type { StorageBucket, User } from '@prisma/client';
|
||||
|
||||
import { prisma } from '../../prisma/client';
|
||||
import { createNotification } from '../notification/notification.controller';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const BILLING_INTERVAL_DAYS = 30;
|
||||
const GRACE_RETRY_DAYS = 1;
|
||||
|
||||
// Утилита для добавления дней к дате
|
||||
function addDays(date: Date, days: number): Date {
|
||||
const result = new Date(date);
|
||||
result.setDate(result.getDate() + days);
|
||||
return result;
|
||||
const clone = new Date(date);
|
||||
clone.setDate(clone.getDate() + days);
|
||||
return clone;
|
||||
}
|
||||
|
||||
export class PaymentService {
|
||||
type BucketWithUser = StorageBucket & { user: User };
|
||||
|
||||
class PaymentService {
|
||||
/**
|
||||
* Обработка автоматических платежей за серверы
|
||||
* Запускается по расписанию каждые 6 часов
|
||||
* Обрабатываем автоматические платежи за S3 бакеты.
|
||||
* Ставим cron на запуск раз в 6 часов.
|
||||
*/
|
||||
async processAutoPayments() {
|
||||
async processAutoPayments(): Promise<void> {
|
||||
const now = new Date();
|
||||
|
||||
// Находим серверы, у которых пришло время оплаты
|
||||
const serversDue = await prisma.server.findMany({
|
||||
const buckets = await prisma.storageBucket.findMany({
|
||||
where: {
|
||||
status: { in: ['running', 'stopped'] },
|
||||
autoRenew: true,
|
||||
nextPaymentDate: {
|
||||
lte: now
|
||||
}
|
||||
nextBillingDate: { lte: now },
|
||||
status: { in: ['active', 'grace'] }
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
tariff: true
|
||||
}
|
||||
include: { user: true }
|
||||
});
|
||||
|
||||
console.log(`[Payment Service] Найдено серверов для оплаты: ${serversDue.length}`);
|
||||
|
||||
for (const server of serversDue) {
|
||||
try {
|
||||
await this.chargeServerPayment(server);
|
||||
} catch (error) {
|
||||
console.error(`[Payment Service] Ошибка при списании за сервер ${server.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Списание оплаты за конкретный сервер
|
||||
*/
|
||||
async chargeServerPayment(server: any) {
|
||||
const amount = server.tariff.price;
|
||||
const user = server.user;
|
||||
|
||||
// Проверяем достаточно ли средств
|
||||
if (user.balance < amount) {
|
||||
console.log(`[Payment Service] Недостаточно средств у пользователя ${user.id} для сервера ${server.id}`);
|
||||
|
||||
// Создаём запись о неудачном платеже
|
||||
await prisma.payment.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
serverId: server.id,
|
||||
amount,
|
||||
status: 'failed',
|
||||
type: 'subscription',
|
||||
processedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
// Отправляем уведомление
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
title: 'Недостаточно средств',
|
||||
message: `Не удалось списать ${amount}₽ за сервер #${server.id}. Пополните баланс, иначе сервер будет приостановлен.`
|
||||
}
|
||||
});
|
||||
|
||||
// Приостанавливаем сервер через 3 дня неоплаты
|
||||
const daysSincePaymentDue = Math.floor((new Date().getTime() - server.nextPaymentDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
if (daysSincePaymentDue >= 3) {
|
||||
await prisma.server.update({
|
||||
where: { id: server.id },
|
||||
data: { status: 'suspended' }
|
||||
});
|
||||
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
title: 'Сервер приостановлен',
|
||||
message: `Сервер #${server.id} приостановлен из-за неоплаты.`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (buckets.length === 0) {
|
||||
logger.debug('[Payment Service] Нет бакетов для списания.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Списываем средства
|
||||
const balanceBefore = user.balance;
|
||||
const balanceAfter = balanceBefore - amount;
|
||||
logger.info(`[Payment Service] Найдено бакетов для списания: ${buckets.length}`);
|
||||
|
||||
await prisma.$transaction([
|
||||
// Обновляем баланс
|
||||
prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { balance: balanceAfter }
|
||||
}),
|
||||
for (const bucket of buckets) {
|
||||
try {
|
||||
await this.chargeBucket(bucket);
|
||||
} catch (error) {
|
||||
logger.error(`[Payment Service] Ошибка списания за бакет ${bucket.id}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Создаём запись о платеже
|
||||
prisma.payment.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
serverId: server.id,
|
||||
amount,
|
||||
status: 'success',
|
||||
type: 'subscription',
|
||||
processedAt: new Date()
|
||||
}
|
||||
}),
|
||||
|
||||
// Записываем транзакцию
|
||||
prisma.transaction.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
amount: -amount,
|
||||
type: 'withdrawal',
|
||||
description: `Оплата сервера #${server.id} за месяц`,
|
||||
balanceBefore,
|
||||
balanceAfter
|
||||
}
|
||||
}),
|
||||
|
||||
// Обновляем дату следующего платежа (через 30 дней)
|
||||
prisma.server.update({
|
||||
where: { id: server.id },
|
||||
data: {
|
||||
nextPaymentDate: addDays(new Date(), 30)
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
console.log(`[Payment Service] Успешно списано ${amount}₽ с пользователя ${user.id} за сервер ${server.id}`);
|
||||
|
||||
// Отправляем уведомление
|
||||
await prisma.notification.create({
|
||||
/**
|
||||
* Устанавливает дату первого списания (через 30 дней) для только что созданного ресурса.
|
||||
*/
|
||||
async setInitialPaymentDate(bucketId: number): Promise<void> {
|
||||
await prisma.storageBucket.update({
|
||||
where: { id: bucketId },
|
||||
data: {
|
||||
userId: user.id,
|
||||
title: 'Списание за сервер',
|
||||
message: `Списано ${amount}₽ за сервер #${server.id}. Следующая оплата: ${addDays(new Date(), 30).toLocaleDateString('ru-RU')}`
|
||||
nextBillingDate: addDays(new Date(), BILLING_INTERVAL_DAYS)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Устанавливаем дату первого платежа при создании сервера
|
||||
*/
|
||||
async setInitialPaymentDate(serverId: number) {
|
||||
await prisma.server.update({
|
||||
where: { id: serverId },
|
||||
private async chargeBucket(bucket: BucketWithUser): Promise<void> {
|
||||
const now = new Date();
|
||||
|
||||
if (bucket.user.balance < bucket.monthlyPrice) {
|
||||
await this.handleInsufficientFunds(bucket, now);
|
||||
return;
|
||||
}
|
||||
|
||||
const { bucket: 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 };
|
||||
}
|
||||
|
||||
const newBalance = user.balance - bucket.monthlyPrice;
|
||||
|
||||
await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: { balance: newBalance }
|
||||
});
|
||||
|
||||
await tx.transaction.create({
|
||||
data: {
|
||||
userId: bucket.userId,
|
||||
amount: -bucket.monthlyPrice,
|
||||
type: 'withdrawal',
|
||||
description: `Ежемесячная оплата бакета «${bucket.name}»`,
|
||||
balanceBefore: user.balance,
|
||||
balanceAfter: newBalance
|
||||
}
|
||||
});
|
||||
|
||||
const nextBilling = addDays(now, BILLING_INTERVAL_DAYS);
|
||||
|
||||
const updated = await tx.storageBucket.update({
|
||||
where: { id: bucket.id },
|
||||
data: {
|
||||
status: 'active',
|
||||
lastBilledAt: now,
|
||||
nextBillingDate: nextBilling,
|
||||
autoRenew: true
|
||||
}
|
||||
});
|
||||
|
||||
return { bucket: updated, balanceBefore: user.balance, balanceAfter: newBalance };
|
||||
});
|
||||
|
||||
if (balanceBefore === balanceAfter) {
|
||||
// Значит баланс поменялся внутри транзакции, пересоздадим попытку в следующий цикл
|
||||
await this.handleInsufficientFunds(bucket, now);
|
||||
return;
|
||||
}
|
||||
|
||||
await createNotification({
|
||||
userId: bucket.userId,
|
||||
type: 'storage_payment_charged',
|
||||
title: 'Оплата S3 хранилища',
|
||||
message: `Списано ₽${bucket.monthlyPrice.toFixed(2)} за бакет «${bucket.name}». Следующее списание ${updatedBucket.nextBillingDate ? new Date(updatedBucket.nextBillingDate).toLocaleDateString('ru-RU') : '—'}`,
|
||||
color: 'blue'
|
||||
});
|
||||
|
||||
logger.info(`[Payment Service] Успешное списание ₽${bucket.monthlyPrice} за бакет ${bucket.name}; баланс ${balanceAfter}`);
|
||||
}
|
||||
|
||||
private async handleInsufficientFunds(bucket: BucketWithUser, now: Date): Promise<void> {
|
||||
if (bucket.status === 'suspended') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (bucket.status === 'grace') {
|
||||
await prisma.storageBucket.update({
|
||||
where: { id: bucket.id },
|
||||
data: {
|
||||
status: 'suspended',
|
||||
autoRenew: false,
|
||||
nextBillingDate: null
|
||||
}
|
||||
});
|
||||
|
||||
await createNotification({
|
||||
userId: bucket.userId,
|
||||
type: 'storage_payment_failed',
|
||||
title: 'S3 бакет приостановлен',
|
||||
message: `Не удалось списать ₽${bucket.monthlyPrice.toFixed(2)} за бакет «${bucket.name}». Автопродление отключено.` ,
|
||||
color: 'red'
|
||||
});
|
||||
|
||||
logger.warn(`[Payment Service] Бакет ${bucket.name} приостановлен из-за нехватки средств.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Переводим в grace и пробуем снова через день
|
||||
const retryDate = addDays(now, GRACE_RETRY_DAYS);
|
||||
await prisma.storageBucket.update({
|
||||
where: { id: bucket.id },
|
||||
data: {
|
||||
nextPaymentDate: addDays(new Date(), 30)
|
||||
status: 'grace',
|
||||
nextBillingDate: retryDate
|
||||
}
|
||||
});
|
||||
|
||||
await createNotification({
|
||||
userId: bucket.userId,
|
||||
type: 'storage_payment_pending',
|
||||
title: 'Недостаточно средств для оплаты S3',
|
||||
message: `На балансе недостаточно средств для оплаты бакета «${bucket.name}». Пополните счёт до ${retryDate.toLocaleDateString('ru-RU')}, иначе бакет будет приостановлен.`,
|
||||
color: 'orange'
|
||||
});
|
||||
|
||||
logger.warn(`[Payment Service] Недостаточно средств для бакета ${bucket.name}, установлен статус grace.`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
268
ospabhost/backend/src/modules/qr-auth/qr-auth.controller.ts
Normal file
268
ospabhost/backend/src/modules/qr-auth/qr-auth.controller.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { prisma } from '../../prisma/client';
|
||||
import crypto from 'crypto';
|
||||
import { createSession } from '../session/session.controller';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const QR_EXPIRATION_SECONDS = 60; // QR-код живёт 60 секунд
|
||||
|
||||
// Генерировать уникальный код для QR
|
||||
function generateQRCode(): string {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
// Создать новый QR-запрос для логина
|
||||
export async function createQRLoginRequest(req: Request, res: Response) {
|
||||
try {
|
||||
const code = generateQRCode();
|
||||
const ipAddress = req.headers['x-forwarded-for'] as string || req.socket.remoteAddress || '';
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setSeconds(expiresAt.getSeconds() + QR_EXPIRATION_SECONDS);
|
||||
|
||||
const qrRequest = await prisma.qrLoginRequest.create({
|
||||
data: {
|
||||
code,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
status: 'pending',
|
||||
expiresAt
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
code: qrRequest.code,
|
||||
expiresAt: qrRequest.expiresAt,
|
||||
expiresIn: QR_EXPIRATION_SECONDS
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Ошибка создания QR-запроса:', error);
|
||||
res.status(500).json({ error: 'Ошибка создания QR-кода' });
|
||||
}
|
||||
}
|
||||
|
||||
// Проверить статус QR-запроса (polling с клиента)
|
||||
export async function checkQRStatus(req: Request, res: Response) {
|
||||
try {
|
||||
const { code } = req.params;
|
||||
|
||||
const qrRequest = await prisma.qrLoginRequest.findUnique({
|
||||
where: { code }
|
||||
});
|
||||
|
||||
if (!qrRequest) {
|
||||
return res.status(404).json({ error: 'QR-код не найден' });
|
||||
}
|
||||
|
||||
// Проверяем истёк ли QR-код
|
||||
if (new Date() > qrRequest.expiresAt) {
|
||||
await prisma.qrLoginRequest.update({
|
||||
where: { code },
|
||||
data: { status: 'expired' }
|
||||
});
|
||||
return res.json({ status: 'expired' });
|
||||
}
|
||||
|
||||
// Если подтверждён, создаём сессию и возвращаем токен
|
||||
if (qrRequest.status === 'confirmed' && qrRequest.userId) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: qrRequest.userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
operator: true,
|
||||
isAdmin: true,
|
||||
balance: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'Пользователь не найден' });
|
||||
}
|
||||
|
||||
// Создаём сессию для нового устройства
|
||||
const { token } = await createSession(user.id, req);
|
||||
|
||||
// Удаляем использованный QR-запрос
|
||||
await prisma.qrLoginRequest.delete({ where: { code } });
|
||||
|
||||
return res.json({
|
||||
status: 'confirmed',
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
operator: user.operator,
|
||||
isAdmin: user.isAdmin,
|
||||
balance: user.balance
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ status: qrRequest.status });
|
||||
} catch (error) {
|
||||
logger.error('Ошибка проверки статуса QR:', error);
|
||||
res.status(500).json({ error: 'Ошибка проверки статуса' });
|
||||
}
|
||||
}
|
||||
|
||||
// Подтвердить QR-вход (вызывается с мобильного устройства где пользователь уже залогинен)
|
||||
export async function confirmQRLogin(req: Request, res: Response) {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
const { code } = req.body;
|
||||
|
||||
logger.debug('[QR Confirm] Запрос подтверждения:', { userId, code, hasUser: !!req.user });
|
||||
|
||||
if (!userId) {
|
||||
logger.warn('[QR Confirm] Ошибка: пользователь не авторизован');
|
||||
return res.status(401).json({ error: 'Не авторизован' });
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
logger.warn('[QR Confirm] Ошибка: код не предоставлен');
|
||||
return res.status(400).json({ error: 'Код не предоставлен' });
|
||||
}
|
||||
|
||||
const qrRequest = await prisma.qrLoginRequest.findUnique({
|
||||
where: { code }
|
||||
});
|
||||
|
||||
logger.debug('[QR Confirm] Найден QR-запрос:', qrRequest ? {
|
||||
code: qrRequest.code,
|
||||
status: qrRequest.status,
|
||||
expiresAt: qrRequest.expiresAt
|
||||
} : 'не найден');
|
||||
|
||||
if (!qrRequest) {
|
||||
logger.warn('[QR Confirm] Ошибка: QR-код не найден в БД');
|
||||
return res.status(404).json({ error: 'QR-код не найден' });
|
||||
}
|
||||
|
||||
if (qrRequest.status !== 'pending' && qrRequest.status !== 'scanning') {
|
||||
logger.warn('[QR Confirm] Ошибка: QR-код уже использован, статус:', qrRequest.status);
|
||||
return res.status(400).json({ error: 'QR-код уже использован' });
|
||||
}
|
||||
|
||||
if (new Date() > qrRequest.expiresAt) {
|
||||
logger.warn('[QR Confirm] Ошибка: QR-код истёк');
|
||||
await prisma.qrLoginRequest.update({
|
||||
where: { code },
|
||||
data: { status: 'expired' }
|
||||
});
|
||||
return res.status(400).json({ error: 'QR-код истёк' });
|
||||
}
|
||||
|
||||
// Подтверждаем вход
|
||||
await prisma.qrLoginRequest.update({
|
||||
where: { code },
|
||||
data: {
|
||||
status: 'confirmed',
|
||||
userId,
|
||||
confirmedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('[QR Confirm] Успешно: вход подтверждён для пользователя', userId);
|
||||
res.json({ message: 'Вход подтверждён', success: true });
|
||||
} catch (error) {
|
||||
logger.error('[QR Confirm] Ошибка подтверждения QR-входа:', error);
|
||||
res.status(500).json({ error: 'Ошибка подтверждения входа' });
|
||||
}
|
||||
}
|
||||
|
||||
// Отклонить QR-вход
|
||||
export async function rejectQRLogin(req: Request, res: Response) {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
const { code } = req.body;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Не авторизован' });
|
||||
}
|
||||
|
||||
const qrRequest = await prisma.qrLoginRequest.findUnique({
|
||||
where: { code }
|
||||
});
|
||||
|
||||
if (!qrRequest) {
|
||||
return res.status(404).json({ error: 'QR-код не найден' });
|
||||
}
|
||||
|
||||
await prisma.qrLoginRequest.update({
|
||||
where: { code },
|
||||
data: { status: 'rejected' }
|
||||
});
|
||||
|
||||
res.json({ message: 'Вход отклонён' });
|
||||
} catch (error) {
|
||||
logger.error('Ошибка отклонения QR-входа:', error);
|
||||
res.status(500).json({ error: 'Ошибка отклонения входа' });
|
||||
}
|
||||
}
|
||||
|
||||
// Обновить статус на "scanning" (когда пользователь открыл страницу подтверждения)
|
||||
export async function markQRAsScanning(req: Request, res: Response) {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
const { code } = req.body;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Не авторизован' });
|
||||
}
|
||||
|
||||
const qrRequest = await prisma.qrLoginRequest.findUnique({
|
||||
where: { code }
|
||||
});
|
||||
|
||||
if (!qrRequest) {
|
||||
return res.status(404).json({ error: 'QR-код не найден' });
|
||||
}
|
||||
|
||||
if (qrRequest.status !== 'pending') {
|
||||
return res.json({ message: 'QR-код уже обработан', status: qrRequest.status });
|
||||
}
|
||||
|
||||
if (new Date() > qrRequest.expiresAt) {
|
||||
await prisma.qrLoginRequest.update({
|
||||
where: { code },
|
||||
data: { status: 'expired' }
|
||||
});
|
||||
return res.status(400).json({ error: 'QR-код истёк' });
|
||||
}
|
||||
|
||||
// Обновляем статус на "scanning"
|
||||
await prisma.qrLoginRequest.update({
|
||||
where: { code },
|
||||
data: { status: 'scanning' }
|
||||
});
|
||||
|
||||
res.json({ message: 'Статус обновлён', success: true });
|
||||
} catch (error) {
|
||||
logger.error('Ошибка обновления статуса QR:', error);
|
||||
res.status(500).json({ error: 'Ошибка обновления статуса' });
|
||||
}
|
||||
}
|
||||
|
||||
// Очистка устаревших QR-запросов (запускать периодически)
|
||||
export async function cleanupExpiredQRRequests() {
|
||||
try {
|
||||
const result = await prisma.qrLoginRequest.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ expiresAt: { lt: new Date() } },
|
||||
{
|
||||
status: { in: ['confirmed', 'rejected', 'expired'] },
|
||||
createdAt: { lt: new Date(Date.now() - 24 * 60 * 60 * 1000) } // старше 24 часов
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
logger.info(`[QR Cleanup] Удалено ${result.count} устаревших QR-запросов`);
|
||||
} catch (error) {
|
||||
logger.error('[QR Cleanup] Ошибка:', error);
|
||||
}
|
||||
}
|
||||
28
ospabhost/backend/src/modules/qr-auth/qr-auth.routes.ts
Normal file
28
ospabhost/backend/src/modules/qr-auth/qr-auth.routes.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
createQRLoginRequest,
|
||||
checkQRStatus,
|
||||
confirmQRLogin,
|
||||
rejectQRLogin,
|
||||
markQRAsScanning
|
||||
} from './qr-auth.controller';
|
||||
import { authMiddleware } from '../auth/auth.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Создать новый QR-код для входа (публичный endpoint)
|
||||
router.post('/generate', createQRLoginRequest);
|
||||
|
||||
// Проверить статус QR-кода (polling, публичный endpoint)
|
||||
router.get('/status/:code', checkQRStatus);
|
||||
|
||||
// Отметить что пользователь открыл страницу подтверждения (требует авторизации)
|
||||
router.post('/scanning', authMiddleware, markQRAsScanning);
|
||||
|
||||
// Подтвердить QR-вход (требует авторизации - вызывается с телефона)
|
||||
router.post('/confirm', authMiddleware, confirmQRLogin);
|
||||
|
||||
// Отклонить QR-вход (требует авторизации)
|
||||
router.post('/reject', authMiddleware, rejectQRLogin);
|
||||
|
||||
export default router;
|
||||
@@ -1,2 +0,0 @@
|
||||
import serverRoutes from './server.routes';
|
||||
export default serverRoutes;
|
||||
@@ -1,191 +0,0 @@
|
||||
import { Server as SocketIOServer, Socket } from 'socket.io';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { getContainerStats } from './proxmoxApi';
|
||||
import { sendResourceAlertEmail } from '../notification/email.service';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export class MonitoringService {
|
||||
private io: SocketIOServer;
|
||||
private monitoringInterval: NodeJS.Timeout | null = null;
|
||||
private readonly MONITORING_INTERVAL = 30000; // 30 секунд
|
||||
|
||||
constructor(io: SocketIOServer) {
|
||||
this.io = io;
|
||||
this.setupSocketHandlers();
|
||||
}
|
||||
|
||||
private setupSocketHandlers() {
|
||||
this.io.on('connection', (socket: Socket) => {
|
||||
console.log(`Client connected: ${socket.id}`);
|
||||
|
||||
// Подписка на обновления конкретного сервера
|
||||
socket.on('subscribe-server', async (serverId: number) => {
|
||||
console.log(`Client ${socket.id} subscribed to server ${serverId}`);
|
||||
socket.join(`server-${serverId}`);
|
||||
|
||||
// Отправляем начальную статистику
|
||||
try {
|
||||
const server = await prisma.server.findUnique({ where: { id: serverId } });
|
||||
if (server && server.proxmoxId) {
|
||||
const stats = await getContainerStats(server.proxmoxId);
|
||||
socket.emit('server-stats', { serverId, stats });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching initial stats for server ${serverId}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
// Отписка от обновлений сервера
|
||||
socket.on('unsubscribe-server', (serverId: number) => {
|
||||
console.log(`Client ${socket.id} unsubscribed from server ${serverId}`);
|
||||
socket.leave(`server-${serverId}`);
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log(`Client disconnected: ${socket.id}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Запуск периодического мониторинга
|
||||
public startMonitoring() {
|
||||
if (this.monitoringInterval) {
|
||||
console.log('Monitoring already running');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Starting server monitoring service...');
|
||||
this.monitoringInterval = setInterval(async () => {
|
||||
await this.checkAllServers();
|
||||
}, this.MONITORING_INTERVAL);
|
||||
|
||||
// Первая проверка сразу
|
||||
this.checkAllServers();
|
||||
}
|
||||
|
||||
// Остановка мониторинга
|
||||
public stopMonitoring() {
|
||||
if (this.monitoringInterval) {
|
||||
clearInterval(this.monitoringInterval);
|
||||
this.monitoringInterval = null;
|
||||
console.log('Monitoring service stopped');
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка всех активных серверов
|
||||
private async checkAllServers() {
|
||||
try {
|
||||
const servers = await prisma.server.findMany({
|
||||
where: {
|
||||
status: {
|
||||
in: ['running', 'stopped', 'creating']
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const server of servers) {
|
||||
if (server.proxmoxId) {
|
||||
try {
|
||||
const stats = await getContainerStats(server.proxmoxId);
|
||||
|
||||
if (stats.status === 'success' && stats.data) {
|
||||
// Обновляем статус и метрики в БД
|
||||
await prisma.server.update({
|
||||
where: { id: server.id },
|
||||
data: {
|
||||
status: stats.data.status,
|
||||
cpuUsage: stats.data.cpu || 0,
|
||||
memoryUsage: stats.data.memory?.usage || 0,
|
||||
diskUsage: stats.data.disk?.usage || 0,
|
||||
networkIn: stats.data.network?.in || 0,
|
||||
networkOut: stats.data.network?.out || 0,
|
||||
lastPing: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
// Отправляем обновления подписанным клиентам
|
||||
this.io.to(`server-${server.id}`).emit('server-stats', {
|
||||
serverId: server.id,
|
||||
stats
|
||||
});
|
||||
|
||||
// Проверяем превышение лимитов и отправляем алерты
|
||||
await this.checkResourceLimits(server, stats.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error monitoring server ${server.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in checkAllServers:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка превышения лимитов ресурсов
|
||||
private async checkResourceLimits(server: any, stats: any) {
|
||||
const alerts = [];
|
||||
|
||||
// CPU превышает 90%
|
||||
if (stats.cpu && stats.cpu > 0.9) {
|
||||
alerts.push({
|
||||
type: 'cpu',
|
||||
message: `CPU usage is at ${(stats.cpu * 100).toFixed(1)}%`,
|
||||
level: 'warning'
|
||||
});
|
||||
|
||||
// Отправляем email уведомление
|
||||
await sendResourceAlertEmail(
|
||||
server.userId,
|
||||
server.id,
|
||||
'CPU',
|
||||
`${(stats.cpu * 100).toFixed(1)}%`
|
||||
);
|
||||
}
|
||||
|
||||
// Memory превышает 90%
|
||||
if (stats.memory?.usage && stats.memory.usage > 90) {
|
||||
alerts.push({
|
||||
type: 'memory',
|
||||
message: `Memory usage is at ${stats.memory.usage.toFixed(1)}%`,
|
||||
level: 'warning'
|
||||
});
|
||||
|
||||
// Отправляем email уведомление
|
||||
await sendResourceAlertEmail(
|
||||
server.userId,
|
||||
server.id,
|
||||
'Memory',
|
||||
`${stats.memory.usage.toFixed(1)}%`
|
||||
);
|
||||
}
|
||||
|
||||
// Disk превышает 90%
|
||||
if (stats.disk?.usage && stats.disk.usage > 90) {
|
||||
alerts.push({
|
||||
type: 'disk',
|
||||
message: `Disk usage is at ${stats.disk.usage.toFixed(1)}%`,
|
||||
level: 'warning'
|
||||
});
|
||||
|
||||
// Отправляем email уведомление
|
||||
await sendResourceAlertEmail(
|
||||
server.userId,
|
||||
server.id,
|
||||
'Disk',
|
||||
`${stats.disk.usage.toFixed(1)}%`
|
||||
);
|
||||
}
|
||||
|
||||
// Отправляем алерты, если есть
|
||||
if (alerts.length > 0) {
|
||||
this.io.to(`server-${server.id}`).emit('server-alerts', {
|
||||
serverId: server.id,
|
||||
alerts
|
||||
});
|
||||
|
||||
console.log(`Alerts for server ${server.id}:`, alerts);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,709 +0,0 @@
|
||||
// Смена root-пароля через SSH (для LXC)
|
||||
import { exec } from 'child_process';
|
||||
|
||||
export async function changeRootPasswordSSH(vmid: number): Promise<{ status: string; password?: string; message?: string }> {
|
||||
const newPassword = generateSecurePassword();
|
||||
return new Promise((resolve) => {
|
||||
exec(`ssh -o StrictHostKeyChecking=no root@${process.env.PROXMOX_NODE} pct set ${vmid} --password ${newPassword}`, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
console.error('Ошибка смены пароля через SSH:', stderr);
|
||||
resolve({ status: 'error', message: stderr });
|
||||
} else {
|
||||
resolve({ status: 'success', password: newPassword });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
import axios from 'axios';
|
||||
import crypto from 'crypto';
|
||||
import dotenv from 'dotenv';
|
||||
import https from 'https';
|
||||
dotenv.config();
|
||||
|
||||
const PROXMOX_API_URL = process.env.PROXMOX_API_URL;
|
||||
const PROXMOX_TOKEN_ID = process.env.PROXMOX_TOKEN_ID;
|
||||
const PROXMOX_TOKEN_SECRET = process.env.PROXMOX_TOKEN_SECRET;
|
||||
const PROXMOX_NODE = process.env.PROXMOX_NODE || 'proxmox';
|
||||
const PROXMOX_VM_STORAGE = process.env.PROXMOX_VM_STORAGE || 'local';
|
||||
const PROXMOX_BACKUP_STORAGE = process.env.PROXMOX_BACKUP_STORAGE || 'local';
|
||||
const PROXMOX_ISO_STORAGE = process.env.PROXMOX_ISO_STORAGE || 'local';
|
||||
const PROXMOX_NETWORK_BRIDGE = process.env.PROXMOX_NETWORK_BRIDGE || 'vmbr0';
|
||||
|
||||
// HTTPS Agent с отключением проверки сертификата (для самоподписанного Proxmox)
|
||||
const httpsAgent = new https.Agent({
|
||||
rejectUnauthorized: false,
|
||||
keepAlive: true,
|
||||
maxSockets: 50,
|
||||
maxFreeSockets: 10,
|
||||
timeout: 60000
|
||||
});
|
||||
|
||||
function getProxmoxHeaders(): Record<string, string> {
|
||||
return {
|
||||
'Authorization': `PVEAPIToken=${PROXMOX_TOKEN_ID}=${PROXMOX_TOKEN_SECRET}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
}
|
||||
|
||||
// Генерация случайного пароля
|
||||
export function generateSecurePassword(length: number = 16): string {
|
||||
const charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*';
|
||||
let password = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
password += charset.charAt(Math.floor(Math.random() * charset.length));
|
||||
}
|
||||
return password;
|
||||
}
|
||||
|
||||
// Получение следующего доступного VMID
|
||||
export async function getNextVMID(): Promise<number> {
|
||||
try {
|
||||
const res = await axios.get(
|
||||
`${PROXMOX_API_URL}/cluster/nextid`,
|
||||
{
|
||||
headers: getProxmoxHeaders(),
|
||||
timeout: 15000, // 15 секунд
|
||||
httpsAgent
|
||||
}
|
||||
);
|
||||
return res.data.data || Math.floor(100 + Math.random() * 899);
|
||||
} catch (error) {
|
||||
console.error('Ошибка получения VMID:', error);
|
||||
return Math.floor(100 + Math.random() * 899);
|
||||
}
|
||||
}
|
||||
|
||||
// Создание LXC контейнера
|
||||
export interface CreateContainerParams {
|
||||
os: { template: string; type: string };
|
||||
tariff: { name: string; price: number; description?: string };
|
||||
user: { id: number; username: string; email?: string };
|
||||
hostname?: string;
|
||||
}
|
||||
|
||||
export async function createLXContainer({ os, tariff, user }: CreateContainerParams) {
|
||||
let vmid: number = 0;
|
||||
let hostname: string = '';
|
||||
|
||||
try {
|
||||
vmid = await getNextVMID();
|
||||
const rootPassword = generateSecurePassword();
|
||||
// Используем hostname из параметров, если есть
|
||||
hostname = arguments[0].hostname;
|
||||
if (!hostname) {
|
||||
if (user.email) {
|
||||
const emailName = user.email.split('@')[0];
|
||||
hostname = `${emailName}-${vmid}`;
|
||||
} else {
|
||||
hostname = `user${user.id}-${vmid}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Определяем ресурсы по названию тарифа (парсим описание)
|
||||
const description = tariff.description || '1 ядро, 1ГБ RAM, 20ГБ SSD';
|
||||
const cores = parseInt(description.match(/(\d+)\s*ядр/)?.[1] || '1');
|
||||
const memory = parseInt(description.match(/(\d+)ГБ\s*RAM/)?.[1] || '1') * 1024; // в MB
|
||||
const diskSize = parseInt(description.match(/(\d+)ГБ\s*SSD/)?.[1] || '20');
|
||||
|
||||
const containerConfig = {
|
||||
vmid,
|
||||
hostname,
|
||||
password: rootPassword,
|
||||
ostemplate: os.template,
|
||||
cores,
|
||||
memory,
|
||||
rootfs: `${PROXMOX_VM_STORAGE}:${diskSize}`,
|
||||
net0: `name=eth0,bridge=${PROXMOX_NETWORK_BRIDGE},ip=dhcp`,
|
||||
unprivileged: 1,
|
||||
start: 1, // Автостарт после создания
|
||||
protection: 0,
|
||||
console: 1,
|
||||
cmode: 'console'
|
||||
};
|
||||
|
||||
console.log('Создание LXC контейнера с параметрами:', containerConfig);
|
||||
|
||||
// Валидация перед отправкой
|
||||
if (!containerConfig.ostemplate) {
|
||||
throw new Error('OS template не задан');
|
||||
}
|
||||
if (containerConfig.cores < 1 || containerConfig.cores > 32) {
|
||||
throw new Error(`Cores должно быть от 1 до 32, получено: ${containerConfig.cores}`);
|
||||
}
|
||||
if (containerConfig.memory < 512 || containerConfig.memory > 65536) {
|
||||
throw new Error(`Memory должно быть от 512 до 65536 MB, получено: ${containerConfig.memory}`);
|
||||
}
|
||||
|
||||
// Детальное логирование перед отправкой
|
||||
console.log('URL Proxmox:', `${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc`);
|
||||
console.log('Параметры контейнера (JSON):', JSON.stringify(containerConfig, null, 2));
|
||||
console.log('Storage для VM:', PROXMOX_VM_STORAGE);
|
||||
|
||||
const response = await axios.post(
|
||||
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc`,
|
||||
containerConfig,
|
||||
{
|
||||
headers: getProxmoxHeaders(),
|
||||
timeout: 120000, // 2 минуты для создания контейнера
|
||||
httpsAgent
|
||||
}
|
||||
);
|
||||
|
||||
console.log('Ответ от Proxmox (создание):', response.status, response.data);
|
||||
|
||||
if (response.data?.data) {
|
||||
// Polling статуса контейнера до running или timeout
|
||||
let status = '';
|
||||
let attempts = 0;
|
||||
const maxAttempts = 10;
|
||||
while (attempts < maxAttempts) {
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
const info = await getContainerStatus(vmid);
|
||||
status = info?.status || '';
|
||||
if (status === 'running' || status === 'stopped' || status === 'created') break;
|
||||
attempts++;
|
||||
}
|
||||
// Получаем IP адрес контейнера
|
||||
const ipAddress = await getContainerIP(vmid);
|
||||
return {
|
||||
status: 'success',
|
||||
vmid,
|
||||
rootPassword,
|
||||
ipAddress,
|
||||
hostname,
|
||||
taskId: response.data.data,
|
||||
containerStatus: status
|
||||
};
|
||||
}
|
||||
// Получить статус контейнера по VMID
|
||||
async function getContainerStatus(vmid: number): Promise<{ status: string }> {
|
||||
try {
|
||||
const res = await axios.get(
|
||||
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/status/current`,
|
||||
{
|
||||
headers: getProxmoxHeaders(),
|
||||
httpsAgent
|
||||
}
|
||||
);
|
||||
return { status: res.data.data.status };
|
||||
} catch (error) {
|
||||
return { status: 'error' };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Не удалось создать контейнер');
|
||||
} catch (error: any) {
|
||||
console.error('❌ ОШИБКА создания LXC контейнера:', error.message);
|
||||
console.error(' Code:', error.code);
|
||||
console.error(' Status:', error.response?.status);
|
||||
console.error(' Response data:', error.response?.data);
|
||||
|
||||
// Логируем контекст ошибки
|
||||
console.error(' VMID:', vmid);
|
||||
console.error(' Hostname:', hostname);
|
||||
console.error(' Storage используемый:', PROXMOX_VM_STORAGE);
|
||||
console.error(' OS Template:', os.template);
|
||||
|
||||
// Специальная обработка socket hang up / ECONNRESET
|
||||
const isSocketError = error?.code === 'ECONNRESET' ||
|
||||
error?.message?.includes('socket hang up') ||
|
||||
error?.cause?.code === 'ECONNRESET';
|
||||
|
||||
if (isSocketError) {
|
||||
console.error('\n⚠️ SOCKET HANG UP DETECTED!');
|
||||
console.error(' Возможные причины:');
|
||||
console.error(' 1. Storage "' + PROXMOX_VM_STORAGE + '" не существует на Proxmox');
|
||||
console.error(' 2. API токен неверный или истёк');
|
||||
console.error(' 3. Proxmox перегружена или недоступна');
|
||||
console.error(' 4. Firewall блокирует соединение\n');
|
||||
}
|
||||
|
||||
const errorMessage = isSocketError
|
||||
? `Proxmox не ответил вовремя. Storage: ${PROXMOX_VM_STORAGE}. Проверьте доступность сервера и корректность конфигурации.`
|
||||
: error.response?.data?.errors || error.message;
|
||||
|
||||
return {
|
||||
status: 'error',
|
||||
message: errorMessage,
|
||||
code: error?.code || error?.response?.status,
|
||||
isSocketError,
|
||||
storage: PROXMOX_VM_STORAGE
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Получение IP адреса контейнера
|
||||
export async function getContainerIP(vmid: number): Promise<string | null> {
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 10000)); // Ждём запуска
|
||||
|
||||
const response = await axios.get(
|
||||
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/interfaces`,
|
||||
{
|
||||
headers: getProxmoxHeaders(),
|
||||
httpsAgent
|
||||
}
|
||||
);
|
||||
|
||||
const interfaces = response.data?.data;
|
||||
if (interfaces && interfaces.length > 0) {
|
||||
// Сначала ищем локальный IP
|
||||
for (const iface of interfaces) {
|
||||
if (iface.inet && iface.inet !== '127.0.0.1') {
|
||||
const ip = iface.inet.split('/')[0];
|
||||
if (
|
||||
ip.startsWith('10.') ||
|
||||
ip.startsWith('192.168.') ||
|
||||
(/^172\.(1[6-9]|2[0-9]|3[01])\./.test(ip))
|
||||
) {
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Если не нашли локальный, возвращаем первый не-127.0.0.1
|
||||
for (const iface of interfaces) {
|
||||
if (iface.inet && iface.inet !== '127.0.0.1') {
|
||||
return iface.inet.split('/')[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Ошибка получения IP:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Управление контейнером (старт/стоп/перезагрузка)
|
||||
export async function controlContainer(vmid: number, action: 'start' | 'stop' | 'restart' | 'suspend' | 'resume') {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/status/${action}`,
|
||||
{},
|
||||
{ headers: getProxmoxHeaders() }
|
||||
);
|
||||
return {
|
||||
status: 'success',
|
||||
action,
|
||||
taskId: response.data?.data
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(`Ошибка ${action} контейнера:`, error);
|
||||
return {
|
||||
status: 'error',
|
||||
message: error.response?.data?.errors || error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Удаление контейнера
|
||||
export async function deleteContainer(vmid: number) {
|
||||
try {
|
||||
const response = await axios.delete(
|
||||
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}`,
|
||||
{ headers: getProxmoxHeaders() }
|
||||
);
|
||||
return {
|
||||
status: 'success',
|
||||
taskId: response.data?.data
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка удаления контейнера:', error);
|
||||
return {
|
||||
status: 'error',
|
||||
message: error.response?.data?.errors || error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Получение статистики контейнера
|
||||
export async function getContainerStats(vmid: number) {
|
||||
try {
|
||||
// Получаем текущий статус
|
||||
const statusResponse = await axios.get(
|
||||
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/status/current`,
|
||||
{ headers: getProxmoxHeaders() }
|
||||
);
|
||||
|
||||
const status = statusResponse.data?.data;
|
||||
|
||||
// Получаем статистику RRD (за последний час)
|
||||
let rrdData = [];
|
||||
let latest: any = {};
|
||||
try {
|
||||
const rrdResponse = await axios.get(
|
||||
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/rrd?timeframe=hour`,
|
||||
{ headers: getProxmoxHeaders() }
|
||||
);
|
||||
rrdData = rrdResponse.data?.data || [];
|
||||
latest = rrdData[rrdData.length - 1] || {};
|
||||
} catch (err: any) {
|
||||
// Если ошибка 400, возвращаем пустую статистику, но не ошибку
|
||||
if (err?.response?.status === 400) {
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
vmid,
|
||||
status: status?.status || 'unknown',
|
||||
uptime: status?.uptime || 0,
|
||||
cpu: 0,
|
||||
memory: {
|
||||
used: status?.mem || 0,
|
||||
max: status?.maxmem || 0,
|
||||
usage: 0
|
||||
},
|
||||
disk: {
|
||||
used: status?.disk || 0,
|
||||
max: status?.maxdisk || 0,
|
||||
usage: 0
|
||||
},
|
||||
network: {
|
||||
in: 0,
|
||||
out: 0
|
||||
},
|
||||
rrdData: []
|
||||
}
|
||||
};
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
vmid,
|
||||
status: status?.status || 'unknown',
|
||||
uptime: status?.uptime || 0,
|
||||
cpu: latest.cpu || 0,
|
||||
memory: {
|
||||
used: status?.mem || 0,
|
||||
max: status?.maxmem || 0,
|
||||
usage: status?.maxmem ? (status.mem / status.maxmem) * 100 : 0
|
||||
},
|
||||
disk: {
|
||||
used: status?.disk || 0,
|
||||
max: status?.maxdisk || 0,
|
||||
usage: status?.maxdisk ? (status.disk / status.maxdisk) * 100 : 0
|
||||
},
|
||||
network: {
|
||||
in: latest.netin || 0,
|
||||
out: latest.netout || 0
|
||||
},
|
||||
rrdData: rrdData.slice(-60) // Последние 60 точек для графиков
|
||||
}
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка получения статистики:', error);
|
||||
return {
|
||||
status: 'error',
|
||||
message: error.response?.data?.errors || error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Смена root пароля
|
||||
export async function changeRootPassword(vmid: number): Promise<{ status: string; password?: string; message?: string }> {
|
||||
try {
|
||||
const newPassword = generateSecurePassword();
|
||||
|
||||
// Выполняем команду смены пароля в контейнере
|
||||
const response = await axios.post(
|
||||
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/status/exec`,
|
||||
{
|
||||
command: `echo 'root:${newPassword}' | chpasswd`
|
||||
},
|
||||
{ headers: getProxmoxHeaders() }
|
||||
);
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
password: newPassword
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка смены пароля:', error);
|
||||
return {
|
||||
status: 'error',
|
||||
message: error.response?.data?.errors || error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Получение ссылки на noVNC консоль
|
||||
export async function getConsoleURL(vmid: number): Promise<{ status: string; url?: string; message?: string }> {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/vncproxy`,
|
||||
{
|
||||
websocket: 1
|
||||
},
|
||||
{ headers: getProxmoxHeaders() }
|
||||
);
|
||||
|
||||
const data = response.data?.data;
|
||||
if (data?.ticket && data?.port) {
|
||||
const consoleUrl = `${process.env.PROXMOX_WEB_URL}/?console=lxc&vmid=${vmid}&node=${PROXMOX_NODE}&resize=off&ticket=${data.ticket}&port=${data.port}`;
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
url: consoleUrl
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Не удалось получить данные для консоли');
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка получения консоли:', error);
|
||||
return {
|
||||
status: 'error',
|
||||
message: error.response?.data?.errors || error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Валидация конфигурации контейнера
|
||||
function validateContainerConfig(config: { cores?: number; memory?: number; rootfs?: string }) {
|
||||
const validated: { cores?: number; memory?: number; rootfs?: string } = {};
|
||||
|
||||
// Валидация cores (1-32 ядра)
|
||||
if (config.cores !== undefined) {
|
||||
const cores = Number(config.cores);
|
||||
if (isNaN(cores) || cores < 1 || cores > 32) {
|
||||
throw new Error('Invalid cores value: must be between 1 and 32');
|
||||
}
|
||||
validated.cores = cores;
|
||||
}
|
||||
|
||||
// Валидация memory (512MB - 64GB)
|
||||
if (config.memory !== undefined) {
|
||||
const memory = Number(config.memory);
|
||||
if (isNaN(memory) || memory < 512 || memory > 65536) {
|
||||
throw new Error('Invalid memory value: must be between 512 and 65536 MB');
|
||||
}
|
||||
validated.memory = memory;
|
||||
}
|
||||
|
||||
// Валидация rootfs (формат: local:размер)
|
||||
if (config.rootfs !== undefined) {
|
||||
const match = config.rootfs.match(/^local:(\d+)$/);
|
||||
if (!match) {
|
||||
throw new Error('Invalid rootfs format: must be "local:SIZE"');
|
||||
}
|
||||
const size = Number(match[1]);
|
||||
if (size < 10 || size > 1000) {
|
||||
throw new Error('Invalid disk size: must be between 10 and 1000 GB');
|
||||
}
|
||||
validated.rootfs = config.rootfs;
|
||||
}
|
||||
|
||||
return validated;
|
||||
}
|
||||
|
||||
// Изменение конфигурации контейнера (CPU, RAM, Disk)
|
||||
export async function resizeContainer(vmid: number, config: { cores?: number; memory?: number; rootfs?: string }) {
|
||||
try {
|
||||
const validatedConfig = validateContainerConfig(config);
|
||||
const response = await axios.put(
|
||||
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/config`,
|
||||
validatedConfig,
|
||||
{ headers: getProxmoxHeaders() }
|
||||
);
|
||||
return {
|
||||
status: 'success',
|
||||
data: response.data?.data
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка изменения конфигурации:', error);
|
||||
return {
|
||||
status: 'error',
|
||||
message: error.response?.data?.errors || error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Валидация имени снэпшота для предотвращения SSRF и path traversal
|
||||
// SECURITY: Эта функция валидирует пользовательский ввод перед использованием в URL
|
||||
// CodeQL может показывать предупреждение, но валидация является достаточной
|
||||
function validateSnapshotName(snapname: string): string {
|
||||
// Разрешены только буквы, цифры, дефисы и подчеркивания
|
||||
const sanitized = snapname.replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
if (sanitized.length === 0) {
|
||||
throw new Error('Invalid snapshot name');
|
||||
}
|
||||
// Ограничиваем длину для предотвращения DoS
|
||||
return sanitized.substring(0, 64);
|
||||
}
|
||||
|
||||
// Создание снэпшота
|
||||
export async function createSnapshot(vmid: number, snapname: string, description?: string) {
|
||||
try {
|
||||
const validSnapname = validateSnapshotName(snapname);
|
||||
const response = await axios.post(
|
||||
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/snapshot`,
|
||||
{
|
||||
snapname: validSnapname,
|
||||
description: description || `Snapshot ${validSnapname}`
|
||||
},
|
||||
{ headers: getProxmoxHeaders() }
|
||||
);
|
||||
return {
|
||||
status: 'success',
|
||||
taskId: response.data?.data,
|
||||
snapname: validSnapname
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка создания снэпшота:', error);
|
||||
return {
|
||||
status: 'error',
|
||||
message: error.response?.data?.errors || error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Получение списка снэпшотов
|
||||
export async function listSnapshots(vmid: number) {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/snapshot`,
|
||||
{ headers: getProxmoxHeaders() }
|
||||
);
|
||||
return {
|
||||
status: 'success',
|
||||
data: response.data?.data || []
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка получения списка снэпшотов:', error);
|
||||
return {
|
||||
status: 'error',
|
||||
message: error.response?.data?.errors || error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Восстановление из снэпшота
|
||||
export async function rollbackSnapshot(vmid: number, snapname: string) {
|
||||
try {
|
||||
const validSnapname = validateSnapshotName(snapname);
|
||||
const response = await axios.post(
|
||||
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/snapshot/${validSnapname}/rollback`,
|
||||
{},
|
||||
{ headers: getProxmoxHeaders() }
|
||||
);
|
||||
return {
|
||||
status: 'success',
|
||||
taskId: response.data?.data
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка восстановления снэпшота:', error);
|
||||
return {
|
||||
status: 'error',
|
||||
message: error.response?.data?.errors || error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Удаление снэпшота
|
||||
export async function deleteSnapshot(vmid: number, snapname: string) {
|
||||
try {
|
||||
const validSnapname = validateSnapshotName(snapname);
|
||||
const response = await axios.delete(
|
||||
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/snapshot/${validSnapname}`,
|
||||
{ headers: getProxmoxHeaders() }
|
||||
);
|
||||
return {
|
||||
status: 'success',
|
||||
taskId: response.data?.data
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка удаления снэпшота:', error);
|
||||
return {
|
||||
status: 'error',
|
||||
message: error.response?.data?.errors || error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Получение списка всех контейнеров
|
||||
export async function listContainers() {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc`,
|
||||
{ headers: getProxmoxHeaders() }
|
||||
);
|
||||
return {
|
||||
status: 'success',
|
||||
data: response.data?.data || []
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка получения списка контейнеров:', error);
|
||||
return {
|
||||
status: 'error',
|
||||
message: error.response?.data?.errors || error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Получение списка доступных storage pools на узле
|
||||
export async function getNodeStorages(node: string = PROXMOX_NODE) {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${PROXMOX_API_URL}/nodes/${node}/storage`,
|
||||
{
|
||||
headers: getProxmoxHeaders(),
|
||||
timeout: 15000,
|
||||
httpsAgent
|
||||
}
|
||||
);
|
||||
return {
|
||||
status: 'success',
|
||||
data: response.data?.data || []
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка получения storage:', error.message);
|
||||
return {
|
||||
status: 'error',
|
||||
message: error.response?.data?.errors || error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка соединения с Proxmox
|
||||
export async function checkProxmoxConnection() {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${PROXMOX_API_URL}/version`,
|
||||
{
|
||||
headers: getProxmoxHeaders(),
|
||||
httpsAgent
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data?.data) {
|
||||
return {
|
||||
status: 'success',
|
||||
message: 'Соединение с Proxmox установлено',
|
||||
version: response.data.data.version,
|
||||
node: PROXMOX_NODE
|
||||
};
|
||||
}
|
||||
return { status: 'error', message: 'Не удалось получить версию Proxmox' };
|
||||
} catch (error: any) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: 'Ошибка соединения с Proxmox',
|
||||
error: error.response?.data || error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Получение конфигурации storage через файл (обходим API если он недоступен)
|
||||
export async function getStorageConfig(): Promise<{
|
||||
configured: string;
|
||||
available: string[];
|
||||
note: string;
|
||||
}> {
|
||||
return {
|
||||
configured: PROXMOX_VM_STORAGE,
|
||||
available: ['local', 'local-lvm', 'vm-storage'],
|
||||
note: `Текущее использование: ${PROXMOX_VM_STORAGE}. Если хранилище недоступно или socket hang up, проверьте что это имя существует в Proxmox (pvesm status)`
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { Server as WebSocketServer, WebSocket } from 'ws';
|
||||
import { Client as SSHClient } from 'ssh2';
|
||||
import dotenv from 'dotenv';
|
||||
import { IncomingMessage } from 'http';
|
||||
import { Server as HttpServer } from 'http';
|
||||
dotenv.config();
|
||||
|
||||
export function setupConsoleWSS(server: HttpServer) {
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {
|
||||
const url = req.url || '';
|
||||
const match = url.match(/\/api\/server\/(\d+)\/console/);
|
||||
const vmid = match ? match[1] : null;
|
||||
if (!vmid) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Получаем параметры SSH из .env
|
||||
const host = process.env.SSH_HOST || process.env.PROXMOX_IP || process.env.PROXMOX_NODE;
|
||||
const port = process.env.SSH_PORT ? Number(process.env.SSH_PORT) : (process.env.PROXMOX_SSH_PORT ? Number(process.env.PROXMOX_SSH_PORT) : 22);
|
||||
const username = process.env.SSH_USER || 'root';
|
||||
let password = process.env.SSH_PASSWORD || process.env.PROXMOX_ROOT_PASSWORD;
|
||||
if (password && password.startsWith('"') && password.endsWith('"')) {
|
||||
password = password.slice(1, -1);
|
||||
}
|
||||
const privateKeyPath = process.env.SSH_PRIVATE_KEY_PATH;
|
||||
let privateKey: Buffer | undefined = undefined;
|
||||
if (privateKeyPath) {
|
||||
try {
|
||||
privateKey = require('fs').readFileSync(privateKeyPath);
|
||||
} catch (e) {
|
||||
console.error('Ошибка чтения SSH ключа:', e);
|
||||
}
|
||||
}
|
||||
|
||||
const ssh = new SSHClient();
|
||||
ssh.on('ready', () => {
|
||||
ssh.shell((err: Error | undefined, stream: any) => {
|
||||
if (err) {
|
||||
ws.send('Ошибка запуска shell: ' + err.message);
|
||||
ws.close();
|
||||
ssh.end();
|
||||
return;
|
||||
}
|
||||
ws.on('message', (msg: string | Buffer) => {
|
||||
stream.write(msg.toString());
|
||||
});
|
||||
stream.on('data', (data: Buffer) => {
|
||||
ws.send(data.toString());
|
||||
});
|
||||
stream.on('close', () => {
|
||||
ws.close();
|
||||
ssh.end();
|
||||
});
|
||||
});
|
||||
}).connect({
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
password: privateKey ? undefined : password,
|
||||
privateKey,
|
||||
hostVerifier: (hash: string) => {
|
||||
console.log('SSH fingerprint:', hash);
|
||||
return true; // всегда принимаем fingerprint
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
ssh.end();
|
||||
});
|
||||
});
|
||||
|
||||
server.on('upgrade', (request: IncomingMessage, socket: any, head: Buffer) => {
|
||||
if (request.url?.startsWith('/api/server/') && request.url?.endsWith('/console')) {
|
||||
wss.handleUpgrade(request, socket, head, (ws: WebSocket) => {
|
||||
wss.emit('connection', ws, request);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,362 +0,0 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import {
|
||||
createLXContainer,
|
||||
controlContainer,
|
||||
getContainerStats,
|
||||
changeRootPassword as proxmoxChangeRootPassword,
|
||||
deleteContainer,
|
||||
resizeContainer,
|
||||
createSnapshot,
|
||||
listSnapshots,
|
||||
rollbackSnapshot,
|
||||
deleteSnapshot
|
||||
} from './proxmoxApi';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Создание сервера (контейнера)
|
||||
export async function createServer(req: Request, res: Response) {
|
||||
try {
|
||||
const { osId, tariffId } = req.body;
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ error: 'Нет авторизации' });
|
||||
|
||||
const os = await prisma.operatingSystem.findUnique({ where: { id: osId } });
|
||||
const tariff = await prisma.tariff.findUnique({ where: { id: tariffId } });
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!os || !tariff || !user) return res.status(400).json({ error: 'Некорректные параметры' });
|
||||
|
||||
// Проверка баланса пользователя
|
||||
if (user.balance < tariff.price) {
|
||||
return res.status(400).json({ error: 'Недостаточно средств на балансе' });
|
||||
}
|
||||
|
||||
// Списываем средства
|
||||
await prisma.user.update({ where: { id: userId }, data: { balance: { decrement: tariff.price } } });
|
||||
|
||||
// Генерация hostname из email
|
||||
let hostname = user.email.split('@')[0];
|
||||
// Нормализуем hostname: убираем недопустимые символы, приводим к нижнему регистру
|
||||
hostname = hostname.replace(/[^a-zA-Z0-9-]/g, '').toLowerCase();
|
||||
// Удалим ведущие и завершающие дефисы
|
||||
hostname = hostname.replace(/^-+|-+$/g, '');
|
||||
if (hostname.length < 3) hostname = `user${userId}`;
|
||||
if (hostname.length > 32) hostname = hostname.slice(0, 32);
|
||||
// Если начинается с цифры или дефиса — префикс
|
||||
if (/^[0-9-]/.test(hostname)) hostname = `u${hostname}`;
|
||||
|
||||
// Создаём контейнер в Proxmox
|
||||
const result = await createLXContainer({
|
||||
os: { template: os.template || '', type: os.type },
|
||||
tariff: { name: tariff.name, price: tariff.price, description: tariff.description || undefined },
|
||||
user: { id: user.id, username: user.username },
|
||||
hostname
|
||||
});
|
||||
if (result.status !== 'success') {
|
||||
// Возвращаем деньги обратно, если не удалось создать
|
||||
await prisma.user.update({ where: { id: userId }, data: { balance: { increment: tariff.price } } });
|
||||
|
||||
// Логируем полный текст ошибки в файл
|
||||
const fs = require('fs');
|
||||
const errorMsg = result.message || JSON.stringify(result);
|
||||
const isSocketError = errorMsg.includes('ECONNRESET') || errorMsg.includes('socket hang up');
|
||||
const logMsg = `[${new Date().toISOString()}] Ошибка Proxmox при создании контейнера (userId=${userId}, hostname=${hostname}, vmid=${result.vmid || 'unknown'}): ${errorMsg}${isSocketError ? ' [SOCKET_ERROR - возможно таймаут]' : ''}\n`;
|
||||
|
||||
fs.appendFile('proxmox-errors.log', logMsg, (err: NodeJS.ErrnoException | null) => {
|
||||
if (err) console.error('Ошибка записи лога:', err);
|
||||
});
|
||||
|
||||
console.error('Ошибка Proxmox при создании контейнера:', result);
|
||||
|
||||
return res.status(500).json({
|
||||
error: 'Ошибка создания сервера в Proxmox',
|
||||
details: isSocketError
|
||||
? 'Сервер Proxmox не ответил вовремя. Пожалуйста, попробуйте позже.'
|
||||
: result.message,
|
||||
fullError: result
|
||||
});
|
||||
}
|
||||
|
||||
// Сохраняем сервер в БД, статус всегда 'running' после покупки
|
||||
// Устанавливаем дату следующего платежа через 30 дней
|
||||
const nextPaymentDate = new Date();
|
||||
nextPaymentDate.setDate(nextPaymentDate.getDate() + 30);
|
||||
|
||||
const server = await prisma.server.create({
|
||||
data: {
|
||||
userId,
|
||||
tariffId,
|
||||
osId,
|
||||
status: 'running',
|
||||
proxmoxId: Number(result.vmid),
|
||||
ipAddress: result.ipAddress,
|
||||
rootPassword: result.rootPassword,
|
||||
nextPaymentDate,
|
||||
autoRenew: true
|
||||
}
|
||||
});
|
||||
|
||||
// Создаём первую транзакцию о покупке
|
||||
await prisma.transaction.create({
|
||||
data: {
|
||||
userId,
|
||||
amount: -tariff.price,
|
||||
type: 'withdrawal',
|
||||
description: `Покупка сервера #${server.id}`,
|
||||
balanceBefore: user.balance,
|
||||
balanceAfter: user.balance - tariff.price
|
||||
}
|
||||
});
|
||||
|
||||
res.json(server);
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка покупки сервера:', error);
|
||||
res.status(500).json({ error: error?.message || 'Ошибка покупки сервера' });
|
||||
}
|
||||
}
|
||||
|
||||
// Получить статус сервера
|
||||
export async function getServerStatus(req: Request, res: Response) {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const server = await prisma.server.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
tariff: true,
|
||||
os: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!server) return res.status(404).json({ error: 'Сервер не найден' });
|
||||
if (!server.proxmoxId) return res.status(400).json({ error: 'Нет VMID Proxmox' });
|
||||
const stats = await getContainerStats(server.proxmoxId);
|
||||
if (stats.status === 'error') {
|
||||
// Если контейнер не найден в Proxmox, возвращаем статус deleted и пустую статистику
|
||||
return res.json({
|
||||
...server,
|
||||
status: 'deleted',
|
||||
stats: {
|
||||
data: {
|
||||
cpu: 0,
|
||||
memory: { usage: 0 }
|
||||
}
|
||||
},
|
||||
error: 'Контейнер не найден в Proxmox',
|
||||
details: stats.message
|
||||
});
|
||||
}
|
||||
res.json({ ...server, stats });
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error?.message || 'Ошибка получения статуса' });
|
||||
}
|
||||
}
|
||||
|
||||
// Запустить сервер
|
||||
export async function startServer(req: Request, res: Response) {
|
||||
await handleControl(req, res, 'start');
|
||||
}
|
||||
// Остановить сервер
|
||||
export async function stopServer(req: Request, res: Response) {
|
||||
await handleControl(req, res, 'stop');
|
||||
}
|
||||
// Перезагрузить сервер
|
||||
export async function restartServer(req: Request, res: Response) {
|
||||
await handleControl(req, res, 'restart');
|
||||
}
|
||||
|
||||
async function handleControl(req: Request, res: Response, action: 'start' | 'stop' | 'restart') {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const server = await prisma.server.findUnique({ where: { id } });
|
||||
if (!server || !server.proxmoxId) return res.status(404).json({ error: 'Сервер не найден или нет VMID' });
|
||||
// Получаем текущий статус VM
|
||||
const stats = await getContainerStats(server.proxmoxId);
|
||||
const currentStatus = stats.status === 'success' && stats.data ? stats.data.status : server.status;
|
||||
// Ограничения на действия
|
||||
if (action === 'start' && currentStatus === 'running') {
|
||||
return res.status(400).json({ error: 'Сервер уже запущен' });
|
||||
}
|
||||
if (action === 'stop' && currentStatus === 'stopped') {
|
||||
return res.status(400).json({ error: 'Сервер уже остановлен' });
|
||||
}
|
||||
// Выполняем действие
|
||||
const result = await controlContainer(server.proxmoxId, action);
|
||||
// Polling статуса VM после управления
|
||||
let newStatus = server.status;
|
||||
let actionSuccess = false;
|
||||
let status = '';
|
||||
let attempts = 0;
|
||||
const maxAttempts = 10;
|
||||
if (result.status === 'success') {
|
||||
while (attempts < maxAttempts) {
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
const stats = await getContainerStats(server.proxmoxId);
|
||||
if (stats.status === 'success' && stats.data) {
|
||||
status = stats.data.status;
|
||||
if ((action === 'start' && status === 'running') ||
|
||||
(action === 'stop' && status === 'stopped') ||
|
||||
(action === 'restart' && status === 'running')) {
|
||||
actionSuccess = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
attempts++;
|
||||
}
|
||||
switch (status) {
|
||||
case 'running':
|
||||
newStatus = 'running';
|
||||
break;
|
||||
case 'stopped':
|
||||
newStatus = 'stopped';
|
||||
break;
|
||||
case 'suspended':
|
||||
newStatus = 'suspended';
|
||||
break;
|
||||
default:
|
||||
newStatus = status || server.status;
|
||||
}
|
||||
await prisma.server.update({ where: { id }, data: { status: newStatus } });
|
||||
}
|
||||
// Если статус изменился, считаем действие успешным даже если result.status !== 'success'
|
||||
if (newStatus !== server.status) {
|
||||
return res.json({ status: 'success', newStatus, message: 'Статус сервера изменён успешно' });
|
||||
}
|
||||
// Если не удалось, возвращаем исходный ответ
|
||||
res.json({ ...result, status: newStatus });
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error?.message || 'Ошибка управления сервером' });
|
||||
}
|
||||
}
|
||||
|
||||
// Удалить сервер
|
||||
export async function deleteServer(req: Request, res: Response) {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const server = await prisma.server.findUnique({ where: { id } });
|
||||
if (!server || !server.proxmoxId) return res.status(404).json({ error: 'Сервер не найден или нет VMID' });
|
||||
|
||||
// Удаляем контейнер в Proxmox
|
||||
const proxmoxResult = await deleteContainer(server.proxmoxId);
|
||||
if (proxmoxResult.status !== 'success') {
|
||||
return res.status(500).json({ error: 'Ошибка удаления контейнера в Proxmox', details: proxmoxResult });
|
||||
}
|
||||
|
||||
await prisma.server.delete({ where: { id } });
|
||||
res.json({ status: 'deleted' });
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error?.message || 'Ошибка удаления сервера' });
|
||||
}
|
||||
}
|
||||
|
||||
// Сменить root-пароль
|
||||
export async function changeRootPassword(req: Request, res: Response) {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const server = await prisma.server.findUnique({ where: { id } });
|
||||
if (!server || !server.proxmoxId) return res.status(404).json({ error: 'Сервер не найден или нет VMID' });
|
||||
// Используем SSH для смены пароля
|
||||
const { changeRootPasswordSSH } = require('./proxmoxApi');
|
||||
const result = await changeRootPasswordSSH(server.proxmoxId);
|
||||
if (result?.status === 'success' && result.password) {
|
||||
await prisma.server.update({ where: { id }, data: { rootPassword: result.password } });
|
||||
}
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error?.message || 'Ошибка смены пароля' });
|
||||
}
|
||||
}
|
||||
|
||||
// Изменить конфигурацию сервера
|
||||
export async function resizeServer(req: Request, res: Response) {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const { cores, memory, disk } = req.body;
|
||||
const server = await prisma.server.findUnique({ where: { id } });
|
||||
if (!server || !server.proxmoxId) return res.status(404).json({ error: 'Сервер не найден или нет VMID' });
|
||||
|
||||
const config: any = {};
|
||||
if (cores) config.cores = Number(cores);
|
||||
if (memory) config.memory = Number(memory);
|
||||
if (disk) {
|
||||
const vmStorage = process.env.PROXMOX_VM_STORAGE || 'local';
|
||||
config.rootfs = `${vmStorage}:${Number(disk)}`;
|
||||
}
|
||||
|
||||
const result = await resizeContainer(server.proxmoxId, config);
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error?.message || 'Ошибка изменения конфигурации' });
|
||||
}
|
||||
}
|
||||
|
||||
// Создать снэпшот
|
||||
export async function createServerSnapshot(req: Request, res: Response) {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const { snapname, description } = req.body;
|
||||
if (!snapname) return res.status(400).json({ error: 'Не указано имя снэпшота' });
|
||||
|
||||
const server = await prisma.server.findUnique({ where: { id } });
|
||||
if (!server || !server.proxmoxId) return res.status(404).json({ error: 'Сервер не найден или нет VMID' });
|
||||
|
||||
const result = await createSnapshot(server.proxmoxId, snapname, description);
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error?.message || 'Ошибка создания снэпшота' });
|
||||
}
|
||||
}
|
||||
|
||||
// Получить список снэпшотов
|
||||
export async function getServerSnapshots(req: Request, res: Response) {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const server = await prisma.server.findUnique({ where: { id } });
|
||||
if (!server || !server.proxmoxId) return res.status(404).json({ error: 'Сервер не найден или нет VMID' });
|
||||
|
||||
const result = await listSnapshots(server.proxmoxId);
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error?.message || 'Ошибка получения снэпшотов' });
|
||||
}
|
||||
}
|
||||
|
||||
// Восстановить из снэпшота
|
||||
export async function rollbackServerSnapshot(req: Request, res: Response) {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const { snapname } = req.body;
|
||||
if (!snapname) return res.status(400).json({ error: 'Не указано имя снэпшота' });
|
||||
|
||||
const server = await prisma.server.findUnique({ where: { id } });
|
||||
if (!server || !server.proxmoxId) return res.status(404).json({ error: 'Сервер не найден или нет VMID' });
|
||||
|
||||
const result = await rollbackSnapshot(server.proxmoxId, snapname);
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error?.message || 'Ошибка восстановления снэпшота' });
|
||||
}
|
||||
}
|
||||
|
||||
// Удалить снэпшот
|
||||
export async function deleteServerSnapshot(req: Request, res: Response) {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const { snapname } = req.body;
|
||||
if (!snapname) return res.status(400).json({ error: 'Не указано имя снэпшота' });
|
||||
|
||||
const server = await prisma.server.findUnique({ where: { id } });
|
||||
if (!server || !server.proxmoxId) return res.status(404).json({ error: 'Сервер не найден или нет VMID' });
|
||||
|
||||
const result = await deleteSnapshot(server.proxmoxId, snapname);
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error?.message || 'Ошибка удаления снэпшота' });
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const PROXMOX_API_URL = process.env.PROXMOX_API_URL;
|
||||
const PROXMOX_TOKEN_ID = process.env.PROXMOX_TOKEN_ID;
|
||||
const PROXMOX_TOKEN_SECRET = process.env.PROXMOX_TOKEN_SECRET;
|
||||
const PROXMOX_NODE = process.env.PROXMOX_NODE || 'proxmox';
|
||||
|
||||
function getProxmoxHeaders(): Record<string, string> {
|
||||
return {
|
||||
'Authorization': `PVEAPIToken=${PROXMOX_TOKEN_ID}=${PROXMOX_TOKEN_SECRET}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение логов контейнера LXC
|
||||
* @param vmid - ID контейнера
|
||||
* @param lines - количество строк логов (по умолчанию 100)
|
||||
* @returns объект с логами или ошибкой
|
||||
*/
|
||||
export async function getContainerLogs(vmid: number, lines: number = 100) {
|
||||
try {
|
||||
// Получаем логи через Proxmox API
|
||||
// Используем журнал systemd для LXC контейнеров
|
||||
const response = await axios.get(
|
||||
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/log?limit=${lines}`,
|
||||
{ headers: getProxmoxHeaders() }
|
||||
);
|
||||
|
||||
const logs = response.data?.data || [];
|
||||
|
||||
// Форматируем логи для удобного отображения
|
||||
const formattedLogs = logs.map((log: { n: number; t: string }) => ({
|
||||
line: log.n,
|
||||
text: log.t,
|
||||
timestamp: new Date().toISOString() // Proxmox не всегда возвращает timestamp
|
||||
}));
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
logs: formattedLogs,
|
||||
total: formattedLogs.length
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка получения логов контейнера:', error);
|
||||
|
||||
// Если API не поддерживает /log, пробуем альтернативный способ
|
||||
if (error.response?.status === 400 || error.response?.status === 501) {
|
||||
return getContainerSystemLogs(vmid, lines);
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'error',
|
||||
message: error.response?.data?.errors || error.message,
|
||||
logs: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Альтернативный метод получения логов через exec команды
|
||||
*/
|
||||
async function getContainerSystemLogs(vmid: number, lines: number = 100) {
|
||||
try {
|
||||
// Выполняем команду для получения логов из контейнера
|
||||
const response = await axios.post(
|
||||
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/status/exec`,
|
||||
{
|
||||
command: `/bin/journalctl -n ${lines} --no-pager || tail -n ${lines} /var/log/syslog || echo "Логи недоступны"`
|
||||
},
|
||||
{ headers: getProxmoxHeaders() }
|
||||
);
|
||||
|
||||
// Получаем результат выполнения команды
|
||||
if (response.data?.data) {
|
||||
const taskId = response.data.data;
|
||||
|
||||
// Ждем завершения задачи и получаем вывод
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
const outputResponse = await axios.get(
|
||||
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/tasks/${taskId}/log`,
|
||||
{ headers: getProxmoxHeaders() }
|
||||
);
|
||||
|
||||
const output = outputResponse.data?.data || [];
|
||||
const formattedLogs = output.map((log: { n: number; t: string }, index: number) => ({
|
||||
line: index + 1,
|
||||
text: log.t || log,
|
||||
timestamp: new Date().toISOString()
|
||||
}));
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
logs: formattedLogs,
|
||||
total: formattedLogs.length
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'error',
|
||||
message: 'Не удалось получить логи',
|
||||
logs: []
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка получения системных логов:', error);
|
||||
return {
|
||||
status: 'error',
|
||||
message: error.response?.data?.errors || error.message,
|
||||
logs: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение последних действий/событий контейнера
|
||||
*/
|
||||
export async function getContainerEvents(vmid: number) {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${PROXMOX_API_URL}/cluster/tasks?vmid=${vmid}`,
|
||||
{ headers: getProxmoxHeaders() }
|
||||
);
|
||||
|
||||
const tasks = response.data?.data || [];
|
||||
|
||||
// Форматируем события
|
||||
const events = tasks.slice(0, 50).map((task: any) => ({
|
||||
type: task.type,
|
||||
status: task.status,
|
||||
starttime: new Date(task.starttime * 1000).toLocaleString(),
|
||||
endtime: task.endtime ? new Date(task.endtime * 1000).toLocaleString() : 'В процессе',
|
||||
user: task.user,
|
||||
node: task.node,
|
||||
id: task.upid
|
||||
}));
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
events
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка получения событий контейнера:', error);
|
||||
return {
|
||||
status: 'error',
|
||||
message: error.response?.data?.errors || error.message,
|
||||
events: []
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import { Router } from 'express';
|
||||
import { authMiddleware } from '../auth/auth.middleware';
|
||||
import {
|
||||
createServer,
|
||||
startServer,
|
||||
stopServer,
|
||||
restartServer,
|
||||
getServerStatus,
|
||||
deleteServer,
|
||||
changeRootPassword,
|
||||
resizeServer,
|
||||
createServerSnapshot,
|
||||
getServerSnapshots,
|
||||
rollbackServerSnapshot,
|
||||
deleteServerSnapshot
|
||||
} from './server.controller';
|
||||
import { getStorageConfig, getNodeStorages, checkProxmoxConnection } from './proxmoxApi';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const router = Router();
|
||||
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
|
||||
|
||||
// Получить список всех серверов (для фронта)
|
||||
router.get('/', async (req, res) => {
|
||||
const userId = req.user?.id;
|
||||
// Если нужен только свои сервера:
|
||||
const where = userId ? { userId } : {};
|
||||
const servers = await prisma.server.findMany({
|
||||
where,
|
||||
include: {
|
||||
os: true,
|
||||
tariff: true
|
||||
}
|
||||
});
|
||||
res.json(servers);
|
||||
});
|
||||
|
||||
// Получить информацию о сервере (для фронта)
|
||||
router.get('/:id', async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const server = await prisma.server.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
os: true,
|
||||
tariff: true
|
||||
}
|
||||
});
|
||||
if (!server) return res.status(404).json({ error: 'Сервер не найден' });
|
||||
res.json(server);
|
||||
});
|
||||
|
||||
|
||||
// Получить статистику сервера (CPU, RAM и т.д.)
|
||||
router.get('/:id/status', getServerStatus);
|
||||
|
||||
// Получить ссылку на noVNC консоль
|
||||
import { getConsoleURL } from './proxmoxApi';
|
||||
router.post('/console', async (req, res) => {
|
||||
const { vmid } = req.body;
|
||||
if (!vmid) return res.status(400).json({ status: 'error', message: 'Не указан VMID' });
|
||||
try {
|
||||
const result = await getConsoleURL(Number(vmid));
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ status: 'error', message: error?.message || 'Ошибка получения консоли' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/create', createServer);
|
||||
router.post('/:id/start', startServer);
|
||||
router.post('/:id/stop', stopServer);
|
||||
router.post('/:id/restart', restartServer);
|
||||
router.delete('/:id', deleteServer);
|
||||
router.post('/:id/password', changeRootPassword);
|
||||
|
||||
// Новые маршруты для управления конфигурацией и снэпшотами
|
||||
router.put('/:id/resize', resizeServer);
|
||||
router.post('/:id/snapshots', createServerSnapshot);
|
||||
router.get('/:id/snapshots', getServerSnapshots);
|
||||
router.post('/:id/snapshots/rollback', rollbackServerSnapshot);
|
||||
router.delete('/:id/snapshots', deleteServerSnapshot);
|
||||
import { getContainerStats } from './proxmoxApi';
|
||||
import { getContainerLogs, getContainerEvents } from './server.logs';
|
||||
|
||||
// Диагностика: проверить конфигурацию storage
|
||||
router.get('/admin/diagnostic/storage', async (req, res) => {
|
||||
try {
|
||||
const storageConfig = await getStorageConfig();
|
||||
|
||||
res.json({
|
||||
configured_storage: storageConfig.configured,
|
||||
note: storageConfig.note,
|
||||
instruction: 'Если ошибка socket hang up, проверьте что PROXMOX_VM_STORAGE установлен правильно в .env'
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Диагностика: проверить соединение с Proxmox
|
||||
router.get('/admin/diagnostic/proxmox', async (req, res) => {
|
||||
try {
|
||||
const connectionStatus = await checkProxmoxConnection();
|
||||
const storages = await getNodeStorages();
|
||||
|
||||
res.json({
|
||||
proxmox_connection: connectionStatus,
|
||||
available_storages: storages.data || [],
|
||||
current_storage_config: process.env.PROXMOX_VM_STORAGE || 'не установлена',
|
||||
note: 'Если ошибка в available_storages, проверьте права API токена'
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Получить графики нагрузок сервера (CPU, RAM, сеть)
|
||||
router.get('/:id/stats', async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
// Проверка прав пользователя (только свои сервера)
|
||||
const userId = req.user?.id;
|
||||
const server = await prisma.server.findUnique({ where: { id } });
|
||||
if (!server || server.userId !== userId) {
|
||||
return res.status(404).json({ error: 'Сервер не найден или нет доступа' });
|
||||
}
|
||||
try {
|
||||
if (!server.proxmoxId && server.proxmoxId !== 0) {
|
||||
return res.status(400).json({ error: 'proxmoxId не задан для сервера' });
|
||||
}
|
||||
const stats = await getContainerStats(Number(server.proxmoxId));
|
||||
res.json(stats);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Ошибка получения статистики', details: err });
|
||||
}
|
||||
});
|
||||
|
||||
// Получить логи сервера
|
||||
router.get('/:id/logs', async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const lines = req.query.lines ? Number(req.query.lines) : 100;
|
||||
|
||||
const userId = req.user?.id;
|
||||
const server = await prisma.server.findUnique({ where: { id } });
|
||||
if (!server || server.userId !== userId) {
|
||||
return res.status(404).json({ error: 'Сервер не найден или нет доступа' });
|
||||
}
|
||||
|
||||
try {
|
||||
if (!server.proxmoxId && server.proxmoxId !== 0) {
|
||||
return res.status(400).json({ error: 'proxmoxId не задан для сервера' });
|
||||
}
|
||||
const logs = await getContainerLogs(Number(server.proxmoxId), lines);
|
||||
res.json(logs);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Ошибка получения логов', details: err });
|
||||
}
|
||||
});
|
||||
|
||||
// Получить события/историю действий сервера
|
||||
router.get('/:id/events', async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
|
||||
const userId = req.user?.id;
|
||||
const server = await prisma.server.findUnique({ where: { id } });
|
||||
if (!server || server.userId !== userId) {
|
||||
return res.status(404).json({ error: 'Сервер не найден или нет доступа' });
|
||||
}
|
||||
|
||||
try {
|
||||
if (!server.proxmoxId && server.proxmoxId !== 0) {
|
||||
return res.status(400).json({ error: 'proxmoxId не задан для сервера' });
|
||||
}
|
||||
const events = await getContainerEvents(Number(server.proxmoxId));
|
||||
res.json(events);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Ошибка получения событий', details: err });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
249
ospabhost/backend/src/modules/session/session.controller.ts
Normal file
249
ospabhost/backend/src/modules/session/session.controller.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { prisma } from '../../prisma/client';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
|
||||
|
||||
// Получить IP адрес из запроса
|
||||
function getClientIP(req: Request): string {
|
||||
const forwarded = req.headers['x-forwarded-for'];
|
||||
if (typeof forwarded === 'string') {
|
||||
return forwarded.split(',')[0].trim();
|
||||
}
|
||||
return req.socket.remoteAddress || 'Unknown';
|
||||
}
|
||||
|
||||
// Парсинг User-Agent (упрощённый)
|
||||
function parseUserAgent(userAgent: string) {
|
||||
let device = 'Desktop';
|
||||
let browser = 'Unknown';
|
||||
|
||||
// Определяем устройство
|
||||
if (/mobile/i.test(userAgent)) {
|
||||
device = 'Mobile';
|
||||
} else if (/tablet|ipad/i.test(userAgent)) {
|
||||
device = 'Tablet';
|
||||
}
|
||||
|
||||
// Определяем браузер
|
||||
if (userAgent.includes('Chrome')) {
|
||||
browser = 'Chrome';
|
||||
} else if (userAgent.includes('Firefox')) {
|
||||
browser = 'Firefox';
|
||||
} else if (userAgent.includes('Safari') && !userAgent.includes('Chrome')) {
|
||||
browser = 'Safari';
|
||||
} else if (userAgent.includes('Edge')) {
|
||||
browser = 'Edge';
|
||||
} else if (userAgent.includes('Opera') || userAgent.includes('OPR')) {
|
||||
browser = 'Opera';
|
||||
}
|
||||
|
||||
return {
|
||||
device,
|
||||
browser,
|
||||
browserVersion: '',
|
||||
os: 'Unknown',
|
||||
osVersion: ''
|
||||
};
|
||||
}
|
||||
|
||||
// Получить примерную локацию по IP (заглушка, нужен сервис геолокации)
|
||||
async function getLocationByIP(ip: string): Promise<string> {
|
||||
// TODO: Интеграция с ipapi.co, ip-api.com или другим сервисом
|
||||
// Пока возвращаем заглушку
|
||||
return 'Россия, Москва';
|
||||
}
|
||||
|
||||
// Получить все активные сессии пользователя
|
||||
export async function getUserSessions(req: Request, res: Response) {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Не авторизован' });
|
||||
}
|
||||
|
||||
const currentToken = req.headers.authorization?.replace('Bearer ', '');
|
||||
|
||||
const sessions = await prisma.session.findMany({
|
||||
where: {
|
||||
userId,
|
||||
expiresAt: { gte: new Date() }
|
||||
},
|
||||
orderBy: { lastActivity: 'desc' }
|
||||
});
|
||||
|
||||
const sessionsWithCurrent = sessions.map(session => ({
|
||||
...session,
|
||||
isCurrent: session.token === currentToken,
|
||||
token: undefined // Не отдаём токен клиенту
|
||||
}));
|
||||
|
||||
res.json(sessionsWithCurrent);
|
||||
} catch (error) {
|
||||
console.error('Ошибка получения сессий:', error);
|
||||
res.status(500).json({ error: 'Ошибка получения сессий' });
|
||||
}
|
||||
}
|
||||
|
||||
// Удалить конкретную сессию
|
||||
export async function deleteSession(req: Request, res: Response) {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
const sessionId = Number(req.params.id);
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Не авторизован' });
|
||||
}
|
||||
|
||||
// Проверяем, что сессия принадлежит пользователю
|
||||
const session = await prisma.session.findFirst({
|
||||
where: { id: sessionId, userId }
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Сессия не найдена' });
|
||||
}
|
||||
|
||||
await prisma.session.delete({ where: { id: sessionId } });
|
||||
|
||||
res.json({ message: 'Сессия удалена' });
|
||||
} catch (error) {
|
||||
console.error('Ошибка удаления сессии:', error);
|
||||
res.status(500).json({ error: 'Ошибка удаления сессии' });
|
||||
}
|
||||
}
|
||||
|
||||
// Удалить все сессии кроме текущей
|
||||
export async function deleteAllOtherSessions(req: Request, res: Response) {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
const currentToken = req.headers.authorization?.replace('Bearer ', '');
|
||||
|
||||
if (!userId || !currentToken) {
|
||||
return res.status(401).json({ error: 'Не авторизован' });
|
||||
}
|
||||
|
||||
const result = await prisma.session.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
token: { not: currentToken }
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Все остальные сессии удалены',
|
||||
deletedCount: result.count
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Ошибка удаления сессий:', error);
|
||||
res.status(500).json({ error: 'Ошибка удаления сессий' });
|
||||
}
|
||||
}
|
||||
|
||||
// Создать новую сессию при логине
|
||||
export async function createSession(
|
||||
userId: number,
|
||||
req: Request,
|
||||
expiresInDays: number = 30
|
||||
): Promise<{ token: string; session: any }> {
|
||||
const token = jwt.sign({ userId }, JWT_SECRET, { expiresIn: `${expiresInDays}d` });
|
||||
|
||||
const ipAddress = getClientIP(req);
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
const parsed = parseUserAgent(userAgent);
|
||||
const location = await getLocationByIP(ipAddress);
|
||||
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 30); // 30 дней
|
||||
|
||||
// Ограничиваем количество сессий до 10
|
||||
const sessionCount = await prisma.session.count({ where: { userId } });
|
||||
if (sessionCount >= 10) {
|
||||
// Удаляем самую старую сессию
|
||||
const oldestSession = await prisma.session.findFirst({
|
||||
where: { userId },
|
||||
orderBy: { lastActivity: 'asc' }
|
||||
});
|
||||
if (oldestSession) {
|
||||
await prisma.session.delete({ where: { id: oldestSession.id } });
|
||||
}
|
||||
}
|
||||
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId,
|
||||
token,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
device: parsed.device,
|
||||
browser: `${parsed.browser} ${parsed.browserVersion}`.trim(),
|
||||
location,
|
||||
expiresAt,
|
||||
lastActivity: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
// Записываем в историю входов
|
||||
await prisma.loginHistory.create({
|
||||
data: {
|
||||
userId,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
device: parsed.device,
|
||||
browser: `${parsed.browser} ${parsed.browserVersion}`.trim(),
|
||||
location,
|
||||
success: true
|
||||
}
|
||||
});
|
||||
|
||||
return { token, session };
|
||||
}
|
||||
|
||||
// Обновить время последней активности сессии
|
||||
export async function updateSessionActivity(token: string) {
|
||||
try {
|
||||
await prisma.session.updateMany({
|
||||
where: { token },
|
||||
data: { lastActivity: new Date() }
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Ошибка обновления активности сессии:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Получить историю входов
|
||||
export async function getLoginHistory(req: Request, res: Response) {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Не авторизован' });
|
||||
}
|
||||
|
||||
const limit = Number(req.query.limit) || 20;
|
||||
const history = await prisma.loginHistory.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit
|
||||
});
|
||||
|
||||
res.json(history);
|
||||
} catch (error) {
|
||||
logger.error('Ошибка получения истории входов:', error);
|
||||
res.status(500).json({ error: 'Ошибка получения истории входов' });
|
||||
}
|
||||
}
|
||||
|
||||
// Очистить устаревшие сессии (запускать периодически)
|
||||
export async function cleanupExpiredSessions() {
|
||||
try {
|
||||
const result = await prisma.session.deleteMany({
|
||||
where: {
|
||||
expiresAt: { lt: new Date() }
|
||||
}
|
||||
});
|
||||
logger.info(`[Session Cleanup] Удалено ${result.count} устаревших сессий`);
|
||||
} catch (error) {
|
||||
logger.error('[Session Cleanup] Ошибка:', error);
|
||||
}
|
||||
}
|
||||
27
ospabhost/backend/src/modules/session/session.routes.ts
Normal file
27
ospabhost/backend/src/modules/session/session.routes.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
getUserSessions,
|
||||
deleteSession,
|
||||
deleteAllOtherSessions,
|
||||
getLoginHistory
|
||||
} from './session.controller';
|
||||
import { authMiddleware } from '../auth/auth.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Все роуты требуют аутентификации
|
||||
router.use(authMiddleware);
|
||||
|
||||
// Получить все активные сессии
|
||||
router.get('/', getUserSessions);
|
||||
|
||||
// Получить историю входов
|
||||
router.get('/history', getLoginHistory);
|
||||
|
||||
// Удалить конкретную сессию
|
||||
router.delete('/:id', deleteSession);
|
||||
|
||||
// Удалить все сессии кроме текущей
|
||||
router.delete('/others/all', deleteAllOtherSessions);
|
||||
|
||||
export default router;
|
||||
@@ -27,7 +27,7 @@ export async function generateSitemap(req: Request, res: Response) {
|
||||
// lastmod: post.updatedAt.toISOString().split('T')[0]
|
||||
// }));
|
||||
} catch (error) {
|
||||
console.log('Блог пока не активирован');
|
||||
// Блог пока не активирован
|
||||
}
|
||||
|
||||
const allPages = [...staticPages, ...dynamicPages];
|
||||
|
||||
42
ospabhost/backend/src/modules/storage/minioClient.ts
Normal file
42
ospabhost/backend/src/modules/storage/minioClient.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Client } from 'minio';
|
||||
|
||||
// Инициализация MinIO клиента через переменные окружения
|
||||
// Добавьте в .env:
|
||||
// MINIO_ENDPOINT=localhost
|
||||
// MINIO_PORT=9000
|
||||
// MINIO_USE_SSL=false
|
||||
// MINIO_ACCESS_KEY=your_access_key
|
||||
// MINIO_SECRET_KEY=your_secret_key
|
||||
// MINIO_BUCKET_PREFIX=ospab
|
||||
|
||||
const {
|
||||
MINIO_ENDPOINT,
|
||||
MINIO_PORT,
|
||||
MINIO_USE_SSL,
|
||||
MINIO_ACCESS_KEY,
|
||||
MINIO_SECRET_KEY
|
||||
} = process.env;
|
||||
|
||||
export const minioClient = new Client({
|
||||
endPoint: MINIO_ENDPOINT || 'localhost',
|
||||
port: MINIO_PORT ? parseInt(MINIO_PORT, 10) : 9000,
|
||||
useSSL: (MINIO_USE_SSL || 'false') === 'true',
|
||||
accessKey: MINIO_ACCESS_KEY || 'minioadmin',
|
||||
secretKey: MINIO_SECRET_KEY || 'minioadmin'
|
||||
});
|
||||
|
||||
export function buildPhysicalBucketName(userId: number, logicalName: string): string {
|
||||
const prefix = process.env.MINIO_BUCKET_PREFIX || 'ospab';
|
||||
return `${prefix}-${userId}-${logicalName}`.toLowerCase();
|
||||
}
|
||||
|
||||
export async function ensureBucketExists(bucketName: string, region: string): Promise<void> {
|
||||
try {
|
||||
const exists = await minioClient.bucketExists(bucketName);
|
||||
if (!exists) {
|
||||
await minioClient.makeBucket(bucketName, region);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
216
ospabhost/backend/src/modules/storage/storage.routes.ts
Normal file
216
ospabhost/backend/src/modules/storage/storage.routes.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
createBucket,
|
||||
listBuckets,
|
||||
getBucket,
|
||||
deleteBucket,
|
||||
updateBucketSettings,
|
||||
listBucketObjects,
|
||||
createPresignedUrl,
|
||||
deleteObjects,
|
||||
createEphemeralKey,
|
||||
listAccessKeys,
|
||||
revokeAccessKey
|
||||
} from './storage.service';
|
||||
import { authMiddleware } from '../auth/auth.middleware';
|
||||
|
||||
// Предполагается, что аутентификация уже навешена на /api/storage через глобальный middleware (passport + JWT)
|
||||
// Здесь используем req.user?.id (нужно убедиться что в auth модуле добавляется user в req)
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Монтируем JWT-мидлвар на модуль, чтобы req.user всегда был установлен
|
||||
router.use(authMiddleware);
|
||||
|
||||
// Создание бакета
|
||||
router.post('/buckets', async (req, res) => {
|
||||
try {
|
||||
const userId = (req as any).user?.id;
|
||||
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
|
||||
|
||||
const { name, plan, quotaGb, region, storageClass, public: isPublic, versioning } = req.body;
|
||||
if (!name || !plan || !quotaGb) return res.status(400).json({ error: 'name, plan, quotaGb обязательны' });
|
||||
|
||||
// Временное определение цены (можно заменить запросом к таблице s3_plan)
|
||||
const PRICE_MAP: Record<string, number> = { basic: 99, standard: 199, plus: 399, pro: 699, enterprise: 1999 };
|
||||
const price = PRICE_MAP[plan] || 0;
|
||||
|
||||
const bucket = await createBucket({
|
||||
userId,
|
||||
name,
|
||||
plan,
|
||||
quotaGb: Number(quotaGb),
|
||||
region: region || 'ru-central-1',
|
||||
storageClass: storageClass || 'standard',
|
||||
public: !!isPublic,
|
||||
versioning: !!versioning,
|
||||
price
|
||||
});
|
||||
|
||||
return res.json({ bucket });
|
||||
} catch (e: unknown) {
|
||||
let message = 'Ошибка создания бакета';
|
||||
if (e instanceof Error) message = e.message;
|
||||
return res.status(400).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Список бакетов пользователя
|
||||
router.get('/buckets', async (req, res) => {
|
||||
try {
|
||||
const userId = (req as any).user?.id;
|
||||
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
|
||||
const buckets = await listBuckets(userId);
|
||||
return res.json({ buckets });
|
||||
} catch (e: unknown) {
|
||||
return res.status(500).json({ error: 'Ошибка получения списка бакетов' });
|
||||
}
|
||||
});
|
||||
|
||||
// Детали одного бакета
|
||||
router.get('/buckets/:id', 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);
|
||||
const bucket = await getBucket(userId, id);
|
||||
return res.json({ bucket });
|
||||
} catch (e: unknown) {
|
||||
let message = 'Ошибка получения бакета';
|
||||
if (e instanceof Error) message = e.message;
|
||||
return res.status(404).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Обновление настроек бакета
|
||||
router.patch('/buckets/:id', 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);
|
||||
const bucket = await updateBucketSettings(userId, id, req.body ?? {});
|
||||
return res.json({ bucket });
|
||||
} catch (e: unknown) {
|
||||
let message = 'Ошибка обновления бакета';
|
||||
if (e instanceof Error) message = e.message;
|
||||
return res.status(400).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Удаление бакета
|
||||
router.delete('/buckets/:id', 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);
|
||||
const force = req.query.force === 'true';
|
||||
const bucket = await deleteBucket(userId, id, force);
|
||||
return res.json({ bucket });
|
||||
} catch (e: unknown) {
|
||||
let message = 'Ошибка удаления бакета';
|
||||
if (e instanceof Error) message = e.message;
|
||||
return res.status(400).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Список объектов в бакете
|
||||
router.get('/buckets/:id/objects', 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);
|
||||
const { prefix, cursor, limit } = req.query;
|
||||
const result = await listBucketObjects(userId, id, {
|
||||
prefix: typeof prefix === 'string' ? prefix : undefined,
|
||||
cursor: typeof cursor === 'string' ? cursor : undefined,
|
||||
limit: limit ? Number(limit) : undefined
|
||||
});
|
||||
return res.json(result);
|
||||
} catch (e: unknown) {
|
||||
let message = 'Ошибка получения списка объектов';
|
||||
if (e instanceof Error) message = e.message;
|
||||
return res.status(400).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Пресайн URL для загрузки/скачивания
|
||||
router.post('/buckets/:id/objects/presign', 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);
|
||||
const { key, method, expiresIn, contentType } = req.body ?? {};
|
||||
if (!key) return res.status(400).json({ error: 'Не указан key объекта' });
|
||||
|
||||
const result = await createPresignedUrl(userId, id, key, { method, expiresIn, contentType });
|
||||
return res.json(result);
|
||||
} catch (e: unknown) {
|
||||
let message = 'Ошибка генерации ссылки';
|
||||
if (e instanceof Error) message = e.message;
|
||||
return res.status(400).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Удаление объектов
|
||||
router.delete('/buckets/:id/objects', 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);
|
||||
const { keys } = req.body ?? {};
|
||||
if (!Array.isArray(keys)) return res.status(400).json({ error: 'keys должен быть массивом' });
|
||||
const result = await deleteObjects(userId, id, keys);
|
||||
return res.json(result);
|
||||
} catch (e: unknown) {
|
||||
let message = 'Ошибка удаления объектов';
|
||||
if (e instanceof Error) message = e.message;
|
||||
return res.status(400).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Управление access keys
|
||||
router.get('/buckets/:id/access-keys', 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);
|
||||
const keys = await listAccessKeys(userId, id);
|
||||
return res.json({ keys });
|
||||
} catch (e: unknown) {
|
||||
let message = 'Ошибка получения ключей';
|
||||
if (e instanceof Error) message = e.message;
|
||||
return res.status(400).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/buckets/:id/access-keys', 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);
|
||||
const { label } = req.body ?? {};
|
||||
const key = await createEphemeralKey(userId, id, label);
|
||||
return res.json({ key });
|
||||
} catch (e: unknown) {
|
||||
let message = 'Ошибка создания ключа';
|
||||
if (e instanceof Error) message = e.message;
|
||||
return res.status(400).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/buckets/:id/access-keys/:keyId', 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);
|
||||
const keyId = Number(req.params.keyId);
|
||||
const result = await revokeAccessKey(userId, id, keyId);
|
||||
return res.json(result);
|
||||
} catch (e: unknown) {
|
||||
let message = 'Ошибка удаления ключа';
|
||||
if (e instanceof Error) message = e.message;
|
||||
return res.status(400).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
480
ospabhost/backend/src/modules/storage/storage.service.ts
Normal file
480
ospabhost/backend/src/modules/storage/storage.service.ts
Normal file
@@ -0,0 +1,480 @@
|
||||
import crypto from 'crypto';
|
||||
import type { StorageBucket } from '@prisma/client';
|
||||
|
||||
import { prisma } from '../../prisma/client';
|
||||
import { ensureBucketExists, buildPhysicalBucketName, minioClient } from './minioClient';
|
||||
import { createNotification } from '../notification/notification.controller';
|
||||
|
||||
interface CreateBucketInput {
|
||||
userId: number;
|
||||
name: string;
|
||||
plan: string;
|
||||
quotaGb: number;
|
||||
region: string;
|
||||
storageClass: string;
|
||||
public: boolean;
|
||||
versioning: boolean;
|
||||
price: number; // ежемесячная стоимость плана для списания
|
||||
}
|
||||
|
||||
interface UpdateBucketInput {
|
||||
public?: boolean;
|
||||
versioning?: boolean;
|
||||
autoRenew?: boolean;
|
||||
storageClass?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface ListObjectsOptions {
|
||||
prefix?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
interface PresignOptions {
|
||||
method?: 'PUT' | 'GET';
|
||||
expiresIn?: number;
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
const BILLING_INTERVAL_DAYS = 30;
|
||||
const USAGE_REFRESH_INTERVAL_MINUTES = 5;
|
||||
const PRESIGN_DEFAULT_TTL = 15 * 60; // 15 минут
|
||||
|
||||
function addDays(date: Date, days: number): Date {
|
||||
const clone = new Date(date);
|
||||
clone.setDate(clone.getDate() + days);
|
||||
return clone;
|
||||
}
|
||||
|
||||
function toNumber(value: bigint | number | null | undefined): number {
|
||||
if (typeof value === 'bigint') {
|
||||
return Number(value);
|
||||
}
|
||||
return value ?? 0;
|
||||
}
|
||||
|
||||
function serializeBucket(bucket: StorageBucket) {
|
||||
return {
|
||||
...bucket,
|
||||
usedBytes: toNumber(bucket.usedBytes),
|
||||
monthlyPrice: Number(bucket.monthlyPrice),
|
||||
nextBillingDate: bucket.nextBillingDate?.toISOString() ?? null,
|
||||
lastBilledAt: bucket.lastBilledAt?.toISOString() ?? null,
|
||||
usageSyncedAt: bucket.usageSyncedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function needsUsageRefresh(bucket: StorageBucket): boolean {
|
||||
if (!bucket.usageSyncedAt) return true;
|
||||
const diffMs = Date.now() - bucket.usageSyncedAt.getTime();
|
||||
return diffMs > USAGE_REFRESH_INTERVAL_MINUTES * 60 * 1000;
|
||||
}
|
||||
|
||||
async function calculateBucketUsage(physicalName: string): Promise<{ totalBytes: bigint; objectCount: number; }>
|
||||
{
|
||||
return await new Promise((resolve, reject) => {
|
||||
let bytes = BigInt(0);
|
||||
let count = 0;
|
||||
const stream = minioClient.listObjectsV2(physicalName, '', true);
|
||||
|
||||
stream.on('data', (obj) => {
|
||||
if (obj?.name) {
|
||||
count += 1;
|
||||
const size = typeof obj.size === 'number' ? obj.size : 0;
|
||||
bytes += BigInt(size);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('error', (err) => reject(err));
|
||||
stream.on('end', () => resolve({ totalBytes: bytes, objectCount: count }));
|
||||
});
|
||||
}
|
||||
|
||||
async function syncBucketUsage(bucket: StorageBucket): Promise<StorageBucket> {
|
||||
const physicalName = buildPhysicalBucketName(bucket.userId, bucket.name);
|
||||
try {
|
||||
const usage = await calculateBucketUsage(physicalName);
|
||||
return await prisma.storageBucket.update({
|
||||
where: { id: bucket.id },
|
||||
data: {
|
||||
usedBytes: usage.totalBytes,
|
||||
objectCount: usage.objectCount,
|
||||
usageSyncedAt: new Date(),
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[Storage] Не удалось синхронизировать usage для бакета ${bucket.id}`, error);
|
||||
return bucket;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBucket(userId: number, bucketId: number): Promise<StorageBucket> {
|
||||
const bucket = await prisma.storageBucket.findFirst({ where: { id: bucketId, userId } });
|
||||
if (!bucket) throw new Error('Бакет не найден');
|
||||
return bucket;
|
||||
}
|
||||
|
||||
async function applyPublicPolicy(physicalName: string, isPublic: boolean) {
|
||||
try {
|
||||
if (isPublic) {
|
||||
const policy = {
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Principal: { AWS: ['*'] },
|
||||
Action: ['s3:GetObject'],
|
||||
Resource: [`arn:aws:s3:::${physicalName}/*`]
|
||||
}
|
||||
]
|
||||
};
|
||||
await minioClient.setBucketPolicy(physicalName, JSON.stringify(policy));
|
||||
} else {
|
||||
// Сбрасываем политику
|
||||
await minioClient.setBucketPolicy(physicalName, '');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Storage] Не удалось применить политику для бакета ${physicalName}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyVersioning(physicalName: string, enabled: boolean) {
|
||||
try {
|
||||
await minioClient.setBucketVersioning(physicalName, {
|
||||
Status: enabled ? 'Enabled' : 'Suspended'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[Storage] Не удалось обновить версионирование для ${physicalName}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function collectObjectKeys(physicalName: string): Promise<string[]> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const keys: string[] = [];
|
||||
const stream = minioClient.listObjectsV2(physicalName, '', true);
|
||||
stream.on('data', (obj) => {
|
||||
if (obj?.name) keys.push(obj.name);
|
||||
});
|
||||
stream.on('error', (err) => reject(err));
|
||||
stream.on('end', () => resolve(keys));
|
||||
});
|
||||
}
|
||||
|
||||
export async function createBucket(data: CreateBucketInput) {
|
||||
const user = await prisma.user.findUnique({ where: { id: data.userId } });
|
||||
if (!user) throw new Error('Пользователь не найден');
|
||||
if (user.balance < data.price) throw new Error('Недостаточно средств');
|
||||
|
||||
const physicalName = buildPhysicalBucketName(data.userId, data.name);
|
||||
|
||||
const now = new Date();
|
||||
await ensureBucketExists(physicalName, data.region);
|
||||
|
||||
try {
|
||||
const bucket = await prisma.$transaction(async (tx) => {
|
||||
const reloadedUser = await tx.user.findUnique({ where: { id: data.userId } });
|
||||
if (!reloadedUser) throw new Error('Пользователь не найден');
|
||||
if (reloadedUser.balance < data.price) throw new Error('Недостаточно средств');
|
||||
|
||||
const updatedUser = await tx.user.update({
|
||||
where: { id: data.userId },
|
||||
data: { balance: reloadedUser.balance - data.price }
|
||||
});
|
||||
|
||||
await tx.transaction.create({
|
||||
data: {
|
||||
userId: data.userId,
|
||||
amount: -data.price,
|
||||
type: 'withdrawal',
|
||||
description: `Создание S3 бакета ${data.name}`,
|
||||
balanceBefore: reloadedUser.balance,
|
||||
balanceAfter: updatedUser.balance
|
||||
}
|
||||
});
|
||||
|
||||
const bucketRecord = await tx.storageBucket.create({
|
||||
data: {
|
||||
userId: data.userId,
|
||||
name: data.name,
|
||||
plan: data.plan,
|
||||
quotaGb: data.quotaGb,
|
||||
region: data.region,
|
||||
storageClass: data.storageClass,
|
||||
public: data.public,
|
||||
versioning: data.versioning,
|
||||
monthlyPrice: data.price,
|
||||
nextBillingDate: addDays(now, BILLING_INTERVAL_DAYS),
|
||||
lastBilledAt: now,
|
||||
autoRenew: true,
|
||||
status: 'active',
|
||||
usageSyncedAt: now
|
||||
}
|
||||
});
|
||||
|
||||
return bucketRecord;
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
applyPublicPolicy(physicalName, data.public),
|
||||
applyVersioning(physicalName, data.versioning)
|
||||
]);
|
||||
|
||||
await createNotification({
|
||||
userId: data.userId,
|
||||
type: 'storage_bucket_created',
|
||||
title: 'Создан новый бакет',
|
||||
message: `Бакет «${data.name}» успешно создан. Следующее списание: ${addDays(now, BILLING_INTERVAL_DAYS).toLocaleDateString('ru-RU')}`,
|
||||
color: 'green'
|
||||
});
|
||||
|
||||
return serializeBucket({ ...bucket, usedBytes: BigInt(0), objectCount: 0 });
|
||||
} catch (error) {
|
||||
// Откатываем созданный бакет в MinIO, если транзакция не удалась
|
||||
try {
|
||||
const keys = await collectObjectKeys(physicalName);
|
||||
if (keys.length > 0) {
|
||||
const chunks = Array.from({ length: Math.ceil(keys.length / 1000) }, (_, idx) =>
|
||||
keys.slice(idx * 1000, (idx + 1) * 1000)
|
||||
);
|
||||
for (const chunk of chunks) {
|
||||
await minioClient.removeObjects(physicalName, chunk);
|
||||
}
|
||||
}
|
||||
await minioClient.removeBucket(physicalName);
|
||||
} catch (cleanupError) {
|
||||
console.error('[Storage] Ошибка очистки бакета после неудачного создания', cleanupError);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listBuckets(userId: number) {
|
||||
const buckets = await prisma.storageBucket.findMany({ where: { userId }, orderBy: { createdAt: 'desc' } });
|
||||
|
||||
const results: StorageBucket[] = [];
|
||||
for (const bucket of buckets) {
|
||||
if (needsUsageRefresh(bucket)) {
|
||||
const refreshed = await syncBucketUsage(bucket);
|
||||
results.push(refreshed);
|
||||
} else {
|
||||
results.push(bucket);
|
||||
}
|
||||
}
|
||||
|
||||
return results.map(serializeBucket);
|
||||
}
|
||||
|
||||
export async function getBucket(userId: number, id: number, options: { refreshUsage?: boolean } = {}) {
|
||||
const bucket = await fetchBucket(userId, id);
|
||||
const shouldRefresh = options.refreshUsage ?? true;
|
||||
const finalBucket = shouldRefresh && needsUsageRefresh(bucket) ? await syncBucketUsage(bucket) : bucket;
|
||||
return serializeBucket(finalBucket);
|
||||
}
|
||||
|
||||
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);
|
||||
if (keys.length > 0 && !force) {
|
||||
throw new Error('Невозможно удалить непустой бакет. Удалите объекты или используйте force=true');
|
||||
}
|
||||
|
||||
if (keys.length > 0) {
|
||||
const chunks = Array.from({ length: Math.ceil(keys.length / 1000) }, (_, idx) =>
|
||||
keys.slice(idx * 1000, (idx + 1) * 1000)
|
||||
);
|
||||
for (const chunk of chunks) {
|
||||
await minioClient.removeObjects(physicalName, chunk);
|
||||
}
|
||||
}
|
||||
|
||||
await minioClient.removeBucket(physicalName);
|
||||
|
||||
await prisma.storageAccessKey.deleteMany({ where: { bucketId: bucket.id } });
|
||||
const deleted = await prisma.storageBucket.delete({ where: { id: bucket.id } });
|
||||
|
||||
await createNotification({
|
||||
userId,
|
||||
type: 'storage_bucket_deleted',
|
||||
title: 'Бакет удалён',
|
||||
message: `Бакет «${bucket.name}» был удалён`,
|
||||
color: 'red'
|
||||
});
|
||||
|
||||
return serializeBucket(deleted);
|
||||
}
|
||||
|
||||
export async function updateBucketSettings(userId: number, id: number, payload: UpdateBucketInput) {
|
||||
const bucket = await fetchBucket(userId, id);
|
||||
const physicalName = buildPhysicalBucketName(bucket.userId, bucket.name);
|
||||
|
||||
if (payload.public !== undefined) {
|
||||
await applyPublicPolicy(physicalName, payload.public);
|
||||
}
|
||||
|
||||
if (payload.versioning !== undefined) {
|
||||
await applyVersioning(physicalName, payload.versioning);
|
||||
}
|
||||
|
||||
const data: UpdateBucketInput & { nextBillingDate?: Date | null } = { ...payload };
|
||||
|
||||
if (payload.autoRenew && !bucket.autoRenew) {
|
||||
data.nextBillingDate = bucket.nextBillingDate ?? addDays(new Date(), BILLING_INTERVAL_DAYS);
|
||||
}
|
||||
|
||||
const updated = await prisma.storageBucket.update({
|
||||
where: { id: bucket.id },
|
||||
data: {
|
||||
...('public' in data ? { public: data.public } : {}),
|
||||
...('versioning' in data ? { versioning: data.versioning } : {}),
|
||||
...('autoRenew' in data ? { autoRenew: data.autoRenew } : {}),
|
||||
...('storageClass' in data ? { storageClass: data.storageClass } : {}),
|
||||
...('name' in data && data.name && data.name !== bucket.name ? { name: data.name } : {}),
|
||||
...(data.nextBillingDate ? { nextBillingDate: data.nextBillingDate } : {}),
|
||||
}
|
||||
});
|
||||
|
||||
if (payload.name && payload.name !== bucket.name) {
|
||||
// Переименовываем физический бакет через копирование ключей
|
||||
const newPhysicalName = buildPhysicalBucketName(bucket.userId, payload.name);
|
||||
await ensureBucketExists(newPhysicalName, bucket.region);
|
||||
const keys = await collectObjectKeys(physicalName);
|
||||
if (keys.length) {
|
||||
for (const key of keys) {
|
||||
const readable = await minioClient.getObject(physicalName, key);
|
||||
await minioClient.putObject(newPhysicalName, key, readable);
|
||||
await minioClient.removeObject(physicalName, key);
|
||||
}
|
||||
}
|
||||
await minioClient.removeBucket(physicalName);
|
||||
await applyPublicPolicy(newPhysicalName, updated.public);
|
||||
await applyVersioning(newPhysicalName, updated.versioning);
|
||||
}
|
||||
|
||||
return serializeBucket(updated);
|
||||
}
|
||||
|
||||
export async function listBucketObjects(userId: number, id: number, options: ListObjectsOptions = {}) {
|
||||
const bucket = await fetchBucket(userId, id);
|
||||
const physicalName = buildPhysicalBucketName(bucket.userId, bucket.name);
|
||||
const { prefix = '', cursor = '', limit = 100 } = options;
|
||||
|
||||
const objects: Array<{ key: string; size: number; etag?: string; lastModified?: string; }> = [];
|
||||
let lastKey: string | null = null;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const stream = minioClient.listObjectsV2(physicalName, prefix, true, cursor);
|
||||
stream.on('data', (obj) => {
|
||||
if (!obj?.name) return;
|
||||
if (objects.length >= limit) {
|
||||
lastKey = obj.name;
|
||||
stream.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
objects.push({
|
||||
key: obj.name,
|
||||
size: typeof obj.size === 'number' ? obj.size : 0,
|
||||
etag: obj.etag,
|
||||
lastModified: obj.lastModified ? new Date(obj.lastModified).toISOString() : undefined,
|
||||
});
|
||||
lastKey = obj.name;
|
||||
});
|
||||
stream.on('error', (err) => reject(err));
|
||||
stream.on('end', () => resolve());
|
||||
});
|
||||
|
||||
return {
|
||||
objects,
|
||||
nextCursor: lastKey,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createPresignedUrl(userId: number, id: number, objectKey: string, options: PresignOptions = {}) {
|
||||
const bucket = await fetchBucket(userId, id);
|
||||
const physicalName = buildPhysicalBucketName(bucket.userId, bucket.name);
|
||||
const method = options.method ?? 'PUT';
|
||||
const expires = options.expiresIn ?? PRESIGN_DEFAULT_TTL;
|
||||
|
||||
if (method === 'PUT') {
|
||||
const url = await minioClient.presignedPutObject(physicalName, objectKey, expires);
|
||||
return { url, method: 'PUT' };
|
||||
}
|
||||
|
||||
if (method === 'GET') {
|
||||
const responseHeaders = options.contentType ? { 'response-content-type': options.contentType } : undefined;
|
||||
const url = await minioClient.presignedGetObject(physicalName, objectKey, expires, responseHeaders);
|
||||
return { url, method: 'GET' };
|
||||
}
|
||||
|
||||
throw new Error('Поддерживаются только методы GET и PUT для пресайн ссылки');
|
||||
}
|
||||
|
||||
export async function deleteObjects(userId: number, id: number, keys: string[]) {
|
||||
if (!Array.isArray(keys) || keys.length === 0) return { deleted: 0 };
|
||||
const bucket = await fetchBucket(userId, id);
|
||||
const physicalName = buildPhysicalBucketName(bucket.userId, bucket.name);
|
||||
|
||||
const chunks = Array.from({ length: Math.ceil(keys.length / 1000) }, (_, idx) =>
|
||||
keys.slice(idx * 1000, (idx + 1) * 1000)
|
||||
);
|
||||
|
||||
let deleted = 0;
|
||||
for (const chunk of chunks) {
|
||||
await minioClient.removeObjects(physicalName, chunk);
|
||||
deleted += chunk.length;
|
||||
}
|
||||
|
||||
await syncBucketUsage(bucket);
|
||||
|
||||
return { deleted };
|
||||
}
|
||||
|
||||
export async function createEphemeralKey(userId: number, id: number, label?: string) {
|
||||
const bucket = await fetchBucket(userId, id);
|
||||
const accessKey = `S3${crypto.randomBytes(8).toString('hex')}`;
|
||||
const secretKey = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
const record = await prisma.storageAccessKey.create({
|
||||
data: {
|
||||
bucketId: bucket.id,
|
||||
accessKey,
|
||||
secretKey,
|
||||
label,
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
accessKey,
|
||||
secretKey,
|
||||
label: record.label,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listAccessKeys(userId: number, id: number) {
|
||||
const bucket = await fetchBucket(userId, id);
|
||||
const keys = await prisma.storageAccessKey.findMany({
|
||||
where: { bucketId: bucket.id },
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
return keys.map((key) => ({
|
||||
id: key.id,
|
||||
accessKey: key.accessKey,
|
||||
label: key.label,
|
||||
createdAt: key.createdAt.toISOString(),
|
||||
lastUsedAt: key.lastUsedAt?.toISOString() ?? null
|
||||
}));
|
||||
}
|
||||
|
||||
export async function revokeAccessKey(userId: number, id: number, keyId: number) {
|
||||
const bucket = await fetchBucket(userId, id);
|
||||
await prisma.storageAccessKey.deleteMany({
|
||||
where: { id: keyId, bucketId: bucket.id }
|
||||
});
|
||||
return { revoked: true };
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
import tariffRoutes from './tariff.routes';
|
||||
export default tariffRoutes;
|
||||
@@ -1,18 +0,0 @@
|
||||
|
||||
import { Router } from 'express';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const router = Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const tariffs = await prisma.tariff.findMany();
|
||||
res.json(tariffs);
|
||||
} catch (err) {
|
||||
console.error('Ошибка получения тарифов:', err);
|
||||
res.status(500).json({ error: 'Ошибка получения тарифов' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,79 +1,310 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { prisma } from '../../prisma/client';
|
||||
import { Request, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
// Настройка multer для загрузки файлов
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const uploadDir = path.join(__dirname, '../../../uploads/tickets');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
cb(null, uniqueSuffix + '-' + file.originalname);
|
||||
}
|
||||
});
|
||||
|
||||
export const uploadTicketFiles = multer({
|
||||
storage,
|
||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedTypes = /jpeg|jpg|png|gif|pdf|doc|docx|txt|zip/;
|
||||
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
|
||||
const mimetype = allowedTypes.test(file.mimetype);
|
||||
if (extname && mimetype) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Недопустимый тип файла'));
|
||||
}
|
||||
}
|
||||
}).array('attachments', 5); // Максимум 5 файлов
|
||||
|
||||
// Создать тикет
|
||||
export async function createTicket(req: Request, res: Response) {
|
||||
const { title, message } = req.body;
|
||||
const userId = req.user?.id;
|
||||
const { title, message, category = 'general', priority = 'normal' } = req.body;
|
||||
const userId = (req as any).user?.id;
|
||||
|
||||
if (!userId) return res.status(401).json({ error: 'Нет авторизации' });
|
||||
if (!title || !message) {
|
||||
return res.status(400).json({ error: 'Необходимо указать title и message' });
|
||||
}
|
||||
|
||||
try {
|
||||
const ticket = await prisma.ticket.create({
|
||||
data: { title, message, userId },
|
||||
data: {
|
||||
title,
|
||||
message,
|
||||
userId,
|
||||
category,
|
||||
priority,
|
||||
status: 'open'
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, username: true, email: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: Отправить уведомление операторам о новом тикете
|
||||
|
||||
res.json(ticket);
|
||||
} catch (err) {
|
||||
console.error('Ошибка создания тикета:', err);
|
||||
res.status(500).json({ error: 'Ошибка создания тикета' });
|
||||
}
|
||||
}
|
||||
|
||||
// Получить тикеты (клиент — свои, оператор — все)
|
||||
// Получить тикеты (клиент — свои, оператор — все с фильтрами)
|
||||
export async function getTickets(req: Request, res: Response) {
|
||||
const userId = req.user?.id;
|
||||
const isOperator = Number(req.user?.operator) === 1;
|
||||
const userId = (req as any).user?.id;
|
||||
const isOperator = Number((req as any).user?.operator) === 1;
|
||||
const { status, category, priority, assignedTo } = req.query;
|
||||
|
||||
if (!userId) return res.status(401).json({ error: 'Нет авторизации' });
|
||||
|
||||
try {
|
||||
const where: any = isOperator ? {} : { userId };
|
||||
|
||||
// Фильтры (только для операторов)
|
||||
if (isOperator) {
|
||||
if (status) where.status = status;
|
||||
if (category) where.category = category;
|
||||
if (priority) where.priority = priority;
|
||||
if (assignedTo) where.assignedTo = Number(assignedTo);
|
||||
}
|
||||
|
||||
const tickets = await prisma.ticket.findMany({
|
||||
where: isOperator ? {} : { userId },
|
||||
where,
|
||||
include: {
|
||||
responses: { include: { operator: true } },
|
||||
user: true
|
||||
responses: {
|
||||
include: {
|
||||
operator: {
|
||||
select: { id: true, username: true, email: true }
|
||||
}
|
||||
},
|
||||
orderBy: { createdAt: 'asc' }
|
||||
},
|
||||
user: {
|
||||
select: { id: true, username: true, email: true }
|
||||
},
|
||||
attachments: true
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
res.json(tickets);
|
||||
} catch (err) {
|
||||
console.error('Ошибка получения тикетов:', err);
|
||||
res.status(500).json({ error: 'Ошибка получения тикетов' });
|
||||
}
|
||||
}
|
||||
|
||||
// Ответить на тикет (только оператор)
|
||||
export async function respondTicket(req: Request, res: Response) {
|
||||
const { ticketId, message } = req.body;
|
||||
const operatorId = req.user?.id;
|
||||
const isOperator = Number(req.user?.operator) === 1;
|
||||
if (!operatorId || !isOperator) return res.status(403).json({ error: 'Нет прав' });
|
||||
// Получить один тикет по ID
|
||||
export async function getTicketById(req: Request, res: Response) {
|
||||
const ticketId = Number(req.params.id);
|
||||
const userId = (req as any).user?.id;
|
||||
const isOperator = Number((req as any).user?.operator) === 1;
|
||||
|
||||
if (!userId) return res.status(401).json({ error: 'Нет авторизации' });
|
||||
|
||||
try {
|
||||
const response = await prisma.response.create({
|
||||
data: { ticketId, operatorId, message },
|
||||
const ticket = await prisma.ticket.findUnique({
|
||||
where: { id: ticketId },
|
||||
include: {
|
||||
responses: {
|
||||
where: isOperator ? {} : { isInternal: false }, // Клиенты не видят внутренние комментарии
|
||||
include: {
|
||||
operator: {
|
||||
select: { id: true, username: true, email: true }
|
||||
}
|
||||
},
|
||||
orderBy: { createdAt: 'asc' }
|
||||
},
|
||||
user: {
|
||||
select: { id: true, username: true, email: true }
|
||||
},
|
||||
attachments: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'Тикет не найден' });
|
||||
}
|
||||
|
||||
// Проверка прав доступа
|
||||
if (!isOperator && ticket.userId !== userId) {
|
||||
return res.status(403).json({ error: 'Нет прав доступа к этому тикету' });
|
||||
}
|
||||
|
||||
res.json(ticket);
|
||||
} catch (err) {
|
||||
console.error('Ошибка получения тикета:', err);
|
||||
res.status(500).json({ error: 'Ошибка получения тикета' });
|
||||
}
|
||||
}
|
||||
|
||||
// Ответить на тикет (клиент или оператор)
|
||||
export async function respondTicket(req: Request, res: Response) {
|
||||
const { ticketId, message, isInternal = false } = req.body;
|
||||
const operatorId = (req as any).user?.id;
|
||||
const isOperator = Number((req as any).user?.operator) === 1;
|
||||
|
||||
if (!operatorId) return res.status(401).json({ error: 'Нет авторизации' });
|
||||
if (!message) return res.status(400).json({ error: 'Сообщение не может быть пустым' });
|
||||
|
||||
// Только операторы могут оставлять внутренние комментарии
|
||||
const actualIsInternal = isOperator ? isInternal : false;
|
||||
|
||||
try {
|
||||
const ticket = await prisma.ticket.findUnique({ where: { id: ticketId } });
|
||||
if (!ticket) return res.status(404).json({ error: 'Тикет не найден' });
|
||||
|
||||
// Клиент может отвечать только на свои тикеты
|
||||
if (!isOperator && ticket.userId !== operatorId) {
|
||||
return res.status(403).json({ error: 'Нет прав' });
|
||||
}
|
||||
|
||||
const response = await prisma.response.create({
|
||||
data: {
|
||||
ticketId,
|
||||
operatorId,
|
||||
message,
|
||||
isInternal: actualIsInternal
|
||||
},
|
||||
include: {
|
||||
operator: {
|
||||
select: { id: true, username: true, email: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Обновляем статус тикета
|
||||
let newStatus = ticket.status;
|
||||
if (isOperator && ticket.status === 'open') {
|
||||
newStatus = 'in_progress';
|
||||
} else if (!isOperator && ticket.status === 'awaiting_reply') {
|
||||
newStatus = 'in_progress';
|
||||
}
|
||||
|
||||
await prisma.ticket.update({
|
||||
where: { id: ticketId },
|
||||
data: { status: 'answered' },
|
||||
data: {
|
||||
status: newStatus,
|
||||
updatedAt: new Date()
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: Отправить уведомление автору тикета (если ответил оператор)
|
||||
|
||||
res.json(response);
|
||||
} catch (err) {
|
||||
console.error('Ошибка ответа на тикет:', err);
|
||||
res.status(500).json({ error: 'Ошибка ответа на тикет' });
|
||||
}
|
||||
}
|
||||
|
||||
// Изменить статус тикета (только оператор)
|
||||
export async function updateTicketStatus(req: Request, res: Response) {
|
||||
const { ticketId, status } = req.body;
|
||||
const userId = (req as any).user?.id;
|
||||
const isOperator = Number((req as any).user?.operator) === 1;
|
||||
|
||||
if (!userId || !isOperator) {
|
||||
return res.status(403).json({ error: 'Нет прав' });
|
||||
}
|
||||
|
||||
const allowedStatuses = ['open', 'in_progress', 'awaiting_reply', 'resolved', 'closed'];
|
||||
if (!allowedStatuses.includes(status)) {
|
||||
return res.status(400).json({ error: 'Недопустимый статус' });
|
||||
}
|
||||
|
||||
try {
|
||||
const ticket = await prisma.ticket.update({
|
||||
where: { id: ticketId },
|
||||
data: {
|
||||
status,
|
||||
closedAt: status === 'closed' ? new Date() : null,
|
||||
updatedAt: new Date()
|
||||
},
|
||||
});
|
||||
|
||||
res.json(ticket);
|
||||
} catch (err) {
|
||||
console.error('Ошибка изменения статуса тикета:', err);
|
||||
res.status(500).json({ error: 'Ошибка изменения статуса тикета' });
|
||||
}
|
||||
}
|
||||
|
||||
// Назначить тикет на оператора (только оператор)
|
||||
export async function assignTicket(req: Request, res: Response) {
|
||||
const { ticketId, operatorId } = req.body;
|
||||
const userId = (req as any).user?.id;
|
||||
const isOperator = Number((req as any).user?.operator) === 1;
|
||||
|
||||
if (!userId || !isOperator) {
|
||||
return res.status(403).json({ error: 'Нет прав' });
|
||||
}
|
||||
|
||||
try {
|
||||
const ticket = await prisma.ticket.update({
|
||||
where: { id: ticketId },
|
||||
data: {
|
||||
assignedTo: operatorId,
|
||||
status: 'in_progress',
|
||||
updatedAt: new Date()
|
||||
},
|
||||
});
|
||||
|
||||
res.json(ticket);
|
||||
} catch (err) {
|
||||
console.error('Ошибка назначения тикета:', err);
|
||||
res.status(500).json({ error: 'Ошибка назначения тикета' });
|
||||
}
|
||||
}
|
||||
|
||||
// Закрыть тикет (клиент или оператор)
|
||||
export async function closeTicket(req: Request, res: Response) {
|
||||
const { ticketId } = req.body;
|
||||
const userId = req.user?.id;
|
||||
const isOperator = Number(req.user?.operator) === 1;
|
||||
const userId = (req as any).user?.id;
|
||||
const isOperator = Number((req as any).user?.operator) === 1;
|
||||
|
||||
if (!userId) return res.status(401).json({ error: 'Нет авторизации' });
|
||||
|
||||
try {
|
||||
const ticket = await prisma.ticket.findUnique({ where: { id: ticketId } });
|
||||
if (!ticket) return res.status(404).json({ error: 'Тикет не найден' });
|
||||
if (!isOperator && ticket.userId !== userId) return res.status(403).json({ error: 'Нет прав' });
|
||||
if (!isOperator && ticket.userId !== userId) {
|
||||
return res.status(403).json({ error: 'Нет прав' });
|
||||
}
|
||||
|
||||
await prisma.ticket.update({
|
||||
where: { id: ticketId },
|
||||
data: { status: 'closed' },
|
||||
data: {
|
||||
status: 'closed',
|
||||
closedAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
});
|
||||
res.json({ success: true });
|
||||
|
||||
res.json({ success: true, message: 'Тикет закрыт' });
|
||||
} catch (err) {
|
||||
console.error('Ошибка закрытия тикета:', err);
|
||||
res.status(500).json({ error: 'Ошибка закрытия тикета' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,44 @@
|
||||
import { Router } from 'express';
|
||||
import { createTicket, getTickets, respondTicket, closeTicket } from './ticket.controller';
|
||||
import {
|
||||
createTicket,
|
||||
getTickets,
|
||||
getTicketById,
|
||||
respondTicket,
|
||||
closeTicket,
|
||||
updateTicketStatus,
|
||||
assignTicket,
|
||||
uploadTicketFiles
|
||||
} from './ticket.controller';
|
||||
import { authMiddleware } from '../auth/auth.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
router.post('/create', createTicket);
|
||||
// Получить все тикеты (с фильтрами для операторов)
|
||||
router.get('/', getTickets);
|
||||
|
||||
// Получить один тикет по ID
|
||||
router.get('/:id', getTicketById);
|
||||
|
||||
// Создать тикет
|
||||
router.post('/create', createTicket);
|
||||
|
||||
// Ответить на тикет
|
||||
router.post('/respond', respondTicket);
|
||||
|
||||
// Изменить статус тикета (только оператор)
|
||||
router.post('/status', updateTicketStatus);
|
||||
|
||||
// Назначить тикет на оператора (только оператор)
|
||||
router.post('/assign', assignTicket);
|
||||
|
||||
// Закрыть тикет
|
||||
router.post('/close', closeTicket);
|
||||
|
||||
// Загрузить файлы к тикету (TODO: доделать обработку)
|
||||
// router.post('/upload', uploadTicketFiles, (req, res) => {
|
||||
// res.json({ files: req.files });
|
||||
// });
|
||||
|
||||
export default router;
|
||||
|
||||
425
ospabhost/backend/src/modules/user/user.controller.ts
Normal file
425
ospabhost/backend/src/modules/user/user.controller.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { prisma } from '../../prisma/client';
|
||||
import bcrypt from 'bcrypt';
|
||||
import crypto from 'crypto';
|
||||
|
||||
// Получить профиль пользователя (расширенный)
|
||||
export const getProfile = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as any).user.id;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
profile: true,
|
||||
notificationSettings: true,
|
||||
_count: {
|
||||
select: {
|
||||
buckets: true,
|
||||
tickets: true,
|
||||
sessions: true,
|
||||
apiKeys: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ success: false, message: 'Пользователь не найден' });
|
||||
}
|
||||
|
||||
// Не отправляем пароль
|
||||
const { password, ...userWithoutPassword } = user;
|
||||
|
||||
res.json({ success: true, data: userWithoutPassword });
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка получения профиля:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// Обновить базовый профиль
|
||||
export const updateProfile = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as any).user.id;
|
||||
const { username, email, phoneNumber, timezone, language } = req.body;
|
||||
|
||||
// Проверка email на уникальность
|
||||
if (email) {
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: { email, id: { not: userId } }
|
||||
});
|
||||
if (existingUser) {
|
||||
return res.status(400).json({ success: false, message: 'Email уже используется' });
|
||||
}
|
||||
}
|
||||
|
||||
// Обновление User
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
...(username && { username }),
|
||||
...(email && { email })
|
||||
}
|
||||
});
|
||||
|
||||
// Обновление или создание UserProfile
|
||||
const profile = await prisma.userProfile.upsert({
|
||||
where: { userId },
|
||||
update: {
|
||||
...(phoneNumber !== undefined && { phoneNumber }),
|
||||
...(timezone && { timezone }),
|
||||
...(language && { language })
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
phoneNumber,
|
||||
timezone,
|
||||
language
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Профиль обновлён',
|
||||
data: { user: updatedUser, profile }
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка обновления профиля:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// Изменить пароль
|
||||
export const changePassword = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as any).user.id;
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
return res.status(400).json({ success: false, message: 'Все поля обязательны' });
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
return res.status(400).json({ success: false, message: 'Новый пароль должен быть минимум 6 символов' });
|
||||
}
|
||||
|
||||
// Проверка текущего пароля
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
return res.status(404).json({ success: false, message: 'Пользователь не найден' });
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(currentPassword, user.password);
|
||||
if (!isPasswordValid) {
|
||||
return res.status(401).json({ success: false, message: 'Неверный текущий пароль' });
|
||||
}
|
||||
|
||||
// Хешируем новый пароль
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
// Обновляем пароль
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { password: hashedPassword }
|
||||
});
|
||||
|
||||
// Завершаем все сеансы кроме текущего (опционально)
|
||||
// Можно добавить логику для сохранения текущего токена
|
||||
|
||||
res.json({ success: true, message: 'Пароль успешно изменён' });
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка смены пароля:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// Загрузить аватар
|
||||
export const uploadAvatar = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as any).user.id;
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ success: false, message: 'Файл не загружен' });
|
||||
}
|
||||
|
||||
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
|
||||
|
||||
// Обновляем профиль
|
||||
await prisma.userProfile.upsert({
|
||||
where: { userId },
|
||||
update: { avatarUrl },
|
||||
create: { userId, avatarUrl }
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Аватар загружен',
|
||||
data: { avatarUrl }
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка загрузки аватара:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// Удалить аватар
|
||||
export const deleteAvatar = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as any).user.id;
|
||||
|
||||
await prisma.userProfile.update({
|
||||
where: { userId },
|
||||
data: { avatarUrl: null }
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'Аватар удалён' });
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка удаления аватара:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// Получить активные сеансы
|
||||
export const getSessions = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as any).user.id;
|
||||
|
||||
const sessions = await prisma.session.findMany({
|
||||
where: {
|
||||
userId,
|
||||
expiresAt: { gte: new Date() } // Только активные
|
||||
},
|
||||
orderBy: { lastActivity: 'desc' }
|
||||
});
|
||||
|
||||
res.json({ success: true, data: sessions });
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка получения сеансов:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// Завершить сеанс
|
||||
export const terminateSession = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as any).user.id;
|
||||
const { sessionId } = req.params;
|
||||
|
||||
// Проверяем, что сеанс принадлежит пользователю
|
||||
const session = await prisma.session.findFirst({
|
||||
where: { id: parseInt(sessionId), userId }
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return res.status(404).json({ success: false, message: 'Сеанс не найден' });
|
||||
}
|
||||
|
||||
// Удаляем сеанс
|
||||
await prisma.session.delete({
|
||||
where: { id: parseInt(sessionId) }
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'Сеанс завершён' });
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка завершения сеанса:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// Получить историю входов
|
||||
export const getLoginHistory = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as any).user.id;
|
||||
const limit = parseInt(req.query.limit as string) || 20;
|
||||
|
||||
const history = await prisma.loginHistory.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit
|
||||
});
|
||||
|
||||
res.json({ success: true, data: history });
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка получения истории:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// Получить SSH ключи
|
||||
|
||||
// Получить API ключи
|
||||
export const getAPIKeys = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as any).user.id;
|
||||
|
||||
const keys = await prisma.aPIKey.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
prefix: true,
|
||||
permissions: true,
|
||||
lastUsed: true,
|
||||
createdAt: true,
|
||||
expiresAt: true
|
||||
// Не отправляем полный ключ из соображений безопасности
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ success: true, data: keys });
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка получения API ключей:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// Создать API ключ
|
||||
export const createAPIKey = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as any).user.id;
|
||||
const { name, permissions, expiresAt } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ success: false, message: 'Имя ключа обязательно' });
|
||||
}
|
||||
|
||||
// Генерируем случайный ключ
|
||||
const key = `ospab_${crypto.randomBytes(32).toString('hex')}`;
|
||||
const prefix = key.substring(0, 16) + '...';
|
||||
|
||||
const apiKey = await prisma.aPIKey.create({
|
||||
data: {
|
||||
userId,
|
||||
name,
|
||||
key,
|
||||
prefix,
|
||||
permissions: permissions ? JSON.stringify(permissions) : null,
|
||||
expiresAt: expiresAt ? new Date(expiresAt) : null
|
||||
}
|
||||
});
|
||||
|
||||
// Отправляем полный ключ только один раз при создании
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'API ключ создан. Сохраните его, он больше не будет показан!',
|
||||
data: { ...apiKey, fullKey: key }
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка создания API ключа:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// Удалить API ключ
|
||||
export const deleteAPIKey = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as any).user.id;
|
||||
const { keyId } = req.params;
|
||||
|
||||
// Проверяем принадлежность ключа
|
||||
const key = await prisma.aPIKey.findFirst({
|
||||
where: { id: parseInt(keyId), userId }
|
||||
});
|
||||
|
||||
if (!key) {
|
||||
return res.status(404).json({ success: false, message: 'Ключ не найден' });
|
||||
}
|
||||
|
||||
await prisma.aPIKey.delete({
|
||||
where: { id: parseInt(keyId) }
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'API ключ удалён' });
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка удаления API ключа:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// Получить настройки уведомлений
|
||||
export const getNotificationSettings = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as any).user.id;
|
||||
|
||||
let settings = await prisma.notificationSettings.findUnique({
|
||||
where: { userId }
|
||||
});
|
||||
|
||||
// Создаём настройки по умолчанию, если их нет
|
||||
if (!settings) {
|
||||
settings = await prisma.notificationSettings.create({
|
||||
data: { userId }
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true, data: settings });
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка получения настроек уведомлений:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// Обновить настройки уведомлений
|
||||
export const updateNotificationSettings = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as any).user.id;
|
||||
const settings = req.body;
|
||||
|
||||
const updated = await prisma.notificationSettings.upsert({
|
||||
where: { userId },
|
||||
update: settings,
|
||||
create: { userId, ...settings }
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Настройки уведомлений обновлены',
|
||||
data: updated
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка обновления настроек уведомлений:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
// Экспорт данных пользователя (GDPR compliance)
|
||||
export const exportUserData = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as any).user.id;
|
||||
|
||||
const userData = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
profile: true,
|
||||
buckets: true,
|
||||
|
||||
tickets: true,
|
||||
checks: true,
|
||||
transactions: true,
|
||||
notifications: true,
|
||||
apiKeys: {
|
||||
select: { id: true, name: true, prefix: true, createdAt: true }
|
||||
},
|
||||
loginHistory: { take: 100 }
|
||||
}
|
||||
});
|
||||
|
||||
if (!userData) {
|
||||
return res.status(404).json({ success: false, message: 'Пользователь не найден' });
|
||||
}
|
||||
|
||||
// Убираем пароль
|
||||
const { password, ...dataWithoutPassword } = userData;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: dataWithoutPassword,
|
||||
exportedAt: new Date().toISOString()
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка экспорта данных:', error);
|
||||
res.status(500).json({ success: false, message: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
109
ospabhost/backend/src/modules/user/user.routes.ts
Normal file
109
ospabhost/backend/src/modules/user/user.routes.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { prisma } from '../../prisma/client';
|
||||
import { logger } from '../../utils/logger';
|
||||
import {
|
||||
getProfile,
|
||||
updateProfile,
|
||||
changePassword,
|
||||
uploadAvatar,
|
||||
deleteAvatar,
|
||||
getSessions,
|
||||
terminateSession,
|
||||
getLoginHistory,
|
||||
getAPIKeys,
|
||||
createAPIKey,
|
||||
deleteAPIKey,
|
||||
getNotificationSettings,
|
||||
updateNotificationSettings,
|
||||
exportUserData
|
||||
} from './user.controller';
|
||||
import { authMiddleware } from '../auth/auth.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Настройка multer для загрузки аватаров
|
||||
const avatarStorage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const uploadDir = path.join(__dirname, '../../../uploads/avatars');
|
||||
// Создаём директорию если не существует
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const userId = (req as any).user.id;
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `avatar-${userId}-${Date.now()}${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
const avatarUpload = multer({
|
||||
storage: avatarStorage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
|
||||
fileFilter: (req, file, cb: any) => {
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
if (allowedTypes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Неподдерживаемый формат изображения'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Все роуты требуют аутентификации
|
||||
router.use(authMiddleware);
|
||||
|
||||
// Профиль
|
||||
router.get('/profile', getProfile);
|
||||
router.put('/profile', updateProfile);
|
||||
|
||||
// Безопасность
|
||||
router.post('/change-password', changePassword);
|
||||
router.get('/sessions', getSessions);
|
||||
router.delete('/sessions/:sessionId', terminateSession);
|
||||
router.get('/login-history', getLoginHistory);
|
||||
|
||||
// Аватар
|
||||
router.post('/avatar', avatarUpload.single('avatar'), uploadAvatar);
|
||||
router.delete('/avatar', deleteAvatar);
|
||||
|
||||
// API ключи
|
||||
router.get('/api-keys', getAPIKeys);
|
||||
router.post('/api-keys', createAPIKey);
|
||||
router.delete('/api-keys/:keyId', deleteAPIKey);
|
||||
|
||||
// Настройки уведомлений
|
||||
router.get('/notification-settings', getNotificationSettings);
|
||||
router.put('/notification-settings', updateNotificationSettings);
|
||||
|
||||
// Экспорт данных
|
||||
router.get('/export', exportUserData);
|
||||
|
||||
// Баланс и транзакции
|
||||
router.get('/balance', async (req, res) => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ error: 'Не авторизован' });
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { balance: true }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'Пользователь не найден' });
|
||||
}
|
||||
|
||||
logger.info(`[User Balance] Пользователь ID ${userId}, баланс: ${user.balance}`);
|
||||
res.json({ status: 'success', balance: user.balance || 0 });
|
||||
} catch (error) {
|
||||
logger.error('[User Balance] Ошибка получения баланса:', error);
|
||||
res.status(500).json({ error: 'Ошибка получения баланса' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
56
ospabhost/backend/src/types/errors.ts
Normal file
56
ospabhost/backend/src/types/errors.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
// Общие типы для обработки ошибок
|
||||
|
||||
export interface ProxmoxError extends Error {
|
||||
response?: {
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
data?: {
|
||||
errors?: string;
|
||||
message?: string;
|
||||
data?: null;
|
||||
};
|
||||
};
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface AxiosError extends Error {
|
||||
response?: {
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
data?: unknown;
|
||||
};
|
||||
code?: string;
|
||||
isAxiosError?: boolean;
|
||||
}
|
||||
|
||||
export function isAxiosError(error: unknown): error is AxiosError {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'isAxiosError' in error &&
|
||||
(error as AxiosError).isAxiosError === true
|
||||
);
|
||||
}
|
||||
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
return 'Неизвестная ошибка';
|
||||
}
|
||||
|
||||
export function getProxmoxErrorMessage(error: unknown): string {
|
||||
if (error && typeof error === 'object') {
|
||||
const err = error as ProxmoxError;
|
||||
if (err.response?.data?.errors) {
|
||||
return err.response.data.errors;
|
||||
}
|
||||
if (err.message) {
|
||||
return err.message;
|
||||
}
|
||||
}
|
||||
return getErrorMessage(error);
|
||||
}
|
||||
13
ospabhost/backend/src/types/express.d.ts
vendored
13
ospabhost/backend/src/types/express.d.ts
vendored
@@ -1,17 +1,10 @@
|
||||
// Типы для расширения Express Request
|
||||
import { User } from '@prisma/client';
|
||||
import { User as PrismaUser } from '@prisma/client';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
balance: number;
|
||||
operator: number;
|
||||
createdAt: Date;
|
||||
}
|
||||
// Используем полный тип User из Prisma
|
||||
interface User extends PrismaUser {}
|
||||
|
||||
interface Request {
|
||||
user?: User;
|
||||
|
||||
56
ospabhost/backend/src/utils/logger.ts
Normal file
56
ospabhost/backend/src/utils/logger.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Logger utility - логирование только в debug режиме
|
||||
* Управляется через NODE_ENV в .env файле
|
||||
*/
|
||||
|
||||
const isDebug = process.env.NODE_ENV !== 'production';
|
||||
|
||||
export const logger = {
|
||||
log: (...args: any[]) => {
|
||||
if (isDebug) {
|
||||
console.log(...args);
|
||||
}
|
||||
},
|
||||
|
||||
error: (...args: any[]) => {
|
||||
// Ошибки логируем всегда
|
||||
console.error(...args);
|
||||
},
|
||||
|
||||
warn: (...args: any[]) => {
|
||||
if (isDebug) {
|
||||
console.warn(...args);
|
||||
}
|
||||
},
|
||||
|
||||
info: (...args: any[]) => {
|
||||
if (isDebug) {
|
||||
console.info(...args);
|
||||
}
|
||||
},
|
||||
|
||||
debug: (...args: any[]) => {
|
||||
if (isDebug) {
|
||||
console.debug(...args);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// WebSocket специфичные логи
|
||||
export const wsLogger = {
|
||||
log: (message: string, ...args: any[]) => {
|
||||
if (isDebug) {
|
||||
console.log(`[WebSocket] ${message}`, ...args);
|
||||
}
|
||||
},
|
||||
|
||||
error: (message: string, ...args: any[]) => {
|
||||
console.error(`[WebSocket] ${message}`, ...args);
|
||||
},
|
||||
|
||||
warn: (message: string, ...args: any[]) => {
|
||||
if (isDebug) {
|
||||
console.warn(`[WebSocket] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
};
|
||||
47
ospabhost/backend/src/websocket/events.ts
Normal file
47
ospabhost/backend/src/websocket/events.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Типы WebSocket событий
|
||||
* Shared между backend и frontend
|
||||
*/
|
||||
|
||||
// События от клиента к серверу
|
||||
export type ClientToServerEvents =
|
||||
| { type: 'auth'; token: string }
|
||||
| { type: 'subscribe:notifications' }
|
||||
| { type: 'subscribe:servers' }
|
||||
| { type: 'subscribe:tickets' }
|
||||
| { type: 'subscribe:balance' }
|
||||
| { type: 'unsubscribe:notifications' }
|
||||
| { type: 'unsubscribe:servers' }
|
||||
| { type: 'unsubscribe:tickets' }
|
||||
| { type: 'unsubscribe:balance' }
|
||||
| { type: 'ping' };
|
||||
|
||||
// События от сервера к клиенту
|
||||
export type ServerToClientEvents =
|
||||
| { type: 'auth:success'; userId: number }
|
||||
| { type: 'auth:error'; message: string }
|
||||
| { type: 'notification:new'; notification: any }
|
||||
| { type: 'notification:read'; notificationId: number }
|
||||
| { type: 'notification:delete'; notificationId: number }
|
||||
| { type: 'notification:updated'; notificationId: number; data: any }
|
||||
| { type: 'notification:count'; count: number }
|
||||
| { type: 'server:created'; server: any }
|
||||
| { 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: 'ticket:status'; ticketId: number; status: string }
|
||||
| { type: 'balance:updated'; balance: number; transaction?: any }
|
||||
| { type: 'check:status'; checkId: number; status: string }
|
||||
| { type: 'pong' }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
// Типы комнат для подписок
|
||||
export type RoomType = 'notifications' | 'servers' | 'tickets' | 'balance';
|
||||
|
||||
// Интерфейс для аутентифицированного WebSocket клиента
|
||||
export interface AuthenticatedClient {
|
||||
userId: number;
|
||||
rooms: Set<RoomType>;
|
||||
lastPing: Date;
|
||||
}
|
||||
282
ospabhost/backend/src/websocket/server.ts
Normal file
282
ospabhost/backend/src/websocket/server.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
import { Server as HTTPServer } from 'http';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import {
|
||||
ClientToServerEvents,
|
||||
ServerToClientEvents,
|
||||
AuthenticatedClient,
|
||||
RoomType
|
||||
} from './events';
|
||||
import { wsLogger } from '../utils/logger';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_key';
|
||||
|
||||
// Хранилище аутентифицированных клиентов
|
||||
const authenticatedClients = new Map<WebSocket, AuthenticatedClient>();
|
||||
|
||||
// Хранилище комнат (userId -> Set<WebSocket>)
|
||||
const rooms = {
|
||||
notifications: new Map<number, Set<WebSocket>>(),
|
||||
servers: new Map<number, Set<WebSocket>>(),
|
||||
tickets: new Map<number, Set<WebSocket>>(),
|
||||
balance: new Map<number, Set<WebSocket>>(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Инициализация WebSocket сервера
|
||||
*/
|
||||
export function initWebSocketServer(server: HTTPServer): WebSocketServer {
|
||||
const wss = new WebSocketServer({
|
||||
server,
|
||||
path: '/ws'
|
||||
});
|
||||
|
||||
wsLogger.log('Сервер инициализирован на пути /ws');
|
||||
|
||||
wss.on('connection', (ws: WebSocket) => {
|
||||
wsLogger.log('Новое подключение');
|
||||
|
||||
// Таймаут для аутентификации (10 секунд)
|
||||
const authTimeout = setTimeout(() => {
|
||||
if (!authenticatedClients.has(ws)) {
|
||||
wsLogger.warn('Таймаут аутентификации, закрываем соединение');
|
||||
sendMessage(ws, { type: 'error', message: 'Аутентификация не выполнена' });
|
||||
ws.close();
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
ws.on('message', async (data: Buffer) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString()) as ClientToServerEvents;
|
||||
await handleClientMessage(ws, message, authTimeout);
|
||||
} catch (error) {
|
||||
wsLogger.error('Ошибка обработки сообщения:', error);
|
||||
sendMessage(ws, { type: 'error', message: 'Ошибка обработки сообщения' });
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
handleDisconnect(ws);
|
||||
clearTimeout(authTimeout);
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
wsLogger.error('Ошибка соединения:', error);
|
||||
handleDisconnect(ws);
|
||||
});
|
||||
});
|
||||
|
||||
// Ping каждые 30 секунд для поддержания соединения
|
||||
setInterval(() => {
|
||||
wss.clients.forEach((ws) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
const client = authenticatedClients.get(ws);
|
||||
if (client) {
|
||||
sendMessage(ws, { type: 'pong' });
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
return wss;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка сообщений от клиента
|
||||
*/
|
||||
async function handleClientMessage(
|
||||
ws: WebSocket,
|
||||
message: ClientToServerEvents,
|
||||
authTimeout: NodeJS.Timeout
|
||||
): Promise<void> {
|
||||
wsLogger.log('Получено сообщение:', message.type);
|
||||
|
||||
switch (message.type) {
|
||||
case 'auth':
|
||||
await handleAuth(ws, message.token, authTimeout);
|
||||
break;
|
||||
|
||||
case 'subscribe:notifications':
|
||||
case 'subscribe:servers':
|
||||
case 'subscribe:tickets':
|
||||
case 'subscribe:balance':
|
||||
handleSubscribe(ws, message.type.split(':')[1] as RoomType);
|
||||
break;
|
||||
|
||||
case 'unsubscribe:notifications':
|
||||
case 'unsubscribe:servers':
|
||||
case 'unsubscribe:tickets':
|
||||
case 'unsubscribe:balance':
|
||||
handleUnsubscribe(ws, message.type.split(':')[1] as RoomType);
|
||||
break;
|
||||
|
||||
case 'ping':
|
||||
sendMessage(ws, { type: 'pong' });
|
||||
break;
|
||||
|
||||
default:
|
||||
sendMessage(ws, { type: 'error', message: 'Неизвестный тип сообщения' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Аутентификация WebSocket соединения
|
||||
*/
|
||||
async function handleAuth(ws: WebSocket, token: string, authTimeout: NodeJS.Timeout): Promise<void> {
|
||||
try {
|
||||
wsLogger.log('Попытка аутентификации...');
|
||||
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as { id: number };
|
||||
const user = await prisma.user.findUnique({ where: { id: decoded.id } });
|
||||
|
||||
if (!user) {
|
||||
wsLogger.warn('Пользователь не найден');
|
||||
sendMessage(ws, { type: 'auth:error', message: 'Пользователь не найден' });
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Сохраняем аутентифицированного клиента
|
||||
authenticatedClients.set(ws, {
|
||||
userId: user.id,
|
||||
rooms: new Set(),
|
||||
lastPing: new Date()
|
||||
});
|
||||
|
||||
clearTimeout(authTimeout);
|
||||
|
||||
wsLogger.log(`Пользователь ${user.id} (${user.username}) аутентифицирован`);
|
||||
sendMessage(ws, { type: 'auth:success', userId: user.id });
|
||||
|
||||
} catch (error) {
|
||||
wsLogger.error('Ошибка аутентификации:', error);
|
||||
sendMessage(ws, { type: 'auth:error', message: 'Неверный токен' });
|
||||
ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Подписка на комнату (тип событий)
|
||||
*/
|
||||
function handleSubscribe(ws: WebSocket, roomType: RoomType): void {
|
||||
const client = authenticatedClients.get(ws);
|
||||
|
||||
if (!client) {
|
||||
sendMessage(ws, { type: 'error', message: 'Не аутентифицирован' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Добавляем комнату в список клиента
|
||||
client.rooms.add(roomType);
|
||||
|
||||
// Добавляем клиента в комнату
|
||||
if (!rooms[roomType].has(client.userId)) {
|
||||
rooms[roomType].set(client.userId, new Set());
|
||||
}
|
||||
rooms[roomType].get(client.userId)!.add(ws);
|
||||
|
||||
wsLogger.log(`Пользователь ${client.userId} подписан на ${roomType}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Отписка от комнаты
|
||||
*/
|
||||
function handleUnsubscribe(ws: WebSocket, roomType: RoomType): void {
|
||||
const client = authenticatedClients.get(ws);
|
||||
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
|
||||
client.rooms.delete(roomType);
|
||||
|
||||
const userSockets = rooms[roomType].get(client.userId);
|
||||
if (userSockets) {
|
||||
userSockets.delete(ws);
|
||||
if (userSockets.size === 0) {
|
||||
rooms[roomType].delete(client.userId);
|
||||
}
|
||||
}
|
||||
|
||||
wsLogger.log(`Пользователь ${client.userId} отписан от ${roomType}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка отключения клиента
|
||||
*/
|
||||
function handleDisconnect(ws: WebSocket): void {
|
||||
const client = authenticatedClients.get(ws);
|
||||
|
||||
if (client) {
|
||||
wsLogger.log(`Пользователь ${client.userId} отключился`);
|
||||
|
||||
// Удаляем из всех комнат
|
||||
client.rooms.forEach(roomType => {
|
||||
const userSockets = rooms[roomType].get(client.userId);
|
||||
if (userSockets) {
|
||||
userSockets.delete(ws);
|
||||
if (userSockets.size === 0) {
|
||||
rooms[roomType].delete(client.userId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
authenticatedClients.delete(ws);
|
||||
} else {
|
||||
wsLogger.log('Неаутентифицированный клиент отключился');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправка сообщения клиенту
|
||||
*/
|
||||
function sendMessage(ws: WebSocket, message: ServerToClientEvents): void {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast сообщения всем клиентам в комнате определённого пользователя
|
||||
*/
|
||||
export function broadcastToUser(userId: number, roomType: RoomType, message: ServerToClientEvents): void {
|
||||
const userSockets = rooms[roomType].get(userId);
|
||||
|
||||
if (userSockets && userSockets.size > 0) {
|
||||
wsLogger.log(`Отправка ${message.type} пользователю ${userId} (${userSockets.size} подключений)`);
|
||||
userSockets.forEach(ws => sendMessage(ws, message));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast всем подключённым пользователям в комнате
|
||||
*/
|
||||
export function broadcastToRoom(roomType: RoomType, message: ServerToClientEvents): void {
|
||||
const count = rooms[roomType].size;
|
||||
wsLogger.log(`Broadcast ${message.type} в комнату ${roomType} (${count} пользователей)`);
|
||||
|
||||
rooms[roomType].forEach((sockets) => {
|
||||
sockets.forEach(ws => sendMessage(ws, message));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить количество подключённых пользователей
|
||||
*/
|
||||
export function getConnectedUsersCount(): number {
|
||||
return authenticatedClients.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить статистику по комнатам
|
||||
*/
|
||||
export function getRoomsStats(): Record<RoomType, number> {
|
||||
return {
|
||||
notifications: rooms.notifications.size,
|
||||
servers: rooms.servers.size,
|
||||
tickets: rooms.tickets.size,
|
||||
balance: rooms.balance.size,
|
||||
};
|
||||
}
|
||||
50
ospabhost/backend/start-pm2.sh
Normal file
50
ospabhost/backend/start-pm2.sh
Normal file
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Цвета для вывода
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${GREEN}=== Запуск Backend через PM2 ===${NC}"
|
||||
|
||||
# Проверка, что находимся в папке backend
|
||||
if [ ! -f "ecosystem.config.js" ]; then
|
||||
echo -e "${RED}Ошибка: ecosystem.config.js не найден!${NC}"
|
||||
echo "Убедитесь, что находитесь в папке backend"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Проверка, собран ли проект
|
||||
if [ ! -d "dist" ]; then
|
||||
echo -e "${YELLOW}Папка dist не найдена. Запускаем сборку...${NC}"
|
||||
npm run build
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED}Ошибка сборки!${NC}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Создание папки для логов
|
||||
mkdir -p logs
|
||||
|
||||
# Проверка, запущен ли уже процесс
|
||||
if pm2 list | grep -q "ospab-backend"; then
|
||||
echo -e "${YELLOW}Процесс ospab-backend уже запущен. Перезапускаем...${NC}"
|
||||
pm2 reload ecosystem.config.js --env production
|
||||
else
|
||||
echo -e "${GREEN}Запускаем новый процесс...${NC}"
|
||||
pm2 start ecosystem.config.js --env production
|
||||
fi
|
||||
|
||||
# Сохранение конфигурации
|
||||
echo -e "${GREEN}Сохраняем конфигурацию PM2...${NC}"
|
||||
pm2 save
|
||||
|
||||
# Показать статус
|
||||
echo -e "\n${GREEN}=== Статус процессов ===${NC}"
|
||||
pm2 list
|
||||
|
||||
echo -e "\n${GREEN}✅ Backend успешно запущен!${NC}"
|
||||
echo -e "${YELLOW}Используйте 'pm2 logs ospab-backend' для просмотра логов${NC}"
|
||||
echo -e "${YELLOW}Используйте 'pm2 monit' для мониторинга в реальном времени${NC}"
|
||||
29
ospabhost/backend/stop-pm2.sh
Normal file
29
ospabhost/backend/stop-pm2.sh
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Цвета для вывода
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${GREEN}=== Остановка Backend (PM2) ===${NC}"
|
||||
|
||||
# Проверка, запущен ли процесс
|
||||
if ! pm2 list | grep -q "ospab-backend"; then
|
||||
echo -e "${RED}Процесс ospab-backend не найден!${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Остановка процесса
|
||||
echo -e "${YELLOW}Останавливаем ospab-backend...${NC}"
|
||||
pm2 stop ospab-backend
|
||||
|
||||
# Удаление из PM2
|
||||
echo -e "${YELLOW}Удаляем из списка PM2...${NC}"
|
||||
pm2 delete ospab-backend
|
||||
|
||||
# Сохранение конфигурации
|
||||
pm2 save
|
||||
|
||||
echo -e "\n${GREEN}✅ Backend успешно остановлен!${NC}"
|
||||
pm2 list
|
||||
149
ospabhost/backend/test-scripts/FIX_501_ERROR.md
Normal file
149
ospabhost/backend/test-scripts/FIX_501_ERROR.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Решение проблемы 501 "Method not implemented"
|
||||
|
||||
## Проблема
|
||||
|
||||
При смене root-пароля через Proxmox API получали ошибку:
|
||||
```
|
||||
AxiosError: Request failed with status code 501
|
||||
statusMessage: "Method 'POST /nodes/sv1/lxc/105/config' not implemented"
|
||||
```
|
||||
|
||||
## Причина
|
||||
|
||||
Proxmox API **не поддерживает POST** для изменения конфигурации контейнера.
|
||||
Правильный метод - **PUT**.
|
||||
|
||||
## Решение
|
||||
|
||||
### Было (неправильно):
|
||||
```typescript
|
||||
const response = await axios.post(
|
||||
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/config`,
|
||||
`password=${encodeURIComponent(newPassword)}`,
|
||||
...
|
||||
);
|
||||
```
|
||||
|
||||
### Стало (правильно):
|
||||
```typescript
|
||||
const response = await axios.put(
|
||||
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/config`,
|
||||
`password=${encodeURIComponent(newPassword)}`,
|
||||
...
|
||||
);
|
||||
```
|
||||
|
||||
## Правильные HTTP методы для Proxmox API
|
||||
|
||||
### LXC Container Config (`/nodes/{node}/lxc/{vmid}/config`)
|
||||
- `GET` - получить конфигурацию
|
||||
- `PUT` - **изменить конфигурацию** (в т.ч. пароль)
|
||||
- ❌ `POST` - не поддерживается
|
||||
|
||||
### LXC Container Status (`/nodes/{node}/lxc/{vmid}/status/{action}`)
|
||||
- `POST` - start/stop/restart/shutdown
|
||||
|
||||
### LXC Container Create (`/nodes/{node}/lxc`)
|
||||
- `POST` - создать новый контейнер
|
||||
|
||||
## Документация Proxmox VE API
|
||||
|
||||
Официальная документация: https://pve.proxmox.com/pve-docs/api-viewer/
|
||||
|
||||
Основные правила:
|
||||
1. **GET** - чтение данных
|
||||
2. **POST** - создание ресурсов, выполнение действий (start/stop)
|
||||
3. **PUT** - изменение существующих ресурсов
|
||||
4. **DELETE** - удаление ресурсов
|
||||
|
||||
## Тестирование
|
||||
|
||||
### Через curl:
|
||||
```bash
|
||||
# Получить конфигурацию (GET)
|
||||
curl -k -X GET \
|
||||
-H "Authorization: PVEAPIToken=user@pam!token=secret" \
|
||||
https://proxmox:8006/api2/json/nodes/nodename/lxc/100/config
|
||||
|
||||
# Изменить пароль (PUT)
|
||||
curl -k -X PUT \
|
||||
-H "Authorization: PVEAPIToken=user@pam!token=secret" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "password=NewPassword123" \
|
||||
https://proxmox:8006/api2/json/nodes/nodename/lxc/100/config
|
||||
```
|
||||
|
||||
### Через веб-интерфейс:
|
||||
1. Откройте панель управления сервером
|
||||
2. Вкладка "Настройки"
|
||||
3. Нажмите "Сменить root-пароль"
|
||||
4. Проверьте логи: `pm2 logs ospab-backend`
|
||||
|
||||
## Проверка прав API токена
|
||||
|
||||
Убедитесь, что API токен имеет права:
|
||||
```bash
|
||||
# В Proxmox WebUI
|
||||
# Datacenter → Permissions → API Tokens
|
||||
# Проверьте права токена:
|
||||
# - VM.Config.* (для изменения конфигурации)
|
||||
# - VM.PowerMgmt (для start/stop)
|
||||
```
|
||||
|
||||
Или через командную строку:
|
||||
```bash
|
||||
pveum user token permissions api-user@pve sv1-api-user
|
||||
```
|
||||
|
||||
## Fallback через SSH
|
||||
|
||||
Если API не работает, система автоматически использует SSH:
|
||||
```bash
|
||||
ssh root@proxmox "pct set {vmid} --password 'новый_пароль'"
|
||||
```
|
||||
|
||||
Для этого нужно:
|
||||
1. Настроить SSH ключи:
|
||||
```bash
|
||||
ssh-keygen -t rsa -b 4096
|
||||
ssh-copy-id root@proxmox_ip
|
||||
```
|
||||
|
||||
2. Проверить доступ:
|
||||
```bash
|
||||
ssh root@proxmox_ip "pct list"
|
||||
```
|
||||
|
||||
## Деплой исправления
|
||||
|
||||
```bash
|
||||
# На сервере
|
||||
cd /var/www/ospab-host/backend
|
||||
git pull
|
||||
npm run build
|
||||
pm2 restart ospab-backend
|
||||
|
||||
# Проверка логов
|
||||
pm2 logs ospab-backend --lines 50
|
||||
```
|
||||
|
||||
## Ожидаемые логи при успехе
|
||||
|
||||
```
|
||||
✅ Пароль успешно изменён для контейнера 105
|
||||
✅ Пароль успешно обновлён для сервера #17 (VMID: 105)
|
||||
```
|
||||
|
||||
## Ожидаемые логи при ошибке
|
||||
|
||||
```
|
||||
❌ Ошибка смены пароля через API: ...
|
||||
⚠️ Пробуем через SSH...
|
||||
✅ Пароль изменён через SSH для контейнера 105
|
||||
```
|
||||
|
||||
## Дополнительные ресурсы
|
||||
|
||||
- [Proxmox VE API Documentation](https://pve.proxmox.com/pve-docs/api-viewer/)
|
||||
- [Proxmox VE Administration Guide](https://pve.proxmox.com/pve-docs/pve-admin-guide.html)
|
||||
- [pct command reference](https://pve.proxmox.com/pve-docs/pct.1.html)
|
||||
144
ospabhost/backend/test-scripts/LOAD_TEST_COMMANDS.md
Normal file
144
ospabhost/backend/test-scripts/LOAD_TEST_COMMANDS.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# Простые команды для создания нагрузки на сервер
|
||||
# Выполняйте их в консоли вашего LXC контейнера через noVNC или SSH
|
||||
|
||||
## 1. БЫСТРЫЙ ТЕСТ (без установки дополнительных пакетов)
|
||||
|
||||
### CPU нагрузка (запустить в фоне)
|
||||
# Создаёт 100% нагрузку на все ядра на 2 минуты
|
||||
yes > /dev/null &
|
||||
yes > /dev/null &
|
||||
yes > /dev/null &
|
||||
yes > /dev/null &
|
||||
|
||||
# Остановить через 2 минуты:
|
||||
# killall yes
|
||||
|
||||
|
||||
### Memory нагрузка
|
||||
# Заполнить 500MB оперативки
|
||||
cat <( </dev/zero head -c 500m) <(sleep 120) | tail &
|
||||
|
||||
# Для 1GB используйте:
|
||||
# cat <( </dev/zero head -c 1000m) <(sleep 120) | tail &
|
||||
|
||||
|
||||
### Disk I/O нагрузка
|
||||
# Создать файл 1GB и записать его несколько раз
|
||||
dd if=/dev/zero of=/tmp/testfile bs=1M count=1000 oflag=direct &
|
||||
|
||||
# Удалить после теста:
|
||||
# rm /tmp/testfile
|
||||
|
||||
|
||||
### Network нагрузка
|
||||
# Скачать большой файл
|
||||
wget -O /dev/null http://speedtest.tele2.net/100MB.zip &
|
||||
|
||||
# Или создать сетевой трафик:
|
||||
# ping -f 8.8.8.8 -c 10000 &
|
||||
|
||||
|
||||
|
||||
## 2. С УСТАНОВКОЙ STRESS-NG (рекомендуется)
|
||||
|
||||
# Установить stress-ng (один раз)
|
||||
apt-get update && apt-get install -y stress-ng
|
||||
|
||||
### Комплексный тест (5 минут)
|
||||
# CPU 50%, Memory 50%, Disk I/O
|
||||
stress-ng --cpu 2 --cpu-load 50 --vm 1 --vm-bytes 512M --hdd 1 --timeout 300s
|
||||
|
||||
### Только CPU (80% нагрузка, 3 минуты)
|
||||
stress-ng --cpu 4 --cpu-load 80 --timeout 180s
|
||||
|
||||
### Только Memory (заполнить 70%, 3 минуты)
|
||||
stress-ng --vm 2 --vm-bytes 70% --timeout 180s
|
||||
|
||||
### Только Disk I/O (3 минуты)
|
||||
stress-ng --hdd 4 --timeout 180s
|
||||
|
||||
|
||||
|
||||
## 3. PYTHON СКРИПТ (если Python установлен)
|
||||
|
||||
# Создать файл test_load.py:
|
||||
cat > /tmp/test_load.py << 'EOF'
|
||||
import time
|
||||
import threading
|
||||
|
||||
def cpu_load():
|
||||
"""CPU нагрузка"""
|
||||
end_time = time.time() + 180 # 3 минуты
|
||||
while time.time() < end_time:
|
||||
[x**2 for x in range(10000)]
|
||||
|
||||
def memory_load():
|
||||
"""Memory нагрузка"""
|
||||
data = []
|
||||
for i in range(100):
|
||||
data.append(' ' * 10000000) # ~10MB каждый
|
||||
time.sleep(1)
|
||||
|
||||
# Запустить в потоках
|
||||
threads = []
|
||||
for i in range(4): # 4 потока для CPU
|
||||
t = threading.Thread(target=cpu_load)
|
||||
t.start()
|
||||
threads.append(t)
|
||||
|
||||
# Memory нагрузка
|
||||
m = threading.Thread(target=memory_load)
|
||||
m.start()
|
||||
threads.append(m)
|
||||
|
||||
# Ждать завершения
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
print("Test completed!")
|
||||
EOF
|
||||
|
||||
# Запустить:
|
||||
python3 /tmp/test_load.py &
|
||||
|
||||
|
||||
|
||||
## 4. МОНИТОРИНГ НАГРУЗКИ
|
||||
|
||||
# Установить htop для визуального мониторинга
|
||||
apt-get install -y htop
|
||||
|
||||
# Запустить htop
|
||||
htop
|
||||
|
||||
# Или использовать стандартные команды:
|
||||
top # CPU и Memory
|
||||
iostat -x 1 # Disk I/O (нужно установить: apt install sysstat)
|
||||
free -h # Memory
|
||||
uptime # Load average
|
||||
|
||||
|
||||
|
||||
## 5. ОСТАНОВИТЬ ВСЕ ТЕСТЫ
|
||||
|
||||
# Остановить все процессы нагрузки
|
||||
killall stress-ng yes dd wget python3
|
||||
|
||||
# Очистить временные файлы
|
||||
rm -f /tmp/testfile /tmp/test_load.py
|
||||
|
||||
|
||||
|
||||
## КАК ПРОВЕРИТЬ РЕЗУЛЬТАТЫ
|
||||
|
||||
1. Откройте панель управления сервером в браузере
|
||||
2. Перейдите на вкладку "Мониторинг"
|
||||
3. Выберите период "1h" или "6h"
|
||||
4. Вы увидите графики:
|
||||
- CPU usage (оранжевый график)
|
||||
- Memory usage (синий график)
|
||||
- Disk usage (зеленый график)
|
||||
- Network In/Out (фиолетовый график)
|
||||
|
||||
5. Обновите страницу через 1-2 минуты после запуска теста
|
||||
6. Используйте кнопки периодов (1h, 6h, 24h) для изменения масштаба
|
||||
168
ospabhost/backend/test-scripts/PASSWORD_CHANGE_TESTING.md
Normal file
168
ospabhost/backend/test-scripts/PASSWORD_CHANGE_TESTING.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# Тестирование смены root-пароля
|
||||
|
||||
## Как это работает
|
||||
|
||||
Система использует **3 метода** смены пароля с автоматическим fallback:
|
||||
|
||||
### Метод 1: Proxmox API (основной)
|
||||
```
|
||||
POST /api/nodes/{node}/lxc/{vmid}/config
|
||||
Body: password=новый_пароль
|
||||
```
|
||||
- Самый быстрый и надёжный
|
||||
- Работает через API токен
|
||||
- Не требует SSH доступа
|
||||
|
||||
### Метод 2: SSH + pct set (fallback)
|
||||
```bash
|
||||
ssh root@proxmox "pct set {vmid} --password 'новый_пароль'"
|
||||
```
|
||||
- Запасной вариант если API не работает
|
||||
- Требует SSH доступа от backend к Proxmox
|
||||
- Настраивается через `.env`
|
||||
|
||||
### Метод 3: Exec внутри контейнера (последний fallback)
|
||||
```
|
||||
POST /api/nodes/{node}/lxc/{vmid}/exec
|
||||
Body: { command: ["bash", "-c", "echo 'root:пароль' | chpasswd"] }
|
||||
```
|
||||
- Выполняет команду внутри контейнера
|
||||
- Работает если контейнер запущен
|
||||
- Не требует SSH
|
||||
|
||||
## Как протестировать
|
||||
|
||||
### 1. Через веб-интерфейс
|
||||
|
||||
1. Откройте панель управления сервером
|
||||
2. Перейдите на вкладку "Настройки"
|
||||
3. Нажмите кнопку "Сменить root-пароль"
|
||||
4. Подтвердите действие
|
||||
5. Новый пароль появится на вкладке "Обзор"
|
||||
|
||||
### 2. Через API (curl)
|
||||
|
||||
```bash
|
||||
# Получить токен (замените email и password)
|
||||
TOKEN=$(curl -X POST https://ospab.host:5000/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"user@example.com","password":"yourpassword"}' \
|
||||
| jq -r '.token')
|
||||
|
||||
# Сменить пароль (замените {id} на ID сервера)
|
||||
curl -X POST https://ospab.host:5000/api/server/{id}/password \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
| jq
|
||||
```
|
||||
|
||||
### 3. Проверка нового пароля
|
||||
|
||||
#### Через noVNC консоль:
|
||||
1. Откройте консоль сервера
|
||||
2. Войдите как `root`
|
||||
3. Введите новый пароль из панели
|
||||
|
||||
#### Через SSH:
|
||||
```bash
|
||||
ssh root@IP_СЕРВЕРА
|
||||
# Введите новый пароль
|
||||
```
|
||||
|
||||
## Логи и отладка
|
||||
|
||||
### Backend логи
|
||||
```bash
|
||||
# Просмотр логов в реальном времени
|
||||
pm2 logs ospab-backend
|
||||
|
||||
# Или если запущен напрямую
|
||||
tail -f /var/www/ospab-host/backend/logs/pm2-out.log
|
||||
```
|
||||
|
||||
### Что искать в логах:
|
||||
|
||||
**Успешная смена:**
|
||||
```
|
||||
✅ Пароль успешно изменён для контейнера 123
|
||||
✅ Пароль успешно обновлён для сервера #5 (VMID: 123)
|
||||
```
|
||||
|
||||
**Ошибки:**
|
||||
```
|
||||
❌ Ошибка смены пароля через API: ...
|
||||
⚠️ Основной метод не сработал, пробуем через exec...
|
||||
❌ Ошибка смены пароля через SSH: ...
|
||||
```
|
||||
|
||||
## Возможные проблемы
|
||||
|
||||
### Проблема 1: "Не удалось изменить пароль"
|
||||
|
||||
**Причина:** Нет доступа к Proxmox API или SSH
|
||||
|
||||
**Решение:**
|
||||
```bash
|
||||
# Проверьте переменные окружения
|
||||
cat /var/www/ospab-host/backend/.env | grep PROXMOX
|
||||
|
||||
# Должны быть установлены:
|
||||
PROXMOX_API_URL=https://IP:8006/api2/json
|
||||
PROXMOX_TOKEN_ID=root@pam!your-token-name
|
||||
PROXMOX_TOKEN_SECRET=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
PROXMOX_NODE=proxmox
|
||||
```
|
||||
|
||||
### Проблема 2: SSH метод не работает
|
||||
|
||||
**Причина:** Нет SSH ключей или доступа
|
||||
|
||||
**Решение:**
|
||||
```bash
|
||||
# Настройте SSH ключи (на сервере с backend)
|
||||
ssh-keygen -t rsa -b 4096
|
||||
ssh-copy-id root@PROXMOX_IP
|
||||
|
||||
# Проверьте доступ
|
||||
ssh root@PROXMOX_IP "pct list"
|
||||
```
|
||||
|
||||
### Проблема 3: Контейнер не запущен
|
||||
|
||||
**Причина:** Exec метод работает только для запущенных контейнеров
|
||||
|
||||
**Решение:**
|
||||
1. Запустите контейнер
|
||||
2. Подождите 30 секунд
|
||||
3. Попробуйте снова сменить пароль
|
||||
|
||||
## Автоматическое скрытие пароля
|
||||
|
||||
Пароль автоматически скрывается через **30 минут** после:
|
||||
- Создания сервера
|
||||
- Смены пароля
|
||||
|
||||
### Как проверить:
|
||||
1. Создайте сервер или смените пароль
|
||||
2. Пароль виден на вкладке "Обзор"
|
||||
3. Через 30 минут пароль будет заменён на "••••••••"
|
||||
4. Кнопка "Показать пароль" перестанет работать
|
||||
|
||||
### Как показать снова:
|
||||
Нажмите "Сменить root-пароль" - будет сгенерирован новый пароль и снова виден 30 минут.
|
||||
|
||||
## Частые вопросы
|
||||
|
||||
**Q: Как часто можно менять пароль?**
|
||||
A: Без ограничений. Каждая смена генерирует новый случайный пароль.
|
||||
|
||||
**Q: Можно ли задать свой пароль?**
|
||||
A: Нет, система генерирует случайный пароль 16 символов для безопасности.
|
||||
|
||||
**Q: Пароль сохраняется в базе?**
|
||||
A: Да, в зашифрованном виде в таблице `server.rootPassword`.
|
||||
|
||||
**Q: Что если я потерял пароль?**
|
||||
A: Просто нажмите "Сменить root-пароль" - будет сгенерирован новый.
|
||||
|
||||
**Q: Работает ли для KVM виртуалок?**
|
||||
A: Эта реализация для LXC контейнеров. Для KVM нужна доработка.
|
||||
81
ospabhost/backend/test-scripts/stress-test.sh
Normal file
81
ospabhost/backend/test-scripts/stress-test.sh
Normal file
@@ -0,0 +1,81 @@
|
||||
#!/bin/bash
|
||||
# Скрипт для создания тестовой нагрузки на сервер
|
||||
# Используйте этот скрипт внутри LXC контейнера
|
||||
|
||||
echo "🔥 Начинаем тест нагрузки сервера..."
|
||||
|
||||
# Установка stress-ng если не установлен
|
||||
if ! command -v stress-ng &> /dev/null; then
|
||||
echo "Установка stress-ng..."
|
||||
apt-get update && apt-get install -y stress-ng
|
||||
fi
|
||||
|
||||
# Проверяем количество ядер
|
||||
CORES=$(nproc)
|
||||
echo "Доступно CPU ядер: $CORES"
|
||||
|
||||
# Функция для CPU нагрузки (30% нагрузка)
|
||||
cpu_stress() {
|
||||
echo "📊 CPU нагрузка: 30-50%..."
|
||||
stress-ng --cpu $CORES --cpu-load 35 --timeout 300s &
|
||||
}
|
||||
|
||||
# Функция для Memory нагрузки (50% памяти)
|
||||
memory_stress() {
|
||||
echo "💾 Memory нагрузка: 50%..."
|
||||
TOTAL_MEM=$(free -m | awk '/^Mem:/{print $2}')
|
||||
TARGET_MEM=$(($TOTAL_MEM / 2))
|
||||
stress-ng --vm 2 --vm-bytes ${TARGET_MEM}M --timeout 300s &
|
||||
}
|
||||
|
||||
# Функция для Disk I/O нагрузки
|
||||
disk_stress() {
|
||||
echo "💿 Disk I/O нагрузка..."
|
||||
stress-ng --hdd 2 --hdd-bytes 50M --timeout 300s &
|
||||
}
|
||||
|
||||
# Функция для Network нагрузки (ping flood)
|
||||
network_stress() {
|
||||
echo "🌐 Network нагрузка..."
|
||||
# Генерируем сетевой трафик
|
||||
dd if=/dev/zero bs=1M count=100 2>/dev/null | dd of=/dev/null 2>/dev/null &
|
||||
}
|
||||
|
||||
# Выбор режима
|
||||
case "${1:-all}" in
|
||||
cpu)
|
||||
cpu_stress
|
||||
;;
|
||||
memory)
|
||||
memory_stress
|
||||
;;
|
||||
disk)
|
||||
disk_stress
|
||||
;;
|
||||
network)
|
||||
network_stress
|
||||
;;
|
||||
all)
|
||||
echo "🚀 Запуск полной нагрузки на 5 минут..."
|
||||
cpu_stress
|
||||
sleep 2
|
||||
memory_stress
|
||||
sleep 2
|
||||
disk_stress
|
||||
;;
|
||||
*)
|
||||
echo "Использование: $0 [cpu|memory|disk|network|all]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "✅ Нагрузка запущена! Тест будет длиться 5 минут."
|
||||
echo "📈 Откройте панель мониторинга чтобы увидеть графики."
|
||||
echo ""
|
||||
echo "Для остановки теста используйте: killall stress-ng"
|
||||
|
||||
# Ждём завершения
|
||||
wait
|
||||
echo ""
|
||||
echo "✅ Тест завершён!"
|
||||
Reference in New Issue
Block a user