english version and minio console access

This commit is contained in:
Georgiy Syralev
2025-12-13 12:53:28 +03:00
parent 753696cc93
commit b799f278a4
47 changed files with 4386 additions and 1264 deletions

View File

@@ -8,6 +8,7 @@ import {
confirmAccountDeletion,
getUserInfo,
} from './account.service';
import { prisma } from '../../prisma/client';
/**
* Получить информацию о текущем пользователе
@@ -49,8 +50,6 @@ export const requestPasswordChangeHandler = async (req: Request, res: Response)
// Проверка текущего пароля
const bcrypt = require('bcrypt');
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
@@ -70,7 +69,7 @@ export const requestPasswordChangeHandler = async (req: Request, res: Response)
success: true,
message: 'Код подтверждения отправлен на вашу почту'
});
} catch (error: any) {
} catch (error: unknown) {
console.error('Ошибка запроса смены пароля:', error);
res.status(500).json({ error: error.message || 'Ошибка сервера' });
}
@@ -98,7 +97,7 @@ export const confirmPasswordChangeHandler = async (req: Request, res: Response)
success: true,
message: 'Пароль успешно изменён'
});
} catch (error: any) {
} catch (error: unknown) {
console.error('Ошибка подтверждения смены пароля:', error);
res.status(400).json({ error: error.message || 'Ошибка подтверждения' });
}
@@ -138,7 +137,7 @@ export const requestUsernameChangeHandler = async (req: Request, res: Response)
success: true,
message: 'Код подтверждения отправлен на вашу почту'
});
} catch (error: any) {
} catch (error: unknown) {
console.error('Ошибка запроса смены имени:', error);
res.status(400).json({ error: error.message || 'Ошибка сервера' });
}
@@ -166,7 +165,7 @@ export const confirmUsernameChangeHandler = async (req: Request, res: Response)
success: true,
message: 'Имя пользователя успешно изменено'
});
} catch (error: any) {
} catch (error: unknown) {
console.error('Ошибка подтверждения смены имени:', error);
res.status(400).json({ error: error.message || 'Ошибка подтверждения' });
}
@@ -188,7 +187,7 @@ export const requestAccountDeletionHandler = async (req: Request, res: Response)
success: true,
message: 'Код подтверждения отправлен на вашу почту. После подтверждения аккаунт будет удалён безвозвратно.'
});
} catch (error: any) {
} catch (error: unknown) {
console.error('Ошибка запроса удаления аккаунта:', error);
res.status(500).json({ error: error.message || 'Ошибка сервера' });
}
@@ -216,8 +215,9 @@ export const confirmAccountDeletionHandler = async (req: Request, res: Response)
success: true,
message: 'Аккаунт успешно удалён'
});
} catch (error: any) {
} catch (error: unknown) {
console.error('Ошибка подтверждения удаления аккаунта:', error);
res.status(400).json({ error: error.message || 'Ошибка подтверждения' });
}
};

View File

@@ -1,10 +1,9 @@
import { PrismaClient } from '@prisma/client';
import { prisma } from '../../prisma/client';
import bcrypt from 'bcrypt';
import crypto from 'crypto';
import nodemailer from 'nodemailer';
import { logger } from '../../utils/logger';
const prisma = new PrismaClient();
// Настройка транспорта для email
const transporter = nodemailer.createTransport({

View File

@@ -105,7 +105,17 @@ export class AdminController {
return res.status(404).json({ message: 'Пользователь не найден' });
}
res.json({ status: 'success', data: user });
const safeUser = {
...user,
balance: typeof user.balance === 'number' ? user.balance : Number(user.balance ?? 0),
buckets: user.buckets?.map((bucket: any) => ({
...bucket,
usedBytes: typeof bucket.usedBytes === 'bigint' ? Number(bucket.usedBytes) : Number(bucket.usedBytes ?? 0),
objectCount: typeof bucket.objectCount === 'number' ? bucket.objectCount : Number(bucket.objectCount ?? 0),
})) ?? [],
};
res.json({ status: 'success', data: safeUser });
} catch (error) {
console.error('Ошибка получения данных пользователя:', error);
res.status(500).json({ message: 'Ошибка получения данных' });
@@ -307,7 +317,7 @@ export class AdminController {
})
]);
const statusMap = bucketStatusCounts.reduce<Record<string, number>>((acc, item) => {
const statusMap = bucketStatusCounts.reduce<Record<string, number>>((acc: Record<string, number>, item: { status: string; _count: { _all: number } }) => {
acc[item.status] = item._count._all;
return acc;
}, {});
@@ -426,7 +436,6 @@ export class AdminController {
await tx.response.deleteMany({ where: { operatorId: userId } });
await tx.storageBucket.deleteMany({ where: { userId } });
await tx.plan.deleteMany({ where: { userId } });
await tx.ticket.deleteMany({ where: { userId } });
await tx.check.deleteMany({ where: { userId } });
@@ -467,16 +476,24 @@ export class AdminController {
return res.status(404).json({ error: 'Пользователь не найден' });
}
console.log(`[Admin] Тест push-уведомления инициирован администратором ${user.username}`);
const now = new Date().toISOString();
const logMsg = `[Admin] PUSH-TEST | userId=${user.id} | username=${user.username} | email=${user.email} | time=${now}`;
console.log(logMsg);
// Имитируем задержку отправки
// Здесь должна быть реальная отправка push (имитация)
await new Promise(resolve => setTimeout(resolve, 500));
return res.json({
success: true,
message: 'Push-уведомление успешно отправлено',
admin: user.username,
timestamp: new Date().toISOString()
message: 'Push-уведомление успешно отправлено (тест)',
details: {
userId: user.id,
username: user.username,
email: user.email,
type: 'push',
time: now,
status: 'sent (mock)'
}
});
} catch (error) {
console.error('[Admin] Ошибка при тестировании push-уведомления:', error);
@@ -497,18 +514,24 @@ export class AdminController {
return res.status(404).json({ error: 'Пользователь не найден' });
}
console.log(`[Admin] Тест email-уведомления инициирован администратором ${user.username}`);
console.log(`[Admin] Email для теста: ${user.email}`);
const now = new Date().toISOString();
const logMsg = `[Admin] EMAIL-TEST | userId=${user.id} | username=${user.username} | email=${user.email} | time=${now}`;
console.log(logMsg);
// Имитируем задержку отправки
// Здесь должна быть реальная отправка email (имитация)
await new Promise(resolve => setTimeout(resolve, 800));
return res.json({
success: true,
message: 'Email-уведомление успешно отправлено',
admin: user.username,
email: user.email,
timestamp: new Date().toISOString()
message: 'Email-уведомление успешно отправлено (тест)',
details: {
userId: user.id,
username: user.username,
email: user.email,
type: 'email',
time: now,
status: 'sent (mock)'
}
});
} catch (error) {
console.error('[Admin] Ошибка при тестировании email-уведомления:', error);

View File

@@ -1,11 +1,11 @@
import type { Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { PrismaClient } from '@prisma/client';
import { prisma } from '../../prisma/client';
import { validateTurnstileToken } from './turnstile.validator';
import { logger } from '../../utils/logger';
import { sendNewLoginEmail, sendWelcomeEmail } from '../notification/email.service';
const prisma = new PrismaClient();
const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_key';
export const register = async (req: Request, res: Response) => {
@@ -36,7 +36,7 @@ export const register = async (req: Request, res: Response) => {
const hashedPassword = await bcrypt.hash(password, 10);
await prisma.user.create({
const newUser = await prisma.user.create({
data: {
username,
email,
@@ -44,6 +44,11 @@ export const register = async (req: Request, res: Response) => {
},
});
// Отправляем приветственное письмо
sendWelcomeEmail(newUser.id).catch((err) => {
logger.error('Ошибка отправки приветственного письма:', err);
});
res.status(201).json({ message: 'Регистрация прошла успешно!' });
} catch (error) {
@@ -85,6 +90,18 @@ export const login = async (req: Request, res: Response) => {
const token = jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: '24h' });
// Отправляем уведомление о новом входе
const userAgent = req.headers['user-agent'] || '';
const ip = req.headers['x-forwarded-for'] as string || req.ip || req.connection.remoteAddress || '';
sendNewLoginEmail(user.id, {
ip: Array.isArray(ip) ? ip[0] : ip.split(',')[0].trim(),
userAgent,
time: new Date(),
}).catch((err) => {
logger.error('Ошибка отправки уведомления о входе:', err);
});
res.status(200).json({ token });
} catch (error) {

View File

@@ -5,9 +5,7 @@ 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();
import { prisma } from '../../prisma/client';
const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_key';
const OAUTH_CALLBACK_URL = process.env.OAUTH_CALLBACK_URL || 'https://api.ospab.host/api/auth';

View File

@@ -166,7 +166,10 @@ export const getPostByIdAdmin = async (req: Request, res: Response) => {
export const createPost = async (req: Request, res: Response) => {
try {
const { title, content, excerpt, coverImage, url, status } = req.body;
const authorId = req.user!.id; // user гарантированно есть после authMiddleware
const authorId = req.user?.id;
if (!authorId) {
return res.status(401).json({ success: false, message: 'Не авторизован' });
}
if (!title || !content || !url) {
return res.status(400).json({ success: false, message: 'Заполните обязательные поля' });

View File

@@ -82,7 +82,7 @@ export async function approveCheck(req: Request, res: Response) {
}
});
logger.info(`[Check] Чек #${checkId} подтверждён оператором ${req.user?.id}. Пользователь ${check.user.username} получил ${check.amount}`);
logger.info(`[Check] Чек #${checkId} подтверждён оператором ${req.user?.id}. Пользователь ${check.user.username} получил ${check.amount}`);
res.json({ success: true, message: 'Чек подтверждён, баланс пополнен' });
} catch (error) {
logger.error('[Check] Ошибка подтверждения чека:', error);
@@ -128,7 +128,7 @@ export async function rejectCheck(req: Request, res: Response) {
}
});
logger.info(`[Check] Чек #${checkId} отклонён оператором ${req.user?.id}. Пользователь: ${check.user.username}${comment ? `, причина: ${comment}` : ''}`);
logger.info(`[Check] Чек #${checkId} отклонён оператором ${req.user?.id}. Пользователь: ${check.user.username}${comment ? `, причина: ${comment}` : ''}`);
res.json({ success: true, message: 'Чек отклонён' });
} catch (error) {
logger.error('[Check] Ошибка отклонения чека:', error);

View File

@@ -1,8 +1,18 @@
import nodemailer from 'nodemailer';
import { PrismaClient } from '@prisma/client';
import { prisma } from '../../prisma/client';
import { logger } from '../../utils/logger';
import {
baseEmailTemplate,
emailHeader,
emailGreeting,
emailParagraph,
emailInfoBox,
emailButton,
emailSignature,
emailSecurityNote,
emailDivider,
} from './email.templates';
const prisma = new PrismaClient();
const FRONTEND_URL = process.env.FRONTEND_URL || 'https://ospab.host';
@@ -19,6 +29,9 @@ const transporter = nodemailer.createTransport({
}
});
export interface EmailNotification {
to: string;
subject: string;
@@ -26,7 +39,9 @@ export interface EmailNotification {
html?: string;
}
type SendEmailResult = { status: 'success'; messageId: string } | { status: 'skipped' | 'error'; message: string };
type SendEmailResult =
| { status: 'success'; messageId: string }
| { status: 'skipped' | 'error'; message: string };
// Отправка email уведомления
export async function sendEmail(notification: EmailNotification): Promise<SendEmailResult> {
@@ -38,15 +53,16 @@ export async function sendEmail(notification: EmailNotification): Promise<SendEm
}
const info = await transporter.sendMail({
from: `"Ospab Host" <${process.env.SMTP_USER}>`,
from: `"ospab.host" <${process.env.SMTP_USER}>`,
...notification
});
logger.info('Email sent: %s', info.messageId);
return { status: 'success', messageId: info.messageId };
} catch (error: any) {
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error('Error sending email:', error);
return { status: 'error', message: error.message };
return { status: 'error', message };
}
}
@@ -75,7 +91,7 @@ export async function sendNotificationEmail(params: SendGenericNotificationEmail
const { to, username, title, message, actionUrl } = params;
const resolvedActionUrl = resolveActionUrl(actionUrl);
const subject = `[Ospab Host] ${title}`.trim();
const subject = `[ospab.host] ${title}`.trim();
const plainTextLines = [
`Здравствуйте${username ? `, ${username}` : ''}!`,
@@ -87,7 +103,7 @@ export async function sendNotificationEmail(params: SendGenericNotificationEmail
plainTextLines.push('', `Перейти: ${resolvedActionUrl}`);
}
plainTextLines.push('', '— Команда Ospab Host');
plainTextLines.push('', '— Команда ospab.host');
const html = `
<div style="font-family: Arial, sans-serif; line-height: 1.6; color: #1f2933;">
@@ -104,7 +120,7 @@ export async function sendNotificationEmail(params: SendGenericNotificationEmail
</p>
` : ''}
<p style="margin-top: 24px; font-size: 12px; color: #6b7280;">Это автоматическое письмо. Не отвечайте на него.</p>
<p style="font-size: 12px; color: #6b7280;">— Команда Ospab Host</p>
<p style="font-size: 12px; color: #6b7280;">— Команда ospab.host</p>
</div>
`;
@@ -122,59 +138,80 @@ export async function sendResourceAlertEmail(userId: number, serverId: number, a
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) return { status: 'error', message: 'User not found' };
const subject = `Предупреждение: Высокая нагрузка на сервер #${serverId}`;
const html = `
<h2>Предупреждение о ресурсах сервера</h2>
<p>Здравствуйте, ${user.username}!</p>
<p>Обнаружено превышение лимитов ресурсов на вашем сервере #${serverId}:</p>
<ul>
<li><strong>Тип:</strong> ${alertType}</li>
<li><strong>Значение:</strong> ${value}</li>
</ul>
<p>Рекомендуем проверить сервер и при необходимости увеличить его ресурсы.</p>
<p>С уважением,<br>Команда Ospab Host</p>
`;
const subject = `Высокая нагрузка на сервер #${serverId}`;
const content = [
emailHeader({ icon: 'alert', title: 'Предупреждение о ресурсах', subtitle: `Сервер #${serverId}` }),
emailGreeting(user.username),
emailParagraph('Обнаружено превышение лимитов ресурсов на вашем сервере:'),
emailInfoBox({
type: 'warning',
items: [
{ label: 'Сервер', value: `#${serverId}` },
{ label: 'Тип предупреждения', value: alertType },
{ label: 'Текущее значение', value: value },
],
}),
emailParagraph('Рекомендуем проверить сервер и при необходимости увеличить его ресурсы или оптимизировать нагрузку.'),
emailButton({ text: 'Открыть панель управления', url: `${FRONTEND_URL}/dashboard/servers` }),
emailSignature(),
].join('');
return await sendEmail({
to: user.email,
subject,
html
const html = baseEmailTemplate({
title: subject,
preheader: `Обнаружена высокая нагрузка на сервер #${serverId}`,
content,
});
} catch (error: any) {
return await sendEmail({ to: user.email, subject, html });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error('Error sending resource alert email:', error);
return { status: 'error', message: error.message };
return { status: 'error', message };
}
}
// Отправка уведомления о создании сервера
export async function sendServerCreatedEmail(userId: number, serverId: number, serverDetails: any) {
export async function sendServerCreatedEmail(userId: number, serverId: number, serverDetails: {
tariff?: string;
os?: string;
ip?: string;
}) {
try {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) return { status: 'error', message: 'User not found' };
const subject = `Ваш сервер #${serverId} успешно создан`;
const html = `
<h2>Сервер успешно создан!</h2>
<p>Здравствуйте, ${user.username}!</p>
<p>Ваш новый сервер был успешно создан:</p>
<ul>
<li><strong>ID сервера:</strong> ${serverId}</li>
<li><strong>Тариф:</strong> ${serverDetails.tariff}</li>
<li><strong>ОС:</strong> ${serverDetails.os}</li>
<li><strong>IP адрес:</strong> ${serverDetails.ip || 'Получение...'}</li>
</ul>
<p>Вы можете управлять сервером через панель управления.</p>
<p>С уважением,<br>Команда Ospab Host</p>
`;
const content = [
emailHeader({ icon: 'success', title: 'Сервер создан!', subtitle: 'Готов к работе' }),
emailGreeting(user.username),
emailParagraph('Поздравляем! Ваш новый сервер был успешно создан и готов к использованию.'),
emailInfoBox({
type: 'success',
items: [
{ label: 'ID сервера', value: `#${serverId}` },
{ label: 'Тариф', value: serverDetails.tariff || 'Стандартный' },
{ label: 'Операционная система', value: serverDetails.os || 'Linux' },
{ label: 'IP адрес', value: serverDetails.ip || 'Назначается...' },
],
}),
emailParagraph('Вы можете управлять сервером через панель управления.'),
emailButton({ text: 'Перейти к серверу', url: `${FRONTEND_URL}/dashboard/servers/${serverId}` }),
emailSignature(),
].join('');
return await sendEmail({
to: user.email,
subject,
html
const html = baseEmailTemplate({
title: subject,
preheader: 'Ваш новый сервер готов к работе',
content,
});
} catch (error: any) {
return await sendEmail({ to: user.email, subject, html });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error('Error sending server created email:', error);
return { status: 'error', message: error.message };
return { status: 'error', message };
}
}
@@ -185,22 +222,320 @@ export async function sendPaymentReminderEmail(userId: number, serverId: number,
if (!user) return { status: 'error', message: 'User not found' };
const subject = `Напоминание: Оплата за сервер #${serverId}`;
const html = `
<h2>Напоминание об оплате</h2>
<p>Здравствуйте, ${user.username}!</p>
<p>До окончания срока действия вашего тарифа для сервера #${serverId} осталось ${daysLeft} дней.</p>
<p>Пожалуйста, пополните баланс, чтобы избежать прерывания обслуживания.</p>
<p>Ваш текущий баланс: ${user.balance}₽</p>
<p>С уважением,<br>Команда Ospab Host</p>
`;
const urgencyType = daysLeft <= 1 ? 'danger' : daysLeft <= 3 ? 'warning' : 'default';
const content = [
emailHeader({ icon: 'payment', title: 'Напоминание об оплате', subtitle: `Осталось ${daysLeft} дн.` }),
emailGreeting(user.username),
emailParagraph(`До окончания срока действия тарифа для сервера #${serverId} осталось <strong>${daysLeft} дней</strong>.`),
emailInfoBox({
type: urgencyType as 'default' | 'warning' | 'danger',
items: [
{ label: 'Сервер', value: `#${serverId}` },
{ label: 'Дней до окончания', value: `${daysLeft}` },
{ label: 'Ваш баланс', value: `${user.balance}` },
],
}),
emailParagraph('Пожалуйста, пополните баланс, чтобы избежать прерывания обслуживания.'),
emailButton({ text: 'Пополнить баланс', url: `${FRONTEND_URL}/dashboard/balance` }),
emailSignature(),
].join('');
return await sendEmail({
to: user.email,
subject,
html
const html = baseEmailTemplate({
title: subject,
preheader: `Осталось ${daysLeft} дней до окончания срока оплаты`,
content,
});
} catch (error: any) {
return await sendEmail({ to: user.email, subject, html });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error('Error sending payment reminder email:', error);
return { status: 'error', message: error.message };
return { status: 'error', message };
}
}
// ===== S3 EMAILS =====
/**
* Уведомление о создании S3-бакета
*/
export async function sendS3BucketCreatedEmail(userId: number, bucketName: string, region: string) {
try {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) return { status: 'error', message: 'User not found' };
const subject = `Ваш S3-бакет "${bucketName}" создан`;
const content = [
emailHeader({ icon: 'storage', title: 'Бакет создан!', subtitle: bucketName }),
emailGreeting(user.username),
emailParagraph('Поздравляем! Ваш новый S3-бакет успешно создан и готов к использованию.'),
emailInfoBox({
type: 'success',
items: [
{ label: 'Название бакета', value: bucketName },
{ label: 'Регион', value: region },
{ label: 'Endpoint', value: 's3.ospab.host' },
],
}),
emailParagraph('Вы можете управлять бакетом через панель управления или использовать S3-совместимые инструменты.'),
emailButton({ text: 'Открыть хранилище', url: `${FRONTEND_URL}/dashboard/storage` }),
emailSignature(),
].join('');
const html = baseEmailTemplate({
title: subject,
preheader: `Бакет ${bucketName} готов к использованию`,
content,
});
return await sendEmail({ to: user.email, subject, html });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error('Error sending S3 bucket created email:', error);
return { status: 'error', message };
}
}
/**
* Уведомление о превышении квоты S3-бакета
*/
export async function sendS3QuotaAlertEmail(userId: number, bucketName: string, usage: string, quota: string) {
try {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) return { status: 'error', message: 'User not found' };
const subject = `Превышение квоты S3-бакета "${bucketName}"`;
const content = [
emailHeader({ icon: 'alert', title: 'Превышение квоты', subtitle: bucketName }),
emailGreeting(user.username),
emailParagraph('Обнаружено превышение квоты хранилища. Загрузка новых файлов может быть ограничена.'),
emailInfoBox({
type: 'warning',
items: [
{ label: 'Бакет', value: bucketName },
{ label: 'Использовано', value: usage },
{ label: 'Квота', value: quota },
],
}),
emailParagraph('Пожалуйста, освободите место или увеличьте тариф для продолжения работы.'),
emailButton({ text: 'Управление хранилищем', url: `${FRONTEND_URL}/dashboard/storage` }),
emailSignature(),
].join('');
const html = baseEmailTemplate({
title: subject,
preheader: `Бакет ${bucketName} превысил квоту`,
content,
});
return await sendEmail({ to: user.email, subject, html });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error('Error sending S3 quota alert email:', error);
return { status: 'error', message };
}
}
/**
* Уведомление об удалении S3-бакета
*/
export async function sendS3BucketDeletedEmail(userId: number, bucketName: string) {
try {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) return { status: 'error', message: 'User not found' };
const subject = `S3-бакет "${bucketName}" удалён`;
const content = [
emailHeader({ icon: 'info', title: 'Бакет удалён', subtitle: bucketName }),
emailGreeting(user.username),
emailParagraph(`Ваш S3-бакет <strong>${bucketName}</strong> был успешно удалён.`),
emailInfoBox({
type: 'default',
items: [
{ label: 'Бакет', value: bucketName },
{ label: 'Статус', value: 'Удалён' },
],
}),
emailSecurityNote('Если вы не удаляли этот бакет, немедленно свяжитесь с поддержкой.'),
emailButton({ text: 'Создать новый бакет', url: `${FRONTEND_URL}/dashboard/storage` }),
emailSignature(),
].join('');
const html = baseEmailTemplate({
title: subject,
preheader: `Бакет ${bucketName} был удалён`,
content,
});
return await sendEmail({ to: user.email, subject, html });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error('Error sending S3 bucket deleted email:', error);
return { status: 'error', message };
}
}
// ===== SECURITY EMAILS =====
/**
* Уведомление о новом входе в аккаунт
*/
export async function sendNewLoginEmail(userId: number, loginDetails: {
ip?: string;
userAgent?: string;
location?: string;
time?: Date;
}) {
try {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) return { status: 'error', message: 'User not found' };
const { ip, userAgent, location, time } = loginDetails;
const loginTime = time || new Date();
const formattedTime = loginTime.toLocaleString('ru-RU', {
timeZone: 'Europe/Moscow',
dateStyle: 'long',
timeStyle: 'short',
});
// Определяем браузер и ОС из User-Agent
let browser = 'Неизвестный браузер';
let os = 'Неизвестная ОС';
if (userAgent) {
if (userAgent.includes('Chrome')) browser = 'Chrome';
else if (userAgent.includes('Firefox')) browser = 'Firefox';
else if (userAgent.includes('Safari')) browser = 'Safari';
else if (userAgent.includes('Edge')) browser = 'Edge';
else if (userAgent.includes('Opera')) browser = 'Opera';
if (userAgent.includes('Windows')) os = 'Windows';
else if (userAgent.includes('Mac')) os = 'macOS';
else if (userAgent.includes('Linux')) os = 'Linux';
else if (userAgent.includes('Android')) os = 'Android';
else if (userAgent.includes('iPhone') || userAgent.includes('iPad')) os = 'iOS';
}
const subject = `Новый вход в ваш аккаунт`;
const content = [
emailHeader({ icon: 'shield', title: 'Новый вход в аккаунт', subtitle: 'Уведомление безопасности' }),
emailGreeting(user.username),
emailParagraph('Зафиксирован новый вход в ваш аккаунт ospab.host:'),
emailInfoBox({
type: 'default',
items: [
{ label: 'Время', value: formattedTime },
{ label: 'IP адрес', value: ip || 'Неизвестен' },
{ label: 'Браузер', value: browser },
{ label: 'Операционная система', value: os },
...(location ? [{ label: 'Местоположение', value: location }] : []),
],
}),
emailSecurityNote('Если это были вы, можете проигнорировать это письмо. Если вы не входили в аккаунт, немедленно смените пароль и свяжитесь с поддержкой.'),
emailButton({ text: 'Проверить активные сессии', url: `${FRONTEND_URL}/dashboard/settings` }),
emailDivider(),
emailParagraph('Для вашей безопасности мы рекомендуем использовать сложные пароли и не передавать данные для входа третьим лицам.'),
emailSignature(),
].join('');
const html = baseEmailTemplate({
title: subject,
preheader: `Новый вход в аккаунт ${formattedTime}`,
content,
});
return await sendEmail({ to: user.email, subject, html });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error('Error sending new login email:', error);
return { status: 'error', message };
}
}
/**
* Уведомление о регистрации (приветственное письмо)
*/
export async function sendWelcomeEmail(userId: number) {
try {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) return { status: 'error', message: 'User not found' };
const subject = `Добро пожаловать в ospab.host!`;
const content = [
emailHeader({ icon: 'success', title: 'Добро пожаловать!', subtitle: 'Регистрация успешна' }),
emailGreeting(user.username),
emailParagraph('Благодарим вас за регистрацию в ospab.host! Теперь вам доступны все возможности нашего облачного хранилища.'),
emailInfoBox({
type: 'success',
items: [
{ label: 'Ваш email', value: user.email },
{ label: 'Имя пользователя', value: user.username },
{ label: 'Дата регистрации', value: new Date().toLocaleDateString('ru-RU') },
],
}),
emailParagraph('Что вы можете сделать:'),
emailParagraph('• Создать S3-совместимое хранилище<br>• Загружать и управлять файлами<br>• Использовать API для интеграции'),
emailButton({ text: 'Начать работу', url: `${FRONTEND_URL}/dashboard` }),
emailSignature(),
].join('');
const html = baseEmailTemplate({
title: subject,
preheader: 'Ваш аккаунт успешно создан',
content,
});
return await sendEmail({ to: user.email, subject, html });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error('Error sending welcome email:', error);
return { status: 'error', message };
}
}
/**
* Уведомление о пополнении баланса
*/
export async function sendBalanceTopUpEmail(userId: number, amount: number, newBalance: number) {
try {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) return { status: 'error', message: 'User not found' };
const subject = `Баланс пополнен на ${amount} руб.`;
const content = [
emailHeader({ icon: 'payment', title: 'Баланс пополнен', subtitle: `+${amount}` }),
emailGreeting(user.username),
emailParagraph('Ваш баланс успешно пополнен.'),
emailInfoBox({
type: 'success',
items: [
{ label: 'Сумма пополнения', value: `${amount}` },
{ label: 'Текущий баланс', value: `${newBalance}` },
{ label: 'Дата', value: new Date().toLocaleString('ru-RU') },
],
}),
emailParagraph('Средства доступны для оплаты услуг.'),
emailButton({ text: 'Перейти в панель', url: `${FRONTEND_URL}/dashboard` }),
emailSignature(),
].join('');
const html = baseEmailTemplate({
title: subject,
preheader: `Баланс пополнен на ${amount} руб.`,
content,
});
return await sendEmail({ to: user.email, subject, html });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error('Error sending balance top-up email:', error);
return { status: 'error', message };
}
}

View File

@@ -0,0 +1,352 @@
/**
* Красивые HTML-шаблоны для email уведомлений
* Стиль основан на дизайне главной страницы ospab.host
*/
const FRONTEND_URL = process.env.FRONTEND_URL || 'https://ospab.host';
// Основные цвета бренда
const COLORS = {
primary: '#4f46e5', // ospab-primary (indigo)
accent: '#6366f1', // ospab-accent
dark: '#1f2937', // gray-800
light: '#f9fafb', // gray-50
text: '#374151', // gray-700
textLight: '#6b7280', // gray-500
border: '#e5e7eb', // gray-200
success: '#10b981', // green-500
warning: '#f59e0b', // amber-500
danger: '#ef4444', // red-500
info: '#3b82f6', // blue-500
};
/**
* Базовый шаблон письма
*/
export function baseEmailTemplate(options: {
title: string;
preheader?: string;
content: string;
footerText?: string;
}): string {
const { title, preheader, content, footerText } = options;
return `
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>${title}</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<style>
body { margin: 0; padding: 0; background-color: ${COLORS.light}; }
table { border-collapse: collapse; }
img { border: 0; display: block; }
a { color: ${COLORS.primary}; text-decoration: none; }
a:hover { text-decoration: underline; }
.button { display: inline-block; padding: 14px 28px; background-color: ${COLORS.primary}; color: #ffffff !important; border-radius: 8px; font-weight: 600; text-decoration: none; }
.button:hover { background-color: ${COLORS.accent}; }
@media only screen and (max-width: 600px) {
.container { width: 100% !important; padding: 0 16px !important; }
.content { padding: 24px 20px !important; }
}
</style>
</head>
<body style="margin: 0; padding: 0; background-color: ${COLORS.light}; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
${preheader ? `<div style="display: none; max-height: 0; overflow: hidden;">${preheader}</div>` : ''}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: ${COLORS.light};">
<tr>
<td align="center" style="padding: 40px 20px;">
<table role="presentation" class="container" width="600" cellpadding="0" cellspacing="0" style="max-width: 600px;">
<!-- Header -->
<tr>
<td align="center" style="padding-bottom: 32px;">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="background: linear-gradient(135deg, ${COLORS.primary} 0%, ${COLORS.accent} 100%); padding: 16px 32px; border-radius: 12px;">
<a href="${FRONTEND_URL}" style="color: #ffffff; font-size: 24px; font-weight: 700; text-decoration: none; letter-spacing: -0.5px;">
ospab.host
</a>
</td>
</tr>
</table>
</td>
</tr>
<!-- Main Content Card -->
<tr>
<td>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 16px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);">
<tr>
<td class="content" style="padding: 40px;">
${content}
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding-top: 32px; text-align: center;">
<p style="margin: 0 0 16px 0; color: ${COLORS.textLight}; font-size: 14px;">
${footerText || 'Это автоматическое письмо от ospab.host'}
</p>
<table role="presentation" cellpadding="0" cellspacing="0" style="margin: 0 auto;">
<tr>
<td style="padding: 0 8px;">
<a href="${FRONTEND_URL}" style="color: ${COLORS.textLight}; font-size: 13px;">Сайт</a>
</td>
<td style="color: ${COLORS.border};">|</td>
<td style="padding: 0 8px;">
<a href="${FRONTEND_URL}/dashboard" style="color: ${COLORS.textLight}; font-size: 13px;">Панель управления</a>
</td>
<td style="color: ${COLORS.border};">|</td>
<td style="padding: 0 8px;">
<a href="${FRONTEND_URL}/dashboard/tickets" style="color: ${COLORS.textLight}; font-size: 13px;">Поддержка</a>
</td>
</tr>
</table>
<p style="margin: 24px 0 0 0; color: ${COLORS.border}; font-size: 12px;">
© ${new Date().getFullYear()} ospab.host. Все права защищены.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`.trim();
}
/**
* Заголовок с иконкой
*/
export function emailHeader(options: {
icon: 'shield' | 'server' | 'storage' | 'payment' | 'alert' | 'success' | 'info';
title: string;
subtitle?: string;
}): string {
const { icon, title, subtitle } = options;
const iconColors: Record<string, string> = {
shield: COLORS.info,
server: COLORS.primary,
storage: COLORS.accent,
payment: COLORS.success,
alert: COLORS.warning,
success: COLORS.success,
info: COLORS.info,
};
const iconSvgs: Record<string, string> = {
shield: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
server: '<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>',
storage: '<path d="M22 12H2"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
payment: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>',
alert: '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
success: '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>',
info: '<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>',
};
const color = iconColors[icon] || COLORS.primary;
const svg = iconSvgs[icon] || iconSvgs.info;
return `
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center" style="padding-bottom: 24px;">
<div style="width: 64px; height: 64px; background-color: ${color}15; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
${svg}
</svg>
</div>
</td>
</tr>
<tr>
<td align="center">
<h1 style="margin: 0 0 8px 0; font-size: 24px; font-weight: 700; color: ${COLORS.dark};">
${title}
</h1>
${subtitle ? `<p style="margin: 0; font-size: 16px; color: ${COLORS.textLight};">${subtitle}</p>` : ''}
</td>
</tr>
</table>
`;
}
/**
* Приветственный блок
*/
export function emailGreeting(username?: string | null): string {
return `
<p style="margin: 24px 0 0 0; font-size: 16px; color: ${COLORS.text}; line-height: 1.6;">
Здравствуйте${username ? `, <strong>${username}</strong>` : ''}!
</p>
`;
}
/**
* Текстовый параграф
*/
export function emailParagraph(text: string): string {
return `
<p style="margin: 16px 0 0 0; font-size: 16px; color: ${COLORS.text}; line-height: 1.6;">
${text}
</p>
`;
}
/**
* Информационный блок (карточка с данными)
*/
export function emailInfoBox(options: {
items: Array<{ label: string; value: string }>;
type?: 'default' | 'warning' | 'success' | 'danger';
}): string {
const { items, type = 'default' } = options;
const bgColors: Record<string, string> = {
default: '#f3f4f6',
warning: '#fef3c7',
success: '#d1fae5',
danger: '#fee2e2',
};
const borderColors: Record<string, string> = {
default: COLORS.border,
warning: '#fcd34d',
success: '#6ee7b7',
danger: '#fca5a5',
};
return `
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 24px 0 0 0; background-color: ${bgColors[type]}; border: 1px solid ${borderColors[type]}; border-radius: 12px;">
<tr>
<td style="padding: 20px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
${items.map((item, index) => `
<tr>
<td style="padding: ${index > 0 ? '12px' : '0'} 0 0 0;">
<span style="font-size: 13px; color: ${COLORS.textLight}; text-transform: uppercase; letter-spacing: 0.5px;">${item.label}</span>
<p style="margin: 4px 0 0 0; font-size: 15px; font-weight: 600; color: ${COLORS.dark};">${item.value}</p>
</td>
</tr>
`).join('')}
</table>
</td>
</tr>
</table>
`;
}
/**
* Кнопка действия
*/
export function emailButton(options: {
text: string;
url: string;
type?: 'primary' | 'secondary';
}): string {
const { text, url, type = 'primary' } = options;
const bgColor = type === 'primary' ? COLORS.primary : 'transparent';
const textColor = type === 'primary' ? '#ffffff' : COLORS.primary;
const border = type === 'primary' ? 'none' : `2px solid ${COLORS.primary}`;
return `
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 32px 0 0 0;">
<tr>
<td align="center">
<a href="${url}" style="display: inline-block; padding: 14px 32px; background-color: ${bgColor}; color: ${textColor} !important; border: ${border}; border-radius: 8px; font-size: 16px; font-weight: 600; text-decoration: none;">
${text}
</a>
</td>
</tr>
</table>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 16px 0 0 0;">
<tr>
<td align="center">
<p style="margin: 0; font-size: 12px; color: ${COLORS.textLight};">
Если кнопка не работает, скопируйте ссылку:<br>
<a href="${url}" style="color: ${COLORS.primary}; word-break: break-all;">${url}</a>
</p>
</td>
</tr>
</table>
`;
}
/**
* Разделитель
*/
export function emailDivider(): string {
return `
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 24px 0;">
<tr>
<td style="border-top: 1px solid ${COLORS.border};"></td>
</tr>
</table>
`;
}
/**
* Подпись
*/
export function emailSignature(): string {
return `
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 32px 0 0 0;">
<tr>
<td>
<p style="margin: 0; font-size: 15px; color: ${COLORS.text};">
С уважением,<br>
<strong>Команда ospab.host</strong>
</p>
</td>
</tr>
</table>
`;
}
/**
* Предупреждение о безопасности
*/
export function emailSecurityNote(text: string): string {
return `
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 24px 0 0 0; background-color: #fef3c7; border: 1px solid #fcd34d; border-radius: 8px;">
<tr>
<td style="padding: 16px;">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="vertical-align: top; padding-right: 12px;">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="${COLORS.warning}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
</td>
<td>
<p style="margin: 0; font-size: 14px; color: ${COLORS.dark};">
${text}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
`;
}

View File

@@ -80,7 +80,8 @@ const ensureNotificationSettings = async (userId: number) => {
// Получить все уведомления пользователя с пагинацией
export const getNotifications = async (req: Request, res: Response) => {
try {
const userId = req.user!.id;
const userId = req.user?.id;
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
const { page = '1', limit = '20', filter = 'all' } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(limit as string);
@@ -121,7 +122,8 @@ export const getNotifications = async (req: Request, res: Response) => {
// Получить количество непрочитанных уведомлений
export const getUnreadCount = async (req: Request, res: Response) => {
try {
const userId = req.user!.id;
const userId = req.user?.id;
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
const count = await prisma.notification.count({
where: {
@@ -140,7 +142,8 @@ export const getUnreadCount = async (req: Request, res: Response) => {
// Пометить уведомление как прочитанное
export const markAsRead = async (req: Request, res: Response) => {
try {
const userId = req.user!.id;
const userId = req.user?.id;
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
const { id } = req.params;
const notification = await prisma.notification.findFirst({
@@ -179,7 +182,8 @@ export const markAsRead = async (req: Request, res: Response) => {
// Пометить все уведомления как прочитанные
export const markAllAsRead = async (req: Request, res: Response) => {
try {
const userId = req.user!.id;
const userId = req.user?.id;
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
await prisma.notification.updateMany({
where: {
@@ -199,7 +203,8 @@ export const markAllAsRead = async (req: Request, res: Response) => {
// Удалить уведомление
export const deleteNotification = async (req: Request, res: Response) => {
try {
const userId = req.user!.id;
const userId = req.user?.id;
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
const { id } = req.params;
const notification = await prisma.notification.findFirst({
@@ -237,7 +242,8 @@ export const deleteNotification = async (req: Request, res: Response) => {
// Удалить все прочитанные уведомления
export const deleteAllRead = async (req: Request, res: Response) => {
try {
const userId = req.user!.id;
const userId = req.user?.id;
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
await prisma.notification.deleteMany({
where: {
@@ -369,7 +375,8 @@ export const getVapidKey = async (req: Request, res: Response) => {
// Подписаться на Push-уведомления
export const subscribe = async (req: Request, res: Response) => {
try {
const userId = req.user!.id;
const userId = req.user?.id;
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
const { subscription } = req.body;
const userAgent = req.headers['user-agent'];
@@ -389,7 +396,8 @@ export const subscribe = async (req: Request, res: Response) => {
// Отписаться от Push-уведомлений
export const unsubscribe = async (req: Request, res: Response) => {
try {
const userId = req.user!.id;
const userId = req.user?.id;
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
const { endpoint } = req.body;
if (!endpoint) {
@@ -408,7 +416,8 @@ export const unsubscribe = async (req: Request, res: Response) => {
// Тестовая отправка Push-уведомления (только для админов)
export const testPushNotification = async (req: Request, res: Response) => {
try {
const userId = req.user!.id;
const userId = req.user?.id;
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
const user = req.user!;
logger.log('[TEST PUSH] Запрос от пользователя:', { userId, username: user.username });
@@ -532,8 +541,9 @@ export const testPushNotification = async (req: Request, res: Response) => {
// Тестовая отправка Email-уведомления (только для админов)
export const testEmailNotification = async (req: Request, res: Response) => {
try {
const userId = req.user!.id;
const user = req.user!;
const user = req.user;
if (!user) return res.status(401).json({ success: false, message: 'Не авторизован' });
const userId = user.id as number;
if (!user.isAdmin) {
return res.status(403).json({

View File

@@ -67,13 +67,13 @@ class PaymentService {
return;
}
const { bucket: updatedBucket, balanceBefore, balanceAfter } = await prisma.$transaction(async (tx) => {
const { updatedBucket, balanceBefore, balanceAfter } = await prisma.$transaction(async (tx) => {
const user = await tx.user.findUnique({ where: { id: bucket.userId } });
if (!user) throw new Error('Пользователь не найден');
if (user.balance < bucket.monthlyPrice) {
// Баланс мог измениться между выборкой и транзакцией
return { bucket, balanceBefore: user.balance, balanceAfter: user.balance };
return { updatedBucket: null, balanceBefore: user.balance, balanceAfter: user.balance };
}
const newBalance = user.balance - bucket.monthlyPrice;
@@ -106,10 +106,10 @@ class PaymentService {
}
});
return { bucket: updated, balanceBefore: user.balance, balanceAfter: newBalance };
return { updatedBucket: updated, balanceBefore: user.balance, balanceAfter: newBalance };
});
if (balanceBefore === balanceAfter) {
if (balanceBefore === balanceAfter || !updatedBucket) {
// Значит баланс поменялся внутри транзакции, пересоздадим попытку в следующий цикл
await this.handleInsufficientFunds(bucket, now);
return;

View File

@@ -30,6 +30,18 @@ export function buildPhysicalBucketName(userId: number, logicalName: string): st
return `${prefix}-${userId}-${logicalName}`.toLowerCase();
}
export function isMinioAuthError(err: unknown): boolean {
if (!err || typeof err !== 'object') return false;
const message = (err instanceof Error ? err.message : String(err)).toLowerCase();
return message.includes('invalidaccesskeyid') || message.includes('accesskeyid') || message.includes('invalid access key') || message.includes('signaturedoesnotmatch') || message.includes('signature does not match');
}
export function isMinioNoSuchBucketError(err: unknown): boolean {
if (!err || typeof err !== 'object') return false;
const message = (err instanceof Error ? err.message : String(err)).toLowerCase();
return message.includes('nosuchbucket') || message.includes('notfound') || message.includes('no such bucket');
}
export async function ensureBucketExists(bucketName: string, region: string): Promise<void> {
try {
const exists = await minioClient.bucketExists(bucketName);
@@ -37,6 +49,17 @@ export async function ensureBucketExists(bucketName: string, region: string): Pr
await minioClient.makeBucket(bucketName, region);
}
} catch (err: unknown) {
if (isMinioAuthError(err)) {
// Provide a more actionable error message for auth issues
const e = new Error(`MinIO authentication failed for endpoint ${MINIO_ENDPOINT}:${MINIO_PORT}. Check MINIO_ACCESS_KEY/MINIO_SECRET_KEY environment variables.`);
(e as any).code = 'MINIO_AUTH_ERROR';
throw e;
}
if (isMinioNoSuchBucketError(err)) {
const e = new Error(`MinIO bucket error: bucket ${bucketName} not found or inaccessible.`);
(e as any).code = 'MINIO_BUCKET_NOT_FOUND';
throw e;
}
throw err;
}
}

View File

@@ -15,11 +15,11 @@ import {
listStoragePlans,
createCheckoutSession,
getCheckoutSession,
applyPromoToCheckoutSession,
markCheckoutSessionConsumed,
listStorageRegions,
listStorageClasses,
getStorageStatus,
generateConsoleCredentials
} from './storage.service';
import { authMiddleware, optionalAuthMiddleware } from '../auth/auth.middleware';
@@ -110,7 +110,7 @@ router.get('/status', async (_req, res) => {
router.post('/checkout', optionalAuthMiddleware, async (req, res) => {
try {
const userId = (req as any).user?.id ?? null;
const userId = req.user?.id ?? null;
const { planCode, planId, customGb } = req.body ?? {};
const numericPlanId = typeof planId === 'number'
@@ -138,7 +138,7 @@ router.use(authMiddleware);
router.get('/cart/:id', async (req, res) => {
try {
const userId = (req as any).user?.id;
const userId = req.user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
const cartId = req.params.id;
const result = await getCheckoutSession(cartId, userId);
@@ -149,10 +149,26 @@ router.get('/cart/:id', async (req, res) => {
}
});
// Apply promo code to a cart
router.post('/cart/:id/apply-promo', authMiddleware, async (req, res) => {
try {
const cartId = req.params.id;
const { promoCode } = req.body ?? {};
const userId = req.user?.id;
if (!promoCode || typeof promoCode !== 'string') return res.status(400).json({ error: 'promoCode required' });
const result = await applyPromoToCheckoutSession(cartId, promoCode.trim(), userId ?? null);
return res.json({ success: true, cart: result });
} catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Не удалось применить промокод';
return res.status(400).json({ error: message });
}
});
// Создание бакета
router.post('/buckets', async (req, res) => {
try {
const userId = (req as any).user?.id;
const userId = req.user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
const { name, cartId, region, storageClass, public: isPublic, versioning } = req.body;
@@ -175,6 +191,7 @@ router.post('/buckets', async (req, res) => {
storageClass: storageClass || 'standard',
public: !!isPublic,
versioning: !!versioning
, cartId
});
await markCheckoutSessionConsumed(cartId);
@@ -190,7 +207,8 @@ router.post('/buckets', async (req, res) => {
// Список бакетов пользователя
router.get('/buckets', async (req, res) => {
try {
const userId = (req as any).user?.id;
const userId = req.user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
const buckets = await listBuckets(userId);
return res.json({ buckets });
@@ -199,35 +217,13 @@ router.get('/buckets', async (req, res) => {
}
});
router.post('/buckets/:id/console-credentials', async (req, res) => {
try {
const userId = (req as any).user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'Некорректный идентификатор бакета' });
}
const credentials = await generateConsoleCredentials(userId, id);
return res.json({ credentials });
} catch (e: unknown) {
let message = 'Не удалось сгенерировать данные входа';
let statusCode = 400;
if (e instanceof Error) {
message = e.message;
// Check for rate limit error
if ((e as any).status === 429) {
statusCode = 429;
}
}
return res.status(statusCode).json({ error: message });
}
});
// Роут console-credentials удалён — используйте access keys
// Детали одного бакета
router.get('/buckets/:id', async (req, res) => {
try {
const userId = (req as any).user?.id;
const userId = req.user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
const id = Number(req.params.id);
const bucket = await getBucket(userId, id);
@@ -242,7 +238,8 @@ router.get('/buckets/:id', async (req, res) => {
// Обновление настроек бакета
router.patch('/buckets/:id', async (req, res) => {
try {
const userId = (req as any).user?.id;
const userId = req.user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
const id = Number(req.params.id);
const bucket = await updateBucketSettings(userId, id, req.body ?? {});
@@ -257,7 +254,8 @@ router.patch('/buckets/:id', async (req, res) => {
// Удаление бакета
router.delete('/buckets/:id', async (req, res) => {
try {
const userId = (req as any).user?.id;
const userId = req.user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
const id = Number(req.params.id);
const force = req.query.force === 'true';
@@ -273,7 +271,8 @@ router.delete('/buckets/:id', async (req, res) => {
// Список объектов в бакете
router.get('/buckets/:id/objects', async (req, res) => {
try {
const userId = (req as any).user?.id;
const userId = req.user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
const id = Number(req.params.id);
const { prefix, cursor, limit } = req.query;
@@ -323,34 +322,72 @@ router.post('/buckets/:id/objects/download-from-uri', async (req, res) => {
const id = Number(req.params.id);
const { url } = req.body ?? {};
if (!url) return res.status(400).json({ error: 'Не указан URL' });
console.log(`[Storage URI Download] Начало загрузки - userId: ${userId}, bucketId: ${id}, url: ${url}`);
if (!url) {
console.log('[Storage URI Download] Ошибка: URL не указан');
return res.status(400).json({ error: 'Не указан URL' });
}
// Проверяем что пользователь имеет доступ к бакету
console.log('[Storage URI Download] Проверка доступа к бакету...');
await getBucket(userId, id); // Проверка доступа
console.log('[Storage URI Download] Доступ к бакету подтверждён');
// Загружаем файл с URL с увеличенным timeout
console.log(`[Storage URI Download] Загрузка файла с ${url}...`);
const response = await axios.get(url, {
responseType: 'arraybuffer',
timeout: 120000, // 120 seconds (2 minutes)
maxContentLength: 5 * 1024 * 1024 * 1024, // 5GB max
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
},
});
const mimeType = response.headers['content-type'] || 'application/octet-stream';
const buffer = response.data;
const bufferSize = Buffer.isBuffer(buffer) ? buffer.length : (buffer as ArrayBuffer).byteLength;
console.log(`[Storage URI Download] Файл загружен успешно - размер: ${bufferSize} байт, mimeType: ${mimeType}`);
// Конвертируем в base64
const base64Data = Buffer.isBuffer(buffer) ? buffer.toString('base64') : Buffer.from(buffer).toString('base64');
console.log(`[Storage URI Download] Base64 длина: ${base64Data.length} символов`);
return res.json({
blob: buffer.toString('base64'),
blob: base64Data,
mimeType,
});
} catch (e: unknown) {
let message = 'Ошибка загрузки файла по URI';
if (e instanceof Error) {
if (e.message.includes('timeout')) {
console.error('[Storage URI Download] Ошибка:', e);
if (axios.isAxiosError(e)) {
console.error('[Storage URI Download] Axios ошибка:', {
status: e.response?.status,
statusText: e.response?.statusText,
headers: e.response?.headers,
code: e.code,
message: e.message,
});
if (e.code === 'ECONNABORTED' || e.message.includes('timeout')) {
message = 'Превышено время ожидания при загрузке файла. Попробуйте позже.';
} else if (e.response?.status === 403) {
message = 'Доступ к файлу запрещён (403). Проверьте, что ссылка публичная.';
} else if (e.response?.status === 404) {
message = 'Файл не найден (404). Проверьте правильность URL.';
} else if (e.response?.status) {
message = `Ошибка загрузки (${e.response.status}): ${e.response.statusText || e.message}`;
} else {
message = e.message;
message = `Ошибка соединения: ${e.message}`;
}
} else if (e instanceof Error) {
message = e.message;
}
console.error('[Storage URI Download] Возвращаем ошибку клиенту:', message);
return res.status(400).json({ error: message });
}
});

View File

@@ -18,10 +18,76 @@ const MINIO_USE_SSL = process.env.MINIO_USE_SSL === 'true';
const MINIO_ADMIN_URL = `http${MINIO_USE_SSL ? 's' : ''}://${MINIO_ENDPOINT}:${MINIO_PORT}`;
// For mc CLI calls
const MINIO_ALIAS = 'minio';
// MINIO_MC_ALIAS - если указан, используем существующий alias (уже настроенный через mc alias set)
// Если не указан, создаём свой alias 'minio-ospab'
const MINIO_ALIAS = process.env.MINIO_MC_ALIAS || 'minio-ospab';
const MINIO_USE_EXISTING_ALIAS = Boolean(process.env.MINIO_MC_ALIAS); // Не перенастраивать, если указан явно
const MINIO_MC_ENABLED = process.env.MINIO_MC_ENABLED !== 'false'; // Enable by default
const execAsync = promisify(exec);
// Track if alias was set up in this process
let minioAliasConfigured = false;
/**
* Escape special characters for shell commands
*/
function escapeShellArg(arg: string): string {
// For Windows/PowerShell and Unix shells, use single quotes and escape single quotes inside
// But mc on Windows works better with double quotes, so we escape special chars
return arg
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\$/g, '\\$')
.replace(/`/g, '\\`')
.replace(/!/g, '\\!');
}
/**
* Ensure mc alias is configured with correct credentials
* If MINIO_MC_ALIAS is set in env, uses existing alias without reconfiguration
* Otherwise creates/updates alias with credentials from env
*/
async function ensureMinioAlias(): Promise<void> {
if (minioAliasConfigured) {
return; // Already configured in this process
}
// If using existing alias from env, just mark as configured and verify it works
if (MINIO_USE_EXISTING_ALIAS) {
try {
// Quick check that alias exists and works
await execAsync(`mc admin info ${MINIO_ALIAS}`, { timeout: 10000 });
console.info(`[MinIO Admin] Using existing alias: ${MINIO_ALIAS}`);
minioAliasConfigured = true;
return;
} catch (error) {
console.error(`[MinIO Admin] Existing alias "${MINIO_ALIAS}" not working:`, (error as Error).message);
throw new Error(`mc alias "${MINIO_ALIAS}" не настроен или не работает. Настройте вручную: mc alias set ${MINIO_ALIAS} <url> <access> <secret>`);
}
}
// Create new alias with credentials from env
try {
// Remove existing alias first (ignore errors if it doesn't exist)
try {
await execAsync(`mc alias rm ${MINIO_ALIAS}`, { timeout: 5000 });
} catch {
// Ignore - alias might not exist
}
// Set up fresh alias with current credentials - escape special characters
const escapedAccessKey = escapeShellArg(MINIO_ACCESS_KEY);
const escapedSecretKey = escapeShellArg(MINIO_SECRET_KEY);
const setupAliasCmd = `mc alias set ${MINIO_ALIAS} "${MINIO_ADMIN_URL}" "${escapedAccessKey}" "${escapedSecretKey}" --api S3v4`;
const { stdout, stderr } = await execAsync(setupAliasCmd, { timeout: 10000 });
console.info(`[MinIO Admin] Alias configured:`, stdout.trim() || stderr.trim() || 'OK');
minioAliasConfigured = true;
} catch (error) {
console.error('[MinIO Admin] Failed to configure alias:', (error as Error).message);
throw new Error(`Не удалось настроить подключение к MinIO: ${(error as Error).message}`);
}
}
const bucketIncludeBase = {
storagePlan: true,
regionConfig: true,
@@ -39,6 +105,7 @@ interface CreateBucketInput {
storageClass: string;
public: boolean;
versioning: boolean;
cartId?: string;
}
interface UpdateBucketInput {
@@ -161,6 +228,15 @@ async function ensureConsoleCredentialSupport(client: any = prisma): Promise<boo
consoleCredentialSupport = true;
return true;
} catch (error) {
// If the error is a MinIO authentication error or bucket not found, surface a clear message and skip cleanup
if ((error as any)?.code === 'MINIO_AUTH_ERROR') {
console.error('[Storage] MinIO authentication error while creating bucket — check MINIO_ACCESS_KEY/MINIO_SECRET_KEY');
throw new Error('MinIO authentication failed. Пожалуйста, проверьте настройки MINIO_ACCESS_KEY и MINIO_SECRET_KEY.');
}
if ((error as any)?.code === 'MINIO_BUCKET_NOT_FOUND') {
console.warn('[Storage] MinIO reports bucket inaccessible or not found during create; skipping cleanup');
throw new Error('MinIO bucket not found or inaccessible. Проверьте доступность MinIO и права доступа.');
}
if (isConsoleCredentialError(error)) {
consoleCredentialSupport = false;
logConsoleWarning(error);
@@ -242,6 +318,8 @@ type CheckoutSessionRecord = {
createdAt: Date;
expiresAt: Date;
consumedAt?: Date | null;
promoCodeId?: number | null;
promoDiscount?: number | null;
};
function addDays(date: Date, days: number): Date {
@@ -307,20 +385,8 @@ async function createMinioUser(username: string, password: string): Promise<void
}
try {
// Setup mc alias with explicit S3v4 signature
// The key is to add the --api S3v4 flag
const setupAliasCmd = `mc alias set ${MINIO_ALIAS} "${MINIO_ADMIN_URL}" "${MINIO_ACCESS_KEY}" "${MINIO_SECRET_KEY}" --api S3v4`;
try {
const { stdout, stderr } = await execAsync(setupAliasCmd, { timeout: 5000 });
console.info(`[MinIO Admin] Alias setup:`, stdout.trim() || stderr.trim());
} catch (err: unknown) {
// Alias might already exist, that's okay
const errorMsg = (err as Error).message;
if (!errorMsg.includes('exists')) {
console.warn('[MinIO Admin] Warning setting up alias:', errorMsg);
}
}
// Setup mc alias - remove first to ensure fresh credentials
await ensureMinioAlias();
// Create or update user
const createUserCmd = `mc admin user add ${MINIO_ALIAS} "${username}" "${password}"`;
@@ -360,6 +426,110 @@ async function createMinioUser(username: string, password: string): Promise<void
}
}
/**
* Create a service account (access key) in MinIO with policy restricted to a specific bucket
* Uses mc admin user add + policy assignment
*/
async function createMinioServiceAccount(accessKey: string, secretKey: string, bucketName: string): Promise<void> {
if (!MINIO_MC_ENABLED) {
console.warn(`[MinIO Admin] mc CLI disabled, skipping service account creation for ${accessKey}`);
return;
}
try {
// Setup mc alias - ensure fresh credentials
await ensureMinioAlias();
// Create user with access key and secret key
const createUserCmd = `mc admin user add ${MINIO_ALIAS} "${accessKey}" "${secretKey}"`;
try {
const { stdout, stderr } = await execAsync(createUserCmd, { timeout: 10000 });
console.info(`[MinIO Admin] Service account ${accessKey} created:`, stdout.trim() || stderr.trim());
} catch (error: unknown) {
const errorMsg = (error as Record<string, any>)?.stderr || (error as Error)?.message || '';
if (!errorMsg.includes('already exists') && !errorMsg.includes('exists')) {
throw error;
}
console.warn(`[MinIO Admin] User ${accessKey} already exists`);
}
// Create bucket-specific policy JSON
const policyName = `policy-${bucketName}`;
const policyJson = JSON.stringify({
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Action: [
's3:GetBucketLocation',
's3:ListBucket',
's3:ListBucketMultipartUploads',
],
Resource: [`arn:aws:s3:::${bucketName}`],
},
{
Effect: 'Allow',
Action: [
's3:GetObject',
's3:PutObject',
's3:DeleteObject',
's3:ListMultipartUploadParts',
's3:AbortMultipartUpload',
],
Resource: [`arn:aws:s3:::${bucketName}/*`],
},
],
});
// Write policy to temp file and add to MinIO
const fs = await import('fs/promises');
const os = await import('os');
const path = await import('path');
const tmpDir = os.tmpdir();
const policyFile = path.join(tmpDir, `minio-policy-${bucketName}.json`);
await fs.writeFile(policyFile, policyJson, 'utf8');
try {
// Add policy to MinIO
const addPolicyCmd = `mc admin policy create ${MINIO_ALIAS} "${policyName}" "${policyFile}"`;
try {
const { stdout } = await execAsync(addPolicyCmd, { timeout: 10000 });
console.info(`[MinIO Admin] Policy ${policyName} created:`, stdout.trim());
} catch (policyError: unknown) {
const policyErrMsg = (policyError as Record<string, any>)?.stderr || (policyError as Error)?.message || '';
// Policy might already exist, try to update it
if (policyErrMsg.includes('already exists') || policyErrMsg.includes('exists')) {
console.info(`[MinIO Admin] Policy ${policyName} already exists, skipping...`);
} else {
console.warn(`[MinIO Admin] Policy creation warning:`, policyErrMsg);
}
}
// Attach policy to user
const attachPolicyCmd = `mc admin policy attach ${MINIO_ALIAS} "${policyName}" --user "${accessKey}"`;
const { stdout: attachOut } = await execAsync(attachPolicyCmd, { timeout: 10000 });
console.info(`[MinIO Admin] Policy ${policyName} attached to user ${accessKey}:`, attachOut.trim());
} finally {
// Cleanup temp file
try {
await fs.unlink(policyFile);
} catch {
// Ignore cleanup errors
}
}
console.info(`[MinIO Admin] Service account ${accessKey} created with access to bucket ${bucketName}`);
} catch (error) {
if (error instanceof Error) {
console.error('[MinIO Admin] Error creating service account:', error.message);
throw new Error(`Не удалось создать ключ доступа в MinIO: ${error.message}`);
}
throw error;
}
}
async function generateLogicalBucketName(userId: number, username: string, requestedName: string): Promise<string> {
const prefix = (process.env.MINIO_BUCKET_PREFIX || 'ospab').toLowerCase();
const userSegment = String(userId);
@@ -423,6 +593,14 @@ function storageClassDelegate() {
return delegate as any;
}
function promoCodeDelegate(client: any = prisma) {
const delegate = (client as any).promoCode;
if (!delegate) {
throw new Error('PromoCode модель недоступна. Выполните prisma generate, чтобы обновить клиент.');
}
return delegate as any;
}
function serializePlan(plan: StoragePlanRecord) {
return {
id: plan.id,
@@ -591,6 +769,54 @@ export async function createCheckoutSession(params: { planCode?: string; planId?
return toCheckoutPayload(session, plan);
}
export async function applyPromoToCheckoutSession(cartId: string, promoCode: string, userId?: number | null) {
// Load session
const session = await checkoutSessionDelegate().findUnique({ where: { id: cartId } }) as CheckoutSessionRecord | null;
if (!session) throw new Error('Корзина не найдена');
if (session.consumedAt) throw new Error('Корзина уже использована');
if (session.expiresAt.getTime() <= Date.now()) throw new Error('Корзина просрочена');
// Find promo
const promo = await promoCodeDelegate().findUnique({ where: { code: promoCode } });
if (!promo) throw new Error('Неверный промокод');
if (promo.used) throw new Error('Промокод уже использован');
// Ensure only owner of session (if set) can apply promo
if (session.userId && userId && session.userId !== userId) {
throw new Error('Нет прав на изменение корзины');
}
// Apply discount
const discountedPrice = Math.max(0, toPlainNumber(session.price) - Number(promo.amount));
const updated = await checkoutSessionDelegate().update({
where: { id: cartId },
data: {
price: discountedPrice,
promoCodeId: promo.id,
promoDiscount: Number(promo.amount),
},
}) as CheckoutSessionRecord;
return {
cartId: updated.id,
plan: {
id: updated.planId,
code: updated.planCode,
name: updated.planName,
price: updated.price,
quotaGb: updated.quotaGb,
bandwidthGb: updated.bandwidthGb,
requestLimit: updated.requestLimit,
description: updated.planDescription,
order: 0,
isActive: true,
},
price: updated.price,
expiresAt: updated.expiresAt.toISOString(),
} as CheckoutSessionPayload;
}
export async function getCheckoutSession(cartId: string, userId: number): Promise<CheckoutSessionResult> {
const session = await checkoutSessionDelegate().findUnique({
where: { id: cartId },
@@ -628,9 +854,18 @@ export async function getCheckoutSession(cartId: string, userId: number): Promis
export async function markCheckoutSessionConsumed(cartId: string) {
try {
await checkoutSessionDelegate().update({
where: { id: cartId },
data: { consumedAt: new Date() },
const session = await checkoutSessionDelegate().findUnique({ where: { id: cartId } }) as CheckoutSessionRecord | null;
if (!session) throw new Error('Корзина не найдена');
await prisma.$transaction(async (tx) => {
const checkoutDelegate = (tx as any).storageCheckoutSession;
if (checkoutDelegate) {
await checkoutDelegate.update({ where: { id: cartId }, data: { consumedAt: new Date() } });
}
if (session.promoCodeId) {
const promoDelegate = promoCodeDelegate(tx);
await promoDelegate.update({ where: { id: session.promoCodeId }, data: { used: true, usedBy: session.userId ?? undefined, usedAt: new Date() } });
}
});
} catch (error) {
console.warn(`[Storage] Не удалось пометить корзину ${cartId} как использованную`, error);
@@ -840,6 +1075,12 @@ export async function createBucket(data: CreateBucketInput) {
}) as StoragePlanRecord | null;
if (!plan) throw new Error('Тариф не найден или отключён');
const planPrice = toPlainNumber(plan.price);
// If cart price differs due to applied promo, use session price for charging
let sessionPrice = planPrice;
if (data.cartId) {
const session = await checkoutSessionDelegate().findUnique({ where: { id: data.cartId } }) as CheckoutSessionRecord | null;
if (session) sessionPrice = Number(session.price);
}
const regionCode = data.region.trim();
if (!regionCode) throw new Error('Регион обязателен');
@@ -868,7 +1109,7 @@ export async function createBucket(data: CreateBucketInput) {
const user = await prisma.user.findUnique({ where: { id: data.userId } });
if (!user) throw new Error('Пользователь не найден');
if (toPlainNumber(user.balance) < planPrice) throw new Error('Недостаточно средств');
if (toPlainNumber(user.balance) < sessionPrice) throw new Error('Недостаточно средств');
const logicalName = await generateLogicalBucketName(data.userId, user.username, data.name);
const physicalName = buildPhysicalBucketName(data.userId, logicalName);
@@ -883,20 +1124,20 @@ export async function createBucket(data: CreateBucketInput) {
await ensureBucketExists(physicalName, regionCode);
try {
const createdBucket = await prisma.$transaction(async (tx) => {
const createdBucket = await prisma.$transaction<BucketWithPlan>(async (tx) => {
const reloadedUser = await tx.user.findUnique({ where: { id: data.userId } });
if (!reloadedUser) throw new Error('Пользователь не найден');
if (toPlainNumber(reloadedUser.balance) < planPrice) throw new Error('Недостаточно средств');
const updatedUser = await tx.user.update({
where: { id: data.userId },
data: { balance: reloadedUser.balance - Number(plan.price) }
data: { balance: reloadedUser.balance - Number(sessionPrice) }
});
await tx.transaction.create({
data: {
userId: data.userId,
amount: -plan.price,
amount: -sessionPrice,
type: 'withdrawal',
description: `Создание S3 бакета ${logicalName} (${plan.name})`,
balanceBefore: reloadedUser.balance,
@@ -984,7 +1225,14 @@ export async function createBucket(data: CreateBucketInput) {
}
await minioClient.removeBucket(physicalName);
} catch (cleanupError) {
console.error('[Storage] Ошибка очистки бакета после неудачного создания', cleanupError);
// If cleanup fails due to auth or missing bucket, avoid spamming logs with stack traces
if ((cleanupError as any)?.code === 'MINIO_AUTH_ERROR') {
console.error('[Storage] Cleanup skipped due to MinIO authentication error');
} else if ((cleanupError as any)?.code === 'MINIO_BUCKET_NOT_FOUND') {
console.warn('[Storage] Cleanup skipped, bucket not found');
} else {
console.error('[Storage] Ошибка очистки бакета после неудачного создания', cleanupError);
}
}
throw error;
}
@@ -1099,7 +1347,21 @@ export async function deleteBucket(userId: number, id: number, force = false) {
const bucket = await fetchBucket(userId, id);
const physicalName = buildPhysicalBucketName(bucket.userId, bucket.name);
const keys = await collectObjectKeys(physicalName);
let keys: string[] = [];
try {
keys = await collectObjectKeys(physicalName);
} catch (err: unknown) {
if ((err as any)?.code === 'MINIO_AUTH_ERROR') {
console.error('[Storage] MinIO authentication error while deleting bucket — aborting deletion');
throw new Error('MinIO authentication failed. Пожалуйста, проверьте настройки MINIO_ACCESS_KEY и MINIO_SECRET_KEY.');
}
if ((err as any)?.code === 'MINIO_BUCKET_NOT_FOUND') {
console.warn('[Storage] Bucket not found in MinIO while attempting delete; continuing with DB cleanup');
keys = [];
} else {
throw err;
}
}
if (keys.length > 0 && !force) {
throw new Error('Невозможно удалить непустой бакет. Удалите объекты или используйте force=true');
}
@@ -1109,11 +1371,35 @@ export async function deleteBucket(userId: number, id: number, force = false) {
keys.slice(idx * 1000, (idx + 1) * 1000)
);
for (const chunk of chunks) {
await minioClient.removeObjects(physicalName, chunk);
try {
await minioClient.removeObjects(physicalName, chunk);
} catch (err: unknown) {
if ((err as any)?.code === 'MINIO_AUTH_ERROR') {
console.error('[Storage] MinIO authentication error while deleting objects');
throw new Error('MinIO authentication failed. Пожалуйста, проверьте настройки MINIO_ACCESS_KEY и MINIO_SECRET_KEY.');
}
if ((err as any)?.code === 'MINIO_BUCKET_NOT_FOUND') {
console.warn('[Storage] Bucket not found while deleting objects; skipping');
break;
}
throw err;
}
}
}
await minioClient.removeBucket(physicalName);
try {
await minioClient.removeBucket(physicalName);
} catch (err: unknown) {
if ((err as any)?.code === 'MINIO_AUTH_ERROR') {
console.error('[Storage] MinIO authentication error while removing bucket');
throw new Error('MinIO authentication failed. Пожалуйста, проверьте настройки MINIO_ACCESS_KEY и MINIO_SECRET_KEY.');
}
if ((err as any)?.code === 'MINIO_BUCKET_NOT_FOUND') {
console.warn('[Storage] Bucket not found when attempting to remove; continuing with DB cleanup');
} else {
throw err;
}
}
await prisma.storageAccessKey.deleteMany({ where: { bucketId: bucket.id } });
const deleted = await prisma.storageBucket.delete({ where: { id: bucket.id } });
@@ -1289,9 +1575,23 @@ export async function deleteObjects(userId: number, id: number, keys: string[])
export async function createEphemeralKey(userId: number, id: number, label?: string) {
const bucket = await fetchBucket(userId, id);
// Проверяем, есть ли уже ключ для этого бакета (разрешён только один)
const existingKeys = await prisma.storageAccessKey.count({
where: { bucketId: bucket.id }
});
if (existingKeys > 0) {
throw new Error('Для этого хранилища уже существует ключ доступа. Удалите существующий ключ, чтобы создать новый.');
}
const accessKey = `S3${crypto.randomBytes(8).toString('hex')}`;
const secretKey = crypto.randomBytes(32).toString('hex');
// Создаём пользователя в MinIO с этими ключами
const physicalBucketName = buildPhysicalBucketName(bucket.userId, bucket.name);
await createMinioServiceAccount(accessKey, secretKey, physicalBucketName);
const record = await prisma.storageAccessKey.create({
data: {
bucketId: bucket.id,
@@ -1328,10 +1628,57 @@ export async function listAccessKeys(userId: number, id: number) {
export async function revokeAccessKey(userId: number, id: number, keyId: number) {
const bucket = await fetchBucket(userId, id);
await prisma.storageAccessKey.deleteMany({
// Получаем ключ перед удалением, чтобы знать accessKey
const keyRecord = await prisma.storageAccessKey.findFirst({
where: { id: keyId, bucketId: bucket.id }
});
if (!keyRecord) {
throw new Error('Ключ не найден');
}
// Удаляем пользователя из MinIO
await deleteMinioServiceAccount(keyRecord.accessKey);
// Удаляем запись из БД
await prisma.storageAccessKey.delete({
where: { id: keyId }
});
return { revoked: true };
}
/**
* Delete a service account (user) from MinIO
*/
async function deleteMinioServiceAccount(accessKey: string): Promise<void> {
if (!MINIO_MC_ENABLED) {
console.warn(`[MinIO Admin] mc CLI disabled, skipping service account deletion for ${accessKey}`);
return;
}
try {
// Setup mc alias
await ensureMinioAlias();
// Remove user from MinIO
const removeUserCmd = `mc admin user rm ${MINIO_ALIAS} "${accessKey}"`;
try {
const { stdout } = await execAsync(removeUserCmd, { timeout: 10000 });
console.info(`[MinIO Admin] Service account ${accessKey} removed:`, stdout.trim());
} catch (error: unknown) {
const errorMsg = (error as Record<string, any>)?.stderr || (error as Error)?.message || '';
// User might not exist, that's okay
if (!errorMsg.includes('does not exist') && !errorMsg.includes('not found')) {
console.warn(`[MinIO Admin] Warning removing user ${accessKey}:`, errorMsg);
}
}
} catch (error) {
// Non-critical - user will be orphaned in MinIO but key removed from DB
console.error('[MinIO Admin] Error deleting service account:', (error as Error).message);
}
}

View File

@@ -150,7 +150,11 @@ export const uploadTicketFiles = multer({
// Создать тикет
export async function createTicket(req: Request, res: Response) {
const { title, message, category = 'general', priority = 'normal' } = req.body;
const userId = Number((req as any).user?.id);
const userIdRaw = req.user?.id;
if (!userIdRaw) {
return res.status(401).json({ error: 'Нет авторизации' });
}
const userId = Number(userIdRaw);
if (!userId) {
return res.status(401).json({ error: 'Нет авторизации' });
@@ -201,8 +205,10 @@ export async function createTicket(req: Request, res: Response) {
// Получить тикеты (клиент — свои, оператор — все с фильтрами)
export async function getTickets(req: Request, res: Response) {
const userId = Number((req as any).user?.id);
const isOperator = Number((req as any).user?.operator) === 1;
const userIdRaw = req.user?.id;
if (!userIdRaw) return res.status(401).json({ error: 'Нет авторизации' });
const userId = Number(userIdRaw);
const isOperator = Number(req.user?.operator ?? 0) === 1;
const {
status,
category,
@@ -307,7 +313,7 @@ export async function getTickets(req: Request, res: Response) {
const normalizedTickets = tickets.map((ticket) => serializeTicket(ticket, assignedOperatorsMap));
const statusMap = statusBuckets.reduce<Record<string, number>>((acc, bucket) => {
const statusMap = statusBuckets.reduce<Record<string, number>>((acc: Record<string, number>, bucket: { status: string; _count: { _all: number } }) => {
acc[bucket.status] = bucket._count._all;
return acc;
}, {});
@@ -344,8 +350,10 @@ export async function getTickets(req: Request, res: Response) {
// Получить один тикет по ID
export async function getTicketById(req: Request, res: Response) {
const ticketId = Number(req.params.id);
const userId = Number((req as any).user?.id);
const isOperator = Number((req as any).user?.operator) === 1;
const userIdRaw = req.user?.id;
if (!userIdRaw) return res.status(401).json({ error: 'Нет авторизации' });
const userId = Number(userIdRaw);
const isOperator = Number(req.user?.operator ?? 0) === 1;
if (!userId) {
return res.status(401).json({ error: 'Нет авторизации' });
@@ -409,8 +417,10 @@ export async function getTicketById(req: Request, res: Response) {
// Ответить на тикет (клиент или оператор)
export async function respondTicket(req: Request, res: Response) {
const { ticketId, message, isInternal = false } = req.body;
const actorId = Number((req as any).user?.id);
const isOperator = Number((req as any).user?.operator) === 1;
const actorIdRaw = req.user?.id;
if (!actorIdRaw) return res.status(401).json({ error: 'Нет авторизации' });
const actorId = Number(actorIdRaw);
const isOperator = Number(req.user?.operator ?? 0) === 1;
if (!actorId) {
return res.status(401).json({ error: 'Нет авторизации' });
@@ -501,8 +511,10 @@ export async function respondTicket(req: Request, res: Response) {
// Изменить статус тикета (только оператор)
export async function updateTicketStatus(req: Request, res: Response) {
const { ticketId, status } = req.body;
const userId = Number((req as any).user?.id);
const isOperator = Number((req as any).user?.operator) === 1;
const userIdRaw = req.user?.id;
if (!userIdRaw) return res.status(403).json({ error: 'Нет прав' });
const userId = Number(userIdRaw);
const isOperator = Number(req.user?.operator ?? 0) === 1;
if (!userId || !isOperator) {
return res.status(403).json({ error: 'Нет прав' });
@@ -569,8 +581,10 @@ export async function updateTicketStatus(req: Request, res: Response) {
// Назначить тикет на оператора (только оператор)
export async function assignTicket(req: Request, res: Response) {
const { ticketId, operatorId } = req.body;
const userId = Number((req as any).user?.id);
const isOperator = Number((req as any).user?.operator) === 1;
const userIdRaw = req.user?.id;
if (!userIdRaw) return res.status(403).json({ error: 'Нет прав' });
const userId = Number(userIdRaw);
const isOperator = Number(req.user?.operator ?? 0) === 1;
if (!userId || !isOperator) {
return res.status(403).json({ error: 'Нет прав' });
@@ -638,8 +652,10 @@ export async function assignTicket(req: Request, res: Response) {
// Закрыть тикет (клиент или оператор)
export async function closeTicket(req: Request, res: Response) {
const { ticketId } = req.body;
const userId = Number((req as any).user?.id);
const isOperator = Number((req as any).user?.operator) === 1;
const userIdRaw = req.user?.id;
if (!userIdRaw) return res.status(401).json({ error: 'Нет авторизации' });
const userId = Number(userIdRaw);
const isOperator = Number(req.user?.operator ?? 0) === 1;
if (!userId) {
return res.status(401).json({ error: 'Нет авторизации' });

View File

@@ -6,7 +6,8 @@ import crypto from 'crypto';
// Получить профиль пользователя (расширенный)
export const getProfile = async (req: Request, res: Response) => {
try {
const userId = (req as any).user.id;
const userId = req.user?.id;
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
const user = await prisma.user.findUnique({
where: { id: userId },
@@ -32,7 +33,7 @@ export const getProfile = async (req: Request, res: Response) => {
const { password, ...userWithoutPassword } = user;
res.json({ success: true, data: userWithoutPassword });
} catch (error: any) {
} catch (error: unknown) {
console.error('Ошибка получения профиля:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
@@ -41,7 +42,8 @@ export const getProfile = async (req: Request, res: Response) => {
// Обновить базовый профиль
export const updateProfile = async (req: Request, res: Response) => {
try {
const userId = (req as any).user.id;
const userId = req.user?.id;
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
const { username, email, phoneNumber, timezone, language } = req.body;
// Проверка email на уникальность
@@ -84,7 +86,7 @@ export const updateProfile = async (req: Request, res: Response) => {
message: 'Профиль обновлён',
data: { user: updatedUser, profile }
});
} catch (error: any) {
} catch (error: unknown) {
console.error('Ошибка обновления профиля:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
@@ -93,7 +95,8 @@ export const updateProfile = async (req: Request, res: Response) => {
// Изменить пароль
export const changePassword = async (req: Request, res: Response) => {
try {
const userId = (req as any).user.id;
const userId = req.user?.id;
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) {
@@ -128,7 +131,7 @@ export const changePassword = async (req: Request, res: Response) => {
// Можно добавить логику для сохранения текущего токена
res.json({ success: true, message: 'Пароль успешно изменён' });
} catch (error: any) {
} catch (error: unknown) {
console.error('Ошибка смены пароля:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
@@ -137,7 +140,8 @@ export const changePassword = async (req: Request, res: Response) => {
// Загрузить аватар
export const uploadAvatar = async (req: Request, res: Response) => {
try {
const userId = (req as any).user.id;
const userId = req.user?.id;
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
if (!req.file) {
return res.status(400).json({ success: false, message: 'Файл не загружен' });
@@ -157,7 +161,7 @@ export const uploadAvatar = async (req: Request, res: Response) => {
message: 'Аватар загружен',
data: { avatarUrl }
});
} catch (error: any) {
} catch (error: unknown) {
console.error('Ошибка загрузки аватара:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
@@ -166,7 +170,8 @@ export const uploadAvatar = async (req: Request, res: Response) => {
// Удалить аватар
export const deleteAvatar = async (req: Request, res: Response) => {
try {
const userId = (req as any).user.id;
const userId = req.user?.id;
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
await prisma.userProfile.update({
where: { userId },
@@ -174,7 +179,7 @@ export const deleteAvatar = async (req: Request, res: Response) => {
});
res.json({ success: true, message: 'Аватар удалён' });
} catch (error: any) {
} catch (error: unknown) {
console.error('Ошибка удаления аватара:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
@@ -183,7 +188,8 @@ export const deleteAvatar = async (req: Request, res: Response) => {
// Получить активные сеансы
export const getSessions = async (req: Request, res: Response) => {
try {
const userId = (req as any).user.id;
const userId = req.user?.id;
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
const sessions = await prisma.session.findMany({
where: {
@@ -194,7 +200,7 @@ export const getSessions = async (req: Request, res: Response) => {
});
res.json({ success: true, data: sessions });
} catch (error: any) {
} catch (error: unknown) {
console.error('Ошибка получения сеансов:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
@@ -203,7 +209,8 @@ export const getSessions = async (req: Request, res: Response) => {
// Завершить сеанс
export const terminateSession = async (req: Request, res: Response) => {
try {
const userId = (req as any).user.id;
const userId = req.user?.id;
if (!userId) return res.status(401).json({ success: false, message: 'Не авторизован' });
const { sessionId } = req.params;
// Проверяем, что сеанс принадлежит пользователю
@@ -221,7 +228,7 @@ export const terminateSession = async (req: Request, res: Response) => {
});
res.json({ success: true, message: 'Сеанс завершён' });
} catch (error: any) {
} catch (error: unknown) {
console.error('Ошибка завершения сеанса:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
@@ -240,7 +247,7 @@ export const getLoginHistory = async (req: Request, res: Response) => {
});
res.json({ success: true, data: history });
} catch (error: any) {
} catch (error: unknown) {
console.error('Ошибка получения истории:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
@@ -269,7 +276,7 @@ export const getAPIKeys = async (req: Request, res: Response) => {
});
res.json({ success: true, data: keys });
} catch (error: any) {
} catch (error: unknown) {
console.error('Ошибка получения API ключей:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
@@ -306,7 +313,7 @@ export const createAPIKey = async (req: Request, res: Response) => {
message: 'API ключ создан. Сохраните его, он больше не будет показан!',
data: { ...apiKey, fullKey: key }
});
} catch (error: any) {
} catch (error: unknown) {
console.error('Ошибка создания API ключа:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
@@ -332,7 +339,7 @@ export const deleteAPIKey = async (req: Request, res: Response) => {
});
res.json({ success: true, message: 'API ключ удалён' });
} catch (error: any) {
} catch (error: unknown) {
console.error('Ошибка удаления API ключа:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
@@ -355,7 +362,7 @@ export const getNotificationSettings = async (req: Request, res: Response) => {
}
res.json({ success: true, data: settings });
} catch (error: any) {
} catch (error: unknown) {
console.error('Ошибка получения настроек уведомлений:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
@@ -378,7 +385,7 @@ export const updateNotificationSettings = async (req: Request, res: Response) =>
message: 'Настройки уведомлений обновлены',
data: updated
});
} catch (error: any) {
} catch (error: unknown) {
console.error('Ошибка обновления настроек уведомлений:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
@@ -418,8 +425,9 @@ export const exportUserData = async (req: Request, res: Response) => {
data: dataWithoutPassword,
exportedAt: new Date().toISOString()
});
} catch (error: any) {
} catch (error: unknown) {
console.error('Ошибка экспорта данных:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};

View File

@@ -35,7 +35,7 @@ const avatarStorage = multer.diskStorage({
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const userId = (req as any).user.id;
const userId = (req as any).user?.id ?? 'anon';
const ext = path.extname(file.originalname);
cb(null, `avatar-${userId}-${Date.now()}${ext}`);
}
@@ -44,7 +44,7 @@ const avatarStorage = multer.diskStorage({
const avatarUpload = multer({
storage: avatarStorage,
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
fileFilter: (req, file, cb: any) => {
fileFilter: (req, file, cb) => {
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);