sitemap и тд
This commit is contained in:
223
ospabhost/backend/src/modules/account/account.controller.ts
Normal file
223
ospabhost/backend/src/modules/account/account.controller.ts
Normal 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 || 'Ошибка подтверждения' });
|
||||
}
|
||||
};
|
||||
65
ospabhost/backend/src/modules/account/account.routes.ts
Normal file
65
ospabhost/backend/src/modules/account/account.routes.ts
Normal 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;
|
||||
307
ospabhost/backend/src/modules/account/account.service.ts
Normal file
307
ospabhost/backend/src/modules/account/account.service.ts
Normal 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>© ${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;
|
||||
}
|
||||
364
ospabhost/backend/src/modules/admin/admin.controller.ts
Normal file
364
ospabhost/backend/src/modules/admin/admin.controller.ts
Normal 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();
|
||||
24
ospabhost/backend/src/modules/admin/admin.routes.ts
Normal file
24
ospabhost/backend/src/modules/admin/admin.routes.ts
Normal 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;
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
48
ospabhost/backend/src/modules/auth/oauth.routes.ts
Normal file
48
ospabhost/backend/src/modules/auth/oauth.routes.ts
Normal 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;
|
||||
137
ospabhost/backend/src/modules/auth/passport.config.ts
Normal file
137
ospabhost/backend/src/modules/auth/passport.config.ts
Normal 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;
|
||||
69
ospabhost/backend/src/modules/auth/turnstile.validator.ts
Normal file
69
ospabhost/backend/src/modules/auth/turnstile.validator.ts
Normal 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: 'Ошибка при проверке капчи',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
165
ospabhost/backend/src/modules/payment/payment.service.ts
Normal file
165
ospabhost/backend/src/modules/payment/payment.service.ts
Normal 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();
|
||||
@@ -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)`
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
153
ospabhost/backend/src/modules/server/server.logs.ts
Normal file
153
ospabhost/backend/src/modules/server/server.logs.ts
Normal 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: []
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
2
ospabhost/backend/src/modules/sitemap/index.ts
Normal file
2
ospabhost/backend/src/modules/sitemap/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import sitemapRoutes from './sitemap.routes';
|
||||
export default sitemapRoutes;
|
||||
57
ospabhost/backend/src/modules/sitemap/sitemap.controller.ts
Normal file
57
ospabhost/backend/src/modules/sitemap/sitemap.controller.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
8
ospabhost/backend/src/modules/sitemap/sitemap.routes.ts
Normal file
8
ospabhost/backend/src/modules/sitemap/sitemap.routes.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user