BIG_UPDATE deleted vps, added s3 infrastructure.

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

View File

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

View File

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

View File

@@ -1,13 +1,20 @@
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import http from 'http';
import passport from './modules/auth/passport.config';
import authRoutes from './modules/auth/auth.routes';
import oauthRoutes from './modules/auth/oauth.routes';
import adminRoutes from './modules/admin/admin.routes';
import ticketRoutes from './modules/ticket/ticket.routes';
import checkRoutes from './modules/check/check.routes';
import proxmoxRoutes from '../proxmox/proxmox.routes';
import tariffRoutes from './modules/tariff';
import osRoutes from './modules/os';
import serverRoutes from './modules/server';
import blogRoutes from './modules/blog/blog.routes';
import notificationRoutes from './modules/notification/notification.routes';
import userRoutes from './modules/user/user.routes';
import sessionRoutes from './modules/session/session.routes';
import qrAuthRoutes from './modules/qr-auth/qr-auth.routes';
import storageRoutes from './modules/storage/storage.routes';
import { logger } from './utils/logger';
dotenv.config();
@@ -20,33 +27,27 @@ app.use(cors({
'https://ospab.host'
],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
app.use(express.json());
app.use((req, res, next) => {
console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`);
next();
});
import { checkProxmoxConnection } from './modules/server/proxmoxApi';
app.use(passport.initialize());
app.get('/', async (req, res) => {
let proxmoxStatus;
try {
proxmoxStatus = await checkProxmoxConnection();
} catch (err) {
proxmoxStatus = { status: 'fail', message: 'Ошибка проверки Proxmox', error: err };
}
// Статистика WebSocket
const wsConnectedUsers = getConnectedUsersCount();
const wsRoomsStats = getRoomsStats();
res.json({
message: 'Сервер ospab.host запущен!',
timestamp: new Date().toISOString(),
port: PORT,
database: process.env.DATABASE_URL ? 'подключена' : 'НЕ НАСТРОЕНА',
proxmox: proxmoxStatus
websocket: {
connected_users: wsConnectedUsers,
rooms: wsRoomsStats
}
});
});
@@ -55,21 +56,24 @@ app.get('/sitemap.xml', (req, res) => {
const baseUrl = 'https://ospab.host';
const staticPages = [
{ loc: '/', priority: '1.0', changefreq: 'weekly' },
{ loc: '/about', priority: '0.9', changefreq: 'monthly' },
{ loc: '/tariffs', priority: '0.95', changefreq: 'weekly' },
{ loc: '/login', priority: '0.7', changefreq: 'monthly' },
{ loc: '/register', priority: '0.8', changefreq: 'monthly' },
{ loc: '/terms', priority: '0.5', changefreq: 'yearly' },
{ loc: '/privacy', priority: '0.5', changefreq: 'yearly' },
{ loc: '/', priority: '1.0', changefreq: 'weekly', description: 'Ospab Host - Облачное S3 хранилище и хостинг сайтов' },
{ loc: '/about', priority: '0.9', changefreq: 'monthly', description: 'О компании - Современная платформа хранения данных' },
{ loc: '/login', priority: '0.7', changefreq: 'monthly', description: 'Вход в панель управления с QR-аутентификацией' },
{ loc: '/register', priority: '0.8', changefreq: 'monthly', description: 'Регистрация аккаунта - Начните за 2 минуты' },
{ loc: '/blog', priority: '0.85', changefreq: 'daily', description: 'Блог о S3 хранилище и хостинге' },
{ loc: '/terms', priority: '0.5', changefreq: 'yearly', description: 'Условия использования сервиса' },
{ loc: '/privacy', priority: '0.5', changefreq: 'yearly', description: 'Политика конфиденциальности и защита данных' },
];
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n';
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">\n';
const lastmod = new Date().toISOString().split('T')[0];
for (const page of staticPages) {
xml += ' <url>\n';
xml += ` <loc>${baseUrl}${page.loc}</loc>\n`;
xml += ` <lastmod>${lastmod}</lastmod>\n`;
xml += ` <priority>${page.priority}</priority>\n`;
xml += ` <changefreq>${page.changefreq}</changefreq>\n`;
xml += ' </url>\n';
@@ -83,64 +87,122 @@ app.get('/sitemap.xml', (req, res) => {
// ==================== ROBOTS.TXT ====================
app.get('/robots.txt', (req, res) => {
const robots = `User-agent: *
const robots = `# ospab Host - Облачное S3 хранилище и хостинг
# Хранение данных, техподдержка 24/7
User-agent: *
Allow: /
Allow: /about
Allow: /tariffs
Allow: /login
Allow: /register
Allow: /blog
Allow: /blog/*
Allow: /terms
Allow: /privacy
Allow: /uploads/blog
# Запрет индексации приватных разделов
Disallow: /dashboard
Disallow: /dashboard/*
Disallow: /api/
Disallow: /qr-login
Disallow: /admin
Disallow: /private
Disallow: /admin/*
Disallow: /uploads/avatars
Disallow: /uploads/tickets
Disallow: /uploads/checks
Sitemap: https://ospab.host/sitemap.xml
# Google
# Поисковые роботы
User-agent: Googlebot
Allow: /
Crawl-delay: 0
# Yandex
User-agent: Yandexbot
Allow: /
Crawl-delay: 0`;
Crawl-delay: 0
User-agent: Bingbot
Allow: /
Crawl-delay: 0
User-agent: Mail.RU_Bot
Allow: /
Crawl-delay: 1`;
res.header('Content-Type', 'text/plain; charset=utf-8');
res.send(robots);
});
import path from 'path';
app.use('/uploads/checks', express.static(path.join(__dirname, '../uploads/checks')));
// Публичный доступ к блогу, аватарам и файлам тикетов
app.use('/uploads/blog', express.static(path.join(__dirname, '../uploads/blog')));
app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
app.use('/uploads/tickets', express.static(path.join(__dirname, '../uploads/tickets')));
app.use('/api/auth', authRoutes);
app.use('/api/auth', oauthRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/ticket', ticketRoutes);
app.use('/api/check', checkRoutes);
app.use('/api/proxmox', proxmoxRoutes);
app.use('/api/tariff', tariffRoutes);
app.use('/api/os', osRoutes);
app.use('/api/server', serverRoutes);
app.use('/api/blog', blogRoutes);
app.use('/api/notifications', notificationRoutes);
app.use('/api/user', userRoutes);
app.use('/api/sessions', sessionRoutes);
app.use('/api/qr-auth', qrAuthRoutes);
app.use('/api/storage', storageRoutes);
const PORT = process.env.PORT || 5000;
import { setupConsoleWSS } from './modules/server/server.console';
import { initWebSocketServer, getConnectedUsersCount, getRoomsStats } from './websocket/server';
import https from 'https';
import fs from 'fs';
// ИСПРАВЛЕНО: используйте fullchain сертификат
const sslOptions = {
key: fs.readFileSync('/etc/apache2/ssl/ospab.host.key'),
cert: fs.readFileSync('/etc/apache2/ssl/ospab.host.fullchain.crt'),
};
const keyPath = process.env.SSL_KEY_PATH ?? '/etc/apache2/ssl/ospab.host.key';
const certPath = process.env.SSL_CERT_PATH ?? '/etc/apache2/ssl/ospab.host.fullchain.crt';
const httpsServer = https.createServer(sslOptions, app);
setupConsoleWSS(httpsServer);
const shouldUseHttps = process.env.NODE_ENV === 'production';
httpsServer.listen(PORT, () => {
console.log(`🚀 HTTPS сервер запущен на порту ${PORT}`);
console.log(`📊 База данных: ${process.env.DATABASE_URL ? 'подключена' : 'НЕ НАСТРОЕНА'}`);
console.log(`📍 Sitemap доступен: https://ospab.host:${PORT}/sitemap.xml`);
console.log(`🤖 Robots.txt доступен: https://ospab.host:${PORT}/robots.txt`);
let server: http.Server | https.Server;
let protocolLabel = 'HTTP';
if (shouldUseHttps) {
const missingPaths: string[] = [];
if (!fs.existsSync(keyPath)) {
missingPaths.push(keyPath);
}
if (!fs.existsSync(certPath)) {
missingPaths.push(certPath);
}
if (missingPaths.length > 0) {
console.error('[Server] SSL режим включён, но сертификаты не найдены:', missingPaths.join(', '));
console.error('[Server] Укажите корректные пути в переменных SSL_KEY_PATH и SSL_CERT_PATH. Сервер остановлен.');
process.exit(1);
}
const sslOptions = {
key: fs.readFileSync(keyPath),
cert: fs.readFileSync(certPath)
};
server = https.createServer(sslOptions, app);
protocolLabel = 'HTTPS';
} else {
server = http.createServer(app);
}
// Инициализация основного WebSocket сервера для real-time обновлений
const wss = initWebSocketServer(server);
server.listen(PORT, () => {
logger.info(`${protocolLabel} сервер запущен на порту ${PORT}`);
logger.info(`База данных: ${process.env.DATABASE_URL ? 'подключена' : 'НЕ НАСТРОЕНА'}`);
logger.info(`WebSocket доступен: ${protocolLabel === 'HTTPS' ? 'wss' : 'ws'}://ospab.host:${PORT}/ws`);
logger.info(`Sitemap доступен: ${protocolLabel === 'HTTPS' ? 'https' : 'http'}://ospab.host:${PORT}/sitemap.xml`);
logger.info(`Robots.txt доступен: ${protocolLabel === 'HTTPS' ? 'https' : 'http'}://ospab.host:${PORT}/robots.txt`);
});

View File

@@ -0,0 +1,56 @@
import { Request, Response, NextFunction } from 'express';
import { prisma } from '../prisma/client';
import path from 'path';
import { logger } from '../utils/logger';
/**
* Middleware для проверки доступа к файлам чеков
* Доступ имеют только: владелец чека или оператор
*/
export async function checkFileAccessMiddleware(req: Request, res: Response, next: NextFunction) {
try {
const userId = req.user?.id;
const isOperator = Number(req.user?.operator) === 1;
// Извлекаем имя файла из URL
const filename = path.basename(req.path);
if (!userId) {
logger.warn(`[CheckFile] Попытка доступа к ${filename} без авторизации`);
return res.status(401).json({ error: 'Требуется авторизация' });
}
// Операторы имеют доступ ко всем чекам
if (isOperator) {
return next();
}
// Для обычных пользователей - проверяем владение чеком
const check = await prisma.check.findFirst({
where: {
fileUrl: {
contains: filename
}
},
select: {
id: true,
userId: true,
fileUrl: true
}
});
if (!check) {
logger.warn(`[CheckFile] Чек с файлом ${filename} не найден в БД`);
return res.status(404).json({ error: 'Файл не найден' });
}
if (check.userId !== userId) {
logger.warn(`[CheckFile] Пользователь ${userId} попытался получить доступ к чужому чеку ${filename} (владелец: ${check.userId})`);
return res.status(403).json({ error: 'Нет доступа к этому файлу' });
}
next();
} catch (error) {
logger.error('[CheckFile] Ошибка проверки доступа:', error);
res.status(500).json({ error: 'Ошибка проверки доступа' });
}
}

View File

@@ -2,6 +2,7 @@ import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcrypt';
import crypto from 'crypto';
import nodemailer from 'nodemailer';
import { logger } from '../../utils/logger';
const prisma = new PrismaClient();
@@ -94,7 +95,7 @@ async function sendVerificationEmail(
</div>
<p><strong>Код действителен в течение 15 минут.</strong></p>
<div class="warning">
<strong>⚠️ Важно:</strong> Если вы не запрашивали это действие, проигнорируйте это письмо и немедленно смените пароль.
<strong>Важно:</strong> Если вы не запрашивали это действие, проигнорируйте это письмо и немедленно смените пароль.
</div>
<p>С уважением,<br>Команда ospab.host</p>
</div>
@@ -271,14 +272,77 @@ export async function confirmAccountDeletion(userId: number, code: string): Prom
throw new Error('Код истёк');
}
// Удаляем все связанные данные пользователя
await prisma.$transaction([
prisma.ticket.deleteMany({ where: { userId } }),
prisma.check.deleteMany({ where: { userId } }),
prisma.server.deleteMany({ where: { userId } }),
prisma.notification.deleteMany({ where: { userId } }),
prisma.user.delete({ where: { id: userId } }),
]);
logger.info(`[ACCOUNT DELETE] Начинаем полное удаление пользователя ${userId}...`);
try {
// Каскадное удаление всех связанных данных пользователя в правильном порядке
await prisma.$transaction(async (tx) => {
// 1. Удаляем ответы в тикетах где пользователь является оператором
const responses = await tx.response.deleteMany({
where: { operatorId: userId }
});
logger.log(` Удалено ответов оператора: ${responses.count}`);
// 2. Удаляем тикеты
const tickets = await tx.ticket.deleteMany({
where: { userId }
});
logger.log(`Удалено тикетов: ${tickets.count}`);
// 3. Удаляем чеки
const checks = await tx.check.deleteMany({
where: { userId }
});
logger.log(`Удалено чеков: ${checks.count}`);
// 4. Удаляем S3 бакеты пользователя
const buckets = await tx.storageBucket.deleteMany({
where: { userId }
});
logger.info(`Удалено S3 бакетов: ${buckets.count}`);
// 5. Удаляем уведомления
const notifications = await tx.notification.deleteMany({
where: { userId }
});
logger.info(` Удалено уведомлений: ${notifications.count}`);
// 6. Удаляем Push-подписки
const pushSubscriptions = await tx.pushSubscription.deleteMany({
where: { userId }
});
logger.info(`Удалено Push-подписок: ${pushSubscriptions.count}`);
// 7. Удаляем транзакции
const transactions = await tx.transaction.deleteMany({
where: { userId }
});
logger.info(`Удалено транзакций: ${transactions.count}`);
// 8. Удаляем сессии
const sessions = await tx.session.deleteMany({
where: { userId }
});
logger.info(`Удалено сессий: ${sessions.count}`);
// 9. Удаляем историю входов
const loginHistory = await tx.loginHistory.deleteMany({
where: { userId }
});
logger.info(`Удалено записей истории входов: ${loginHistory.count}`);
// 10. Наконец, удаляем самого пользователя
await tx.user.delete({
where: { id: userId }
});
logger.info(`Пользователь ${userId} удалён из БД`);
});
logger.info(`[ACCOUNT DELETE] Пользователь ${userId} полностью удалён`);
} catch (error) {
logger.error(`[ACCOUNT DELETE] Ошибка при удалении пользователя ${userId}:`, error);
throw new Error('Ошибка при удалении аккаунта');
}
verificationCodes.delete(`delete_${userId}`);
}

View File

@@ -1,5 +1,6 @@
import { Request, Response } from 'express';
import { prisma } from '../../prisma/client';
import { createNotification } from '../notification/notification.controller';
/**
* Middleware для проверки прав администратора
@@ -44,7 +45,7 @@ export class AdminController {
createdAt: true,
_count: {
select: {
servers: true,
buckets: true,
tickets: true
}
}
@@ -71,11 +72,8 @@ export class AdminController {
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
servers: {
include: {
tariff: true,
os: true
}
buckets: {
orderBy: { createdAt: 'desc' }
},
checks: {
orderBy: { createdAt: 'desc' },
@@ -139,16 +137,18 @@ export class AdminController {
balanceAfter,
adminId
}
}),
prisma.notification.create({
data: {
userId,
title: 'Пополнение баланса',
message: `На ваш счёт зачислено ${amount}₽. ${description || ''}`
}
})
]);
// Создаём уведомление через новую систему
await createNotification({
userId,
type: 'balance_deposit',
title: 'Пополнение баланса',
message: `На ваш счёт зачислено ${amount}₽. ${description || ''}`,
color: 'green'
});
res.json({
status: 'success',
message: `Баланс пополнен на ${amount}`,
@@ -200,16 +200,18 @@ export class AdminController {
balanceAfter,
adminId
}
}),
prisma.notification.create({
data: {
userId,
title: 'Списание с баланса',
message: `С вашего счёта списано ${amount}₽. ${description || ''}`
}
})
]);
// Создаём уведомление через новую систему
await createNotification({
userId,
type: 'balance_withdrawal',
title: 'Списание с баланса',
message: `С вашего счёта списано ${amount}₽. ${description || ''}`,
color: 'red'
});
res.json({
status: 'success',
message: `Списано ${amount}`,
@@ -222,47 +224,41 @@ export class AdminController {
}
/**
* Удалить сервер пользователя
* Удалить S3 бакет пользователя
*/
async deleteServer(req: Request, res: Response) {
async deleteBucket(req: Request, res: Response) {
try {
const serverId = parseInt(req.params.serverId);
const bucketId = parseInt(req.params.bucketId);
const { reason } = req.body;
const adminId = (req as any).user?.id;
const server = await prisma.server.findUnique({
where: { id: serverId },
include: { user: true, tariff: true }
const bucket = await prisma.storageBucket.findUnique({
where: { id: bucketId },
include: { user: true }
});
if (!server) {
return res.status(404).json({ message: 'Сервер не найден' });
if (!bucket) {
return res.status(404).json({ message: 'Бакет не найден' });
}
// Удаляем сервер из Proxmox (если есть proxmoxId)
// TODO: Добавить вызов proxmoxApi.deleteContainer(server.proxmoxId)
await prisma.storageBucket.delete({
where: { id: bucketId }
});
// Удаляем из БД
await prisma.$transaction([
prisma.server.delete({
where: { id: serverId }
}),
prisma.notification.create({
data: {
userId: server.userId,
title: 'Сервер удалён',
message: `Ваш сервер #${serverId} был удалён администратором. ${reason ? `Причина: ${reason}` : ''}`
}
})
]);
await createNotification({
userId: bucket.userId,
type: 'storage_bucket_deleted',
title: 'Бакет удалён',
message: `Ваш бакет «${bucket.name}» был удалён администратором. ${reason ? `Причина: ${reason}` : ''}`,
color: 'red'
});
res.json({
status: 'success',
message: `Сервер #${serverId} удалён`
message: `Бакет «${bucket.name}» удалён`
});
} catch (error) {
console.error('Ошибка удаления сервера:', error);
res.status(500).json({ message: 'Ошибка удаления сервера' });
console.error('Ошибка удаления бакета:', error);
res.status(500).json({ message: 'Ошибка удаления бакета' });
}
}
@@ -273,20 +269,26 @@ export class AdminController {
try {
const [
totalUsers,
totalServers,
activeServers,
suspendedServers,
totalBuckets,
publicBuckets,
totalBalance,
pendingChecks,
openTickets
openTickets,
bucketsAggregates
] = await Promise.all([
prisma.user.count(),
prisma.server.count(),
prisma.server.count({ where: { status: 'running' } }),
prisma.server.count({ where: { status: 'suspended' } }),
prisma.storageBucket.count(),
prisma.storageBucket.count({ where: { public: true } }),
prisma.user.aggregate({ _sum: { balance: true } }),
prisma.check.count({ where: { status: 'pending' } }),
prisma.ticket.count({ where: { status: 'open' } })
prisma.ticket.count({ where: { status: 'open' } }),
prisma.storageBucket.aggregate({
_sum: {
usedBytes: true,
objectCount: true,
quotaGb: true
}
})
]);
// Получаем последние транзакции
@@ -310,10 +312,12 @@ export class AdminController {
users: {
total: totalUsers
},
servers: {
total: totalServers,
active: activeServers,
suspended: suspendedServers
storage: {
total: totalBuckets,
public: publicBuckets,
objects: bucketsAggregates._sum.objectCount ?? 0,
usedBytes: bucketsAggregates._sum.usedBytes ?? 0,
quotaGb: bucketsAggregates._sum.quotaGb ?? 0
},
balance: {
total: totalBalance._sum.balance || 0

View File

@@ -18,7 +18,7 @@ router.post('/users/:userId/balance/add', adminController.addBalance.bind(adminC
router.post('/users/:userId/balance/withdraw', adminController.withdrawBalance.bind(adminController));
router.patch('/users/:userId/role', adminController.updateUserRole.bind(adminController));
// Управление серверами
router.delete('/servers/:serverId', adminController.deleteServer.bind(adminController));
// Управление S3 бакетами
router.delete('/buckets/:bucketId', adminController.deleteBucket.bind(adminController));
export default router;

View File

@@ -3,6 +3,7 @@ import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { PrismaClient } from '@prisma/client';
import { validateTurnstileToken } from './turnstile.validator';
import { logger } from '../../utils/logger';
const prisma = new PrismaClient();
const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_key';
@@ -46,7 +47,7 @@ export const register = async (req: Request, res: Response) => {
res.status(201).json({ message: 'Регистрация прошла успешно!' });
} catch (error) {
console.error('Ошибка при регистрации:', error);
logger.error('Ошибка при регистрации:', error);
res.status(500).json({ message: 'Ошибка сервера.' });
}
};
@@ -87,7 +88,7 @@ export const login = async (req: Request, res: Response) => {
res.status(200).json({ token });
} catch (error) {
console.error('Ошибка при входе:', error);
logger.error('Ошибка при входе:', error);
res.status(500).json({ message: 'Ошибка сервера.' });
}
};
@@ -108,38 +109,33 @@ export const getMe = async (req: Request, res: Response) => {
operator: true,
isAdmin: true,
balance: true,
servers: {
buckets: {
orderBy: { createdAt: 'desc' },
select: {
id: true,
status: true,
name: true,
plan: true,
quotaGb: true,
usedBytes: true,
objectCount: true,
storageClass: true,
region: true,
public: true,
versioning: true,
createdAt: true,
ipAddress: true,
nextPaymentDate: true,
autoRenew: true,
tariff: {
select: {
name: true,
price: true,
},
},
os: {
select: {
name: true,
type: true,
},
},
},
updatedAt: true
}
},
tickets: true,
},
});
console.log('API /api/auth/me user:', user);
logger.debug('API /api/auth/me user:', user);
if (!user) {
return res.status(404).json({ message: 'Пользователь не найден.' });
return res.status(401).json({ message: 'Сессия недействительна. Выполните вход заново.' });
}
res.status(200).json({ user });
} catch (error) {
console.error('Ошибка при получении данных пользователя:', error);
res.status(500).json({ message: 'Ошибка сервера.' });
logger.error('Ошибка при получении данных пользователя:', error);
res.status(503).json({ message: 'Не удалось загрузить профиль. Попробуйте позже.' });
}
};

View File

@@ -1,8 +1,6 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
import { prisma } from '../../prisma/client';
const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_key';
export const authMiddleware = async (req: Request, res: Response, next: NextFunction) => {
@@ -19,14 +17,66 @@ export const authMiddleware = async (req: Request, res: Response, next: NextFunc
const decoded = jwt.verify(token, JWT_SECRET) as { id: number };
const user = await prisma.user.findUnique({ where: { id: decoded.id } });
if (!user) return res.status(401).json({ message: 'Пользователь не найден.' });
if (!user) {
console.warn(`[Auth] Пользователь с ID ${decoded.id} не найден, токен отклонён`);
return res.status(401).json({ message: 'Сессия недействительна. Авторизуйтесь снова.' });
}
req.user = user;
next();
return next();
} catch (error) {
console.error('Ошибка в мидлваре аутентификации:', error);
if (error instanceof jwt.JsonWebTokenError) {
return res.status(401).json({ message: 'Неверный или просроченный токен.' });
}
res.status(500).json({ message: 'Ошибка сервера.' });
return res.status(503).json({ message: 'Авторизация временно недоступна. Попробуйте позже.' });
}
};
// Middleware для проверки прав администратора
export const adminMiddleware = (req: Request, res: Response, next: NextFunction) => {
const user = req.user;
if (!user || !user.isAdmin) {
return res.status(403).json({ message: 'Доступ запрещён. Требуются права администратора.' });
}
next();
};
// Опциональный middleware - проверяет токен если он есть, но не требует авторизации
export const optionalAuthMiddleware = async (req: Request, res: Response, next: NextFunction) => {
try {
const authHeader = req.headers.authorization;
// Если нет токена - просто пропускаем дальше (для гостей)
if (!authHeader) {
return next();
}
const token = authHeader.split(' ')[1];
if (!token) {
return next();
}
// Если токен есть - проверяем и добавляем пользователя
try {
const decoded = jwt.verify(token, JWT_SECRET) as { id: number };
const user = await prisma.user.findUnique({ where: { id: decoded.id } });
if (!user) {
console.warn(`[Auth][optional] Пользователь с ID ${decoded.id} не найден, токен отклонён`);
return res.status(401).json({ message: 'Сессия недействительна. Авторизуйтесь снова.' });
}
req.user = user;
} catch (err) {
console.warn('[Auth][optional] Ошибка проверки токена:', err);
return res.status(401).json({ message: 'Неверный или просроченный токен.' });
}
return next();
} catch (error) {
console.error('Ошибка в optionalAuthMiddleware:', error);
return res.status(503).json({ message: 'Авторизация временно недоступна. Попробуйте позже.' });
}
};

View File

@@ -6,6 +6,12 @@ const router = Router();
const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_key';
const FRONTEND_URL = process.env.FRONTEND_URL || 'https://ospab.host';
interface AuthenticatedUser {
id: number;
email: string;
username: string;
}
// Google OAuth
router.get('/google', passport.authenticate('google', { scope: ['profile', 'email'] }));
@@ -13,7 +19,7 @@ router.get(
'/google/callback',
passport.authenticate('google', { session: false, failureRedirect: `${FRONTEND_URL}/login?error=auth_failed` }),
(req: Request, res: Response) => {
const user = req.user as any;
const user = req.user as AuthenticatedUser;
const token = jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: '24h' });
res.redirect(`${FRONTEND_URL}/login?token=${token}`);
}
@@ -26,7 +32,7 @@ router.get(
'/github/callback',
passport.authenticate('github', { session: false, failureRedirect: `${FRONTEND_URL}/login?error=auth_failed` }),
(req: Request, res: Response) => {
const user = req.user as any;
const user = req.user as AuthenticatedUser;
const token = jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: '24h' });
res.redirect(`${FRONTEND_URL}/login?token=${token}`);
}
@@ -39,7 +45,7 @@ router.get(
'/yandex/callback',
passport.authenticate('yandex', { session: false, failureRedirect: `${FRONTEND_URL}/login?error=auth_failed` }),
(req: Request, res: Response) => {
const user = req.user as any;
const user = req.user as AuthenticatedUser;
const token = jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: '24h' });
res.redirect(`${FRONTEND_URL}/login?token=${token}`);
}

View File

@@ -0,0 +1,323 @@
import { Request, Response } from 'express';
import { prisma } from '../../prisma/client';
// Получить все опубликованные посты (публичный доступ)
export const getAllPosts = async (req: Request, res: Response) => {
try {
const posts = await prisma.post.findMany({
where: { status: 'published' },
include: {
author: {
select: { id: true, username: true }
},
_count: {
select: { comments: true }
}
},
orderBy: { publishedAt: 'desc' }
});
res.json({ success: true, data: posts });
} catch (error) {
console.error('Ошибка получения постов:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// Получить один пост по URL (публичный доступ)
export const getPostByUrl = async (req: Request, res: Response) => {
try {
const { url } = req.params;
const post = await prisma.post.findUnique({
where: { url },
include: {
author: {
select: { id: true, username: true }
},
comments: {
where: { status: 'approved' },
include: {
user: {
select: { id: true, username: true }
}
},
orderBy: { createdAt: 'desc' }
}
}
});
if (!post) {
return res.status(404).json({ success: false, message: 'Статья не найдена' });
}
// Увеличить счетчик просмотров
await prisma.post.update({
where: { id: post.id },
data: { views: { increment: 1 } }
});
res.json({ success: true, data: post });
} catch (error) {
console.error('Ошибка получения поста:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// Добавить комментарий (публичный доступ)
export const addComment = async (req: Request, res: Response) => {
try {
const { postId } = req.params;
const { content, authorName } = req.body;
const userId = req.user?.id; // Если пользователь авторизован
if (!content || content.trim().length === 0) {
return res.status(400).json({ success: false, message: 'Содержимое комментария не может быть пустым' });
}
if (!userId && (!authorName || authorName.trim().length === 0)) {
return res.status(400).json({ success: false, message: 'Укажите ваше имя' });
}
// Проверяем, существует ли пост
const post = await prisma.post.findUnique({
where: { id: parseInt(postId) }
});
if (!post) {
return res.status(404).json({ success: false, message: 'Пост не найден' });
}
const comment = await prisma.comment.create({
data: {
postId: parseInt(postId),
userId: userId || null,
authorName: !userId ? authorName.trim() : null,
content: content.trim(),
status: 'pending' // Комментарии требуют модерации
},
include: {
user: {
select: { id: true, username: true }
}
}
});
res.json({ success: true, data: comment, message: 'Комментарий отправлен на модерацию' });
} catch (error) {
console.error('Ошибка добавления комментария:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// === ADMIN ENDPOINTS ===
// Получить все посты (включая черновики) - только для админов
export const getAllPostsAdmin = async (req: Request, res: Response) => {
try {
const posts = await prisma.post.findMany({
include: {
author: {
select: { id: true, username: true }
},
_count: {
select: { comments: true }
}
},
orderBy: { createdAt: 'desc' }
});
res.json({ success: true, data: posts });
} catch (error) {
console.error('Ошибка получения постов:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// Получить один пост по ID - только для админов
export const getPostByIdAdmin = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const post = await prisma.post.findUnique({
where: { id: parseInt(id) },
include: {
author: {
select: { id: true, username: true }
},
_count: {
select: { comments: true }
}
}
});
if (!post) {
return res.status(404).json({ success: false, message: 'Пост не найден' });
}
res.json({ success: true, data: post });
} catch (error) {
console.error('Ошибка получения поста:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// Создать пост - только для админов
export const createPost = async (req: Request, res: Response) => {
try {
const { title, content, excerpt, coverImage, url, status } = req.body;
const authorId = req.user!.id; // user гарантированно есть после authMiddleware
if (!title || !content || !url) {
return res.status(400).json({ success: false, message: 'Заполните обязательные поля' });
}
// Проверка уникальности URL
const existingPost = await prisma.post.findUnique({ where: { url } });
if (existingPost) {
return res.status(400).json({ success: false, message: 'URL уже используется' });
}
const post = await prisma.post.create({
data: {
title,
content,
excerpt,
coverImage,
url,
status: status || 'draft',
authorId,
publishedAt: status === 'published' ? new Date() : null
},
include: {
author: {
select: { id: true, username: true }
}
}
});
res.json({ success: true, data: post });
} catch (error) {
console.error('Ошибка создания поста:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// Обновить пост - только для админов
export const updatePost = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const { title, content, excerpt, coverImage, url, status } = req.body;
// Проверка уникальности URL (если изменился)
if (url) {
const existingPost = await prisma.post.findUnique({ where: { url } });
if (existingPost && existingPost.id !== parseInt(id)) {
return res.status(400).json({ success: false, message: 'URL уже используется' });
}
}
const currentPost = await prisma.post.findUnique({ where: { id: parseInt(id) } });
const wasPublished = currentPost?.status === 'published';
const nowPublished = status === 'published';
const post = await prisma.post.update({
where: { id: parseInt(id) },
data: {
title,
content,
excerpt,
coverImage,
url,
status,
publishedAt: !wasPublished && nowPublished ? new Date() : currentPost?.publishedAt
},
include: {
author: {
select: { id: true, username: true }
}
}
});
res.json({ success: true, data: post });
} catch (error) {
console.error('Ошибка обновления поста:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// Удалить пост - только для админов
export const deletePost = async (req: Request, res: Response) => {
try {
const { id } = req.params;
await prisma.post.delete({
where: { id: parseInt(id) }
});
res.json({ success: true, message: 'Пост удалён' });
} catch (error) {
console.error('Ошибка удаления поста:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// Получить все комментарии (для модерации) - только для админов
export const getAllComments = async (req: Request, res: Response) => {
try {
const comments = await prisma.comment.findMany({
include: {
user: {
select: { id: true, username: true }
},
post: {
select: { id: true, title: true }
}
},
orderBy: { createdAt: 'desc' }
});
res.json({ success: true, data: comments });
} catch (error) {
console.error('Ошибка получения комментариев:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// Модерация комментария - только для админов
export const moderateComment = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const { status } = req.body; // approved, rejected
if (!['approved', 'rejected'].includes(status)) {
return res.status(400).json({ success: false, message: 'Неверный статус' });
}
const comment = await prisma.comment.update({
where: { id: parseInt(id) },
data: { status }
});
res.json({ success: true, data: comment });
} catch (error) {
console.error('Ошибка модерации комментария:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// Удалить комментарий - только для админов
export const deleteComment = async (req: Request, res: Response) => {
try {
const { id } = req.params;
await prisma.comment.delete({
where: { id: parseInt(id) }
});
res.json({ success: true, message: 'Комментарий удалён' });
} catch (error) {
console.error('Ошибка удаления комментария:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};

View File

@@ -0,0 +1,70 @@
import { Router } from 'express';
import multer from 'multer';
import path from 'path';
import {
getAllPosts,
getPostByUrl,
addComment,
getAllPostsAdmin,
getPostByIdAdmin,
createPost,
updatePost,
deletePost,
getAllComments,
moderateComment,
deleteComment
} from './blog.controller';
import { uploadImage, deleteImage } from './upload.controller';
import { authMiddleware, adminMiddleware, optionalAuthMiddleware } from '../auth/auth.middleware';
// Конфигурация multer для загрузки изображений
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, path.join(__dirname, '../../../uploads/blog'));
},
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, uniqueSuffix + path.extname(file.originalname));
}
});
const upload = multer({
storage: storage,
limits: {
fileSize: 10 * 1024 * 1024 // 10MB
},
fileFilter: function (req, file, cb) {
const allowedTypes = /jpeg|jpg|png|gif|webp/;
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const mimetype = allowedTypes.test(file.mimetype);
if (mimetype && extname) {
return cb(null, true);
} else {
cb(new Error('Разрешены только изображения (jpeg, jpg, png, gif, webp)'));
}
}
});
const router = Router();
// Публичные маршруты
router.get('/posts', getAllPosts);
router.get('/posts/:url', getPostByUrl);
router.post('/posts/:postId/comments', optionalAuthMiddleware, addComment); // Гости и авторизованные могут комментировать
// Админские маршруты
router.post('/admin/upload-image', authMiddleware, adminMiddleware, upload.single('image'), uploadImage);
router.delete('/admin/images/:filename', authMiddleware, adminMiddleware, deleteImage);
router.get('/admin/posts', authMiddleware, adminMiddleware, getAllPostsAdmin);
router.get('/admin/posts/:id', authMiddleware, adminMiddleware, getPostByIdAdmin);
router.post('/admin/posts', authMiddleware, adminMiddleware, createPost);
router.put('/admin/posts/:id', authMiddleware, adminMiddleware, updatePost);
router.delete('/admin/posts/:id', authMiddleware, adminMiddleware, deletePost);
router.get('/admin/comments', authMiddleware, adminMiddleware, getAllComments);
router.patch('/admin/comments/:id', authMiddleware, adminMiddleware, moderateComment);
router.delete('/admin/comments/:id', authMiddleware, adminMiddleware, deleteComment);
export default router;

View File

@@ -0,0 +1,67 @@
// backend/src/modules/blog/upload.controller.ts
import { Request, Response } from 'express';
import path from 'path';
import fs from 'fs';
export const uploadImage = async (req: Request, res: Response) => {
try {
if (!req.file) {
return res.status(400).json({
success: false,
message: 'Файл не загружен'
});
}
// Генерируем URL для доступа к изображению
const imageUrl = `/uploads/blog/${req.file.filename}`;
return res.status(200).json({
success: true,
data: {
url: `https://ospab.host:5000${imageUrl}`,
filename: req.file.filename
}
});
} catch (error) {
console.error('Ошибка загрузки изображения:', error);
return res.status(500).json({
success: false,
message: 'Ошибка загрузки изображения'
});
}
};
export const deleteImage = async (req: Request, res: Response) => {
try {
const { filename } = req.params;
if (!filename) {
return res.status(400).json({
success: false,
message: 'Имя файла не указано'
});
}
const filePath = path.join(__dirname, '../../../uploads/blog', filename);
// Проверяем существование файла
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
return res.status(200).json({
success: true,
message: 'Изображение удалено'
});
} else {
return res.status(404).json({
success: false,
message: 'Файл не найден'
});
}
} catch (error) {
console.error('Ошибка удаления изображения:', error);
return res.status(500).json({
success: false,
message: 'Ошибка удаления изображения'
});
}
};

View File

@@ -1,10 +1,9 @@
import { PrismaClient } from '@prisma/client';
import { prisma } from '../../prisma/client';
import { Request, Response } from 'express';
import { Multer } from 'multer';
import path from 'path';
import fs from 'fs';
const prisma = new PrismaClient();
import { logger } from '../../utils/logger';
// Тип расширенного запроса с Multer
interface MulterRequest extends Request {
@@ -38,29 +37,194 @@ export async function getChecks(req: Request, res: Response) {
res.json(checks);
}
// Подтвердить чек и пополнить баланс
// Подтвердить чек и пополнить баланс (только оператор)
export async function approveCheck(req: Request, res: Response) {
const { checkId } = req.body;
// Найти чек
const check = await prisma.check.findUnique({ where: { id: checkId } });
if (!check) return res.status(404).json({ error: 'Чек не найден' });
// Обновить статус
await prisma.check.update({ where: { id: checkId }, data: { status: 'approved' } });
// Пополнить баланс пользователя
await prisma.user.update({
where: { id: check.userId },
data: {
balance: {
increment: check.amount
}
try {
const { checkId } = req.body;
const isOperator = Number(req.user?.operator) === 1;
// Проверка прав оператора
if (!isOperator) {
logger.warn(`[Check] Попытка подтверждения чека #${checkId} не оператором (userId: ${req.user?.id})`);
return res.status(403).json({ error: 'Нет прав. Только операторы могут подтверждать чеки' });
}
});
res.json({ success: true });
// Найти чек
const check = await prisma.check.findUnique({
where: { id: checkId },
include: { user: true }
});
if (!check) {
return res.status(404).json({ error: 'Чек не найден' });
}
// Проверка что чек ещё не обработан
if (check.status !== 'pending') {
return res.status(400).json({
error: `Чек уже обработан (статус: ${check.status})`
});
}
// Обновить статус чека
await prisma.check.update({
where: { id: checkId },
data: { status: 'approved' }
});
// Пополнить баланс пользователя
await prisma.user.update({
where: { id: check.userId },
data: {
balance: {
increment: check.amount
}
}
});
logger.info(`[Check] ✅ Чек #${checkId} подтверждён оператором ${req.user?.id}. Пользователь ${check.user.username} получил ${check.amount}`);
res.json({ success: true, message: 'Чек подтверждён, баланс пополнен' });
} catch (error) {
logger.error('[Check] Ошибка подтверждения чека:', error);
res.status(500).json({ error: 'Ошибка подтверждения чека' });
}
}
// Отклонить чек
// Отклонить чек (только оператор)
export async function rejectCheck(req: Request, res: Response) {
const { checkId } = req.body;
await prisma.check.update({ where: { id: checkId }, data: { status: 'rejected' } });
res.json({ success: true });
try {
const { checkId, comment } = req.body;
const isOperator = Number(req.user?.operator) === 1;
// Проверка прав оператора
if (!isOperator) {
logger.warn(`[Check] Попытка отклонения чека #${checkId} не оператором (userId: ${req.user?.id})`);
return res.status(403).json({ error: 'Нет прав. Только операторы могут отклонять чеки' });
}
// Найти чек
const check = await prisma.check.findUnique({
where: { id: checkId },
include: { user: true }
});
if (!check) {
return res.status(404).json({ error: 'Чек не найден' });
}
// Проверка что чек ещё не обработан
if (check.status !== 'pending') {
return res.status(400).json({
error: `Чек уже обработан (статус: ${check.status})`
});
}
// Обновить статус чека
await prisma.check.update({
where: { id: checkId },
data: {
status: 'rejected',
// Можно добавить поле comment в модель Check для хранения причины отклонения
}
});
logger.info(`[Check] ❌ Чек #${checkId} отклонён оператором ${req.user?.id}. Пользователь: ${check.user.username}${comment ? `, причина: ${comment}` : ''}`);
res.json({ success: true, message: 'Чек отклонён' });
} catch (error) {
logger.error('[Check] Ошибка отклонения чека:', error);
res.status(500).json({ error: 'Ошибка отклонения чека' });
}
}
// Получить историю чеков текущего пользователя
export async function getUserChecks(req: Request, res: Response) {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизован' });
const checks = await prisma.check.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
take: 50 // Последние 50 чеков
});
res.json({ status: 'success', data: checks });
} catch (error) {
res.status(500).json({ error: 'Ошибка получения истории чеков' });
}
}
// Просмотреть конкретный чек (изображение)
export async function viewCheck(req: Request, res: Response) {
try {
const checkId = Number(req.params.id);
const userId = req.user?.id;
const isOperator = Number(req.user?.operator) === 1;
const check = await prisma.check.findUnique({ where: { id: checkId } });
if (!check) {
return res.status(404).json({ error: 'Чек не найден' });
}
// Проверка прав доступа (только владелец или оператор)
if (check.userId !== userId && !isOperator) {
return res.status(403).json({ error: 'Нет доступа к этому чеку' });
}
res.json({ status: 'success', data: check });
} catch (error) {
res.status(500).json({ error: 'Ошибка получения чека' });
}
}
// Получить файл изображения чека с авторизацией
export async function getCheckFile(req: Request, res: Response) {
try {
const filename = req.params.filename;
const userId = req.user?.id;
const isOperator = Number(req.user?.operator) === 1;
logger.debug(`[CheckFile] Запрос файла ${filename} от пользователя ${userId}, оператор: ${isOperator}`);
// Операторы имеют доступ ко всем файлам
if (!isOperator) {
// Для обычных пользователей проверяем владение
const check = await prisma.check.findFirst({
where: {
fileUrl: {
contains: filename
}
},
select: {
id: true,
userId: true
}
});
if (!check) {
logger.warn(`[CheckFile] Чек с файлом ${filename} не найден в БД`);
return res.status(404).json({ error: 'Файл не найден' });
}
if (check.userId !== userId) {
logger.warn(`[CheckFile] Пользователь ${userId} попытался получить доступ к чужому чеку (владелец: ${check.userId})`);
return res.status(403).json({ error: 'Нет доступа к этому файлу' });
}
}
// Путь к файлу
const filePath = path.join(__dirname, '../../../uploads/checks', filename);
if (!fs.existsSync(filePath)) {
logger.warn(`[CheckFile] Файл ${filename} не найден на диске`);
return res.status(404).json({ error: 'Файл не найден на сервере' });
}
logger.debug(`[CheckFile] Доступ разрешён, отправка файла ${filename}`);
res.sendFile(filePath);
} catch (error) {
logger.error('[CheckFile] Ошибка получения файла:', error);
res.status(500).json({ error: 'Ошибка получения файла' });
}
}

View File

@@ -1,5 +1,5 @@
import { Router } from 'express';
import { uploadCheck, getChecks, approveCheck, rejectCheck } from './check.controller';
import { uploadCheck, getChecks, approveCheck, rejectCheck, getUserChecks, viewCheck, getCheckFile } from './check.controller';
import { authMiddleware } from '../auth/auth.middleware';
import multer, { MulterError } from 'multer';
import path from 'path';
@@ -48,7 +48,10 @@ const upload = multer({
router.use(authMiddleware);
router.post('/upload', upload.single('file'), uploadCheck);
router.get('/', getChecks);
router.get('/', getChecks); // Для операторов - все чеки
router.get('/my', getUserChecks); // Для пользователей - свои чеки
router.get('/file/:filename', getCheckFile); // Получение файла чека с авторизацией
router.get('/:id', viewCheck); // Просмотр конкретного чека
router.post('/approve', approveCheck);
router.post('/reject', rejectCheck);

View File

@@ -1,5 +1,6 @@
import nodemailer from 'nodemailer';
import { PrismaClient } from '@prisma/client';
import { logger } from '../../utils/logger';
const prisma = new PrismaClient();
@@ -28,7 +29,7 @@ export async function sendEmail(notification: EmailNotification) {
try {
// Проверяем наличие конфигурации SMTP
if (!process.env.SMTP_USER || !process.env.SMTP_PASS) {
console.log('SMTP not configured, skipping email notification');
logger.debug('SMTP not configured, skipping email notification');
return { status: 'skipped', message: 'SMTP not configured' };
}
@@ -37,10 +38,10 @@ export async function sendEmail(notification: EmailNotification) {
...notification
});
console.log('Email sent: %s', info.messageId);
logger.info('Email sent: %s', info.messageId);
return { status: 'success', messageId: info.messageId };
} catch (error: any) {
console.error('Error sending email:', error);
logger.error('Error sending email:', error);
return { status: 'error', message: error.message };
}
}
@@ -70,7 +71,7 @@ export async function sendResourceAlertEmail(userId: number, serverId: number, a
html
});
} catch (error: any) {
console.error('Error sending resource alert email:', error);
logger.error('Error sending resource alert email:', error);
return { status: 'error', message: error.message };
}
}
@@ -102,7 +103,7 @@ export async function sendServerCreatedEmail(userId: number, serverId: number, s
html
});
} catch (error: any) {
console.error('Error sending server created email:', error);
logger.error('Error sending server created email:', error);
return { status: 'error', message: error.message };
}
}
@@ -129,7 +130,7 @@ export async function sendPaymentReminderEmail(userId: number, serverId: number,
html
});
} catch (error: any) {
console.error('Error sending payment reminder email:', error);
logger.error('Error sending payment reminder email:', error);
return { status: 'error', message: error.message };
}
}

View File

@@ -0,0 +1,420 @@
import { Request, Response } from 'express';
import { prisma } from '../../prisma/client';
import { subscribePush, unsubscribePush, getVapidPublicKey, sendPushNotification } from './push.service';
import { broadcastToUser } from '../../websocket/server';
import { logger } from '../../utils/logger';
// Получить все уведомления пользователя с пагинацией
export const getNotifications = async (req: Request, res: Response) => {
try {
const userId = req.user!.id;
const { page = '1', limit = '20', filter = 'all' } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(limit as string);
const take = parseInt(limit as string);
const where: { userId: number; isRead?: boolean } = { userId };
if (filter === 'unread') {
where.isRead = false;
}
const [notifications, total] = await Promise.all([
prisma.notification.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take
}),
prisma.notification.count({ where })
]);
res.json({
success: true,
data: notifications,
pagination: {
page: parseInt(page as string),
limit: parseInt(limit as string),
total,
totalPages: Math.ceil(total / take)
}
});
} catch (error) {
console.error('Ошибка получения уведомлений:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// Получить количество непрочитанных уведомлений
export const getUnreadCount = async (req: Request, res: Response) => {
try {
const userId = req.user!.id;
const count = await prisma.notification.count({
where: {
userId,
isRead: false
}
});
res.json({ success: true, count });
} catch (error) {
console.error('Ошибка подсчета непрочитанных:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// Пометить уведомление как прочитанное
export const markAsRead = async (req: Request, res: Response) => {
try {
const userId = req.user!.id;
const { id } = req.params;
const notification = await prisma.notification.findFirst({
where: {
id: parseInt(id),
userId
}
});
if (!notification) {
return res.status(404).json({ success: false, message: 'Уведомление не найдено' });
}
await prisma.notification.update({
where: { id: parseInt(id) },
data: { isRead: true }
});
// Отправляем через WebSocket
try {
broadcastToUser(userId, 'notifications', {
type: 'notification:read',
notificationId: parseInt(id)
});
} catch (wsError) {
logger.warn('[WS] Ошибка отправки через WebSocket:', wsError);
}
res.json({ success: true, message: 'Отмечено как прочитанное' });
} catch (error) {
console.error('Ошибка отметки уведомления:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// Пометить все уведомления как прочитанные
export const markAllAsRead = async (req: Request, res: Response) => {
try {
const userId = req.user!.id;
await prisma.notification.updateMany({
where: {
userId,
isRead: false
},
data: { isRead: true }
});
res.json({ success: true, message: 'Все уведомления прочитаны' });
} catch (error) {
console.error('Ошибка отметки всех уведомлений:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// Удалить уведомление
export const deleteNotification = async (req: Request, res: Response) => {
try {
const userId = req.user!.id;
const { id } = req.params;
const notification = await prisma.notification.findFirst({
where: {
id: parseInt(id),
userId
}
});
if (!notification) {
return res.status(404).json({ success: false, message: 'Уведомление не найдено' });
}
await prisma.notification.delete({
where: { id: parseInt(id) }
});
// Отправляем через WebSocket
try {
broadcastToUser(userId, 'notifications', {
type: 'notification:delete',
notificationId: parseInt(id)
});
} catch (wsError) {
logger.warn('[WS] Ошибка отправки через WebSocket:', wsError);
}
res.json({ success: true, message: 'Уведомление удалено' });
} catch (error) {
console.error('Ошибка удаления уведомления:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// Удалить все прочитанные уведомления
export const deleteAllRead = async (req: Request, res: Response) => {
try {
const userId = req.user!.id;
await prisma.notification.deleteMany({
where: {
userId,
isRead: true
}
});
res.json({ success: true, message: 'Прочитанные уведомления удалены' });
} catch (error) {
console.error('Ошибка удаления прочитанных:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// Функция-хелпер для создания уведомления
interface CreateNotificationParams {
userId: number;
type: string;
title: string;
message: string;
ticketId?: number;
checkId?: number;
actionUrl?: string;
icon?: string;
color?: string;
}
export async function createNotification(params: CreateNotificationParams) {
try {
const notification = await prisma.notification.create({
data: {
userId: params.userId,
type: params.type,
title: params.title,
message: params.message,
ticketId: params.ticketId,
checkId: params.checkId,
actionUrl: params.actionUrl,
icon: params.icon,
color: params.color
}
});
// Отправляем через WebSocket всем подключенным клиентам пользователя
try {
broadcastToUser(params.userId, 'notifications', {
type: 'notification:new',
notification
});
logger.log(`[WS] Уведомление отправлено пользователю ${params.userId} через WebSocket`);
} catch (wsError) {
logger.warn('[WS] Ошибка отправки через WebSocket:', wsError);
// Не прерываем выполнение
}
// Отправляем Push-уведомление если есть подписки
try {
await sendPushNotification(params.userId, {
title: params.title,
body: params.message,
icon: params.icon,
data: {
notificationId: notification.id,
type: params.type,
actionUrl: params.actionUrl
}
});
} catch (pushError) {
console.error('Ошибка отправки Push:', pushError);
// Не прерываем выполнение если Push не отправился
}
return notification;
} catch (error) {
console.error('Ошибка создания уведомления:', error);
throw error;
}
}
// Получить публичный VAPID ключ для настройки Push на клиенте
export const getVapidKey = async (req: Request, res: Response) => {
try {
const publicKey = getVapidPublicKey();
res.json({ success: true, publicKey });
} catch (error) {
console.error('Ошибка получения VAPID ключа:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// Подписаться на Push-уведомления
export const subscribe = async (req: Request, res: Response) => {
try {
const userId = req.user!.id;
const { subscription } = req.body;
const userAgent = req.headers['user-agent'];
if (!subscription || !subscription.endpoint || !subscription.keys) {
return res.status(400).json({ success: false, message: 'Некорректные данные подписки' });
}
await subscribePush(userId, subscription, userAgent);
res.json({ success: true, message: 'Push-уведомления подключены' });
} catch (error) {
console.error('Ошибка подписки на Push:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// Отписаться от Push-уведомлений
export const unsubscribe = async (req: Request, res: Response) => {
try {
const userId = req.user!.id;
const { endpoint } = req.body;
if (!endpoint) {
return res.status(400).json({ success: false, message: 'Endpoint не указан' });
}
await unsubscribePush(userId, endpoint);
res.json({ success: true, message: 'Push-уведомления отключены' });
} catch (error) {
console.error('Ошибка отписки от Push:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// Тестовая отправка Push-уведомления (только для админов)
export const testPushNotification = async (req: Request, res: Response) => {
try {
const userId = req.user!.id;
const user = req.user!;
logger.log('[TEST PUSH] Запрос от пользователя:', { userId, username: user.username });
// Проверяем права администратора
if (!user.isAdmin) {
logger.log('[TEST PUSH] Отказано в доступе - пользователь не админ');
return res.status(403).json({
success: false,
message: 'Только администраторы могут отправлять тестовые уведомления'
});
}
logger.log('[TEST PUSH] Пользователь является админом, продолжаем...');
// Проверяем наличие подписок
const subscriptions = await prisma.pushSubscription.findMany({
where: { userId }
});
logger.log(`[TEST PUSH] Найдено подписок для пользователя ${userId}:`, subscriptions.length);
if (subscriptions.length === 0) {
logger.log('[TEST PUSH] Нет активных подписок');
return res.status(400).json({
success: false,
message: 'У вас нет активных Push-подписок. Включите уведомления на странице уведомлений.'
});
}
// Выводим информацию о подписках
subscriptions.forEach((sub, index) => {
logger.log(` Подписка ${index + 1}:`, {
id: sub.id,
endpoint: sub.endpoint.substring(0, 50) + '...',
userAgent: sub.userAgent,
createdAt: sub.createdAt,
lastUsed: sub.lastUsed
});
});
// Создаём тестовое уведомление в БД
logger.log('[TEST PUSH] Создаём тестовое уведомление в БД...');
const notification = await prisma.notification.create({
data: {
userId,
type: 'test',
title: 'Тестовое уведомление',
message: 'Это тестовое Push-уведомление. Если вы его видите — всё работает отлично!',
icon: 'test',
color: 'purple',
actionUrl: '/dashboard/notifications'
}
});
logger.log('[TEST PUSH] Уведомление создано в БД:', notification.id);
// Отправляем Push-уведомление
logger.log('[TEST PUSH] Отправляем Push-уведомление...');
try {
await sendPushNotification(userId, {
title: 'Тестовое уведомление',
body: 'Это тестовое Push-уведомление. Если вы его видите — всё работает отлично!',
icon: '/logo192.png',
badge: '/favicon.svg',
data: {
notificationId: notification.id,
actionUrl: '/dashboard/notifications'
}
});
logger.log('[TEST PUSH] Push-уведомление успешно отправлено!');
res.json({
success: true,
message: 'Тестовое Push-уведомление отправлено! Проверьте браузер.',
data: {
notificationId: notification.id,
subscriptionsCount: subscriptions.length
}
});
} catch (pushError) {
logger.error('[TEST PUSH] Ошибка при отправке Push:', pushError);
// Детальная информация об ошибке
if (pushError && typeof pushError === 'object') {
logger.error(' Детали ошибки:', {
name: (pushError as Error).name,
message: (pushError as Error).message,
stack: (pushError as Error).stack?.split('\n').slice(0, 3)
});
if ('statusCode' in pushError) {
logger.error(' HTTP статус код:', (pushError as { statusCode: number }).statusCode);
}
}
res.status(500).json({
success: false,
message: 'Уведомление создано в БД, но ошибка при отправке Push. Проверьте консоль сервера.',
error: pushError instanceof Error ? pushError.message : 'Неизвестная ошибка'
});
}
} catch (error) {
logger.error('[TEST PUSH] Критическая ошибка:', error);
if (error instanceof Error) {
logger.error(' Стек ошибки:', error.stack);
}
res.status(500).json({
success: false,
message: 'Критическая ошибка при отправке тестового уведомления',
error: error instanceof Error ? error.message : 'Неизвестная ошибка'
});
}
};

View File

@@ -1,9 +1,51 @@
import { Router } from 'express';
import { getNotifications } from './notification.service';
import {
getNotifications,
getUnreadCount,
markAsRead,
markAllAsRead,
deleteNotification,
deleteAllRead,
getVapidKey,
subscribe,
unsubscribe,
testPushNotification
} from './notification.controller';
import { authMiddleware } from '../auth/auth.middleware';
const router = Router();
router.get('/', authMiddleware, getNotifications);
// Все роуты требуют авторизации
router.use(authMiddleware);
// Получить уведомления с пагинацией и фильтрами
router.get('/', getNotifications);
// Получить количество непрочитанных
router.get('/unread-count', getUnreadCount);
// Получить публичный VAPID ключ для Push-уведомлений
router.get('/vapid-key', getVapidKey);
// Подписаться на Push-уведомления
router.post('/subscribe-push', subscribe);
// Отписаться от Push-уведомлений
router.delete('/unsubscribe-push', unsubscribe);
// Тестовая отправка Push-уведомления (только для админов)
router.post('/test-push', testPushNotification);
// Пометить уведомление как прочитанное
router.post('/:id/read', markAsRead);
// Пометить все как прочитанные
router.post('/read-all', markAllAsRead);
// Удалить уведомление
router.delete('/:id', deleteNotification);
// Удалить все прочитанные
router.delete('/read/all', deleteAllRead);
export default router;

View File

@@ -1,28 +0,0 @@
import { Request, Response } from 'express';
import { prisma } from '../../prisma/client';
export const getNotifications = async (req: Request, res: Response) => {
try {
// @ts-ignore
const userId = req.user?.id;
if (!userId) return res.status(401).json({ error: 'Нет авторизации' });
const notifications = await prisma.notification.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
});
res.json({ notifications });
} catch (err) {
res.status(500).json({ error: 'Ошибка получения уведомлений' });
}
};
export const createNotification = async (userId: number, title: string, message: string) => {
return prisma.notification.create({
data: {
userId,
title,
message,
},
});
};

View File

@@ -0,0 +1,148 @@
import webpush from 'web-push';
import { prisma } from '../../prisma/client';
// VAPID ключи (нужно сгенерировать один раз и сохранить в .env)
// Для генерации: npx web-push generate-vapid-keys
const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY || '';
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY || '';
const VAPID_SUBJECT = process.env.VAPID_SUBJECT || 'mailto:support@ospab.host';
// Настройка web-push
if (VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY) {
webpush.setVapidDetails(
VAPID_SUBJECT,
VAPID_PUBLIC_KEY,
VAPID_PRIVATE_KEY
);
}
// Сохранить Push-подписку пользователя
export async function subscribePush(userId: number, subscription: {
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
}, userAgent?: string) {
try {
// Проверяем, существует ли уже такая подписка
const existing = await prisma.pushSubscription.findFirst({
where: {
userId,
endpoint: subscription.endpoint
}
});
if (existing) {
// Обновляем lastUsed
await prisma.pushSubscription.update({
where: { id: existing.id },
data: { lastUsed: new Date() }
});
return existing;
}
// Создаём новую подписку
const pushSubscription = await prisma.pushSubscription.create({
data: {
userId,
endpoint: subscription.endpoint,
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
userAgent
}
});
return pushSubscription;
} catch (error) {
console.error('Ошибка сохранения Push-подписки:', error);
throw error;
}
}
// Удалить Push-подписку
export async function unsubscribePush(userId: number, endpoint: string) {
try {
await prisma.pushSubscription.deleteMany({
where: {
userId,
endpoint
}
});
} catch (error) {
console.error('Ошибка удаления Push-подписки:', error);
throw error;
}
}
// Отправить Push-уведомление конкретному пользователю
export async function sendPushNotification(
userId: number,
payload: {
title: string;
body: string;
icon?: string;
badge?: string;
data?: Record<string, unknown>;
}
) {
try {
// Получаем все подписки пользователя
const subscriptions = await prisma.pushSubscription.findMany({
where: { userId }
});
if (subscriptions.length === 0) {
return; // Нет подписок
}
// Отправляем на все устройства параллельно
const promises = subscriptions.map(async (sub) => {
try {
const pushSubscription = {
endpoint: sub.endpoint,
keys: {
p256dh: sub.p256dh,
auth: sub.auth
}
};
await webpush.sendNotification(
pushSubscription,
JSON.stringify({
title: payload.title,
body: payload.body,
icon: payload.icon || '/logo192.png',
badge: payload.badge || '/logo192.png',
data: payload.data || {}
})
);
// Обновляем lastUsed
await prisma.pushSubscription.update({
where: { id: sub.id },
data: { lastUsed: new Date() }
});
} catch (error: unknown) {
// Если подписка устарела (410 Gone), удаляем её
if (error && typeof error === 'object' && 'statusCode' in error && error.statusCode === 410) {
await prisma.pushSubscription.delete({
where: { id: sub.id }
});
} else {
console.error(`Ошибка отправки Push на ${sub.endpoint}:`, error);
}
}
});
await Promise.allSettled(promises);
} catch (error) {
console.error('Ошибка отправки Push-уведомлений:', error);
throw error;
}
}
// Получить публичный VAPID ключ (для frontend)
export function getVapidPublicKey() {
return VAPID_PUBLIC_KEY;
}

View File

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

View File

@@ -1,22 +0,0 @@
import { Router } from 'express';
import { PrismaClient } from '@prisma/client';
import { authMiddleware } from '../auth/auth.middleware';
const router = Router();
const prisma = new PrismaClient();
router.use(authMiddleware);
// GET /api/os — получить все ОС (только для авторизованных)
router.get('/', async (req, res) => {
try {
const oses = await prisma.operatingSystem.findMany();
res.json(oses);
} catch (err) {
console.error('Ошибка получения ОС:', err);
res.status(500).json({ error: 'Ошибка получения ОС' });
}
});
export default router;

View File

@@ -1,164 +1,177 @@
import { prisma } from '../../prisma/client';
import type { StorageBucket, User } from '@prisma/client';
import { prisma } from '../../prisma/client';
import { createNotification } from '../notification/notification.controller';
import { logger } from '../../utils/logger';
const BILLING_INTERVAL_DAYS = 30;
const GRACE_RETRY_DAYS = 1;
// Утилита для добавления дней к дате
function addDays(date: Date, days: number): Date {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
const clone = new Date(date);
clone.setDate(clone.getDate() + days);
return clone;
}
export class PaymentService {
type BucketWithUser = StorageBucket & { user: User };
class PaymentService {
/**
* Обработка автоматических платежей за серверы
* Запускается по расписанию каждые 6 часов
* Обрабатываем автоматические платежи за S3 бакеты.
* Ставим cron на запуск раз в 6 часов.
*/
async processAutoPayments() {
async processAutoPayments(): Promise<void> {
const now = new Date();
// Находим серверы, у которых пришло время оплаты
const serversDue = await prisma.server.findMany({
const buckets = await prisma.storageBucket.findMany({
where: {
status: { in: ['running', 'stopped'] },
autoRenew: true,
nextPaymentDate: {
lte: now
}
nextBillingDate: { lte: now },
status: { in: ['active', 'grace'] }
},
include: {
user: true,
tariff: true
}
include: { user: true }
});
console.log(`[Payment Service] Найдено серверов для оплаты: ${serversDue.length}`);
for (const server of serversDue) {
try {
await this.chargeServerPayment(server);
} catch (error) {
console.error(`[Payment Service] Ошибка при списании за сервер ${server.id}:`, error);
}
}
}
/**
* Списание оплаты за конкретный сервер
*/
async chargeServerPayment(server: any) {
const amount = server.tariff.price;
const user = server.user;
// Проверяем достаточно ли средств
if (user.balance < amount) {
console.log(`[Payment Service] Недостаточно средств у пользователя ${user.id} для сервера ${server.id}`);
// Создаём запись о неудачном платеже
await prisma.payment.create({
data: {
userId: user.id,
serverId: server.id,
amount,
status: 'failed',
type: 'subscription',
processedAt: new Date()
}
});
// Отправляем уведомление
await prisma.notification.create({
data: {
userId: user.id,
title: 'Недостаточно средств',
message: `Не удалось списать ${amount}₽ за сервер #${server.id}. Пополните баланс, иначе сервер будет приостановлен.`
}
});
// Приостанавливаем сервер через 3 дня неоплаты
const daysSincePaymentDue = Math.floor((new Date().getTime() - server.nextPaymentDate.getTime()) / (1000 * 60 * 60 * 24));
if (daysSincePaymentDue >= 3) {
await prisma.server.update({
where: { id: server.id },
data: { status: 'suspended' }
});
await prisma.notification.create({
data: {
userId: user.id,
title: 'Сервер приостановлен',
message: `Сервер #${server.id} приостановлен из-за неоплаты.`
}
});
}
if (buckets.length === 0) {
logger.debug('[Payment Service] Нет бакетов для списания.');
return;
}
// Списываем средства
const balanceBefore = user.balance;
const balanceAfter = balanceBefore - amount;
logger.info(`[Payment Service] Найдено бакетов для списания: ${buckets.length}`);
await prisma.$transaction([
// Обновляем баланс
prisma.user.update({
where: { id: user.id },
data: { balance: balanceAfter }
}),
for (const bucket of buckets) {
try {
await this.chargeBucket(bucket);
} catch (error) {
logger.error(`[Payment Service] Ошибка списания за бакет ${bucket.id}`, error);
}
}
}
// Создаём запись о платеже
prisma.payment.create({
data: {
userId: user.id,
serverId: server.id,
amount,
status: 'success',
type: 'subscription',
processedAt: new Date()
}
}),
// Записываем транзакцию
prisma.transaction.create({
data: {
userId: user.id,
amount: -amount,
type: 'withdrawal',
description: `Оплата сервера #${server.id} за месяц`,
balanceBefore,
balanceAfter
}
}),
// Обновляем дату следующего платежа (через 30 дней)
prisma.server.update({
where: { id: server.id },
data: {
nextPaymentDate: addDays(new Date(), 30)
}
})
]);
console.log(`[Payment Service] Успешно списано ${amount}с пользователя ${user.id} за сервер ${server.id}`);
// Отправляем уведомление
await prisma.notification.create({
/**
* Устанавливает дату первого списания (через 30 дней) для только что созданного ресурса.
*/
async setInitialPaymentDate(bucketId: number): Promise<void> {
await prisma.storageBucket.update({
where: { id: bucketId },
data: {
userId: user.id,
title: 'Списание за сервер',
message: `Списано ${amount}₽ за сервер #${server.id}. Следующая оплата: ${addDays(new Date(), 30).toLocaleDateString('ru-RU')}`
nextBillingDate: addDays(new Date(), BILLING_INTERVAL_DAYS)
}
});
}
/**
* Устанавливаем дату первого платежа при создании сервера
*/
async setInitialPaymentDate(serverId: number) {
await prisma.server.update({
where: { id: serverId },
private async chargeBucket(bucket: BucketWithUser): Promise<void> {
const now = new Date();
if (bucket.user.balance < bucket.monthlyPrice) {
await this.handleInsufficientFunds(bucket, now);
return;
}
const { bucket: updatedBucket, balanceBefore, balanceAfter } = await prisma.$transaction(async (tx) => {
const user = await tx.user.findUnique({ where: { id: bucket.userId } });
if (!user) throw new Error('Пользователь не найден');
if (user.balance < bucket.monthlyPrice) {
// Баланс мог измениться между выборкой и транзакцией
return { bucket, balanceBefore: user.balance, balanceAfter: user.balance };
}
const newBalance = user.balance - bucket.monthlyPrice;
await tx.user.update({
where: { id: user.id },
data: { balance: newBalance }
});
await tx.transaction.create({
data: {
userId: bucket.userId,
amount: -bucket.monthlyPrice,
type: 'withdrawal',
description: `Ежемесячная оплата бакета «${bucket.name}»`,
balanceBefore: user.balance,
balanceAfter: newBalance
}
});
const nextBilling = addDays(now, BILLING_INTERVAL_DAYS);
const updated = await tx.storageBucket.update({
where: { id: bucket.id },
data: {
status: 'active',
lastBilledAt: now,
nextBillingDate: nextBilling,
autoRenew: true
}
});
return { bucket: updated, balanceBefore: user.balance, balanceAfter: newBalance };
});
if (balanceBefore === balanceAfter) {
// Значит баланс поменялся внутри транзакции, пересоздадим попытку в следующий цикл
await this.handleInsufficientFunds(bucket, now);
return;
}
await createNotification({
userId: bucket.userId,
type: 'storage_payment_charged',
title: 'Оплата S3 хранилища',
message: `Списано ₽${bucket.monthlyPrice.toFixed(2)} за бакет «${bucket.name}». Следующее списание ${updatedBucket.nextBillingDate ? new Date(updatedBucket.nextBillingDate).toLocaleDateString('ru-RU') : '—'}`,
color: 'blue'
});
logger.info(`[Payment Service] Успешное списание ₽${bucket.monthlyPrice} за бакет ${bucket.name}; баланс ${balanceAfter}`);
}
private async handleInsufficientFunds(bucket: BucketWithUser, now: Date): Promise<void> {
if (bucket.status === 'suspended') {
return;
}
if (bucket.status === 'grace') {
await prisma.storageBucket.update({
where: { id: bucket.id },
data: {
status: 'suspended',
autoRenew: false,
nextBillingDate: null
}
});
await createNotification({
userId: bucket.userId,
type: 'storage_payment_failed',
title: 'S3 бакет приостановлен',
message: `Не удалось списать ₽${bucket.monthlyPrice.toFixed(2)} за бакет «${bucket.name}». Автопродление отключено.` ,
color: 'red'
});
logger.warn(`[Payment Service] Бакет ${bucket.name} приостановлен из-за нехватки средств.`);
return;
}
// Переводим в grace и пробуем снова через день
const retryDate = addDays(now, GRACE_RETRY_DAYS);
await prisma.storageBucket.update({
where: { id: bucket.id },
data: {
nextPaymentDate: addDays(new Date(), 30)
status: 'grace',
nextBillingDate: retryDate
}
});
await createNotification({
userId: bucket.userId,
type: 'storage_payment_pending',
title: 'Недостаточно средств для оплаты S3',
message: `На балансе недостаточно средств для оплаты бакета «${bucket.name}». Пополните счёт до ${retryDate.toLocaleDateString('ru-RU')}, иначе бакет будет приостановлен.`,
color: 'orange'
});
logger.warn(`[Payment Service] Недостаточно средств для бакета ${bucket.name}, установлен статус grace.`);
}
}

View File

@@ -0,0 +1,268 @@
import { Request, Response } from 'express';
import { prisma } from '../../prisma/client';
import crypto from 'crypto';
import { createSession } from '../session/session.controller';
import { logger } from '../../utils/logger';
const QR_EXPIRATION_SECONDS = 60; // QR-код живёт 60 секунд
// Генерировать уникальный код для QR
function generateQRCode(): string {
return crypto.randomBytes(32).toString('hex');
}
// Создать новый QR-запрос для логина
export async function createQRLoginRequest(req: Request, res: Response) {
try {
const code = generateQRCode();
const ipAddress = req.headers['x-forwarded-for'] as string || req.socket.remoteAddress || '';
const userAgent = req.headers['user-agent'] || '';
const expiresAt = new Date();
expiresAt.setSeconds(expiresAt.getSeconds() + QR_EXPIRATION_SECONDS);
const qrRequest = await prisma.qrLoginRequest.create({
data: {
code,
ipAddress,
userAgent,
status: 'pending',
expiresAt
}
});
res.json({
code: qrRequest.code,
expiresAt: qrRequest.expiresAt,
expiresIn: QR_EXPIRATION_SECONDS
});
} catch (error) {
logger.error('Ошибка создания QR-запроса:', error);
res.status(500).json({ error: 'Ошибка создания QR-кода' });
}
}
// Проверить статус QR-запроса (polling с клиента)
export async function checkQRStatus(req: Request, res: Response) {
try {
const { code } = req.params;
const qrRequest = await prisma.qrLoginRequest.findUnique({
where: { code }
});
if (!qrRequest) {
return res.status(404).json({ error: 'QR-код не найден' });
}
// Проверяем истёк ли QR-код
if (new Date() > qrRequest.expiresAt) {
await prisma.qrLoginRequest.update({
where: { code },
data: { status: 'expired' }
});
return res.json({ status: 'expired' });
}
// Если подтверждён, создаём сессию и возвращаем токен
if (qrRequest.status === 'confirmed' && qrRequest.userId) {
const user = await prisma.user.findUnique({
where: { id: qrRequest.userId },
select: {
id: true,
email: true,
username: true,
operator: true,
isAdmin: true,
balance: true
}
});
if (!user) {
return res.status(404).json({ error: 'Пользователь не найден' });
}
// Создаём сессию для нового устройства
const { token } = await createSession(user.id, req);
// Удаляем использованный QR-запрос
await prisma.qrLoginRequest.delete({ where: { code } });
return res.json({
status: 'confirmed',
token,
user: {
id: user.id,
email: user.email,
username: user.username,
operator: user.operator,
isAdmin: user.isAdmin,
balance: user.balance
}
});
}
res.json({ status: qrRequest.status });
} catch (error) {
logger.error('Ошибка проверки статуса QR:', error);
res.status(500).json({ error: 'Ошибка проверки статуса' });
}
}
// Подтвердить QR-вход (вызывается с мобильного устройства где пользователь уже залогинен)
export async function confirmQRLogin(req: Request, res: Response) {
try {
const userId = req.user?.id;
const { code } = req.body;
logger.debug('[QR Confirm] Запрос подтверждения:', { userId, code, hasUser: !!req.user });
if (!userId) {
logger.warn('[QR Confirm] Ошибка: пользователь не авторизован');
return res.status(401).json({ error: 'Не авторизован' });
}
if (!code) {
logger.warn('[QR Confirm] Ошибка: код не предоставлен');
return res.status(400).json({ error: 'Код не предоставлен' });
}
const qrRequest = await prisma.qrLoginRequest.findUnique({
where: { code }
});
logger.debug('[QR Confirm] Найден QR-запрос:', qrRequest ? {
code: qrRequest.code,
status: qrRequest.status,
expiresAt: qrRequest.expiresAt
} : 'не найден');
if (!qrRequest) {
logger.warn('[QR Confirm] Ошибка: QR-код не найден в БД');
return res.status(404).json({ error: 'QR-код не найден' });
}
if (qrRequest.status !== 'pending' && qrRequest.status !== 'scanning') {
logger.warn('[QR Confirm] Ошибка: QR-код уже использован, статус:', qrRequest.status);
return res.status(400).json({ error: 'QR-код уже использован' });
}
if (new Date() > qrRequest.expiresAt) {
logger.warn('[QR Confirm] Ошибка: QR-код истёк');
await prisma.qrLoginRequest.update({
where: { code },
data: { status: 'expired' }
});
return res.status(400).json({ error: 'QR-код истёк' });
}
// Подтверждаем вход
await prisma.qrLoginRequest.update({
where: { code },
data: {
status: 'confirmed',
userId,
confirmedAt: new Date()
}
});
logger.info('[QR Confirm] Успешно: вход подтверждён для пользователя', userId);
res.json({ message: 'Вход подтверждён', success: true });
} catch (error) {
logger.error('[QR Confirm] Ошибка подтверждения QR-входа:', error);
res.status(500).json({ error: 'Ошибка подтверждения входа' });
}
}
// Отклонить QR-вход
export async function rejectQRLogin(req: Request, res: Response) {
try {
const userId = req.user?.id;
const { code } = req.body;
if (!userId) {
return res.status(401).json({ error: 'Не авторизован' });
}
const qrRequest = await prisma.qrLoginRequest.findUnique({
where: { code }
});
if (!qrRequest) {
return res.status(404).json({ error: 'QR-код не найден' });
}
await prisma.qrLoginRequest.update({
where: { code },
data: { status: 'rejected' }
});
res.json({ message: 'Вход отклонён' });
} catch (error) {
logger.error('Ошибка отклонения QR-входа:', error);
res.status(500).json({ error: 'Ошибка отклонения входа' });
}
}
// Обновить статус на "scanning" (когда пользователь открыл страницу подтверждения)
export async function markQRAsScanning(req: Request, res: Response) {
try {
const userId = req.user?.id;
const { code } = req.body;
if (!userId) {
return res.status(401).json({ error: 'Не авторизован' });
}
const qrRequest = await prisma.qrLoginRequest.findUnique({
where: { code }
});
if (!qrRequest) {
return res.status(404).json({ error: 'QR-код не найден' });
}
if (qrRequest.status !== 'pending') {
return res.json({ message: 'QR-код уже обработан', status: qrRequest.status });
}
if (new Date() > qrRequest.expiresAt) {
await prisma.qrLoginRequest.update({
where: { code },
data: { status: 'expired' }
});
return res.status(400).json({ error: 'QR-код истёк' });
}
// Обновляем статус на "scanning"
await prisma.qrLoginRequest.update({
where: { code },
data: { status: 'scanning' }
});
res.json({ message: 'Статус обновлён', success: true });
} catch (error) {
logger.error('Ошибка обновления статуса QR:', error);
res.status(500).json({ error: 'Ошибка обновления статуса' });
}
}
// Очистка устаревших QR-запросов (запускать периодически)
export async function cleanupExpiredQRRequests() {
try {
const result = await prisma.qrLoginRequest.deleteMany({
where: {
OR: [
{ expiresAt: { lt: new Date() } },
{
status: { in: ['confirmed', 'rejected', 'expired'] },
createdAt: { lt: new Date(Date.now() - 24 * 60 * 60 * 1000) } // старше 24 часов
}
]
}
});
logger.info(`[QR Cleanup] Удалено ${result.count} устаревших QR-запросов`);
} catch (error) {
logger.error('[QR Cleanup] Ошибка:', error);
}
}

View File

@@ -0,0 +1,28 @@
import { Router } from 'express';
import {
createQRLoginRequest,
checkQRStatus,
confirmQRLogin,
rejectQRLogin,
markQRAsScanning
} from './qr-auth.controller';
import { authMiddleware } from '../auth/auth.middleware';
const router = Router();
// Создать новый QR-код для входа (публичный endpoint)
router.post('/generate', createQRLoginRequest);
// Проверить статус QR-кода (polling, публичный endpoint)
router.get('/status/:code', checkQRStatus);
// Отметить что пользователь открыл страницу подтверждения (требует авторизации)
router.post('/scanning', authMiddleware, markQRAsScanning);
// Подтвердить QR-вход (требует авторизации - вызывается с телефона)
router.post('/confirm', authMiddleware, confirmQRLogin);
// Отклонить QR-вход (требует авторизации)
router.post('/reject', authMiddleware, rejectQRLogin);
export default router;

View File

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

View File

@@ -1,191 +0,0 @@
import { Server as SocketIOServer, Socket } from 'socket.io';
import { PrismaClient } from '@prisma/client';
import { getContainerStats } from './proxmoxApi';
import { sendResourceAlertEmail } from '../notification/email.service';
const prisma = new PrismaClient();
export class MonitoringService {
private io: SocketIOServer;
private monitoringInterval: NodeJS.Timeout | null = null;
private readonly MONITORING_INTERVAL = 30000; // 30 секунд
constructor(io: SocketIOServer) {
this.io = io;
this.setupSocketHandlers();
}
private setupSocketHandlers() {
this.io.on('connection', (socket: Socket) => {
console.log(`Client connected: ${socket.id}`);
// Подписка на обновления конкретного сервера
socket.on('subscribe-server', async (serverId: number) => {
console.log(`Client ${socket.id} subscribed to server ${serverId}`);
socket.join(`server-${serverId}`);
// Отправляем начальную статистику
try {
const server = await prisma.server.findUnique({ where: { id: serverId } });
if (server && server.proxmoxId) {
const stats = await getContainerStats(server.proxmoxId);
socket.emit('server-stats', { serverId, stats });
}
} catch (error) {
console.error(`Error fetching initial stats for server ${serverId}:`, error);
}
});
// Отписка от обновлений сервера
socket.on('unsubscribe-server', (serverId: number) => {
console.log(`Client ${socket.id} unsubscribed from server ${serverId}`);
socket.leave(`server-${serverId}`);
});
socket.on('disconnect', () => {
console.log(`Client disconnected: ${socket.id}`);
});
});
}
// Запуск периодического мониторинга
public startMonitoring() {
if (this.monitoringInterval) {
console.log('Monitoring already running');
return;
}
console.log('Starting server monitoring service...');
this.monitoringInterval = setInterval(async () => {
await this.checkAllServers();
}, this.MONITORING_INTERVAL);
// Первая проверка сразу
this.checkAllServers();
}
// Остановка мониторинга
public stopMonitoring() {
if (this.monitoringInterval) {
clearInterval(this.monitoringInterval);
this.monitoringInterval = null;
console.log('Monitoring service stopped');
}
}
// Проверка всех активных серверов
private async checkAllServers() {
try {
const servers = await prisma.server.findMany({
where: {
status: {
in: ['running', 'stopped', 'creating']
}
}
});
for (const server of servers) {
if (server.proxmoxId) {
try {
const stats = await getContainerStats(server.proxmoxId);
if (stats.status === 'success' && stats.data) {
// Обновляем статус и метрики в БД
await prisma.server.update({
where: { id: server.id },
data: {
status: stats.data.status,
cpuUsage: stats.data.cpu || 0,
memoryUsage: stats.data.memory?.usage || 0,
diskUsage: stats.data.disk?.usage || 0,
networkIn: stats.data.network?.in || 0,
networkOut: stats.data.network?.out || 0,
lastPing: new Date()
}
});
// Отправляем обновления подписанным клиентам
this.io.to(`server-${server.id}`).emit('server-stats', {
serverId: server.id,
stats
});
// Проверяем превышение лимитов и отправляем алерты
await this.checkResourceLimits(server, stats.data);
}
} catch (error) {
console.error(`Error monitoring server ${server.id}:`, error);
}
}
}
} catch (error) {
console.error('Error in checkAllServers:', error);
}
}
// Проверка превышения лимитов ресурсов
private async checkResourceLimits(server: any, stats: any) {
const alerts = [];
// CPU превышает 90%
if (stats.cpu && stats.cpu > 0.9) {
alerts.push({
type: 'cpu',
message: `CPU usage is at ${(stats.cpu * 100).toFixed(1)}%`,
level: 'warning'
});
// Отправляем email уведомление
await sendResourceAlertEmail(
server.userId,
server.id,
'CPU',
`${(stats.cpu * 100).toFixed(1)}%`
);
}
// Memory превышает 90%
if (stats.memory?.usage && stats.memory.usage > 90) {
alerts.push({
type: 'memory',
message: `Memory usage is at ${stats.memory.usage.toFixed(1)}%`,
level: 'warning'
});
// Отправляем email уведомление
await sendResourceAlertEmail(
server.userId,
server.id,
'Memory',
`${stats.memory.usage.toFixed(1)}%`
);
}
// Disk превышает 90%
if (stats.disk?.usage && stats.disk.usage > 90) {
alerts.push({
type: 'disk',
message: `Disk usage is at ${stats.disk.usage.toFixed(1)}%`,
level: 'warning'
});
// Отправляем email уведомление
await sendResourceAlertEmail(
server.userId,
server.id,
'Disk',
`${stats.disk.usage.toFixed(1)}%`
);
}
// Отправляем алерты, если есть
if (alerts.length > 0) {
this.io.to(`server-${server.id}`).emit('server-alerts', {
serverId: server.id,
alerts
});
console.log(`Alerts for server ${server.id}:`, alerts);
}
}
}

View File

@@ -1,709 +0,0 @@
// Смена root-пароля через SSH (для LXC)
import { exec } from 'child_process';
export async function changeRootPasswordSSH(vmid: number): Promise<{ status: string; password?: string; message?: string }> {
const newPassword = generateSecurePassword();
return new Promise((resolve) => {
exec(`ssh -o StrictHostKeyChecking=no root@${process.env.PROXMOX_NODE} pct set ${vmid} --password ${newPassword}`, (err, stdout, stderr) => {
if (err) {
console.error('Ошибка смены пароля через SSH:', stderr);
resolve({ status: 'error', message: stderr });
} else {
resolve({ status: 'success', password: newPassword });
}
});
});
}
import axios from 'axios';
import crypto from 'crypto';
import dotenv from 'dotenv';
import https from 'https';
dotenv.config();
const PROXMOX_API_URL = process.env.PROXMOX_API_URL;
const PROXMOX_TOKEN_ID = process.env.PROXMOX_TOKEN_ID;
const PROXMOX_TOKEN_SECRET = process.env.PROXMOX_TOKEN_SECRET;
const PROXMOX_NODE = process.env.PROXMOX_NODE || 'proxmox';
const PROXMOX_VM_STORAGE = process.env.PROXMOX_VM_STORAGE || 'local';
const PROXMOX_BACKUP_STORAGE = process.env.PROXMOX_BACKUP_STORAGE || 'local';
const PROXMOX_ISO_STORAGE = process.env.PROXMOX_ISO_STORAGE || 'local';
const PROXMOX_NETWORK_BRIDGE = process.env.PROXMOX_NETWORK_BRIDGE || 'vmbr0';
// HTTPS Agent с отключением проверки сертификата (для самоподписанного Proxmox)
const httpsAgent = new https.Agent({
rejectUnauthorized: false,
keepAlive: true,
maxSockets: 50,
maxFreeSockets: 10,
timeout: 60000
});
function getProxmoxHeaders(): Record<string, string> {
return {
'Authorization': `PVEAPIToken=${PROXMOX_TOKEN_ID}=${PROXMOX_TOKEN_SECRET}`,
'Content-Type': 'application/json'
};
}
// Генерация случайного пароля
export function generateSecurePassword(length: number = 16): string {
const charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*';
let password = '';
for (let i = 0; i < length; i++) {
password += charset.charAt(Math.floor(Math.random() * charset.length));
}
return password;
}
// Получение следующего доступного VMID
export async function getNextVMID(): Promise<number> {
try {
const res = await axios.get(
`${PROXMOX_API_URL}/cluster/nextid`,
{
headers: getProxmoxHeaders(),
timeout: 15000, // 15 секунд
httpsAgent
}
);
return res.data.data || Math.floor(100 + Math.random() * 899);
} catch (error) {
console.error('Ошибка получения VMID:', error);
return Math.floor(100 + Math.random() * 899);
}
}
// Создание LXC контейнера
export interface CreateContainerParams {
os: { template: string; type: string };
tariff: { name: string; price: number; description?: string };
user: { id: number; username: string; email?: string };
hostname?: string;
}
export async function createLXContainer({ os, tariff, user }: CreateContainerParams) {
let vmid: number = 0;
let hostname: string = '';
try {
vmid = await getNextVMID();
const rootPassword = generateSecurePassword();
// Используем hostname из параметров, если есть
hostname = arguments[0].hostname;
if (!hostname) {
if (user.email) {
const emailName = user.email.split('@')[0];
hostname = `${emailName}-${vmid}`;
} else {
hostname = `user${user.id}-${vmid}`;
}
}
// Определяем ресурсы по названию тарифа (парсим описание)
const description = tariff.description || '1 ядро, 1ГБ RAM, 20ГБ SSD';
const cores = parseInt(description.match(/(\d+)\s*ядр/)?.[1] || '1');
const memory = parseInt(description.match(/(\d+)ГБ\s*RAM/)?.[1] || '1') * 1024; // в MB
const diskSize = parseInt(description.match(/(\d+)ГБ\s*SSD/)?.[1] || '20');
const containerConfig = {
vmid,
hostname,
password: rootPassword,
ostemplate: os.template,
cores,
memory,
rootfs: `${PROXMOX_VM_STORAGE}:${diskSize}`,
net0: `name=eth0,bridge=${PROXMOX_NETWORK_BRIDGE},ip=dhcp`,
unprivileged: 1,
start: 1, // Автостарт после создания
protection: 0,
console: 1,
cmode: 'console'
};
console.log('Создание LXC контейнера с параметрами:', containerConfig);
// Валидация перед отправкой
if (!containerConfig.ostemplate) {
throw new Error('OS template не задан');
}
if (containerConfig.cores < 1 || containerConfig.cores > 32) {
throw new Error(`Cores должно быть от 1 до 32, получено: ${containerConfig.cores}`);
}
if (containerConfig.memory < 512 || containerConfig.memory > 65536) {
throw new Error(`Memory должно быть от 512 до 65536 MB, получено: ${containerConfig.memory}`);
}
// Детальное логирование перед отправкой
console.log('URL Proxmox:', `${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc`);
console.log('Параметры контейнера (JSON):', JSON.stringify(containerConfig, null, 2));
console.log('Storage для VM:', PROXMOX_VM_STORAGE);
const response = await axios.post(
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc`,
containerConfig,
{
headers: getProxmoxHeaders(),
timeout: 120000, // 2 минуты для создания контейнера
httpsAgent
}
);
console.log('Ответ от Proxmox (создание):', response.status, response.data);
if (response.data?.data) {
// Polling статуса контейнера до running или timeout
let status = '';
let attempts = 0;
const maxAttempts = 10;
while (attempts < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 3000));
const info = await getContainerStatus(vmid);
status = info?.status || '';
if (status === 'running' || status === 'stopped' || status === 'created') break;
attempts++;
}
// Получаем IP адрес контейнера
const ipAddress = await getContainerIP(vmid);
return {
status: 'success',
vmid,
rootPassword,
ipAddress,
hostname,
taskId: response.data.data,
containerStatus: status
};
}
// Получить статус контейнера по VMID
async function getContainerStatus(vmid: number): Promise<{ status: string }> {
try {
const res = await axios.get(
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/status/current`,
{
headers: getProxmoxHeaders(),
httpsAgent
}
);
return { status: res.data.data.status };
} catch (error) {
return { status: 'error' };
}
}
throw new Error('Не удалось создать контейнер');
} catch (error: any) {
console.error('❌ ОШИБКА создания LXC контейнера:', error.message);
console.error(' Code:', error.code);
console.error(' Status:', error.response?.status);
console.error(' Response data:', error.response?.data);
// Логируем контекст ошибки
console.error(' VMID:', vmid);
console.error(' Hostname:', hostname);
console.error(' Storage используемый:', PROXMOX_VM_STORAGE);
console.error(' OS Template:', os.template);
// Специальная обработка socket hang up / ECONNRESET
const isSocketError = error?.code === 'ECONNRESET' ||
error?.message?.includes('socket hang up') ||
error?.cause?.code === 'ECONNRESET';
if (isSocketError) {
console.error('\n⚠ SOCKET HANG UP DETECTED!');
console.error(' Возможные причины:');
console.error(' 1. Storage "' + PROXMOX_VM_STORAGE + '" не существует на Proxmox');
console.error(' 2. API токен неверный или истёк');
console.error(' 3. Proxmox перегружена или недоступна');
console.error(' 4. Firewall блокирует соединение\n');
}
const errorMessage = isSocketError
? `Proxmox не ответил вовремя. Storage: ${PROXMOX_VM_STORAGE}. Проверьте доступность сервера и корректность конфигурации.`
: error.response?.data?.errors || error.message;
return {
status: 'error',
message: errorMessage,
code: error?.code || error?.response?.status,
isSocketError,
storage: PROXMOX_VM_STORAGE
};
}
}
// Получение IP адреса контейнера
export async function getContainerIP(vmid: number): Promise<string | null> {
try {
await new Promise(resolve => setTimeout(resolve, 10000)); // Ждём запуска
const response = await axios.get(
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/interfaces`,
{
headers: getProxmoxHeaders(),
httpsAgent
}
);
const interfaces = response.data?.data;
if (interfaces && interfaces.length > 0) {
// Сначала ищем локальный IP
for (const iface of interfaces) {
if (iface.inet && iface.inet !== '127.0.0.1') {
const ip = iface.inet.split('/')[0];
if (
ip.startsWith('10.') ||
ip.startsWith('192.168.') ||
(/^172\.(1[6-9]|2[0-9]|3[01])\./.test(ip))
) {
return ip;
}
}
}
// Если не нашли локальный, возвращаем первый не-127.0.0.1
for (const iface of interfaces) {
if (iface.inet && iface.inet !== '127.0.0.1') {
return iface.inet.split('/')[0];
}
}
}
return null;
} catch (error) {
console.error('Ошибка получения IP:', error);
return null;
}
}
// Управление контейнером (старт/стоп/перезагрузка)
export async function controlContainer(vmid: number, action: 'start' | 'stop' | 'restart' | 'suspend' | 'resume') {
try {
const response = await axios.post(
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/status/${action}`,
{},
{ headers: getProxmoxHeaders() }
);
return {
status: 'success',
action,
taskId: response.data?.data
};
} catch (error: any) {
console.error(`Ошибка ${action} контейнера:`, error);
return {
status: 'error',
message: error.response?.data?.errors || error.message
};
}
}
// Удаление контейнера
export async function deleteContainer(vmid: number) {
try {
const response = await axios.delete(
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}`,
{ headers: getProxmoxHeaders() }
);
return {
status: 'success',
taskId: response.data?.data
};
} catch (error: any) {
console.error('Ошибка удаления контейнера:', error);
return {
status: 'error',
message: error.response?.data?.errors || error.message
};
}
}
// Получение статистики контейнера
export async function getContainerStats(vmid: number) {
try {
// Получаем текущий статус
const statusResponse = await axios.get(
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/status/current`,
{ headers: getProxmoxHeaders() }
);
const status = statusResponse.data?.data;
// Получаем статистику RRD (за последний час)
let rrdData = [];
let latest: any = {};
try {
const rrdResponse = await axios.get(
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/rrd?timeframe=hour`,
{ headers: getProxmoxHeaders() }
);
rrdData = rrdResponse.data?.data || [];
latest = rrdData[rrdData.length - 1] || {};
} catch (err: any) {
// Если ошибка 400, возвращаем пустую статистику, но не ошибку
if (err?.response?.status === 400) {
return {
status: 'success',
data: {
vmid,
status: status?.status || 'unknown',
uptime: status?.uptime || 0,
cpu: 0,
memory: {
used: status?.mem || 0,
max: status?.maxmem || 0,
usage: 0
},
disk: {
used: status?.disk || 0,
max: status?.maxdisk || 0,
usage: 0
},
network: {
in: 0,
out: 0
},
rrdData: []
}
};
} else {
throw err;
}
}
return {
status: 'success',
data: {
vmid,
status: status?.status || 'unknown',
uptime: status?.uptime || 0,
cpu: latest.cpu || 0,
memory: {
used: status?.mem || 0,
max: status?.maxmem || 0,
usage: status?.maxmem ? (status.mem / status.maxmem) * 100 : 0
},
disk: {
used: status?.disk || 0,
max: status?.maxdisk || 0,
usage: status?.maxdisk ? (status.disk / status.maxdisk) * 100 : 0
},
network: {
in: latest.netin || 0,
out: latest.netout || 0
},
rrdData: rrdData.slice(-60) // Последние 60 точек для графиков
}
};
} catch (error: any) {
console.error('Ошибка получения статистики:', error);
return {
status: 'error',
message: error.response?.data?.errors || error.message
};
}
}
// Смена root пароля
export async function changeRootPassword(vmid: number): Promise<{ status: string; password?: string; message?: string }> {
try {
const newPassword = generateSecurePassword();
// Выполняем команду смены пароля в контейнере
const response = await axios.post(
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/status/exec`,
{
command: `echo 'root:${newPassword}' | chpasswd`
},
{ headers: getProxmoxHeaders() }
);
return {
status: 'success',
password: newPassword
};
} catch (error: any) {
console.error('Ошибка смены пароля:', error);
return {
status: 'error',
message: error.response?.data?.errors || error.message
};
}
}
// Получение ссылки на noVNC консоль
export async function getConsoleURL(vmid: number): Promise<{ status: string; url?: string; message?: string }> {
try {
const response = await axios.post(
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/vncproxy`,
{
websocket: 1
},
{ headers: getProxmoxHeaders() }
);
const data = response.data?.data;
if (data?.ticket && data?.port) {
const consoleUrl = `${process.env.PROXMOX_WEB_URL}/?console=lxc&vmid=${vmid}&node=${PROXMOX_NODE}&resize=off&ticket=${data.ticket}&port=${data.port}`;
return {
status: 'success',
url: consoleUrl
};
}
throw new Error('Не удалось получить данные для консоли');
} catch (error: any) {
console.error('Ошибка получения консоли:', error);
return {
status: 'error',
message: error.response?.data?.errors || error.message
};
}
}
// Валидация конфигурации контейнера
function validateContainerConfig(config: { cores?: number; memory?: number; rootfs?: string }) {
const validated: { cores?: number; memory?: number; rootfs?: string } = {};
// Валидация cores (1-32 ядра)
if (config.cores !== undefined) {
const cores = Number(config.cores);
if (isNaN(cores) || cores < 1 || cores > 32) {
throw new Error('Invalid cores value: must be between 1 and 32');
}
validated.cores = cores;
}
// Валидация memory (512MB - 64GB)
if (config.memory !== undefined) {
const memory = Number(config.memory);
if (isNaN(memory) || memory < 512 || memory > 65536) {
throw new Error('Invalid memory value: must be between 512 and 65536 MB');
}
validated.memory = memory;
}
// Валидация rootfs (формат: local:размер)
if (config.rootfs !== undefined) {
const match = config.rootfs.match(/^local:(\d+)$/);
if (!match) {
throw new Error('Invalid rootfs format: must be "local:SIZE"');
}
const size = Number(match[1]);
if (size < 10 || size > 1000) {
throw new Error('Invalid disk size: must be between 10 and 1000 GB');
}
validated.rootfs = config.rootfs;
}
return validated;
}
// Изменение конфигурации контейнера (CPU, RAM, Disk)
export async function resizeContainer(vmid: number, config: { cores?: number; memory?: number; rootfs?: string }) {
try {
const validatedConfig = validateContainerConfig(config);
const response = await axios.put(
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/config`,
validatedConfig,
{ headers: getProxmoxHeaders() }
);
return {
status: 'success',
data: response.data?.data
};
} catch (error: any) {
console.error('Ошибка изменения конфигурации:', error);
return {
status: 'error',
message: error.response?.data?.errors || error.message
};
}
}
// Валидация имени снэпшота для предотвращения SSRF и path traversal
// SECURITY: Эта функция валидирует пользовательский ввод перед использованием в URL
// CodeQL может показывать предупреждение, но валидация является достаточной
function validateSnapshotName(snapname: string): string {
// Разрешены только буквы, цифры, дефисы и подчеркивания
const sanitized = snapname.replace(/[^a-zA-Z0-9_-]/g, '');
if (sanitized.length === 0) {
throw new Error('Invalid snapshot name');
}
// Ограничиваем длину для предотвращения DoS
return sanitized.substring(0, 64);
}
// Создание снэпшота
export async function createSnapshot(vmid: number, snapname: string, description?: string) {
try {
const validSnapname = validateSnapshotName(snapname);
const response = await axios.post(
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/snapshot`,
{
snapname: validSnapname,
description: description || `Snapshot ${validSnapname}`
},
{ headers: getProxmoxHeaders() }
);
return {
status: 'success',
taskId: response.data?.data,
snapname: validSnapname
};
} catch (error: any) {
console.error('Ошибка создания снэпшота:', error);
return {
status: 'error',
message: error.response?.data?.errors || error.message
};
}
}
// Получение списка снэпшотов
export async function listSnapshots(vmid: number) {
try {
const response = await axios.get(
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/snapshot`,
{ headers: getProxmoxHeaders() }
);
return {
status: 'success',
data: response.data?.data || []
};
} catch (error: any) {
console.error('Ошибка получения списка снэпшотов:', error);
return {
status: 'error',
message: error.response?.data?.errors || error.message
};
}
}
// Восстановление из снэпшота
export async function rollbackSnapshot(vmid: number, snapname: string) {
try {
const validSnapname = validateSnapshotName(snapname);
const response = await axios.post(
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/snapshot/${validSnapname}/rollback`,
{},
{ headers: getProxmoxHeaders() }
);
return {
status: 'success',
taskId: response.data?.data
};
} catch (error: any) {
console.error('Ошибка восстановления снэпшота:', error);
return {
status: 'error',
message: error.response?.data?.errors || error.message
};
}
}
// Удаление снэпшота
export async function deleteSnapshot(vmid: number, snapname: string) {
try {
const validSnapname = validateSnapshotName(snapname);
const response = await axios.delete(
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/snapshot/${validSnapname}`,
{ headers: getProxmoxHeaders() }
);
return {
status: 'success',
taskId: response.data?.data
};
} catch (error: any) {
console.error('Ошибка удаления снэпшота:', error);
return {
status: 'error',
message: error.response?.data?.errors || error.message
};
}
}
// Получение списка всех контейнеров
export async function listContainers() {
try {
const response = await axios.get(
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc`,
{ headers: getProxmoxHeaders() }
);
return {
status: 'success',
data: response.data?.data || []
};
} catch (error: any) {
console.error('Ошибка получения списка контейнеров:', error);
return {
status: 'error',
message: error.response?.data?.errors || error.message
};
}
}
// Получение списка доступных storage pools на узле
export async function getNodeStorages(node: string = PROXMOX_NODE) {
try {
const response = await axios.get(
`${PROXMOX_API_URL}/nodes/${node}/storage`,
{
headers: getProxmoxHeaders(),
timeout: 15000,
httpsAgent
}
);
return {
status: 'success',
data: response.data?.data || []
};
} catch (error: any) {
console.error('Ошибка получения storage:', error.message);
return {
status: 'error',
message: error.response?.data?.errors || error.message
};
}
}
// Проверка соединения с Proxmox
export async function checkProxmoxConnection() {
try {
const response = await axios.get(
`${PROXMOX_API_URL}/version`,
{
headers: getProxmoxHeaders(),
httpsAgent
}
);
if (response.data?.data) {
return {
status: 'success',
message: 'Соединение с Proxmox установлено',
version: response.data.data.version,
node: PROXMOX_NODE
};
}
return { status: 'error', message: 'Не удалось получить версию Proxmox' };
} catch (error: any) {
return {
status: 'error',
message: 'Ошибка соединения с Proxmox',
error: error.response?.data || error.message
};
}
}
// Получение конфигурации storage через файл (обходим API если он недоступен)
export async function getStorageConfig(): Promise<{
configured: string;
available: string[];
note: string;
}> {
return {
configured: PROXMOX_VM_STORAGE,
available: ['local', 'local-lvm', 'vm-storage'],
note: `Текущее использование: ${PROXMOX_VM_STORAGE}. Если хранилище недоступно или socket hang up, проверьте что это имя существует в Proxmox (pvesm status)`
};
}

View File

@@ -1,83 +0,0 @@
import { Server as WebSocketServer, WebSocket } from 'ws';
import { Client as SSHClient } from 'ssh2';
import dotenv from 'dotenv';
import { IncomingMessage } from 'http';
import { Server as HttpServer } from 'http';
dotenv.config();
export function setupConsoleWSS(server: HttpServer) {
const wss = new WebSocketServer({ noServer: true });
wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {
const url = req.url || '';
const match = url.match(/\/api\/server\/(\d+)\/console/);
const vmid = match ? match[1] : null;
if (!vmid) {
ws.close();
return;
}
// Получаем параметры SSH из .env
const host = process.env.SSH_HOST || process.env.PROXMOX_IP || process.env.PROXMOX_NODE;
const port = process.env.SSH_PORT ? Number(process.env.SSH_PORT) : (process.env.PROXMOX_SSH_PORT ? Number(process.env.PROXMOX_SSH_PORT) : 22);
const username = process.env.SSH_USER || 'root';
let password = process.env.SSH_PASSWORD || process.env.PROXMOX_ROOT_PASSWORD;
if (password && password.startsWith('"') && password.endsWith('"')) {
password = password.slice(1, -1);
}
const privateKeyPath = process.env.SSH_PRIVATE_KEY_PATH;
let privateKey: Buffer | undefined = undefined;
if (privateKeyPath) {
try {
privateKey = require('fs').readFileSync(privateKeyPath);
} catch (e) {
console.error('Ошибка чтения SSH ключа:', e);
}
}
const ssh = new SSHClient();
ssh.on('ready', () => {
ssh.shell((err: Error | undefined, stream: any) => {
if (err) {
ws.send('Ошибка запуска shell: ' + err.message);
ws.close();
ssh.end();
return;
}
ws.on('message', (msg: string | Buffer) => {
stream.write(msg.toString());
});
stream.on('data', (data: Buffer) => {
ws.send(data.toString());
});
stream.on('close', () => {
ws.close();
ssh.end();
});
});
}).connect({
host,
port,
username,
password: privateKey ? undefined : password,
privateKey,
hostVerifier: (hash: string) => {
console.log('SSH fingerprint:', hash);
return true; // всегда принимаем fingerprint
}
});
ws.on('close', () => {
ssh.end();
});
});
server.on('upgrade', (request: IncomingMessage, socket: any, head: Buffer) => {
if (request.url?.startsWith('/api/server/') && request.url?.endsWith('/console')) {
wss.handleUpgrade(request, socket, head, (ws: WebSocket) => {
wss.emit('connection', ws, request);
});
}
});
}

View File

@@ -1,362 +0,0 @@
import { Request, Response } from 'express';
import { PrismaClient } from '@prisma/client';
import {
createLXContainer,
controlContainer,
getContainerStats,
changeRootPassword as proxmoxChangeRootPassword,
deleteContainer,
resizeContainer,
createSnapshot,
listSnapshots,
rollbackSnapshot,
deleteSnapshot
} from './proxmoxApi';
const prisma = new PrismaClient();
// Создание сервера (контейнера)
export async function createServer(req: Request, res: Response) {
try {
const { osId, tariffId } = req.body;
const userId = req.user?.id;
if (!userId) return res.status(401).json({ error: 'Нет авторизации' });
const os = await prisma.operatingSystem.findUnique({ where: { id: osId } });
const tariff = await prisma.tariff.findUnique({ where: { id: tariffId } });
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!os || !tariff || !user) return res.status(400).json({ error: 'Некорректные параметры' });
// Проверка баланса пользователя
if (user.balance < tariff.price) {
return res.status(400).json({ error: 'Недостаточно средств на балансе' });
}
// Списываем средства
await prisma.user.update({ where: { id: userId }, data: { balance: { decrement: tariff.price } } });
// Генерация hostname из email
let hostname = user.email.split('@')[0];
// Нормализуем hostname: убираем недопустимые символы, приводим к нижнему регистру
hostname = hostname.replace(/[^a-zA-Z0-9-]/g, '').toLowerCase();
// Удалим ведущие и завершающие дефисы
hostname = hostname.replace(/^-+|-+$/g, '');
if (hostname.length < 3) hostname = `user${userId}`;
if (hostname.length > 32) hostname = hostname.slice(0, 32);
// Если начинается с цифры или дефиса — префикс
if (/^[0-9-]/.test(hostname)) hostname = `u${hostname}`;
// Создаём контейнер в Proxmox
const result = await createLXContainer({
os: { template: os.template || '', type: os.type },
tariff: { name: tariff.name, price: tariff.price, description: tariff.description || undefined },
user: { id: user.id, username: user.username },
hostname
});
if (result.status !== 'success') {
// Возвращаем деньги обратно, если не удалось создать
await prisma.user.update({ where: { id: userId }, data: { balance: { increment: tariff.price } } });
// Логируем полный текст ошибки в файл
const fs = require('fs');
const errorMsg = result.message || JSON.stringify(result);
const isSocketError = errorMsg.includes('ECONNRESET') || errorMsg.includes('socket hang up');
const logMsg = `[${new Date().toISOString()}] Ошибка Proxmox при создании контейнера (userId=${userId}, hostname=${hostname}, vmid=${result.vmid || 'unknown'}): ${errorMsg}${isSocketError ? ' [SOCKET_ERROR - возможно таймаут]' : ''}\n`;
fs.appendFile('proxmox-errors.log', logMsg, (err: NodeJS.ErrnoException | null) => {
if (err) console.error('Ошибка записи лога:', err);
});
console.error('Ошибка Proxmox при создании контейнера:', result);
return res.status(500).json({
error: 'Ошибка создания сервера в Proxmox',
details: isSocketError
? 'Сервер Proxmox не ответил вовремя. Пожалуйста, попробуйте позже.'
: result.message,
fullError: result
});
}
// Сохраняем сервер в БД, статус всегда 'running' после покупки
// Устанавливаем дату следующего платежа через 30 дней
const nextPaymentDate = new Date();
nextPaymentDate.setDate(nextPaymentDate.getDate() + 30);
const server = await prisma.server.create({
data: {
userId,
tariffId,
osId,
status: 'running',
proxmoxId: Number(result.vmid),
ipAddress: result.ipAddress,
rootPassword: result.rootPassword,
nextPaymentDate,
autoRenew: true
}
});
// Создаём первую транзакцию о покупке
await prisma.transaction.create({
data: {
userId,
amount: -tariff.price,
type: 'withdrawal',
description: `Покупка сервера #${server.id}`,
balanceBefore: user.balance,
balanceAfter: user.balance - tariff.price
}
});
res.json(server);
} catch (error: any) {
console.error('Ошибка покупки сервера:', error);
res.status(500).json({ error: error?.message || 'Ошибка покупки сервера' });
}
}
// Получить статус сервера
export async function getServerStatus(req: Request, res: Response) {
try {
const id = Number(req.params.id);
const server = await prisma.server.findUnique({
where: { id },
include: {
tariff: true,
os: true,
user: {
select: {
id: true,
username: true,
email: true,
}
}
}
});
if (!server) return res.status(404).json({ error: 'Сервер не найден' });
if (!server.proxmoxId) return res.status(400).json({ error: 'Нет VMID Proxmox' });
const stats = await getContainerStats(server.proxmoxId);
if (stats.status === 'error') {
// Если контейнер не найден в Proxmox, возвращаем статус deleted и пустую статистику
return res.json({
...server,
status: 'deleted',
stats: {
data: {
cpu: 0,
memory: { usage: 0 }
}
},
error: 'Контейнер не найден в Proxmox',
details: stats.message
});
}
res.json({ ...server, stats });
} catch (error: any) {
res.status(500).json({ error: error?.message || 'Ошибка получения статуса' });
}
}
// Запустить сервер
export async function startServer(req: Request, res: Response) {
await handleControl(req, res, 'start');
}
// Остановить сервер
export async function stopServer(req: Request, res: Response) {
await handleControl(req, res, 'stop');
}
// Перезагрузить сервер
export async function restartServer(req: Request, res: Response) {
await handleControl(req, res, 'restart');
}
async function handleControl(req: Request, res: Response, action: 'start' | 'stop' | 'restart') {
try {
const id = Number(req.params.id);
const server = await prisma.server.findUnique({ where: { id } });
if (!server || !server.proxmoxId) return res.status(404).json({ error: 'Сервер не найден или нет VMID' });
// Получаем текущий статус VM
const stats = await getContainerStats(server.proxmoxId);
const currentStatus = stats.status === 'success' && stats.data ? stats.data.status : server.status;
// Ограничения на действия
if (action === 'start' && currentStatus === 'running') {
return res.status(400).json({ error: 'Сервер уже запущен' });
}
if (action === 'stop' && currentStatus === 'stopped') {
return res.status(400).json({ error: 'Сервер уже остановлен' });
}
// Выполняем действие
const result = await controlContainer(server.proxmoxId, action);
// Polling статуса VM после управления
let newStatus = server.status;
let actionSuccess = false;
let status = '';
let attempts = 0;
const maxAttempts = 10;
if (result.status === 'success') {
while (attempts < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 3000));
const stats = await getContainerStats(server.proxmoxId);
if (stats.status === 'success' && stats.data) {
status = stats.data.status;
if ((action === 'start' && status === 'running') ||
(action === 'stop' && status === 'stopped') ||
(action === 'restart' && status === 'running')) {
actionSuccess = true;
break;
}
}
attempts++;
}
switch (status) {
case 'running':
newStatus = 'running';
break;
case 'stopped':
newStatus = 'stopped';
break;
case 'suspended':
newStatus = 'suspended';
break;
default:
newStatus = status || server.status;
}
await prisma.server.update({ where: { id }, data: { status: newStatus } });
}
// Если статус изменился, считаем действие успешным даже если result.status !== 'success'
if (newStatus !== server.status) {
return res.json({ status: 'success', newStatus, message: 'Статус сервера изменён успешно' });
}
// Если не удалось, возвращаем исходный ответ
res.json({ ...result, status: newStatus });
} catch (error: any) {
res.status(500).json({ error: error?.message || 'Ошибка управления сервером' });
}
}
// Удалить сервер
export async function deleteServer(req: Request, res: Response) {
try {
const id = Number(req.params.id);
const server = await prisma.server.findUnique({ where: { id } });
if (!server || !server.proxmoxId) return res.status(404).json({ error: 'Сервер не найден или нет VMID' });
// Удаляем контейнер в Proxmox
const proxmoxResult = await deleteContainer(server.proxmoxId);
if (proxmoxResult.status !== 'success') {
return res.status(500).json({ error: 'Ошибка удаления контейнера в Proxmox', details: proxmoxResult });
}
await prisma.server.delete({ where: { id } });
res.json({ status: 'deleted' });
} catch (error: any) {
res.status(500).json({ error: error?.message || 'Ошибка удаления сервера' });
}
}
// Сменить root-пароль
export async function changeRootPassword(req: Request, res: Response) {
try {
const id = Number(req.params.id);
const server = await prisma.server.findUnique({ where: { id } });
if (!server || !server.proxmoxId) return res.status(404).json({ error: 'Сервер не найден или нет VMID' });
// Используем SSH для смены пароля
const { changeRootPasswordSSH } = require('./proxmoxApi');
const result = await changeRootPasswordSSH(server.proxmoxId);
if (result?.status === 'success' && result.password) {
await prisma.server.update({ where: { id }, data: { rootPassword: result.password } });
}
res.json(result);
} catch (error: any) {
res.status(500).json({ error: error?.message || 'Ошибка смены пароля' });
}
}
// Изменить конфигурацию сервера
export async function resizeServer(req: Request, res: Response) {
try {
const id = Number(req.params.id);
const { cores, memory, disk } = req.body;
const server = await prisma.server.findUnique({ where: { id } });
if (!server || !server.proxmoxId) return res.status(404).json({ error: 'Сервер не найден или нет VMID' });
const config: any = {};
if (cores) config.cores = Number(cores);
if (memory) config.memory = Number(memory);
if (disk) {
const vmStorage = process.env.PROXMOX_VM_STORAGE || 'local';
config.rootfs = `${vmStorage}:${Number(disk)}`;
}
const result = await resizeContainer(server.proxmoxId, config);
res.json(result);
} catch (error: any) {
res.status(500).json({ error: error?.message || 'Ошибка изменения конфигурации' });
}
}
// Создать снэпшот
export async function createServerSnapshot(req: Request, res: Response) {
try {
const id = Number(req.params.id);
const { snapname, description } = req.body;
if (!snapname) return res.status(400).json({ error: 'Не указано имя снэпшота' });
const server = await prisma.server.findUnique({ where: { id } });
if (!server || !server.proxmoxId) return res.status(404).json({ error: 'Сервер не найден или нет VMID' });
const result = await createSnapshot(server.proxmoxId, snapname, description);
res.json(result);
} catch (error: any) {
res.status(500).json({ error: error?.message || 'Ошибка создания снэпшота' });
}
}
// Получить список снэпшотов
export async function getServerSnapshots(req: Request, res: Response) {
try {
const id = Number(req.params.id);
const server = await prisma.server.findUnique({ where: { id } });
if (!server || !server.proxmoxId) return res.status(404).json({ error: 'Сервер не найден или нет VMID' });
const result = await listSnapshots(server.proxmoxId);
res.json(result);
} catch (error: any) {
res.status(500).json({ error: error?.message || 'Ошибка получения снэпшотов' });
}
}
// Восстановить из снэпшота
export async function rollbackServerSnapshot(req: Request, res: Response) {
try {
const id = Number(req.params.id);
const { snapname } = req.body;
if (!snapname) return res.status(400).json({ error: 'Не указано имя снэпшота' });
const server = await prisma.server.findUnique({ where: { id } });
if (!server || !server.proxmoxId) return res.status(404).json({ error: 'Сервер не найден или нет VMID' });
const result = await rollbackSnapshot(server.proxmoxId, snapname);
res.json(result);
} catch (error: any) {
res.status(500).json({ error: error?.message || 'Ошибка восстановления снэпшота' });
}
}
// Удалить снэпшот
export async function deleteServerSnapshot(req: Request, res: Response) {
try {
const id = Number(req.params.id);
const { snapname } = req.body;
if (!snapname) return res.status(400).json({ error: 'Не указано имя снэпшота' });
const server = await prisma.server.findUnique({ where: { id } });
if (!server || !server.proxmoxId) return res.status(404).json({ error: 'Сервер не найден или нет VMID' });
const result = await deleteSnapshot(server.proxmoxId, snapname);
res.json(result);
} catch (error: any) {
res.status(500).json({ error: error?.message || 'Ошибка удаления снэпшота' });
}
}

View File

@@ -1,153 +0,0 @@
import axios from 'axios';
import dotenv from 'dotenv';
dotenv.config();
const PROXMOX_API_URL = process.env.PROXMOX_API_URL;
const PROXMOX_TOKEN_ID = process.env.PROXMOX_TOKEN_ID;
const PROXMOX_TOKEN_SECRET = process.env.PROXMOX_TOKEN_SECRET;
const PROXMOX_NODE = process.env.PROXMOX_NODE || 'proxmox';
function getProxmoxHeaders(): Record<string, string> {
return {
'Authorization': `PVEAPIToken=${PROXMOX_TOKEN_ID}=${PROXMOX_TOKEN_SECRET}`,
'Content-Type': 'application/json'
};
}
/**
* Получение логов контейнера LXC
* @param vmid - ID контейнера
* @param lines - количество строк логов (по умолчанию 100)
* @returns объект с логами или ошибкой
*/
export async function getContainerLogs(vmid: number, lines: number = 100) {
try {
// Получаем логи через Proxmox API
// Используем журнал systemd для LXC контейнеров
const response = await axios.get(
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/log?limit=${lines}`,
{ headers: getProxmoxHeaders() }
);
const logs = response.data?.data || [];
// Форматируем логи для удобного отображения
const formattedLogs = logs.map((log: { n: number; t: string }) => ({
line: log.n,
text: log.t,
timestamp: new Date().toISOString() // Proxmox не всегда возвращает timestamp
}));
return {
status: 'success',
logs: formattedLogs,
total: formattedLogs.length
};
} catch (error: any) {
console.error('Ошибка получения логов контейнера:', error);
// Если API не поддерживает /log, пробуем альтернативный способ
if (error.response?.status === 400 || error.response?.status === 501) {
return getContainerSystemLogs(vmid, lines);
}
return {
status: 'error',
message: error.response?.data?.errors || error.message,
logs: []
};
}
}
/**
* Альтернативный метод получения логов через exec команды
*/
async function getContainerSystemLogs(vmid: number, lines: number = 100) {
try {
// Выполняем команду для получения логов из контейнера
const response = await axios.post(
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/status/exec`,
{
command: `/bin/journalctl -n ${lines} --no-pager || tail -n ${lines} /var/log/syslog || echo "Логи недоступны"`
},
{ headers: getProxmoxHeaders() }
);
// Получаем результат выполнения команды
if (response.data?.data) {
const taskId = response.data.data;
// Ждем завершения задачи и получаем вывод
await new Promise(resolve => setTimeout(resolve, 2000));
const outputResponse = await axios.get(
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/tasks/${taskId}/log`,
{ headers: getProxmoxHeaders() }
);
const output = outputResponse.data?.data || [];
const formattedLogs = output.map((log: { n: number; t: string }, index: number) => ({
line: index + 1,
text: log.t || log,
timestamp: new Date().toISOString()
}));
return {
status: 'success',
logs: formattedLogs,
total: formattedLogs.length
};
}
return {
status: 'error',
message: 'Не удалось получить логи',
logs: []
};
} catch (error: any) {
console.error('Ошибка получения системных логов:', error);
return {
status: 'error',
message: error.response?.data?.errors || error.message,
logs: []
};
}
}
/**
* Получение последних действий/событий контейнера
*/
export async function getContainerEvents(vmid: number) {
try {
const response = await axios.get(
`${PROXMOX_API_URL}/cluster/tasks?vmid=${vmid}`,
{ headers: getProxmoxHeaders() }
);
const tasks = response.data?.data || [];
// Форматируем события
const events = tasks.slice(0, 50).map((task: any) => ({
type: task.type,
status: task.status,
starttime: new Date(task.starttime * 1000).toLocaleString(),
endtime: task.endtime ? new Date(task.endtime * 1000).toLocaleString() : 'В процессе',
user: task.user,
node: task.node,
id: task.upid
}));
return {
status: 'success',
events
};
} catch (error: any) {
console.error('Ошибка получения событий контейнера:', error);
return {
status: 'error',
message: error.response?.data?.errors || error.message,
events: []
};
}
}

View File

@@ -1,185 +0,0 @@
import { Router } from 'express';
import { authMiddleware } from '../auth/auth.middleware';
import {
createServer,
startServer,
stopServer,
restartServer,
getServerStatus,
deleteServer,
changeRootPassword,
resizeServer,
createServerSnapshot,
getServerSnapshots,
rollbackServerSnapshot,
deleteServerSnapshot
} from './server.controller';
import { getStorageConfig, getNodeStorages, checkProxmoxConnection } from './proxmoxApi';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const router = Router();
router.use(authMiddleware);
// Получить список всех серверов (для фронта)
router.get('/', async (req, res) => {
const userId = req.user?.id;
// Если нужен только свои сервера:
const where = userId ? { userId } : {};
const servers = await prisma.server.findMany({
where,
include: {
os: true,
tariff: true
}
});
res.json(servers);
});
// Получить информацию о сервере (для фронта)
router.get('/:id', async (req, res) => {
const id = Number(req.params.id);
const server = await prisma.server.findUnique({
where: { id },
include: {
os: true,
tariff: true
}
});
if (!server) return res.status(404).json({ error: 'Сервер не найден' });
res.json(server);
});
// Получить статистику сервера (CPU, RAM и т.д.)
router.get('/:id/status', getServerStatus);
// Получить ссылку на noVNC консоль
import { getConsoleURL } from './proxmoxApi';
router.post('/console', async (req, res) => {
const { vmid } = req.body;
if (!vmid) return res.status(400).json({ status: 'error', message: 'Не указан VMID' });
try {
const result = await getConsoleURL(Number(vmid));
res.json(result);
} catch (error: any) {
res.status(500).json({ status: 'error', message: error?.message || 'Ошибка получения консоли' });
}
});
router.post('/create', createServer);
router.post('/:id/start', startServer);
router.post('/:id/stop', stopServer);
router.post('/:id/restart', restartServer);
router.delete('/:id', deleteServer);
router.post('/:id/password', changeRootPassword);
// Новые маршруты для управления конфигурацией и снэпшотами
router.put('/:id/resize', resizeServer);
router.post('/:id/snapshots', createServerSnapshot);
router.get('/:id/snapshots', getServerSnapshots);
router.post('/:id/snapshots/rollback', rollbackServerSnapshot);
router.delete('/:id/snapshots', deleteServerSnapshot);
import { getContainerStats } from './proxmoxApi';
import { getContainerLogs, getContainerEvents } from './server.logs';
// Диагностика: проверить конфигурацию storage
router.get('/admin/diagnostic/storage', async (req, res) => {
try {
const storageConfig = await getStorageConfig();
res.json({
configured_storage: storageConfig.configured,
note: storageConfig.note,
instruction: 'Если ошибка socket hang up, проверьте что PROXMOX_VM_STORAGE установлен правильно в .env'
});
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Диагностика: проверить соединение с Proxmox
router.get('/admin/diagnostic/proxmox', async (req, res) => {
try {
const connectionStatus = await checkProxmoxConnection();
const storages = await getNodeStorages();
res.json({
proxmox_connection: connectionStatus,
available_storages: storages.data || [],
current_storage_config: process.env.PROXMOX_VM_STORAGE || 'не установлена',
note: 'Если ошибка в available_storages, проверьте права API токена'
});
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Получить графики нагрузок сервера (CPU, RAM, сеть)
router.get('/:id/stats', async (req, res) => {
const id = Number(req.params.id);
// Проверка прав пользователя (только свои сервера)
const userId = req.user?.id;
const server = await prisma.server.findUnique({ where: { id } });
if (!server || server.userId !== userId) {
return res.status(404).json({ error: 'Сервер не найден или нет доступа' });
}
try {
if (!server.proxmoxId && server.proxmoxId !== 0) {
return res.status(400).json({ error: 'proxmoxId не задан для сервера' });
}
const stats = await getContainerStats(Number(server.proxmoxId));
res.json(stats);
} catch (err) {
res.status(500).json({ error: 'Ошибка получения статистики', details: err });
}
});
// Получить логи сервера
router.get('/:id/logs', async (req, res) => {
const id = Number(req.params.id);
const lines = req.query.lines ? Number(req.query.lines) : 100;
const userId = req.user?.id;
const server = await prisma.server.findUnique({ where: { id } });
if (!server || server.userId !== userId) {
return res.status(404).json({ error: 'Сервер не найден или нет доступа' });
}
try {
if (!server.proxmoxId && server.proxmoxId !== 0) {
return res.status(400).json({ error: 'proxmoxId не задан для сервера' });
}
const logs = await getContainerLogs(Number(server.proxmoxId), lines);
res.json(logs);
} catch (err) {
res.status(500).json({ error: 'Ошибка получения логов', details: err });
}
});
// Получить события/историю действий сервера
router.get('/:id/events', async (req, res) => {
const id = Number(req.params.id);
const userId = req.user?.id;
const server = await prisma.server.findUnique({ where: { id } });
if (!server || server.userId !== userId) {
return res.status(404).json({ error: 'Сервер не найден или нет доступа' });
}
try {
if (!server.proxmoxId && server.proxmoxId !== 0) {
return res.status(400).json({ error: 'proxmoxId не задан для сервера' });
}
const events = await getContainerEvents(Number(server.proxmoxId));
res.json(events);
} catch (err) {
res.status(500).json({ error: 'Ошибка получения событий', details: err });
}
});
export default router;

View File

@@ -0,0 +1,249 @@
import { Request, Response } from 'express';
import { prisma } from '../../prisma/client';
import jwt from 'jsonwebtoken';
import { logger } from '../../utils/logger';
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
// Получить IP адрес из запроса
function getClientIP(req: Request): string {
const forwarded = req.headers['x-forwarded-for'];
if (typeof forwarded === 'string') {
return forwarded.split(',')[0].trim();
}
return req.socket.remoteAddress || 'Unknown';
}
// Парсинг User-Agent (упрощённый)
function parseUserAgent(userAgent: string) {
let device = 'Desktop';
let browser = 'Unknown';
// Определяем устройство
if (/mobile/i.test(userAgent)) {
device = 'Mobile';
} else if (/tablet|ipad/i.test(userAgent)) {
device = 'Tablet';
}
// Определяем браузер
if (userAgent.includes('Chrome')) {
browser = 'Chrome';
} else if (userAgent.includes('Firefox')) {
browser = 'Firefox';
} else if (userAgent.includes('Safari') && !userAgent.includes('Chrome')) {
browser = 'Safari';
} else if (userAgent.includes('Edge')) {
browser = 'Edge';
} else if (userAgent.includes('Opera') || userAgent.includes('OPR')) {
browser = 'Opera';
}
return {
device,
browser,
browserVersion: '',
os: 'Unknown',
osVersion: ''
};
}
// Получить примерную локацию по IP (заглушка, нужен сервис геолокации)
async function getLocationByIP(ip: string): Promise<string> {
// TODO: Интеграция с ipapi.co, ip-api.com или другим сервисом
// Пока возвращаем заглушку
return 'Россия, Москва';
}
// Получить все активные сессии пользователя
export async function getUserSessions(req: Request, res: Response) {
try {
const userId = req.user?.id;
if (!userId) {
return res.status(401).json({ error: 'Не авторизован' });
}
const currentToken = req.headers.authorization?.replace('Bearer ', '');
const sessions = await prisma.session.findMany({
where: {
userId,
expiresAt: { gte: new Date() }
},
orderBy: { lastActivity: 'desc' }
});
const sessionsWithCurrent = sessions.map(session => ({
...session,
isCurrent: session.token === currentToken,
token: undefined // Не отдаём токен клиенту
}));
res.json(sessionsWithCurrent);
} catch (error) {
console.error('Ошибка получения сессий:', error);
res.status(500).json({ error: 'Ошибка получения сессий' });
}
}
// Удалить конкретную сессию
export async function deleteSession(req: Request, res: Response) {
try {
const userId = req.user?.id;
const sessionId = Number(req.params.id);
if (!userId) {
return res.status(401).json({ error: 'Не авторизован' });
}
// Проверяем, что сессия принадлежит пользователю
const session = await prisma.session.findFirst({
where: { id: sessionId, userId }
});
if (!session) {
return res.status(404).json({ error: 'Сессия не найдена' });
}
await prisma.session.delete({ where: { id: sessionId } });
res.json({ message: 'Сессия удалена' });
} catch (error) {
console.error('Ошибка удаления сессии:', error);
res.status(500).json({ error: 'Ошибка удаления сессии' });
}
}
// Удалить все сессии кроме текущей
export async function deleteAllOtherSessions(req: Request, res: Response) {
try {
const userId = req.user?.id;
const currentToken = req.headers.authorization?.replace('Bearer ', '');
if (!userId || !currentToken) {
return res.status(401).json({ error: 'Не авторизован' });
}
const result = await prisma.session.deleteMany({
where: {
userId,
token: { not: currentToken }
}
});
res.json({
message: 'Все остальные сессии удалены',
deletedCount: result.count
});
} catch (error) {
console.error('Ошибка удаления сессий:', error);
res.status(500).json({ error: 'Ошибка удаления сессий' });
}
}
// Создать новую сессию при логине
export async function createSession(
userId: number,
req: Request,
expiresInDays: number = 30
): Promise<{ token: string; session: any }> {
const token = jwt.sign({ userId }, JWT_SECRET, { expiresIn: `${expiresInDays}d` });
const ipAddress = getClientIP(req);
const userAgent = req.headers['user-agent'] || '';
const parsed = parseUserAgent(userAgent);
const location = await getLocationByIP(ipAddress);
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 30); // 30 дней
// Ограничиваем количество сессий до 10
const sessionCount = await prisma.session.count({ where: { userId } });
if (sessionCount >= 10) {
// Удаляем самую старую сессию
const oldestSession = await prisma.session.findFirst({
where: { userId },
orderBy: { lastActivity: 'asc' }
});
if (oldestSession) {
await prisma.session.delete({ where: { id: oldestSession.id } });
}
}
const session = await prisma.session.create({
data: {
userId,
token,
ipAddress,
userAgent,
device: parsed.device,
browser: `${parsed.browser} ${parsed.browserVersion}`.trim(),
location,
expiresAt,
lastActivity: new Date()
}
});
// Записываем в историю входов
await prisma.loginHistory.create({
data: {
userId,
ipAddress,
userAgent,
device: parsed.device,
browser: `${parsed.browser} ${parsed.browserVersion}`.trim(),
location,
success: true
}
});
return { token, session };
}
// Обновить время последней активности сессии
export async function updateSessionActivity(token: string) {
try {
await prisma.session.updateMany({
where: { token },
data: { lastActivity: new Date() }
});
} catch (error) {
logger.error('Ошибка обновления активности сессии:', error);
}
}
// Получить историю входов
export async function getLoginHistory(req: Request, res: Response) {
try {
const userId = req.user?.id;
if (!userId) {
return res.status(401).json({ error: 'Не авторизован' });
}
const limit = Number(req.query.limit) || 20;
const history = await prisma.loginHistory.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
take: limit
});
res.json(history);
} catch (error) {
logger.error('Ошибка получения истории входов:', error);
res.status(500).json({ error: 'Ошибка получения истории входов' });
}
}
// Очистить устаревшие сессии (запускать периодически)
export async function cleanupExpiredSessions() {
try {
const result = await prisma.session.deleteMany({
where: {
expiresAt: { lt: new Date() }
}
});
logger.info(`[Session Cleanup] Удалено ${result.count} устаревших сессий`);
} catch (error) {
logger.error('[Session Cleanup] Ошибка:', error);
}
}

View File

@@ -0,0 +1,27 @@
import { Router } from 'express';
import {
getUserSessions,
deleteSession,
deleteAllOtherSessions,
getLoginHistory
} from './session.controller';
import { authMiddleware } from '../auth/auth.middleware';
const router = Router();
// Все роуты требуют аутентификации
router.use(authMiddleware);
// Получить все активные сессии
router.get('/', getUserSessions);
// Получить историю входов
router.get('/history', getLoginHistory);
// Удалить конкретную сессию
router.delete('/:id', deleteSession);
// Удалить все сессии кроме текущей
router.delete('/others/all', deleteAllOtherSessions);
export default router;

View File

@@ -27,7 +27,7 @@ export async function generateSitemap(req: Request, res: Response) {
// lastmod: post.updatedAt.toISOString().split('T')[0]
// }));
} catch (error) {
console.log('Блог пока не активирован');
// Блог пока не активирован
}
const allPages = [...staticPages, ...dynamicPages];

View File

@@ -0,0 +1,42 @@
import { Client } from 'minio';
// Инициализация MinIO клиента через переменные окружения
// Добавьте в .env:
// MINIO_ENDPOINT=localhost
// MINIO_PORT=9000
// MINIO_USE_SSL=false
// MINIO_ACCESS_KEY=your_access_key
// MINIO_SECRET_KEY=your_secret_key
// MINIO_BUCKET_PREFIX=ospab
const {
MINIO_ENDPOINT,
MINIO_PORT,
MINIO_USE_SSL,
MINIO_ACCESS_KEY,
MINIO_SECRET_KEY
} = process.env;
export const minioClient = new Client({
endPoint: MINIO_ENDPOINT || 'localhost',
port: MINIO_PORT ? parseInt(MINIO_PORT, 10) : 9000,
useSSL: (MINIO_USE_SSL || 'false') === 'true',
accessKey: MINIO_ACCESS_KEY || 'minioadmin',
secretKey: MINIO_SECRET_KEY || 'minioadmin'
});
export function buildPhysicalBucketName(userId: number, logicalName: string): string {
const prefix = process.env.MINIO_BUCKET_PREFIX || 'ospab';
return `${prefix}-${userId}-${logicalName}`.toLowerCase();
}
export async function ensureBucketExists(bucketName: string, region: string): Promise<void> {
try {
const exists = await minioClient.bucketExists(bucketName);
if (!exists) {
await minioClient.makeBucket(bucketName, region);
}
} catch (err: unknown) {
throw err;
}
}

View File

@@ -0,0 +1,216 @@
import { Router } from 'express';
import {
createBucket,
listBuckets,
getBucket,
deleteBucket,
updateBucketSettings,
listBucketObjects,
createPresignedUrl,
deleteObjects,
createEphemeralKey,
listAccessKeys,
revokeAccessKey
} from './storage.service';
import { authMiddleware } from '../auth/auth.middleware';
// Предполагается, что аутентификация уже навешена на /api/storage через глобальный middleware (passport + JWT)
// Здесь используем req.user?.id (нужно убедиться что в auth модуле добавляется user в req)
const router = Router();
// Монтируем JWT-мидлвар на модуль, чтобы req.user всегда был установлен
router.use(authMiddleware);
// Создание бакета
router.post('/buckets', async (req, res) => {
try {
const userId = (req as any).user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
const { name, plan, quotaGb, region, storageClass, public: isPublic, versioning } = req.body;
if (!name || !plan || !quotaGb) return res.status(400).json({ error: 'name, plan, quotaGb обязательны' });
// Временное определение цены (можно заменить запросом к таблице s3_plan)
const PRICE_MAP: Record<string, number> = { basic: 99, standard: 199, plus: 399, pro: 699, enterprise: 1999 };
const price = PRICE_MAP[plan] || 0;
const bucket = await createBucket({
userId,
name,
plan,
quotaGb: Number(quotaGb),
region: region || 'ru-central-1',
storageClass: storageClass || 'standard',
public: !!isPublic,
versioning: !!versioning,
price
});
return res.json({ bucket });
} catch (e: unknown) {
let message = 'Ошибка создания бакета';
if (e instanceof Error) message = e.message;
return res.status(400).json({ error: message });
}
});
// Список бакетов пользователя
router.get('/buckets', async (req, res) => {
try {
const userId = (req as any).user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
const buckets = await listBuckets(userId);
return res.json({ buckets });
} catch (e: unknown) {
return res.status(500).json({ error: 'Ошибка получения списка бакетов' });
}
});
// Детали одного бакета
router.get('/buckets/:id', async (req, res) => {
try {
const userId = (req as any).user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
const id = Number(req.params.id);
const bucket = await getBucket(userId, id);
return res.json({ bucket });
} catch (e: unknown) {
let message = 'Ошибка получения бакета';
if (e instanceof Error) message = e.message;
return res.status(404).json({ error: message });
}
});
// Обновление настроек бакета
router.patch('/buckets/:id', async (req, res) => {
try {
const userId = (req as any).user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
const id = Number(req.params.id);
const bucket = await updateBucketSettings(userId, id, req.body ?? {});
return res.json({ bucket });
} catch (e: unknown) {
let message = 'Ошибка обновления бакета';
if (e instanceof Error) message = e.message;
return res.status(400).json({ error: message });
}
});
// Удаление бакета
router.delete('/buckets/:id', async (req, res) => {
try {
const userId = (req as any).user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
const id = Number(req.params.id);
const force = req.query.force === 'true';
const bucket = await deleteBucket(userId, id, force);
return res.json({ bucket });
} catch (e: unknown) {
let message = 'Ошибка удаления бакета';
if (e instanceof Error) message = e.message;
return res.status(400).json({ error: message });
}
});
// Список объектов в бакете
router.get('/buckets/:id/objects', async (req, res) => {
try {
const userId = (req as any).user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
const id = Number(req.params.id);
const { prefix, cursor, limit } = req.query;
const result = await listBucketObjects(userId, id, {
prefix: typeof prefix === 'string' ? prefix : undefined,
cursor: typeof cursor === 'string' ? cursor : undefined,
limit: limit ? Number(limit) : undefined
});
return res.json(result);
} catch (e: unknown) {
let message = 'Ошибка получения списка объектов';
if (e instanceof Error) message = e.message;
return res.status(400).json({ error: message });
}
});
// Пресайн URL для загрузки/скачивания
router.post('/buckets/:id/objects/presign', async (req, res) => {
try {
const userId = (req as any).user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
const id = Number(req.params.id);
const { key, method, expiresIn, contentType } = req.body ?? {};
if (!key) return res.status(400).json({ error: 'Не указан key объекта' });
const result = await createPresignedUrl(userId, id, key, { method, expiresIn, contentType });
return res.json(result);
} catch (e: unknown) {
let message = 'Ошибка генерации ссылки';
if (e instanceof Error) message = e.message;
return res.status(400).json({ error: message });
}
});
// Удаление объектов
router.delete('/buckets/:id/objects', async (req, res) => {
try {
const userId = (req as any).user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
const id = Number(req.params.id);
const { keys } = req.body ?? {};
if (!Array.isArray(keys)) return res.status(400).json({ error: 'keys должен быть массивом' });
const result = await deleteObjects(userId, id, keys);
return res.json(result);
} catch (e: unknown) {
let message = 'Ошибка удаления объектов';
if (e instanceof Error) message = e.message;
return res.status(400).json({ error: message });
}
});
// Управление access keys
router.get('/buckets/:id/access-keys', async (req, res) => {
try {
const userId = (req as any).user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
const id = Number(req.params.id);
const keys = await listAccessKeys(userId, id);
return res.json({ keys });
} catch (e: unknown) {
let message = 'Ошибка получения ключей';
if (e instanceof Error) message = e.message;
return res.status(400).json({ error: message });
}
});
router.post('/buckets/:id/access-keys', async (req, res) => {
try {
const userId = (req as any).user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
const id = Number(req.params.id);
const { label } = req.body ?? {};
const key = await createEphemeralKey(userId, id, label);
return res.json({ key });
} catch (e: unknown) {
let message = 'Ошибка создания ключа';
if (e instanceof Error) message = e.message;
return res.status(400).json({ error: message });
}
});
router.delete('/buckets/:id/access-keys/:keyId', async (req, res) => {
try {
const userId = (req as any).user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
const id = Number(req.params.id);
const keyId = Number(req.params.keyId);
const result = await revokeAccessKey(userId, id, keyId);
return res.json(result);
} catch (e: unknown) {
let message = 'Ошибка удаления ключа';
if (e instanceof Error) message = e.message;
return res.status(400).json({ error: message });
}
});
export default router;

View File

@@ -0,0 +1,480 @@
import crypto from 'crypto';
import type { StorageBucket } from '@prisma/client';
import { prisma } from '../../prisma/client';
import { ensureBucketExists, buildPhysicalBucketName, minioClient } from './minioClient';
import { createNotification } from '../notification/notification.controller';
interface CreateBucketInput {
userId: number;
name: string;
plan: string;
quotaGb: number;
region: string;
storageClass: string;
public: boolean;
versioning: boolean;
price: number; // ежемесячная стоимость плана для списания
}
interface UpdateBucketInput {
public?: boolean;
versioning?: boolean;
autoRenew?: boolean;
storageClass?: string;
name?: string;
}
interface ListObjectsOptions {
prefix?: string;
cursor?: string;
limit?: number;
}
interface PresignOptions {
method?: 'PUT' | 'GET';
expiresIn?: number;
contentType?: string;
}
const BILLING_INTERVAL_DAYS = 30;
const USAGE_REFRESH_INTERVAL_MINUTES = 5;
const PRESIGN_DEFAULT_TTL = 15 * 60; // 15 минут
function addDays(date: Date, days: number): Date {
const clone = new Date(date);
clone.setDate(clone.getDate() + days);
return clone;
}
function toNumber(value: bigint | number | null | undefined): number {
if (typeof value === 'bigint') {
return Number(value);
}
return value ?? 0;
}
function serializeBucket(bucket: StorageBucket) {
return {
...bucket,
usedBytes: toNumber(bucket.usedBytes),
monthlyPrice: Number(bucket.monthlyPrice),
nextBillingDate: bucket.nextBillingDate?.toISOString() ?? null,
lastBilledAt: bucket.lastBilledAt?.toISOString() ?? null,
usageSyncedAt: bucket.usageSyncedAt?.toISOString() ?? null,
};
}
function needsUsageRefresh(bucket: StorageBucket): boolean {
if (!bucket.usageSyncedAt) return true;
const diffMs = Date.now() - bucket.usageSyncedAt.getTime();
return diffMs > USAGE_REFRESH_INTERVAL_MINUTES * 60 * 1000;
}
async function calculateBucketUsage(physicalName: string): Promise<{ totalBytes: bigint; objectCount: number; }>
{
return await new Promise((resolve, reject) => {
let bytes = BigInt(0);
let count = 0;
const stream = minioClient.listObjectsV2(physicalName, '', true);
stream.on('data', (obj) => {
if (obj?.name) {
count += 1;
const size = typeof obj.size === 'number' ? obj.size : 0;
bytes += BigInt(size);
}
});
stream.on('error', (err) => reject(err));
stream.on('end', () => resolve({ totalBytes: bytes, objectCount: count }));
});
}
async function syncBucketUsage(bucket: StorageBucket): Promise<StorageBucket> {
const physicalName = buildPhysicalBucketName(bucket.userId, bucket.name);
try {
const usage = await calculateBucketUsage(physicalName);
return await prisma.storageBucket.update({
where: { id: bucket.id },
data: {
usedBytes: usage.totalBytes,
objectCount: usage.objectCount,
usageSyncedAt: new Date(),
}
});
} catch (error) {
console.error(`[Storage] Не удалось синхронизировать usage для бакета ${bucket.id}`, error);
return bucket;
}
}
async function fetchBucket(userId: number, bucketId: number): Promise<StorageBucket> {
const bucket = await prisma.storageBucket.findFirst({ where: { id: bucketId, userId } });
if (!bucket) throw new Error('Бакет не найден');
return bucket;
}
async function applyPublicPolicy(physicalName: string, isPublic: boolean) {
try {
if (isPublic) {
const policy = {
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Principal: { AWS: ['*'] },
Action: ['s3:GetObject'],
Resource: [`arn:aws:s3:::${physicalName}/*`]
}
]
};
await minioClient.setBucketPolicy(physicalName, JSON.stringify(policy));
} else {
// Сбрасываем политику
await minioClient.setBucketPolicy(physicalName, '');
}
} catch (error) {
console.error(`[Storage] Не удалось применить политику для бакета ${physicalName}`, error);
}
}
async function applyVersioning(physicalName: string, enabled: boolean) {
try {
await minioClient.setBucketVersioning(physicalName, {
Status: enabled ? 'Enabled' : 'Suspended'
});
} catch (error) {
console.error(`[Storage] Не удалось обновить версионирование для ${physicalName}`, error);
}
}
async function collectObjectKeys(physicalName: string): Promise<string[]> {
return await new Promise((resolve, reject) => {
const keys: string[] = [];
const stream = minioClient.listObjectsV2(physicalName, '', true);
stream.on('data', (obj) => {
if (obj?.name) keys.push(obj.name);
});
stream.on('error', (err) => reject(err));
stream.on('end', () => resolve(keys));
});
}
export async function createBucket(data: CreateBucketInput) {
const user = await prisma.user.findUnique({ where: { id: data.userId } });
if (!user) throw new Error('Пользователь не найден');
if (user.balance < data.price) throw new Error('Недостаточно средств');
const physicalName = buildPhysicalBucketName(data.userId, data.name);
const now = new Date();
await ensureBucketExists(physicalName, data.region);
try {
const bucket = await prisma.$transaction(async (tx) => {
const reloadedUser = await tx.user.findUnique({ where: { id: data.userId } });
if (!reloadedUser) throw new Error('Пользователь не найден');
if (reloadedUser.balance < data.price) throw new Error('Недостаточно средств');
const updatedUser = await tx.user.update({
where: { id: data.userId },
data: { balance: reloadedUser.balance - data.price }
});
await tx.transaction.create({
data: {
userId: data.userId,
amount: -data.price,
type: 'withdrawal',
description: `Создание S3 бакета ${data.name}`,
balanceBefore: reloadedUser.balance,
balanceAfter: updatedUser.balance
}
});
const bucketRecord = await tx.storageBucket.create({
data: {
userId: data.userId,
name: data.name,
plan: data.plan,
quotaGb: data.quotaGb,
region: data.region,
storageClass: data.storageClass,
public: data.public,
versioning: data.versioning,
monthlyPrice: data.price,
nextBillingDate: addDays(now, BILLING_INTERVAL_DAYS),
lastBilledAt: now,
autoRenew: true,
status: 'active',
usageSyncedAt: now
}
});
return bucketRecord;
});
await Promise.all([
applyPublicPolicy(physicalName, data.public),
applyVersioning(physicalName, data.versioning)
]);
await createNotification({
userId: data.userId,
type: 'storage_bucket_created',
title: 'Создан новый бакет',
message: `Бакет «${data.name}» успешно создан. Следующее списание: ${addDays(now, BILLING_INTERVAL_DAYS).toLocaleDateString('ru-RU')}`,
color: 'green'
});
return serializeBucket({ ...bucket, usedBytes: BigInt(0), objectCount: 0 });
} catch (error) {
// Откатываем созданный бакет в MinIO, если транзакция не удалась
try {
const keys = await collectObjectKeys(physicalName);
if (keys.length > 0) {
const chunks = Array.from({ length: Math.ceil(keys.length / 1000) }, (_, idx) =>
keys.slice(idx * 1000, (idx + 1) * 1000)
);
for (const chunk of chunks) {
await minioClient.removeObjects(physicalName, chunk);
}
}
await minioClient.removeBucket(physicalName);
} catch (cleanupError) {
console.error('[Storage] Ошибка очистки бакета после неудачного создания', cleanupError);
}
throw error;
}
}
export async function listBuckets(userId: number) {
const buckets = await prisma.storageBucket.findMany({ where: { userId }, orderBy: { createdAt: 'desc' } });
const results: StorageBucket[] = [];
for (const bucket of buckets) {
if (needsUsageRefresh(bucket)) {
const refreshed = await syncBucketUsage(bucket);
results.push(refreshed);
} else {
results.push(bucket);
}
}
return results.map(serializeBucket);
}
export async function getBucket(userId: number, id: number, options: { refreshUsage?: boolean } = {}) {
const bucket = await fetchBucket(userId, id);
const shouldRefresh = options.refreshUsage ?? true;
const finalBucket = shouldRefresh && needsUsageRefresh(bucket) ? await syncBucketUsage(bucket) : bucket;
return serializeBucket(finalBucket);
}
export async function deleteBucket(userId: number, id: number, force = false) {
const bucket = await fetchBucket(userId, id);
const physicalName = buildPhysicalBucketName(bucket.userId, bucket.name);
const keys = await collectObjectKeys(physicalName);
if (keys.length > 0 && !force) {
throw new Error('Невозможно удалить непустой бакет. Удалите объекты или используйте force=true');
}
if (keys.length > 0) {
const chunks = Array.from({ length: Math.ceil(keys.length / 1000) }, (_, idx) =>
keys.slice(idx * 1000, (idx + 1) * 1000)
);
for (const chunk of chunks) {
await minioClient.removeObjects(physicalName, chunk);
}
}
await minioClient.removeBucket(physicalName);
await prisma.storageAccessKey.deleteMany({ where: { bucketId: bucket.id } });
const deleted = await prisma.storageBucket.delete({ where: { id: bucket.id } });
await createNotification({
userId,
type: 'storage_bucket_deleted',
title: 'Бакет удалён',
message: `Бакет «${bucket.name}» был удалён`,
color: 'red'
});
return serializeBucket(deleted);
}
export async function updateBucketSettings(userId: number, id: number, payload: UpdateBucketInput) {
const bucket = await fetchBucket(userId, id);
const physicalName = buildPhysicalBucketName(bucket.userId, bucket.name);
if (payload.public !== undefined) {
await applyPublicPolicy(physicalName, payload.public);
}
if (payload.versioning !== undefined) {
await applyVersioning(physicalName, payload.versioning);
}
const data: UpdateBucketInput & { nextBillingDate?: Date | null } = { ...payload };
if (payload.autoRenew && !bucket.autoRenew) {
data.nextBillingDate = bucket.nextBillingDate ?? addDays(new Date(), BILLING_INTERVAL_DAYS);
}
const updated = await prisma.storageBucket.update({
where: { id: bucket.id },
data: {
...('public' in data ? { public: data.public } : {}),
...('versioning' in data ? { versioning: data.versioning } : {}),
...('autoRenew' in data ? { autoRenew: data.autoRenew } : {}),
...('storageClass' in data ? { storageClass: data.storageClass } : {}),
...('name' in data && data.name && data.name !== bucket.name ? { name: data.name } : {}),
...(data.nextBillingDate ? { nextBillingDate: data.nextBillingDate } : {}),
}
});
if (payload.name && payload.name !== bucket.name) {
// Переименовываем физический бакет через копирование ключей
const newPhysicalName = buildPhysicalBucketName(bucket.userId, payload.name);
await ensureBucketExists(newPhysicalName, bucket.region);
const keys = await collectObjectKeys(physicalName);
if (keys.length) {
for (const key of keys) {
const readable = await minioClient.getObject(physicalName, key);
await minioClient.putObject(newPhysicalName, key, readable);
await minioClient.removeObject(physicalName, key);
}
}
await minioClient.removeBucket(physicalName);
await applyPublicPolicy(newPhysicalName, updated.public);
await applyVersioning(newPhysicalName, updated.versioning);
}
return serializeBucket(updated);
}
export async function listBucketObjects(userId: number, id: number, options: ListObjectsOptions = {}) {
const bucket = await fetchBucket(userId, id);
const physicalName = buildPhysicalBucketName(bucket.userId, bucket.name);
const { prefix = '', cursor = '', limit = 100 } = options;
const objects: Array<{ key: string; size: number; etag?: string; lastModified?: string; }> = [];
let lastKey: string | null = null;
await new Promise<void>((resolve, reject) => {
const stream = minioClient.listObjectsV2(physicalName, prefix, true, cursor);
stream.on('data', (obj) => {
if (!obj?.name) return;
if (objects.length >= limit) {
lastKey = obj.name;
stream.destroy();
return;
}
objects.push({
key: obj.name,
size: typeof obj.size === 'number' ? obj.size : 0,
etag: obj.etag,
lastModified: obj.lastModified ? new Date(obj.lastModified).toISOString() : undefined,
});
lastKey = obj.name;
});
stream.on('error', (err) => reject(err));
stream.on('end', () => resolve());
});
return {
objects,
nextCursor: lastKey,
};
}
export async function createPresignedUrl(userId: number, id: number, objectKey: string, options: PresignOptions = {}) {
const bucket = await fetchBucket(userId, id);
const physicalName = buildPhysicalBucketName(bucket.userId, bucket.name);
const method = options.method ?? 'PUT';
const expires = options.expiresIn ?? PRESIGN_DEFAULT_TTL;
if (method === 'PUT') {
const url = await minioClient.presignedPutObject(physicalName, objectKey, expires);
return { url, method: 'PUT' };
}
if (method === 'GET') {
const responseHeaders = options.contentType ? { 'response-content-type': options.contentType } : undefined;
const url = await minioClient.presignedGetObject(physicalName, objectKey, expires, responseHeaders);
return { url, method: 'GET' };
}
throw new Error('Поддерживаются только методы GET и PUT для пресайн ссылки');
}
export async function deleteObjects(userId: number, id: number, keys: string[]) {
if (!Array.isArray(keys) || keys.length === 0) return { deleted: 0 };
const bucket = await fetchBucket(userId, id);
const physicalName = buildPhysicalBucketName(bucket.userId, bucket.name);
const chunks = Array.from({ length: Math.ceil(keys.length / 1000) }, (_, idx) =>
keys.slice(idx * 1000, (idx + 1) * 1000)
);
let deleted = 0;
for (const chunk of chunks) {
await minioClient.removeObjects(physicalName, chunk);
deleted += chunk.length;
}
await syncBucketUsage(bucket);
return { deleted };
}
export async function createEphemeralKey(userId: number, id: number, label?: string) {
const bucket = await fetchBucket(userId, id);
const accessKey = `S3${crypto.randomBytes(8).toString('hex')}`;
const secretKey = crypto.randomBytes(32).toString('hex');
const record = await prisma.storageAccessKey.create({
data: {
bucketId: bucket.id,
accessKey,
secretKey,
label,
}
});
return {
id: record.id,
accessKey,
secretKey,
label: record.label,
createdAt: record.createdAt.toISOString(),
};
}
export async function listAccessKeys(userId: number, id: number) {
const bucket = await fetchBucket(userId, id);
const keys = await prisma.storageAccessKey.findMany({
where: { bucketId: bucket.id },
orderBy: { createdAt: 'desc' }
});
return keys.map((key) => ({
id: key.id,
accessKey: key.accessKey,
label: key.label,
createdAt: key.createdAt.toISOString(),
lastUsedAt: key.lastUsedAt?.toISOString() ?? null
}));
}
export async function revokeAccessKey(userId: number, id: number, keyId: number) {
const bucket = await fetchBucket(userId, id);
await prisma.storageAccessKey.deleteMany({
where: { id: keyId, bucketId: bucket.id }
});
return { revoked: true };
}

View File

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

View File

@@ -1,18 +0,0 @@
import { Router } from 'express';
import { PrismaClient } from '@prisma/client';
const router = Router();
const prisma = new PrismaClient();
router.get('/', async (req, res) => {
try {
const tariffs = await prisma.tariff.findMany();
res.json(tariffs);
} catch (err) {
console.error('Ошибка получения тарифов:', err);
res.status(500).json({ error: 'Ошибка получения тарифов' });
}
});
export default router;

View File

@@ -1,79 +1,310 @@
import { PrismaClient } from '@prisma/client';
import { prisma } from '../../prisma/client';
import { Request, Response } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
const prisma = new PrismaClient();
// Настройка multer для загрузки файлов
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const uploadDir = path.join(__dirname, '../../../uploads/tickets');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, uniqueSuffix + '-' + file.originalname);
}
});
export const uploadTicketFiles = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
fileFilter: (req, file, cb) => {
const allowedTypes = /jpeg|jpg|png|gif|pdf|doc|docx|txt|zip/;
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const mimetype = allowedTypes.test(file.mimetype);
if (extname && mimetype) {
cb(null, true);
} else {
cb(new Error('Недопустимый тип файла'));
}
}
}).array('attachments', 5); // Максимум 5 файлов
// Создать тикет
export async function createTicket(req: Request, res: Response) {
const { title, message } = req.body;
const userId = req.user?.id;
const { title, message, category = 'general', priority = 'normal' } = req.body;
const userId = (req as any).user?.id;
if (!userId) return res.status(401).json({ error: 'Нет авторизации' });
if (!title || !message) {
return res.status(400).json({ error: 'Необходимо указать title и message' });
}
try {
const ticket = await prisma.ticket.create({
data: { title, message, userId },
data: {
title,
message,
userId,
category,
priority,
status: 'open'
},
include: {
user: {
select: { id: true, username: true, email: true }
}
}
});
// TODO: Отправить уведомление операторам о новом тикете
res.json(ticket);
} catch (err) {
console.error('Ошибка создания тикета:', err);
res.status(500).json({ error: 'Ошибка создания тикета' });
}
}
// Получить тикеты (клиент — свои, оператор — все)
// Получить тикеты (клиент — свои, оператор — все с фильтрами)
export async function getTickets(req: Request, res: Response) {
const userId = req.user?.id;
const isOperator = Number(req.user?.operator) === 1;
const userId = (req as any).user?.id;
const isOperator = Number((req as any).user?.operator) === 1;
const { status, category, priority, assignedTo } = req.query;
if (!userId) return res.status(401).json({ error: 'Нет авторизации' });
try {
const where: any = isOperator ? {} : { userId };
// Фильтры (только для операторов)
if (isOperator) {
if (status) where.status = status;
if (category) where.category = category;
if (priority) where.priority = priority;
if (assignedTo) where.assignedTo = Number(assignedTo);
}
const tickets = await prisma.ticket.findMany({
where: isOperator ? {} : { userId },
where,
include: {
responses: { include: { operator: true } },
user: true
responses: {
include: {
operator: {
select: { id: true, username: true, email: true }
}
},
orderBy: { createdAt: 'asc' }
},
user: {
select: { id: true, username: true, email: true }
},
attachments: true
},
orderBy: { createdAt: 'desc' },
});
res.json(tickets);
} catch (err) {
console.error('Ошибка получения тикетов:', err);
res.status(500).json({ error: 'Ошибка получения тикетов' });
}
}
// Ответить на тикет (только оператор)
export async function respondTicket(req: Request, res: Response) {
const { ticketId, message } = req.body;
const operatorId = req.user?.id;
const isOperator = Number(req.user?.operator) === 1;
if (!operatorId || !isOperator) return res.status(403).json({ error: 'Нет прав' });
// Получить один тикет по ID
export async function getTicketById(req: Request, res: Response) {
const ticketId = Number(req.params.id);
const userId = (req as any).user?.id;
const isOperator = Number((req as any).user?.operator) === 1;
if (!userId) return res.status(401).json({ error: 'Нет авторизации' });
try {
const response = await prisma.response.create({
data: { ticketId, operatorId, message },
const ticket = await prisma.ticket.findUnique({
where: { id: ticketId },
include: {
responses: {
where: isOperator ? {} : { isInternal: false }, // Клиенты не видят внутренние комментарии
include: {
operator: {
select: { id: true, username: true, email: true }
}
},
orderBy: { createdAt: 'asc' }
},
user: {
select: { id: true, username: true, email: true }
},
attachments: true
}
});
if (!ticket) {
return res.status(404).json({ error: 'Тикет не найден' });
}
// Проверка прав доступа
if (!isOperator && ticket.userId !== userId) {
return res.status(403).json({ error: 'Нет прав доступа к этому тикету' });
}
res.json(ticket);
} catch (err) {
console.error('Ошибка получения тикета:', err);
res.status(500).json({ error: 'Ошибка получения тикета' });
}
}
// Ответить на тикет (клиент или оператор)
export async function respondTicket(req: Request, res: Response) {
const { ticketId, message, isInternal = false } = req.body;
const operatorId = (req as any).user?.id;
const isOperator = Number((req as any).user?.operator) === 1;
if (!operatorId) return res.status(401).json({ error: 'Нет авторизации' });
if (!message) return res.status(400).json({ error: 'Сообщение не может быть пустым' });
// Только операторы могут оставлять внутренние комментарии
const actualIsInternal = isOperator ? isInternal : false;
try {
const ticket = await prisma.ticket.findUnique({ where: { id: ticketId } });
if (!ticket) return res.status(404).json({ error: 'Тикет не найден' });
// Клиент может отвечать только на свои тикеты
if (!isOperator && ticket.userId !== operatorId) {
return res.status(403).json({ error: 'Нет прав' });
}
const response = await prisma.response.create({
data: {
ticketId,
operatorId,
message,
isInternal: actualIsInternal
},
include: {
operator: {
select: { id: true, username: true, email: true }
}
}
});
// Обновляем статус тикета
let newStatus = ticket.status;
if (isOperator && ticket.status === 'open') {
newStatus = 'in_progress';
} else if (!isOperator && ticket.status === 'awaiting_reply') {
newStatus = 'in_progress';
}
await prisma.ticket.update({
where: { id: ticketId },
data: { status: 'answered' },
data: {
status: newStatus,
updatedAt: new Date()
},
});
// TODO: Отправить уведомление автору тикета (если ответил оператор)
res.json(response);
} catch (err) {
console.error('Ошибка ответа на тикет:', err);
res.status(500).json({ error: 'Ошибка ответа на тикет' });
}
}
// Изменить статус тикета (только оператор)
export async function updateTicketStatus(req: Request, res: Response) {
const { ticketId, status } = req.body;
const userId = (req as any).user?.id;
const isOperator = Number((req as any).user?.operator) === 1;
if (!userId || !isOperator) {
return res.status(403).json({ error: 'Нет прав' });
}
const allowedStatuses = ['open', 'in_progress', 'awaiting_reply', 'resolved', 'closed'];
if (!allowedStatuses.includes(status)) {
return res.status(400).json({ error: 'Недопустимый статус' });
}
try {
const ticket = await prisma.ticket.update({
where: { id: ticketId },
data: {
status,
closedAt: status === 'closed' ? new Date() : null,
updatedAt: new Date()
},
});
res.json(ticket);
} catch (err) {
console.error('Ошибка изменения статуса тикета:', err);
res.status(500).json({ error: 'Ошибка изменения статуса тикета' });
}
}
// Назначить тикет на оператора (только оператор)
export async function assignTicket(req: Request, res: Response) {
const { ticketId, operatorId } = req.body;
const userId = (req as any).user?.id;
const isOperator = Number((req as any).user?.operator) === 1;
if (!userId || !isOperator) {
return res.status(403).json({ error: 'Нет прав' });
}
try {
const ticket = await prisma.ticket.update({
where: { id: ticketId },
data: {
assignedTo: operatorId,
status: 'in_progress',
updatedAt: new Date()
},
});
res.json(ticket);
} catch (err) {
console.error('Ошибка назначения тикета:', err);
res.status(500).json({ error: 'Ошибка назначения тикета' });
}
}
// Закрыть тикет (клиент или оператор)
export async function closeTicket(req: Request, res: Response) {
const { ticketId } = req.body;
const userId = req.user?.id;
const isOperator = Number(req.user?.operator) === 1;
const userId = (req as any).user?.id;
const isOperator = Number((req as any).user?.operator) === 1;
if (!userId) return res.status(401).json({ error: 'Нет авторизации' });
try {
const ticket = await prisma.ticket.findUnique({ where: { id: ticketId } });
if (!ticket) return res.status(404).json({ error: 'Тикет не найден' });
if (!isOperator && ticket.userId !== userId) return res.status(403).json({ error: 'Нет прав' });
if (!isOperator && ticket.userId !== userId) {
return res.status(403).json({ error: 'Нет прав' });
}
await prisma.ticket.update({
where: { id: ticketId },
data: { status: 'closed' },
data: {
status: 'closed',
closedAt: new Date(),
updatedAt: new Date()
},
});
res.json({ success: true });
res.json({ success: true, message: 'Тикет закрыт' });
} catch (err) {
console.error('Ошибка закрытия тикета:', err);
res.status(500).json({ error: 'Ошибка закрытия тикета' });
}
}

View File

@@ -1,14 +1,44 @@
import { Router } from 'express';
import { createTicket, getTickets, respondTicket, closeTicket } from './ticket.controller';
import {
createTicket,
getTickets,
getTicketById,
respondTicket,
closeTicket,
updateTicketStatus,
assignTicket,
uploadTicketFiles
} from './ticket.controller';
import { authMiddleware } from '../auth/auth.middleware';
const router = Router();
router.use(authMiddleware);
router.post('/create', createTicket);
// Получить все тикеты (с фильтрами для операторов)
router.get('/', getTickets);
// Получить один тикет по ID
router.get('/:id', getTicketById);
// Создать тикет
router.post('/create', createTicket);
// Ответить на тикет
router.post('/respond', respondTicket);
// Изменить статус тикета (только оператор)
router.post('/status', updateTicketStatus);
// Назначить тикет на оператора (только оператор)
router.post('/assign', assignTicket);
// Закрыть тикет
router.post('/close', closeTicket);
// Загрузить файлы к тикету (TODO: доделать обработку)
// router.post('/upload', uploadTicketFiles, (req, res) => {
// res.json({ files: req.files });
// });
export default router;

View File

@@ -0,0 +1,425 @@
import { Request, Response } from 'express';
import { prisma } from '../../prisma/client';
import bcrypt from 'bcrypt';
import crypto from 'crypto';
// Получить профиль пользователя (расширенный)
export const getProfile = async (req: Request, res: Response) => {
try {
const userId = (req as any).user.id;
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
profile: true,
notificationSettings: true,
_count: {
select: {
buckets: true,
tickets: true,
sessions: true,
apiKeys: true
}
}
}
});
if (!user) {
return res.status(404).json({ success: false, message: 'Пользователь не найден' });
}
// Не отправляем пароль
const { password, ...userWithoutPassword } = user;
res.json({ success: true, data: userWithoutPassword });
} catch (error: any) {
console.error('Ошибка получения профиля:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// Обновить базовый профиль
export const updateProfile = async (req: Request, res: Response) => {
try {
const userId = (req as any).user.id;
const { username, email, phoneNumber, timezone, language } = req.body;
// Проверка email на уникальность
if (email) {
const existingUser = await prisma.user.findFirst({
where: { email, id: { not: userId } }
});
if (existingUser) {
return res.status(400).json({ success: false, message: 'Email уже используется' });
}
}
// Обновление User
const updatedUser = await prisma.user.update({
where: { id: userId },
data: {
...(username && { username }),
...(email && { email })
}
});
// Обновление или создание UserProfile
const profile = await prisma.userProfile.upsert({
where: { userId },
update: {
...(phoneNumber !== undefined && { phoneNumber }),
...(timezone && { timezone }),
...(language && { language })
},
create: {
userId,
phoneNumber,
timezone,
language
}
});
res.json({
success: true,
message: 'Профиль обновлён',
data: { user: updatedUser, profile }
});
} catch (error: any) {
console.error('Ошибка обновления профиля:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// Изменить пароль
export const changePassword = async (req: Request, res: Response) => {
try {
const userId = (req as any).user.id;
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) {
return res.status(400).json({ success: false, message: 'Все поля обязательны' });
}
if (newPassword.length < 6) {
return res.status(400).json({ success: false, message: 'Новый пароль должен быть минимум 6 символов' });
}
// Проверка текущего пароля
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
return res.status(404).json({ success: false, message: 'Пользователь не найден' });
}
const isPasswordValid = await bcrypt.compare(currentPassword, user.password);
if (!isPasswordValid) {
return res.status(401).json({ success: false, message: 'Неверный текущий пароль' });
}
// Хешируем новый пароль
const hashedPassword = await bcrypt.hash(newPassword, 10);
// Обновляем пароль
await prisma.user.update({
where: { id: userId },
data: { password: hashedPassword }
});
// Завершаем все сеансы кроме текущего (опционально)
// Можно добавить логику для сохранения текущего токена
res.json({ success: true, message: 'Пароль успешно изменён' });
} catch (error: any) {
console.error('Ошибка смены пароля:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// Загрузить аватар
export const uploadAvatar = async (req: Request, res: Response) => {
try {
const userId = (req as any).user.id;
if (!req.file) {
return res.status(400).json({ success: false, message: 'Файл не загружен' });
}
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
// Обновляем профиль
await prisma.userProfile.upsert({
where: { userId },
update: { avatarUrl },
create: { userId, avatarUrl }
});
res.json({
success: true,
message: 'Аватар загружен',
data: { avatarUrl }
});
} catch (error: any) {
console.error('Ошибка загрузки аватара:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// Удалить аватар
export const deleteAvatar = async (req: Request, res: Response) => {
try {
const userId = (req as any).user.id;
await prisma.userProfile.update({
where: { userId },
data: { avatarUrl: null }
});
res.json({ success: true, message: 'Аватар удалён' });
} catch (error: any) {
console.error('Ошибка удаления аватара:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// Получить активные сеансы
export const getSessions = async (req: Request, res: Response) => {
try {
const userId = (req as any).user.id;
const sessions = await prisma.session.findMany({
where: {
userId,
expiresAt: { gte: new Date() } // Только активные
},
orderBy: { lastActivity: 'desc' }
});
res.json({ success: true, data: sessions });
} catch (error: any) {
console.error('Ошибка получения сеансов:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// Завершить сеанс
export const terminateSession = async (req: Request, res: Response) => {
try {
const userId = (req as any).user.id;
const { sessionId } = req.params;
// Проверяем, что сеанс принадлежит пользователю
const session = await prisma.session.findFirst({
where: { id: parseInt(sessionId), userId }
});
if (!session) {
return res.status(404).json({ success: false, message: 'Сеанс не найден' });
}
// Удаляем сеанс
await prisma.session.delete({
where: { id: parseInt(sessionId) }
});
res.json({ success: true, message: 'Сеанс завершён' });
} catch (error: any) {
console.error('Ошибка завершения сеанса:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// Получить историю входов
export const getLoginHistory = async (req: Request, res: Response) => {
try {
const userId = (req as any).user.id;
const limit = parseInt(req.query.limit as string) || 20;
const history = await prisma.loginHistory.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
take: limit
});
res.json({ success: true, data: history });
} catch (error: any) {
console.error('Ошибка получения истории:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// Получить SSH ключи
// Получить API ключи
export const getAPIKeys = async (req: Request, res: Response) => {
try {
const userId = (req as any).user.id;
const keys = await prisma.aPIKey.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
select: {
id: true,
name: true,
prefix: true,
permissions: true,
lastUsed: true,
createdAt: true,
expiresAt: true
// Не отправляем полный ключ из соображений безопасности
}
});
res.json({ success: true, data: keys });
} catch (error: any) {
console.error('Ошибка получения API ключей:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// Создать API ключ
export const createAPIKey = async (req: Request, res: Response) => {
try {
const userId = (req as any).user.id;
const { name, permissions, expiresAt } = req.body;
if (!name) {
return res.status(400).json({ success: false, message: 'Имя ключа обязательно' });
}
// Генерируем случайный ключ
const key = `ospab_${crypto.randomBytes(32).toString('hex')}`;
const prefix = key.substring(0, 16) + '...';
const apiKey = await prisma.aPIKey.create({
data: {
userId,
name,
key,
prefix,
permissions: permissions ? JSON.stringify(permissions) : null,
expiresAt: expiresAt ? new Date(expiresAt) : null
}
});
// Отправляем полный ключ только один раз при создании
res.json({
success: true,
message: 'API ключ создан. Сохраните его, он больше не будет показан!',
data: { ...apiKey, fullKey: key }
});
} catch (error: any) {
console.error('Ошибка создания API ключа:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// Удалить API ключ
export const deleteAPIKey = async (req: Request, res: Response) => {
try {
const userId = (req as any).user.id;
const { keyId } = req.params;
// Проверяем принадлежность ключа
const key = await prisma.aPIKey.findFirst({
where: { id: parseInt(keyId), userId }
});
if (!key) {
return res.status(404).json({ success: false, message: 'Ключ не найден' });
}
await prisma.aPIKey.delete({
where: { id: parseInt(keyId) }
});
res.json({ success: true, message: 'API ключ удалён' });
} catch (error: any) {
console.error('Ошибка удаления API ключа:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// Получить настройки уведомлений
export const getNotificationSettings = async (req: Request, res: Response) => {
try {
const userId = (req as any).user.id;
let settings = await prisma.notificationSettings.findUnique({
where: { userId }
});
// Создаём настройки по умолчанию, если их нет
if (!settings) {
settings = await prisma.notificationSettings.create({
data: { userId }
});
}
res.json({ success: true, data: settings });
} catch (error: any) {
console.error('Ошибка получения настроек уведомлений:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// Обновить настройки уведомлений
export const updateNotificationSettings = async (req: Request, res: Response) => {
try {
const userId = (req as any).user.id;
const settings = req.body;
const updated = await prisma.notificationSettings.upsert({
where: { userId },
update: settings,
create: { userId, ...settings }
});
res.json({
success: true,
message: 'Настройки уведомлений обновлены',
data: updated
});
} catch (error: any) {
console.error('Ошибка обновления настроек уведомлений:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
// Экспорт данных пользователя (GDPR compliance)
export const exportUserData = async (req: Request, res: Response) => {
try {
const userId = (req as any).user.id;
const userData = await prisma.user.findUnique({
where: { id: userId },
include: {
profile: true,
buckets: true,
tickets: true,
checks: true,
transactions: true,
notifications: true,
apiKeys: {
select: { id: true, name: true, prefix: true, createdAt: true }
},
loginHistory: { take: 100 }
}
});
if (!userData) {
return res.status(404).json({ success: false, message: 'Пользователь не найден' });
}
// Убираем пароль
const { password, ...dataWithoutPassword } = userData;
res.json({
success: true,
data: dataWithoutPassword,
exportedAt: new Date().toISOString()
});
} catch (error: any) {
console.error('Ошибка экспорта данных:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};

View File

@@ -0,0 +1,109 @@
import { Router } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { prisma } from '../../prisma/client';
import { logger } from '../../utils/logger';
import {
getProfile,
updateProfile,
changePassword,
uploadAvatar,
deleteAvatar,
getSessions,
terminateSession,
getLoginHistory,
getAPIKeys,
createAPIKey,
deleteAPIKey,
getNotificationSettings,
updateNotificationSettings,
exportUserData
} from './user.controller';
import { authMiddleware } from '../auth/auth.middleware';
const router = Router();
// Настройка multer для загрузки аватаров
const avatarStorage = multer.diskStorage({
destination: (req, file, cb) => {
const uploadDir = path.join(__dirname, '../../../uploads/avatars');
// Создаём директорию если не существует
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const userId = (req as any).user.id;
const ext = path.extname(file.originalname);
cb(null, `avatar-${userId}-${Date.now()}${ext}`);
}
});
const avatarUpload = multer({
storage: avatarStorage,
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
fileFilter: (req, file, cb: any) => {
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Неподдерживаемый формат изображения'));
}
}
});
// Все роуты требуют аутентификации
router.use(authMiddleware);
// Профиль
router.get('/profile', getProfile);
router.put('/profile', updateProfile);
// Безопасность
router.post('/change-password', changePassword);
router.get('/sessions', getSessions);
router.delete('/sessions/:sessionId', terminateSession);
router.get('/login-history', getLoginHistory);
// Аватар
router.post('/avatar', avatarUpload.single('avatar'), uploadAvatar);
router.delete('/avatar', deleteAvatar);
// API ключи
router.get('/api-keys', getAPIKeys);
router.post('/api-keys', createAPIKey);
router.delete('/api-keys/:keyId', deleteAPIKey);
// Настройки уведомлений
router.get('/notification-settings', getNotificationSettings);
router.put('/notification-settings', updateNotificationSettings);
// Экспорт данных
router.get('/export', exportUserData);
// Баланс и транзакции
router.get('/balance', async (req, res) => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизован' });
const user = await prisma.user.findUnique({
where: { id: userId },
select: { balance: true }
});
if (!user) {
return res.status(404).json({ error: 'Пользователь не найден' });
}
logger.info(`[User Balance] Пользователь ID ${userId}, баланс: ${user.balance}`);
res.json({ status: 'success', balance: user.balance || 0 });
} catch (error) {
logger.error('[User Balance] Ошибка получения баланса:', error);
res.status(500).json({ error: 'Ошибка получения баланса' });
}
});
export default router;

View File

@@ -0,0 +1,56 @@
// Общие типы для обработки ошибок
export interface ProxmoxError extends Error {
response?: {
status?: number;
statusText?: string;
data?: {
errors?: string;
message?: string;
data?: null;
};
};
code?: string;
}
export interface AxiosError extends Error {
response?: {
status?: number;
statusText?: string;
data?: unknown;
};
code?: string;
isAxiosError?: boolean;
}
export function isAxiosError(error: unknown): error is AxiosError {
return (
typeof error === 'object' &&
error !== null &&
'isAxiosError' in error &&
(error as AxiosError).isAxiosError === true
);
}
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'string') {
return error;
}
return 'Неизвестная ошибка';
}
export function getProxmoxErrorMessage(error: unknown): string {
if (error && typeof error === 'object') {
const err = error as ProxmoxError;
if (err.response?.data?.errors) {
return err.response.data.errors;
}
if (err.message) {
return err.message;
}
}
return getErrorMessage(error);
}

View File

@@ -1,17 +1,10 @@
// Типы для расширения Express Request
import { User } from '@prisma/client';
import { User as PrismaUser } from '@prisma/client';
declare global {
namespace Express {
interface User {
id: number;
email: string;
username: string;
password: string;
balance: number;
operator: number;
createdAt: Date;
}
// Используем полный тип User из Prisma
interface User extends PrismaUser {}
interface Request {
user?: User;

View File

@@ -0,0 +1,56 @@
/**
* Logger utility - логирование только в debug режиме
* Управляется через NODE_ENV в .env файле
*/
const isDebug = process.env.NODE_ENV !== 'production';
export const logger = {
log: (...args: any[]) => {
if (isDebug) {
console.log(...args);
}
},
error: (...args: any[]) => {
// Ошибки логируем всегда
console.error(...args);
},
warn: (...args: any[]) => {
if (isDebug) {
console.warn(...args);
}
},
info: (...args: any[]) => {
if (isDebug) {
console.info(...args);
}
},
debug: (...args: any[]) => {
if (isDebug) {
console.debug(...args);
}
}
};
// WebSocket специфичные логи
export const wsLogger = {
log: (message: string, ...args: any[]) => {
if (isDebug) {
console.log(`[WebSocket] ${message}`, ...args);
}
},
error: (message: string, ...args: any[]) => {
console.error(`[WebSocket] ${message}`, ...args);
},
warn: (message: string, ...args: any[]) => {
if (isDebug) {
console.warn(`[WebSocket] ${message}`, ...args);
}
}
};

View File

@@ -0,0 +1,47 @@
/**
* Типы WebSocket событий
* Shared между backend и frontend
*/
// События от клиента к серверу
export type ClientToServerEvents =
| { type: 'auth'; token: string }
| { type: 'subscribe:notifications' }
| { type: 'subscribe:servers' }
| { type: 'subscribe:tickets' }
| { type: 'subscribe:balance' }
| { type: 'unsubscribe:notifications' }
| { type: 'unsubscribe:servers' }
| { type: 'unsubscribe:tickets' }
| { type: 'unsubscribe:balance' }
| { type: 'ping' };
// События от сервера к клиенту
export type ServerToClientEvents =
| { type: 'auth:success'; userId: number }
| { type: 'auth:error'; message: string }
| { type: 'notification:new'; notification: any }
| { type: 'notification:read'; notificationId: number }
| { type: 'notification:delete'; notificationId: number }
| { type: 'notification:updated'; notificationId: number; data: any }
| { type: 'notification:count'; count: number }
| { type: 'server:created'; server: any }
| { type: 'server:status'; serverId: number; status: string; ipAddress?: string }
| { type: 'server:stats'; serverId: number; stats: any }
| { type: 'ticket:new'; ticket: any }
| { type: 'ticket:response'; ticketId: number; response: any }
| { type: 'ticket:status'; ticketId: number; status: string }
| { type: 'balance:updated'; balance: number; transaction?: any }
| { type: 'check:status'; checkId: number; status: string }
| { type: 'pong' }
| { type: 'error'; message: string };
// Типы комнат для подписок
export type RoomType = 'notifications' | 'servers' | 'tickets' | 'balance';
// Интерфейс для аутентифицированного WebSocket клиента
export interface AuthenticatedClient {
userId: number;
rooms: Set<RoomType>;
lastPing: Date;
}

View File

@@ -0,0 +1,282 @@
import WebSocket, { WebSocketServer } from 'ws';
import { Server as HTTPServer } from 'http';
import jwt from 'jsonwebtoken';
import { PrismaClient } from '@prisma/client';
import {
ClientToServerEvents,
ServerToClientEvents,
AuthenticatedClient,
RoomType
} from './events';
import { wsLogger } from '../utils/logger';
const prisma = new PrismaClient();
const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_key';
// Хранилище аутентифицированных клиентов
const authenticatedClients = new Map<WebSocket, AuthenticatedClient>();
// Хранилище комнат (userId -> Set<WebSocket>)
const rooms = {
notifications: new Map<number, Set<WebSocket>>(),
servers: new Map<number, Set<WebSocket>>(),
tickets: new Map<number, Set<WebSocket>>(),
balance: new Map<number, Set<WebSocket>>(),
};
/**
* Инициализация WebSocket сервера
*/
export function initWebSocketServer(server: HTTPServer): WebSocketServer {
const wss = new WebSocketServer({
server,
path: '/ws'
});
wsLogger.log('Сервер инициализирован на пути /ws');
wss.on('connection', (ws: WebSocket) => {
wsLogger.log('Новое подключение');
// Таймаут для аутентификации (10 секунд)
const authTimeout = setTimeout(() => {
if (!authenticatedClients.has(ws)) {
wsLogger.warn('Таймаут аутентификации, закрываем соединение');
sendMessage(ws, { type: 'error', message: 'Аутентификация не выполнена' });
ws.close();
}
}, 10000);
ws.on('message', async (data: Buffer) => {
try {
const message = JSON.parse(data.toString()) as ClientToServerEvents;
await handleClientMessage(ws, message, authTimeout);
} catch (error) {
wsLogger.error('Ошибка обработки сообщения:', error);
sendMessage(ws, { type: 'error', message: 'Ошибка обработки сообщения' });
}
});
ws.on('close', () => {
handleDisconnect(ws);
clearTimeout(authTimeout);
});
ws.on('error', (error) => {
wsLogger.error('Ошибка соединения:', error);
handleDisconnect(ws);
});
});
// Ping каждые 30 секунд для поддержания соединения
setInterval(() => {
wss.clients.forEach((ws) => {
if (ws.readyState === WebSocket.OPEN) {
const client = authenticatedClients.get(ws);
if (client) {
sendMessage(ws, { type: 'pong' });
}
}
});
}, 30000);
return wss;
}
/**
* Обработка сообщений от клиента
*/
async function handleClientMessage(
ws: WebSocket,
message: ClientToServerEvents,
authTimeout: NodeJS.Timeout
): Promise<void> {
wsLogger.log('Получено сообщение:', message.type);
switch (message.type) {
case 'auth':
await handleAuth(ws, message.token, authTimeout);
break;
case 'subscribe:notifications':
case 'subscribe:servers':
case 'subscribe:tickets':
case 'subscribe:balance':
handleSubscribe(ws, message.type.split(':')[1] as RoomType);
break;
case 'unsubscribe:notifications':
case 'unsubscribe:servers':
case 'unsubscribe:tickets':
case 'unsubscribe:balance':
handleUnsubscribe(ws, message.type.split(':')[1] as RoomType);
break;
case 'ping':
sendMessage(ws, { type: 'pong' });
break;
default:
sendMessage(ws, { type: 'error', message: 'Неизвестный тип сообщения' });
}
}
/**
* Аутентификация WebSocket соединения
*/
async function handleAuth(ws: WebSocket, token: string, authTimeout: NodeJS.Timeout): Promise<void> {
try {
wsLogger.log('Попытка аутентификации...');
const decoded = jwt.verify(token, JWT_SECRET) as { id: number };
const user = await prisma.user.findUnique({ where: { id: decoded.id } });
if (!user) {
wsLogger.warn('Пользователь не найден');
sendMessage(ws, { type: 'auth:error', message: 'Пользователь не найден' });
ws.close();
return;
}
// Сохраняем аутентифицированного клиента
authenticatedClients.set(ws, {
userId: user.id,
rooms: new Set(),
lastPing: new Date()
});
clearTimeout(authTimeout);
wsLogger.log(`Пользователь ${user.id} (${user.username}) аутентифицирован`);
sendMessage(ws, { type: 'auth:success', userId: user.id });
} catch (error) {
wsLogger.error('Ошибка аутентификации:', error);
sendMessage(ws, { type: 'auth:error', message: 'Неверный токен' });
ws.close();
}
}
/**
* Подписка на комнату (тип событий)
*/
function handleSubscribe(ws: WebSocket, roomType: RoomType): void {
const client = authenticatedClients.get(ws);
if (!client) {
sendMessage(ws, { type: 'error', message: 'Не аутентифицирован' });
return;
}
// Добавляем комнату в список клиента
client.rooms.add(roomType);
// Добавляем клиента в комнату
if (!rooms[roomType].has(client.userId)) {
rooms[roomType].set(client.userId, new Set());
}
rooms[roomType].get(client.userId)!.add(ws);
wsLogger.log(`Пользователь ${client.userId} подписан на ${roomType}`);
}
/**
* Отписка от комнаты
*/
function handleUnsubscribe(ws: WebSocket, roomType: RoomType): void {
const client = authenticatedClients.get(ws);
if (!client) {
return;
}
client.rooms.delete(roomType);
const userSockets = rooms[roomType].get(client.userId);
if (userSockets) {
userSockets.delete(ws);
if (userSockets.size === 0) {
rooms[roomType].delete(client.userId);
}
}
wsLogger.log(`Пользователь ${client.userId} отписан от ${roomType}`);
}
/**
* Обработка отключения клиента
*/
function handleDisconnect(ws: WebSocket): void {
const client = authenticatedClients.get(ws);
if (client) {
wsLogger.log(`Пользователь ${client.userId} отключился`);
// Удаляем из всех комнат
client.rooms.forEach(roomType => {
const userSockets = rooms[roomType].get(client.userId);
if (userSockets) {
userSockets.delete(ws);
if (userSockets.size === 0) {
rooms[roomType].delete(client.userId);
}
}
});
authenticatedClients.delete(ws);
} else {
wsLogger.log('Неаутентифицированный клиент отключился');
}
}
/**
* Отправка сообщения клиенту
*/
function sendMessage(ws: WebSocket, message: ServerToClientEvents): void {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
}
}
/**
* Broadcast сообщения всем клиентам в комнате определённого пользователя
*/
export function broadcastToUser(userId: number, roomType: RoomType, message: ServerToClientEvents): void {
const userSockets = rooms[roomType].get(userId);
if (userSockets && userSockets.size > 0) {
wsLogger.log(`Отправка ${message.type} пользователю ${userId} (${userSockets.size} подключений)`);
userSockets.forEach(ws => sendMessage(ws, message));
}
}
/**
* Broadcast всем подключённым пользователям в комнате
*/
export function broadcastToRoom(roomType: RoomType, message: ServerToClientEvents): void {
const count = rooms[roomType].size;
wsLogger.log(`Broadcast ${message.type} в комнату ${roomType} (${count} пользователей)`);
rooms[roomType].forEach((sockets) => {
sockets.forEach(ws => sendMessage(ws, message));
});
}
/**
* Получить количество подключённых пользователей
*/
export function getConnectedUsersCount(): number {
return authenticatedClients.size;
}
/**
* Получить статистику по комнатам
*/
export function getRoomsStats(): Record<RoomType, number> {
return {
notifications: rooms.notifications.size,
servers: rooms.servers.size,
tickets: rooms.tickets.size,
balance: rooms.balance.size,
};
}