new api endpoint and api rate limit

This commit is contained in:
Georgiy Syralev
2026-01-01 16:55:17 +03:00
parent 4690bdf23e
commit bdb333958a
32 changed files with 884 additions and 377 deletions

View File

@@ -4,60 +4,68 @@
## Архитектура и основные компоненты
- **Монорепозиторий**: две части — `backend` (Express, TypeScript, Prisma) и `frontend` (React, Vite, TypeScript).
- **Backend**:
- Точка входа: `backend/src/index.ts` (Express, маршруты `/api/*`, CORS, логирование).
- Модули: `backend/src/modules/*` — домены (auth, ticket, check, os, server, tariff), каждый экспортирует маршруты и сервисы.
- Интеграция с Proxmox: через API, см. `backend/src/modules/server/proxmoxApi.ts` (создание/управление контейнерами, смена пароля root, статистика).
- Точка входа: `backend/src/index.ts` (Express, маршруты `/api/*`, CORS, логирование, WebSocket).
- Модули: `backend/src/modules/*` — домены (auth, admin, ticket, check, blog, notification, user, session, qr-auth, storage, payment, account, sitemap), каждый экспортирует маршруты и сервисы.
- Интеграция с MinIO: для S3-совместимого хранилища, параметры из `.env`.
- ORM: Prisma, схема — `backend/prisma/schema.prisma`, миграции и seed-скрипты — в `backend/prisma/`.
- Статические файлы чеков: `backend/uploads/checks` (доступны по `/uploads/checks`).
- **Frontend**:
- SPA на React + Vite, точка входа: `frontend/src/main.tsx`.
- Страницы: `frontend/src/pages/*`, компоненты: `frontend/src/components/*`.
- Авторизация: `frontend/src/context/authcontext.tsx`, `useAuth.ts` (контекст, хуки).
- Авторизация: `frontend/src/context/authcontext.tsx` (контекст, хуки).
- Дашборд: `frontend/src/pages/dashboard/mainpage.tsx` — реализует сайдбар, вкладки, загрузку данных пользователя, обработку токена, обновление данных через кастомное событие `userDataUpdate`.
- Локализация: поддержка ru/en через `useTranslation` и `LocaleProvider`.
## Ключевые паттерны и конвенции
- **API**: все маршруты backend — с префиксом `/api/`.
- **Модули backend**: каждый домен — отдельная папка, экспортирует маршруты и сервисы (см. пример: `server/proxmoxApi.ts`).
- **Работа с Proxmox**: все операции через функции из `proxmoxApi.ts`, параметры берутся из `.env`.
- **Статусные поля**: для Server, Check, Ticket — строковые статусы (`creating`, `running`, `pending`, `open` и др.).
- **Пароли**: генерируются через `generateSecurePassword` (см. `proxmoxApi.ts`).
- **Описание тарифа**: парсится для выделения ресурсов (ядра, RAM, SSD) при создании контейнера.
- **Модули backend**: каждый домен — отдельная папка, экспортирует маршруты и сервисы.
- **Работа с MinIO**: все операции S3 через MinIO SDK, параметры берутся из `.env`.
- **Статусные поля**: для Check, Ticket, Post, StorageBucket — строковые статусы (`pending`, `approved`, `open`, `published`, `active` и др.).
- **Пароли**: генерируются через `generateSecurePassword` (для консольных учётных данных S3).
- **Описание тарифа**: для StoragePlan — цена за GB, трафик, операции.
- **Frontend**: авторизация через контекст, проверка токена, автоматический logout при ошибке 401.
- **Дашборд**: вкладки и права оператора определяются по полю `operator` в userData, обновление данных через событие `userDataUpdate`.
- **Дашборд**: вкладки и права оператора/админа определяются по полям `operator` и `isAdmin` в userData, обновление данных через событие `userDataUpdate`.
- **Уведомления**: email и push через web-push, шаблоны в `notification/email.service.ts`.
- **QR-аутентификация**: временные коды с TTL 60 сек, статусы `pending`, `confirmed`, `expired`.
## Сборка, запуск и workflow
- **Backend**:
- `npm run dev` — запуск с hot-reload (ts-node-dev).
- `npm run build` — компиляция TypeScript.
- `npm start` — запуск собранного кода.
- PM2: `npm run pm2:start`, `pm2:restart`, etc. для production.
- **Frontend**:
- `npm run dev` — запуск Vite dev server.
- `npm run build` — сборка.
- `npm run build` — сборка TypeScript + Vite.
- `npm run preview` — предпросмотр production-сборки.
- `npm run lint` — проверка ESLint.
## Интеграции и взаимодействие
- **Frontend ↔ Backend**: через REST API (`/api/*`), авторизация через JWT-токен в localStorage.
- **Backend ↔ Proxmox**: через HTTP API, параметры из `.env`.
- **Frontend ↔ Backend**: через REST API (`/api/*`), авторизация через JWT-токен в localStorage, WebSocket для real-time обновлений.
- **Backend ↔ MinIO**: для S3 хранилища, параметры из `.env`.
- **OAuth**: поддержка Google, GitHub, VK, Yandex через Passport.js.
- **Push-уведомления**: через web-push API, подписки в PushSubscription.
- **Prisma**: миграции и seed-скрипты — в `backend/prisma/`.
## Внешние зависимости
- **Backend**: express, prisma, axios, bcrypt, jsonwebtoken, multer, dotenv.
- **Frontend**: react, react-dom, react-router-dom, tailwindcss, axios.
- **Backend**: express, prisma, axios, bcrypt, jsonwebtoken, multer, dotenv, minio, web-push, passport, ws.
- **Frontend**: react, react-dom, react-router-dom, tailwindcss, axios, quill, recharts, xterm.
## Примеры ключевых файлов
- `backend/src/index.ts` — точка входа, маршрутизация.
- `backend/src/modules/server/proxmoxApi.ts` — интеграция с Proxmox.
- `backend/prisma/schema.prisma` — схема данных.
- `backend/src/index.ts` — точка входа, маршрутизация, WebSocket.
- `backend/src/modules/storage/storage.service.ts` — интеграция с MinIO.
- `backend/prisma/schema.prisma` — схема данных (User, StorageBucket, Ticket, etc.).
- `frontend/src/pages/dashboard/mainpage.tsx` — дашборд, обработка токена, сайдбар, вкладки.
- `frontend/src/context/authcontext.tsx` — авторизация.
- `frontend/src/context/authcontext.tsx` — авторизация, JWT, logout.
## Особенности и conventions
- **CORS**: разрешены только локальные адреса для разработки.
- **Логирование**: каждый запрос логируется с датой и методом.
- **CORS**: разрешены origins из `.env` (PUBLIC_APP_ORIGIN, etc.).
- **Логирование**: каждый запрос логируется с датой и методом через `logger`.
- **Статические файлы**: чеки доступны по `/uploads/checks`.
- **Пароли root**: генерируются и меняются через API Proxmox.
- **Frontend**: сайдбар и вкладки строятся динамически, права оператора определяются по userData.
- **Пароли для S3 консоли**: генерируются еженедельно, хэшируются.
- **Frontend**: сайдбар и вкладки строятся динамически, права по userData, локализация через `useTranslation`.
- **Блог**: Rich Text через Quill, статусы `draft`, `published`, `archived`.
- **Тикеты**: автоназначение операторам, внутренние комментарии.
---

View File

@@ -15,9 +15,11 @@
"axios": "^1.12.2",
"bcrypt": "^6.0.0",
"bcryptjs": "^2.4.3",
"compression": "^1.8.1",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"express-rate-limit": "^8.2.1",
"express-session": "^1.18.2",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
@@ -39,6 +41,7 @@
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/compression": "^1.8.1",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.23",
"@types/express-session": "^1.18.2",
@@ -1569,6 +1572,17 @@
"@types/node": "*"
}
},
"node_modules/@types/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*",
"@types/node": "*"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
@@ -1672,6 +1686,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz",
"integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -2310,6 +2325,45 @@
"node": ">= 0.8"
}
},
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"license": "MIT",
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
"negotiator": "~0.6.4",
"on-headers": "~1.1.0",
"safe-buffer": "5.2.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/compression/node_modules/negotiator": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -2668,6 +2722,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -2709,6 +2764,24 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"license": "MIT",
"dependencies": {
"ip-address": "10.0.1"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/express-session": {
"version": "1.18.2",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz",
@@ -3194,6 +3267,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz",
@@ -4046,6 +4128,7 @@
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@prisma/config": "6.19.0",
"@prisma/engines": "6.19.0"
@@ -4847,6 +4930,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View File

@@ -24,9 +24,11 @@
"axios": "^1.12.2",
"bcrypt": "^6.0.0",
"bcryptjs": "^2.4.3",
"compression": "^1.8.1",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"express-rate-limit": "^8.2.1",
"express-session": "^1.18.2",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
@@ -48,6 +50,7 @@
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/compression": "^1.8.1",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.23",
"@types/express-session": "^1.18.2",

View File

@@ -2,6 +2,9 @@ import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import http from 'http';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import compression from 'compression';
import passport from './modules/auth/passport.config';
import authRoutes from './modules/auth/auth.routes';
import oauthRoutes from './modules/auth/oauth.routes';
@@ -66,6 +69,35 @@ app.use(cors({
allowedHeaders: ['Content-Type', 'Authorization']
}));
// Security headers
app.use(helmet({
contentSecurityPolicy: false, // Отключаем CSP для совместимости с WebSocket
crossOriginEmbedderPolicy: false
}));
// Response compression
app.use(compression());
// Global rate limiter - 1000 requests per 15 minutes
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 1000,
message: 'Too many requests, please try again later',
standardHeaders: true,
legacyHeaders: false,
});
// Strict rate limiter for auth endpoints - 10 requests per 15 minutes
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
message: 'Too many authentication attempts, please try again later',
standardHeaders: true,
legacyHeaders: false,
});
app.use(globalLimiter);
app.use(express.json({ limit: '100mb' }));
app.use(express.urlencoded({ limit: '100mb', extended: true }));
app.use(passport.initialize());
@@ -91,21 +123,8 @@ app.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
app.get('/', async (req, res) => {
// Статистика WebSocket
const wsConnectedUsers = getConnectedUsersCount();
const wsRoomsStats = getRoomsStats();
res.json({
message: 'Сервер ospab.host запущен!',
timestamp: new Date().toISOString(),
port: PORT,
database: process.env.DATABASE_URL ? 'подключена' : 'НЕ НАСТРОЕНА',
websocket: {
connected_users: wsConnectedUsers,
rooms: wsRoomsStats
}
});
app.get('/', (_req, res) => {
res.json({ status: 'active', message: 'ospab backend active' });
});
// ==================== SITEMAP ====================
@@ -306,8 +325,8 @@ app.use('/api/auth', (req, res, next) => {
});
next();
});
app.use('/api/auth', authRoutes);
app.use('/api/auth', oauthRoutes);
app.use('/api/auth', authLimiter, authRoutes);
app.use('/api/auth', authLimiter, oauthRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/ticket', ticketRoutes);
app.use('/api/check', checkRoutes);
@@ -318,7 +337,7 @@ app.use('/api/sessions', sessionRoutes);
app.use('/api/qr-auth', qrAuthRoutes);
app.use('/api/storage', storageRoutes);
const PORT = process.env.PORT || 5000;
const PORT = parseInt(process.env.PORT || '5000', 10);
import { initWebSocketServer, getConnectedUsersCount, getRoomsStats } from './websocket/server';
import https from 'https';
@@ -381,7 +400,7 @@ app.use((err: any, _req: any, res: any, _next: any) => {
}
});
server.listen(PORT, () => {
server.listen(PORT, '0.0.0.0', () => {
logger.info(`${protocolLabel} сервер запущен на порту ${PORT}`);
logger.info(`База данных: ${process.env.DATABASE_URL ? 'подключена' : 'НЕ НАСТРОЕНА'}`);
logger.info(`API доступен: ${normalizedApiOrigin}`);

View File

@@ -1,4 +1,5 @@
import { Request, Response } from 'express';
import { logger } from '../../utils/logger';
import {
requestPasswordChange,
confirmPasswordChange,
@@ -29,7 +30,7 @@ export const getAccountInfo = async (req: Request, res: Response) => {
const userInfo = await getUserInfo(userId);
res.json(userInfo);
} catch (error) {
console.error('Ошибка получения информации об аккаунте:', error);
logger.error('Ошибка получения информации об аккаунте:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
};
@@ -76,7 +77,7 @@ export const requestPasswordChangeHandler = async (req: Request, res: Response)
message: 'Код подтверждения отправлен на вашу почту'
});
} catch (error: unknown) {
console.error('Ошибка запроса смены пароля:', error);
logger.error('Ошибка запроса смены пароля:', error);
res.status(500).json({ error: getErrorMessage(error) || 'Ошибка сервера' });
}
};
@@ -104,7 +105,7 @@ export const confirmPasswordChangeHandler = async (req: Request, res: Response)
message: 'Пароль успешно изменён'
});
} catch (error: unknown) {
console.error('Ошибка подтверждения смены пароля:', error);
logger.error('Ошибка подтверждения смены пароля:', error);
res.status(400).json({ error: getErrorMessage(error) || 'Ошибка подтверждения' });
}
};
@@ -144,7 +145,7 @@ export const requestUsernameChangeHandler = async (req: Request, res: Response)
message: 'Код подтверждения отправлен на вашу почту'
});
} catch (error: unknown) {
console.error('Ошибка запроса смены имени:', error);
logger.error('Ошибка запроса смены имени:', error);
res.status(400).json({ error: getErrorMessage(error) || 'Ошибка сервера' });
}
};
@@ -172,7 +173,7 @@ export const confirmUsernameChangeHandler = async (req: Request, res: Response)
message: 'Имя пользователя успешно изменено'
});
} catch (error: unknown) {
console.error('Ошибка подтверждения смены имени:', error);
logger.error('Ошибка подтверждения смены имени:', error);
res.status(400).json({ error: getErrorMessage(error) || 'Ошибка подтверждения' });
}
};
@@ -194,7 +195,7 @@ export const requestAccountDeletionHandler = async (req: Request, res: Response)
message: 'Код подтверждения отправлен на вашу почту. После подтверждения аккаунт будет удалён безвозвратно.'
});
} catch (error: unknown) {
console.error('Ошибка запроса удаления аккаунта:', error);
logger.error('Ошибка запроса удаления аккаунта:', error);
res.status(500).json({ error: getErrorMessage(error) || 'Ошибка сервера' });
}
};
@@ -222,7 +223,7 @@ export const confirmAccountDeletionHandler = async (req: Request, res: Response)
message: 'Аккаунт успешно удалён'
});
} catch (error: unknown) {
console.error('Ошибка подтверждения удаления аккаунта:', error);
logger.error('Ошибка подтверждения удаления аккаунта:', error);
res.status(400).json({ error: getErrorMessage(error) || 'Ошибка подтверждения' });
}
};

View File

@@ -1,4 +1,5 @@
import { prisma } from '../../prisma/client';
import { Prisma } from '@prisma/client';
import bcrypt from 'bcrypt';
import crypto from 'crypto';
import nodemailer from 'nodemailer';
@@ -275,7 +276,7 @@ export async function confirmAccountDeletion(userId: number, code: string): Prom
try {
// Каскадное удаление всех связанных данных пользователя в правильном порядке
await prisma.$transaction(async (tx) => {
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
// 1. Удаляем ответы в тикетах где пользователь является оператором
const responses = await tx.response.deleteMany({
where: { operatorId: userId }

View File

@@ -1,7 +1,9 @@
import { Request, Response } from 'express';
import { prisma } from '../../prisma/client';
import { Prisma } from '@prisma/client';
import { createNotification } from '../notification/notification.controller';
import { sendNotificationEmail } from '../notification/email.service';
import { logger } from '../../utils/logger';
function toNumeric(value: unknown): number {
if (typeof value === 'bigint') {
@@ -35,7 +37,7 @@ export const requireAdmin = async (req: Request, res: Response, next: any) => {
next();
} catch (error) {
console.error('Ошибка проверки прав админа:', error);
logger.error('Ошибка проверки прав админа:', error);
res.status(500).json({ message: 'Ошибка сервера' });
}
};
@@ -69,7 +71,7 @@ export class AdminController {
res.json({ status: 'success', data: users });
} catch (error) {
console.error('Ошибка получения пользователей:', error);
logger.error('Ошибка получения пользователей:', error);
res.status(500).json({ message: 'Ошибка получения пользователей' });
}
}
@@ -118,7 +120,7 @@ export class AdminController {
res.json({ status: 'success', data: safeUser });
} catch (error) {
console.error('Ошибка получения данных пользователя:', error);
logger.error('Ошибка получения данных пользователя:', error);
res.status(500).json({ message: 'Ошибка получения данных' });
}
}
@@ -177,7 +179,7 @@ export class AdminController {
newBalance: balanceAfter
});
} catch (error) {
console.error('Ошибка пополнения баланса:', error);
logger.error('Ошибка пополнения баланса:', error);
res.status(500).json({ message: 'Ошибка пополнения баланса' });
}
}
@@ -240,7 +242,7 @@ export class AdminController {
newBalance: balanceAfter
});
} catch (error) {
console.error('Ошибка списания средств:', error);
logger.error('Ошибка списания средств:', error);
res.status(500).json({ message: 'Ошибка списания средств' });
}
}
@@ -279,7 +281,7 @@ export class AdminController {
message: `Бакет «${bucket.name}» удалён`
});
} catch (error) {
console.error('Ошибка удаления бакета:', error);
logger.error('Ошибка удаления бакета:', error);
res.status(500).json({ message: 'Ошибка удаления бакета' });
}
}
@@ -372,7 +374,7 @@ export class AdminController {
}
});
} catch (error) {
console.error('Ошибка получения статистики:', error);
logger.error('Ошибка получения статистики:', error);
res.status(500).json({ message: 'Ошибка получения статистики' });
}
}
@@ -399,7 +401,7 @@ export class AdminController {
message: 'Права пользователя обновлены'
});
} catch (error) {
console.error('Ошибка обновления прав:', error);
logger.error('Ошибка обновления прав:', error);
res.status(500).json({ message: 'Ошибка обновления прав' });
}
}
@@ -428,7 +430,7 @@ export class AdminController {
return res.status(404).json({ message: 'Пользователь не найден' });
}
await prisma.$transaction(async (tx) => {
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await tx.ticket.updateMany({
where: { assignedTo: userId },
data: { assignedTo: null }
@@ -460,7 +462,7 @@ export class AdminController {
message: `Пользователь ${user.username} удалён.`
});
} catch (error) {
console.error('Ошибка удаления пользователя администратором:', error);
logger.error('Ошибка удаления пользователя администратором:', error);
res.status(500).json({ message: 'Не удалось удалить пользователя' });
}
}
@@ -479,7 +481,7 @@ export class AdminController {
const now = new Date().toISOString();
const logMsg = `[Admin] PUSH-TEST | userId=${user.id} | username=${user.username} | email=${user.email} | time=${now}`;
console.log(logMsg);
logger.info(logMsg);
// Здесь должна быть реальная отправка push (имитация)
await new Promise(resolve => setTimeout(resolve, 500));
@@ -497,7 +499,7 @@ export class AdminController {
}
});
} catch (error) {
console.error('[Admin] Ошибка при тестировании push-уведомления:', error);
logger.error('[Admin] Ошибка при тестировании push-уведомления:', error);
const message = error instanceof Error ? error.message : 'Неизвестная ошибка';
return res.status(500).json({ error: `Ошибка при тестировании: ${message}` });
}
@@ -521,7 +523,7 @@ export class AdminController {
const now = new Date().toISOString();
const logMsg = `[Admin] EMAIL-TEST | userId=${user.id} | username=${user.username} | email=${user.email} | time=${now}`;
console.log(logMsg);
logger.info(logMsg);
// Отправляем реальное email уведомление
const emailResult = await sendNotificationEmail({
@@ -562,7 +564,7 @@ export class AdminController {
}
});
} catch (error) {
console.error('[Admin] Ошибка при тестировании email-уведомления:', error);
logger.error('[Admin] Ошибка при тестировании email-уведомления:', error);
const message = error instanceof Error ? error.message : 'Неизвестная ошибка';
return res.status(500).json({ error: `Ошибка при тестировании: ${message}` });
}

View File

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

View File

@@ -1,4 +1,5 @@
import axios from 'axios';
import { logger } from '../../utils/logger';
const TURNSTILE_SECRET_KEY = process.env.TURNSTILE_SECRET_KEY;
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
@@ -20,7 +21,7 @@ export async function validateTurnstileToken(
remoteip?: string
): Promise<TurnstileValidationResult> {
if (!TURNSTILE_SECRET_KEY) {
console.error('TURNSTILE_SECRET_KEY не найден в переменных окружения');
logger.error('TURNSTILE_SECRET_KEY не найден в переменных окружения');
return {
success: false,
message: 'Turnstile не настроен на сервере',
@@ -60,7 +61,7 @@ export async function validateTurnstileToken(
};
}
} catch (error) {
console.error('Ошибка при валидации Turnstile:', error);
logger.error('Ошибка при валидации Turnstile:', error);
return {
success: false,
message: 'Ошибка при проверке капчи',

View File

@@ -1,5 +1,6 @@
import { Request, Response } from 'express';
import { prisma } from '../../prisma/client';
import { logger } from '../../utils/logger';
// Получить все опубликованные посты (публичный доступ)
export const getAllPosts = async (req: Request, res: Response) => {
@@ -19,7 +20,7 @@ export const getAllPosts = async (req: Request, res: Response) => {
res.json({ success: true, data: posts });
} catch (error) {
console.error('Ошибка получения постов:', error);
logger.error('Ошибка получения постов:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
@@ -59,7 +60,7 @@ export const getPostByUrl = async (req: Request, res: Response) => {
res.json({ success: true, data: post });
} catch (error) {
console.error('Ошибка получения поста:', error);
logger.error('Ошибка получения поста:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
@@ -105,7 +106,7 @@ export const addComment = async (req: Request, res: Response) => {
res.json({ success: true, data: comment, message: 'Комментарий отправлен на модерацию' });
} catch (error) {
console.error('Ошибка добавления комментария:', error);
logger.error('Ошибка добавления комментария:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
@@ -129,7 +130,7 @@ export const getAllPostsAdmin = async (req: Request, res: Response) => {
res.json({ success: true, data: posts });
} catch (error) {
console.error('Ошибка получения постов:', error);
logger.error('Ошибка получения постов:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
@@ -157,7 +158,7 @@ export const getPostByIdAdmin = async (req: Request, res: Response) => {
res.json({ success: true, data: post });
} catch (error) {
console.error('Ошибка получения поста:', error);
logger.error('Ошибка получения поста:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
@@ -201,7 +202,7 @@ export const createPost = async (req: Request, res: Response) => {
res.json({ success: true, data: post });
} catch (error) {
console.error('Ошибка создания поста:', error);
logger.error('Ошибка создания поста:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
@@ -244,7 +245,7 @@ export const updatePost = async (req: Request, res: Response) => {
res.json({ success: true, data: post });
} catch (error) {
console.error('Ошибка обновления поста:', error);
logger.error('Ошибка обновления поста:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
@@ -260,7 +261,7 @@ export const deletePost = async (req: Request, res: Response) => {
res.json({ success: true, message: 'Пост удалён' });
} catch (error) {
console.error('Ошибка удаления поста:', error);
logger.error('Ошибка удаления поста:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
@@ -282,7 +283,7 @@ export const getAllComments = async (req: Request, res: Response) => {
res.json({ success: true, data: comments });
} catch (error) {
console.error('Ошибка получения комментариев:', error);
logger.error('Ошибка получения комментариев:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
@@ -304,7 +305,7 @@ export const moderateComment = async (req: Request, res: Response) => {
res.json({ success: true, data: comment });
} catch (error) {
console.error('Ошибка модерации комментария:', error);
logger.error('Ошибка модерации комментария:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
@@ -320,7 +321,7 @@ export const deleteComment = async (req: Request, res: Response) => {
res.json({ success: true, message: 'Комментарий удалён' });
} catch (error) {
console.error('Ошибка удаления комментария:', error);
logger.error('Ошибка удаления комментария:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};

View File

@@ -2,6 +2,7 @@
import { Request, Response } from 'express';
import path from 'path';
import fs from 'fs';
import { logger } from '../../utils/logger';
export const uploadImage = async (req: Request, res: Response) => {
try {
@@ -23,7 +24,7 @@ export const uploadImage = async (req: Request, res: Response) => {
}
});
} catch (error) {
console.error('Ошибка загрузки изображения:', error);
logger.error('Ошибка загрузки изображения:', error);
return res.status(500).json({
success: false,
message: 'Ошибка загрузки изображения'
@@ -58,7 +59,7 @@ export const deleteImage = async (req: Request, res: Response) => {
});
}
} catch (error) {
console.error('Ошибка удаления изображения:', error);
logger.error('Ошибка удаления изображения:', error);
return res.status(500).json({
success: false,
message: 'Ошибка удаления изображения'

View File

@@ -114,7 +114,7 @@ export const getNotifications = async (req: Request, res: Response) => {
}
});
} catch (error) {
console.error('Ошибка получения уведомлений:', error);
logger.error('Ошибка получения уведомлений:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
@@ -134,7 +134,7 @@ export const getUnreadCount = async (req: Request, res: Response) => {
res.json({ success: true, count });
} catch (error) {
console.error('Ошибка подсчета непрочитанных:', error);
logger.error('Ошибка подсчета непрочитанных:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
@@ -174,7 +174,7 @@ export const markAsRead = async (req: Request, res: Response) => {
res.json({ success: true, message: 'Отмечено как прочитанное' });
} catch (error) {
console.error('Ошибка отметки уведомления:', error);
logger.error('Ошибка отметки уведомления:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
@@ -195,7 +195,7 @@ export const markAllAsRead = async (req: Request, res: Response) => {
res.json({ success: true, message: 'Все уведомления прочитаны' });
} catch (error) {
console.error('Ошибка отметки всех уведомлений:', error);
logger.error('Ошибка отметки всех уведомлений:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
@@ -234,7 +234,7 @@ export const deleteNotification = async (req: Request, res: Response) => {
res.json({ success: true, message: 'Уведомление удалено' });
} catch (error) {
console.error('Ошибка удаления уведомления:', error);
logger.error('Ошибка удаления уведомления:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
@@ -254,7 +254,7 @@ export const deleteAllRead = async (req: Request, res: Response) => {
res.json({ success: true, message: 'Прочитанные уведомления удалены' });
} catch (error) {
console.error('Ошибка удаления прочитанных:', error);
logger.error('Ошибка удаления прочитанных:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
@@ -322,7 +322,7 @@ export async function createNotification(params: CreateNotificationParams) {
}
});
} catch (pushError) {
console.error('Ошибка отправки Push:', pushError);
logger.error('Ошибка отправки Push:', pushError);
// Не прерываем выполнение если Push не отправился
}
} else {
@@ -346,7 +346,7 @@ export async function createNotification(params: CreateNotificationParams) {
logger.warn(`[Email] Уведомление ${notification.id} пропущено: ${result.message}`);
}
} catch (emailError) {
console.error('Ошибка отправки email уведомления:', emailError);
logger.error('Ошибка отправки email уведомления:', emailError);
}
} else if (!email) {
logger.debug(`Email уведомление для пользователя ${params.userId} пропущено: отсутствует адрес`);
@@ -356,7 +356,7 @@ export async function createNotification(params: CreateNotificationParams) {
return notification;
} catch (error) {
console.error('Ошибка создания уведомления:', error);
logger.error('Ошибка создания уведомления:', error);
throw error;
}
}
@@ -367,7 +367,7 @@ export const getVapidKey = async (req: Request, res: Response) => {
const publicKey = getVapidPublicKey();
res.json({ success: true, publicKey });
} catch (error) {
console.error('Ошибка получения VAPID ключа:', error);
logger.error('Ошибка получения VAPID ключа:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
@@ -388,7 +388,7 @@ export const subscribe = async (req: Request, res: Response) => {
res.json({ success: true, message: 'Push-уведомления подключены' });
} catch (error) {
console.error('Ошибка подписки на Push:', error);
logger.error('Ошибка подписки на Push:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
@@ -408,7 +408,7 @@ export const unsubscribe = async (req: Request, res: Response) => {
res.json({ success: true, message: 'Push-уведомления отключены' });
} catch (error) {
console.error('Ошибка отписки от Push:', error);
logger.error('Ошибка отписки от Push:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};

View File

@@ -1,5 +1,6 @@
import webpush from 'web-push';
import { prisma } from '../../prisma/client';
import { logger } from '../../utils/logger';
// VAPID ключи (нужно сгенерировать один раз и сохранить в .env)
// Для генерации: npx web-push generate-vapid-keys
@@ -55,7 +56,7 @@ export async function subscribePush(userId: number, subscription: {
return pushSubscription;
} catch (error) {
console.error('Ошибка сохранения Push-подписки:', error);
logger.error('Ошибка сохранения Push-подписки:', error);
throw error;
}
}
@@ -70,7 +71,7 @@ export async function unsubscribePush(userId: number, endpoint: string) {
}
});
} catch (error) {
console.error('Ошибка удаления Push-подписки:', error);
logger.error('Ошибка удаления Push-подписки:', error);
throw error;
}
}
@@ -130,14 +131,14 @@ export async function sendPushNotification(
where: { id: sub.id }
});
} else {
console.error(`Ошибка отправки Push на ${sub.endpoint}:`, error);
logger.error(`Ошибка отправки Push на ${sub.endpoint}:`, error);
}
}
});
await Promise.allSettled(promises);
} catch (error) {
console.error('Ошибка отправки Push-уведомлений:', error);
logger.error('Ошибка отправки Push-уведомлений:', error);
throw error;
}
}

View File

@@ -32,7 +32,7 @@ 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({
logger.info('[QR Create] Создан QR-запрос', JSON.stringify({
code: qrRequest.code,
ipAddress: qrRequest.ipAddress,
userAgent: qrRequest.userAgent?.toString?.().slice(0, 200),

View File

@@ -81,7 +81,7 @@ export async function getUserSessions(req: Request, res: Response) {
res.json(sessionsWithCurrent);
} catch (error) {
console.error('Ошибка получения сессий:', error);
logger.error('Ошибка получения сессий:', error);
res.status(500).json({ error: 'Ошибка получения сессий' });
}
}
@@ -109,7 +109,7 @@ export async function deleteSession(req: Request, res: Response) {
res.json({ message: 'Сессия удалена' });
} catch (error) {
console.error('Ошибка удаления сессии:', error);
logger.error('Ошибка удаления сессии:', error);
res.status(500).json({ error: 'Ошибка удаления сессии' });
}
}
@@ -136,7 +136,7 @@ export async function deleteAllOtherSessions(req: Request, res: Response) {
deletedCount: result.count
});
} catch (error) {
console.error('Ошибка удаления сессий:', error);
logger.error('Ошибка удаления сессий:', error);
res.status(500).json({ error: 'Ошибка удаления сессий' });
}
}

View File

@@ -1,5 +1,6 @@
import { Request, Response } from 'express';
import { prisma } from '../../prisma/client';
import { logger } from '../../utils/logger';
export async function generateSitemap(req: Request, res: Response) {
try {
@@ -51,7 +52,7 @@ export async function generateSitemap(req: Request, res: Response) {
res.header('Content-Type', 'application/xml');
res.send(xml);
} catch (error) {
console.error('Ошибка генерации sitemap:', error);
logger.error('Ошибка генерации sitemap:', error);
res.status(500).json({ error: 'Ошибка генерации sitemap' });
}
}

View File

@@ -1,4 +1,5 @@
import axios from 'axios';
import { logger } from '../../utils/logger';
/**
* Cloudflare API для проверки файлов
@@ -32,7 +33,7 @@ export async function scanFileWithVirusTotal(
fileName: string,
): Promise<FileScanResult> {
if (!VIRUSTOTAL_API_KEY) {
console.warn('[FileScanner] VirusTotal API key не настроена');
logger.warn('[FileScanner] VirusTotal API key не настроена');
return {
isSafe: true,
detections: 0,
@@ -82,7 +83,7 @@ export async function scanFileWithVirusTotal(
return uploadFileForAnalysis(fileBuffer, fileName);
}
console.error('[FileScanner] Ошибка при проверке по хешу:', hashError);
logger.error('[FileScanner] Ошибка при проверке по хешу:', hashError);
throw new Error('Не удалось проверить файл на вирусы');
}
}
@@ -117,7 +118,7 @@ async function uploadFileForAnalysis(
return analysisResult;
} catch (error) {
console.error('[FileScanner] Ошибка при загрузке файла на анализ:', error);
logger.error('[FileScanner] Ошибка при загрузке файла на анализ:', error);
throw new Error('Не удалось загрузить файл на анализ');
}
}
@@ -167,7 +168,7 @@ async function waitForAnalysisCompletion(
// Ждём перед следующей попыткой
await new Promise((resolve) => setTimeout(resolve, 1000));
} catch (error) {
console.error(`[FileScanner] Ошибка при проверке статуса анализа (попытка ${attempt + 1}):`, error);
logger.error(`[FileScanner] Ошибка при проверке статуса анализа (попытка ${attempt + 1}):`, error);
}
}
@@ -260,7 +261,7 @@ export async function validateFileForUpload(
};
} catch (error) {
// Если сканирование не удалось, позволяем загрузку, но логируем ошибку
console.error('[FileScanner] Ошибка сканирования:', error);
logger.error('[FileScanner] Ошибка сканирования:', error);
return {
isValid: true, // Не блокируем загрузку при ошибке сканирования
};

View File

@@ -1,5 +1,6 @@
import { Router } from 'express';
import axios from 'axios';
import { logger } from '../../utils/logger';
import {
createBucket,
listBuckets,
@@ -62,7 +63,7 @@ router.put('/plans/:id', authMiddleware, async (req, res) => {
return res.json({ success: true, plan: updated });
} catch (error) {
console.error('[Storage] Ошибка обновления тарифа:', error);
logger.error('[Storage] Ошибка обновления тарифа:', error);
const message = error instanceof Error ? error.message : 'Не удалось обновить тариф';
return res.status(500).json({ error: message });
}
@@ -73,7 +74,7 @@ router.get('/plans', async (_req, res) => {
const plans = await listStoragePlans();
return res.json({ plans });
} catch (error) {
console.error('[Storage] Ошибка получения тарифов:', error);
logger.error('[Storage] Ошибка получения тарифов:', error);
return res.status(500).json({ error: 'Не удалось загрузить тарифы' });
}
});
@@ -83,7 +84,7 @@ router.get('/regions', async (_req, res) => {
const regions = await listStorageRegions();
return res.json({ regions });
} catch (error) {
console.error('[Storage] Ошибка получения регионов:', error);
logger.error('[Storage] Ошибка получения регионов:', error);
return res.status(500).json({ error: 'Не удалось загрузить список регионов' });
}
});
@@ -93,7 +94,7 @@ router.get('/classes', async (_req, res) => {
const classes = await listStorageClasses();
return res.json({ classes });
} catch (error) {
console.error('[Storage] Ошибка получения классов хранения:', error);
logger.error('[Storage] Ошибка получения классов хранения:', error);
return res.status(500).json({ error: 'Не удалось загрузить список классов хранения' });
}
});
@@ -103,7 +104,7 @@ router.get('/status', async (_req, res) => {
const status = await getStorageStatus();
return res.json(status);
} catch (error) {
console.error('[Storage] Ошибка получения статуса хранилища:', error);
logger.error('[Storage] Ошибка получения статуса хранилища:', error);
return res.status(500).json({ error: 'Не удалось получить статус хранилища' });
}
});
@@ -128,7 +129,7 @@ router.post('/checkout', optionalAuthMiddleware, async (req, res) => {
return res.json(session);
} catch (error) {
const message = error instanceof Error ? error.message : 'Не удалось создать корзину';
console.error('[Storage] Ошибка создания корзины:', error);
logger.error('[Storage] Ошибка создания корзины:', error);
return res.status(400).json({ error: message });
}
});
@@ -323,20 +324,20 @@ router.post('/buckets/:id/objects/download-from-uri', async (req, res) => {
const id = Number(req.params.id);
const { url } = req.body ?? {};
console.log(`[Storage URI Download] Начало загрузки - userId: ${userId}, bucketId: ${id}, url: ${url}`);
logger.info(`[Storage URI Download] Начало загрузки - userId: ${userId}, bucketId: ${id}, url: ${url}`);
if (!url) {
console.log('[Storage URI Download] Ошибка: URL не указан');
logger.info('[Storage URI Download] Ошибка: URL не указан');
return res.status(400).json({ error: 'Не указан URL' });
}
// Проверяем что пользователь имеет доступ к бакету
console.log('[Storage URI Download] Проверка доступа к бакету...');
logger.info('[Storage URI Download] Проверка доступа к бакету...');
await getBucket(userId, id); // Проверка доступа
console.log('[Storage URI Download] Доступ к бакету подтверждён');
logger.info('[Storage URI Download] Доступ к бакету подтверждён');
// Загружаем файл с URL с увеличенным timeout
console.log(`[Storage URI Download] Загрузка файла с ${url}...`);
logger.info(`[Storage URI Download] Загрузка файла с ${url}...`);
const response = await axios.get(url, {
responseType: 'arraybuffer',
timeout: 120000, // 120 seconds (2 minutes)
@@ -350,11 +351,11 @@ router.post('/buckets/:id/objects/download-from-uri', async (req, res) => {
const buffer = response.data;
const bufferSize = Buffer.isBuffer(buffer) ? buffer.length : (buffer as ArrayBuffer).byteLength;
console.log(`[Storage URI Download] Файл загружен успешно - размер: ${bufferSize} байт, mimeType: ${mimeType}`);
logger.info(`[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} символов`);
logger.info(`[Storage URI Download] Base64 длина: ${base64Data.length} символов`);
return res.json({
blob: base64Data,
@@ -362,10 +363,10 @@ router.post('/buckets/:id/objects/download-from-uri', async (req, res) => {
});
} catch (e: unknown) {
let message = 'Ошибка загрузки файла по URI';
console.error('[Storage URI Download] Ошибка:', e);
logger.error('[Storage URI Download] Ошибка:', e);
if (axios.isAxiosError(e)) {
console.error('[Storage URI Download] Axios ошибка:', {
logger.error('[Storage URI Download] Axios ошибка:', {
status: e.response?.status,
statusText: e.response?.statusText,
headers: e.response?.headers,
@@ -388,7 +389,7 @@ router.post('/buckets/:id/objects/download-from-uri', async (req, res) => {
message = e.message;
}
console.error('[Storage URI Download] Возвращаем ошибку клиенту:', message);
logger.error('[Storage URI Download] Возвращаем ошибку клиенту:', message);
return res.status(400).json({ error: message });
}
});

View File

@@ -4,6 +4,8 @@ import axios from 'axios';
import { exec } from 'child_process';
import { promisify } from 'util';
import type { StorageBucket } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { logger } from '../../utils/logger';
import { prisma } from '../../prisma/client';
import { ensureBucketExists, buildPhysicalBucketName, minioClient } from './minioClient';
@@ -57,11 +59,11 @@ async function ensureMinioAlias(): Promise<void> {
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}`);
logger.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);
logger.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>`);
}
}
@@ -80,10 +82,10 @@ async function ensureMinioAlias(): Promise<void> {
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');
logger.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);
logger.error('[MinIO Admin] Failed to configure alias:', (error as Error).message);
throw new Error(`Не удалось настроить подключение к MinIO: ${(error as Error).message}`);
}
}
@@ -205,7 +207,7 @@ function logConsoleWarning(error: unknown) {
return;
}
consoleSupportLogged = true;
console.warn('[Storage] Таблица storage_console_credential недоступна. Продолжаем без данных консоли.', error);
logger.warn('[Storage] Таблица storage_console_credential недоступна. Продолжаем без данных консоли.', error);
}
async function ensureConsoleCredentialSupport(client: any = prisma): Promise<boolean> {
@@ -230,11 +232,11 @@ async function ensureConsoleCredentialSupport(client: any = prisma): Promise<boo
} 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');
logger.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');
logger.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)) {
@@ -380,7 +382,7 @@ function generateConsolePassword(): string {
*/
async function createMinioUser(username: string, password: string): Promise<void> {
if (!MINIO_MC_ENABLED) {
console.warn(`[MinIO Admin] mc CLI disabled, skipping user creation for ${username}`);
logger.warn(`[MinIO Admin] mc CLI disabled, skipping user creation for ${username}`);
return;
}
@@ -393,21 +395,21 @@ async function createMinioUser(username: string, password: string): Promise<void
try {
const { stdout, stderr } = await execAsync(createUserCmd, { timeout: 10000 });
console.info(`[MinIO Admin] User ${username} created/updated:`, stdout.trim() || stderr.trim());
logger.info(`[MinIO Admin] User ${username} created/updated:`, stdout.trim() || stderr.trim());
} catch (error: unknown) {
const errorMsg = (error as Record<string, any>)?.stderr || (error as Error)?.message || '';
// Check if error is because user already exists
if (errorMsg.includes('already exists') || errorMsg.includes('exists')) {
console.warn(`[MinIO Admin] User ${username} already exists, updating password`);
logger.warn(`[MinIO Admin] User ${username} already exists, updating password`);
// Try to update password
try {
const changePassCmd = `mc admin user chpass ${MINIO_ALIAS} "${username}" "${password}"`;
const { stdout: chpassOut } = await execAsync(changePassCmd, { timeout: 10000 });
console.info(`[MinIO Admin] Password updated for user ${username}:`, chpassOut.trim());
logger.info(`[MinIO Admin] Password updated for user ${username}:`, chpassOut.trim());
} catch (changeError: unknown) {
console.error(`[MinIO Admin] Could not update password:`, (changeError as Error)?.message);
logger.error(`[MinIO Admin] Could not update password:`, (changeError as Error)?.message);
// Don't throw, user exists anyway
}
} else {
@@ -416,10 +418,10 @@ async function createMinioUser(username: string, password: string): Promise<void
}
} catch (error) {
if (error instanceof Error) {
console.error('[MinIO Admin] Error creating user:', error.message);
logger.error('[MinIO Admin] Error creating user:', error.message);
// Don't throw - this is a non-critical operation
// The credential will still be saved in DB
console.warn(`[MinIO Admin] User creation failed but continuing. User will need to be created manually via mc: mc admin user add minio "${username}" "<password>"`);
logger.warn(`[MinIO Admin] User creation failed but continuing. User will need to be created manually via mc: mc admin user add minio "${username}" "<password>"`);
} else {
throw error;
}
@@ -432,7 +434,7 @@ async function createMinioUser(username: string, password: string): Promise<void
*/
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}`);
logger.warn(`[MinIO Admin] mc CLI disabled, skipping service account creation for ${accessKey}`);
return;
}
@@ -445,13 +447,13 @@ async function createMinioServiceAccount(accessKey: string, secretKey: string, b
try {
const { stdout, stderr } = await execAsync(createUserCmd, { timeout: 10000 });
console.info(`[MinIO Admin] Service account ${accessKey} created:`, stdout.trim() || stderr.trim());
logger.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`);
logger.warn(`[MinIO Admin] User ${accessKey} already exists`);
}
// Create bucket-specific policy JSON
@@ -496,21 +498,21 @@ async function createMinioServiceAccount(accessKey: string, secretKey: string, b
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());
logger.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...`);
logger.info(`[MinIO Admin] Policy ${policyName} already exists, skipping...`);
} else {
console.warn(`[MinIO Admin] Policy creation warning:`, policyErrMsg);
logger.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());
logger.info(`[MinIO Admin] Policy ${policyName} attached to user ${accessKey}:`, attachOut.trim());
} finally {
// Cleanup temp file
try {
@@ -520,10 +522,10 @@ async function createMinioServiceAccount(accessKey: string, secretKey: string, b
}
}
console.info(`[MinIO Admin] Service account ${accessKey} created with access to bucket ${bucketName}`);
logger.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);
logger.error('[MinIO Admin] Error creating service account:', error.message);
throw new Error(`Не удалось создать ключ доступа в MinIO: ${error.message}`);
}
throw error;
@@ -866,7 +868,7 @@ export async function markCheckoutSessionConsumed(cartId: string) {
const session = await checkoutSessionDelegate().findUnique({ where: { id: cartId } }) as CheckoutSessionRecord | null;
if (!session) throw new Error('Корзина не найдена');
await prisma.$transaction(async (tx) => {
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const checkoutDelegate = (tx as any).storageCheckoutSession;
if (checkoutDelegate) {
await checkoutDelegate.update({ where: { id: cartId }, data: { consumedAt: new Date() } });
@@ -877,7 +879,7 @@ export async function markCheckoutSessionConsumed(cartId: string) {
}
});
} catch (error) {
console.warn(`[Storage] Не удалось пометить корзину ${cartId} как использованную`, error);
logger.warn(`[Storage] Не удалось пометить корзину ${cartId} как использованную`, error);
}
}
@@ -1016,7 +1018,7 @@ async function syncBucketUsage(bucket: BucketWithPlan): Promise<BucketWithPlan>
});
return updated as BucketWithPlan;
} catch (error) {
console.error(`[Storage] Не удалось синхронизировать usage для бакета ${bucket.id}`, error);
logger.error(`[Storage] Не удалось синхронизировать usage для бакета ${bucket.id}`, error);
return bucket;
}
}
@@ -1051,7 +1053,7 @@ async function applyPublicPolicy(physicalName: string, isPublic: boolean) {
await minioClient.setBucketPolicy(physicalName, '');
}
} catch (error) {
console.error(`[Storage] Не удалось применить политику для бакета ${physicalName}`, error);
logger.error(`[Storage] Не удалось применить политику для бакета ${physicalName}`, error);
}
}
@@ -1061,7 +1063,7 @@ async function applyVersioning(physicalName: string, enabled: boolean) {
Status: enabled ? 'Enabled' : 'Suspended'
});
} catch (error) {
console.error(`[Storage] Не удалось обновить версионирование для ${physicalName}`, error);
logger.error(`[Storage] Не удалось обновить версионирование для ${physicalName}`, error);
}
}
@@ -1133,7 +1135,7 @@ export async function createBucket(data: CreateBucketInput) {
await ensureBucketExists(physicalName, regionCode);
try {
const createdBucket = await prisma.$transaction<BucketWithPlan>(async (tx) => {
const createdBucket = await prisma.$transaction<BucketWithPlan>(async (tx: Prisma.TransactionClient) => {
const reloadedUser = await tx.user.findUnique({ where: { id: data.userId } });
if (!reloadedUser) throw new Error('Пользователь не найден');
if (toPlainNumber(reloadedUser.balance) < planPrice) throw new Error('Недостаточно средств');
@@ -1236,11 +1238,11 @@ export async function createBucket(data: CreateBucketInput) {
} catch (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');
logger.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');
logger.warn('[Storage] Cleanup skipped, bucket not found');
} else {
console.error('[Storage] Ошибка очистки бакета после неудачного создания', cleanupError);
logger.error('[Storage] Ошибка очистки бакета после неудачного создания', cleanupError);
}
}
throw error;
@@ -1306,11 +1308,11 @@ export async function generateConsoleCredentials(userId: number, id: number) {
try {
await createMinioUser(login, password);
} catch (minioError) {
console.warn('[Storage] MinIO user creation failed, but continuing:', (minioError as Error).message);
logger.warn('[Storage] MinIO user creation failed, but continuing:', (minioError as Error).message);
}
try {
await prisma.$transaction(async (tx) => {
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
if (!(await ensureConsoleCredentialSupport(tx))) {
throw new Error('MinIO Console недоступна. Обратитесь в поддержку.');
}
@@ -1343,7 +1345,7 @@ export async function generateConsoleCredentials(userId: number, id: number) {
throw error;
}
console.info(`[Storage] Пользователь ${userId} сгенерировал данные входа MinIO Console для бакета ${id}`);
logger.info(`[Storage] Пользователь ${userId} сгенерировал данные входа MinIO Console для бакета ${id}`);
return {
login,
@@ -1361,11 +1363,11 @@ export async function deleteBucket(userId: number, id: number, force = false) {
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');
logger.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');
logger.warn('[Storage] Bucket not found in MinIO while attempting delete; continuing with DB cleanup');
keys = [];
} else {
throw err;
@@ -1384,11 +1386,11 @@ export async function deleteBucket(userId: number, id: number, force = false) {
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');
logger.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');
logger.warn('[Storage] Bucket not found while deleting objects; skipping');
break;
}
throw err;
@@ -1400,11 +1402,11 @@ export async function deleteBucket(userId: number, id: number, force = false) {
await minioClient.removeBucket(physicalName);
} catch (err: unknown) {
if ((err as any)?.code === 'MINIO_AUTH_ERROR') {
console.error('[Storage] MinIO authentication error while removing bucket');
logger.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');
logger.warn('[Storage] Bucket not found when attempting to remove; continuing with DB cleanup');
} else {
throw err;
}
@@ -1663,7 +1665,7 @@ export async function revokeAccessKey(userId: number, id: number, keyId: number)
*/
async function deleteMinioServiceAccount(accessKey: string): Promise<void> {
if (!MINIO_MC_ENABLED) {
console.warn(`[MinIO Admin] mc CLI disabled, skipping service account deletion for ${accessKey}`);
logger.warn(`[MinIO Admin] mc CLI disabled, skipping service account deletion for ${accessKey}`);
return;
}
@@ -1676,17 +1678,17 @@ async function deleteMinioServiceAccount(accessKey: string): Promise<void> {
try {
const { stdout } = await execAsync(removeUserCmd, { timeout: 10000 });
console.info(`[MinIO Admin] Service account ${accessKey} removed:`, stdout.trim());
logger.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);
logger.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);
logger.error('[MinIO Admin] Error deleting service account:', (error as Error).message);
}
}

View File

@@ -3,6 +3,7 @@ import type { Request, Response } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { logger } from '../../utils/logger';
interface SerializedUserSummary {
id: number;
@@ -198,7 +199,7 @@ export async function createTicket(req: Request, res: Response) {
return res.json({ ticket: normalizedTicket });
} catch (err) {
console.error('Ошибка создания тикета:', err);
logger.error('Ошибка создания тикета:', err);
return res.status(500).json({ error: 'Ошибка создания тикета' });
}
}
@@ -342,7 +343,7 @@ export async function getTickets(req: Request, res: Response) {
stats,
});
} catch (err) {
console.error('Ошибка получения тикетов:', err);
logger.error('Ошибка получения тикетов:', err);
return res.status(500).json({ error: 'Ошибка получения тикетов' });
}
}
@@ -409,7 +410,7 @@ export async function getTicketById(req: Request, res: Response) {
return res.json({ ticket: normalizedTicket });
} catch (err) {
console.error('Ошибка получения тикета:', err);
logger.error('Ошибка получения тикета:', err);
return res.status(500).json({ error: 'Ошибка получения тикета' });
}
}
@@ -503,7 +504,7 @@ export async function respondTicket(req: Request, res: Response) {
assignedTo: updateData.assignedTo ?? ticket.assignedTo ?? null,
});
} catch (err) {
console.error('Ошибка ответа на тикет:', err);
logger.error('Ошибка ответа на тикет:', err);
return res.status(500).json({ error: 'Ошибка ответа на тикет' });
}
}
@@ -573,7 +574,7 @@ export async function updateTicketStatus(req: Request, res: Response) {
return res.json({ ticket: normalizedTicket });
} catch (err) {
console.error('Ошибка изменения статуса тикета:', err);
logger.error('Ошибка изменения статуса тикета:', err);
return res.status(500).json({ error: 'Ошибка изменения статуса тикета' });
}
}
@@ -644,7 +645,7 @@ export async function assignTicket(req: Request, res: Response) {
return res.json({ ticket: normalizedTicket });
} catch (err) {
console.error('Ошибка назначения тикета:', err);
logger.error('Ошибка назначения тикета:', err);
return res.status(500).json({ error: 'Ошибка назначения тикета' });
}
}
@@ -689,7 +690,7 @@ export async function closeTicket(req: Request, res: Response) {
return res.json({ success: true, message: 'Тикет закрыт' });
} catch (err) {
console.error('Ошибка закрытия тикета:', err);
logger.error('Ошибка закрытия тикета:', err);
return res.status(500).json({ error: 'Ошибка закрытия тикета' });
}
}

View File

@@ -2,6 +2,7 @@ import { Request, Response } from 'express';
import { prisma } from '../../prisma/client';
import bcrypt from 'bcrypt';
import crypto from 'crypto';
import { logger } from '../../utils/logger';
// Получить профиль пользователя (расширенный)
export const getProfile = async (req: Request, res: Response) => {
@@ -34,7 +35,7 @@ export const getProfile = async (req: Request, res: Response) => {
res.json({ success: true, data: userWithoutPassword });
} catch (error: unknown) {
console.error('Ошибка получения профиля:', error);
logger.error('Ошибка получения профиля:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
@@ -49,7 +50,8 @@ export const updateProfile = async (req: Request, res: Response) => {
// Проверка email на уникальность
if (email) {
const existingUser = await prisma.user.findFirst({
where: { email, id: { not: userId } }
where: { email, id: { not: userId } },
select: { id: true }
});
if (existingUser) {
return res.status(400).json({ success: false, message: 'Email уже используется' });
@@ -87,7 +89,7 @@ export const updateProfile = async (req: Request, res: Response) => {
data: { user: updatedUser, profile }
});
} catch (error: unknown) {
console.error('Ошибка обновления профиля:', error);
logger.error('Ошибка обновления профиля:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
@@ -108,7 +110,10 @@ export const changePassword = async (req: Request, res: Response) => {
}
// Проверка текущего пароля
const user = await prisma.user.findUnique({ where: { id: userId } });
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, password: true }
});
if (!user) {
return res.status(404).json({ success: false, message: 'Пользователь не найден' });
}
@@ -132,7 +137,7 @@ export const changePassword = async (req: Request, res: Response) => {
res.json({ success: true, message: 'Пароль успешно изменён' });
} catch (error: unknown) {
console.error('Ошибка смены пароля:', error);
logger.error('Ошибка смены пароля:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
@@ -162,7 +167,7 @@ export const uploadAvatar = async (req: Request, res: Response) => {
data: { avatarUrl }
});
} catch (error: unknown) {
console.error('Ошибка загрузки аватара:', error);
logger.error('Ошибка загрузки аватара:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
@@ -180,7 +185,7 @@ export const deleteAvatar = async (req: Request, res: Response) => {
res.json({ success: true, message: 'Аватар удалён' });
} catch (error: unknown) {
console.error('Ошибка удаления аватара:', error);
logger.error('Ошибка удаления аватара:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
@@ -201,7 +206,7 @@ export const getSessions = async (req: Request, res: Response) => {
res.json({ success: true, data: sessions });
} catch (error: unknown) {
console.error('Ошибка получения сеансов:', error);
logger.error('Ошибка получения сеансов:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
@@ -229,7 +234,7 @@ export const terminateSession = async (req: Request, res: Response) => {
res.json({ success: true, message: 'Сеанс завершён' });
} catch (error: unknown) {
console.error('Ошибка завершения сеанса:', error);
logger.error('Ошибка завершения сеанса:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
@@ -248,7 +253,7 @@ export const getLoginHistory = async (req: Request, res: Response) => {
res.json({ success: true, data: history });
} catch (error: unknown) {
console.error('Ошибка получения истории:', error);
logger.error('Ошибка получения истории:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
@@ -277,7 +282,7 @@ export const getAPIKeys = async (req: Request, res: Response) => {
res.json({ success: true, data: keys });
} catch (error: unknown) {
console.error('Ошибка получения API ключей:', error);
logger.error('Ошибка получения API ключей:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
@@ -314,7 +319,7 @@ export const createAPIKey = async (req: Request, res: Response) => {
data: { ...apiKey, fullKey: key }
});
} catch (error: unknown) {
console.error('Ошибка создания API ключа:', error);
logger.error('Ошибка создания API ключа:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
@@ -340,7 +345,7 @@ export const deleteAPIKey = async (req: Request, res: Response) => {
res.json({ success: true, message: 'API ключ удалён' });
} catch (error: unknown) {
console.error('Ошибка удаления API ключа:', error);
logger.error('Ошибка удаления API ключа:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
@@ -363,7 +368,7 @@ export const getNotificationSettings = async (req: Request, res: Response) => {
res.json({ success: true, data: settings });
} catch (error: unknown) {
console.error('Ошибка получения настроек уведомлений:', error);
logger.error('Ошибка получения настроек уведомлений:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
@@ -386,7 +391,7 @@ export const updateNotificationSettings = async (req: Request, res: Response) =>
data: updated
});
} catch (error: unknown) {
console.error('Ошибка обновления настроек уведомлений:', error);
logger.error('Ошибка обновления настроек уведомлений:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};
@@ -426,7 +431,7 @@ export const exportUserData = async (req: Request, res: Response) => {
exportedAt: new Date().toISOString()
});
} catch (error: unknown) {
console.error('Ошибка экспорта данных:', error);
logger.error('Ошибка экспорта данных:', error);
res.status(500).json({ success: false, message: 'Ошибка сервера' });
}
};

View File

@@ -85,6 +85,7 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -1589,6 +1590,7 @@
"integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -1621,6 +1623,7 @@
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -1688,6 +1691,7 @@
"integrity": "sha512-VGMpFQGUQWYT9LfnPcX8ouFojyrZ/2w3K5BucvxL/spdNehccKhB4jUyB1yBCXpr2XFm0jkECxgrpXBW2ipoAw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.44.0",
"@typescript-eslint/types": "8.44.0",
@@ -1940,6 +1944,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2167,6 +2172,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741",
@@ -2840,6 +2846,7 @@
"integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -4235,6 +4242,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -4478,6 +4486,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -4490,6 +4499,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -4531,7 +4541,8 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/react-qr-code": {
"version": "2.0.18",
@@ -4612,6 +4623,7 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -4734,7 +4746,8 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@@ -5309,6 +5322,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -5368,6 +5382,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -5492,6 +5507,7 @@
"integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -5585,6 +5601,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},

View File

@@ -1,27 +1,29 @@
import { useEffect } from 'react';
import { useEffect, lazy, Suspense } from 'react';
import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-router-dom';
import Pagetempl from './components/pagetempl';
import DashboardTempl from './components/dashboardtempl';
import Homepage from './pages/index';
import Dashboard from './pages/dashboard/mainpage';
import Loginpage from './pages/login';
import Registerpage from './pages/register';
import QRLoginPage from './pages/qr-login';
import Aboutpage from './pages/about';
import S3PlansPage from './pages/s3plans';
import Privacy from './pages/privacy';
import Terms from './pages/terms';
import Blog from './pages/blog';
import BlogPost from './pages/blogpost';
import NotFound from './pages/404';
import Unauthorized from './pages/401';
import Forbidden from './pages/403';
import ServerError from './pages/500';
import BadGateway from './pages/502';
import ServiceUnavailable from './pages/503';
import GatewayTimeout from './pages/504';
import ErrorPage from './pages/errors';
import NetworkError from './pages/errors/NetworkError';
// Lazy loading для оптимизации
const Homepage = lazy(() => import('./pages/index'));
const Dashboard = lazy(() => import('./pages/dashboard/mainpage'));
const Loginpage = lazy(() => import('./pages/login'));
const Registerpage = lazy(() => import('./pages/register'));
const QRLoginPage = lazy(() => import('./pages/qr-login'));
const Aboutpage = lazy(() => import('./pages/about'));
const S3PlansPage = lazy(() => import('./pages/s3plans'));
const Privacy = lazy(() => import('./pages/privacy'));
const Terms = lazy(() => import('./pages/terms'));
const Blog = lazy(() => import('./pages/blog'));
const BlogPost = lazy(() => import('./pages/blogpost'));
const NotFound = lazy(() => import('./pages/404'));
const Unauthorized = lazy(() => import('./pages/401'));
const Forbidden = lazy(() => import('./pages/403'));
const ServerError = lazy(() => import('./pages/500'));
const BadGateway = lazy(() => import('./pages/502'));
const ServiceUnavailable = lazy(() => import('./pages/503'));
const GatewayTimeout = lazy(() => import('./pages/504'));
const ErrorPage = lazy(() => import('./pages/errors'));
const NetworkError = lazy(() => import('./pages/errors/NetworkError'));
import Privateroute from './components/privateroute';
import { AuthProvider } from './context/authcontext';
import { WebSocketProvider } from './context/WebSocketContext';
@@ -222,6 +224,102 @@ const SEO_CONFIG: Record<string, {
},
},
},
'/tariffs': {
ru: {
title: 'Тарифы S3 хранилища',
description: 'Выберите тариф для S3 хранилища ospab.host. Цена за GB, трафик, операции. Создайте бакет и начните хранить файлы.',
keywords: 'тарифы S3, цены хранилища, облачное хранилище тарифы, S3 планы',
og: {
title: 'Тарифы S3 хранилища ospab.host',
description: 'Выберите подходящий тариф для вашего проекта',
image: 'https://ospab.host/og-image.jpg',
url: 'https://ospab.host/tariffs',
},
},
en: {
title: 'S3 Storage Plans',
description: 'Choose a plan for ospab.host S3 storage. Price per GB, traffic, operations. Create a bucket and start storing files.',
keywords: 'S3 plans, storage prices, cloud storage plans, S3 tariffs',
og: {
title: 'ospab.host S3 Storage Plans',
description: 'Choose the right plan for your project',
image: 'https://ospab.host/og-image.jpg',
url: 'https://ospab.host/tariffs',
},
},
},
'/qr-login': {
ru: {
title: 'QR Вход',
description: 'Быстрый вход через QR код. Сканируйте код в мобильном приложении Telegram для мгновенной авторизации.',
keywords: 'QR вход, быстрый вход, Telegram авторизация, QR код вход',
og: {
title: 'QR Вход в ospab.host',
description: 'Быстрая авторизация через QR код',
image: 'https://ospab.host/og-image.jpg',
url: 'https://ospab.host/qr-login',
},
},
en: {
title: 'QR Login',
description: 'Quick login via QR code. Scan the code in the Telegram mobile app for instant authorization.',
keywords: 'QR login, quick login, Telegram authorization, QR code login',
og: {
title: 'QR Login to ospab.host',
description: 'Fast authorization via QR code',
image: 'https://ospab.host/og-image.jpg',
url: 'https://ospab.host/qr-login',
},
},
},
'/blog/:url': {
ru: {
title: 'Статья блога',
description: 'Прочитайте статью в блоге ospab.host. Полезные материалы о хостинге, S3 и DevOps.',
keywords: 'блог статья, хостинг гайд, S3 туториал, DevOps',
og: {
title: 'Статья блога ospab.host',
description: 'Полезные материалы о технологиях',
image: 'https://ospab.host/og-image.jpg',
url: 'https://ospab.host/blog',
},
},
en: {
title: 'Blog Post',
description: 'Read an article on the ospab.host blog. Useful materials about hosting, S3 and DevOps.',
keywords: 'blog post, hosting guide, S3 tutorial, DevOps',
og: {
title: 'ospab.host Blog Post',
description: 'Useful materials about technologies',
image: 'https://ospab.host/og-image.jpg',
url: 'https://ospab.host/blog',
},
},
},
'/dashboard': {
ru: {
title: 'Панель управления',
description: 'Личный кабинет ospab.host. Управляйте хранилищем, тикетами, балансом и настройками аккаунта.',
keywords: 'панель управления, личный кабинет, дашборд, управление аккаунтом',
og: {
title: 'Панель управления ospab.host',
description: 'Управление вашим аккаунтом и услугами',
image: 'https://ospab.host/og-image.jpg',
url: 'https://ospab.host/dashboard',
},
},
en: {
title: 'Control Panel',
description: 'ospab.host personal account. Manage storage, tickets, balance and account settings.',
keywords: 'control panel, personal account, dashboard, account management',
og: {
title: 'ospab.host Control Panel',
description: 'Manage your account and services',
image: 'https://ospab.host/og-image.jpg',
url: 'https://ospab.host/dashboard',
},
},
},
};
// Компонент для обновления SEO при изменении маршрута
@@ -232,16 +330,24 @@ function SEOUpdater() {
useEffect(() => {
const pathname = location.pathname;
// Нормализуем путь, убирая префикс /en для поиска в SEO_CONFIG
let normalizedPath = pathname;
if (pathname.startsWith('/en/')) {
normalizedPath = pathname.slice(3);
} else if (pathname === '/en') {
normalizedPath = '/';
}
// Получаем SEO данные для текущего маршрута, иначе используем дефолтные
const seoConfig = SEO_CONFIG[pathname];
const seoConfig = SEO_CONFIG[normalizedPath];
const seoData = seoConfig ? seoConfig[locale] : {
title: locale === 'en' ? 'ospab.host - cloud hosting' : 'ospab.host - облачный хостинг',
title: locale === 'en' ? 'ospab.host - cloud storage' : 'ospab.host - облачное хранилище',
description: locale === 'en'
? 'ospab.host - reliable cloud hosting and virtual machines in Veliky Novgorod.'
: 'ospab.host - надёжный облачный хостинг и виртуальные машины в Великом Новгороде.',
? 'ospab.host - reliable cloud S3-compatible storage in Veliky Novgorod. File storage, backups, media content.'
: 'ospab.host - надёжное облачное S3-совместимое хранилище в Великом Новгороде. Хранение файлов, резервные копии, медиа-контент.',
keywords: locale === 'en'
? 'hosting, cloud hosting, VPS, VDS'
: 'хостинг, облачный хостинг, VPS, VDS',
? 'hosting, cloud storage, S3, file storage'
: 'хостинг, облачное хранилище, S3, хранение файлов',
};
// Устанавливаем title
@@ -277,7 +383,7 @@ function SEOUpdater() {
canonicalTag.setAttribute('rel', 'canonical');
document.head.appendChild(canonicalTag);
}
canonicalTag.setAttribute('href', `https://ospab.host${pathname}`);
canonicalTag.setAttribute('href', `https://ospab.host${normalizedPath}`);
// Open Graph теги
if (seoData.og) {
@@ -312,6 +418,7 @@ function App() {
<WebSocketProvider>
<ToastProvider>
<ErrorBoundary>
<Suspense fallback={<div style={{display:'flex',justifyContent:'center',alignItems:'center',height:'100vh'}}>Loading...</div>}>
<Routes>
{/* Русские маршруты (без префикса) */}
<Route path="/" element={<Pagetempl><Homepage /></Pagetempl>} />
@@ -379,6 +486,7 @@ function App() {
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</ErrorBoundary>
</ToastProvider>
</WebSocketProvider>

View File

@@ -5,14 +5,6 @@
const PRODUCTION_API_ORIGIN = 'https://api.ospab.host';
const resolveDefaultApiUrl = () => {
if (typeof window === 'undefined') {
return import.meta.env.DEV ? 'http://localhost:5000' : PRODUCTION_API_ORIGIN;
}
if (import.meta.env.DEV) {
return 'http://localhost:5000';
}
return PRODUCTION_API_ORIGIN;
};

View File

@@ -61,9 +61,92 @@ export const en: TranslationKeys = {
about: {
title: 'About Us',
subtitle: 'Ospab.host — modern cloud storage platform',
hero: {
title: 'The Story of ospab.host',
subtitle: 'The first data center in Veliky Novgorod.',
},
founder: {
name: 'Georgy',
title: 'Founder & CEO',
age: '13 years old',
location: 'Veliky Novgorod',
github: 'Project source code',
bio: "At 13, I decided to create something that didn't exist in my city — a modern data center. Starting with learning technologies and working on my first hosting, I'm gradually turning a dream into reality. With the help of an investor friend, we're building the infrastructure of the future for Veliky Novgorod.",
alt: 'Georgy, founder of ospab.host',
},
story: {
title: 'Our Story',
text: 'We created ospab.host to provide reliable and affordable cloud storage for businesses and developers.',
sections: {
start: {
title: 'September 2025 — The Beginning',
text: "It all started with a simple idea: create a place where anyone can host their project, website, or server with maximum reliability and speed. Veliky Novgorod deserves its own data center, and I decided to take on this task.",
},
support: {
title: 'Support and Development',
text: "My investor friend believed in the project and helps with infrastructure development. We're building not just a business, but a community where every client is like a friend, and support is always nearby.",
},
future: {
title: 'Present and Future',
text: "Currently, we're actively working on hosting and preparing infrastructure for the future data center. ospab.host is the first step towards the digital future of Veliky Novgorod, and we're just getting started.",
},
},
},
mission: {
title: 'Our Mission',
subtitle: "Make quality hosting accessible to everyone, and the data center — the city's pride",
features: {
technologies: {
title: 'Modern Technologies',
description: 'We use the latest equipment and software for maximum performance',
},
security: {
title: 'Data Security',
description: 'Customer data protection is our priority. Regular backups and 24/7 monitoring',
},
support: {
title: 'Personal Support',
description: 'Every customer receives personal attention and help from the founder',
},
},
},
whyChoose: {
title: 'Why choose ospab.host?',
features: {
first: {
title: 'First data center in the city',
description: "We're making Veliky Novgorod history",
},
pricing: {
title: 'Affordable pricing',
description: 'Quality hosting for everyone without overpaying',
},
fastSupport: {
title: 'Fast support',
description: "We'll answer questions anytime",
},
transparency: {
title: 'Transparency',
description: 'Honest about capabilities and limitations',
},
infrastructure: {
title: 'Modern infrastructure',
description: 'Up-to-date software and equipment',
},
dream: {
title: 'A dream becoming reality',
description: 'A story to be proud of',
},
openSource: {
title: 'Source code on GitHub',
},
},
},
cta: {
title: 'Become part of history',
subtitle: 'Join ospab.host and help create the digital future of Veliky Novgorod',
startFree: 'Start for free',
viewPlans: 'View plans',
},
team: {
title: 'Our Team',
@@ -324,8 +407,13 @@ export const en: TranslationKeys = {
title: 'S3 Storage Pricing',
subtitle: 'Choose the right plan for your needs',
popular: 'Popular',
features: 'Features',
storage: 'Storage',
features: 'Features', baseFeatures: [
'S3-compatible API and AWS SDK support',
'Deployment in ru-central-1 region',
'Versioning and presigned URLs',
'Access management via Access Key/Secret Key',
'Notifications and monitoring in client panel'
], storage: 'Storage',
traffic: 'Outbound Traffic',
requests: 'Requests',
support: 'Support',
@@ -338,5 +426,45 @@ export const en: TranslationKeys = {
contactUs: 'Contact Us',
customPlan: 'Need a custom plan?',
customPlanDescription: 'Contact us to discuss special requirements.',
error: {
loadFailed: 'Failed to load plans',
loadError: 'Error loading plans',
},
page: {
title: 'Transparent pricing for any volume',
subtitle: 'Pay only for the resources you need. 12 ready-made plans for teams of any size, with included traffic, requests and priority support.',
network: 'network',
api: 'S3-compatible API',
loadReady: 'Load Ready',
loadReadyDesc: 'Infrastructure ready for peak loads up to 10 Gbit/s per server.',
security: 'Security',
securityDesc: 'AES-256 encryption, regular audits and compliance with standards.',
compatibility: 'Compatibility',
compatibilityDesc: 'Full compatibility with AWS S3 API and SDK.',
payAsYouGo: 'Pay as you go',
payAsYouGoDesc: 'Pay only for used resources without hidden fees.',
customPlanTitle: 'Custom Plan',
customPlanDesc: 'Calculate cost for your project',
gb: 'GB',
calculate: 'Calculate',
paymentError: 'Failed to start payment',
creatingCart: 'Creating cart...',
selectPlan: 'Select plan',
customTitle: 'Custom Plan',
customDesc: 'Specify the required amount of GB and get automatic cost calculation',
gbQuestion: 'How many GB do you need?',
useCases: {
backups: 'Backups & DR',
backupsDesc: 'Replication, Object Lock and object lifecycle allow storing backups and quick recovery.',
media: 'Media Platforms',
mediaDesc: 'CDN integration, presigned URLs and high bandwidth for video, images and audio.',
saas: 'SaaS & Data Lake',
saasDesc: 'IAM, API versions and audit logs ensure security and compliance with GDPR, 152-FZ and SOC 2.',
},
cta: {
title: 'Ready to deploy S3 storage?',
subtitle: 'Create an account and get access to management console, API keys and detailed usage analytics.',
},
},
},
};

View File

@@ -59,9 +59,92 @@ export const ru = {
about: {
title: 'О компании',
subtitle: 'Ospab.host — современная платформа облачного хранилища',
hero: {
title: 'История ospab.host',
subtitle: 'Первый дата-центр в Великом Новгороде.',
},
founder: {
name: 'Георгий',
title: 'Основатель и CEO',
age: '13 лет',
location: 'Великий Новгород',
github: 'Исходный код проекта',
bio: 'В 13 лет я решил создать то, чего не было в моём городе — современный дата-центр. Начав с изучения технологий и работы над первым хостингом, я постепенно превращаю мечту в реальность. С помощью друга-инвестора мы строим инфраструктуру будущего для Великого Новгорода.',
alt: 'Георгий, основатель ospab.host',
},
story: {
title: 'Наша история',
text: 'Мы создали ospab.host чтобы предоставить надёжное и доступное облачное хранилище для бизнеса и разработчиков.',
sections: {
start: {
title: 'Сентябрь 2025 — Начало пути',
text: 'Всё началось с простой идеи: создать место, где любой сможет разместить свой проект, сайт или сервер с максимальной надёжностью и скоростью. Великий Новгород заслуживает свой дата-центр, и я решил взяться за эту задачу.',
},
support: {
title: 'Поддержка и развитие',
text: 'Мой друг-инвестор поверил в проект и помогает с развитием инфраструктуры. Мы строим не просто бизнес, а сообщество, где каждый клиент — как друг, а поддержка всегда рядом.',
},
future: {
title: 'Настоящее и будущее',
text: 'Сейчас мы активно работаем над хостингом и подготовкой инфраструктуры для будущего ЦОД. ospab.host — это первый шаг к цифровому будущему Великого Новгорода, и мы только начинаем.',
},
},
},
mission: {
title: 'Наша миссия',
subtitle: 'Сделать качественный хостинг доступным для всех, а ЦОД — гордостью города',
features: {
technologies: {
title: 'Современные технологии',
description: 'Используем новейшее оборудование и программное обеспечение для максимальной производительности',
},
security: {
title: 'Безопасность данных',
description: 'Защита информации клиентов — наш приоритет. Регулярные бэкапы и мониторинг 24/7',
},
support: {
title: 'Личная поддержка',
description: 'Каждый клиент получает персональное внимание и помощь от основателя',
},
},
},
whyChoose: {
title: 'Почему выбирают ospab.host?',
features: {
first: {
title: 'Первый ЦОД в городе',
description: 'Мы создаём историю Великого Новгорода',
},
pricing: {
title: 'Доступные тарифы',
description: 'Качественный хостинг для всех без переплат',
},
fastSupport: {
title: 'Быстрая поддержка',
description: 'Ответим на вопросы в любое время',
},
transparency: {
title: 'Прозрачность',
description: 'Честно о возможностях и ограничениях',
},
infrastructure: {
title: 'Современная инфраструктура',
description: 'Актуальное ПО и оборудование',
},
dream: {
title: 'Мечта становится реальностью',
description: 'История, которой можно гордиться',
},
openSource: {
title: 'Исходный код на GitHub',
},
},
},
cta: {
title: 'Станьте частью истории',
subtitle: 'Присоединяйтесь к ospab.host и помогите создать цифровое будущее Великого Новгорода',
startFree: 'Начать бесплатно',
viewPlans: 'Посмотреть тарифы',
},
team: {
title: 'Наша команда',
@@ -323,6 +406,13 @@ export const ru = {
subtitle: 'Выберите подходящий план для ваших задач',
popular: 'Популярный',
features: 'Возможности',
baseFeatures: [
'S3-совместимый API и совместимость с AWS SDK',
'Развёртывание в регионе ru-central-1',
'Версионирование и presigned URL',
'Управление доступом через Access Key/Secret Key',
'Уведомления и мониторинг в панели клиента'
],
storage: 'Хранилище',
traffic: 'Исходящий трафик',
requests: 'Запросов',
@@ -336,6 +426,46 @@ export const ru = {
contactUs: 'Связаться с нами',
customPlan: 'Нужен индивидуальный план?',
customPlanDescription: 'Свяжитесь с нами для обсуждения особых условий.',
error: {
loadFailed: 'Не удалось загрузить тарифы',
loadError: 'Ошибка загрузки тарифов',
},
page: {
title: 'Прозрачные тарифы для любого объёма',
subtitle: 'Оплачивайте только за необходимые ресурсы. 12 готовых тарифов для команд любого размера, с включённым трафиком, запросами и приоритетной поддержкой.',
network: 'сеть',
api: 'S3-совместимый API',
loadReady: 'Готовность к нагрузке',
loadReadyDesc: 'Инфраструктура готова к пиковым нагрузкам до 10 Гбит/с на сервер.',
security: 'Безопасность',
securityDesc: 'Шифрование AES-256, регулярные аудиты и соответствие стандартам.',
compatibility: 'Совместимость',
compatibilityDesc: 'Полная совместимость с AWS S3 API и SDK.',
payAsYouGo: 'Оплата по факту',
payAsYouGoDesc: 'Оплачивайте только за использованные ресурсы без скрытых платежей.',
customPlanTitle: 'Индивидуальный тариф',
customPlanDesc: 'Рассчитайте стоимость для вашего проекта',
gb: 'ГБ',
calculate: 'Рассчитать',
paymentError: 'Не удалось начать оплату',
creatingCart: 'Создание корзины...',
selectPlan: 'Выбрать план',
customTitle: 'Кастомный тариф',
customDesc: 'Укажите нужное количество GB и получите автоматический расчёт стоимости',
gbQuestion: 'Сколько GB вам нужно?',
useCases: {
backups: 'Бэкапы и DR',
backupsDesc: 'Репликация, Object Lock и цикл жизни объектов позволяют хранить резервные копии и быстро восстанавливаться.',
media: 'Медиа-платформы',
mediaDesc: 'CDN-интеграция, presigned URL и высокая пропускная способность для видео, изображений и аудио.',
saas: 'SaaS & Data Lake',
saasDesc: 'IAM, версии API и аудит логов обеспечивают безопасность и соответствие требованиям GDPR, 152-ФЗ и SOC 2.',
},
cta: {
title: 'Готовы развернуть S3 хранилище?',
subtitle: 'Создайте аккаунт и получите доступ к консоли управления, API ключам и детальной аналитике использования.',
},
},
},
};

View File

@@ -24,7 +24,7 @@ type TranslationKey = NestedKeyOf<TranslationKeys>;
/**
* Получить значение по вложенному ключу
*/
function getNestedValue(obj: Record<string, unknown>, path: string): string {
function getNestedValue(obj: Record<string, unknown>, path: string): any {
const keys = path.split('.');
let current: unknown = obj;
@@ -36,7 +36,7 @@ function getNestedValue(obj: Record<string, unknown>, path: string): string {
}
}
return typeof current === 'string' ? current : path;
return current;
}
/**
@@ -46,18 +46,20 @@ export function useTranslation() {
const { locale, setLocale } = useLocale();
const t = useCallback(
(key: TranslationKey, params?: Record<string, string | number>): string => {
(key: TranslationKey, params?: Record<string, string | number>): any => {
const translation = getNestedValue(
translations[locale] as unknown as Record<string, unknown>,
key
);
if (!params) return translation;
if (typeof translation === 'string' && params) {
// Замена параметров {{param}}
return translation.replace(/\{\{(\w+)\}\}/g, (_, paramKey) => {
return params[paramKey]?.toString() ?? `{{${paramKey}}}`;
});
}
return translation ?? key;
},
[locale]
);

View File

@@ -3,9 +3,8 @@ import { useTranslation } from '../i18n';
import { useLocalePath } from '../middleware';
const AboutPage = () => {
const { locale } = useTranslation();
const { t } = useTranslation();
const localePath = useLocalePath();
const isEn = locale === 'en';
return (
<div className="min-h-screen bg-white">
@@ -19,10 +18,10 @@ const AboutPage = () => {
<div className="container mx-auto max-w-6xl relative z-10">
<div className="text-center mb-12">
<h1 className="text-5xl md:text-6xl font-extrabold mb-6 leading-tight">
{isEn ? 'The Story of ospab.host' : 'История ospab.host'}
{t('about.hero.title')}
</h1>
<p className="text-xl md:text-2xl text-blue-100 max-w-3xl mx-auto">
{isEn ? 'The first data center in Veliky Novgorod.' : 'Первый дата-центр в Великом Новгороде.'}
{t('about.hero.subtitle')}
</p>
</div>
</div>
@@ -36,7 +35,7 @@ const AboutPage = () => {
<div className="flex-shrink-0">
<img
src="/me.jpg"
alt={isEn ? 'Georgy, founder of ospab.host' : 'Георгий, основатель ospab.host'}
alt={t('about.founder.alt')}
className="w-48 h-48 md:w-56 md:h-56 rounded-2xl shadow-2xl border-4 border-ospab-primary object-cover"
width="224"
height="224"
@@ -45,23 +44,23 @@ const AboutPage = () => {
<div className="flex-1 text-center md:text-left">
<div className="mb-6">
<h2 className="text-4xl font-bold text-gray-900 mb-3">{isEn ? 'Georgy' : 'Георгий'}</h2>
<p className="text-xl text-ospab-primary font-semibold mb-2">{isEn ? 'Founder & CEO' : 'Основатель и CEO'}</p>
<h2 className="text-4xl font-bold text-gray-900 mb-3">{t('about.founder.name')}</h2>
<p className="text-xl text-ospab-primary font-semibold mb-2">{t('about.founder.title')}</p>
<div className="flex flex-wrap justify-center md:justify-start gap-4 text-gray-600">
<span className="flex items-center gap-2">
<FaUsers className="text-ospab-primary" />
{isEn ? '13 years old' : '13 лет'}
{t('about.founder.age')}
</span>
<span className="flex items-center gap-2">
<FaServer className="text-ospab-primary" />
{isEn ? 'Veliky Novgorod' : 'Великий Новгород'}
{t('about.founder.location')}
</span>
<a
href="https://github.com/ospab/ospabhost8.1"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 hover:text-ospab-primary transition-colors"
title={isEn ? 'Project source code' : 'Исходный код проекта'}
title={t('about.founder.github')}
>
<FaGithub className="text-ospab-primary" />
GitHub
@@ -70,9 +69,7 @@ const AboutPage = () => {
</div>
<p className="text-lg text-gray-700 leading-relaxed">
{isEn
? "At 13, I decided to create something that didn't exist in my city — a modern data center. Starting with learning technologies and working on my first hosting, I'm gradually turning a dream into reality. With the help of an investor friend, we're building the infrastructure of the future for Veliky Novgorod."
: 'В 13 лет я решил создать то, чего не было в моём городе — современный дата-центр. Начав с изучения технологий и работы над первым хостингом, я постепенно превращаю мечту в реальность. С помощью друга-инвестора мы строим инфраструктуру будущего для Великого Новгорода.'}
{t('about.founder.bio')}
</p>
</div>
</div>
@@ -84,43 +81,37 @@ const AboutPage = () => {
<section className="py-20 px-4">
<div className="container mx-auto max-w-4xl">
<h2 className="text-4xl md:text-5xl font-bold text-center text-gray-900 mb-12">
{isEn ? 'Our Story' : 'Наша история'}
{t('about.story.title')}
</h2>
<div className="space-y-8">
<div className="bg-gradient-to-r from-blue-50 to-white p-8 rounded-2xl border-l-4 border-ospab-primary shadow-lg">
<h3 className="text-2xl font-bold text-gray-900 mb-4 flex items-center gap-3">
<FaRocket className="text-ospab-primary" />
{isEn ? 'September 2025 — The Beginning' : 'Сентябрь 2025 — Начало пути'}
{t('about.story.sections.start.title')}
</h3>
<p className="text-lg text-gray-700 leading-relaxed">
{isEn
? "It all started with a simple idea: create a place where anyone can host their project, website, or server with maximum reliability and speed. Veliky Novgorod deserves its own data center, and I decided to take on this task."
: 'Всё началось с простой идеи: создать место, где любой сможет разместить свой проект, сайт или сервер с максимальной надёжностью и скоростью. Великий Новгород заслуживает свой дата-центр, и я решил взяться за эту задачу.'}
{t('about.story.sections.start.text')}
</p>
</div>
<div className="bg-gradient-to-r from-purple-50 to-white p-8 rounded-2xl border-l-4 border-ospab-accent shadow-lg">
<h3 className="text-2xl font-bold text-gray-900 mb-4 flex items-center gap-3">
<FaHeart className="text-ospab-accent" />
{isEn ? 'Support and Development' : 'Поддержка и развитие'}
{t('about.story.sections.support.title')}
</h3>
<p className="text-lg text-gray-700 leading-relaxed">
{isEn
? "My investor friend believed in the project and helps with infrastructure development. We're building not just a business, but a community where every client is like a friend, and support is always nearby."
: 'Мой друг-инвестор поверил в проект и помогает с развитием инфраструктуры. Мы строим не просто бизнес, а сообщество, где каждый клиент — как друг, а поддержка всегда рядом.'}
{t('about.story.sections.support.text')}
</p>
</div>
<div className="bg-gradient-to-r from-green-50 to-white p-8 rounded-2xl border-l-4 border-green-500 shadow-lg">
<h3 className="text-2xl font-bold text-gray-900 mb-4 flex items-center gap-3">
<FaChartLine className="text-green-500" />
{isEn ? 'Present and Future' : 'Настоящее и будущее'}
{t('about.story.sections.future.title')}
</h3>
<p className="text-lg text-gray-700 leading-relaxed">
{isEn
? "Currently, we're actively working on hosting and preparing infrastructure for the future data center. ospab.host is the first step towards the digital future of Veliky Novgorod, and we're just getting started."
: 'Сейчас мы активно работаем над хостингом и подготовкой инфраструктуры для будущего ЦОД. ospab.host — это первый шаг к цифровому будущему Великого Новгорода, и мы только начинаем.'}
{t('about.story.sections.future.text')}
</p>
</div>
</div>
@@ -132,12 +123,10 @@ const AboutPage = () => {
<div className="container mx-auto max-w-6xl">
<div className="text-center mb-16">
<h2 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
{isEn ? 'Our Mission' : 'Наша миссия'}
{t('about.mission.title')}
</h2>
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
{isEn
? "Make quality hosting accessible to everyone, and the data center — the city's pride"
: 'Сделать качественный хостинг доступным для всех, а ЦОД — гордостью города'}
{t('about.mission.subtitle')}
</p>
</div>
@@ -146,11 +135,9 @@ const AboutPage = () => {
<div className="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mb-6">
<FaServer className="text-3xl text-ospab-primary" />
</div>
<h3 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'Modern Technologies' : 'Современные технологии'}</h3>
<h3 className="text-2xl font-bold text-gray-900 mb-4">{t('about.mission.features.technologies.title')}</h3>
<p className="text-gray-600 leading-relaxed">
{isEn
? 'We use the latest equipment and software for maximum performance'
: 'Используем новейшее оборудование и программное обеспечение для максимальной производительности'}
{t('about.mission.features.technologies.description')}
</p>
</div>
@@ -158,11 +145,9 @@ const AboutPage = () => {
<div className="w-16 h-16 bg-pink-100 rounded-2xl flex items-center justify-center mb-6">
<FaShieldAlt className="text-3xl text-ospab-accent" />
</div>
<h3 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'Data Security' : 'Безопасность данных'}</h3>
<h3 className="text-2xl font-bold text-gray-900 mb-4">{t('about.mission.features.security.title')}</h3>
<p className="text-gray-600 leading-relaxed">
{isEn
? 'Customer data protection is our priority. Regular backups and 24/7 monitoring'
: 'Защита информации клиентов — наш приоритет. Регулярные бэкапы и мониторинг 24/7'}
{t('about.mission.features.security.description')}
</p>
</div>
@@ -170,11 +155,9 @@ const AboutPage = () => {
<div className="w-16 h-16 bg-green-100 rounded-2xl flex items-center justify-center mb-6">
<FaUsers className="text-3xl text-green-500" />
</div>
<h3 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'Personal Support' : 'Личная поддержка'}</h3>
<h3 className="text-2xl font-bold text-gray-900 mb-4">{t('about.mission.features.support.title')}</h3>
<p className="text-gray-600 leading-relaxed">
{isEn
? 'Every customer receives personal attention and help from the founder'
: 'Каждый клиент получает персональное внимание и помощь от основателя'}
{t('about.mission.features.support.description')}
</p>
</div>
</div>
@@ -186,7 +169,7 @@ const AboutPage = () => {
<div className="container mx-auto max-w-6xl">
<div className="bg-gradient-to-br from-ospab-primary to-blue-700 rounded-3xl shadow-2xl p-12 md:p-16 text-white">
<h2 className="text-4xl md:text-5xl font-bold text-center mb-12">
{isEn ? 'Why choose ospab.host?' : 'Почему выбирают ospab.host?'}
{t('about.whyChoose.title')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
@@ -195,8 +178,8 @@ const AboutPage = () => {
<span className="text-white font-bold"></span>
</div>
<div>
<h4 className="font-bold text-lg mb-2">{isEn ? 'First data center in the city' : 'Первый ЦОД в городе'}</h4>
<p className="text-blue-100">{isEn ? "We're making Veliky Novgorod history" : 'Мы создаём историю Великого Новгорода'}</p>
<h4 className="font-bold text-lg mb-2">{t('about.whyChoose.features.first.title')}</h4>
<p className="text-blue-100">{t('about.whyChoose.features.first.description')}</p>
</div>
</div>
@@ -205,8 +188,8 @@ const AboutPage = () => {
<span className="text-white font-bold"></span>
</div>
<div>
<h4 className="font-bold text-lg mb-2">{isEn ? 'Affordable pricing' : 'Доступные тарифы'}</h4>
<p className="text-blue-100">{isEn ? 'Quality hosting for everyone without overpaying' : 'Качественный хостинг для всех без переплат'}</p>
<h4 className="font-bold text-lg mb-2">{t('about.whyChoose.features.pricing.title')}</h4>
<p className="text-blue-100">{t('about.whyChoose.features.pricing.description')}</p>
</div>
</div>
@@ -215,8 +198,8 @@ const AboutPage = () => {
<span className="text-white font-bold"></span>
</div>
<div>
<h4 className="font-bold text-lg mb-2">{isEn ? 'Fast support' : 'Быстрая поддержка'}</h4>
<p className="text-blue-100">{isEn ? "We'll answer questions anytime" : 'Ответим на вопросы в любое время'}</p>
<h4 className="font-bold text-lg mb-2">{t('about.whyChoose.features.fastSupport.title')}</h4>
<p className="text-blue-100">{t('about.whyChoose.features.fastSupport.description')}</p>
</div>
</div>
@@ -225,8 +208,8 @@ const AboutPage = () => {
<span className="text-white font-bold"></span>
</div>
<div>
<h4 className="font-bold text-lg mb-2">{isEn ? 'Transparency' : 'Прозрачность'}</h4>
<p className="text-blue-100">{isEn ? 'Honest about capabilities and limitations' : 'Честно о возможностях и ограничениях'}</p>
<h4 className="font-bold text-lg mb-2">{t('about.whyChoose.features.transparency.title')}</h4>
<p className="text-blue-100">{t('about.whyChoose.features.transparency.description')}</p>
</div>
</div>
@@ -235,8 +218,8 @@ const AboutPage = () => {
<span className="text-white font-bold"></span>
</div>
<div>
<h4 className="font-bold text-lg mb-2">{isEn ? 'Modern infrastructure' : 'Современная инфраструктура'}</h4>
<p className="text-blue-100">{isEn ? 'Up-to-date software and equipment' : 'Актуальное ПО и оборудование'}</p>
<h4 className="font-bold text-lg mb-2">{t('about.whyChoose.features.infrastructure.title')}</h4>
<p className="text-blue-100">{t('about.whyChoose.features.infrastructure.description')}</p>
</div>
</div>
@@ -245,8 +228,8 @@ const AboutPage = () => {
<span className="text-white font-bold"></span>
</div>
<div>
<h4 className="font-bold text-lg mb-2">{isEn ? 'A dream becoming reality' : 'Мечта становится реальностью'}</h4>
<p className="text-blue-100">{isEn ? 'A story to be proud of' : 'История, которой можно гордиться'}</p>
<h4 className="font-bold text-lg mb-2">{t('about.whyChoose.features.dream.title')}</h4>
<p className="text-blue-100">{t('about.whyChoose.features.dream.description')}</p>
</div>
</div>
@@ -263,7 +246,7 @@ const AboutPage = () => {
rel="noopener noreferrer"
className="hover:underline"
>
{isEn ? 'Source code on GitHub' : 'Исходный код на GitHub'}
{t('about.whyChoose.features.openSource.title')}
</a>
</p>
</div>
@@ -277,25 +260,23 @@ const AboutPage = () => {
<section className="py-20 px-4 bg-gray-50">
<div className="container mx-auto max-w-4xl text-center">
<h2 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
{isEn ? 'Become part of history' : 'Станьте частью истории'}
{t('about.cta.title')}
</h2>
<p className="text-xl text-gray-600 mb-10">
{isEn
? 'Join ospab.host and help create the digital future of Veliky Novgorod'
: 'Присоединяйтесь к ospab.host и помогите создать цифровое будущее Великого Новгорода'}
{t('about.cta.subtitle')}
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<a
href={localePath('/register')}
className="px-8 py-4 bg-ospab-primary hover:bg-blue-700 text-white font-bold text-lg rounded-full transition-all transform hover:scale-105 shadow-lg"
>
{isEn ? 'Start for free' : 'Начать бесплатно'}
{t('about.cta.startFree')}
</a>
<a
href={localePath('/tariffs')}
className="px-8 py-4 bg-white hover:bg-gray-50 text-ospab-primary font-bold text-lg rounded-full border-2 border-ospab-primary transition-all transform hover:scale-105 shadow-lg"
>
{isEn ? 'View plans' : 'Посмотреть тарифы'}
{t('about.cta.viewPlans')}
</a>
</div>
</div>

View File

@@ -47,19 +47,7 @@ const S3PlansPage = () => {
const [error, setError] = useState<string | null>(null);
const [selectingPlan, setSelectingPlan] = useState<string | null>(null);
const BASE_FEATURES = locale === 'en' ? [
'S3-compatible API and AWS SDK support',
'Deployment in ru-central-1 region',
'Versioning and presigned URLs',
'Access management via Access Key/Secret Key',
'Notifications and monitoring in client panel'
] : [
'S3-совместимый API и совместимость с AWS SDK',
'Развёртывание в регионе ru-central-1',
'Версионирование и presigned URL',
'Управление доступом через Access Key/Secret Key',
'Уведомления и мониторинг в панели клиента'
];
const BASE_FEATURES = t('tariffs.baseFeatures') as string[];
const formatMetric = (value: number, suffix: string) =>
`${value.toLocaleString(locale === 'en' ? 'en-US' : 'ru-RU')} ${suffix}`;
@@ -73,14 +61,14 @@ const S3PlansPage = () => {
setError(null);
const response = await fetch(`${API_URL}/api/storage/plans`);
if (!response.ok) {
throw new Error(locale === 'en' ? 'Failed to load plans' : 'Не удалось загрузить тарифы');
throw new Error(t('tariffs.error.loadFailed'));
}
const data = await response.json();
if (!cancelled) {
setPlans(Array.isArray(data?.plans) ? data.plans : []);
}
} catch (err) {
const message = err instanceof Error ? err.message : (locale === 'en' ? 'Error loading plans' : 'Ошибка загрузки тарифов');
const message = err instanceof Error ? err.message : t('tariffs.error.loadError');
if (!cancelled) {
setError(message);
}
@@ -164,7 +152,7 @@ const S3PlansPage = () => {
}
navigate(localePath(`/dashboard/checkout?cart=${encodeURIComponent(cartId)}`));
} catch (err) {
const message = err instanceof Error ? err.message : (locale === 'en' ? 'Failed to start payment' : 'Не удалось начать оплату');
const message = err instanceof Error ? err.message : t('tariffs.page.paymentError');
setError(message);
} finally {
setSelectingPlan(null);
@@ -180,22 +168,20 @@ const S3PlansPage = () => {
<span>S3 Object Storage</span>
</div>
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold mb-6 text-gray-900">
{locale === 'en' ? 'Transparent pricing for any volume' : 'Прозрачные тарифы для любого объёма'}
{t('tariffs.page.title')}
</h1>
<p className="text-lg sm:text-xl text-gray-600 max-w-3xl mx-auto mb-8">
{locale === 'en'
? 'Pay only for the resources you need. 12 ready-made plans for teams of any size, with included traffic, requests and priority support.'
: 'Оплачивайте только за необходимые ресурсы. 12 готовых тарифов для команд любого размера, с включённым трафиком, запросами и приоритетной поддержкой.'}
{t('tariffs.page.subtitle')}
</p>
<div className="flex flex-wrap justify-center gap-4 text-sm text-gray-500">
<span className="inline-flex items-center gap-2 px-3 py-1 bg-white rounded-full shadow-sm">
<FaBolt className="text-blue-500" /> NVMe + 10Gb/s {locale === 'en' ? 'network' : 'сеть'}
<FaBolt className="text-blue-500" /> NVMe + 10Gb/s {t('tariffs.page.network')}
</span>
<span className="inline-flex items-center gap-2 px-3 py-1 bg-white rounded-full shadow-sm">
<FaLock className="text-emerald-500" /> AES-256 at-rest
</span>
<span className="inline-flex items-center gap-2 px-3 py-1 bg-white rounded-full shadow-sm">
<FaInfinity className="text-purple-500" /> {locale === 'en' ? 'S3-compatible API' : 'S3-совместимый API'}
<FaInfinity className="text-purple-500" /> {t('tariffs.page.api')}
</span>
</div>
</div>
@@ -208,33 +194,27 @@ const S3PlansPage = () => {
<div className="w-14 h-14 bg-blue-100 rounded-full flex items-center justify-center mb-4">
<FaBolt className="text-2xl text-blue-600" />
</div>
<h3 className="text-lg font-semibold mb-2">{locale === 'en' ? 'Load Ready' : 'Готовность к нагрузке'}</h3>
<h3 className="text-lg font-semibold mb-2">{t('tariffs.page.loadReady')}</h3>
<p className="text-gray-600 text-sm">
{locale === 'en'
? 'Unified NVMe platform with auto-scaling, CDN integration and cross-region replication.'
: 'Единая платформа на NVMe с автоматическим масштабированием, CDN-интеграцией и кросс-региональной репликацией.'}
{t('tariffs.page.loadReadyDesc')}
</p>
</div>
<div className="p-6 bg-gray-50 rounded-xl">
<div className="w-14 h-14 bg-green-100 rounded-full flex items-center justify-center mb-4">
<FaShieldAlt className="text-2xl text-green-600" />
</div>
<h3 className="text-lg font-semibold mb-2">{locale === 'en' ? 'Security by Default' : 'Безопасность по умолчанию'}</h3>
<h3 className="text-lg font-semibold mb-2">{t('tariffs.page.security')}</h3>
<p className="text-gray-600 text-sm">
{locale === 'en'
? '3 data copies, IAM roles, in-transit and at-rest encryption, audit logs, Object Lock and retention policies.'
: '3 копии данных, IAM роли, шифрование in-transit и at-rest, audit логи, Object Lock и политики хранения.'}
{t('tariffs.page.securityDesc')}
</p>
</div>
<div className="p-6 bg-gray-50 rounded-xl">
<div className="w-14 h-14 bg-purple-100 rounded-full flex items-center justify-center mb-4">
<FaCloud className="text-2xl text-purple-600" />
</div>
<h3 className="text-lg font-semibold mb-2">{locale === 'en' ? 'AWS SDK Compatibility' : 'Совместимость с AWS SDK'}</h3>
<h3 className="text-lg font-semibold mb-2">{t('tariffs.page.compatibility')}</h3>
<p className="text-gray-600 text-sm">
{locale === 'en'
? 'Full S3 API, support for AWS CLI, Terraform, rclone, s3cmd and other tools without code changes.'
: 'Полный S3 API, поддержка AWS CLI, Terraform, rclone, s3cmd и других инструментов без изменений в коде.'}
{t('tariffs.page.compatibilityDesc')}
</p>
</div>
</div>
@@ -325,11 +305,11 @@ const S3PlansPage = () => {
{selectingPlan === plan.code ? (
<>
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
<span>{locale === 'en' ? 'Creating cart...' : 'Создание корзины...'}</span>
<span>{t('tariffs.page.creatingCart')}</span>
</>
) : (
<>
<span>{locale === 'en' ? 'Select plan' : 'Выбрать план'}</span>
<span>{t('tariffs.page.selectPlan')}</span>
<FaArrowRight />
</>
)}
@@ -345,8 +325,8 @@ const S3PlansPage = () => {
{customPlan && customPlanCalculated && (
<div className="mt-20 pt-20 border-t border-gray-200">
<div className="mb-12">
<h2 className="text-3xl font-bold text-gray-900 mb-2">{locale === 'en' ? 'Custom Plan' : 'Кастомный тариф'}</h2>
<p className="text-gray-600">{locale === 'en' ? 'Specify the required amount of GB and get automatic cost calculation' : 'Укажите нужное количество GB и получите автоматический расчёт стоимости'}</p>
<h2 className="text-3xl font-bold text-gray-900 mb-2">{t('tariffs.page.customTitle')}</h2>
<p className="text-gray-600">{t('tariffs.page.customDesc')}</p>
</div>
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-2xl p-8 border border-blue-200">
@@ -354,7 +334,7 @@ const S3PlansPage = () => {
{/* Input */}
<div>
<label className="block text-sm font-semibold text-gray-900 mb-4">
{locale === 'en' ? 'How many GB do you need?' : 'Сколько GB вам нужно?'}
{t('tariffs.page.gbQuestion')}
</label>
<div className="flex items-center gap-4 mb-6">
<input
@@ -466,27 +446,21 @@ const S3PlansPage = () => {
</h2>
<div className="grid md:grid-cols-3 gap-8">
<div className="p-6 bg-gray-50 rounded-xl">
<h3 className="text-lg font-semibold mb-3">{locale === 'en' ? 'Backups & DR' : 'Бэкапы и DR'}</h3>
<h3 className="text-lg font-semibold mb-3">{t('tariffs.page.useCases.backups')}</h3>
<p className="text-gray-600 text-sm mb-4">
{locale === 'en'
? 'Replication, Object Lock and object lifecycle allow storing backups and quick recovery.'
: 'Репликация, Object Lock и цикл жизни объектов позволяют хранить резервные копии и быстро восстанавливаться.'}
{t('tariffs.page.useCases.backupsDesc')}
</p>
</div>
<div className="p-6 bg-gray-50 rounded-xl">
<h3 className="text-lg font-semibold mb-3">{locale === 'en' ? 'Media Platforms' : 'Медиа-платформы'}</h3>
<h3 className="text-lg font-semibold mb-3">{t('tariffs.page.useCases.media')}</h3>
<p className="text-gray-600 text-sm mb-4">
{locale === 'en'
? 'CDN integration, presigned URLs and high bandwidth for video, images and audio.'
: 'CDN-интеграция, presigned URL и высокая пропускная способность для видео, изображений и аудио.'}
{t('tariffs.page.useCases.mediaDesc')}
</p>
</div>
<div className="p-6 bg-gray-50 rounded-xl">
<h3 className="text-lg font-semibold mb-3">SaaS & Data Lake</h3>
<p className="text-gray-600 text-sm mb-4">
{locale === 'en'
? 'IAM, API versions and audit logs ensure security and compliance with GDPR, 152-FZ and SOC 2.'
: 'IAM, версии API и аудит логов обеспечивают безопасность и соответствие требованиям GDPR, 152-ФЗ и SOC 2.'}
{t('tariffs.page.useCases.saasDesc')}
</p>
</div>
</div>
@@ -495,11 +469,9 @@ const S3PlansPage = () => {
<section className="py-20 px-6 sm:px-8 bg-gray-900 text-white">
<div className="container mx-auto max-w-4xl text-center">
<h2 className="text-4xl font-bold mb-6">{locale === 'en' ? 'Ready to deploy S3 storage?' : 'Готовы развернуть S3 хранилище?'}</h2>
<h2 className="text-4xl font-bold mb-6">{t('tariffs.page.cta.title')}</h2>
<p className="text-lg sm:text-xl mb-8 text-white/80">
{locale === 'en'
? 'Create an account and get access to management console, API keys and detailed usage analytics.'
: 'Создайте аккаунт и получите доступ к консоли управления, API ключам и детальной аналитике использования.'}
{t('tariffs.page.cta.subtitle')}
</p>
<div className="flex flex-wrap justify-center gap-4">
<Link

View File

@@ -3,10 +3,14 @@
# Upstream для бэкенда
upstream backend_api {
server 127.0.0.1:3001;
server 127.0.0.1:5000;
keepalive 32;
}
# Rate limiting zones
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login_limit:10m rate=5r/m;
# Redirect HTTP to HTTPS
server {
listen 80;
@@ -30,8 +34,8 @@ server {
server_name ospab.host www.ospab.host;
# SSL Configuration
ssl_certificate /etc/letsencrypt/live/ospab.host/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ospab.host/privkey.pem;
ssl_certificate /etc/apache2/ssl/ospab.host.fullchain.crt;
ssl_certificate_key /etc/apache2/ssl/ospab.host.key;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;
@@ -51,7 +55,7 @@ server {
resolver_timeout 5s;
# Root directory for frontend build
root /var/www/ospab.host/frontend/dist;
root /var/www/ospab-host/frontend/dist;
index index.html;
# Gzip compression
@@ -68,15 +72,11 @@ server {
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Rate limiting zone
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login_limit:10m rate=5r/m;
# API proxy to backend
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
proxy_pass http://backend_api;
proxy_pass https://backend_api;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
@@ -97,7 +97,7 @@ server {
location /api/auth/login {
limit_req zone=login_limit burst=5 nodelay;
proxy_pass http://backend_api;
proxy_pass https://backend_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -111,7 +111,7 @@ server {
location /api/auth/register {
limit_req zone=login_limit burst=3 nodelay;
proxy_pass http://backend_api;
proxy_pass https://backend_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -124,7 +124,7 @@ server {
# WebSocket support for real-time features
location /ws {
proxy_pass http://backend_api;
proxy_pass https://backend_api;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
@@ -136,7 +136,7 @@ server {
# Uploaded files (checks images)
location /uploads/ {
alias /var/www/ospab.host/backend/uploads/;
alias /var/www/ospab-host/backend/uploads/;
expires 30d;
add_header Cache-Control "public, immutable";
try_files $uri =404;
@@ -221,8 +221,51 @@ server {
listen [::]:443 ssl http2;
server_name www.ospab.host;
ssl_certificate /etc/letsencrypt/live/ospab.host/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ospab.host/privkey.pem;
ssl_certificate /etc/apache2/ssl/ospab.host.fullchain.crt;
ssl_certificate_key /etc/apache2/ssl/ospab.host.key;
return 301 https://ospab.host$request_uri;
}
# API server
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name api.ospab.host;
# SSL Configuration
ssl_certificate /etc/letsencrypt/live/api.ospab.host/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.ospab.host/privkey.pem;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;
# Modern SSL configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# HSTS
add_header Strict-Transport-Security "max-age=63072000" always;
# OCSP Stapling
ssl_stapling on;
ssl_stapling_verify on;
resolver 8.8.8.8 8.8.4.4 valid=300s;
resolver_timeout 5s;
# Proxy to backend
location / {
proxy_pass https://backend_api;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 60s;
proxy_connect_timeout 60s;
}
}