sitemap и тд

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,165 @@
import { prisma } from '../../prisma/client';
// Утилита для добавления дней к дате
function addDays(date: Date, days: number): Date {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
export class PaymentService {
/**
* Обработка автоматических платежей за серверы
* Запускается по расписанию каждые 6 часов
*/
async processAutoPayments() {
const now = new Date();
// Находим серверы, у которых пришло время оплаты
const serversDue = await prisma.server.findMany({
where: {
status: { in: ['running', 'stopped'] },
autoRenew: true,
nextPaymentDate: {
lte: now
}
},
include: {
user: true,
tariff: true
}
});
console.log(`[Payment Service] Найдено серверов для оплаты: ${serversDue.length}`);
for (const server of serversDue) {
try {
await this.chargeServerPayment(server);
} catch (error) {
console.error(`[Payment Service] Ошибка при списании за сервер ${server.id}:`, error);
}
}
}
/**
* Списание оплаты за конкретный сервер
*/
async chargeServerPayment(server: any) {
const amount = server.tariff.price;
const user = server.user;
// Проверяем достаточно ли средств
if (user.balance < amount) {
console.log(`[Payment Service] Недостаточно средств у пользователя ${user.id} для сервера ${server.id}`);
// Создаём запись о неудачном платеже
await prisma.payment.create({
data: {
userId: user.id,
serverId: server.id,
amount,
status: 'failed',
type: 'subscription',
processedAt: new Date()
}
});
// Отправляем уведомление
await prisma.notification.create({
data: {
userId: user.id,
title: 'Недостаточно средств',
message: `Не удалось списать ${amount}₽ за сервер #${server.id}. Пополните баланс, иначе сервер будет приостановлен.`
}
});
// Приостанавливаем сервер через 3 дня неоплаты
const daysSincePaymentDue = Math.floor((new Date().getTime() - server.nextPaymentDate.getTime()) / (1000 * 60 * 60 * 24));
if (daysSincePaymentDue >= 3) {
await prisma.server.update({
where: { id: server.id },
data: { status: 'suspended' }
});
await prisma.notification.create({
data: {
userId: user.id,
title: 'Сервер приостановлен',
message: `Сервер #${server.id} приостановлен из-за неоплаты.`
}
});
}
return;
}
// Списываем средства
const balanceBefore = user.balance;
const balanceAfter = balanceBefore - amount;
await prisma.$transaction([
// Обновляем баланс
prisma.user.update({
where: { id: user.id },
data: { balance: balanceAfter }
}),
// Создаём запись о платеже
prisma.payment.create({
data: {
userId: user.id,
serverId: server.id,
amount,
status: 'success',
type: 'subscription',
processedAt: new Date()
}
}),
// Записываем транзакцию
prisma.transaction.create({
data: {
userId: user.id,
amount: -amount,
type: 'withdrawal',
description: `Оплата сервера #${server.id} за месяц`,
balanceBefore,
balanceAfter
}
}),
// Обновляем дату следующего платежа (через 30 дней)
prisma.server.update({
where: { id: server.id },
data: {
nextPaymentDate: addDays(new Date(), 30)
}
})
]);
console.log(`[Payment Service] Успешно списано ${amount}с пользователя ${user.id} за сервер ${server.id}`);
// Отправляем уведомление
await prisma.notification.create({
data: {
userId: user.id,
title: 'Списание за сервер',
message: `Списано ${amount}₽ за сервер #${server.id}. Следующая оплата: ${addDays(new Date(), 30).toLocaleDateString('ru-RU')}`
}
});
}
/**
* Устанавливаем дату первого платежа при создании сервера
*/
async setInitialPaymentDate(serverId: number) {
await prisma.server.update({
where: { id: serverId },
data: {
nextPaymentDate: addDays(new Date(), 30)
}
});
}
}
export default new PaymentService();

View File

@@ -17,12 +17,26 @@ export async function changeRootPasswordSSH(vmid: number): Promise<{ status: str
import axios from 'axios';
import crypto from 'crypto';
import dotenv from 'dotenv';
import https from 'https';
dotenv.config();
const PROXMOX_API_URL = process.env.PROXMOX_API_URL;
const PROXMOX_TOKEN_ID = process.env.PROXMOX_TOKEN_ID;
const PROXMOX_TOKEN_SECRET = process.env.PROXMOX_TOKEN_SECRET;
const PROXMOX_NODE = process.env.PROXMOX_NODE || 'proxmox';
const PROXMOX_VM_STORAGE = process.env.PROXMOX_VM_STORAGE || 'local';
const PROXMOX_BACKUP_STORAGE = process.env.PROXMOX_BACKUP_STORAGE || 'local';
const PROXMOX_ISO_STORAGE = process.env.PROXMOX_ISO_STORAGE || 'local';
const PROXMOX_NETWORK_BRIDGE = process.env.PROXMOX_NETWORK_BRIDGE || 'vmbr0';
// HTTPS Agent с отключением проверки сертификата (для самоподписанного Proxmox)
const httpsAgent = new https.Agent({
rejectUnauthorized: false,
keepAlive: true,
maxSockets: 50,
maxFreeSockets: 10,
timeout: 60000
});
function getProxmoxHeaders(): Record<string, string> {
return {
@@ -46,7 +60,11 @@ export async function getNextVMID(): Promise<number> {
try {
const res = await axios.get(
`${PROXMOX_API_URL}/cluster/nextid`,
{ headers: getProxmoxHeaders() }
{
headers: getProxmoxHeaders(),
timeout: 15000, // 15 секунд
httpsAgent
}
);
return res.data.data || Math.floor(100 + Math.random() * 899);
} catch (error) {
@@ -59,16 +77,27 @@ export async function getNextVMID(): Promise<number> {
export interface CreateContainerParams {
os: { template: string; type: string };
tariff: { name: string; price: number; description?: string };
user: { id: number; username: string };
user: { id: number; username: string; email?: string };
hostname?: string;
}
export async function createLXContainer({ os, tariff, user }: CreateContainerParams) {
let vmid: number = 0;
let hostname: string = '';
try {
const vmid = await getNextVMID();
const rootPassword = generateSecurePassword();
// Используем hostname из параметров, если есть
const hostname = arguments[0].hostname || `user${user.id}-${tariff.name.toLowerCase().replace(/\s/g, '-')}`;
vmid = await getNextVMID();
const rootPassword = generateSecurePassword();
// Используем hostname из параметров, если есть
hostname = arguments[0].hostname;
if (!hostname) {
if (user.email) {
const emailName = user.email.split('@')[0];
hostname = `${emailName}-${vmid}`;
} else {
hostname = `user${user.id}-${vmid}`;
}
}
// Определяем ресурсы по названию тарифа (парсим описание)
const description = tariff.description || '1 ядро, 1ГБ RAM, 20ГБ SSD';
@@ -83,8 +112,8 @@ export async function createLXContainer({ os, tariff, user }: CreateContainerPar
ostemplate: os.template,
cores,
memory,
rootfs: `local:${diskSize}`,
net0: 'name=eth0,bridge=vmbr0,ip=dhcp',
rootfs: `${PROXMOX_VM_STORAGE}:${diskSize}`,
net0: `name=eth0,bridge=${PROXMOX_NETWORK_BRIDGE},ip=dhcp`,
unprivileged: 1,
start: 1, // Автостарт после создания
protection: 0,
@@ -94,11 +123,33 @@ export async function createLXContainer({ os, tariff, user }: CreateContainerPar
console.log('Создание LXC контейнера с параметрами:', containerConfig);
// Валидация перед отправкой
if (!containerConfig.ostemplate) {
throw new Error('OS template не задан');
}
if (containerConfig.cores < 1 || containerConfig.cores > 32) {
throw new Error(`Cores должно быть от 1 до 32, получено: ${containerConfig.cores}`);
}
if (containerConfig.memory < 512 || containerConfig.memory > 65536) {
throw new Error(`Memory должно быть от 512 до 65536 MB, получено: ${containerConfig.memory}`);
}
// Детальное логирование перед отправкой
console.log('URL Proxmox:', `${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc`);
console.log('Параметры контейнера (JSON):', JSON.stringify(containerConfig, null, 2));
console.log('Storage для VM:', PROXMOX_VM_STORAGE);
const response = await axios.post(
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc`,
containerConfig,
{ headers: getProxmoxHeaders() }
{
headers: getProxmoxHeaders(),
timeout: 120000, // 2 минуты для создания контейнера
httpsAgent
}
);
console.log('Ответ от Proxmox (создание):', response.status, response.data);
if (response.data?.data) {
// Polling статуса контейнера до running или timeout
@@ -129,7 +180,10 @@ async function getContainerStatus(vmid: number): Promise<{ status: string }> {
try {
const res = await axios.get(
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/status/current`,
{ headers: getProxmoxHeaders() }
{
headers: getProxmoxHeaders(),
httpsAgent
}
);
return { status: res.data.data.status };
} catch (error) {
@@ -139,10 +193,41 @@ async function getContainerStatus(vmid: number): Promise<{ status: string }> {
throw new Error('Не удалось создать контейнер');
} catch (error: any) {
console.error('Ошибка создания LXC контейнера:', error);
console.error('❌ ОШИБКА создания LXC контейнера:', error.message);
console.error(' Code:', error.code);
console.error(' Status:', error.response?.status);
console.error(' Response data:', error.response?.data);
// Логируем контекст ошибки
console.error(' VMID:', vmid);
console.error(' Hostname:', hostname);
console.error(' Storage используемый:', PROXMOX_VM_STORAGE);
console.error(' OS Template:', os.template);
// Специальная обработка socket hang up / ECONNRESET
const isSocketError = error?.code === 'ECONNRESET' ||
error?.message?.includes('socket hang up') ||
error?.cause?.code === 'ECONNRESET';
if (isSocketError) {
console.error('\n⚠ SOCKET HANG UP DETECTED!');
console.error(' Возможные причины:');
console.error(' 1. Storage "' + PROXMOX_VM_STORAGE + '" не существует на Proxmox');
console.error(' 2. API токен неверный или истёк');
console.error(' 3. Proxmox перегружена или недоступна');
console.error(' 4. Firewall блокирует соединение\n');
}
const errorMessage = isSocketError
? `Proxmox не ответил вовремя. Storage: ${PROXMOX_VM_STORAGE}. Проверьте доступность сервера и корректность конфигурации.`
: error.response?.data?.errors || error.message;
return {
status: 'error',
message: error.response?.data?.errors || error.message
message: errorMessage,
code: error?.code || error?.response?.status,
isSocketError,
storage: PROXMOX_VM_STORAGE
};
}
}
@@ -154,18 +239,34 @@ export async function getContainerIP(vmid: number): Promise<string | null> {
const response = await axios.get(
`${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/interfaces`,
{ headers: getProxmoxHeaders() }
{
headers: getProxmoxHeaders(),
httpsAgent
}
);
const interfaces = response.data?.data;
if (interfaces && interfaces.length > 0) {
// Сначала ищем локальный IP
for (const iface of interfaces) {
if (iface.inet && iface.inet !== '127.0.0.1') {
return iface.inet.split('/')[0]; // Убираем маску подсети
const ip = iface.inet.split('/')[0];
if (
ip.startsWith('10.') ||
ip.startsWith('192.168.') ||
(/^172\.(1[6-9]|2[0-9]|3[01])\./.test(ip))
) {
return ip;
}
}
}
// Если не нашли локальный, возвращаем первый не-127.0.0.1
for (const iface of interfaces) {
if (iface.inet && iface.inet !== '127.0.0.1') {
return iface.inet.split('/')[0];
}
}
}
return null;
} catch (error) {
console.error('Ошибка получения IP:', error);
@@ -540,12 +641,39 @@ export async function listContainers() {
}
}
// Получение списка доступных storage pools на узле
export async function getNodeStorages(node: string = PROXMOX_NODE) {
try {
const response = await axios.get(
`${PROXMOX_API_URL}/nodes/${node}/storage`,
{
headers: getProxmoxHeaders(),
timeout: 15000,
httpsAgent
}
);
return {
status: 'success',
data: response.data?.data || []
};
} catch (error: any) {
console.error('Ошибка получения storage:', error.message);
return {
status: 'error',
message: error.response?.data?.errors || error.message
};
}
}
// Проверка соединения с Proxmox
export async function checkProxmoxConnection() {
try {
const response = await axios.get(
`${PROXMOX_API_URL}/version`,
{ headers: getProxmoxHeaders() }
{
headers: getProxmoxHeaders(),
httpsAgent
}
);
if (response.data?.data) {
@@ -565,3 +693,17 @@ export async function checkProxmoxConnection() {
};
}
}
// Получение конфигурации storage через файл (обходим API если он недоступен)
export async function getStorageConfig(): Promise<{
configured: string;
available: string[];
note: string;
}> {
return {
configured: PROXMOX_VM_STORAGE,
available: ['local', 'local-lvm', 'vm-storage'],
note: `Текущее использование: ${PROXMOX_VM_STORAGE}. Если хранилище недоступно или socket hang up, проверьте что это имя существует в Proxmox (pvesm status)`
};
}

View File

@@ -37,10 +37,14 @@ export async function createServer(req: Request, res: Response) {
// Генерация hostname из email
let hostname = user.email.split('@')[0];
hostname = hostname.replace(/[^a-zA-Z0-9-]/g, '');
if (hostname.length < 3) hostname = `user${userId}`;
if (hostname.length > 32) hostname = hostname.slice(0, 32);
if (/^[0-9-]/.test(hostname)) hostname = `u${hostname}`;
// Нормализуем hostname: убираем недопустимые символы, приводим к нижнему регистру
hostname = hostname.replace(/[^a-zA-Z0-9-]/g, '').toLowerCase();
// Удалим ведущие и завершающие дефисы
hostname = hostname.replace(/^-+|-+$/g, '');
if (hostname.length < 3) hostname = `user${userId}`;
if (hostname.length > 32) hostname = hostname.slice(0, 32);
// Если начинается с цифры или дефиса — префикс
if (/^[0-9-]/.test(hostname)) hostname = `u${hostname}`;
// Создаём контейнер в Proxmox
const result = await createLXContainer({
@@ -52,21 +56,33 @@ export async function createServer(req: Request, res: Response) {
if (result.status !== 'success') {
// Возвращаем деньги обратно, если не удалось создать
await prisma.user.update({ where: { id: userId }, data: { balance: { increment: tariff.price } } });
// Логируем полный текст ошибки в файл
const fs = require('fs');
const logMsg = `[${new Date().toISOString()}] Ошибка Proxmox: ${JSON.stringify(result, null, 2)}\n`;
const errorMsg = result.message || JSON.stringify(result);
const isSocketError = errorMsg.includes('ECONNRESET') || errorMsg.includes('socket hang up');
const logMsg = `[${new Date().toISOString()}] Ошибка Proxmox при создании контейнера (userId=${userId}, hostname=${hostname}, vmid=${result.vmid || 'unknown'}): ${errorMsg}${isSocketError ? ' [SOCKET_ERROR - возможно таймаут]' : ''}\n`;
fs.appendFile('proxmox-errors.log', logMsg, (err: NodeJS.ErrnoException | null) => {
if (err) console.error('Ошибка записи лога:', err);
});
console.error('Ошибка Proxmox:', result.message);
console.error('Ошибка Proxmox при создании контейнера:', result);
return res.status(500).json({
error: 'Ошибка создания сервера в Proxmox',
details: result.message,
details: isSocketError
? 'Сервер Proxmox не ответил вовремя. Пожалуйста, попробуйте позже.'
: result.message,
fullError: result
});
}
// Сохраняем сервер в БД, статус всегда 'running' после покупки
// Устанавливаем дату следующего платежа через 30 дней
const nextPaymentDate = new Date();
nextPaymentDate.setDate(nextPaymentDate.getDate() + 30);
const server = await prisma.server.create({
data: {
userId,
@@ -76,8 +92,23 @@ export async function createServer(req: Request, res: Response) {
proxmoxId: Number(result.vmid),
ipAddress: result.ipAddress,
rootPassword: result.rootPassword,
nextPaymentDate,
autoRenew: true
}
});
// Создаём первую транзакцию о покупке
await prisma.transaction.create({
data: {
userId,
amount: -tariff.price,
type: 'withdrawal',
description: `Покупка сервера #${server.id}`,
balanceBefore: user.balance,
balanceAfter: user.balance - tariff.price
}
});
res.json(server);
} catch (error: any) {
console.error('Ошибка покупки сервера:', error);
@@ -89,7 +120,20 @@ export async function createServer(req: Request, res: Response) {
export async function getServerStatus(req: Request, res: Response) {
try {
const id = Number(req.params.id);
const server = await prisma.server.findUnique({ where: { id } });
const server = await prisma.server.findUnique({
where: { id },
include: {
tariff: true,
os: true,
user: {
select: {
id: true,
username: true,
email: true,
}
}
}
});
if (!server) return res.status(404).json({ error: 'Сервер не найден' });
if (!server.proxmoxId) return res.status(400).json({ error: 'Нет VMID Proxmox' });
const stats = await getContainerStats(server.proxmoxId);
@@ -197,11 +241,13 @@ export async function deleteServer(req: Request, res: Response) {
const id = Number(req.params.id);
const server = await prisma.server.findUnique({ where: { id } });
if (!server || !server.proxmoxId) return res.status(404).json({ error: 'Сервер не найден или нет VMID' });
// Удаляем контейнер в Proxmox
const proxmoxResult = await deleteContainer(server.proxmoxId);
if (proxmoxResult.status !== 'success') {
return res.status(500).json({ error: 'Ошибка удаления контейнера в Proxmox', details: proxmoxResult });
}
await prisma.server.delete({ where: { id } });
res.json({ status: 'deleted' });
} catch (error: any) {
@@ -238,7 +284,10 @@ export async function resizeServer(req: Request, res: Response) {
const config: any = {};
if (cores) config.cores = Number(cores);
if (memory) config.memory = Number(memory);
if (disk) config.rootfs = `local:${Number(disk)}`;
if (disk) {
const vmStorage = process.env.PROXMOX_VM_STORAGE || 'local';
config.rootfs = `${vmStorage}:${Number(disk)}`;
}
const result = await resizeContainer(server.proxmoxId, config);
res.json(result);

View File

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

View File

@@ -14,6 +14,7 @@ import {
rollbackServerSnapshot,
deleteServerSnapshot
} from './server.controller';
import { getStorageConfig, getNodeStorages, checkProxmoxConnection } from './proxmoxApi';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
@@ -83,5 +84,102 @@ router.post('/:id/snapshots', createServerSnapshot);
router.get('/:id/snapshots', getServerSnapshots);
router.post('/:id/snapshots/rollback', rollbackServerSnapshot);
router.delete('/:id/snapshots', deleteServerSnapshot);
import { getContainerStats } from './proxmoxApi';
import { getContainerLogs, getContainerEvents } from './server.logs';
// Диагностика: проверить конфигурацию storage
router.get('/admin/diagnostic/storage', async (req, res) => {
try {
const storageConfig = await getStorageConfig();
res.json({
configured_storage: storageConfig.configured,
note: storageConfig.note,
instruction: 'Если ошибка socket hang up, проверьте что PROXMOX_VM_STORAGE установлен правильно в .env'
});
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Диагностика: проверить соединение с Proxmox
router.get('/admin/diagnostic/proxmox', async (req, res) => {
try {
const connectionStatus = await checkProxmoxConnection();
const storages = await getNodeStorages();
res.json({
proxmox_connection: connectionStatus,
available_storages: storages.data || [],
current_storage_config: process.env.PROXMOX_VM_STORAGE || 'не установлена',
note: 'Если ошибка в available_storages, проверьте права API токена'
});
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Получить графики нагрузок сервера (CPU, RAM, сеть)
router.get('/:id/stats', async (req, res) => {
const id = Number(req.params.id);
// Проверка прав пользователя (только свои сервера)
const userId = req.user?.id;
const server = await prisma.server.findUnique({ where: { id } });
if (!server || server.userId !== userId) {
return res.status(404).json({ error: 'Сервер не найден или нет доступа' });
}
try {
if (!server.proxmoxId && server.proxmoxId !== 0) {
return res.status(400).json({ error: 'proxmoxId не задан для сервера' });
}
const stats = await getContainerStats(Number(server.proxmoxId));
res.json(stats);
} catch (err) {
res.status(500).json({ error: 'Ошибка получения статистики', details: err });
}
});
// Получить логи сервера
router.get('/:id/logs', async (req, res) => {
const id = Number(req.params.id);
const lines = req.query.lines ? Number(req.query.lines) : 100;
const userId = req.user?.id;
const server = await prisma.server.findUnique({ where: { id } });
if (!server || server.userId !== userId) {
return res.status(404).json({ error: 'Сервер не найден или нет доступа' });
}
try {
if (!server.proxmoxId && server.proxmoxId !== 0) {
return res.status(400).json({ error: 'proxmoxId не задан для сервера' });
}
const logs = await getContainerLogs(Number(server.proxmoxId), lines);
res.json(logs);
} catch (err) {
res.status(500).json({ error: 'Ошибка получения логов', details: err });
}
});
// Получить события/историю действий сервера
router.get('/:id/events', async (req, res) => {
const id = Number(req.params.id);
const userId = req.user?.id;
const server = await prisma.server.findUnique({ where: { id } });
if (!server || server.userId !== userId) {
return res.status(404).json({ error: 'Сервер не найден или нет доступа' });
}
try {
if (!server.proxmoxId && server.proxmoxId !== 0) {
return res.status(400).json({ error: 'proxmoxId не задан для сервера' });
}
const events = await getContainerEvents(Number(server.proxmoxId));
res.json(events);
} catch (err) {
res.status(500).json({ error: 'Ошибка получения событий', details: err });
}
});
export default router;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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