new api endpoint and api rate limit
This commit is contained in:
54
.github/copilot-instructions.md
vendored
54
.github/copilot-instructions.md
vendored
@@ -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`.
|
||||
- **Тикеты**: автоназначение операторам, внутренние комментарии.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Binary file not shown.
84
ospabhost/backend/package-lock.json
generated
84
ospabhost/backend/package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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) || 'Ошибка подтверждения' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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}` });
|
||||
}
|
||||
|
||||
@@ -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: 'Авторизация временно недоступна. Попробуйте позже.' });
|
||||
}
|
||||
};
|
||||
@@ -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: 'Ошибка при проверке капчи',
|
||||
|
||||
@@ -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: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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: 'Ошибка удаления изображения'
|
||||
|
||||
@@ -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: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: 'Ошибка удаления сессий' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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, // Не блокируем загрузку при ошибке сканирования
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: 'Ошибка закрытия тикета' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,4 +5,4 @@ VITE_TURNSTILE_SITE_KEY=0x4AAAAAAB7306voAK0Pjx8O
|
||||
|
||||
# API URLs (с портом 5000)
|
||||
VITE_API_URL=https://ospab.host:5000
|
||||
VITE_SOCKET_URL=wss://ospab.host:5000
|
||||
VITE_SOCKET_URL=wss://ospab.host:5000
|
||||
|
||||
21
ospabhost/frontend/package-lock.json
generated
21
ospabhost/frontend/package-lock.json
generated
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,9 +418,10 @@ function App() {
|
||||
<WebSocketProvider>
|
||||
<ToastProvider>
|
||||
<ErrorBoundary>
|
||||
<Routes>
|
||||
{/* Русские маршруты (без префикса) */}
|
||||
<Route path="/" element={<Pagetempl><Homepage /></Pagetempl>} />
|
||||
<Suspense fallback={<div style={{display:'flex',justifyContent:'center',alignItems:'center',height:'100vh'}}>Loading...</div>}>
|
||||
<Routes>
|
||||
{/* Русские маршруты (без префикса) */}
|
||||
<Route path="/" element={<Pagetempl><Homepage /></Pagetempl>} />
|
||||
<Route path="/about" element={<Pagetempl><Aboutpage /></Pagetempl>} />
|
||||
<Route path="/tariffs" element={<Pagetempl><S3PlansPage /></Pagetempl>} />
|
||||
<Route path="/blog" element={<Pagetempl><Blog /></Pagetempl>} />
|
||||
@@ -379,6 +486,7 @@ function App() {
|
||||
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</ToastProvider>
|
||||
</WebSocketProvider>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 ключам и детальной аналитике использования.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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}}}`;
|
||||
});
|
||||
}
|
||||
|
||||
// Замена параметров {{param}}
|
||||
return translation.replace(/\{\{(\w+)\}\}/g, (_, paramKey) => {
|
||||
return params[paramKey]?.toString() ?? `{{${paramKey}}}`;
|
||||
});
|
||||
return translation ?? key;
|
||||
},
|
||||
[locale]
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user