sitemap и тд

This commit is contained in:
Georgiy Syralev
2025-11-01 12:29:46 +03:00
parent 727785c7a0
commit d45baf2260
80 changed files with 9811 additions and 748 deletions

View File

@@ -0,0 +1,139 @@
# Конфигурация сети и хранилища для Proxmox
## Обзор
Теперь вы можете настроить сетевой интерфейс и диск для контейнеров/VM через переменные окружения в `.env` файле.
## Переменные окружения
### 1. Сетевой мост (Network Bridge)
```env
PROXMOX_NETWORK_BRIDGE=vmbr0
```
**Как узнать доступные мосты:**
1. Войдите в Proxmox веб-интерфейс
2. Перейдите: `Datacenter → Node (sv1) → Network`
3. Посмотрите список доступных мостов (обычно `vmbr0`, `vmbr1`, `vmbr2`)
**Изменение:**
- Просто измените значение в `.env` на нужный мост
- Например: `PROXMOX_NETWORK_BRIDGE=vmbr1`
### 2. Хранилище для дисков (Storage)
```env
PROXMOX_VM_STORAGE=local
PROXMOX_BACKUP_STORAGE=local
PROXMOX_ISO_STORAGE=local
```
**Как узнать доступные хранилища:**
1. Войдите в Proxmox веб-интерфейс
2. Перейдите: `Datacenter → Storage`
3. Посмотрите список доступных хранилищ (обычно `local`, `local-lvm`, `nfs-storage`)
**Изменение:**
- Измените значения в `.env` на нужные хранилища
- Например: `PROXMOX_VM_STORAGE=local-lvm`
## Применение изменений
После изменения `.env` файла:
### На локальной машине (разработка):
```bash
cd backend
npm run build
npm run dev
```
### На production сервере:
```bash
cd /var/www/ospab-host/ospabhost/backend
# 1. Редактируем .env файл
vim .env
# 2. Изменяем нужные переменные
# PROXMOX_NETWORK_BRIDGE=vmbr1 # например, на другой мост
# PROXMOX_VM_STORAGE=local-lvm # например, на другое хранилище
# 3. Пересобираем и перезапускаем
npm run build
pm2 restart ospab-backend
# 4. Проверяем логи
pm2 logs ospab-backend --lines 30
```
## Проверка настроек
После создания нового контейнера проверьте его конфигурацию:
```bash
# SSH на Proxmox сервер
ssh root@sv1.ospab.host
# Посмотреть конфигурацию контейнера (замените 100 на VMID)
pct config 100
# Проверить сетевой интерфейс (должен показать ваш мост)
# net0: name=eth0,bridge=vmbr0,ip=dhcp
# Проверить хранилище (должен показать ваше хранилище)
# rootfs: local:100/vm-100-disk-0.raw,size=20G
```
## Примеры конфигураций
### Конфигурация 1: Стандартная (по умолчанию)
```env
PROXMOX_NETWORK_BRIDGE=vmbr0
PROXMOX_VM_STORAGE=local
```
### Конфигурация 2: Отдельная сеть + LVM хранилище
```env
PROXMOX_NETWORK_BRIDGE=vmbr1
PROXMOX_VM_STORAGE=local-lvm
```
### Конфигурация 3: NFS хранилище
```env
PROXMOX_NETWORK_BRIDGE=vmbr0
PROXMOX_VM_STORAGE=nfs-storage
PROXMOX_BACKUP_STORAGE=nfs-storage
```
## Решение проблем
### Ошибка: "storage 'xxx' does not exist"
- Проверьте, что хранилище существует в Proxmox (Datacenter → Storage)
- Убедитесь, что имя написано правильно (чувствительно к регистру)
### Ошибка: "bridge 'xxx' does not exist"
- Проверьте, что мост существует в Proxmox (Node → Network)
- Убедитесь, что имя написано правильно (обычно `vmbr0`, `vmbr1`)
### Контейнер создаётся, но не имеет сети
- Проверьте, что мост активен и настроен правильно
- Убедитесь, что DHCP работает в вашей сети (или используйте статический IP)
## Логирование
При создании контейнера в логах backend вы увидите:
```
Создание LXC контейнера с параметрами: {
...
net0: 'name=eth0,bridge=vmbr0,ip=dhcp',
rootfs: 'local:20',
...
}
```
Проверьте эти значения, чтобы убедиться, что используются правильные настройки.

View File

@@ -0,0 +1,102 @@
import axios from 'axios';
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 || 'sv1';
function getProxmoxHeaders(): Record<string, string> {
return {
'Authorization': `PVEAPIToken=${PROXMOX_TOKEN_ID}=${PROXMOX_TOKEN_SECRET}`,
'Content-Type': 'application/json'
};
}
const httpsAgent = new https.Agent({
rejectUnauthorized: false,
keepAlive: true
});
async function checkProxmox() {
try {
console.log('📋 Проверка соединения с Proxmox...\n');
console.log('URL:', PROXMOX_API_URL);
console.log('NODE:', PROXMOX_NODE);
console.log('TOKEN_ID:', PROXMOX_TOKEN_ID);
console.log('---');
// 1. Проверка версии
console.log('\n1⃣ Проверка версии Proxmox...');
const versionRes = await axios.get(`${PROXMOX_API_URL}/version`, {
headers: getProxmoxHeaders(),
timeout: 10000,
httpsAgent
});
console.log('✅ Версия:', versionRes.data?.data?.version);
// 2. Проверка storage
console.log('\n2⃣ Получение списка storage на узле ' + PROXMOX_NODE + '...');
const storageRes = await axios.get(
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/storage`,
{
headers: getProxmoxHeaders(),
timeout: 10000,
httpsAgent
}
);
if (storageRes.data?.data) {
console.log('✅ Доступные storage:');
storageRes.data.data.forEach((storage: any) => {
console.log(` - ${storage.storage} (type: ${storage.type}, enabled: ${storage.enabled ? 'да' : 'нет'})`);
});
}
// 3. Проверка контейнеров
console.log('\n3⃣ Получение списка контейнеров...');
const containersRes = await axios.get(
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc`,
{
headers: getProxmoxHeaders(),
timeout: 10000,
httpsAgent
}
);
if (containersRes.data?.data) {
console.log(`✅ Найдено контейнеров: ${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...');
const vmidRes = await axios.get(`${PROXMOX_API_URL}/cluster/nextid`, {
headers: getProxmoxHeaders(),
timeout: 10000,
httpsAgent
});
console.log('✅ Следующий VMID:', vmidRes.data?.data);
console.log('\n✅ Все проверки пройдены успешно!');
} catch (error: any) {
console.error('\n❌ Ошибка:', error.message);
console.error('Code:', error.code);
console.error('Status:', error.response?.status);
if (error.response?.data?.errors) {
console.error('Детали:', error.response.data.errors);
}
console.log('\n💡 РЕКОМЕНДАЦИЯ:');
console.log('1. Проверьте права API токена на узле');
console.log('2. Запустите на Proxmox сервере: pvesm status (чтобы узнать реальные storage)');
console.log('3. Проверьте соединение SSH: ssh -o StrictHostKeyChecking=no root@sv1.ospab.host');
process.exit(1);
}
}
checkProxmox();

View File

@@ -0,0 +1,48 @@
#!/bin/bash
# Запустить на Proxmox: ssh root@sv1.ospab.host < find-storage.sh
echo "════════════════════════════════════════════════════════════"
echo "🔍 ПОИСК ВСЕХ STORAGE И ДИСКОВ НА PROXMOX"
echo "════════════════════════════════════════════════════════════"
echo ""
echo "1ВСЕ storage (включая отключённые):"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
pvesm status
echo ""
echo "2ВСЕ физические диски и разделы:"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
lsblk -o NAME,SIZE,TYPE,MOUNTPOINT,FSTYPE
echo ""
echo "3⃣ Использование дискового пространства:"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
df -h | grep -E "Filesystem|local|vm-storage|root|dev"
echo ""
echo "4⃣ LVM volumes (если используется):"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
lvs 2>/dev/null || echo "LVM не используется"
echo ""
echo "5⃣ Конфигурация storage в Proxmox:"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
cat /etc/pve/storage.cfg
echo ""
echo "6⃣ Права на storage директориях:"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
ls -lh /mnt/pve/ 2>/dev/null || echo "Нет /mnt/pve"
echo ""
echo "════════════════════════════════════════════════════════════"
echo "✅ Анализ завершен"
echo "════════════════════════════════════════════════════════════"
echo ""
echo "📝 ИНСТРУКЦИЯ:"
echo "1. Найдите в выводе выше ваш EXTRA диск (не local)"
echo "2. Проверьте строку 'NAME' в pvesm status - это точное имя storage"
echo "3. Обновите в backend/.env: PROXMOX_VM_STORAGE=<НАЙДЕННОЕМЯ>"
echo "4. Перезагрузите backend"
echo ""

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env node
/**
* Генератор SSO секретного ключа
*
* Использование:
* node generate-sso-secret.js
*/
const crypto = require('crypto');
// Генерируем 64 символьный hex ключ (32 байта)
const ssoSecret = crypto.randomBytes(32).toString('hex');
console.log('\n═══════════════════════════════════════════════════════════════');
console.log(' SSO SECRET KEY');
console.log('═══════════════════════════════════════════════════════════════\n');
console.log('Ваш новый SSO_SECRET_KEY:\n');
console.log(` ${ssoSecret}\n`);
console.log('─────────────────────────────────────────────────────────────── ');
console.log('\n📋 Как использовать:\n');
console.log('1. Скопируйте ключ выше');
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');

View File

@@ -0,0 +1,104 @@
# Nginx конфигурация для React приложения с OAuth (ospab-host)
# Файл: /etc/nginx/sites-available/ospab-oauth
#
# ⚠️ ВАЖНО: Этот конфиг для React приложения (ospab-host)
# Для PHP сайта-визитки используйте nginx-visit.conf
#
# Установка на сервере:
# 1. sudo cp nginx-oauth.conf /etc/nginx/sites-available/ospab-oauth
# 2. sudo ln -s /etc/nginx/sites-available/ospab-oauth /etc/nginx/sites-enabled/
# 3. sudo nginx -t && sudo systemctl reload nginx
server {
listen 443 ssl http2;
server_name ospab.host;
# SSL сертификаты (замените на свои пути)
ssl_certificate /etc/letsencrypt/live/ospab.host/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ospab.host/privkey.pem;
# SSL настройки
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# Логи
access_log /var/log/nginx/ospab-oauth-access.log;
error_log /var/log/nginx/ospab-oauth-error.log;
# Проксирование OAuth роутов на backend (порт 5000)
location /api/auth/ {
proxy_pass http://localhost:5000;
proxy_http_version 1.1;
# WebSocket поддержка (если нужно)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
# Заголовки для корректной работы OAuth
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_cache_bypass $http_upgrade;
# Таймауты
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# Все остальные API запросы (опционально)
location /api/ {
proxy_pass http://localhost:5000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Таймауты
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# Статические файлы чеков
location /uploads/ {
proxy_pass http://localhost:5000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Frontend сайт (React/Vite) - все остальные запросы
location / {
root /var/www/ospab-host/frontend/dist;
try_files $uri $uri/ /index.html;
# Кэширование статических файлов
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Отключить кэширование для index.html
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
}
}
# Редирект с HTTP на HTTPS
server {
listen 80;
server_name ospab.host;
return 301 https://$server_name$request_uri;
}

View File

@@ -0,0 +1,100 @@
# Nginx конфигурация для сайта-визитки (PHP)
# Файл: /etc/nginx/sites-available/ospab-visit
#
# Установка на сервере:
# 1. sudo cp nginx-visit.conf /etc/nginx/sites-available/ospab-visit
# 2. sudo ln -s /etc/nginx/sites-available/ospab-visit /etc/nginx/sites-enabled/
# 3. Проверьте версию PHP: php -v
# 4. Измените строку fastcgi_pass если версия PHP другая (php8.1, php8.3 и т.д.)
# 5. sudo nginx -t && sudo systemctl reload nginx
#
# Требования:
# - PHP 8.1+ и PHP-FPM установлены
# - SSL сертификаты Let's Encrypt настроены
# - Директория /var/www/ospab-visit создана
server {
listen 443 ssl http2;
server_name ospab.host www.ospab.host;
# SSL сертификаты
ssl_certificate /etc/letsencrypt/live/ospab.host/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ospab.host/privkey.pem;
# SSL настройки
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# Логи
access_log /var/log/nginx/ospab-visit-access.log;
error_log /var/log/nginx/ospab-visit-error.log;
# Корневая директория сайта
root /var/www/ospab-visit;
index index.php index.html index.htm;
# Максимальный размер загружаемых файлов
client_max_body_size 100M;
# Основная локация для PHP
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# Обработка PHP файлов через PHP-FPM
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock; # Измените версию PHP если нужно
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
# Таймауты для PHP
fastcgi_connect_timeout 60s;
fastcgi_send_timeout 60s;
fastcgi_read_timeout 60s;
}
# Запрет доступа к скрытым файлам
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
# Кэширование статических файлов
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Безопасность - запрет доступа к служебным файлам
location ~ /\.ht {
deny all;
}
# Запрет доступа к composer файлам
location ~ ^/(composer\.(json|lock)|package\.(json|lock))$ {
deny all;
}
}
# Редирект с HTTP на HTTPS
server {
listen 80;
server_name ospab.host www.ospab.host;
return 301 https://ospab.host$request_uri;
}
# Редирект с www на без www (опционально)
server {
listen 443 ssl http2;
server_name www.ospab.host;
ssl_certificate /etc/letsencrypt/live/ospab.host/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ospab.host/privkey.pem;
return 301 https://ospab.host$request_uri;
}

View File

@@ -18,8 +18,14 @@
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"express-session": "^1.18.2",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
"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",
"ws": "^8.18.3",
@@ -30,9 +36,13 @@
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.23",
"@types/express-session": "^1.18.2",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.0.0",
"@types/node": "^20.12.12",
"@types/passport": "^1.0.17",
"@types/passport-github": "^1.1.12",
"@types/passport-google-oauth20": "^2.0.16",
"@types/xterm": "^2.0.3",
"prisma": "^6.16.2",
"ts-node-dev": "^2.0.0",
@@ -274,6 +284,16 @@
"@types/send": "*"
}
},
"node_modules/@types/express-session": {
"version": "1.18.2",
"resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.2.tgz",
"integrity": "sha512-k+I0BxwVXsnEU2hV77cCobC08kIsn4y44C3gC0b46uxZVMaXA04lSPgRLR/bSL2w0t0ShJiG8o4jPzRG/nscFg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
@@ -325,6 +345,62 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/oauth": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.6.tgz",
"integrity": "sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/passport": {
"version": "1.0.17",
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz",
"integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/passport-github": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@types/passport-github/-/passport-github-1.1.12.tgz",
"integrity": "sha512-VJpMEIH+cOoXB694QgcxuvWy2wPd1Oq3gqrg2Y9DMVBYs9TmH9L14qnqPDZsNMZKBDH+SvqRsGZj9SgHYeDgcA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*",
"@types/passport": "*",
"@types/passport-oauth2": "*"
}
},
"node_modules/@types/passport-google-oauth20": {
"version": "2.0.16",
"resolved": "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.16.tgz",
"integrity": "sha512-ayXK2CJ7uVieqhYOc6k/pIr5pcQxOLB6kBev+QUGS7oEZeTgIs1odDobXRqgfBPvXzl0wXCQHftV5220czZCPA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*",
"@types/passport": "*",
"@types/passport-oauth2": "*"
}
},
"node_modules/@types/passport-oauth2": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.8.0.tgz",
"integrity": "sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*",
"@types/oauth": "*",
"@types/passport": "*"
}
},
"node_modules/@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
@@ -521,6 +597,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64url": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz",
"integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
@@ -1115,6 +1200,40 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-session": {
"version": "1.18.2",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz",
"integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.7",
"debug": "2.6.9",
"depd": "~2.0.0",
"on-headers": "~1.1.0",
"parseurl": "~1.3.3",
"safe-buffer": "5.2.1",
"uid-safe": "~2.1.5"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/express-session/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express-session/node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/exsolve": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz",
@@ -1402,6 +1521,15 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
@@ -1840,6 +1968,12 @@
"node": "^14.16.0 || >=16.10.0"
}
},
"node_modules/oauth": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz",
"integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==",
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -1880,6 +2014,15 @@
"node": ">= 0.8"
}
},
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -1899,6 +2042,134 @@
"node": ">= 0.8"
}
},
"node_modules/passport": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
"license": "MIT",
"dependencies": {
"passport-strategy": "1.x.x",
"pause": "0.0.1",
"utils-merge": "^1.0.1"
},
"engines": {
"node": ">= 0.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/passport-github": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/passport-github/-/passport-github-1.1.0.tgz",
"integrity": "sha512-XARXJycE6fFh/dxF+Uut8OjlwbFEXgbPVj/+V+K7cvriRK7VcAOm+NgBmbiLM9Qv3SSxEAV+V6fIk89nYHXa8A==",
"license": "MIT",
"dependencies": {
"passport-oauth2": "1.x.x"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/passport-google-oauth20": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz",
"integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==",
"license": "MIT",
"dependencies": {
"passport-oauth2": "1.x.x"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/passport-oauth": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/passport-oauth/-/passport-oauth-1.0.0.tgz",
"integrity": "sha512-4IZNVsZbN1dkBzmEbBqUxDG8oFOIK81jqdksE3HEb/vI3ib3FMjbiZZ6MTtooyYZzmKu0BfovjvT1pdGgIq+4Q==",
"dependencies": {
"passport-oauth1": "1.x.x",
"passport-oauth2": "1.x.x"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/passport-oauth1": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/passport-oauth1/-/passport-oauth1-1.3.0.tgz",
"integrity": "sha512-8T/nX4gwKTw0PjxP1xfD0QhrydQNakzeOpZ6M5Uqdgz9/a/Ag62RmJxnZQ4LkbdXGrRehQHIAHNAu11rCP46Sw==",
"license": "MIT",
"dependencies": {
"oauth": "0.9.x",
"passport-strategy": "1.x.x",
"utils-merge": "1.x.x"
},
"engines": {
"node": ">= 0.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/passport-oauth1/node_modules/oauth": {
"version": "0.9.15",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
"license": "MIT"
},
"node_modules/passport-oauth2": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz",
"integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==",
"license": "MIT",
"dependencies": {
"base64url": "3.x.x",
"oauth": "0.10.x",
"passport-strategy": "1.x.x",
"uid2": "0.0.x",
"utils-merge": "1.x.x"
},
"engines": {
"node": ">= 0.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/passport-strategy": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
"integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/passport-yandex": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/passport-yandex/-/passport-yandex-0.0.5.tgz",
"integrity": "sha512-zw0JR2jLrPGhF7eAzVJb7CvAd8Uacy7dSckzt0bzonTBDbsWx2wdmVDa11Kg4fDLirHkQF72nM0ijDs8oKdO3A==",
"license": "MIT",
"dependencies": {
"passport-oauth": "1.0.x",
"pkginfo": "0.3.x"
},
"engines": {
"node": ">= 0.10.0"
}
},
"node_modules/passport-yandex/node_modules/pkginfo": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/pkginfo/-/pkginfo-0.3.1.tgz",
"integrity": "sha512-yO5feByMzAp96LtP58wvPKSbaKAi/1C4kV9XpTctr6EepnP6F33RBNOiVrdz9BrPA98U2BMFsTNHo44TWcbQ2A==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@@ -1929,6 +2200,11 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/pause": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
@@ -2050,6 +2326,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/random-bytes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -2623,6 +2908,24 @@
"node": ">=14.17"
}
},
"node_modules/uid-safe": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
"license": "MIT",
"dependencies": {
"random-bytes": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/uid2": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz",
"integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==",
"license": "MIT"
},
"node_modules/undici": {
"version": "6.22.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.22.0.tgz",

View File

@@ -21,8 +21,14 @@
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"express-session": "^1.18.2",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
"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",
"ws": "^8.18.3",
@@ -33,9 +39,13 @@
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.23",
"@types/express-session": "^1.18.2",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.0.0",
"@types/node": "^20.12.12",
"@types/passport": "^1.0.17",
"@types/passport-github": "^1.1.12",
"@types/passport-google-oauth20": "^2.0.16",
"@types/xterm": "^2.0.3",
"prisma": "^6.16.2",
"ts-node-dev": "^2.0.0",

View File

@@ -0,0 +1,6 @@
-- Добавление полей для интеграции с VPS Panel
ALTER TABLE `server` ADD COLUMN `panelVpsId` INT,
ADD COLUMN `panelSyncStatus` VARCHAR(255) DEFAULT 'pending';
-- Создание индекса для быстрого поиска серверов по ID на панели
CREATE INDEX `idx_panelVpsId` ON `server`(`panelVpsId`);

View File

@@ -66,6 +66,12 @@ model Server {
diskUsage Float? @default(0)
networkIn Float? @default(0)
networkOut Float? @default(0)
// Автоматические платежи
nextPaymentDate DateTime? // Дата следующего списания
autoRenew Boolean @default(true) // Автопродление
payments Payment[]
@@map("server")
}
@@ -78,12 +84,15 @@ model User {
createdAt DateTime @default(now())
plans Plan[] @relation("UserPlans")
operator Int @default(0)
isAdmin Boolean @default(false) // Админские права
tickets Ticket[] @relation("UserTickets")
responses Response[] @relation("OperatorResponses")
checks Check[] @relation("UserChecks")
balance Float @default(0)
servers Server[]
notifications Notification[]
payments Payment[]
transactions Transaction[] // История всех транзакций
@@map("user")
}
@@ -160,4 +169,36 @@ model Notification {
createdAt DateTime @default(now())
@@map("notification")
}
// Автоматические платежи за серверы
model Payment {
id Int @id @default(autoincrement())
userId Int
serverId Int
amount Float
status String @default("pending") // pending, success, failed
type String // subscription, manual
createdAt DateTime @default(now())
processedAt DateTime?
user User @relation(fields: [userId], references: [id])
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
@@map("payment")
}
// История всех транзакций (пополнения, списания, возвраты)
model Transaction {
id Int @id @default(autoincrement())
userId Int
amount Float
type String // deposit (пополнение), withdrawal (списание), refund (возврат)
description String
balanceBefore Float
balanceAfter Float
adminId Int? // ID админа, если операция выполнена админом
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
@@map("transaction")
}

View File

@@ -0,0 +1,43 @@
#!/bin/bash
# Скрипт диагностики для Proxmox - запустить на сервере Proxmox
echo "════════════════════════════════════════════════════════════"
echo " ДИАГНОСТИКА PROXMOX ДЛЯ OSPABHOST"
echo "════════════════════════════════════════════════════════════"
echo ""
echo "1⃣ Версия Proxmox:"
pveversion
echo ""
echo "2⃣ Доступные storage pools:"
pvesm status --enabled 1
echo ""
echo "3⃣ Существующие контейнеры LXC:"
pct list
echo ""
echo "4⃣ API пользователи и токены:"
echo "Users:"
pveum user list | grep -E "api-user|ospab"
echo ""
echo "Tokens:"
pveum token list
echo ""
echo "5⃣ Права API пользователя:"
pveum acl list | grep api-user
echo ""
echo "6⃣ Сетевые интерфейсы:"
ip -br address
echo ""
echo "7⃣ Логи Proxmox (последние 20 строк):"
tail -20 /var/log/pve/api2-access.log
echo ""
echo "════════════════════════════════════════════════════════════"
echo "✅ Диагностика завершена"
echo "════════════════════════════════════════════════════════════"

View File

@@ -0,0 +1,24 @@
import paymentService from '../modules/payment/payment.service';
/**
* Cron-задача для обработки автоматических платежей
* Запускается каждые 6 часов
*/
export function startPaymentCron() {
// Запускаем сразу при старте
console.log('[Payment Cron] Запуск обработки автоматических платежей...');
paymentService.processAutoPayments().catch((err: any) => {
console.error('[Payment Cron] Ошибка при обработке платежей:', err);
});
// Затем каждые 6 часов
setInterval(async () => {
console.log('[Payment Cron] Запуск обработки автоматических платежей...');
try {
await paymentService.processAutoPayments();
console.log('[Payment Cron] Обработка завершена');
} catch (error) {
console.error('[Payment Cron] Ошибка при обработке платежей:', error);
}
}, 6 * 60 * 60 * 1000); // 6 часов в миллисекундах
}

View File

@@ -13,13 +13,12 @@ dotenv.config();
const app = express();
// ИСПРАВЛЕНО: более точная настройка CORS
app.use(cors({
origin: [
'http://localhost:3000',
'http://localhost:5173',
'https://ospab.host'
], // Vite обычно использует 5173
],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
@@ -27,7 +26,6 @@ app.use(cors({
app.use(express.json());
// Добавим логирование для отладки
app.use((req, res, next) => {
console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`);
next();
@@ -36,7 +34,6 @@ app.use((req, res, next) => {
import { checkProxmoxConnection } from './modules/server/proxmoxApi';
app.get('/', async (req, res) => {
// Проверка соединения с Proxmox
let proxmoxStatus;
try {
proxmoxStatus = await checkProxmoxConnection();
@@ -53,9 +50,68 @@ app.get('/', async (req, res) => {
});
});
// ==================== SITEMAP ====================
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' },
];
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n';
for (const page of staticPages) {
xml += ' <url>\n';
xml += ` <loc>${baseUrl}${page.loc}</loc>\n`;
xml += ` <priority>${page.priority}</priority>\n`;
xml += ` <changefreq>${page.changefreq}</changefreq>\n`;
xml += ' </url>\n';
}
xml += '</urlset>';
res.header('Content-Type', 'application/xml');
res.send(xml);
});
// ==================== ROBOTS.TXT ====================
app.get('/robots.txt', (req, res) => {
const robots = `User-agent: *
Allow: /
Allow: /about
Allow: /tariffs
Allow: /login
Allow: /register
Allow: /terms
Disallow: /dashboard
Disallow: /api/
Disallow: /admin
Disallow: /private
Sitemap: https://ospab.host/sitemap.xml
# Google
User-agent: Googlebot
Allow: /
Crawl-delay: 0
# Yandex
User-agent: Yandexbot
Allow: /
Crawl-delay: 0`;
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')));
@@ -73,9 +129,10 @@ import { setupConsoleWSS } from './modules/server/server.console';
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.crt'),
cert: fs.readFileSync('/etc/apache2/ssl/ospab.host.fullchain.crt'),
};
const httpsServer = https.createServer(sslOptions, app);
@@ -84,4 +141,6 @@ setupConsoleWSS(httpsServer);
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`);
});

View File

@@ -0,0 +1,223 @@
import { Request, Response } from 'express';
import {
requestPasswordChange,
confirmPasswordChange,
requestUsernameChange,
confirmUsernameChange,
requestAccountDeletion,
confirmAccountDeletion,
getUserInfo,
} from './account.service';
/**
* Получить информацию о текущем пользователе
*/
export const getAccountInfo = async (req: Request, res: Response) => {
try {
const userId = (req.user as any)?.id;
if (!userId) {
return res.status(401).json({ error: 'Не авторизован' });
}
const userInfo = await getUserInfo(userId);
res.json(userInfo);
} catch (error) {
console.error('Ошибка получения информации об аккаунте:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
};
/**
* Запрос на смену пароля (отправка кода)
*/
export const requestPasswordChangeHandler = async (req: Request, res: Response) => {
try {
const userId = (req.user as any)?.id;
if (!userId) {
return res.status(401).json({ error: 'Не авторизован' });
}
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) {
return res.status(400).json({ error: 'Текущий и новый пароль обязательны' });
}
if (newPassword.length < 6) {
return res.status(400).json({ error: 'Новый пароль должен быть минимум 6 символов' });
}
// Проверка текущего пароля
const bcrypt = require('bcrypt');
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
return res.status(404).json({ error: 'Пользователь не найден' });
}
if (user.password) {
const isValidPassword = await bcrypt.compare(currentPassword, user.password);
if (!isValidPassword) {
return res.status(400).json({ error: 'Неверный текущий пароль' });
}
}
await requestPasswordChange(userId, newPassword);
res.json({
success: true,
message: 'Код подтверждения отправлен на вашу почту'
});
} catch (error: any) {
console.error('Ошибка запроса смены пароля:', error);
res.status(500).json({ error: error.message || 'Ошибка сервера' });
}
};
/**
* Подтверждение смены пароля
*/
export const confirmPasswordChangeHandler = async (req: Request, res: Response) => {
try {
const userId = (req.user as any)?.id;
if (!userId) {
return res.status(401).json({ error: 'Не авторизован' });
}
const { code } = req.body;
if (!code) {
return res.status(400).json({ error: 'Код подтверждения обязателен' });
}
await confirmPasswordChange(userId, code);
res.json({
success: true,
message: 'Пароль успешно изменён'
});
} catch (error: any) {
console.error('Ошибка подтверждения смены пароля:', error);
res.status(400).json({ error: error.message || 'Ошибка подтверждения' });
}
};
/**
* Запрос на смену имени пользователя (отправка кода)
*/
export const requestUsernameChangeHandler = async (req: Request, res: Response) => {
try {
const userId = (req.user as any)?.id;
if (!userId) {
return res.status(401).json({ error: 'Не авторизован' });
}
const { newUsername } = req.body;
if (!newUsername) {
return res.status(400).json({ error: 'Новое имя пользователя обязательно' });
}
if (newUsername.length < 3 || newUsername.length > 20) {
return res.status(400).json({
error: 'Имя пользователя должно быть от 3 до 20 символов'
});
}
if (!/^[a-zA-Z0-9_-]+$/.test(newUsername)) {
return res.status(400).json({
error: 'Имя пользователя может содержать только буквы, цифры, дефис и подчёркивание'
});
}
await requestUsernameChange(userId, newUsername);
res.json({
success: true,
message: 'Код подтверждения отправлен на вашу почту'
});
} catch (error: any) {
console.error('Ошибка запроса смены имени:', error);
res.status(400).json({ error: error.message || 'Ошибка сервера' });
}
};
/**
* Подтверждение смены имени пользователя
*/
export const confirmUsernameChangeHandler = async (req: Request, res: Response) => {
try {
const userId = (req.user as any)?.id;
if (!userId) {
return res.status(401).json({ error: 'Не авторизован' });
}
const { code } = req.body;
if (!code) {
return res.status(400).json({ error: 'Код подтверждения обязателен' });
}
await confirmUsernameChange(userId, code);
res.json({
success: true,
message: 'Имя пользователя успешно изменено'
});
} catch (error: any) {
console.error('Ошибка подтверждения смены имени:', error);
res.status(400).json({ error: error.message || 'Ошибка подтверждения' });
}
};
/**
* Запрос на удаление аккаунта (отправка кода)
*/
export const requestAccountDeletionHandler = async (req: Request, res: Response) => {
try {
const userId = (req.user as any)?.id;
if (!userId) {
return res.status(401).json({ error: 'Не авторизован' });
}
await requestAccountDeletion(userId);
res.json({
success: true,
message: 'Код подтверждения отправлен на вашу почту. После подтверждения аккаунт будет удалён безвозвратно.'
});
} catch (error: any) {
console.error('Ошибка запроса удаления аккаунта:', error);
res.status(500).json({ error: error.message || 'Ошибка сервера' });
}
};
/**
* Подтверждение удаления аккаунта
*/
export const confirmAccountDeletionHandler = async (req: Request, res: Response) => {
try {
const userId = (req.user as any)?.id;
if (!userId) {
return res.status(401).json({ error: 'Не авторизован' });
}
const { code } = req.body;
if (!code) {
return res.status(400).json({ error: 'Код подтверждения обязателен' });
}
await confirmAccountDeletion(userId, code);
res.json({
success: true,
message: 'Аккаунт успешно удалён'
});
} catch (error: any) {
console.error('Ошибка подтверждения удаления аккаунта:', error);
res.status(400).json({ error: error.message || 'Ошибка подтверждения' });
}
};

View File

@@ -0,0 +1,65 @@
import { Router } from 'express';
import { authMiddleware } from '../auth/auth.middleware';
import {
getAccountInfo,
requestPasswordChangeHandler,
confirmPasswordChangeHandler,
requestUsernameChangeHandler,
confirmUsernameChangeHandler,
requestAccountDeletionHandler,
confirmAccountDeletionHandler,
} from './account.controller';
const router = Router();
// Все маршруты требуют авторизации
router.use(authMiddleware);
/**
* GET /api/account/info
* Получить информацию об аккаунте
*/
router.get('/info', getAccountInfo);
/**
* POST /api/account/password/request
* Запросить смену пароля (отправка кода на email)
* Body: { currentPassword: string, newPassword: string }
*/
router.post('/password/request', requestPasswordChangeHandler);
/**
* POST /api/account/password/confirm
* Подтвердить смену пароля
* Body: { code: string }
*/
router.post('/password/confirm', confirmPasswordChangeHandler);
/**
* POST /api/account/username/request
* Запросить смену имени пользователя (отправка кода на email)
* Body: { newUsername: string }
*/
router.post('/username/request', requestUsernameChangeHandler);
/**
* POST /api/account/username/confirm
* Подтвердить смену имени пользователя
* Body: { code: string }
*/
router.post('/username/confirm', confirmUsernameChangeHandler);
/**
* POST /api/account/delete/request
* Запросить удаление аккаунта (отправка кода на email)
*/
router.post('/delete/request', requestAccountDeletionHandler);
/**
* POST /api/account/delete/confirm
* Подтвердить удаление аккаунта
* Body: { code: string }
*/
router.post('/delete/confirm', confirmAccountDeletionHandler);
export default router;

View File

@@ -0,0 +1,307 @@
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcrypt';
import crypto from 'crypto';
import nodemailer from 'nodemailer';
const prisma = new PrismaClient();
// Настройка транспорта для email
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
secure: false,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
// Временное хранилище кодов подтверждения (в production лучше использовать Redis)
interface VerificationCode {
code: string;
userId: number;
type: 'password' | 'username' | 'delete';
newValue?: string;
expiresAt: Date;
}
const verificationCodes = new Map<string, VerificationCode>();
// Очистка устаревших кодов каждые 5 минут
setInterval(() => {
const now = new Date();
for (const [key, value] of verificationCodes.entries()) {
if (value.expiresAt < now) {
verificationCodes.delete(key);
}
}
}, 5 * 60 * 1000);
/**
* Генерация 6-значного кода подтверждения
*/
function generateVerificationCode(): string {
return crypto.randomInt(100000, 999999).toString();
}
/**
* Отправка email с кодом подтверждения
*/
async function sendVerificationEmail(
email: string,
code: string,
type: 'password' | 'username' | 'delete'
): Promise<void> {
const subjects = {
password: 'Подтверждение смены пароля',
username: 'Подтверждение смены имени пользователя',
delete: 'Подтверждение удаления аккаунта',
};
const messages = {
password: 'Вы запросили смену пароля на ospab.host',
username: 'Вы запросили смену имени пользователя на ospab.host',
delete: 'Вы запросили удаление аккаунта на ospab.host',
};
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }
.content { background: #f9f9f9; padding: 30px; border-radius: 0 0 10px 10px; }
.code-box { background: white; border: 2px dashed #667eea; padding: 20px; text-align: center; margin: 20px 0; border-radius: 8px; }
.code { font-size: 32px; font-weight: bold; color: #667eea; letter-spacing: 8px; }
.warning { background: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; }
.footer { text-align: center; margin-top: 20px; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>ospab.host</h1>
<p>${subjects[type]}</p>
</div>
<div class="content">
<p>Здравствуйте!</p>
<p>${messages[type]}</p>
<p>Введите этот код для подтверждения:</p>
<div class="code-box">
<div class="code">${code}</div>
</div>
<p><strong>Код действителен в течение 15 минут.</strong></p>
<div class="warning">
<strong>⚠️ Важно:</strong> Если вы не запрашивали это действие, проигнорируйте это письмо и немедленно смените пароль.
</div>
<p>С уважением,<br>Команда ospab.host</p>
</div>
<div class="footer">
<p>Это автоматическое письмо, пожалуйста, не отвечайте на него.</p>
<p>&copy; ${new Date().getFullYear()} ospab.host. Все права защищены.</p>
</div>
</div>
</body>
</html>
`;
await transporter.sendMail({
from: `"ospab.host" <${process.env.SMTP_USER}>`,
to: email,
subject: subjects[type],
html: htmlContent,
});
}
/**
* Запрос на смену пароля - отправка кода
*/
export async function requestPasswordChange(userId: number, newPassword: string): Promise<void> {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new Error('Пользователь не найден');
}
const code = generateVerificationCode();
const hashedPassword = await bcrypt.hash(newPassword, 10);
verificationCodes.set(`password_${userId}`, {
code,
userId,
type: 'password',
newValue: hashedPassword,
expiresAt: new Date(Date.now() + 15 * 60 * 1000), // 15 минут
});
await sendVerificationEmail(user.email, code, 'password');
}
/**
* Подтверждение смены пароля
*/
export async function confirmPasswordChange(userId: number, code: string): Promise<void> {
const verification = verificationCodes.get(`password_${userId}`);
if (!verification) {
throw new Error('Код не найден или истёк');
}
if (verification.code !== code) {
throw new Error('Неверный код подтверждения');
}
if (verification.expiresAt < new Date()) {
verificationCodes.delete(`password_${userId}`);
throw new Error('Код истёк');
}
if (!verification.newValue) {
throw new Error('Новый пароль не найден');
}
await prisma.user.update({
where: { id: userId },
data: { password: verification.newValue },
});
verificationCodes.delete(`password_${userId}`);
}
/**
* Запрос на смену имени пользователя - отправка кода
*/
export async function requestUsernameChange(userId: number, newUsername: string): Promise<void> {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new Error('Пользователь не найден');
}
// Проверка, что имя пользователя не занято
const existingUser = await prisma.user.findFirst({
where: { username: newUsername, id: { not: userId } },
});
if (existingUser) {
throw new Error('Имя пользователя уже занято');
}
const code = generateVerificationCode();
verificationCodes.set(`username_${userId}`, {
code,
userId,
type: 'username',
newValue: newUsername,
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
});
await sendVerificationEmail(user.email, code, 'username');
}
/**
* Подтверждение смены имени пользователя
*/
export async function confirmUsernameChange(userId: number, code: string): Promise<void> {
const verification = verificationCodes.get(`username_${userId}`);
if (!verification) {
throw new Error('Код не найден или истёк');
}
if (verification.code !== code) {
throw new Error('Неверный код подтверждения');
}
if (verification.expiresAt < new Date()) {
verificationCodes.delete(`username_${userId}`);
throw new Error('Код истёк');
}
if (!verification.newValue) {
throw new Error('Новое имя пользователя не найдено');
}
await prisma.user.update({
where: { id: userId },
data: { username: verification.newValue },
});
verificationCodes.delete(`username_${userId}`);
}
/**
* Запрос на удаление аккаунта - отправка кода
*/
export async function requestAccountDeletion(userId: number): Promise<void> {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new Error('Пользователь не найден');
}
const code = generateVerificationCode();
verificationCodes.set(`delete_${userId}`, {
code,
userId,
type: 'delete',
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
});
await sendVerificationEmail(user.email, code, 'delete');
}
/**
* Подтверждение удаления аккаунта
*/
export async function confirmAccountDeletion(userId: number, code: string): Promise<void> {
const verification = verificationCodes.get(`delete_${userId}`);
if (!verification) {
throw new Error('Код не найден или истёк');
}
if (verification.code !== code) {
throw new Error('Неверный код подтверждения');
}
if (verification.expiresAt < new Date()) {
verificationCodes.delete(`delete_${userId}`);
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 } }),
]);
verificationCodes.delete(`delete_${userId}`);
}
/**
* Получение информации о пользователе
*/
export async function getUserInfo(userId: number) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
username: true,
email: true,
balance: true,
operator: true,
createdAt: true,
},
});
if (!user) {
throw new Error('Пользователь не найден');
}
return user;
}

View File

@@ -0,0 +1,364 @@
import { Request, Response } from 'express';
import { prisma } from '../../prisma/client';
/**
* Middleware для проверки прав администратора
*/
export const requireAdmin = async (req: Request, res: Response, next: any) => {
try {
const userId = (req as any).user?.id;
if (!userId) {
return res.status(401).json({ message: 'Не авторизован' });
}
const user = await prisma.user.findUnique({
where: { id: userId }
});
if (!user || !user.isAdmin) {
return res.status(403).json({ message: 'Доступ запрещен. Требуются права администратора.' });
}
next();
} catch (error) {
console.error('Ошибка проверки прав админа:', error);
res.status(500).json({ message: 'Ошибка сервера' });
}
};
export class AdminController {
/**
* Получить всех пользователей
*/
async getAllUsers(req: Request, res: Response) {
try {
const users = await prisma.user.findMany({
select: {
id: true,
username: true,
email: true,
balance: true,
isAdmin: true,
operator: true,
createdAt: true,
_count: {
select: {
servers: true,
tickets: true
}
}
},
orderBy: {
createdAt: 'desc'
}
});
res.json({ status: 'success', data: users });
} catch (error) {
console.error('Ошибка получения пользователей:', error);
res.status(500).json({ message: 'Ошибка получения пользователей' });
}
}
/**
* Получить детальную информацию о пользователе
*/
async getUserDetails(req: Request, res: Response) {
try {
const userId = parseInt(req.params.userId);
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
servers: {
include: {
tariff: true,
os: true
}
},
checks: {
orderBy: { createdAt: 'desc' },
take: 10
},
tickets: {
orderBy: { createdAt: 'desc' },
take: 10
},
transactions: {
orderBy: { createdAt: 'desc' },
take: 20
}
}
});
if (!user) {
return res.status(404).json({ message: 'Пользователь не найден' });
}
res.json({ status: 'success', data: user });
} catch (error) {
console.error('Ошибка получения данных пользователя:', error);
res.status(500).json({ message: 'Ошибка получения данных' });
}
}
/**
* Начислить средства пользователю
*/
async addBalance(req: Request, res: Response) {
try {
const userId = parseInt(req.params.userId);
const { amount, description } = req.body;
const adminId = (req as any).user?.id;
if (!amount || amount <= 0) {
return res.status(400).json({ message: 'Некорректная сумма' });
}
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
return res.status(404).json({ message: 'Пользователь не найден' });
}
const balanceBefore = user.balance;
const balanceAfter = balanceBefore + amount;
await prisma.$transaction([
prisma.user.update({
where: { id: userId },
data: { balance: balanceAfter }
}),
prisma.transaction.create({
data: {
userId,
amount,
type: 'deposit',
description: description || `Пополнение баланса администратором`,
balanceBefore,
balanceAfter,
adminId
}
}),
prisma.notification.create({
data: {
userId,
title: 'Пополнение баланса',
message: `На ваш счёт зачислено ${amount}₽. ${description || ''}`
}
})
]);
res.json({
status: 'success',
message: `Баланс пополнен на ${amount}`,
newBalance: balanceAfter
});
} catch (error) {
console.error('Ошибка пополнения баланса:', error);
res.status(500).json({ message: 'Ошибка пополнения баланса' });
}
}
/**
* Списать средства у пользователя
*/
async withdrawBalance(req: Request, res: Response) {
try {
const userId = parseInt(req.params.userId);
const { amount, description } = req.body;
const adminId = (req as any).user?.id;
if (!amount || amount <= 0) {
return res.status(400).json({ message: 'Некорректная сумма' });
}
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
return res.status(404).json({ message: 'Пользователь не найден' });
}
if (user.balance < amount) {
return res.status(400).json({ message: 'Недостаточно средств на балансе' });
}
const balanceBefore = user.balance;
const balanceAfter = balanceBefore - amount;
await prisma.$transaction([
prisma.user.update({
where: { id: userId },
data: { balance: balanceAfter }
}),
prisma.transaction.create({
data: {
userId,
amount: -amount,
type: 'withdrawal',
description: description || `Списание администратором`,
balanceBefore,
balanceAfter,
adminId
}
}),
prisma.notification.create({
data: {
userId,
title: 'Списание с баланса',
message: `С вашего счёта списано ${amount}₽. ${description || ''}`
}
})
]);
res.json({
status: 'success',
message: `Списано ${amount}`,
newBalance: balanceAfter
});
} catch (error) {
console.error('Ошибка списания средств:', error);
res.status(500).json({ message: 'Ошибка списания средств' });
}
}
/**
* Удалить сервер пользователя
*/
async deleteServer(req: Request, res: Response) {
try {
const serverId = parseInt(req.params.serverId);
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 }
});
if (!server) {
return res.status(404).json({ message: 'Сервер не найден' });
}
// Удаляем сервер из Proxmox (если есть proxmoxId)
// TODO: Добавить вызов proxmoxApi.deleteContainer(server.proxmoxId)
// Удаляем из БД
await prisma.$transaction([
prisma.server.delete({
where: { id: serverId }
}),
prisma.notification.create({
data: {
userId: server.userId,
title: 'Сервер удалён',
message: `Ваш сервер #${serverId} был удалён администратором. ${reason ? `Причина: ${reason}` : ''}`
}
})
]);
res.json({
status: 'success',
message: `Сервер #${serverId} удалён`
});
} catch (error) {
console.error('Ошибка удаления сервера:', error);
res.status(500).json({ message: 'Ошибка удаления сервера' });
}
}
/**
* Получить статистику платформы
*/
async getStatistics(req: Request, res: Response) {
try {
const [
totalUsers,
totalServers,
activeServers,
suspendedServers,
totalBalance,
pendingChecks,
openTickets
] = await Promise.all([
prisma.user.count(),
prisma.server.count(),
prisma.server.count({ where: { status: 'running' } }),
prisma.server.count({ where: { status: 'suspended' } }),
prisma.user.aggregate({ _sum: { balance: true } }),
prisma.check.count({ where: { status: 'pending' } }),
prisma.ticket.count({ where: { status: 'open' } })
]);
// Получаем последние транзакции
const recentTransactions = await prisma.transaction.findMany({
take: 10,
orderBy: { createdAt: 'desc' },
include: {
user: {
select: {
id: true,
username: true,
email: true
}
}
}
});
res.json({
status: 'success',
data: {
users: {
total: totalUsers
},
servers: {
total: totalServers,
active: activeServers,
suspended: suspendedServers
},
balance: {
total: totalBalance._sum.balance || 0
},
checks: {
pending: pendingChecks
},
tickets: {
open: openTickets
},
recentTransactions
}
});
} catch (error) {
console.error('Ошибка получения статистики:', error);
res.status(500).json({ message: 'Ошибка получения статистики' });
}
}
/**
* Изменить права пользователя (админ/оператор)
*/
async updateUserRole(req: Request, res: Response) {
try {
const userId = parseInt(req.params.userId);
const { isAdmin, operator } = req.body;
const updates: any = {};
if (typeof isAdmin === 'boolean') updates.isAdmin = isAdmin;
if (typeof operator === 'number') updates.operator = operator;
await prisma.user.update({
where: { id: userId },
data: updates
});
res.json({
status: 'success',
message: 'Права пользователя обновлены'
});
} catch (error) {
console.error('Ошибка обновления прав:', error);
res.status(500).json({ message: 'Ошибка обновления прав' });
}
}
}
export default new AdminController();

View File

@@ -0,0 +1,24 @@
import { Router } from 'express';
import adminController, { requireAdmin } from './admin.controller';
import { authMiddleware } from '../auth/auth.middleware';
const router = Router();
// Все маршруты требуют JWT аутентификации и прав администратора
router.use(authMiddleware);
router.use(requireAdmin);
// Статистика
router.get('/statistics', adminController.getStatistics.bind(adminController));
// Управление пользователями
router.get('/users', adminController.getAllUsers.bind(adminController));
router.get('/users/:userId', adminController.getUserDetails.bind(adminController));
router.post('/users/:userId/balance/add', adminController.addBalance.bind(adminController));
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));
export default router;

View File

@@ -2,17 +2,31 @@ import type { Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { PrismaClient } from '@prisma/client';
import { validateTurnstileToken } from './turnstile.validator';
const prisma = new PrismaClient();
const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_key';
export const register = async (req: Request, res: Response) => {
const { username, email, password } = req.body;
const { username, email, password, turnstileToken } = req.body;
if (!username || !email || !password) {
return res.status(400).json({ message: 'Все поля обязательны.' });
}
// Валидация Turnstile токена
const turnstileValidation = await validateTurnstileToken(
turnstileToken,
req.ip || req.connection.remoteAddress
);
if (!turnstileValidation.success) {
return res.status(400).json({
message: turnstileValidation.message || 'Проверка капчи не прошла.',
errorCodes: turnstileValidation.errorCodes,
});
}
try {
const existingUser = await prisma.user.findUnique({ where: { email } });
if (existingUser) {
@@ -38,12 +52,25 @@ export const register = async (req: Request, res: Response) => {
};
export const login = async (req: Request, res: Response) => {
const { email, password } = req.body;
const { email, password, turnstileToken } = req.body;
if (!email || !password) {
return res.status(400).json({ message: 'Необходимо указать email и password.' });
}
// Валидация Turnstile токена
const turnstileValidation = await validateTurnstileToken(
turnstileToken,
req.ip || req.connection.remoteAddress
);
if (!turnstileValidation.success) {
return res.status(400).json({
message: turnstileValidation.message || 'Проверка капчи не прошла.',
errorCodes: turnstileValidation.errorCodes,
});
}
try {
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
@@ -79,8 +106,30 @@ export const getMe = async (req: Request, res: Response) => {
email: true,
createdAt: true,
operator: true,
isAdmin: true,
balance: true,
servers: true,
servers: {
select: {
id: true,
status: true,
createdAt: true,
ipAddress: true,
nextPaymentDate: true,
autoRenew: true,
tariff: {
select: {
name: true,
price: true,
},
},
os: {
select: {
name: true,
type: true,
},
},
},
},
tickets: true,
},
});

View File

@@ -0,0 +1,48 @@
import { Router, Request, Response } from 'express';
import passport from './passport.config';
import jwt from 'jsonwebtoken';
const router = Router();
const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_key';
const FRONTEND_URL = process.env.FRONTEND_URL || 'https://ospab.host';
// Google OAuth
router.get('/google', passport.authenticate('google', { scope: ['profile', 'email'] }));
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 token = jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: '24h' });
res.redirect(`${FRONTEND_URL}/login?token=${token}`);
}
);
// GitHub OAuth
router.get('/github', passport.authenticate('github', { scope: ['user:email'] }));
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 token = jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: '24h' });
res.redirect(`${FRONTEND_URL}/login?token=${token}`);
}
);
// Yandex OAuth
router.get('/yandex', passport.authenticate('yandex'));
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 token = jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: '24h' });
res.redirect(`${FRONTEND_URL}/login?token=${token}`);
}
);
export default router;

View File

@@ -0,0 +1,137 @@
import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import { Strategy as GitHubStrategy } from 'passport-github';
import { Strategy as YandexStrategy } from 'passport-yandex';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_key';
const OAUTH_CALLBACK_URL = process.env.OAUTH_CALLBACK_URL || 'http://localhost:5000/api/auth';
interface OAuthProfile {
id: string;
displayName?: string;
emails?: Array<{ value: string }>;
provider: string;
}
// Функция для создания или получения пользователя
async function findOrCreateUser(profile: OAuthProfile) {
const email = profile.emails?.[0]?.value;
if (!email) {
throw new Error('Email не предоставлен провайдером');
}
let user = await prisma.user.findUnique({ where: { email } });
if (!user) {
user = await prisma.user.create({
data: {
username: profile.displayName || email.split('@')[0],
email,
password: '', // OAuth пользователи не имеют пароля
},
});
}
return user;
}
// Google OAuth
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
passport.use(
new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: `${OAUTH_CALLBACK_URL}/google/callback`,
},
async (accessToken, refreshToken, profile, done) => {
try {
const user = await findOrCreateUser(profile as OAuthProfile);
return done(null, user);
} catch (error) {
return done(error as Error);
}
}
)
);
}
// GitHub OAuth
if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) {
passport.use(
new GitHubStrategy(
{
clientID: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
callbackURL: `${OAUTH_CALLBACK_URL}/github/callback`,
scope: ['user:email'],
},
async (accessToken: string, refreshToken: string, profile: any, done: any) => {
try {
// GitHub может вернуть emails в массиве или не вернуть вообще
// Нужно запросить emails отдельно через API если они не пришли
let email = profile.emails?.[0]?.value;
// Если email не пришёл, используем username@users.noreply.github.com
if (!email && profile.username) {
email = `${profile.username}@users.noreply.github.com`;
}
if (!email) {
throw new Error('Email не предоставлен GitHub. Убедитесь, что ваш email публичный в настройках GitHub.');
}
const oauthProfile: OAuthProfile = {
id: profile.id,
displayName: profile.displayName || profile.username,
emails: [{ value: email }],
provider: 'github'
};
const user = await findOrCreateUser(oauthProfile);
return done(null, user);
} catch (error) {
return done(error as Error);
}
}
)
);
}
// Yandex OAuth
if (process.env.YANDEX_CLIENT_ID && process.env.YANDEX_CLIENT_SECRET) {
passport.use(
new YandexStrategy(
{
clientID: process.env.YANDEX_CLIENT_ID,
clientSecret: process.env.YANDEX_CLIENT_SECRET,
callbackURL: `${OAUTH_CALLBACK_URL}/yandex/callback`,
},
async (accessToken: string, refreshToken: string, profile: any, done: any) => {
try {
const user = await findOrCreateUser(profile as OAuthProfile);
return done(null, user);
} catch (error) {
return done(error as Error);
}
}
)
);
}
passport.serializeUser((user: any, done) => {
done(null, user.id);
});
passport.deserializeUser(async (id: number, done) => {
try {
const user = await prisma.user.findUnique({ where: { id } });
done(null, user);
} catch (error) {
done(error);
}
});
export default passport;

View File

@@ -0,0 +1,69 @@
import axios from 'axios';
const TURNSTILE_SECRET_KEY = process.env.TURNSTILE_SECRET_KEY;
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
export interface TurnstileValidationResult {
success: boolean;
errorCodes?: string[];
message?: string;
}
/**
* Валидирует токен Cloudflare Turnstile на стороне сервера
* @param token - токен, полученный от клиента
* @param remoteip - IP-адрес клиента (опционально)
* @returns результат валидации
*/
export async function validateTurnstileToken(
token: string,
remoteip?: string
): Promise<TurnstileValidationResult> {
if (!TURNSTILE_SECRET_KEY) {
console.error('TURNSTILE_SECRET_KEY не найден в переменных окружения');
return {
success: false,
message: 'Turnstile не настроен на сервере',
};
}
if (!token) {
return {
success: false,
message: 'Токен капчи не предоставлен',
};
}
try {
const formData = new URLSearchParams();
formData.append('secret', TURNSTILE_SECRET_KEY);
formData.append('response', token);
if (remoteip) {
formData.append('remoteip', remoteip);
}
const response = await axios.post(TURNSTILE_VERIFY_URL, formData, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
const data = response.data;
if (data.success) {
return { success: true };
} else {
return {
success: false,
errorCodes: data['error-codes'],
message: 'Проверка капчи не прошла',
};
}
} catch (error) {
console.error('Ошибка при валидации Turnstile:', error);
return {
success: false,
message: 'Ошибка при проверке капчи',
};
}
}

View File

@@ -9,7 +9,14 @@ const router = Router();
// Настройка Multer для загрузки чеков
const storage = multer.diskStorage({
destination: function (req: Express.Request, file: Express.Multer.File, cb: (error: Error | null, destination: string) => void) {
cb(null, path.join(__dirname, '../../../uploads/checks'));
const uploadDir = path.join(__dirname, '../../../uploads/checks');
// Проверяем и создаём директорию, если её нет
try {
require('fs').mkdirSync(uploadDir, { recursive: true });
} catch (err) {
// Игнорируем ошибку, если папка уже существует
}
cb(null, uploadDir);
},
filename: function (req: Express.Request, file: Express.Multer.File, cb: (error: Error | null, filename: string) => void) {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
@@ -26,15 +33,15 @@ const allowedMimeTypes = [
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB лимит
fileFilter: (req, file, cb) => {
if (allowedMimeTypes.includes(file.mimetype)) {
cb(null, true);
} else {
// Кастомная ошибка для Multer
const err: any = new Error('Недопустимый формат файла. Разрешены только изображения: jpg, jpeg, png, gif, webp.');
err.code = 'LIMIT_FILE_FORMAT';
cb(err, false);
}
} else {
const err: any = new Error('Недопустимый формат файла. Разрешены только изображения: jpg, jpeg, png, gif, webp.');
err.code = 'LIMIT_FILE_FORMAT';
cb(err, false);
}
}
});

View File

@@ -0,0 +1,165 @@
import { prisma } from '../../prisma/client';
// Утилита для добавления дней к дате
function addDays(date: Date, days: number): Date {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
export class PaymentService {
/**
* Обработка автоматических платежей за серверы
* Запускается по расписанию каждые 6 часов
*/
async processAutoPayments() {
const now = new Date();
// Находим серверы, у которых пришло время оплаты
const serversDue = await prisma.server.findMany({
where: {
status: { in: ['running', 'stopped'] },
autoRenew: true,
nextPaymentDate: {
lte: now
}
},
include: {
user: true,
tariff: 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} приостановлен из-за неоплаты.`
}
});
}
return;
}
// Списываем средства
const balanceBefore = user.balance;
const balanceAfter = balanceBefore - amount;
await prisma.$transaction([
// Обновляем баланс
prisma.user.update({
where: { id: user.id },
data: { balance: balanceAfter }
}),
// Создаём запись о платеже
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({
data: {
userId: user.id,
title: 'Списание за сервер',
message: `Списано ${amount}₽ за сервер #${server.id}. Следующая оплата: ${addDays(new Date(), 30).toLocaleDateString('ru-RU')}`
}
});
}
/**
* Устанавливаем дату первого платежа при создании сервера
*/
async setInitialPaymentDate(serverId: number) {
await prisma.server.update({
where: { id: serverId },
data: {
nextPaymentDate: addDays(new Date(), 30)
}
});
}
}
export default new PaymentService();

View File

@@ -17,12 +17,26 @@ export async function changeRootPasswordSSH(vmid: number): Promise<{ status: str
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 {
@@ -46,7 +60,11 @@ export async function getNextVMID(): Promise<number> {
try {
const res = await axios.get(
`${PROXMOX_API_URL}/cluster/nextid`,
{ headers: getProxmoxHeaders() }
{
headers: getProxmoxHeaders(),
timeout: 15000, // 15 секунд
httpsAgent
}
);
return res.data.data || Math.floor(100 + Math.random() * 899);
} catch (error) {
@@ -59,16 +77,27 @@ export async function getNextVMID(): Promise<number> {
export interface CreateContainerParams {
os: { template: string; type: string };
tariff: { name: string; price: number; description?: string };
user: { id: number; username: 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 {
const vmid = await getNextVMID();
const rootPassword = generateSecurePassword();
// Используем hostname из параметров, если есть
const hostname = arguments[0].hostname || `user${user.id}-${tariff.name.toLowerCase().replace(/\s/g, '-')}`;
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';
@@ -83,8 +112,8 @@ export async function createLXContainer({ os, tariff, user }: CreateContainerPar
ostemplate: os.template,
cores,
memory,
rootfs: `local:${diskSize}`,
net0: 'name=eth0,bridge=vmbr0,ip=dhcp',
rootfs: `${PROXMOX_VM_STORAGE}:${diskSize}`,
net0: `name=eth0,bridge=${PROXMOX_NETWORK_BRIDGE},ip=dhcp`,
unprivileged: 1,
start: 1, // Автостарт после создания
protection: 0,
@@ -94,11 +123,33 @@ export async function createLXContainer({ os, tariff, user }: CreateContainerPar
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() }
{
headers: getProxmoxHeaders(),
timeout: 120000, // 2 минуты для создания контейнера
httpsAgent
}
);
console.log('Ответ от Proxmox (создание):', response.status, response.data);
if (response.data?.data) {
// Polling статуса контейнера до running или timeout
@@ -129,7 +180,10 @@ 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() }
{
headers: getProxmoxHeaders(),
httpsAgent
}
);
return { status: res.data.data.status };
} catch (error) {
@@ -139,10 +193,41 @@ async function getContainerStatus(vmid: number): Promise<{ status: string }> {
throw new Error('Не удалось создать контейнер');
} catch (error: any) {
console.error('Ошибка создания LXC контейнера:', error);
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: error.response?.data?.errors || error.message
message: errorMessage,
code: error?.code || error?.response?.status,
isSocketError,
storage: PROXMOX_VM_STORAGE
};
}
}
@@ -154,18 +239,34 @@ export async function getContainerIP(vmid: number): Promise<string | null> {
const response = await axios.get(
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/interfaces`,
{ headers: getProxmoxHeaders() }
{
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') {
return iface.inet.split('/')[0]; // Убираем маску подсети
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);
@@ -540,12 +641,39 @@ export async function listContainers() {
}
}
// Получение списка доступных 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() }
{
headers: getProxmoxHeaders(),
httpsAgent
}
);
if (response.data?.data) {
@@ -565,3 +693,17 @@ export async function checkProxmoxConnection() {
};
}
}
// Получение конфигурации 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

@@ -37,10 +37,14 @@ export async function createServer(req: Request, res: Response) {
// Генерация hostname из email
let hostname = user.email.split('@')[0];
hostname = hostname.replace(/[^a-zA-Z0-9-]/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}`;
// Нормализуем 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({
@@ -52,21 +56,33 @@ export async function createServer(req: Request, res: Response) {
if (result.status !== 'success') {
// Возвращаем деньги обратно, если не удалось создать
await prisma.user.update({ where: { id: userId }, data: { balance: { increment: tariff.price } } });
// Логируем полный текст ошибки в файл
const fs = require('fs');
const logMsg = `[${new Date().toISOString()}] Ошибка Proxmox: ${JSON.stringify(result, null, 2)}\n`;
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.message);
console.error('Ошибка Proxmox при создании контейнера:', result);
return res.status(500).json({
error: 'Ошибка создания сервера в Proxmox',
details: result.message,
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,
@@ -76,8 +92,23 @@ export async function createServer(req: Request, res: Response) {
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);
@@ -89,7 +120,20 @@ export async function createServer(req: Request, res: Response) {
export async function getServerStatus(req: Request, res: Response) {
try {
const id = Number(req.params.id);
const server = await prisma.server.findUnique({ where: { 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);
@@ -197,11 +241,13 @@ export async function deleteServer(req: Request, res: Response) {
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) {
@@ -238,7 +284,10 @@ export async function resizeServer(req: Request, res: Response) {
const config: any = {};
if (cores) config.cores = Number(cores);
if (memory) config.memory = Number(memory);
if (disk) config.rootfs = `local:${Number(disk)}`;
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);

View File

@@ -0,0 +1,153 @@
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

@@ -14,6 +14,7 @@ import {
rollbackServerSnapshot,
deleteServerSnapshot
} from './server.controller';
import { getStorageConfig, getNodeStorages, checkProxmoxConnection } from './proxmoxApi';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
@@ -83,5 +84,102 @@ 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,2 @@
import sitemapRoutes from './sitemap.routes';
export default sitemapRoutes;

View File

@@ -0,0 +1,57 @@
import { Request, Response } from 'express';
import { prisma } from '../../prisma/client';
export async function generateSitemap(req: Request, res: Response) {
try {
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' },
];
// Динамические страницы (если будут статьи в будущем)
let dynamicPages: any[] = [];
try {
// Если будет блог, добавьте сюда
// const posts = await prisma.post.findMany();
// dynamicPages = posts.map(post => ({
// loc: `/blog/${post.slug}`,
// priority: '0.7',
// changefreq: 'weekly',
// lastmod: post.updatedAt.toISOString().split('T')[0]
// }));
} catch (error) {
console.log('Блог пока не активирован');
}
const allPages = [...staticPages, ...dynamicPages];
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n';
for (const page of allPages) {
xml += ' <url>\n';
xml += ` <loc>${baseUrl}${page.loc}</loc>\n`;
xml += ` <priority>${page.priority}</priority>\n`;
xml += ` <changefreq>${page.changefreq}</changefreq>\n`;
if (page.lastmod) {
xml += ` <lastmod>${page.lastmod}</lastmod>\n`;
}
xml += ' </url>\n';
}
xml += '</urlset>';
res.header('Content-Type', 'application/xml');
res.send(xml);
} catch (error) {
console.error('Ошибка генерации sitemap:', error);
res.status(500).json({ error: 'Ошибка генерации sitemap' });
}
}

View File

@@ -0,0 +1,8 @@
import { Router } from 'express';
import { generateSitemap } from './sitemap.controller';
const router = Router();
router.get('/sitemap.xml', generateSitemap);
export default router;

View File

@@ -1,22 +1,8 @@
import { PrismaClient } from '@prisma/client';
import { Request, Response } from 'express';
const prisma = new PrismaClient();
// Расширяем тип Request для user
declare global {
namespace Express {
interface Request {
user?: {
id: number;
operator?: number;
// можно добавить другие поля при необходимости
};
}
}
}
// Создать тикет
export async function createTicket(req: Request, res: Response) {
const { title, message } = req.body;

View File

@@ -0,0 +1,22 @@
// Типы для расширения Express Request
import { User } from '@prisma/client';
declare global {
namespace Express {
interface User {
id: number;
email: string;
username: string;
password: string;
balance: number;
operator: number;
createdAt: Date;
}
interface Request {
user?: User;
}
}
}
export {};

View File

@@ -0,0 +1,38 @@
declare module 'passport-vkontakte' {
import { Strategy as PassportStrategy } from 'passport';
export interface StrategyOptions {
clientID: string;
clientSecret: string;
callbackURL: string;
scope?: string[];
}
export interface Profile {
id: string;
displayName?: string;
name?: {
familyName?: string;
givenName?: string;
};
emails?: Array<{ value: string }>;
photos?: Array<{ value: string }>;
provider: string;
}
export type VerifyCallback = (error: any, user?: any, info?: any) => void;
export type VerifyFunction = (
accessToken: string,
refreshToken: string,
params: any,
profile: Profile,
done: VerifyCallback
) => void;
export class Strategy extends PassportStrategy {
constructor(options: StrategyOptions, verify: VerifyFunction);
name: string;
authenticate(req: any, options?: any): void;
}
}