english version update

This commit is contained in:
Georgiy Syralev
2025-12-31 19:59:43 +03:00
parent b799f278a4
commit a2809a705f
57 changed files with 4263 additions and 1333 deletions

View File

@@ -13,33 +13,35 @@ datasource db {
// VPS/Server models removed - moving to S3 storage
model User {
id Int @id @default(autoincrement())
username String
email String @unique
password String
createdAt DateTime @default(now())
id Int @id @default(autoincrement())
username String
email String @unique
password String
createdAt DateTime @default(now())
// plans Plan[] @relation("UserPlans")
operator Int @default(0)
isAdmin Boolean @default(false)
tickets Ticket[] @relation("UserTickets")
responses Response[] @relation("OperatorResponses")
checks Check[] @relation("UserChecks")
balance Float @default(0)
notifications Notification[]
operator Int @default(0)
isAdmin Boolean @default(false)
tickets Ticket[] @relation("UserTickets")
responses Response[] @relation("OperatorResponses")
checks Check[] @relation("UserChecks")
balance Float @default(0)
notifications Notification[]
pushSubscriptions PushSubscription[]
transactions Transaction[] // История всех транзакций
posts Post[] @relation("PostAuthor") // Статьи блога
comments Comment[] @relation("UserComments") // Комментарии
buckets StorageBucket[] // S3 хранилища пользователя
checkoutSessions StorageCheckoutSession[]
transactions Transaction[] // История всех транзакций
posts Post[] @relation("PostAuthor") // Статьи блога
comments Comment[] @relation("UserComments") // Комментарии
buckets StorageBucket[] // S3 хранилища пользователя
checkoutSessions StorageCheckoutSession[]
// Список промокодов, использованных пользователем
usedPromoCodes PromoCode[]
// Новые relations для расширенных настроек
sessions Session[]
loginHistory LoginHistory[]
apiKeys APIKey[]
notificationSettings NotificationSettings?
profile UserProfile?
qrLoginRequests QrLoginRequest[]
sessions Session[]
loginHistory LoginHistory[]
apiKeys APIKey[]
notificationSettings NotificationSettings?
profile UserProfile?
qrLoginRequests QrLoginRequest[]
@@map("user")
}
@@ -53,48 +55,47 @@ model Check {
createdAt DateTime @default(now())
user User @relation("UserChecks", fields: [userId], references: [id])
@@map("check")
@@map("check")
}
model Service {
id Int @id @default(autoincrement())
name String @unique
price Float
id Int @id @default(autoincrement())
name String @unique
price Float
// planId Int?
// plan Plan? @relation("PlanServices", fields: [planId], references: [id])
@@map("service")
@@map("service")
}
model Ticket {
id Int @id @default(autoincrement())
title String
message String @db.Text
userId Int
status String @default("open") // open, in_progress, awaiting_reply, resolved, closed
priority String @default("normal") // low, normal, high, urgent
category String @default("general") // general, technical, billing, other
assignedTo Int? // ID оператора, которому назначен тикет
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
closedAt DateTime?
responses Response[] @relation("TicketResponses")
id Int @id @default(autoincrement())
title String
message String @db.Text
userId Int
status String @default("open") // open, in_progress, awaiting_reply, resolved, closed
priority String @default("normal") // low, normal, high, urgent
category String @default("general") // general, technical, billing, other
assignedTo Int? // ID оператора, которому назначен тикет
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
closedAt DateTime?
responses Response[] @relation("TicketResponses")
attachments TicketAttachment[]
user User? @relation("UserTickets", fields: [userId], references: [id])
user User? @relation("UserTickets", fields: [userId], references: [id])
@@map("ticket")
}
model Response {
id Int @id @default(autoincrement())
ticketId Int
operatorId Int
message String @db.Text
isInternal Boolean @default(false) // Внутренний комментарий (виден только операторам)
createdAt DateTime @default(now())
ticket Ticket @relation("TicketResponses", fields: [ticketId], references: [id], onDelete: Cascade)
operator User @relation("OperatorResponses", fields: [operatorId], references: [id])
id Int @id @default(autoincrement())
ticketId Int
operatorId Int
message String @db.Text
isInternal Boolean @default(false) // Внутренний комментарий (виден только операторам)
createdAt DateTime @default(now())
ticket Ticket @relation("TicketResponses", fields: [ticketId], references: [id], onDelete: Cascade)
operator User @relation("OperatorResponses", fields: [operatorId], references: [id])
attachments ResponseAttachment[]
@@map("response")
@@ -102,17 +103,17 @@ model Response {
// Прикреплённые файлы к тикетам
model TicketAttachment {
id Int @id @default(autoincrement())
ticketId Int
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
filename String
fileUrl String
fileSize Int // Размер в байтах
mimeType String
id Int @id @default(autoincrement())
ticketId Int
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
filename String
fileUrl String
fileSize Int // Размер в байтах
mimeType String
createdAt DateTime @default(now())
@@map("ticket_attachment")
}
@@ -121,32 +122,32 @@ model ResponseAttachment {
id Int @id @default(autoincrement())
responseId Int
response Response @relation(fields: [responseId], references: [id], onDelete: Cascade)
filename String
fileUrl String
fileSize Int
mimeType String
createdAt DateTime @default(now())
filename String
fileUrl String
fileSize Int
mimeType String
createdAt DateTime @default(now())
@@map("response_attachment")
}
// QR-код авторизация (как в Telegram Web)
model QrLoginRequest {
id Int @id @default(autoincrement())
code String @unique @db.VarChar(128) // Уникальный код QR
userId Int? // После подтверждения - ID пользователя
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
status String @default("pending") // pending, confirmed, expired, rejected
ipAddress String?
userAgent String? @db.Text
createdAt DateTime @default(now())
expiresAt DateTime // Через 60 секунд
confirmedAt DateTime?
id Int @id @default(autoincrement())
code String @unique @db.VarChar(128) // Уникальный код QR
userId Int? // После подтверждения - ID пользователя
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
status String @default("pending") // pending, confirmed, expired, rejected
ipAddress String?
userAgent String? @db.Text
createdAt DateTime @default(now())
expiresAt DateTime // Через 60 секунд
confirmedAt DateTime?
@@index([code])
@@index([status, expiresAt])
@@map("qr_login_request")
@@ -154,34 +155,34 @@ model QrLoginRequest {
// История всех транзакций (пополнения, списания, возвраты)
model Transaction {
id Int @id @default(autoincrement())
userId Int
amount Float
type String // deposit (пополнение), withdrawal (списание), refund (возврат)
description String
id Int @id @default(autoincrement())
userId Int
amount Float
type String // deposit (пополнение), withdrawal (списание), refund (возврат)
description String
balanceBefore Float
balanceAfter Float
adminId Int? // ID админа, если операция выполнена админом
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
adminId Int? // ID админа, если операция выполнена админом
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
@@map("transaction")
}
// Блог
model Post {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
title String
content String @db.Text // Rich text content (HTML)
excerpt String? @db.Text // Краткое описание для ленты
coverImage String? // URL обложки
url String @unique // Пользовательский URL (blog_name)
status String @default("draft") // draft, published, archived
content String @db.Text // Rich text content (HTML)
excerpt String? @db.Text // Краткое описание для ленты
coverImage String? // URL обложки
url String @unique // Пользовательский URL (blog_name)
status String @default("draft") // draft, published, archived
authorId Int
author User @relation("PostAuthor", fields: [authorId], references: [id])
views Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
author User @relation("PostAuthor", fields: [authorId], references: [id])
views Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
publishedAt DateTime?
comments Comment[]
@@ -190,41 +191,41 @@ model Post {
// Комментарии к статьям блога
model Comment {
id Int @id @default(autoincrement())
postId Int
userId Int? // null если комментарий от гостя
authorName String? // Имя автора (для гостей)
content String @db.Text
status String @default("pending") // pending, approved, rejected
createdAt DateTime @default(now())
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
user User? @relation("UserComments", fields: [userId], references: [id])
id Int @id @default(autoincrement())
postId Int
userId Int? // null если комментарий от гостя
authorName String? // Имя автора (для гостей)
content String @db.Text
status String @default("pending") // pending, approved, rejected
createdAt DateTime @default(now())
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
user User? @relation("UserComments", fields: [userId], references: [id])
@@map("comment")
}
// Модель для уведомлений
model Notification {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type String // server_created, payment_charged, tariff_expiring, ticket_reply, payment_received, balance_low
title String
message String @db.Text
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type String // server_created, payment_charged, tariff_expiring, ticket_reply, payment_received, balance_low
title String
message String @db.Text
// Связанные сущности (опционально)
ticketId Int?
checkId Int?
ticketId Int?
checkId Int?
// Метаданные
actionUrl String? // URL для перехода при клике
icon String? // Иконка (emoji или path)
color String? // Цвет (green, blue, orange, red, purple)
actionUrl String? // URL для перехода при клике
icon String? // Иконка (emoji или path)
color String? // Цвет (green, blue, orange, red, purple)
isRead Boolean @default(false)
createdAt DateTime @default(now())
@@index([userId, isRead])
@@index([userId, createdAt])
@@map("notification")
@@ -232,19 +233,19 @@ model Notification {
// Модель для Push-подписок (Web Push API)
model PushSubscription {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
endpoint String @db.VarChar(512)
p256dh String @db.Text // Публичный ключ для шифрования
auth String @db.Text // Токен аутентификации
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
endpoint String @db.VarChar(512)
p256dh String @db.Text // Публичный ключ для шифрования
auth String @db.Text // Токен аутентификации
userAgent String? @db.Text // Браузер/устройство
createdAt DateTime @default(now())
lastUsed DateTime @default(now())
@@unique([userId, endpoint])
@@index([userId])
@@map("push_subscription")
@@ -252,40 +253,40 @@ model PushSubscription {
// Активные сеансы пользователя
model Session {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
token String @unique @db.VarChar(500) // JWT refresh token
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
token String @unique @db.VarChar(500) // JWT refresh token
ipAddress String?
userAgent String? @db.Text
device String? // Desktop, Mobile, Tablet
browser String? // Chrome, Firefox, Safari, etc.
location String? // Город/страна по IP
userAgent String? @db.Text
device String? // Desktop, Mobile, Tablet
browser String? // Chrome, Firefox, Safari, etc.
location String? // Город/страна по IP
lastActivity DateTime @default(now())
createdAt DateTime @default(now())
expiresAt DateTime
@@index([userId])
@@map("session")
}
// История входов
model LoginHistory {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
ipAddress String
userAgent String? @db.Text
userAgent String? @db.Text
device String?
browser String?
location String?
success Boolean @default(true) // true = успешный вход, false = неудачная попытка
createdAt DateTime @default(now())
@@index([userId])
@@index([createdAt])
@@map("login_history")
@@ -293,20 +294,20 @@ model LoginHistory {
// API ключи для разработчиков
model APIKey {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String // Название (например, "Production API")
key String @unique @db.VarChar(64) // Сам API ключ
prefix String @db.VarChar(16) // Префикс для отображения (ospab_xxxx)
permissions String? @db.Text // JSON массив разрешений ["servers:read", "servers:create", etc.]
lastUsed DateTime?
createdAt DateTime @default(now())
expiresAt DateTime?
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String // Название (например, "Production API")
key String @unique @db.VarChar(64) // Сам API ключ
prefix String @db.VarChar(16) // Префикс для отображения (ospab_xxxx)
permissions String? @db.Text // JSON массив разрешений ["servers:read", "servers:create", etc.]
lastUsed DateTime?
createdAt DateTime @default(now())
expiresAt DateTime?
@@index([userId])
@@index([key])
@@map("api_key")
@@ -317,95 +318,95 @@ model NotificationSettings {
id Int @id @default(autoincrement())
userId Int @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Email уведомления
emailBalanceLow Boolean @default(true)
emailPaymentCharged Boolean @default(true)
emailTicketReply Boolean @default(true)
emailNewsletter Boolean @default(false)
emailBalanceLow Boolean @default(true)
emailPaymentCharged Boolean @default(true)
emailTicketReply Boolean @default(true)
emailNewsletter Boolean @default(false)
// Push уведомления
pushBalanceLow Boolean @default(true)
pushPaymentCharged Boolean @default(true)
pushTicketReply Boolean @default(true)
pushBalanceLow Boolean @default(true)
pushPaymentCharged Boolean @default(true)
pushTicketReply Boolean @default(true)
updatedAt DateTime @updatedAt
@@map("notification_settings")
}
// Настройки профиля
model UserProfile {
id Int @id @default(autoincrement())
userId Int @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
avatarUrl String? // Путь к аватару
id Int @id @default(autoincrement())
userId Int @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
avatarUrl String? // Путь к аватару
phoneNumber String?
timezone String? @default("Europe/Moscow")
language String? @default("ru")
timezone String? @default("Europe/Moscow")
language String? @default("ru")
// Настройки приватности
profilePublic Boolean @default(false)
showEmail Boolean @default(false)
// 2FA
twoFactorEnabled Boolean @default(false)
twoFactorSecret String? @db.Text
updatedAt DateTime @updatedAt
updatedAt DateTime @updatedAt
@@map("user_profile")
}
// S3 Bucket модель
model StorageBucket {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String // Уникальное имя бакета в рамках пользователя
plan String // Код тарифа из StoragePlan
quotaGb Int // Лимит включённого объёма в GB
usedBytes BigInt @default(0) // Текущий объём хранения в байтах
objectCount Int @default(0)
storageClass String @default("standard") // standard, infrequent, archive
region String @default("ru-central-1")
public Boolean @default(false)
versioning Boolean @default(false)
status String @default("active") // active, grace, suspended
monthlyPrice Float
nextBillingDate DateTime?
lastBilledAt DateTime?
autoRenew Boolean @default(true)
usageSyncedAt DateTime?
storagePlan StoragePlan? @relation(fields: [plan], references: [code])
regionConfig StorageRegion @relation("BucketRegion", fields: [region], references: [code])
storageClassConfig StorageClass @relation("BucketClass", fields: [storageClass], references: [code])
consoleCredential StorageConsoleCredential?
name String // Уникальное имя бакета в рамках пользователя
plan String // Код тарифа из StoragePlan
quotaGb Int // Лимит включённого объёма в GB
usedBytes BigInt @default(0) // Текущий объём хранения в байтах
objectCount Int @default(0)
storageClass String @default("standard") // standard, infrequent, archive
region String @default("ru-central-1")
public Boolean @default(false)
versioning Boolean @default(false)
status String @default("active") // active, grace, suspended
monthlyPrice Float
nextBillingDate DateTime?
lastBilledAt DateTime?
autoRenew Boolean @default(true)
usageSyncedAt DateTime?
storagePlan StoragePlan? @relation(fields: [plan], references: [code])
regionConfig StorageRegion @relation("BucketRegion", fields: [region], references: [code])
storageClassConfig StorageClass @relation("BucketClass", fields: [storageClass], references: [code])
consoleCredential StorageConsoleCredential?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accessKeys StorageAccessKey[]
accessKeys StorageAccessKey[]
@@unique([userId, name]) // Имя уникально в рамках пользователя
@@index([userId])
@@index([region])
@@index([storageClass])
@@unique([userId, name]) // Имя уникально в рамках пользователя
@@map("storage_bucket")
}
model StorageAccessKey {
id Int @id @default(autoincrement())
bucketId Int
bucket StorageBucket @relation(fields: [bucketId], references: [id], onDelete: Cascade)
id Int @id @default(autoincrement())
bucketId Int
bucket StorageBucket @relation(fields: [bucketId], references: [id], onDelete: Cascade)
accessKey String @unique
secretKey String // хранится в зашифрованном виде
accessKey String @unique
secretKey String // хранится в зашифрованном виде
label String?
createdAt DateTime @default(now())
createdAt DateTime @default(now())
lastUsedAt DateTime?
@@index([bucketId])
@@ -413,36 +414,36 @@ model StorageAccessKey {
}
model StorageConsoleCredential {
id Int @id @default(autoincrement())
bucketId Int @unique
bucket StorageBucket @relation(fields: [bucketId], references: [id], onDelete: Cascade)
id Int @id @default(autoincrement())
bucketId Int @unique
bucket StorageBucket @relation(fields: [bucketId], references: [id], onDelete: Cascade)
login String
passwordHash String
login String
passwordHash String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastGeneratedAt DateTime? // Для rate limiting (1 раз в неделю)
@@map("storage_console_credential")
}
model StoragePlan {
id Int @id @default(autoincrement())
code String @unique
name String
price Float
pricePerGb Decimal? @db.Decimal(10, 4) // Цена за 1 GB для кастомного тарифа
bandwidthPerGb Decimal? @db.Decimal(10, 4) // GB трафика на 1 GB хранения
requestsPerGb Int? // Количество операций на 1 GB хранения
quotaGb Int // Базовая квота для обычных тарифов (0 для custom)
bandwidthGb Int // Базовый трафик для обычных тарифов (0 для custom)
requestLimit String // Текстовое описание лимита операций
order Int @default(0)
isActive Boolean @default(true)
description String?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
id Int @id @default(autoincrement())
code String @unique
name String
price Float
pricePerGb Decimal? @db.Decimal(10, 4) // Цена за 1 GB для кастомного тарифа
bandwidthPerGb Decimal? @db.Decimal(10, 4) // GB трафика на 1 GB хранения
requestsPerGb Int? // Количество операций на 1 GB хранения
quotaGb Int // Базовая квота для обычных тарифов (0 для custom)
bandwidthGb Int // Базовый трафик для обычных тарифов (0 для custom)
requestLimit String // Текстовое описание лимита операций
order Int @default(0)
isActive Boolean @default(true)
description String?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
buckets StorageBucket[]
checkoutSessions StorageCheckoutSession[]
@@ -451,22 +452,22 @@ model StoragePlan {
}
model StorageCheckoutSession {
id String @id @default(uuid())
id String @id @default(uuid())
userId Int?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
planId Int
plan StoragePlan @relation(fields: [planId], references: [id])
planCode String
planName String
planDescription String?
price Float
promoCodeId Int?
promoCode PromoCode? @relation(fields: [promoCodeId], references: [id])
promoDiscount Float? @default(0)
promoCodeId Int?
promoCode PromoCode? @relation(fields: [promoCodeId], references: [id])
promoDiscount Float? @default(0)
quotaGb Int
bandwidthGb Int
requestLimit String
createdAt DateTime @default(now())
createdAt DateTime @default(now())
expiresAt DateTime
consumedAt DateTime?
@@ -486,40 +487,43 @@ model StorageRegion {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
buckets StorageBucket[] @relation("BucketRegion")
buckets StorageBucket[] @relation("BucketRegion")
@@map("storage_region")
}
model StorageClass {
id Int @id @default(autoincrement())
code String @unique
name String
description String?
redundancy String?
performance String?
retrievalFee String?
isDefault Boolean @default(false)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id Int @id @default(autoincrement())
code String @unique
name String
description String?
redundancy String?
performance String?
retrievalFee String?
isDefault Boolean @default(false)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
buckets StorageBucket[] @relation("BucketClass")
buckets StorageBucket[] @relation("BucketClass")
@@map("storage_class")
}
model PromoCode {
id Int @id @default(autoincrement())
code String @unique
amount Float // discount amount in RUB
used Boolean @default(false)
usedBy Int?
id Int @id @default(autoincrement())
code String @unique
amount Float // discount amount in RUB
used Boolean @default(false)
usedBy Int?
usedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [usedBy], references: [id])
user User? @relation(fields: [usedBy], references: [id], onDelete: SetNull)
// Обратная связь для корзин, в которые применяли этот промокод
checkoutSessions StorageCheckoutSession[]
@@map("promo_code")
}
}

View File

@@ -111,15 +111,72 @@ app.get('/', async (req, res) => {
// ==================== SITEMAP ====================
app.get('/sitemap.xml', (req, res) => {
const baseUrl = 'https://ospab.host';
const staticPages = [
{ loc: '/', priority: '1.0', changefreq: 'weekly', description: 'Ospab Host - Облачное S3 хранилище и хостинг сайтов' },
{ loc: '/about', priority: '0.9', changefreq: 'monthly', description: 'О компании - Современная платформа хранения данных' },
{ loc: '/login', priority: '0.7', changefreq: 'monthly', description: 'Вход в панель управления с QR-аутентификацией' },
{ loc: '/register', priority: '0.8', changefreq: 'monthly', description: 'Регистрация аккаунта - Начните за 2 минуты' },
{ loc: '/blog', priority: '0.85', changefreq: 'daily', description: 'Блог о S3 хранилище и хостинге' },
{ loc: '/terms', priority: '0.5', changefreq: 'yearly', description: 'Условия использования сервиса' },
{ loc: '/privacy', priority: '0.5', changefreq: 'yearly', description: 'Политика конфиденциальности и защита данных' },
const pages = [
// Главная страница
{
loc: '/',
priority: '1.0',
changefreq: 'weekly',
ru: { title: 'Ospab Host - Облачное S3 хранилище и хостинг сайтов', description: 'Надёжное облачное S3-совместимое хранилище в Великом Новгороде' },
en: { title: 'Ospab Host - Cloud S3 Storage and Website Hosting', description: 'Reliable S3-compatible cloud storage in Veliky Novgorod' }
},
// О компании
{
loc: '/about',
priority: '0.9',
changefreq: 'monthly',
ru: { title: 'О компании - Современная платформа хранения данных', description: 'Узнайте о ospab.host - платформе облачного хранилища в Великом Новгороде' },
en: { title: 'About Us - Modern Data Storage Platform', description: 'Learn about ospab.host - cloud storage platform in Veliky Novgorod' }
},
// Вход
{
loc: '/login',
priority: '0.7',
changefreq: 'monthly',
ru: { title: 'Вход в панель управления с QR-аутентификацией', description: 'Войдите в ваш личный кабинет ospab.host' },
en: { title: 'Login to Control Panel with QR Authentication', description: 'Sign in to your ospab.host account' }
},
// Регистрация
{
loc: '/register',
priority: '0.8',
changefreq: 'monthly',
ru: { title: 'Регистрация аккаунта - Начните за 2 минуты', description: 'Зарегистрируйтесь в ospab.host и начните пользоваться облачным хранилищем' },
en: { title: 'Account Registration - Start in 2 Minutes', description: 'Register with ospab.host and start using cloud storage' }
},
// Блог
{
loc: '/blog',
priority: '0.85',
changefreq: 'daily',
ru: { title: 'Блог о S3 хранилище и хостинге', description: 'Статьи о хостинге, S3 хранилище и облачных технологиях' },
en: { title: 'Blog about S3 Storage and Hosting', description: 'Articles about hosting, S3 storage and cloud technologies' }
},
// Тарифы
{
loc: '/tariffs',
priority: '0.9',
changefreq: 'weekly',
ru: { title: 'Тарифы на облачное S3 хранилище', description: 'Выберите подходящий тариф для вашего проекта' },
en: { title: 'Cloud S3 Storage Plans', description: 'Choose the right plan for your project' }
},
// Условия использования
{
loc: '/terms',
priority: '0.5',
changefreq: 'yearly',
ru: { title: 'Условия использования сервиса', description: 'Правила и условия использования ospab.host' },
en: { title: 'Terms of Service', description: 'Rules and conditions for using ospab.host' }
},
// Политика конфиденциальности
{
loc: '/privacy',
priority: '0.5',
changefreq: 'yearly',
ru: { title: 'Политика конфиденциальности и защита данных', description: 'Как мы защищаем ваши данные и обеспечиваем конфиденциальность' },
en: { title: 'Privacy Policy and Data Protection', description: 'How we protect your data and ensure privacy' }
}
];
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
@@ -127,12 +184,25 @@ app.get('/sitemap.xml', (req, res) => {
const lastmod = new Date().toISOString().split('T')[0];
for (const page of staticPages) {
for (const page of pages) {
// Русская версия (без префикса)
xml += ' <url>\n';
xml += ` <loc>${baseUrl}${page.loc}</loc>\n`;
xml += ` <lastmod>${lastmod}</lastmod>\n`;
xml += ` <priority>${page.priority}</priority>\n`;
xml += ` <changefreq>${page.changefreq}</changefreq>\n`;
xml += ' <xhtml:link rel="alternate" hreflang="ru" href="' + baseUrl + page.loc + '"/>\n';
xml += ' <xhtml:link rel="alternate" hreflang="en" href="' + baseUrl + '/en' + page.loc + '"/>\n';
xml += ' </url>\n';
// Английская версия (с префиксом /en)
xml += ' <url>\n';
xml += ` <loc>${baseUrl}/en${page.loc}</loc>\n`;
xml += ` <lastmod>${lastmod}</lastmod>\n`;
xml += ` <priority>${page.priority}</priority>\n`;
xml += ` <changefreq>${page.changefreq}</changefreq>\n`;
xml += ' <xhtml:link rel="alternate" hreflang="ru" href="' + baseUrl + page.loc + '"/>\n';
xml += ' <xhtml:link rel="alternate" hreflang="en" href="' + baseUrl + '/en' + page.loc + '"/>\n';
xml += ' </url>\n';
}
@@ -145,20 +215,29 @@ app.get('/sitemap.xml', (req, res) => {
// ==================== ROBOTS.TXT ====================
app.get('/robots.txt', (req, res) => {
const robots = `# ospab Host - Облачное S3 хранилище и хостинг
# Хранение данных, техподдержка 24/7
# Cloud S3 Storage and Website Hosting
User-agent: *
Allow: /
Allow: /about
Allow: /en/about
Allow: /login
Allow: /en/login
Allow: /register
Allow: /en/register
Allow: /blog
Allow: /en/blog
Allow: /blog/*
Allow: /en/blog/*
Allow: /tariffs
Allow: /en/tariffs
Allow: /terms
Allow: /en/terms
Allow: /privacy
Allow: /en/privacy
Allow: /uploads/blog
# Запрет индексации приватных разделов
# Disallow private sections / Запрет индексации приватных разделов
Disallow: /dashboard
Disallow: /dashboard/*
Disallow: /api/
@@ -171,7 +250,7 @@ Disallow: /uploads/checks
Sitemap: https://ospab.host/sitemap.xml
# Поисковые роботы
# Search engine robots / Поисковые роботы
User-agent: Googlebot
Allow: /
Crawl-delay: 0

View File

@@ -10,12 +10,18 @@ import {
} from './account.service';
import { prisma } from '../../prisma/client';
// Хелпер для извлечения сообщения из ошибки
function getErrorMessage(error: unknown): string {
if (error instanceof Error) return getErrorMessage(error);
return String(error);
}
/**
* Получить информацию о текущем пользователе
*/
export const getAccountInfo = async (req: Request, res: Response) => {
try {
const userId = (req.user as any)?.id;
const userId = (req.user as { id?: number })?.id;
if (!userId) {
return res.status(401).json({ error: 'Не авторизован' });
}
@@ -33,7 +39,7 @@ export const getAccountInfo = async (req: Request, res: Response) => {
*/
export const requestPasswordChangeHandler = async (req: Request, res: Response) => {
try {
const userId = (req.user as any)?.id;
const userId = (req.user as { id?: number })?.id;
if (!userId) {
return res.status(401).json({ error: 'Не авторизован' });
}
@@ -71,7 +77,7 @@ export const requestPasswordChangeHandler = async (req: Request, res: Response)
});
} catch (error: unknown) {
console.error('Ошибка запроса смены пароля:', error);
res.status(500).json({ error: error.message || 'Ошибка сервера' });
res.status(500).json({ error: getErrorMessage(error) || 'Ошибка сервера' });
}
};
@@ -80,7 +86,7 @@ export const requestPasswordChangeHandler = async (req: Request, res: Response)
*/
export const confirmPasswordChangeHandler = async (req: Request, res: Response) => {
try {
const userId = (req.user as any)?.id;
const userId = (req.user as { id?: number })?.id;
if (!userId) {
return res.status(401).json({ error: 'Не авторизован' });
}
@@ -99,7 +105,7 @@ export const confirmPasswordChangeHandler = async (req: Request, res: Response)
});
} catch (error: unknown) {
console.error('Ошибка подтверждения смены пароля:', error);
res.status(400).json({ error: error.message || 'Ошибка подтверждения' });
res.status(400).json({ error: getErrorMessage(error) || 'Ошибка подтверждения' });
}
};
@@ -108,7 +114,7 @@ export const confirmPasswordChangeHandler = async (req: Request, res: Response)
*/
export const requestUsernameChangeHandler = async (req: Request, res: Response) => {
try {
const userId = (req.user as any)?.id;
const userId = (req.user as { id?: number })?.id;
if (!userId) {
return res.status(401).json({ error: 'Не авторизован' });
}
@@ -139,7 +145,7 @@ export const requestUsernameChangeHandler = async (req: Request, res: Response)
});
} catch (error: unknown) {
console.error('Ошибка запроса смены имени:', error);
res.status(400).json({ error: error.message || 'Ошибка сервера' });
res.status(400).json({ error: getErrorMessage(error) || 'Ошибка сервера' });
}
};
@@ -148,7 +154,7 @@ export const requestUsernameChangeHandler = async (req: Request, res: Response)
*/
export const confirmUsernameChangeHandler = async (req: Request, res: Response) => {
try {
const userId = (req.user as any)?.id;
const userId = (req.user as { id?: number })?.id;
if (!userId) {
return res.status(401).json({ error: 'Не авторизован' });
}
@@ -167,7 +173,7 @@ export const confirmUsernameChangeHandler = async (req: Request, res: Response)
});
} catch (error: unknown) {
console.error('Ошибка подтверждения смены имени:', error);
res.status(400).json({ error: error.message || 'Ошибка подтверждения' });
res.status(400).json({ error: getErrorMessage(error) || 'Ошибка подтверждения' });
}
};
@@ -176,7 +182,7 @@ export const confirmUsernameChangeHandler = async (req: Request, res: Response)
*/
export const requestAccountDeletionHandler = async (req: Request, res: Response) => {
try {
const userId = (req.user as any)?.id;
const userId = (req.user as { id?: number })?.id;
if (!userId) {
return res.status(401).json({ error: 'Не авторизован' });
}
@@ -189,7 +195,7 @@ export const requestAccountDeletionHandler = async (req: Request, res: Response)
});
} catch (error: unknown) {
console.error('Ошибка запроса удаления аккаунта:', error);
res.status(500).json({ error: error.message || 'Ошибка сервера' });
res.status(500).json({ error: getErrorMessage(error) || 'Ошибка сервера' });
}
};
@@ -198,7 +204,7 @@ export const requestAccountDeletionHandler = async (req: Request, res: Response)
*/
export const confirmAccountDeletionHandler = async (req: Request, res: Response) => {
try {
const userId = (req.user as any)?.id;
const userId = (req.user as { id?: number })?.id;
if (!userId) {
return res.status(401).json({ error: 'Не авторизован' });
}
@@ -217,7 +223,8 @@ export const confirmAccountDeletionHandler = async (req: Request, res: Response)
});
} catch (error: unknown) {
console.error('Ошибка подтверждения удаления аккаунта:', error);
res.status(400).json({ error: error.message || 'Ошибка подтверждения' });
res.status(400).json({ error: getErrorMessage(error) || 'Ошибка подтверждения' });
}
};

View File

@@ -1,6 +1,7 @@
import { Request, Response } from 'express';
import { prisma } from '../../prisma/client';
import { createNotification } from '../notification/notification.controller';
import { sendNotificationEmail } from '../notification/email.service';
function toNumeric(value: unknown): number {
if (typeof value === 'bigint') {
@@ -514,23 +515,50 @@ export class AdminController {
return res.status(404).json({ error: 'Пользователь не найден' });
}
if (!user.email) {
return res.status(400).json({ error: 'У пользователя не указан 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));
// Отправляем реальное email уведомление
const emailResult = await sendNotificationEmail({
to: user.email,
username: user.username,
title: 'Тестовое уведомление',
message: 'Это тестовое email-уведомление от ospab.host. Если вы получили это письмо, email-уведомления настроены корректно.',
actionUrl: '/dashboard/notifications',
type: 'test_email'
});
if (emailResult.status === 'error') {
return res.status(500).json({
success: false,
error: `Ошибка отправки email: ${emailResult.message}`,
details: { userId: user.id, email: user.email, time: now }
});
}
if (emailResult.status === 'skipped') {
return res.status(400).json({
success: false,
error: 'SMTP не настроен. Укажите SMTP_USER и SMTP_PASS в переменных окружения.',
details: { userId: user.id, email: user.email, time: now }
});
}
return res.json({
success: true,
message: 'Email-уведомление успешно отправлено (тест)',
message: 'Email-уведомление успешно отправлено',
details: {
userId: user.id,
username: user.username,
email: user.email,
type: 'email',
time: now,
status: 'sent (mock)'
messageId: 'messageId' in emailResult ? emailResult.messageId : undefined
}
});
} catch (error) {

View File

@@ -4,7 +4,7 @@ import crypto from 'crypto';
import { createSession } from '../session/session.controller';
import { logger } from '../../utils/logger';
const QR_EXPIRATION_SECONDS = 60; // QR-код живёт 60 секунд
const QR_EXPIRATION_SECONDS = 180; // QR-код живёт 180 секунд (3 минуты)
// Генерировать уникальный код для QR
function generateQRCode(): string {
@@ -14,7 +14,7 @@ function generateQRCode(): string {
// Создать новый QR-запрос для логина
export async function createQRLoginRequest(req: Request, res: Response) {
try {
const code = generateQRCode();
const code = generateQRCode();
const ipAddress = req.headers['x-forwarded-for'] as string || req.socket.remoteAddress || '';
const userAgent = req.headers['user-agent'] || '';
@@ -31,6 +31,16 @@ export async function createQRLoginRequest(req: Request, res: Response) {
}
});
// Ensure QR creation is visible in production logs: write directly to stdout
console.log('[QR Create] Создан QR-запрос', JSON.stringify({
code: qrRequest.code,
ipAddress: qrRequest.ipAddress,
userAgent: qrRequest.userAgent?.toString?.().slice(0, 200),
host: req.headers.host,
origin: req.headers.origin,
referer: req.headers.referer
}));
res.json({
code: qrRequest.code,
expiresAt: qrRequest.expiresAt,
@@ -47,21 +57,38 @@ export async function checkQRStatus(req: Request, res: Response) {
try {
const { code } = req.params;
// Log incoming status checks for tracing
logger.debug('[QR Status] Проверка статуса QR', {
code,
ip: req.headers['x-forwarded-for'] || req.socket.remoteAddress,
ua: (req.headers['user-agent'] || '').toString().slice(0, 200)
});
const qrRequest = await prisma.qrLoginRequest.findUnique({
where: { code }
});
if (!qrRequest) {
// Log as error so it appears in production logs — include host/origin/referer and remote IP for tracing
logger.error('[QR Status] QR-код не найден', {
code,
host: req.headers.host,
origin: req.headers.origin,
referer: req.headers.referer,
ip: req.headers['x-forwarded-for'] || req.socket.remoteAddress
});
return res.status(404).json({ error: 'QR-код не найден' });
}
// Проверяем истёк ли QR-код
if (new Date() > qrRequest.expiresAt) {
const now = new Date();
const expiresIn = Math.max(0, Math.ceil((qrRequest.expiresAt.getTime() - now.getTime()) / 1000));
if (expiresIn <= 0) {
await prisma.qrLoginRequest.update({
where: { code },
data: { status: 'expired' }
});
return res.json({ status: 'expired' });
return res.json({ status: 'expired', expiresAt: qrRequest.expiresAt, expiresIn: 0 });
}
// Если подтверждён, создаём сессию и возвращаем токен
@@ -85,8 +112,12 @@ export async function checkQRStatus(req: Request, res: Response) {
// Создаём сессию для нового устройства
const { token } = await createSession(user.id, req);
// Удаляем использованный QR-запрос
await prisma.qrLoginRequest.delete({ where: { code } });
// Попытка безопасно удалить использованный QR-запрос (deleteMany не бросает если записи не найдено)
try {
await prisma.qrLoginRequest.deleteMany({ where: { code } });
} catch (err) {
logger.warn('[QR Status] Не удалось удалить QR-запрос (возможно уже удалён)', { code, error: err });
}
return res.json({
status: 'confirmed',
@@ -102,7 +133,7 @@ export async function checkQRStatus(req: Request, res: Response) {
});
}
res.json({ status: qrRequest.status });
return res.json({ status: qrRequest.status, expiresAt: qrRequest.expiresAt, expiresIn, ipAddress: qrRequest.ipAddress ?? undefined, userAgent: qrRequest.userAgent ? (qrRequest.userAgent as string).slice(0, 200) : undefined });
} catch (error) {
logger.error('Ошибка проверки статуса QR:', error);
res.status(500).json({ error: 'Ошибка проверки статуса' });
@@ -266,3 +297,61 @@ export async function cleanupExpiredQRRequests() {
logger.error('[QR Cleanup] Ошибка:', error);
}
}
// DEV-only: получить последние N QR-запросов (для отладки)
export async function listRecentQRRequests(req: Request, res: Response) {
try {
// In production allow only requests from localhost (for safe debugging)
if (process.env.NODE_ENV === 'production') {
const remote = req.socket.remoteAddress || '';
const isLocal = remote === '::1' || remote === '127.0.0.1' || remote.startsWith('::ffff:127.0.0.1');
if (!isLocal) {
return res.status(404).json({ error: 'Not found' });
}
}
const limit = Math.min(100, Number(req.query.limit) || 50);
const rows = await prisma.qrLoginRequest.findMany({
orderBy: { createdAt: 'desc' },
take: limit,
select: {
code: true,
status: true,
ipAddress: true,
userAgent: true,
createdAt: true,
expiresAt: true,
userId: true
}
});
res.json({ count: rows.length, rows });
} catch (error) {
logger.error('[QR Debug] Ошибка получения списка QR-запросов:', error);
res.status(500).json({ error: 'Ошибка получения списка' });
}
}
// DEV-only: получить QR-запрос по коду
export async function getQRRequestByCode(req: Request, res: Response) {
try {
// In production allow only requests from localhost (for safe debugging)
if (process.env.NODE_ENV === 'production') {
const remote = req.socket.remoteAddress || '';
const isLocal = remote === '::1' || remote === '127.0.0.1' || remote.startsWith('::ffff:127.0.0.1');
if (!isLocal) {
return res.status(404).json({ error: 'Not found' });
}
}
const { code } = req.params;
const row = await prisma.qrLoginRequest.findUnique({ where: { code } });
if (!row) {
return res.status(404).json({ error: 'QR-код не найден' });
}
res.json(row);
} catch (error) {
logger.error('[QR Debug] Ошибка получения QR по коду:', error);
res.status(500).json({ error: 'Ошибка получения' });
}
}

View File

@@ -4,7 +4,9 @@ import {
checkQRStatus,
confirmQRLogin,
rejectQRLogin,
markQRAsScanning
markQRAsScanning,
listRecentQRRequests,
getQRRequestByCode
} from './qr-auth.controller';
import { authMiddleware } from '../auth/auth.middleware';
@@ -16,6 +18,10 @@ router.post('/generate', createQRLoginRequest);
// Проверить статус QR-кода (polling, публичный endpoint)
router.get('/status/:code', checkQRStatus);
// DEV-only debug endpoints (возвращают информацию о последних QR-запросах)
router.get('/debug/list', listRecentQRRequests);
router.get('/debug/get/:code', getQRRequestByCode);
// Отметить что пользователь открыл страницу подтверждения (требует авторизации)
router.post('/scanning', authMiddleware, markQRAsScanning);

View File

@@ -160,7 +160,8 @@ router.post('/cart/:id/apply-promo', authMiddleware, async (req, res) => {
return res.json({ success: true, cart: result });
} catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Не удалось применить промокод';
return res.status(400).json({ error: message });
const status = typeof message === 'string' && message.includes('PromoCode модель недоступна') ? 500 : 400;
return res.status(status).json({ error: message });
}
});

View File

@@ -661,9 +661,13 @@ function buildPlanFromSession(session: CheckoutSessionRecord, plan?: StoragePlan
isActive: true,
};
// Для custom тарифа всегда берём значения из сессии
return {
...base,
price: toPlainNumber(session.price),
quotaGb: session.quotaGb,
bandwidthGb: session.bandwidthGb,
requestLimit: session.requestLimit,
};
}
@@ -672,6 +676,8 @@ type CheckoutSessionPayload = {
plan: ReturnType<typeof serializePlan>;
price: number;
expiresAt: string;
originalPrice?: number | null;
promoDiscount?: number | null;
};
type CheckoutSessionResult = {
@@ -694,11 +700,14 @@ function ensureSessionActive(session: CheckoutSessionRecord, userId: number): Ch
}
function toCheckoutPayload(session: CheckoutSessionRecord, plan?: StoragePlanRecord | null): CheckoutSessionPayload {
const original = plan ? Number(plan.price) : toPlainNumber(session.price);
return {
cartId: session.id,
plan: buildPlanFromSession(session, plan),
price: toPlainNumber(session.price),
expiresAt: session.expiresAt.toISOString(),
originalPrice: original,
promoDiscount: session.promoDiscount ?? null,
};
}