BIG_UPDATE deleted vps, added s3 infrastructure.

This commit is contained in:
Georgiy Syralev
2025-11-23 14:35:16 +03:00
parent ae1f93a934
commit c4c2610480
173 changed files with 22684 additions and 5894 deletions

View File

@@ -10,6 +10,11 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*
# PM2
logs/
.pm2/
pm2-*.log
# TypeScript
*.tsbuildinfo

View 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/процесс

View 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) для детальной информации.

View 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 экземплярах и будет автоматически запускаться при перезагрузке сервера. 🚀

View File

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

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

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

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

View File

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

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

File diff suppressed because it is too large Load Diff

View File

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

View 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
```
Готово! 🎉

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

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

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

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

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

View File

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

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

View File

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

View File

@@ -1,2 +0,0 @@
// Импорт и экспорт функций для работы с Proxmox
export * from './proxmoxApi';

View File

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

View File

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

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

View File

@@ -1,6 +0,0 @@
import { checkProxmoxConnection } from './modules/server/proxmoxApi';
(async () => {
const result = await checkProxmoxConnection();
console.log('Проверка соединения с Proxmox:', result);
})();

View File

@@ -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 часов в миллисекундах
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: 'Не удалось загрузить профиль. Попробуйте позже.' });
}
};

View File

@@ -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: 'Авторизация временно недоступна. Попробуйте позже.' });
}
};

View File

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

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

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

View 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: 'Ошибка удаления изображения'
});
}
};

View File

@@ -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: 'Ошибка получения файла' });
}
}

View File

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

View File

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

View File

@@ -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 : 'Неизвестная ошибка'
});
}
};

View File

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

View File

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

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

View File

@@ -1,2 +0,0 @@
import osRoutes from './os.routes';
export default osRoutes;

View File

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

View File

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

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

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

View File

@@ -1,2 +0,0 @@
import serverRoutes from './server.routes';
export default serverRoutes;

View File

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

View File

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

View File

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

View File

@@ -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 || 'Ошибка удаления снэпшота' });
}
}

View File

@@ -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: []
};
}
}

View File

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

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

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

View File

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

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

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

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

View File

@@ -1,2 +0,0 @@
import tariffRoutes from './tariff.routes';
export default tariffRoutes;

View File

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

View File

@@ -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: 'Ошибка закрытия тикета' });
}
}

View File

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

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

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

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

View File

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

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

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

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

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

View 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

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

View 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) для изменения масштаба

View 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 нужна доработка.

View 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 "✅ Тест завершён!"