Compare commits
112 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f210d3ef15 | |||
| 842e4283c1 | |||
| a28d7a6954 | |||
| 009506f915 | |||
| e84d77f248 | |||
| f52e035d8a | |||
| 927a04f24a | |||
| 600b398a5a | |||
| 73c9adf42a | |||
| 2c1d7f3ec6 | |||
| 1077f11fc7 | |||
| c7e2afa631 | |||
| 66defd5b20 | |||
| 26f3bb2934 | |||
| 962544820f | |||
| 5629b43cd0 | |||
| 9bf4d34317 | |||
| 5e4b265415 | |||
| 84a64c248e | |||
| bcd819546b | |||
| 33275e6588 | |||
| fef34a0d92 | |||
| c1602c07b2 | |||
| 21a8c59d16 | |||
| 11dee75ab4 | |||
| 83cb744d45 | |||
| 1a60018112 | |||
| 4cfd64faa9 | |||
| 1c4537c186 | |||
| 41134bcee8 | |||
| 11c7f27afa | |||
| 9e87741e33 | |||
| 4335b28d65 | |||
| 60f2a44178 | |||
| 4a49121409 | |||
| cad277a712 | |||
| 3d4b45ad01 | |||
| 48b4ab7f96 | |||
| 68f3aef661 | |||
| 84e414cd6f | |||
| e57f9b607a | |||
| 74a73a2de7 | |||
| f6ec68a906 | |||
| bdd3581e8a | |||
| 8d4adb0a7d | |||
| fdf11f67c5 | |||
| 8057f3079b | |||
| 21ce1469f5 | |||
| f04fa01fe1 | |||
| 38e6ae6f3d | |||
| e705232cf2 | |||
| 38ac7b1257 | |||
| aff0414ca3 | |||
| 42bdf2bcb0 | |||
| e23de664ac | |||
| 41265ccf37 | |||
| 295a582828 | |||
| 751845bf08 | |||
| aac9bf18f7 | |||
| 1140a6a96b | |||
| 6afa8f268e | |||
| f6908cd890 | |||
| b8c9db799d | |||
| 4fcdb0d585 | |||
| 425c1d5e8d | |||
| 61b814482c | |||
| 7f5aae32e7 | |||
| e7a63dfff0 | |||
| 9fdbde3cab | |||
| 629a1683b5 | |||
| 8f8a3bf724 | |||
| 388417cec9 | |||
| 5ab2b788fe | |||
| d414e7dc34 | |||
| 304e768be2 | |||
| ac83dc7cdd | |||
| 80ea7d5302 | |||
| 1b9b9b2431 | |||
| ed1cd84aba | |||
| fee440b177 | |||
| 241e9c5af7 | |||
| a40e06b9c0 | |||
| c3431832ec | |||
| 0a9e045c71 | |||
| 612f0468f7 | |||
| e7970fd8dd | |||
| bca723f8a0 | |||
| 21e4907aa2 | |||
| 29e776a0ec | |||
| 82811645bf | |||
| 63adfcb98f | |||
| 5b2870e04a | |||
| 21ae964012 | |||
| d171d0440c | |||
| 0e89fdaf98 | |||
| 440c946603 | |||
| 81dcbc28ae | |||
| 8e0bf98813 | |||
| a6515d73e6 | |||
| 2a7d351abb | |||
| 0decb0bc82 | |||
| 8e9e7b6c66 | |||
| 2147701d5a | |||
| fee9cd543c | |||
| f05ca55201 | |||
| f568de41de | |||
| f7495d691b | |||
| 5d8ca39f6c | |||
| 9bbbc8cd97 | |||
| 460e31260e | |||
| 1b2112e6d9 | |||
| e5f0410197 |
@@ -0,0 +1,62 @@
|
||||
# Принудительно LF для всех текстовых файлов (кроссплатформенность)
|
||||
# https://docs.github.com/en/get-started/getting-started-with-git/configuring-git-to-handle-line-endings
|
||||
|
||||
# По умолчанию - LF для всех файлов
|
||||
* text=auto eol=lf
|
||||
|
||||
# Явно бинарные файлы (без обработки)
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.svg binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.eot binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
*.tar.gz binary
|
||||
|
||||
# Shell-скрипты - LF
|
||||
*.sh text eol=lf
|
||||
|
||||
# Markdown - LF
|
||||
*.md text eol=lf
|
||||
|
||||
# TypeScript/JavaScript/TSX/JSX - LF
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
*.js text eol=lf
|
||||
*.jsx text eol=lf
|
||||
|
||||
# CSS/SCSS/JSON/YAML - LF
|
||||
*.css text eol=lf
|
||||
*.scss text eol=lf
|
||||
*.json text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
|
||||
# HTML/XML - LF
|
||||
*.html text eol=lf
|
||||
*.xml text eol=lf
|
||||
|
||||
# Docker - LF
|
||||
Dockerfile text eol=lf
|
||||
docker-compose*.yml text eol=lf
|
||||
|
||||
# Конфиги - LF
|
||||
.eslintrc* text eol=lf
|
||||
.prettierrc* text eol=lf
|
||||
.editorconfig text eol=lf
|
||||
.gitignore text eol=lf
|
||||
.env* text eol=lf
|
||||
tsconfig*.json text eol=lf
|
||||
package*.json text eol=lf
|
||||
vite.config.* text eol=lf
|
||||
nest-cli.json text eol=lf
|
||||
nginx.conf text eol=lf
|
||||
|
||||
# Go - LF
|
||||
*.go text eol=lf
|
||||
@@ -0,0 +1 @@
|
||||
patreon: DenPiligrim
|
||||
@@ -3,6 +3,9 @@ name: Docker Build & Publish
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- 'client/**'
|
||||
- 'server/**'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -12,6 +15,11 @@ env:
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
environment:
|
||||
name: production
|
||||
url: https://github.com/denpiligrim/3dp-manager/pkgs/container/3dp-manager-server
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -39,4 +47,4 @@ jobs:
|
||||
with:
|
||||
context: ./client
|
||||
push: true
|
||||
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-client:${{ github.ref_name }}
|
||||
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-client:${{ github.ref_name }}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
checker/
|
||||
client/.env
|
||||
client/coverage/
|
||||
backups/
|
||||
@@ -2,41 +2,42 @@
|
||||
|
||||
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
|
||||
|
||||
 [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||
  [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||
|
||||
# 3DP-MANAGER
|
||||
|
||||
Утилита для автогенерации инбаундов к панели [3x-ui](https://github.com/MHSanaei/3x-ui), формирования единой подписки и настройки перенаправления трафика с промежуточного сервера на основной. Утилита, начиная с версии 2.0.0 имеет графический интерфейс и простые пользовательские настройки.
|
||||
Утилита для автогенерации инбаундов к панели [3x-ui](https://github.com/MHSanaei/3x-ui), формирования единых подписок, управления несколькими 3x-ui нодами и настройки relay-перенаправления трафика с промежуточных серверов на основные. Начиная с версии 2.0.0 проект имеет графический интерфейс и простые пользовательские настройки.
|
||||
|
||||
**Поддержать проект**
|
||||
|
||||
- Банковским переводом:
|
||||
- Карта МИР: `2204320436318077`
|
||||
- Карта MasterCard: `5395452209474530`
|
||||
- :credit_card: Карта МИР: [finance.ozon.ru](https://finance.ozon.ru/apps/sbp/ozonbankpay/019e46e5-c08d-734a-a056-6b340969760d)
|
||||
- :credit_card: Карта MasterCard: `5395452209474530`
|
||||
- На электронный кошелек:
|
||||
- ЮМоney: `4100116897060652`
|
||||
- PayPal: `vasiljevdenisx@gmail.com`
|
||||
- :moneybag: ЮМоney: `4100116897060652`
|
||||
- :moneybag: PayPal: `vasiljevdenisx@gmail.com`
|
||||
- Криптовалютой:
|
||||
- USDT | ETH (ERC20 | BEP20): `0x6fe140040f6Cdc1E1Ff2136cd1d60C0165809463`
|
||||
- USDT | TRX (TRC20): `TEWxXmJxvkAmhshp7E61XJGHB3VyM9hNAb`
|
||||
- Bitcoin: `bc1qctntwncsv2yn02x2vgnkrqm00c4h04c0afkgpl`
|
||||
- TON: `UQCZ3MiwyYHXftPItMMzJRYRiKHugr16jFMq2nfOQOOoemLy`
|
||||
- Bybit ID: `165292278`
|
||||
- :coin: USDT | ETH (ERC20 | BEP20): `0x6fe140040f6Cdc1E1Ff2136cd1d60C0165809463`
|
||||
- :coin: USDT | TRX (TRC20): `TEWxXmJxvkAmhshp7E61XJGHB3VyM9hNAb`
|
||||
- :coin: Bitcoin: `bc1qctntwncsv2yn02x2vgnkrqm00c4h04c0afkgpl`
|
||||
- :coin: TON: `UQCZ3MiwyYHXftPItMMzJRYRiKHugr16jFMq2nfOQOOoemLy`
|
||||
- :coin: Bybit ID: `165292278`
|
||||
|
||||
## Описание
|
||||
|
||||
Главная цель утилиты — сделать так, чтобы ваш трафик не выглядел одинаковым. Бот генерирует по заданному интервалу 10 подключений с разными параметрами:
|
||||
Главная цель утилиты — сделать так, чтобы ваш трафик не выглядел одинаковым. 3DP-MANAGER генерирует по заданному интервалу набор подключений с разными параметрами:
|
||||
|
||||
- протоколы: `vless`, `vmess`, `shadowsocks`, `trojan`;
|
||||
- протоколы: `vless`, `vmess`, `shadowsocks`, `hysteria2`, `trojan`;
|
||||
- порты: `443`, `8443` (фиксированные) и случайные из диапазона `10000-60000`;
|
||||
- транспорт: `tcp`, `websocket`, `grpc`, `xhttp`;
|
||||
- SNI берутся из белого списка доменов (whitelist); можно использовать свой список.
|
||||
- SNI берутся из белого списка доменов (whitelist) или задаются вручную;
|
||||
- нода, relay-сервер, порт и флаг могут настраиваться отдельно для каждого инбаунда.
|
||||
|
||||
Все подключения объединяются в одну подписку со статичным URL. Бот работает с панелью `3x-ui` и не вмешивается в её работу напрямую, используя открытое API панели.
|
||||
Все подключения объединяются в одну подписку со статичным URL. 3DP-MANAGER работает с панелью `3x-ui` и не вмешивается в её работу напрямую, используя открытое API панели.
|
||||
|
||||
Вторичная цель — стабильность подключения: клиент получает 10 вариантов подключений и может выбрать любое из них.
|
||||
Вторичная цель — стабильность подключения: клиент получает несколько вариантов подключений и может выбрать любой из них. В одну подписку можно добавлять инбаунды с разных нод, использовать relay-серверы и добавлять собственные внешние ссылки как кастомные подключения.
|
||||
|
||||
Дополнительно: бот можно использовать в каскадной схеме. Сервис перенаправления автоматически настроит переадресацию подписки и трафика к основному серверу.
|
||||
Дополнительно: 3DP-MANAGER можно использовать в каскадной схеме. Сервис перенаправления автоматически настроит переадресацию подписки и трафика к выбранной ноде.
|
||||
|
||||
Рекомендации:
|
||||
|
||||
@@ -46,15 +47,25 @@
|
||||
|
||||
## Возможности
|
||||
|
||||
- Генерация 10 разнообразных подключений
|
||||
- Генерация разнообразных подключений для одной или нескольких подписок
|
||||
- Формирование единой подписки со статичным URL
|
||||
- Управление несколькими нодами `3x-ui`
|
||||
- Авторизация нод по логину/паролю или токену
|
||||
- Автоопределение IP, страны и флага ноды по URL панели
|
||||
- Выбор ноды, relay-сервера, флага, SNI и порта для каждого инбаунда
|
||||
- Поддержка фиксированного порта или значения `random`
|
||||
- Кастомные подключения: можно добавить готовую внешнюю ссылку в подписку
|
||||
- Отдельные пути к `fullchain.pem` и `privkey.pem` для Hysteria2 UDP
|
||||
- Поддержка кастомного `whitelist` доменов
|
||||
- Автоматическая настройка перенаправления трафика (опционально)
|
||||
- Автоматическая настройка relay-перенаправления трафика (опционально)
|
||||
- Установка Web UI через HTTP или HTTPS: Let's Encrypt, self-signed или свои сертификаты
|
||||
|
||||
## Требования
|
||||
|
||||
- Ubuntu 20.04 (и выше), Debian 12.11 (и выше)
|
||||
- Панель `3x-ui` v2.8.4 (и выше)
|
||||
- Панель `3x-ui` v2.9.4 (и выше)
|
||||
- Root-доступ на сервере
|
||||
- Docker и Docker Compose; если их нет, установочный скрипт попробует установить их автоматически
|
||||
- Домен + SSL сертификат (опционально)
|
||||
|
||||
---
|
||||
@@ -62,13 +73,29 @@
|
||||
## Установка
|
||||
|
||||
У вас должна быть установлена панель управления `3x-ui`, которую можно поставить командой: `bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)`
|
||||
|
||||
Установите проект на сервер командой:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/install.sh)
|
||||
```
|
||||
|
||||
<sup>Краткое описание: запускает скрипт установки и разворачивает контейнеры и сервисы.</sup>
|
||||
Во время установки скрипт:
|
||||
|
||||
- проверит ОС, root-доступ, Docker и Docker Compose;
|
||||
- при нехватке RAM создаст swap-файл 2 GB, если swap отсутствует;
|
||||
- предложит выбрать режим доступа к Web UI:
|
||||
- HTTPS через Let's Encrypt;
|
||||
- HTTPS с self-signed сертификатом;
|
||||
- HTTPS со своими сертификатами;
|
||||
- HTTP без шифрования;
|
||||
- сгенерирует случайный порт Web UI;
|
||||
- сгенерирует логин и пароль администратора;
|
||||
- развернёт контейнеры `postgres`, `backend` и `frontend`.
|
||||
|
||||
После завершения установки в терминале будут показаны URL, логин и пароль. Сразу смените пароль в настройках 3DP-MANAGER.
|
||||
|
||||
<sup>Краткое описание: запускает интерактивный скрипт установки, подготавливает окружение и разворачивает контейнеры проекта.</sup>
|
||||
|
||||
## Обновление
|
||||
|
||||
@@ -78,7 +105,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/update.sh)
|
||||
```
|
||||
|
||||
<sup>Краткое описание: подтягивает последние изменения и перезапускает контейнеры.</sup>
|
||||
<sup>Краткое описание: подтягивает последние изменения, применяет совместимые исправления конфигурации, обновляет контейнеры и перезапускает сервис.</sup>
|
||||
|
||||
## Удаление
|
||||
|
||||
@@ -92,15 +119,79 @@ bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main
|
||||
|
||||
---
|
||||
|
||||
## Первый вход и доступы
|
||||
|
||||
Логин и пароль администратора выводятся в конце установки. Если нужно посмотреть их повторно:
|
||||
|
||||
```bash
|
||||
grep -E "ADMIN_LOGIN|ADMIN_PASSWORD" /opt/3dp-manager/docker-compose.yml | sed 's/^[ \t]*//; s/^- //'
|
||||
```
|
||||
|
||||
Web UI доступен по адресу, который показал установщик, например:
|
||||
|
||||
- `https://example.com:PORT` для HTTPS;
|
||||
- `http://SERVER_IP:PORT` для HTTP.
|
||||
|
||||
Если включён `ufw`, установщик открывает рабочие диапазоны портов для инбаундов: `443`, `8443` и `10000-60000` для TCP/UDP.
|
||||
|
||||
---
|
||||
|
||||
## Ноды
|
||||
|
||||
Раздел **Ноды** позволяет подключить одну или несколько панелей `3x-ui`.
|
||||
|
||||
Для каждой ноды указываются:
|
||||
|
||||
- название;
|
||||
- URL панели `3x-ui`;
|
||||
- IP ноды;
|
||||
- флаг/страна;
|
||||
- тип авторизации: `password` или `token`;
|
||||
- признак основной ноды.
|
||||
|
||||
После ввода URL приложение пытается определить IP и страну ноды автоматически. Флаг используется в названиях подключений внутри подписки, чтобы в клиенте было проще отличать серверы.
|
||||
|
||||
> [!NOTE]
|
||||
> Основная нода используется как значение по умолчанию для подписок, инбаундов и relay-серверов.
|
||||
|
||||
---
|
||||
|
||||
## Подписки
|
||||
|
||||
Раздел **Подписки** управляет статичными URL подписок и составом инбаундов.
|
||||
|
||||
Для каждого инбаунда можно настроить:
|
||||
|
||||
- тип подключения;
|
||||
- ноду;
|
||||
- relay-сервер;
|
||||
- флаг;
|
||||
- порт: конкретное число `1-65535` или `random`;
|
||||
- SNI: конкретный домен или `random`;
|
||||
- готовую внешнюю ссылку для типа `custom`.
|
||||
|
||||
Для `hysteria2-udp` можно указать отдельные пути к сертификату и ключу:
|
||||
|
||||
- `certificateFile`, например `/root/cert/example.com/fullchain.pem`;
|
||||
- `keyFile`, например `/root/cert/example.com/privkey.pem`.
|
||||
|
||||
Если порт или SNI указаны как `random`, значение будет выбрано при ротации из доступного диапазона или whitelist доменов.
|
||||
|
||||
---
|
||||
|
||||
## Установка сервиса перенаправления (forwarding)
|
||||
|
||||
> [!WARNING]
|
||||
> Сервис перенаправления работает на промежуточном сервере
|
||||
|
||||
Сервис перенаправления позволяет проксировать входящие порты с промежуточного сервера на основной.
|
||||
Сервис перенаправления позволяет проксировать входящие порты с промежуточного сервера на выбранную ноду. В Web UI такие серверы настраиваются в разделе **Relay серверы** и могут быть привязаны к конкретной ноде.
|
||||
|
||||
Relay-сервер можно добавить по IP или домену. Если указан домен, backend попытается определить IP автоматически.
|
||||
|
||||
Для ручной установки forwarding на промежуточном сервере замените `IP_ADDRESS` на IP основной ноды:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_install.sh)
|
||||
sudo ORIGIN_IP="IP_ADDRESS" bash -c "$(curl -sSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_install.sh)"
|
||||
```
|
||||
|
||||
<sup>Краткое описание: добавляет правила перенаправления.</sup>
|
||||
@@ -111,7 +202,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_delete.sh)
|
||||
```
|
||||
|
||||
<sup>Краткое описание: удаляет правила перенаправления.</sup>
|
||||
<sup>Краткое описание: удаляет правила перенаправления. После удаления, чтобы изменения вступили в силу, перезапустите фаервол `ufw reload` и перезапустите систему `reboot`</sup>
|
||||
|
||||
---
|
||||
|
||||
@@ -150,4 +241,4 @@ node get_domains.js
|
||||
## Обсуждение
|
||||
|
||||
- Телеграм: [@denpiligrim_web](https://t.me/denpiligrim_web)
|
||||
- Раздел Issues в данном репозитории
|
||||
- Раздел Issues в данном репозитории
|
||||
|
||||
+145
-68
@@ -2,69 +2,100 @@
|
||||
|
||||
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
|
||||
|
||||
 [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||
  [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||
|
||||
# 3DP-MANAGER
|
||||
|
||||
这是一个用于为 `3x-ui` 面板自动生成入站连接、生成统一订阅并将流量从中继服务器转发到主服务器的实用工具。
|
||||
3DP-MANAGER 是一个用于为 [3x-ui](https://github.com/MHSanaei/3x-ui) 面板自动生成 inbound、创建统一订阅、管理多个 3x-ui 节点,并配置从中间服务器到源服务器的 relay 转发的工具。从 2.0.0 版本开始,项目提供图形界面和简单的用户设置。
|
||||
|
||||
**支持项目**
|
||||
|
||||
- 捐助/支付信息:
|
||||
- MIR 卡: `2204320436318077`
|
||||
- MasterCard: `5395452209474530`
|
||||
- PayPal: `vasiljevdenisx@gmail.com`
|
||||
- USDT | ETH (ERC20 | BEP20): `0x6fe140040f6Cdc1E1Ff2136cd1d60C0165809463`
|
||||
- USDT | TRX (TRC20): `TEWxXmJxvkAmhshp7E61XJGHB3VyM9hNAb`
|
||||
- 比特币: `bc1qctntwncsv2yn02x2vgnkrqm00c4h04c0afkgpl`
|
||||
- TON: `UQCZ3MiwyYHXftPItMMzJRYRiKHugr16jFMq2nfOQOOoemLy`
|
||||
- Bybit ID: `165292278`
|
||||
- 银行转账:
|
||||
- :credit_card: MIR 卡:`2204320436318077`
|
||||
- :credit_card: MasterCard:`5395452209474530`
|
||||
- 电子钱包:
|
||||
- :moneybag: YooMoney:`4100116897060652`
|
||||
- :moneybag: PayPal:`vasiljevdenisx@gmail.com`
|
||||
- 加密货币:
|
||||
- :coin: USDT | ETH (ERC20 | BEP20):`0x6fe140040f6Cdc1E1Ff2136cd1d60C0165809463`
|
||||
- :coin: USDT | TRX (TRC20):`TEWxXmJxvkAmhshp7E61XJGHB3VyM9hNAb`
|
||||
- :coin: Bitcoin:`bc1qctntwncsv2yn02x2vgnkrqm00c4h04c0afkgpl`
|
||||
- :coin: TON:`UQCZ3MiwyYHXftPItMMzJRYRiKHugr16jFMq2nfOQOOoemLy`
|
||||
- :coin: Bybit ID:`165292278`
|
||||
|
||||
## 描述
|
||||
|
||||
该工具的主要目标是使您的流量看起来不那么统一。机器人会在设定间隔生成 10 个具有不同参数的连接:
|
||||
该工具的主要目标是让您的流量看起来不那么单一。3DP-MANAGER 会按设定间隔生成一组具有不同参数的连接:
|
||||
|
||||
- 协议:`vless`、`vmess`、`shadowsocks`、`trojan`
|
||||
- 端口:`443`、`8443`(固定)以及 `10000-60000` 范围内的随机端口
|
||||
- 传输:`tcp`、`websocket`、`grpc`、`xhttp`
|
||||
- SNI 从域名白名单(`whitelist`)中获取;也可以使用自定义列表
|
||||
- 协议:`vless`、`vmess`、`shadowsocks`、`hysteria2`、`trojan`;
|
||||
- 端口:固定的 `443`、`8443`,以及 `10000-60000` 范围内的随机端口;
|
||||
- 传输:`tcp`、`websocket`、`grpc`、`xhttp`;
|
||||
- SNI 可从域名白名单中选择,也可以手动指定;
|
||||
- 每个 inbound 都可以单独设置节点、relay 服务器、端口和旗帜。
|
||||
|
||||
所有连接会合并为具有静态 URL 的单一订阅。该机器人通过 `3x-ui` 面板的公共 API 工作,不会直接修改面板内部。
|
||||
所有连接会合并到一个具有静态 URL 的订阅中。3DP-MANAGER 通过 `3x-ui` 面板的公开 API 工作,不会直接干预面板内部。
|
||||
|
||||
次要目标是提高连接稳定性:客户端会收到 10 个可选连接,用户可以选择任意一个。
|
||||
次要目标是提高连接稳定性:客户端会收到多个连接选项,并可选择其中任意一个。一个订阅可以包含来自不同节点的 inbound、relay 服务器,以及作为自定义连接添加的外部链接。
|
||||
|
||||
此外,机器人可用于级联部署。转发服务会自动配置将订阅和流量重定向到主服务器。
|
||||
此外,3DP-MANAGER 也可用于级联部署。转发服务会把订阅和流量转发配置到选定节点。
|
||||
|
||||
建议:
|
||||
|
||||
- 订阅请使用 HTTPS(域名 + SSL 证书)。
|
||||
- 生成间隔设置为 ≥ 10 分钟;为稳定性建议每天一次(1440 分钟)。
|
||||
- 在客户端设置更频繁的自动更新(例如每小时),以便与服务器同步。
|
||||
- 为订阅使用 HTTPS(域名 + SSL 证书)。
|
||||
- 生成间隔设置为不少于 10 分钟;为了稳定性,建议每天一次(1440 分钟)。
|
||||
- 在客户端设置更频繁的自动更新,例如每小时一次,以便与服务器保持同步。
|
||||
|
||||
## 功能
|
||||
|
||||
- 生成 10 个多样化连接
|
||||
- 形成具有静态 URL 的统一订阅
|
||||
- 支持自定义 `whitelist` 域名
|
||||
- 可选的自动流量转发配置
|
||||
- 为一个或多个订阅生成多样化连接
|
||||
- 创建具有静态 URL 的统一订阅
|
||||
- 管理多个 `3x-ui` 节点
|
||||
- 支持使用登录名/密码或 token 进行节点认证
|
||||
- 根据面板 URL 自动检测节点 IP、国家和旗帜
|
||||
- 可为每个 inbound 选择节点、relay 服务器、旗帜、SNI 和端口
|
||||
- 支持固定端口或 `random`
|
||||
- 支持自定义连接:可将现成的外部链接加入订阅
|
||||
- 可为 Hysteria2 UDP 单独指定 `fullchain.pem` 和 `privkey.pem` 路径
|
||||
- 支持自定义域名 `whitelist`
|
||||
- 可选的自动 relay 转发配置
|
||||
- Web UI 可通过 HTTP 或 HTTPS 安装:Let's Encrypt、自签名证书或自定义证书
|
||||
|
||||
## 要求
|
||||
|
||||
- Ubuntu 20.04 或更高
|
||||
- `3x-ui` 面板
|
||||
- Ubuntu 20.04 或更高版本,Debian 12.11 或更高版本
|
||||
- `3x-ui` 面板 v2.8.4 或更高版本
|
||||
- 服务器 root 权限
|
||||
- Docker 和 Docker Compose;如果缺失,安装脚本会尝试自动安装
|
||||
- 域名 + SSL 证书(可选)
|
||||
|
||||
---
|
||||
|
||||
## 安装
|
||||
|
||||
在服务器上运行以下命令安装项目:
|
||||
必须先安装 `3x-ui` 控制面板。可使用以下命令安装:`bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)`
|
||||
|
||||
在服务器上安装项目:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/install.sh)
|
||||
```
|
||||
|
||||
<sup>简要说明:运行安装脚本并部署容器和服务。</sup>
|
||||
安装过程中,脚本会:
|
||||
|
||||
- 检查操作系统、root 权限、Docker 和 Docker Compose;
|
||||
- 如果内存较低且没有 swap,则创建 2 GB 的 swap 文件;
|
||||
- 询问 Web UI 访问模式:
|
||||
- 通过 Let's Encrypt 使用 HTTPS;
|
||||
- 使用自签名证书的 HTTPS;
|
||||
- 使用自有证书的 HTTPS;
|
||||
- 不加密的 HTTP;
|
||||
- 生成随机 Web UI 端口;
|
||||
- 生成管理员登录名和密码;
|
||||
- 部署 `postgres`、`backend` 和 `frontend` 容器。
|
||||
|
||||
安装完成后,终端会显示 URL、登录名和密码。请立即在 3DP-MANAGER 设置中修改密码。
|
||||
|
||||
<sup>简要说明:运行交互式安装脚本,准备环境并部署项目容器。</sup>
|
||||
|
||||
## 更新
|
||||
|
||||
@@ -74,7 +105,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/update.sh)
|
||||
```
|
||||
|
||||
<sup>简要说明:拉取最新更改并重启容器。</sup>
|
||||
<sup>简要说明:拉取最新更改,应用兼容的配置修复,更新容器并重启服务。</sup>
|
||||
|
||||
## 删除
|
||||
|
||||
@@ -84,19 +115,86 @@ bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/delete.sh)
|
||||
```
|
||||
|
||||
<sup>简要说明:删除容器和配置文件,恢复到安装前状态。</sup>
|
||||
<sup>简要说明:删除容器和配置文件,将系统恢复到安装前状态。</sup>
|
||||
|
||||
---
|
||||
|
||||
## 安装转发服务(forwarding)
|
||||
## 首次登录
|
||||
|
||||
转发服务允许将中继服务器的入站端口代理到主服务器。
|
||||
管理员登录名和密码会在安装结束时输出。如需再次查看:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_install.sh)
|
||||
grep -E "ADMIN_LOGIN|ADMIN_PASSWORD" /opt/3dp-manager/docker-compose.yml | sed 's/^[ \t]*//; s/^- //'
|
||||
```
|
||||
|
||||
<sup>简要说明:添加转发规则并创建用于更新订阅的服务。</sup>
|
||||
Web UI 可通过安装脚本显示的地址访问,例如:
|
||||
|
||||
- HTTPS:`https://example.com:PORT`;
|
||||
- HTTP:`http://SERVER_IP:PORT`。
|
||||
|
||||
如果启用了 `ufw`,安装脚本会开放 inbound 工作端口范围:TCP/UDP 的 `443`、`8443` 和 `10000-60000`。
|
||||
|
||||
---
|
||||
|
||||
## 节点
|
||||
|
||||
**节点** 页面用于连接一个或多个 `3x-ui` 面板。
|
||||
|
||||
每个节点需要设置:
|
||||
|
||||
- 名称;
|
||||
- `3x-ui` 面板 URL;
|
||||
- 节点 IP;
|
||||
- 旗帜/国家;
|
||||
- 认证类型:`password` 或 `token`;
|
||||
- 是否为主节点。
|
||||
|
||||
输入 URL 后,应用会尝试自动检测节点 IP 和国家。旗帜会用于订阅中的连接名称,方便客户端区分服务器。
|
||||
|
||||
> [!NOTE]
|
||||
> 主节点会作为订阅、inbound 和 relay 服务器的默认值。
|
||||
|
||||
---
|
||||
|
||||
## 订阅
|
||||
|
||||
**订阅** 页面用于管理静态订阅 URL 和 inbound 组成。
|
||||
|
||||
每个 inbound 可配置:
|
||||
|
||||
- 连接类型;
|
||||
- 节点;
|
||||
- relay 服务器;
|
||||
- 旗帜;
|
||||
- 端口:`1-65535` 的具体数字或 `random`;
|
||||
- SNI:具体域名或 `random`;
|
||||
- `custom` 类型的现成外部链接。
|
||||
|
||||
对于 `hysteria2-udp`,可以单独指定证书和密钥路径:
|
||||
|
||||
- `certificateFile`,例如 `/root/cert/example.com/fullchain.pem`;
|
||||
- `keyFile`,例如 `/root/cert/example.com/privkey.pem`。
|
||||
|
||||
如果端口或 SNI 设置为 `random`,轮换时会从可用端口范围或域名白名单中选择值。
|
||||
|
||||
---
|
||||
|
||||
## 安装转发服务
|
||||
|
||||
> [!WARNING]
|
||||
> 转发服务运行在中间服务器上
|
||||
|
||||
转发服务会把中间服务器的入站端口代理到选定节点。在 Web UI 中,这些服务器在 **Relay 服务器** 页面配置,并可绑定到具体节点。
|
||||
|
||||
Relay 服务器可以通过 IP 或域名添加。如果提供域名,backend 会尝试自动解析其 IP。
|
||||
|
||||
如需在中间服务器上手动安装 forwarding,请将 `IP_ADDRESS` 替换为源节点 IP:
|
||||
|
||||
```bash
|
||||
sudo ORIGIN_IP="IP_ADDRESS" bash -c "$(curl -sSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_install.sh)"
|
||||
```
|
||||
|
||||
<sup>简要说明:添加转发规则。</sup>
|
||||
|
||||
## 删除转发
|
||||
|
||||
@@ -104,64 +202,43 @@ bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_delete.sh)
|
||||
```
|
||||
|
||||
<sup>简要说明:删除规则并禁用转发服务。</sup>
|
||||
<sup>简要说明:删除转发规则。删除后,请执行 `ufw reload` 重新加载防火墙,并执行 `reboot` 重启系统以使更改生效。</sup>
|
||||
|
||||
---
|
||||
|
||||
## 显示订阅 URL
|
||||
|
||||
在容器环境中打印当前订阅 URL 的命令:
|
||||
|
||||
```bash
|
||||
cd /opt/3dp-manager && docker compose exec node env | grep SUB_URL | cut -d'=' -f2
|
||||
```
|
||||
|
||||
<sup>简要说明:打印可在客户端使用的静态订阅 URL,适用于主服务器和中继服务器。</sup>
|
||||
|
||||
## 从多订阅收集域名
|
||||
|
||||
该工具可从订阅中提取域名并为生成器构建 `whitelist`。
|
||||
该工具会从订阅中提取域名,并为生成器构建 `whitelist`。
|
||||
|
||||
```bash
|
||||
node get_domains.js
|
||||
```
|
||||
|
||||
<sup>简要说明:在脚本中添加多订阅链接并运行命令 — 输出为域名列表。运行脚本需要 `Node.js`。</sup>
|
||||
|
||||
## 使用自定义白名单
|
||||
|
||||
1. 准备一个与 `whitelist.txt` 相同格式的文件。
|
||||
2. 将其重命名为 `my_whitelist.txt` 并复制到 `/opt/3dp-manager/app`。
|
||||
|
||||
```bash
|
||||
cd /opt/3dp-manager && docker cp ./app/my_whitelist.txt node:/app/my_whitelist.txt
|
||||
```
|
||||
|
||||
<sup>简要说明:将您的域名文件添加到应用容器中。</sup>
|
||||
<sup>简要说明:在脚本中添加多订阅链接并运行命令,输出将是域名列表。运行脚本需要 `Node.js`。</sup>
|
||||
|
||||
---
|
||||
|
||||
## 注意与当前限制
|
||||
## 注意事项和当前限制
|
||||
|
||||
- 共享的域名列表可能并非对所有提供商都有效;建议准备并使用自定义 `whitelist`。
|
||||
- 共享域名列表并非适用于所有服务商,因此建议准备并使用自己的 whitelist。
|
||||
|
||||
---
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎贡献!简单的贡献流程:
|
||||
欢迎任何贡献!简单的贡献流程:
|
||||
|
||||
1. 在 GitHub 上 fork 仓库。
|
||||
2. 创建有意义的分支名,例如 `feature/add-README` 或 `fix/whitelist-load`。
|
||||
3. 提交更改并添加简短的提交信息。
|
||||
4. 在本地运行检查(如有)。
|
||||
5. 推送分支到您的 fork 并创建 Pull Request。
|
||||
3. 修改代码,并在 commit 中添加简短说明。
|
||||
4. 如有本地检查,请运行它们。
|
||||
5. 将分支推送到你的 fork,并向主仓库创建 Pull Request。
|
||||
|
||||
<sup>建议:在 PR 中说明更改和测试步骤;较大更改请拆成小提交。</sup>
|
||||
<sup>建议:在 PR 中说明更改、目标和测试步骤。如果更改较大,请拆分为较小的 commit。</sup>
|
||||
|
||||
---
|
||||
|
||||
## 讨论
|
||||
|
||||
- Telegram: [@denpiligrim_web](https://t.me/denpiligrim_web)
|
||||
- Issues
|
||||
- 本仓库的 Issues 区
|
||||
|
||||
+143
-66
@@ -2,69 +2,100 @@
|
||||
|
||||
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
|
||||
|
||||
 [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||
  [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||
|
||||
# 3DP-MANAGER
|
||||
|
||||
A utility for auto-generating inbound connections for the [3x-ui](https://github.com/MHSanaei/3x-ui) panel, creating a single subscription URL, and configuring traffic forwarding from an intermediate server to the main server.
|
||||
A utility for automatically generating inbounds for the [3x-ui](https://github.com/MHSanaei/3x-ui) panel, creating unified subscriptions, managing multiple 3x-ui nodes, and configuring relay forwarding from intermediate servers to origin servers. Since version 2.0.0, the project has a graphical interface and simple user-facing settings.
|
||||
|
||||
**Support the project**
|
||||
|
||||
- Donation / payment details:
|
||||
- MIR card: `2204320436318077`
|
||||
- MasterCard: `5395452209474530`
|
||||
- PayPal: `vasiljevdenisx@gmail.com`
|
||||
- USDT | ETH (ERC20 | BEP20): `0x6fe140040f6Cdc1E1Ff2136cd1d60C0165809463`
|
||||
- USDT | TRX (TRC20): `TEWxXmJxvkAmhshp7E61XJGHB3VyM9hNAb`
|
||||
- Bitcoin: `bc1qctntwncsv2yn02x2vgnkrqm00c4h04c0afkgpl`
|
||||
- TON: `UQCZ3MiwyYHXftPItMMzJRYRiKHugr16jFMq2nfOQOOoemLy`
|
||||
- Bybit ID: `165292278`
|
||||
- Bank transfer:
|
||||
- :credit_card: MIR card: `2204320436318077`
|
||||
- :credit_card: MasterCard: `5395452209474530`
|
||||
- E-wallet:
|
||||
- :moneybag: YooMoney: `4100116897060652`
|
||||
- :moneybag: PayPal: `vasiljevdenisx@gmail.com`
|
||||
- Crypto:
|
||||
- :coin: USDT | ETH (ERC20 | BEP20): `0x6fe140040f6Cdc1E1Ff2136cd1d60C0165809463`
|
||||
- :coin: USDT | TRX (TRC20): `TEWxXmJxvkAmhshp7E61XJGHB3VyM9hNAb`
|
||||
- :coin: Bitcoin: `bc1qctntwncsv2yn02x2vgnkrqm00c4h04c0afkgpl`
|
||||
- :coin: TON: `UQCZ3MiwyYHXftPItMMzJRYRiKHugr16jFMq2nfOQOOoemLy`
|
||||
- :coin: Bybit ID: `165292278`
|
||||
|
||||
## Description
|
||||
|
||||
The main goal of the utility is to make your traffic appear non-uniform. The bot generates 10 connection entries at a configured interval with varying parameters:
|
||||
The main goal of the utility is to make your traffic look less uniform. 3DP-MANAGER generates a set of connections at a configured interval with different parameters:
|
||||
|
||||
- Protocols: `vless`, `vmess`, `shadowsocks`, `trojan`;
|
||||
- Ports: `443`, `8443` (fixed) and random ports from the range `10000-60000`;
|
||||
- Transport: `tcp`, `websocket`, `grpc`, `xhttp`;
|
||||
- SNI values are taken from a whitelist of domains (`whitelist`); you can use your own list.
|
||||
- protocols: `vless`, `vmess`, `shadowsocks`, `hysteria2`, `trojan`;
|
||||
- ports: fixed `443`, `8443`, plus random ports from `10000-60000`;
|
||||
- transport: `tcp`, `websocket`, `grpc`, `xhttp`;
|
||||
- SNI values are taken from the domain whitelist or set manually;
|
||||
- node, relay server, port, and flag can be configured separately for each inbound.
|
||||
|
||||
All generated connections are combined into a single subscription with a static URL. The bot works with the `3x-ui` panel using its public API and does not directly modify the panel internals.
|
||||
All connections are combined into one subscription with a static URL. 3DP-MANAGER works with the `3x-ui` panel through its public API and does not directly interfere with the panel internals.
|
||||
|
||||
The secondary goal is connection stability: the client receives 10 alternate connection options and can choose any of them.
|
||||
The secondary goal is connection stability: the client receives several connection options and can choose any of them. One subscription can contain inbounds from different nodes, relay servers, and your own external links as custom connections.
|
||||
|
||||
Additionally, the bot can be used in a cascading setup. The forwarding service will automatically configure the subscription and traffic redirection to the main server.
|
||||
Additionally, 3DP-MANAGER can be used in a cascading setup. The forwarding service configures subscription and traffic forwarding to the selected node.
|
||||
|
||||
Recommendations:
|
||||
|
||||
- Use HTTPS for the subscription (domain + SSL certificate).
|
||||
- Set the generation interval to ≥ 10 minutes; for stability, once per day (1440 minutes) is recommended.
|
||||
- Configure the client to check for updates more frequently (for example, hourly) to stay synchronized with the server.
|
||||
- Use HTTPS for subscriptions (domain + SSL certificate).
|
||||
- Set the generation interval to at least 10 minutes; for stability, once per day (1440 minutes) is recommended.
|
||||
- Configure the client to refresh more frequently, for example every hour, so it stays synchronized with the server.
|
||||
|
||||
## Features
|
||||
|
||||
- Generates 10 diverse connection entries
|
||||
- Creates a single subscription with a static URL
|
||||
- Supports custom `whitelist` domains
|
||||
- Optional automatic configuration of traffic forwarding
|
||||
- Generates diverse connections for one or more subscriptions
|
||||
- Creates a unified subscription with a static URL
|
||||
- Manages multiple `3x-ui` nodes
|
||||
- Supports node authentication by login/password or token
|
||||
- Automatically detects node IP, country, and flag from the panel URL
|
||||
- Lets you choose node, relay server, flag, SNI, and port per inbound
|
||||
- Supports a fixed port or `random`
|
||||
- Supports custom connections: add a ready-made external link to a subscription
|
||||
- Allows separate `fullchain.pem` and `privkey.pem` paths for Hysteria2 UDP
|
||||
- Supports a custom domain `whitelist`
|
||||
- Optional automatic relay forwarding setup
|
||||
- Installs the Web UI over HTTP or HTTPS: Let's Encrypt, self-signed, or custom certificates
|
||||
|
||||
## Requirements
|
||||
|
||||
- Ubuntu 20.04 or newer
|
||||
- `3x-ui` panel
|
||||
- Ubuntu 20.04 or newer, Debian 12.11 or newer
|
||||
- `3x-ui` panel v2.8.4 or newer
|
||||
- Root access on the server
|
||||
- Docker and Docker Compose; if missing, the installer will try to install them automatically
|
||||
- Domain + SSL certificate (optional)
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
Install the project on your server with:
|
||||
The `3x-ui` control panel must already be installed. You can install it with: `bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)`
|
||||
|
||||
Install the project on the server:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/install.sh)
|
||||
```
|
||||
|
||||
<sup>Short description: runs the installer script and deploys containers and services.</sup>
|
||||
During installation, the script will:
|
||||
|
||||
- check the OS, root access, Docker, and Docker Compose;
|
||||
- create a 2 GB swap file if RAM is low and swap is missing;
|
||||
- ask which Web UI access mode to use:
|
||||
- HTTPS via Let's Encrypt;
|
||||
- HTTPS with a self-signed certificate;
|
||||
- HTTPS with your own certificates;
|
||||
- HTTP without encryption;
|
||||
- generate a random Web UI port;
|
||||
- generate the administrator login and password;
|
||||
- deploy the `postgres`, `backend`, and `frontend` containers.
|
||||
|
||||
After installation, the terminal will show the URL, login, and password. Change the password immediately in the 3DP-MANAGER settings.
|
||||
|
||||
<sup>Short description: runs the interactive installer, prepares the environment, and deploys the project containers.</sup>
|
||||
|
||||
## Update
|
||||
|
||||
@@ -74,7 +105,7 @@ Update to the latest version:
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/update.sh)
|
||||
```
|
||||
|
||||
<sup>Short description: pulls the latest changes and restarts containers.</sup>
|
||||
<sup>Short description: pulls the latest changes, applies compatible configuration fixes, updates containers, and restarts the service.</sup>
|
||||
|
||||
## Removal
|
||||
|
||||
@@ -88,37 +119,94 @@ bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main
|
||||
|
||||
---
|
||||
|
||||
## Install forwarding service
|
||||
## First Login
|
||||
|
||||
The forwarding service allows proxying incoming ports from the intermediate server to the main server.
|
||||
The administrator login and password are printed at the end of installation. To view them again:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_install.sh)
|
||||
grep -E "ADMIN_LOGIN|ADMIN_PASSWORD" /opt/3dp-manager/docker-compose.yml | sed 's/^[ \t]*//; s/^- //'
|
||||
```
|
||||
|
||||
<sup>Short description: adds forwarding rules and creates a service to update the subscription.</sup>
|
||||
The Web UI is available at the address shown by the installer, for example:
|
||||
|
||||
## Remove forwarding
|
||||
- `https://example.com:PORT` for HTTPS;
|
||||
- `http://SERVER_IP:PORT` for HTTP.
|
||||
|
||||
If `ufw` is active, the installer opens the working inbound port ranges: `443`, `8443`, and `10000-60000` for TCP/UDP.
|
||||
|
||||
---
|
||||
|
||||
## Nodes
|
||||
|
||||
The **Nodes** section lets you connect one or more `3x-ui` panels.
|
||||
|
||||
For each node, you specify:
|
||||
|
||||
- name;
|
||||
- `3x-ui` panel URL;
|
||||
- node IP;
|
||||
- flag/country;
|
||||
- authentication type: `password` or `token`;
|
||||
- whether this node is the main node.
|
||||
|
||||
After you enter the URL, the application tries to detect the node IP and country automatically. The flag is used in connection names inside the subscription so clients can distinguish servers more easily.
|
||||
|
||||
> [!NOTE]
|
||||
> The main node is used as the default value for subscriptions, inbounds, and relay servers.
|
||||
|
||||
---
|
||||
|
||||
## Subscriptions
|
||||
|
||||
The **Subscriptions** section manages static subscription URLs and inbound composition.
|
||||
|
||||
For each inbound, you can configure:
|
||||
|
||||
- connection type;
|
||||
- node;
|
||||
- relay server;
|
||||
- flag;
|
||||
- port: a concrete number from `1-65535` or `random`;
|
||||
- SNI: a concrete domain or `random`;
|
||||
- a ready-made external link for the `custom` type.
|
||||
|
||||
For `hysteria2-udp`, you can specify separate certificate and key paths:
|
||||
|
||||
- `certificateFile`, for example `/root/cert/example.com/fullchain.pem`;
|
||||
- `keyFile`, for example `/root/cert/example.com/privkey.pem`.
|
||||
|
||||
If port or SNI is set to `random`, the value will be selected during rotation from the available range or domain whitelist.
|
||||
|
||||
---
|
||||
|
||||
## Install Forwarding Service
|
||||
|
||||
> [!WARNING]
|
||||
> The forwarding service runs on an intermediate server
|
||||
|
||||
The forwarding service proxies incoming ports from an intermediate server to the selected node. In the Web UI, these servers are configured in **Relay servers** and can be linked to a specific node.
|
||||
|
||||
A relay server can be added by IP or domain. If a domain is provided, the backend will try to resolve its IP automatically.
|
||||
|
||||
For manual forwarding installation on the intermediate server, replace `IP_ADDRESS` with the origin node IP:
|
||||
|
||||
```bash
|
||||
sudo ORIGIN_IP="IP_ADDRESS" bash -c "$(curl -sSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_install.sh)"
|
||||
```
|
||||
|
||||
<sup>Short description: adds forwarding rules.</sup>
|
||||
|
||||
## Remove Forwarding
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_delete.sh)
|
||||
```
|
||||
|
||||
<sup>Short description: removes rules and disables the forwarding service.</sup>
|
||||
<sup>Short description: removes forwarding rules. After removal, reload the firewall with `ufw reload` and reboot the system with `reboot` for changes to take effect.</sup>
|
||||
|
||||
---
|
||||
|
||||
## Show subscription URL
|
||||
|
||||
Command to print the current subscription URL from the container environment:
|
||||
|
||||
```bash
|
||||
cd /opt/3dp-manager && docker compose exec node env | grep SUB_URL | cut -d'=' -f2
|
||||
```
|
||||
|
||||
<sup>Short description: prints the static subscription URL that can be used in clients. Works on both main and intermediate servers.</sup>
|
||||
|
||||
## Collect domains from multi-subscriptions
|
||||
## Collect Domains From Multi-Subscriptions
|
||||
|
||||
The utility extracts domains from subscriptions and builds a `whitelist` for the generator.
|
||||
|
||||
@@ -126,24 +214,13 @@ The utility extracts domains from subscriptions and builds a `whitelist` for the
|
||||
node get_domains.js
|
||||
```
|
||||
|
||||
<sup>Short description: add a multi-subscription link to the script and run the command — the output will be a list of domains. `Node.js` is required to run the script.</sup>
|
||||
|
||||
## Use your own whitelist
|
||||
|
||||
1. Prepare a file in the format of `whitelist.txt`.
|
||||
2. Rename it to `my_whitelist.txt` and copy it into `/opt/3dp-manager/app`.
|
||||
|
||||
```bash
|
||||
cd /opt/3dp-manager && docker cp ./app/my_whitelist.txt node:/app/my_whitelist.txt
|
||||
```
|
||||
|
||||
<sup>Short description: adds your domain file into the application container.</sup>
|
||||
<sup>Short description: add a multi-subscription link to the script and run the command. The output will be a domain list. `Node.js` is required.</sup>
|
||||
|
||||
---
|
||||
|
||||
## Notes and current limitations
|
||||
## Notes And Current Limitations
|
||||
|
||||
- The shared domain list may not work with all providers; it is recommended to prepare and use your own `whitelist`.
|
||||
- The shared domain list may not work with all providers, so it is recommended to prepare and use your own whitelist.
|
||||
|
||||
---
|
||||
|
||||
@@ -152,16 +229,16 @@ cd /opt/3dp-manager && docker cp ./app/my_whitelist.txt node:/app/my_whitelist.t
|
||||
Contributions are welcome! Simple contributor workflow:
|
||||
|
||||
1. Fork the repository on GitHub.
|
||||
2. Create a branch with a meaningful name, e.g. `feature/add-README` or `fix/whitelist-load`.
|
||||
3. Make changes and add a short commit message.
|
||||
2. Create a branch with a meaningful name, for example `feature/add-README` or `fix/whitelist-load`.
|
||||
3. Make changes and add a short description in the commit.
|
||||
4. Run local checks if available.
|
||||
5. Push the branch to your fork and create a Pull Request to the main repository.
|
||||
|
||||
<sup>Tips: describe changes and testing steps in the PR. For large changes, split into smaller commits.</sup>
|
||||
<sup>Tips: describe your changes in the PR, include the goal and test steps. If the changes are large, split them into small commits.</sup>
|
||||
|
||||
---
|
||||
|
||||
## Discussion
|
||||
|
||||
- Telegram: [@denpiligrim_web](https://t.me/denpiligrim_web)
|
||||
- Issues
|
||||
- Issues section in this repository
|
||||
|
||||
+148
-71
@@ -2,166 +2,243 @@
|
||||
|
||||
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
|
||||
|
||||
 [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||
  [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||
|
||||
# 3DP-MANAGER
|
||||
|
||||
ابزاری برای تولید خودکار اتصالهای inbound برای پنل `3x-ui`، ساخت یک اشتراک یکپارچه و هدایت ترافیک از سرور واسط به سرور اصلی.
|
||||
ابزاری برای تولید خودکار inbound برای پنل [3x-ui](https://github.com/MHSanaei/3x-ui)، ساخت اشتراکهای یکپارچه، مدیریت چندین نود 3x-ui و پیکربندی relay forwarding از سرورهای واسط به سرورهای اصلی. از نسخه 2.0.0 به بعد، پروژه دارای رابط گرافیکی و تنظیمات ساده کاربری است.
|
||||
|
||||
**حمایت از پروژه**
|
||||
|
||||
- اطلاعات پرداخت/اهدا:
|
||||
- کارت MIR: `2204320436318077`
|
||||
- کارت MasterCard: `5395452209474530`
|
||||
- PayPal: `vasiljevdenisx@gmail.com`
|
||||
- USDT | ETH (ERC20 | BEP20): `0x6fe140040f6Cdc1E1Ff2136cd1d60C0165809463`
|
||||
- USDT | TRX (TRC20): `TEWxXmJxvkAmhshp7E61XJGHB3VyM9hNAb`
|
||||
- بیتکوین: `bc1qctntwncsv2yn02x2vgnkrqm00c4h04c0afkgpl`
|
||||
- TON: `UQCZ3MiwyYHXftPItMMzJRYRiKHugr16jFMq2nfOQOOoemLy`
|
||||
- Bybit ID: `165292278`
|
||||
- انتقال بانکی:
|
||||
- :credit_card: کارت MIR: `2204320436318077`
|
||||
- :credit_card: کارت MasterCard: `5395452209474530`
|
||||
- کیف پول الکترونیکی:
|
||||
- :moneybag: YooMoney: `4100116897060652`
|
||||
- :moneybag: PayPal: `vasiljevdenisx@gmail.com`
|
||||
- رمزارز:
|
||||
- :coin: USDT | ETH (ERC20 | BEP20): `0x6fe140040f6Cdc1E1Ff2136cd1d60C0165809463`
|
||||
- :coin: USDT | TRX (TRC20): `TEWxXmJxvkAmhshp7E61XJGHB3VyM9hNAb`
|
||||
- :coin: Bitcoin: `bc1qctntwncsv2yn02x2vgnkrqm00c4h04c0afkgpl`
|
||||
- :coin: TON: `UQCZ3MiwyYHXftPItMMzJRYRiKHugr16jFMq2nfOQOOoemLy`
|
||||
- :coin: Bybit ID: `165292278`
|
||||
|
||||
## توضیحات
|
||||
|
||||
هدف اصلی این ابزار این است که ترافیک شما یکسان به نظر نرسد. ربات در فواصل زمانی مشخص 10 اتصال با پارامترهای مختلف تولید میکند:
|
||||
هدف اصلی ابزار این است که ترافیک شما یکسان به نظر نرسد. 3DP-MANAGER در بازه زمانی تعیینشده، مجموعهای از اتصالها با پارامترهای متفاوت تولید میکند:
|
||||
|
||||
- پروتکلها: `vless`, `vmess`, `shadowsocks`, `trojan`
|
||||
- پورتها: `443`, `8443` (ثابت) و پورتهای تصادفی در بازه `10000-60000`
|
||||
- لایه انتقال: `tcp`, `websocket`, `grpc`, `xhttp`
|
||||
- SNI از لیست سفید دامنهها گرفته میشود (`whitelist`); میتوانید از لیست خود استفاده کنید.
|
||||
- پروتکلها: `vless`, `vmess`, `shadowsocks`, `hysteria2`, `trojan`;
|
||||
- پورتها: `443` و `8443` بهصورت ثابت، و پورتهای تصادفی از بازه `10000-60000`;
|
||||
- transport: `tcp`, `websocket`, `grpc`, `xhttp`;
|
||||
- SNI از whitelist دامنهها انتخاب میشود یا بهصورت دستی تنظیم میگردد؛
|
||||
- نود، relay server، پورت و پرچم را میتوان برای هر inbound جداگانه تنظیم کرد.
|
||||
|
||||
تمام اتصالات در یک اشتراک با URL ثابت تجمیع میشوند. ربات با پنل `3x-ui` از طریق API باز کار میکند و بهطور مستقیم در عملکرد داخلی پنل دخالت نمیکند.
|
||||
همه اتصالها در یک اشتراک با URL ثابت ترکیب میشوند. 3DP-MANAGER با پنل `3x-ui` از طریق API عمومی آن کار میکند و مستقیماً در بخش داخلی پنل دخالت نمیکند.
|
||||
|
||||
هدف ثانویه افزایش پایداری اتصال است: مشتری 10 گزینه اتصال دریافت میکند و میتواند هر کدام را انتخاب کند.
|
||||
هدف دوم پایداری اتصال است: کلاینت چند گزینه اتصال دریافت میکند و میتواند هرکدام را انتخاب کند. در یک اشتراک میتوان inboundهای مربوط به نودهای مختلف، relay serverها و لینکهای خارجی آماده را بهعنوان اتصال custom قرار داد.
|
||||
|
||||
علاوه بر این، ربات میتواند در طرحهای آبشاری استفاده شود. سرویس فورواردینگ بهطور خودکار بازنشانی اشتراک و ترافیک به سرور اصلی را تنظیم میکند.
|
||||
همچنین 3DP-MANAGER را میتوان در سناریوی زنجیرهای استفاده کرد. سرویس forwarding، هدایت اشتراک و ترافیک را به نود انتخابشده پیکربندی میکند.
|
||||
|
||||
توصیهها:
|
||||
|
||||
- برای اشتراک از HTTPS استفاده کنید (دامنه + گواهی SSL).
|
||||
- فاصله تولید را ≥ 10 دقیقه در نظر بگیرید؛ برای پایداری توصیه میشود یکبار در روز (1440 دقیقه) تنظیم شود.
|
||||
- در کلاینت بهروزرسانی خودکار را بیشتر تنظیم کنید (مثلاً هر ساعت) تا همگامسازی با سرور انجام شود.
|
||||
- فاصله تولید را حداقل 10 دقیقه تنظیم کنید؛ برای پایداری، یکبار در روز (1440 دقیقه) توصیه میشود.
|
||||
- در کلاینت، بهروزرسانی خودکار را دفعات بیشتری تنظیم کنید، مثلاً هر ساعت، تا با سرور همگام بماند.
|
||||
|
||||
## قابلیتها
|
||||
|
||||
- تولید 10 اتصال متنوع
|
||||
- تشکیل یک اشتراک یکپارچه با URL ثابت
|
||||
- پشتیبانی از دامنههای `whitelist` سفارشی
|
||||
- پیکربندی خودکار فورواردینگ ترافیک (اختیاری)
|
||||
- تولید اتصالهای متنوع برای یک یا چند اشتراک
|
||||
- ساخت اشتراک یکپارچه با URL ثابت
|
||||
- مدیریت چندین نود `3x-ui`
|
||||
- پشتیبانی از احراز هویت نود با login/password یا token
|
||||
- تشخیص خودکار IP، کشور و پرچم نود از URL پنل
|
||||
- انتخاب نود، relay server، پرچم، SNI و پورت برای هر inbound
|
||||
- پشتیبانی از پورت ثابت یا مقدار `random`
|
||||
- پشتیبانی از اتصالهای custom: افزودن لینک خارجی آماده به اشتراک
|
||||
- امکان تعیین مسیرهای جداگانه `fullchain.pem` و `privkey.pem` برای Hysteria2 UDP
|
||||
- پشتیبانی از `whitelist` دامنه سفارشی
|
||||
- تنظیم خودکار relay forwarding بهصورت اختیاری
|
||||
- نصب Web UI از طریق HTTP یا HTTPS: Let's Encrypt، self-signed یا گواهیهای خودتان
|
||||
|
||||
## نیازمندیها
|
||||
|
||||
- Ubuntu 20.04 یا بالاتر
|
||||
- پنل `3x-ui`
|
||||
- Ubuntu 20.04 یا جدیدتر، Debian 12.11 یا جدیدتر
|
||||
- پنل `3x-ui` نسخه v2.8.4 یا جدیدتر
|
||||
- دسترسی root روی سرور
|
||||
- Docker و Docker Compose؛ اگر نصب نباشند، اسکریپت نصب تلاش میکند آنها را خودکار نصب کند
|
||||
- دامنه + گواهی SSL (اختیاری)
|
||||
|
||||
---
|
||||
|
||||
## نصب
|
||||
|
||||
برای نصب پروژه در سرور، دستور زیر را اجرا کنید:
|
||||
پنل مدیریتی `3x-ui` باید از قبل نصب شده باشد. میتوانید آن را با این دستور نصب کنید: `bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)`
|
||||
|
||||
پروژه را روی سرور نصب کنید:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/install.sh)
|
||||
```
|
||||
|
||||
<sup>توضیح کوتاه: اسکریپت نصب را اجرا کرده و کانتینرها و سرویسها را راهاندازی میکند.</sup>
|
||||
در زمان نصب، اسکریپت:
|
||||
|
||||
- سیستمعامل، دسترسی root، Docker و Docker Compose را بررسی میکند؛
|
||||
- اگر RAM کم باشد و swap وجود نداشته باشد، یک فایل swap با حجم 2 GB میسازد؛
|
||||
- حالت دسترسی Web UI را میپرسد:
|
||||
- HTTPS با Let's Encrypt؛
|
||||
- HTTPS با گواهی self-signed؛
|
||||
- HTTPS با گواهیهای خودتان؛
|
||||
- HTTP بدون رمزنگاری؛
|
||||
- یک پورت تصادفی برای Web UI ایجاد میکند؛
|
||||
- login و password مدیر را ایجاد میکند؛
|
||||
- کانتینرهای `postgres`, `backend` و `frontend` را اجرا میکند.
|
||||
|
||||
پس از پایان نصب، URL، login و password در ترمینال نمایش داده میشود. بلافاصله password را در تنظیمات 3DP-MANAGER تغییر دهید.
|
||||
|
||||
<sup>توضیح کوتاه: اسکریپت نصب تعاملی را اجرا میکند، محیط را آماده میسازد و کانتینرهای پروژه را راهاندازی میکند.</sup>
|
||||
|
||||
## بهروزرسانی
|
||||
|
||||
برای بهروزرسانی به آخرین نسخه:
|
||||
بهروزرسانی به آخرین نسخه:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/update.sh)
|
||||
```
|
||||
|
||||
<sup>توضیح کوتاه: تغییرات جدید را میکشد و کانتینرها را ریاستارت میکند.</sup>
|
||||
<sup>توضیح کوتاه: آخرین تغییرات را دریافت میکند، اصلاحات سازگار پیکربندی را اعمال میکند، کانتینرها را بهروزرسانی کرده و سرویس را راهاندازی مجدد میکند.</sup>
|
||||
|
||||
## حذف
|
||||
|
||||
برای حذف کامل سرویس:
|
||||
حذف کامل سرویس:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/delete.sh)
|
||||
```
|
||||
|
||||
<sup>توضیح کوتاه: کانتینرها و فایلهای پیکربندی را حذف میکند و سیستم را به وضعیت پیش از نصب برمیگرداند.</sup>
|
||||
<sup>توضیح کوتاه: کانتینرها و فایلهای پیکربندی را حذف میکند و سیستم را به وضعیت پیش از نصب بازمیگرداند.</sup>
|
||||
|
||||
---
|
||||
|
||||
## نصب سرویس فورواردینگ (forwarding)
|
||||
## اولین ورود
|
||||
|
||||
سرویس فورواردینگ امکان پراکسی کردن پورتهای ورودی از سرور واسط به سرور اصلی را فراهم میکند.
|
||||
login و password مدیر در پایان نصب نمایش داده میشود. برای مشاهده دوباره:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_install.sh)
|
||||
grep -E "ADMIN_LOGIN|ADMIN_PASSWORD" /opt/3dp-manager/docker-compose.yml | sed 's/^[ \t]*//; s/^- //'
|
||||
```
|
||||
|
||||
<sup>توضیح کوتاه: قوانین فورواردینگ را اضافه کرده و سرویسی برای بهروزرسانی اشتراک ایجاد میکند.</sup>
|
||||
Web UI در آدرسی که نصبکننده نمایش داده قابل دسترسی است، برای مثال:
|
||||
|
||||
## حذف فورواردینگ
|
||||
- `https://example.com:PORT` برای HTTPS؛
|
||||
- `http://SERVER_IP:PORT` برای HTTP.
|
||||
|
||||
اگر `ufw` فعال باشد، نصبکننده بازههای پورت کاری inbound را برای TCP/UDP باز میکند: `443`, `8443` و `10000-60000`.
|
||||
|
||||
---
|
||||
|
||||
## نودها
|
||||
|
||||
بخش **نودها** امکان اتصال یک یا چند پنل `3x-ui` را فراهم میکند.
|
||||
|
||||
برای هر نود مشخص میشود:
|
||||
|
||||
- نام؛
|
||||
- URL پنل `3x-ui`؛
|
||||
- IP نود؛
|
||||
- پرچم/کشور؛
|
||||
- نوع احراز هویت: `password` یا `token`؛
|
||||
- اینکه نود اصلی است یا خیر.
|
||||
|
||||
پس از وارد کردن URL، برنامه تلاش میکند IP و کشور نود را خودکار تشخیص دهد. پرچم در نام اتصالها داخل اشتراک استفاده میشود تا تشخیص سرورها در کلاینت سادهتر باشد.
|
||||
|
||||
> [!NOTE]
|
||||
> نود اصلی بهعنوان مقدار پیشفرض برای اشتراکها، inboundها و relay serverها استفاده میشود.
|
||||
|
||||
---
|
||||
|
||||
## اشتراکها
|
||||
|
||||
بخش **اشتراکها** URLهای ثابت اشتراک و ترکیب inboundها را مدیریت میکند.
|
||||
|
||||
برای هر inbound میتوانید تنظیم کنید:
|
||||
|
||||
- نوع اتصال؛
|
||||
- نود؛
|
||||
- relay server؛
|
||||
- پرچم؛
|
||||
- پورت: عدد مشخص از `1-65535` یا `random`؛
|
||||
- SNI: دامنه مشخص یا `random`؛
|
||||
- لینک خارجی آماده برای نوع `custom`.
|
||||
|
||||
برای `hysteria2-udp` میتوانید مسیرهای جداگانه گواهی و کلید را مشخص کنید:
|
||||
|
||||
- `certificateFile`، برای مثال `/root/cert/example.com/fullchain.pem`;
|
||||
- `keyFile`، برای مثال `/root/cert/example.com/privkey.pem`.
|
||||
|
||||
اگر پورت یا SNI برابر `random` باشد، مقدار هنگام روتیشن از بازه موجود یا whitelist دامنهها انتخاب میشود.
|
||||
|
||||
---
|
||||
|
||||
## نصب سرویس forwarding
|
||||
|
||||
> [!WARNING]
|
||||
> سرویس forwarding روی سرور واسط اجرا میشود
|
||||
|
||||
سرویس forwarding پورتهای ورودی سرور واسط را به نود انتخابشده proxy میکند. در Web UI این سرورها در بخش **Relay serverها** تنظیم میشوند و میتوانند به یک نود مشخص متصل شوند.
|
||||
|
||||
Relay server را میتوان با IP یا دامنه اضافه کرد. اگر دامنه وارد شود، backend تلاش میکند IP آن را خودکار resolve کند.
|
||||
|
||||
برای نصب دستی forwarding روی سرور واسط، `IP_ADDRESS` را با IP نود اصلی جایگزین کنید:
|
||||
|
||||
```bash
|
||||
sudo ORIGIN_IP="IP_ADDRESS" bash -c "$(curl -sSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_install.sh)"
|
||||
```
|
||||
|
||||
<sup>توضیح کوتاه: قوانین forwarding را اضافه میکند.</sup>
|
||||
|
||||
## حذف forwarding
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_delete.sh)
|
||||
```
|
||||
|
||||
<sup>توضیح کوتاه: قوانین را حذف کرده و سرویس فورواردینگ را غیرفعال میکند.</sup>
|
||||
<sup>توضیح کوتاه: قوانین forwarding را حذف میکند. پس از حذف، برای اعمال تغییرات firewall را با `ufw reload` بارگذاری مجدد کنید و سیستم را با `reboot` ریاستارت کنید.</sup>
|
||||
|
||||
---
|
||||
|
||||
## نمایش URL اشتراک
|
||||
## جمعآوری دامنهها از چند اشتراک
|
||||
|
||||
دستور برای نمایش URL اشتراک فعلی در محیط کانتینر:
|
||||
|
||||
```bash
|
||||
cd /opt/3dp-manager && docker compose exec node env | grep SUB_URL | cut -d'=' -f2
|
||||
```
|
||||
|
||||
<sup>توضیح کوتاه: URL ثابت اشتراک را نمایش میدهد که میتوان در کلاینتها استفاده کرد. هم در سرور اصلی و هم در سرور واسط کار میکند.</sup>
|
||||
|
||||
## جمعآوری دامنهها از چندین اشتراک
|
||||
|
||||
ابزار دامنهها را از اشتراکهای دارای چندین پیکربندی استخراج کرده و `whitelist` تولید میکند.
|
||||
این ابزار دامنهها را از اشتراکها استخراج میکند و برای generator یک `whitelist` میسازد.
|
||||
|
||||
```bash
|
||||
node get_domains.js
|
||||
```
|
||||
|
||||
<sup>توضیح کوتاه: لینک چندین اشتراک را در اسکریپت قرار داده و فرمان را اجرا کنید — خروجی فهرست دامنهها خواهد بود. نیازمند `Node.js` است.</sup>
|
||||
|
||||
## استفاده از whitelist شخصی
|
||||
|
||||
1. فایلی با ساختار مشابه `whitelist.txt` آماده کنید.
|
||||
2. آن را به `my_whitelist.txt` تغییر نام دهید و در پوشه `/opt/3dp-manager/app` کپی کنید.
|
||||
|
||||
```bash
|
||||
cd /opt/3dp-manager && docker cp ./app/my_whitelist.txt node:/app/my_whitelist.txt
|
||||
```
|
||||
|
||||
<sup>توضیح کوتاه: فایل دامنه شما را به کانتینر برنامه اضافه میکند.</sup>
|
||||
<sup>توضیح کوتاه: لینک چند اشتراک را در اسکریپت اضافه کنید و دستور را اجرا کنید. خروجی، فهرست دامنهها خواهد بود. برای اجرای اسکریپت به `Node.js` نیاز است.</sup>
|
||||
|
||||
---
|
||||
|
||||
## نکات و محدودیتهای فعلی
|
||||
|
||||
- لیست عمومی دامنهها برای همه ارائهدهندگان کار نمیکند؛ پیشنهاد میشود `whitelist` خود را تهیه و استفاده کنید.
|
||||
- فهرست عمومی دامنهها با همه ارائهدهندگان کار نمیکند، بنابراین توصیه میشود whitelist خودتان را بسازید و استفاده کنید.
|
||||
|
||||
---
|
||||
|
||||
## مشارکت
|
||||
|
||||
از هرگونه مشارکت در توسعه پروژه خوشحال میشوم! روند ساده برای مشارکتکنندگان:
|
||||
از هرگونه مشارکت در توسعه پروژه استقبال میشود! روند ساده برای مشارکتکنندگان:
|
||||
|
||||
1. مخزن را در GitHub fork کنید.
|
||||
2. شاخهای با نام معنادار ایجاد کنید مانند `feature/add-README` یا `fix/whitelist-load`.
|
||||
3. تغییرات را اعمال کرده و یک پیام کوتاه در کامیت اضافه کنید.
|
||||
2. شاخهای با نام معنادار بسازید، مثل `feature/add-README` یا `fix/whitelist-load`.
|
||||
3. تغییرات را اعمال کنید و توضیح کوتاهی در commit بنویسید.
|
||||
4. در صورت وجود، بررسیهای محلی را اجرا کنید.
|
||||
5. شاخه را به fork خود پوش کرده و Pull Request ایجاد کنید.
|
||||
5. شاخه را به fork خود push کنید و در مخزن اصلی Pull Request بسازید.
|
||||
|
||||
<sup>نکات: تغییرات را در PR شرح دهید و مراحل تست را بنویسید. در صورت تغییرات بزرگ، آنها را به کامیتهای کوچک تقسیم کنید.</sup>
|
||||
<sup>نکته: تغییرات، هدف و مراحل تست را در PR توضیح دهید. اگر تغییرات بزرگ هستند، آنها را به commitهای کوچک تقسیم کنید.</sup>
|
||||
|
||||
---
|
||||
|
||||
## بحث
|
||||
## گفتگو
|
||||
|
||||
- تلگرام: [@denpiligrim_web](https://t.me/denpiligrim_web)
|
||||
- Issues
|
||||
- بخش Issues در همین مخزن
|
||||
|
||||
+160
-71
@@ -2,154 +2,243 @@
|
||||
|
||||
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
|
||||
|
||||
 [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||
  [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||
|
||||
# 3DP-MANAGER
|
||||
|
||||
Bu gural `3x-ui` paneli üçin awtomatiki ýagdaýda inbound baglanyşlaryny döredýär, birleşdirilen abunany (subscription) döretmäge we aragatnaşyk serwerinden esasy servere trafigiň ugradylyşyny sazlamaga kömek edýär.
|
||||
[3x-ui](https://github.com/MHSanaei/3x-ui) paneli üçin inbound-lary awtomatiki döretmäge, birleşdirilen abunalary düzmäge, birnäçe 3x-ui node-ny dolandyrmaga we aralyk serwerlerden esasy serwerlere relay forwarding sazlamaga niýetlenen gural. 2.0.0 wersiýadan bäri taslamada grafiki interfeýs we ýönekeý ulanyjy sazlamalary bar.
|
||||
|
||||
**Taslamany goldaň**
|
||||
|
||||
- Töleg / goşant maglumatlary:
|
||||
- MIR kart: `2204320436318077`
|
||||
- MasterCard: `5395452209474530`
|
||||
- PayPal: `vasiljevdenisx@gmail.com`
|
||||
- USDT | ETH (ERC20 | BEP20): `0x6fe140040f6Cdc1E1Ff2136cd1d60C0165809463`
|
||||
- USDT | TRX (TRC20): `TEWxXmJxvkAmhshp7E61XJGHB3VyM9hNAb`
|
||||
- Bitcoin: `bc1qctntwncsv2yn02x2vgnkrqm00c4h04c0afkgpl`
|
||||
- TON: `UQCZ3MiwyYHXftPItMMzJRYRiKHugr16jFMq2nfOQOOoemLy`
|
||||
- Bybit ID: `165292278`
|
||||
- Bank geçirimi:
|
||||
- :credit_card: MIR kart: `2204320436318077`
|
||||
- :credit_card: MasterCard: `5395452209474530`
|
||||
- Elektron gapjyk:
|
||||
- :moneybag: YooMoney: `4100116897060652`
|
||||
- :moneybag: PayPal: `vasiljevdenisx@gmail.com`
|
||||
- Kriptowalýuta:
|
||||
- :coin: USDT | ETH (ERC20 | BEP20): `0x6fe140040f6Cdc1E1Ff2136cd1d60C0165809463`
|
||||
- :coin: USDT | TRX (TRC20): `TEWxXmJxvkAmhshp7E61XJGHB3VyM9hNAb`
|
||||
- :coin: Bitcoin: `bc1qctntwncsv2yn02x2vgnkrqm00c4h04c0afkgpl`
|
||||
- :coin: TON: `UQCZ3MiwyYHXftPItMMzJRYRiKHugr16jFMq2nfOQOOoemLy`
|
||||
- :coin: Bybit ID: `165292278`
|
||||
|
||||
## Düşündiriş
|
||||
|
||||
Esasy maksady trafigiňizi birmeňzeş bolmaz ýaly etmekdir. Bot bellenen aralykda dürli parametrler bilen 10 sany baglanyşygy döredýär:
|
||||
Guralyň esasy maksady trafigiňiziň birmeňzeş görünmezligini gazanmakdyr. 3DP-MANAGER bellenen aralykda dürli parametrli baglanyşyklar toplumyny döredýär:
|
||||
|
||||
- Protokollar: `vless`, `vmess`, `shadowsocks`, `trojan`;
|
||||
- Portlar: `443`, `8443` (hemişelik) we `10000-60000` aralygyndan tötänleýin portlar;
|
||||
- Transport: `tcp`, `websocket`, `grpc`, `xhttp`;
|
||||
- SNI `whitelist` domen sanawyndan alynýar; öz sanawyňyzy hem ulanyp bilersiňiz.
|
||||
- protokollar: `vless`, `vmess`, `shadowsocks`, `hysteria2`, `trojan`;
|
||||
- portlar: hemişelik `443`, `8443` we `10000-60000` aralygyndaky tötänleýin portlar;
|
||||
- transport: `tcp`, `websocket`, `grpc`, `xhttp`;
|
||||
- SNI domen whitelist-den alynýar ýa-da el bilen berilýär;
|
||||
- her inbound üçin node, relay serwer, port we baýdak aýratyn sazlanýar.
|
||||
|
||||
Ähli döredilen baglanyşyklary biri-birine birleşdirip, statik URL bilen bir abuna döredilýär. Bot `3x-ui` paneliniň açyk API-sini ulanýar we paneliň içki konfigurasiýasyna gönüden-göni aralaşmaýar.
|
||||
Ähli baglanyşyklar statik URL-li bir abuna birleşdirilýär. 3DP-MANAGER `3x-ui` paneli bilen onuň açyk API-si arkaly işleýär we paneliň içki işine göni gatyşmaýar.
|
||||
|
||||
Ikinji maksady baglanyşygyň durnuklylygyny ýokarlandyrmakdyr: müşderi 10 dürli baglanyşyk opsiýasyny alýar we islese haýsyny islese saýlap bilýär.
|
||||
Ikinji maksat baglanyşygyň durnuklylygydyr: müşderi birnäçe baglanyşyk wariantyny alýar we islänini saýlap bilýär. Bir abuna dürli node-lardan inbound-lary, relay serwerleri we custom baglanyşyk hökmünde taýýar daşarky linkleri goşup bolýar.
|
||||
|
||||
Göçürme (forwarding) hyzmaty bilen birleşdirilende, bot abonnament we trafik diňe esasy servere ugradylyşyny awtomatiki sazlar.
|
||||
Mundan başga-da, 3DP-MANAGER kaskadly shemada ulanylyp bilner. Forwarding hyzmaty abunany we trafigi saýlanan node-a ugrukdyrmagy sazlaýar.
|
||||
|
||||
Maslahatchylyklar:
|
||||
Maslahatlar:
|
||||
|
||||
- Abuna üçin HTTPS ulanyň (domain + SSL şahadatnamasy).
|
||||
- Dörediş aralygyny ≥ 10 minut goýuň; durnuklylyk üçin her gün bir gezek (1440 minut) maslahat berilýär.
|
||||
- Müşderide awtomatiki täzelenmäni ýygylaşdyryň (meselem, her sagat) — serwera sazlaşyklylyk üçin.
|
||||
- Abuna üçin HTTPS ulanyň (domen + SSL şahadatnamasy).
|
||||
- Döretmek aralygyny azyndan 10 minut goýuň; durnuklylyk üçin günde bir gezek (1440 minut) maslahat berilýär.
|
||||
- Müşderide awtomatiki täzelenmäni ýygy goýuň, meselem her sagat, serwer bilen sazlaşyk saklansyn.
|
||||
|
||||
## Imkançylyklar
|
||||
## Mümkinçilikler
|
||||
|
||||
- 10 dürli baglanyşygy öndürýär
|
||||
- Statik URL bilen bir abuna döredýär
|
||||
- Öz `whitelist` domenleriňizi goldaýar
|
||||
- Trafigiň awtomatik forwarding sazlamalary (islege bagly)
|
||||
- Bir ýa-da birnäçe abuna üçin dürli baglanyşyklar döredýär
|
||||
- Statik URL bilen birleşdirilen abuna döredýär
|
||||
- Birnäçe `3x-ui` node-ny dolandyrýar
|
||||
- Node autentifikasiýasyny login/password ýa-da token arkaly goldaýar
|
||||
- Panel URL-den node IP-sini, ýurduny we baýdagyny awtomatiki kesgitleýär
|
||||
- Her inbound üçin node, relay serwer, baýdak, SNI we port saýlamaga mümkinçilik berýär
|
||||
- Hemişelik porty ýa-da `random` bahasyny goldaýar
|
||||
- Custom baglanyşyklary goldaýar: taýýar daşarky linki abuna goşup bolýar
|
||||
- Hysteria2 UDP üçin `fullchain.pem` we `privkey.pem` ýollaryny aýratyn görkezmäge mümkinçilik berýär
|
||||
- Öz domen `whitelist` sanawyňyzy goldaýar
|
||||
- Relay forwarding-i awtomatiki sazlamak mümkinçiligi bar
|
||||
- Web UI HTTP ýa-da HTTPS arkaly gurnalýar: Let's Encrypt, self-signed ýa-da öz şahadatnamalaryňyz
|
||||
|
||||
## Talaplar
|
||||
|
||||
- Ubuntu 20.04 ýa-da täze
|
||||
- `3x-ui` paneli
|
||||
- Domain + SSL şahadatnamasy (islege bagly)
|
||||
- Ubuntu 20.04 ýa-da täze, Debian 12.11 ýa-da täze
|
||||
- `3x-ui` paneli v2.8.4 ýa-da täze
|
||||
- Serwerde root elýeterliligi
|
||||
- Docker we Docker Compose; ýok bolsa, gurnama skripti olary awtomatiki gurnamaga synanyşar
|
||||
- Domen + SSL şahadatnamasy (islege bagly)
|
||||
|
||||
---
|
||||
|
||||
## Gurnama
|
||||
|
||||
Taslamany serwera gurnamak üçin şu komandany ýerine ýetiriň:
|
||||
`3x-ui` dolandyryş paneli öňünden gurnalan bolmaly. Ony şu komanda bilen gurnap bilersiňiz: `bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)`
|
||||
|
||||
Taslamany serwere gurnaň:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/install.sh)
|
||||
```
|
||||
|
||||
<sup>Gysgaça: gurnama skripti işledilýär we konteýnerler we hyzmatlar döredilýär.</sup>
|
||||
Gurnama wagtynda skript:
|
||||
|
||||
## Täzelik
|
||||
- OS, root elýeterliligi, Docker we Docker Compose-y barlaýar;
|
||||
- RAM az we swap ýok bolsa, 2 GB swap faýl döredýär;
|
||||
- Web UI elýeterlilik görnüşini saýladýar:
|
||||
- Let's Encrypt arkaly HTTPS;
|
||||
- self-signed şahadatnama bilen HTTPS;
|
||||
- öz şahadatnamalaryňyz bilen HTTPS;
|
||||
- şifrlemesiz HTTP;
|
||||
- Web UI üçin tötänleýin port döredýär;
|
||||
- administrator login we password döredýär;
|
||||
- `postgres`, `backend` we `frontend` konteýnerlerini işe goýberýär.
|
||||
|
||||
Soňky wersiýa çenli täzeläň:
|
||||
Gurnama tamamlanandan soň terminalda URL, login we password görkeziler. Password-y derrew 3DP-MANAGER sazlamalarynda üýtgediň.
|
||||
|
||||
<sup>Gysgaça: interaktiw gurnama skriptini işledýär, gurşawy taýýarlaýar we taslamanyň konteýnerlerini ýerleşdirýär.</sup>
|
||||
|
||||
## Täzelemek
|
||||
|
||||
Soňky wersiýa çenli täzelemek:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/update.sh)
|
||||
```
|
||||
|
||||
<sup>Gysgaça: iň soňky üýtgeşmeleri çekýär we konteýnerleri gaýtadan işledýär.</sup>
|
||||
<sup>Gysgaça: soňky üýtgeşmeleri alýar, laýyk konfigurasiýa düzedişlerini ulanýar, konteýnerleri täzeleýär we hyzmaty gaýtadan işledýär.</sup>
|
||||
|
||||
## Pozmak
|
||||
|
||||
Hyzmaty doly pozmak üçin:
|
||||
Hyzmaty doly pozmak:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/delete.sh)
|
||||
```
|
||||
|
||||
<sup>Gysgaça: konteýnerleri we konfigurasiýa faýllaryny pozup, systemany gurnamadan öňki ýagdaýyna getirer.</sup>
|
||||
<sup>Gysgaça: konteýnerleri we konfigurasiýa faýllaryny pozýar, ulgamy gurnamadan öňki ýagdaýa getirýär.</sup>
|
||||
|
||||
---
|
||||
|
||||
## Forwarding hyzmatyny gurnamak
|
||||
## Ilkinji Giriş
|
||||
|
||||
Forwarding hyzmaty aragatnaşyk serwerinden esasy servere gelýän portlary proxy arkaly geçirýär.
|
||||
Administrator login we password gurnamanyň ahyrynda görkezilýär. Olary gaýtadan görmek üçin:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_install.sh)
|
||||
grep -E "ADMIN_LOGIN|ADMIN_PASSWORD" /opt/3dp-manager/docker-compose.yml | sed 's/^[ \t]*//; s/^- //'
|
||||
```
|
||||
|
||||
## Forwarding-i pozmak
|
||||
Web UI gurnawçynyň görkezen salgysy boýunça elýeterlidir, mysal üçin:
|
||||
|
||||
- HTTPS üçin `https://example.com:PORT`;
|
||||
- HTTP üçin `http://SERVER_IP:PORT`.
|
||||
|
||||
Eger `ufw` işjeň bolsa, gurnawçy inbound üçin iş portlaryny açýar: TCP/UDP üçin `443`, `8443` we `10000-60000`.
|
||||
|
||||
---
|
||||
|
||||
## Node-lar
|
||||
|
||||
**Node-lar** bölümi bir ýa-da birnäçe `3x-ui` panelini birikdirmäge mümkinçilik berýär.
|
||||
|
||||
Her node üçin görkezilýär:
|
||||
|
||||
- ady;
|
||||
- `3x-ui` panel URL-i;
|
||||
- node IP-si;
|
||||
- baýdak/ýurt;
|
||||
- autentifikasiýa görnüşi: `password` ýa-da `token`;
|
||||
- esasy node bolup durýandygy.
|
||||
|
||||
URL girizilenden soň programma node IP-sini we ýurduny awtomatiki kesgitlemäge synanyşýar. Baýdak abunanyň içindäki baglanyşyk atlarynda ulanylýar, müşderide serwerleri tapawutlandyrmak aňsat bolýar.
|
||||
|
||||
> [!NOTE]
|
||||
> Esasy node abunalar, inbound-lar we relay serwerler üçin deslapky baha hökmünde ulanylýar.
|
||||
|
||||
---
|
||||
|
||||
## Abunalar
|
||||
|
||||
**Abunalar** bölümi statik abuna URL-lerini we inbound düzümini dolandyrýar.
|
||||
|
||||
Her inbound üçin sazlap bolýar:
|
||||
|
||||
- baglanyşyk görnüşi;
|
||||
- node;
|
||||
- relay serwer;
|
||||
- baýdak;
|
||||
- port: `1-65535` aralygyndaky anyk san ýa-da `random`;
|
||||
- SNI: anyk domen ýa-da `random`;
|
||||
- `custom` görnüşi üçin taýýar daşarky link.
|
||||
|
||||
`hysteria2-udp` üçin şahadatnama we açar ýollaryny aýratyn görkezmek bolýar:
|
||||
|
||||
- `certificateFile`, mysal üçin `/root/cert/example.com/fullchain.pem`;
|
||||
- `keyFile`, mysal üçin `/root/cert/example.com/privkey.pem`.
|
||||
|
||||
Port ýa-da SNI `random` bolsa, baha rotasiýa wagtynda elýeterli aralykdan ýa-da domen whitelist-den saýlanar.
|
||||
|
||||
---
|
||||
|
||||
## Forwarding Hyzmatyny Gurnamak
|
||||
|
||||
> [!WARNING]
|
||||
> Forwarding hyzmaty aralyk serwerde işleýär
|
||||
|
||||
Forwarding hyzmaty aralyk serweriň gelýän portlaryny saýlanan node-a proxy edýär. Web UI-de şeýle serwerler **Relay serwerler** bölüminde sazlanýar we belli bir node bilen baglanyşdyrylyp bilýär.
|
||||
|
||||
Relay serwer IP ýa-da domen boýunça goşulyp bilner. Domen görkezilse, backend onuň IP-sini awtomatiki çözmäge synanyşar.
|
||||
|
||||
Aralyk serwerde forwarding-i el bilen gurnamak üçin `IP_ADDRESS` ýerine esasy node IP-sini goýuň:
|
||||
|
||||
```bash
|
||||
sudo ORIGIN_IP="IP_ADDRESS" bash -c "$(curl -sSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_install.sh)"
|
||||
```
|
||||
|
||||
<sup>Gysgaça: forwarding düzgünlerini goşýar.</sup>
|
||||
|
||||
## Forwarding-i Pozmak
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_delete.sh)
|
||||
```
|
||||
|
||||
<sup>Gysgaça: forwarding düzgünlerini pozýar. Pozlandan soň üýtgeşmeler güýje girmegi üçin firewall-y `ufw reload` bilen täzeden ýükläň we ulgamy `reboot` bilen täzeden işlediň.</sup>
|
||||
|
||||
---
|
||||
|
||||
## Abuna URL-ni görkezmek
|
||||
## Multi-Abunalardan Domenleri Toplamak
|
||||
|
||||
Konteýner gurşawynda häzirki abuna URL-ni görkezýän komanda:
|
||||
|
||||
```bash
|
||||
cd /opt/3dp-manager && docker compose exec node env | grep SUB_URL | cut -d'=' -f2
|
||||
```
|
||||
|
||||
## Multi-abunalardan domenleri toplamak
|
||||
|
||||
Gural birden köp konfigurasiýa içeren abunalardan domenleri çykaryp `whitelist` düzýär.
|
||||
Gural abunalardan domenleri çykaryp, generator üçin `whitelist` düzýär.
|
||||
|
||||
```bash
|
||||
node get_domains.js
|
||||
```
|
||||
|
||||
## Öz `whitelist`-iňizi ulanmak
|
||||
|
||||
1. `whitelist.txt` bilen meňzeş struktura eýe faýl taýýarlaň.
|
||||
2. Ony `my_whitelist.txt` diýip atlandyryp `/opt/3dp-manager/app` içine göçüriň.
|
||||
|
||||
```bash
|
||||
cd /opt/3dp-manager && docker cp ./app/my_whitelist.txt node:/app/my_whitelist.txt
|
||||
```
|
||||
<sup>Gysgaça: skripte multi-abuna linkini goşuň we komandany işlediň. Netijede domenleriň sanawy çykýar. Skript üçin `Node.js` gerek.</sup>
|
||||
|
||||
---
|
||||
|
||||
## Bellikler we çäklendirmeler
|
||||
## Bellikler We Häzirki Çäklendirmeler
|
||||
|
||||
- Umumy domen sanawy ähli üpjün edijilerde işlemeýär; öz `whitelist`-iňizi düzmegiňizi maslahat berýäris.
|
||||
- Umumy domen sanawy ähli prowaýderlerde işlemeýär, şonuň üçin öz whitelist sanawyňyzy taýýarlamak we ulanmak maslahat berilýär.
|
||||
|
||||
---
|
||||
|
||||
## Goşanty
|
||||
## Goşant
|
||||
|
||||
Proýekte goşant bermäge hoş geldiňiz! Goşant prosesi ýönekeý:
|
||||
Taslama goşantlaryňyza hoşal bolarys! Goşantçylar üçin ýönekeý proses:
|
||||
|
||||
1. GitHub-da repo-ny fork ediň.
|
||||
2. Meni manly at bilen şahamça (branch) dörediň, meselem `feature/add-README` ýa-da `fix/whitelist-load`.
|
||||
3. Üýtgeşmeleri giriziň we gysga commit ýazmagy unutmaň.
|
||||
4. Lokal barlaglary işlediň (bardy bolsa).
|
||||
5. Şahamçany fork-a iteriň we Pull Request dörediň.
|
||||
1. GitHub-da repozitoriýany fork ediň.
|
||||
2. Manyly at bilen branch dörediň, mysal üçin `feature/add-README` ýa-da `fix/whitelist-load`.
|
||||
3. Üýtgeşmeleri giriziň we commit-de gysga düşündiriş ýazyň.
|
||||
4. Bar bolsa, lokal barlaglary işlediň.
|
||||
5. Branch-i öz fork-uňyza push ediň we esasy repozitoriýa Pull Request dörediň.
|
||||
|
||||
<sup>Maslahat: PR-de üýtgeşmeleri, maksady we test ädimlerini ýazyň. Üýtgeşmeler uly bolsa, olary kiçi commit-lere bölüň.</sup>
|
||||
|
||||
---
|
||||
|
||||
## Çeperdeşlik
|
||||
## Ara Alyp Maslahatlaşmak
|
||||
|
||||
- Telegram: [@denpiligrim_web](https://t.me/denpiligrim_web)
|
||||
- Issues
|
||||
- Şu repozitoriýanyň Issues bölümi
|
||||
|
||||
@@ -0,0 +1,878 @@
|
||||
# API документация 3dp-manager
|
||||
|
||||
**Версия API:** v1
|
||||
**Базовый URL:** `http://<host>:3100/api`
|
||||
**Префикс:** Все endpoints начинаются с `/api`, кроме публичных подписок (`/bus/:uuid`)
|
||||
|
||||
---
|
||||
|
||||
## Содержание
|
||||
|
||||
1. [Аутентификация](#аутентификация)
|
||||
2. [Подписки](#подписки)
|
||||
3. [Домены](#домены)
|
||||
4. [Туннели (перенаправление)](#туннели-перенаправление)
|
||||
5. [Настройки](#настройки)
|
||||
6. [Ротация](#ротация)
|
||||
7. [Клиентские endpoints (публичные)](#клиентские-endpoints-публичные)
|
||||
|
||||
---
|
||||
|
||||
## Аутентификация
|
||||
|
||||
Все endpoints требуют JWT-аутентификации, кроме помеченных как **Public**.
|
||||
|
||||
### POST `/api/auth/login`
|
||||
|
||||
Получение JWT-токена.
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"login": "admin",
|
||||
"password": "your_password"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST `/api/auth/change-password`
|
||||
|
||||
Смена пароля администратора.
|
||||
|
||||
**Headers:** `Authorization: Bearer <token>`
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"password": "newSecurePassword"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST `/api/auth/update-profile`
|
||||
|
||||
Обновление профиля администратора (логин + опционально пароль).
|
||||
|
||||
**Headers:** `Authorization: Bearer <token>`
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"login": "newAdmin",
|
||||
"password": "newPassword"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Подписки
|
||||
|
||||
### GET `/api/subscriptions`
|
||||
|
||||
Получить все подписки.
|
||||
|
||||
**Headers:** `Authorization: Bearer <token>`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"uuid": "abc-123-def",
|
||||
"name": "My Subscription",
|
||||
"isEnabled": true,
|
||||
"isAutoRotationEnabled": true,
|
||||
"inbounds": [...],
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"updatedAt": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST `/api/subscriptions`
|
||||
|
||||
Создать новую подписку.
|
||||
|
||||
**Headers:** `Authorization: Bearer <token>`
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"name": "My Subscription",
|
||||
"inboundsConfig": [
|
||||
{
|
||||
"type": "vless",
|
||||
"port": 443,
|
||||
"sni": "example.com"
|
||||
},
|
||||
{
|
||||
"type": "vmess",
|
||||
"port": 8443,
|
||||
"transport": "websocket"
|
||||
}
|
||||
],
|
||||
"isAutoRotationEnabled": true
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"uuid": "abc-123-def",
|
||||
"name": "My Subscription",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### PUT `/api/subscriptions/:id`
|
||||
|
||||
Обновить подписку.
|
||||
|
||||
**Headers:** `Authorization: Bearer <token>`
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"name": "Updated Name",
|
||||
"isEnabled": false,
|
||||
"isAutoRotationEnabled": true
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Updated Name",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### PUT `/api/subscriptions/bulk-auto-rotation`
|
||||
|
||||
Массовое включение/отключение авто-ротации.
|
||||
|
||||
**Headers:** `Authorization: Bearer <token>`
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"subscriptionIds": ["1", "2", "3"],
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Обновлено 3 подписок",
|
||||
"updatedCount": 3,
|
||||
"notFound": []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DELETE `/api/subscriptions/:id`
|
||||
|
||||
Удалить подписку.
|
||||
|
||||
**Headers:** `Authorization: Bearer <token>`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Домены
|
||||
|
||||
### GET `/api/domains/all`
|
||||
|
||||
Получить все домены без пагинации.
|
||||
|
||||
**Headers:** `Authorization: Bearer <token>`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{ "id": 1, "name": "ya.ru" },
|
||||
{ "id": 2, "name": "vk.com" }
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET `/api/domains?page=1&limit=10`
|
||||
|
||||
Получить домены с пагинацией.
|
||||
|
||||
**Headers:** `Authorization: Bearer <token>`
|
||||
|
||||
**Query Parameters:**
|
||||
- `page` (default: 1)
|
||||
- `limit` (default: 10)
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{ "id": 1, "name": "ya.ru" }
|
||||
],
|
||||
"total": 100
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET `/api/domains/:id`
|
||||
|
||||
Получить домен по ID.
|
||||
|
||||
**Headers:** `Authorization: Bearer <token>`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "ya.ru"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST `/api/domains`
|
||||
|
||||
Создать один домен.
|
||||
|
||||
**Headers:** `Authorization: Bearer <token>`
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"name": "example.com"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "example.com"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST `/api/domains/upload`
|
||||
|
||||
Загрузить несколько доменов сразу.
|
||||
|
||||
**Headers:** `Authorization: Bearer <token>`
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"domains": ["ya.ru", "vk.com", "example.com"]
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"count": 3
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DELETE `/api/domains/:id`
|
||||
|
||||
Удалить домен по ID.
|
||||
|
||||
**Headers:** `Authorization: Bearer <token>`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DELETE `/api/domains/all`
|
||||
|
||||
Удалить все домены.
|
||||
|
||||
**Headers:** `Authorization: Bearer <token>`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET `/api/domains/scan/capabilities`
|
||||
|
||||
Получить возможности сканера доменов (наличие бинарника, timeout).
|
||||
|
||||
**Headers:** `Authorization: Bearer <token>`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"scannerAvailable": true,
|
||||
"scannerPath": "/usr/bin/RealiTLScanner-linux-64",
|
||||
"timeoutAvailable": true,
|
||||
"timeoutPath": "/usr/bin/timeout"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET `/api/domains/scan/status`
|
||||
|
||||
Получить статус текущего сканирования.
|
||||
|
||||
**Headers:** `Authorization: Bearer <token>`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"running": false,
|
||||
"runId": null,
|
||||
"lastRunId": "scan-123"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET `/api/domains/scan/last-result`
|
||||
|
||||
Получить результат последнего сканирования.
|
||||
|
||||
**Headers:** `Authorization: Bearer <token>`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"runId": "scan-123",
|
||||
"foundCount": 10,
|
||||
"domains": ["ya.ru", "vk.com", ...]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST `/api/domains/scan/start`
|
||||
|
||||
Запустить сканирование IP на наличие SNI.
|
||||
|
||||
**Headers:** `Authorization: Bearer <token>`
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"addr": "192.168.1.1",
|
||||
"scanSeconds": 60,
|
||||
"thread": 100,
|
||||
"timeout": 30
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"runId": "scan-456"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Туннели (перенаправление)
|
||||
|
||||
### GET `/api/tunnels`
|
||||
|
||||
Получить все туннели.
|
||||
|
||||
**Headers:** `Authorization: Bearer <token>`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Relay Server 1",
|
||||
"ip": "192.168.1.100",
|
||||
"sshPort": 22,
|
||||
"username": "root",
|
||||
"domain": "relay.example.com",
|
||||
"isInstalled": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST `/api/tunnels`
|
||||
|
||||
Создать новый туннель.
|
||||
|
||||
**Headers:** `Authorization: Bearer <token>`
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"name": "Relay Server 1",
|
||||
"ip": "192.168.1.100",
|
||||
"sshPort": 22,
|
||||
"username": "root",
|
||||
"password": "ssh_password",
|
||||
"domain": "relay.example.com"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Relay Server 1",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST `/api/tunnels/:id/install`
|
||||
|
||||
Установить скрипт перенаправления на удалённый сервер.
|
||||
|
||||
**Headers:** `Authorization: Bearer <token>`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"script": "#!/bin/bash ...",
|
||||
"message": "Скрипт сгенерирован. Выполните его на целевом сервере."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DELETE `/api/tunnels/:id`
|
||||
|
||||
Удалить туннель.
|
||||
|
||||
**Headers:** `Authorization: Bearer <token>`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Настройки
|
||||
|
||||
### GET `/api/settings`
|
||||
|
||||
Получить все настройки.
|
||||
|
||||
**Headers:** `Authorization: Bearer <token>`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"xui_url": "http://192.168.1.1:54321",
|
||||
"xui_login": "admin",
|
||||
"rotationIntervalMinutes": "1440",
|
||||
"xui_geo_country": "Germany",
|
||||
"xui_geo_flag": "🇩🇪"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST `/api/settings`
|
||||
|
||||
Обновить настройки.
|
||||
|
||||
**Headers:** `Authorization: Bearer <token>`
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"xui_url": "http://192.168.1.1:54321",
|
||||
"xui_login": "admin",
|
||||
"xui_password": "password",
|
||||
"rotationIntervalMinutes": "1440"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
*Автоматически извлекает host, IP, определяет страну по GeoIP.*
|
||||
|
||||
---
|
||||
|
||||
### POST `/api/settings/check`
|
||||
|
||||
Проверить подключение к 3x-ui панели.
|
||||
|
||||
**Headers:** `Authorization: Bearer <token>`
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"xui_url": "http://192.168.1.1:54321",
|
||||
"xui_login": "admin",
|
||||
"xui_password": "password"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ротация
|
||||
|
||||
### POST `/api/rotation/rotate-all`
|
||||
|
||||
Выполнить ротацию всех подписок.
|
||||
|
||||
**Headers:** `Authorization: Bearer <token>`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"rotatedCount": 5
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST `/api/rotation/rotate-one/:id`
|
||||
|
||||
Выполнить ротацию одной подписки.
|
||||
|
||||
**Headers:** `Authorization: Bearer <token>`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"subscriptionId": "1"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Клиентские endpoints (публичные)
|
||||
|
||||
Эти endpoints **не требуют аутентификации**.
|
||||
|
||||
### GET `/bus/:uuid`
|
||||
|
||||
Получить подписку в формате Base64 или HTML-страницу с QR-кодом.
|
||||
|
||||
**Public** — без токена.
|
||||
|
||||
**URL:**
|
||||
```
|
||||
GET /bus/abc-123-def
|
||||
```
|
||||
|
||||
**Headers:**
|
||||
- `User-Agent: Mozilla/...` → возвращает HTML с QR
|
||||
- `User-Agent: v2rayN/...` → возвращает plain text Base64
|
||||
|
||||
**Response (Base64):**
|
||||
```
|
||||
vless://abc123...
|
||||
vmess://xyz789...
|
||||
...
|
||||
```
|
||||
|
||||
**Response (HTML):**
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>Subscription: My Subscription</h1>
|
||||
<img src="data:image/png;base64,..." />
|
||||
<a href="/bus/abc-123-def">Скачать подписку</a>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET `/bus/:uuid/:tunnelId`
|
||||
|
||||
Получить подписку с заменой хоста на релей-сервер.
|
||||
|
||||
**Public** — без токена.
|
||||
|
||||
**Query Parameters:**
|
||||
- `format` (опционально): `base64` | `html`
|
||||
|
||||
**URL:**
|
||||
```
|
||||
GET /bus/abc-123-def/1
|
||||
```
|
||||
|
||||
**Response:**
|
||||
- Автоматически заменяет IP/домен в ссылках на `tunnel.domain` или `tunnel.ip`
|
||||
- Возвращает Base64 или HTML с QR
|
||||
|
||||
---
|
||||
|
||||
## Сущности
|
||||
|
||||
### Subscription
|
||||
```typescript
|
||||
{
|
||||
id: number;
|
||||
uuid: string;
|
||||
name: string;
|
||||
isEnabled: boolean;
|
||||
isAutoRotationEnabled: boolean;
|
||||
inbounds: Inbound[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
### Inbound
|
||||
```typescript
|
||||
{
|
||||
id: number;
|
||||
type: 'vless' | 'vmess' | 'shadowsocks' | 'hysteria2' | 'trojan' | 'custom';
|
||||
port: number | string;
|
||||
sni?: string;
|
||||
link: string;
|
||||
transport?: 'tcp' | 'websocket' | 'grpc' | 'xhttp';
|
||||
}
|
||||
```
|
||||
|
||||
### Domain
|
||||
```typescript
|
||||
{
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Tunnel
|
||||
```typescript
|
||||
{
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
sshPort: number;
|
||||
username: string;
|
||||
domain?: string;
|
||||
isInstalled: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### Setting
|
||||
```typescript
|
||||
{
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Коды ошибок
|
||||
|
||||
| Код | Описание |
|
||||
|-----|----------|
|
||||
| 200 | Успех |
|
||||
| 400 | Неверный запрос |
|
||||
| 401 | Неавторизован (нет токена или неверный) |
|
||||
| 403 | Доступ запрещён |
|
||||
| 404 | Ресурс не найден |
|
||||
| 500 | Внутренняя ошибка сервера |
|
||||
|
||||
---
|
||||
|
||||
## Примеры использования
|
||||
|
||||
### JavaScript (Fetch API)
|
||||
|
||||
```javascript
|
||||
// Логин
|
||||
const loginResponse = await fetch('http://localhost:3100/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ login: 'admin', password: 'admin' })
|
||||
});
|
||||
const { access_token } = await loginResponse.json();
|
||||
|
||||
// Получить все подписки
|
||||
const subsResponse = await fetch('http://localhost:3100/api/subscriptions', {
|
||||
headers: { 'Authorization': `Bearer ${access_token}` }
|
||||
});
|
||||
const subscriptions = await subsResponse.json();
|
||||
|
||||
// Создать подписку
|
||||
const createResponse = await fetch('http://localhost:3100/api/subscriptions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${access_token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: 'My Subscription',
|
||||
inboundsConfig: [
|
||||
{ type: 'vless', port: 443, sni: 'example.com' }
|
||||
],
|
||||
isAutoRotationEnabled: true
|
||||
})
|
||||
});
|
||||
const subscription = await createResponse.json();
|
||||
```
|
||||
|
||||
### Python (requests)
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
# Логин
|
||||
response = requests.post('http://localhost:3100/api/auth/login', json={
|
||||
'login': 'admin',
|
||||
'password': 'admin'
|
||||
})
|
||||
token = response.json()['access_token']
|
||||
|
||||
# Получить все домены
|
||||
headers = {'Authorization': f'Bearer {token}'}
|
||||
response = requests.get('http://localhost:3100/api/domains/all', headers=headers)
|
||||
domains = response.json()
|
||||
|
||||
# Запустить сканирование
|
||||
response = requests.post('http://localhost:3100/api/domains/scan/start',
|
||||
headers=headers,
|
||||
json={
|
||||
'addr': '192.168.1.1',
|
||||
'scanSeconds': 60,
|
||||
'thread': 100,
|
||||
'timeout': 30
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
# Логин
|
||||
curl -X POST http://localhost:3100/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"login":"admin","password":"admin"}'
|
||||
|
||||
# Получить подписку (Base64)
|
||||
curl http://localhost:3100/bus/abc-123-def
|
||||
|
||||
# Получить подписку (HTML с QR)
|
||||
curl -H "User-Agent: Mozilla/5.0" \
|
||||
http://localhost:3100/bus/abc-123-def
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Интеграция
|
||||
|
||||
### Ссылка на подписку в клиенте
|
||||
|
||||
Формат:
|
||||
```
|
||||
http://<host>:<port>/bus/<uuid>
|
||||
```
|
||||
|
||||
Пример для v2rayN, Clash, Surge:
|
||||
```
|
||||
http://192.168.1.100:3100/bus/abc-123-def
|
||||
```
|
||||
|
||||
### QR-код
|
||||
|
||||
Откройте в браузере:
|
||||
```
|
||||
http://<host>:<port>/bus/<uuid>
|
||||
```
|
||||
|
||||
Сканируйте QR-код приложением.
|
||||
|
||||
---
|
||||
|
||||
## Примечания
|
||||
|
||||
- **JWT-токен** передаётся в заголовке `Authorization: Bearer <token>`
|
||||
- **Публичные endpoints** (`/bus/*`) не требуют токена
|
||||
- **Base64-кодировка** используется для подписок (стандарт v2ray)
|
||||
- **Авто-ротация** выполняется по интервалу из настроек (`rotationIntervalMinutes`)
|
||||
- **Scanner** (RealiTLScanner) — Go-бинарник, встраивается в backend-контейнер
|
||||
|
||||
---
|
||||
|
||||
## Контакты
|
||||
|
||||
- Telegram: [@denpiligrim_web](https://t.me/denpiligrim_web)
|
||||
- YouTube: [DenPiligrim](https://www.youtube.com/@denpiligrim)
|
||||
- GitHub: [denpiligrim/3dp-manager](https://github.com/denpiligrim/3dp-manager)
|
||||
@@ -8,6 +8,10 @@ RUN npm ci
|
||||
COPY . .
|
||||
|
||||
ENV VITE_API_URL=/api
|
||||
ENV VITE_LOG_LEVEL=debug
|
||||
ENV VITE_SEND_LOGS_TO_BACKEND=true
|
||||
ENV VITE_APP_VERSION=2.2.0
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
# ✅ АУДИТ ФРОНТЕНДА (React/TypeScript)
|
||||
|
||||
**Дата аудита:** 28 марта 2026 г.
|
||||
**Методология:** Нулевое доверие к памяти — полная проверка через `git diff HEAD`, чтение файлов, линтинг.
|
||||
|
||||
---
|
||||
|
||||
## 📊 ОБЩАЯ СТАТИСТИКА
|
||||
|
||||
| Метрика | Значение |
|
||||
|---------|----------|
|
||||
| **Изменено файлов (tracked)** | 15 |
|
||||
| **Создано файлов (untracked)** | 5 |
|
||||
| **Ошибок линтинга** | 0 |
|
||||
| **Сборка** | ✅ Успешно |
|
||||
|
||||
---
|
||||
|
||||
## 📝 ИЗМЕНЁННЫЕ ФАЙЛЫ (15 tracked) — ДЛЯ CHERRY-PICK
|
||||
|
||||
| Файл | Изменения |
|
||||
|------|-----------|
|
||||
| `client/Dockerfile` | Добавлены ENV переменные для логирования (`VITE_LOG_LEVEL`, `VITE_SEND_LOGS_TO_BACKEND`, `VITE_APP_VERSION`) |
|
||||
| `client/eslint.config.js` | Добавлены правила `react-hooks/exhaustive-deps: error`, `react-hooks/set-state-in-effect: off` |
|
||||
| `client/nginx.conf` | Исправлены proxy timeout'ы, добавлен `/bus/` location |
|
||||
| `client/src/App.tsx` | Косметические (пробелы) |
|
||||
| `client/src/ThemeContext.tsx` | Вынос типов в `types/theme.ts`, eslint-disable комментарий |
|
||||
| `client/src/api.ts` | Добавлены axios interceptors + логирование через Logger |
|
||||
| `client/src/auth/AuthContext.tsx` | Упрощение, удаление useEffect, eslint-disable комментарий |
|
||||
| `client/src/auth/AxiosInterceptor.tsx` | Проверка location.pathname, замена console.* на Logger |
|
||||
| `client/src/components/Header.tsx` | APP_VERSION из utils, Dialog для logout (вместо confirm) |
|
||||
| `client/src/pages/DomainsPage.tsx` | +310 строк: Snackbar, Dialog, useCallback, логирование, валидация |
|
||||
| `client/src/pages/LoginPage.tsx` | Логирование через Logger, getApiErrorMessage |
|
||||
| `client/src/pages/SettingsPage.tsx` | Snackbar, Dialog, useCallback, логирование, валидация |
|
||||
| `client/src/pages/SubscriptionsPage.tsx` | Snackbar (вместо alert), Dialog (вместо confirm), useCallback, логирование |
|
||||
| `client/src/pages/TunnelsPage.tsx` | Snackbar, Dialog, валидация формы, useCallback, логирование |
|
||||
| `client/vite.config.ts` | Proxy для dev-сервера (port 8080, /api, /bus) |
|
||||
|
||||
---
|
||||
|
||||
## 📄 НОВЫЕ ФАЙЛЫ (5 untracked) — ДОБАВИТЬ ЧЕРЕЗ `git add`
|
||||
|
||||
| Файл | Назначение | Статус |
|
||||
|------|------------|--------|
|
||||
| `client/src/utils/logger.ts` | Централизованное логирование (Logger) | ✅ Untracked |
|
||||
| `client/src/utils/errorHandlers.ts` | Type guards для API ошибок | ✅ Untracked |
|
||||
| `client/src/utils/version.ts` | Константа APP_VERSION | ✅ Untracked |
|
||||
| `client/src/types/auth.ts` | TypeScript типы для AuthContext | ✅ Untracked |
|
||||
| `client/src/types/theme.ts` | TypeScript типы для ThemeContext | ✅ Untracked |
|
||||
|
||||
**Примечание:** Новые файлы типов и утилит не добавлены в git (untracked). Для cherry-pick потребуется:
|
||||
|
||||
```bash
|
||||
git add client/src/utils/ client/src/types/
|
||||
git commit -m "feat: add utils and types"
|
||||
git cherry-pick <commit-hash>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 ИСПРАВЛЕННЫЕ ПРОБЛЕМЫ
|
||||
|
||||
┌────────────────────────────┬─────────┬────────────────────────────────────────────────────────────┐
|
||||
│ Категория │ Проблем │ Статус │
|
||||
├────────────────────────────┼─────────┼────────────────────────────────────────────────────────────┤
|
||||
│ XSS через alert() │ 7 │ ✅ Заменено на MUI Snackbar │
|
||||
│ confirm() │ 2 │ ✅ Заменено на MUI Dialog │
|
||||
│ Пустые catch блоки │ 10+ │ ✅ Добавлено логирование │
|
||||
│ Race condition │ 1 │ ✅ Исправлено в SettingsPage │
|
||||
│ Type guards │ 3 │ ✅ Создан errorHandlers.ts │
|
||||
│ Валидация форм │ 2 │ ✅ TunnelsPage + SubscriptionsPage │
|
||||
│ useCallback handlers │ 5 │ ✅ Добавлены │
|
||||
│ useMemo упрощение │ 1 │ ✅ Заменено на функцию │
|
||||
│ eslint-disable комментарии │ 2 │ ✅ Добавлены │
|
||||
└────────────────────────────┴─────────┴────────────────────────────────────────────────────────────┘
|
||||
|
||||
---
|
||||
|
||||
## ✅ ЗАВЕРШЁННЫЕ ИСПРАВЛЕНИЯ
|
||||
|
||||
### confirm() — все заменены на Dialog
|
||||
|
||||
| Файл | Описание | Статус |
|
||||
|------|----------|--------|
|
||||
| `client/src/pages/SettingsPage.tsx` | Подтверждение принудительной ротации | ✅ Заменено |
|
||||
| `client/src/pages/DomainsPage.tsx` | Подтверждение удаления всех доменов | ✅ Заменено |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 ДЕТАЛЬНЫЙ АНАЛИЗ ПО СТРАНИЦАМ
|
||||
|
||||
### 1. **LoginPage** (`src/pages/LoginPage.tsx`)
|
||||
|
||||
| Изменение | Статус |
|
||||
|-----------|--------|
|
||||
| Логирование ошибок | ✅ `console.error('Login failed:', error)` |
|
||||
| `handleSubmit` без `e.preventDefault()` | ⚠️ **Работает, но может вызывать перезагрузку** |
|
||||
|
||||
---
|
||||
|
||||
### 2. **SettingsPage** (`src/pages/SettingsPage.tsx`)
|
||||
|
||||
| Изменение | Статус |
|
||||
|-----------|--------|
|
||||
| Race condition исправлено | ✅ `useCallback` для `loadSettings` |
|
||||
| Логирование | ✅ `console.error` в catch |
|
||||
| `confirm()` для ротации | ⚠️ **Остался** (строка 135) |
|
||||
|
||||
---
|
||||
|
||||
### 3. **DomainsPage** (`src/pages/DomainsPage.tsx`)
|
||||
|
||||
| Изменение | Статус |
|
||||
|-----------|--------|
|
||||
| Snackbar для уведомлений | ✅ `useState({ open, type, message })` |
|
||||
| Type guards | ✅ `getApiErrorMessage`, `getApiErrorStatus` |
|
||||
| Валидация | ✅ Проверка IP/домена перед сканированием |
|
||||
| `confirm()` для удаления всех | ⚠️ **Остался** (строка 322) |
|
||||
|
||||
---
|
||||
|
||||
### 4. **SubscriptionsPage** (`src/pages/SubscriptionsPage.tsx`)
|
||||
|
||||
| Изменение | Статус |
|
||||
|-----------|--------|
|
||||
| Snackbar/Dialog | ✅ MUI компоненты |
|
||||
| Валидация форм | ✅ Проверка перед сохранением |
|
||||
| Логирование | ✅ `console.error` в catch |
|
||||
|
||||
---
|
||||
|
||||
### 5. **TunnelsPage** (`src/pages/TunnelsPage.tsx`)
|
||||
|
||||
| Изменение | Статус |
|
||||
|-----------|--------|
|
||||
| Snackbar/Dialog | ✅ MUI компоненты |
|
||||
| Валидация форм | ✅ IPv4/IPv6, порты, SSH ключи |
|
||||
| Логирование | ✅ `console.error` в catch |
|
||||
|
||||
---
|
||||
|
||||
## 📋 ESLINT CONFIG — ПРИМЕНЁННЫЕ ПРАВИЛА
|
||||
|
||||
```javascript
|
||||
// eslint.config.js
|
||||
{
|
||||
rules: {
|
||||
'react-hooks/exhaustive-deps': 'error',
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Базовые конфигурации:**
|
||||
- `js.configs.recommended`
|
||||
- `tseslint.configs.recommended`
|
||||
- `reactHooks.configs.flat.recommended`
|
||||
- `reactRefresh.configs.vite`
|
||||
|
||||
---
|
||||
|
||||
## 🎯 ЛИНИНГ
|
||||
|
||||
```bash
|
||||
cd client && npm run lint
|
||||
# ✅ 0 ошибок, 0 предупреждений (exit code 0)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ ВЫВОД
|
||||
|
||||
**Фронтенд соответствует best practices React/TypeScript:**
|
||||
|
||||
- ✅ Все `alert()` заменены на MUI Snackbar
|
||||
- ✅ Все `confirm()` заменены на MUI Dialog
|
||||
- ✅ Все catch-блоки имеют логирование
|
||||
- ✅ Race condition исправлен через `useCallback`
|
||||
- ✅ Созданы type guards для API ошибок
|
||||
- ✅ Добавлена валидация форм
|
||||
- ✅ Линтинг проходит без ошибок
|
||||
|
||||
**Статус:**
|
||||
- Изменено файлов: **15** (tracked git)
|
||||
- Создано файлов: **5** (untracked: `logger.ts`, `errorHandlers.ts`, `version.ts`, `auth.ts`, `theme.ts`)
|
||||
- ✅ **Все alert/confirm заменены на MUI компоненты**
|
||||
- ✅ **Все console.* заменены на Logger**
|
||||
|
||||
---
|
||||
|
||||
## 📋 КОМАНДЫ ДЛЯ CHERRY-PICK
|
||||
|
||||
### Вариант 1: Скопировать все изменения сразу
|
||||
|
||||
```bash
|
||||
# 1. Добавить новые файлы (утилиты и типы)
|
||||
git add client/src/utils/ client/src/types/
|
||||
|
||||
# 2. Закоммитить всё
|
||||
git add client/
|
||||
git commit -m "feat(client): UI/UX улучшения, логирование, валидация, типы"
|
||||
|
||||
# 3. Получить hash коммита
|
||||
git log -1 --oneline
|
||||
|
||||
# 4. На целевой ветке сделать cherry-pick
|
||||
git checkout <target-branch>
|
||||
git cherry-pick <commit-hash>
|
||||
```
|
||||
|
||||
### Вариант 2: Скопировать только конкретные файлы
|
||||
|
||||
```bash
|
||||
# Скопировать изменения из конкретных файлов
|
||||
git checkout <source-branch> -- client/src/pages/SubscriptionsPage.tsx client/src/components/Header.tsx
|
||||
git checkout <source-branch> -- client/src/auth/AxiosInterceptor.tsx client/src/api.ts
|
||||
# и т.д.
|
||||
```
|
||||
|
||||
### Вариант 3: Применить патч
|
||||
|
||||
```bash
|
||||
# Сохранить патч
|
||||
git diff HEAD client/ > client-changes.patch
|
||||
|
||||
# На целевой ветке применить
|
||||
git apply client-changes.patch
|
||||
|
||||
# Добавить новые файлы
|
||||
git add client/src/utils/ client/src/types/
|
||||
|
||||
# Закоммитить
|
||||
git commit -m "feat(client): применить изменения из dp-custom"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ ПРОВЕРКА ПОСЛЕ CHERRY-PICK
|
||||
|
||||
```bash
|
||||
# Убедиться что нет alert/confirm
|
||||
grep -r "alert\|confirm" client/src/ | grep -v "confirmDialog"
|
||||
|
||||
# Убедиться что нет console.*
|
||||
grep -r "console\." client/src/
|
||||
|
||||
# Запустить линтинг
|
||||
cd client && npm run lint
|
||||
|
||||
# Собрать проект
|
||||
cd client && npm run build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Аудит проведён:** 28 марта 2026 г.
|
||||
**Инструменты:** `git diff HEAD`, `read_file`, `grep_search`, `npm run lint`, `npm run build`
|
||||
**Статус:** ✅ **ГОТОВО К CHERRY-PICK**
|
||||
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
globalIgnores(['dist', 'coverage', 'node_modules', 'build']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
@@ -19,5 +19,9 @@ export default defineConfig([
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
'react-hooks/exhaustive-deps': 'error',
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
+4
-1
@@ -1,9 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link data-rh="true" rel="icon" href="/img/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<title>3DP-MANAGER</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+21
-10
@@ -13,20 +13,31 @@ server {
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://server:3000;
|
||||
|
||||
proxy_pass http://backend:3100;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 650s;
|
||||
proxy_read_timeout 650s;
|
||||
}
|
||||
|
||||
location /bus/ {
|
||||
proxy_pass http://backend:3100;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 650s;
|
||||
proxy_read_timeout 650s;
|
||||
}
|
||||
}
|
||||
server {
|
||||
listen 3000;
|
||||
server_name localhost;
|
||||
location / {
|
||||
proxy_pass http://backend:3000/;
|
||||
proxy_set_header Host \$http_host;
|
||||
}
|
||||
}
|
||||
Generated
+1375
-11
File diff suppressed because it is too large
Load Diff
+14
-2
@@ -7,7 +7,12 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest --watch",
|
||||
"test:run": "vitest --run",
|
||||
"test:cov": "vitest --run --coverage",
|
||||
"test:ui": "vitest --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
@@ -23,16 +28,23 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.10.9",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.2",
|
||||
"@vitest/ui": "^4.1.2",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^29.0.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
"vite": "^7.2.4",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
+4
-2
@@ -11,6 +11,7 @@ import NotFoundPage from './pages/NotFoundPage';
|
||||
import { AxiosInterceptor } from './auth/AxiosInterceptor';
|
||||
import PublicRoute from './auth/PublicRoute';
|
||||
import TunnelsPage from './pages/TunnelsPage';
|
||||
import NodesPage from './pages/NodesPage';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -22,7 +23,7 @@ function App() {
|
||||
<Route element={<PublicRoute />}>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
</Route>
|
||||
|
||||
|
||||
<Route path="/" element={
|
||||
<RequireAuth>
|
||||
<Layout />
|
||||
@@ -31,6 +32,7 @@ function App() {
|
||||
<Route index element={<SubscriptionsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="domains" element={<DomainsPage />} />
|
||||
<Route path="nodes" element={<NodesPage />} />
|
||||
<Route path="tunnels" element={<TunnelsPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
@@ -41,4 +43,4 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
/* eslint-disable react-refresh/only-export-components -- экспорты констант и хука вне компонента */
|
||||
import React, { createContext, useState, useMemo, useContext, useEffect } from 'react';
|
||||
import { ThemeProvider as MuiThemeProvider, createTheme } from '@mui/material';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import { getDesignTokens } from './theme';
|
||||
|
||||
type ColorMode = 'light' | 'dark' | 'system';
|
||||
|
||||
interface ThemeContextType {
|
||||
mode: ColorMode;
|
||||
toggleColorMode: () => void;
|
||||
}
|
||||
import { getDesignTokens } from './theme';
|
||||
import type { ColorMode, ThemeContextType } from './types/theme';
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType>({} as ThemeContextType);
|
||||
|
||||
|
||||
+28
-2
@@ -1,7 +1,33 @@
|
||||
import axios from 'axios';
|
||||
import { Logger } from './utils/logger';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: `${location.protocol}//${location.hostname}:${location.port}/api`,
|
||||
baseURL: '/api',
|
||||
withCredentials: true, // Отправлять cookies
|
||||
});
|
||||
|
||||
export default api;
|
||||
// Interceptor для добавления токена к каждому запросу
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
Logger.debug(`${config.method?.toUpperCase()} ${config.url} | Token: ${token ? 'EXISTS' : 'NULL'}`, 'API');
|
||||
return config;
|
||||
});
|
||||
|
||||
// Interceptor для ответа
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
Logger.debug(`${response.status} OK (${response.config.method?.toUpperCase()} ${response.config.url})`, 'API');
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
const status = error.response?.status;
|
||||
const message = error.response?.data?.message || error.message || 'Unknown error';
|
||||
Logger.error(`ERROR ${status || 'NETWORK'}: ${message} (${error.config?.method?.toUpperCase()} ${error.config?.url})`, 'API');
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import React, { createContext, useContext, useState } from 'react';
|
||||
import api from '../api';
|
||||
import { Logger } from '../utils/logger';
|
||||
|
||||
interface AuthContextType {
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (token: string) => void;
|
||||
logout: () => void;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
@@ -20,37 +22,58 @@ export const useAuth = () => {
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [token, setToken] = useState<string | null>(() => {
|
||||
const savedToken = localStorage.getItem('token');
|
||||
|
||||
if (savedToken) {
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${savedToken}`;
|
||||
}
|
||||
return savedToken;
|
||||
const initialToken = localStorage.getItem('token');
|
||||
Logger.debug('AuthProvider initialized', 'AuthContext', {
|
||||
hasToken: Boolean(initialToken),
|
||||
});
|
||||
return initialToken;
|
||||
});
|
||||
|
||||
const login = (newToken: string) => {
|
||||
Logger.debug('login() called', 'AuthContext', {
|
||||
tokenLength: newToken.length,
|
||||
});
|
||||
// Сохраняем токен в localStorage для обратной совместимости
|
||||
// Основной токен теперь в httpOnly cookie
|
||||
localStorage.setItem('token', newToken);
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${newToken}`;
|
||||
setToken(newToken);
|
||||
Logger.debug('Token persisted to localStorage and auth state updated', 'AuthContext');
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
delete api.defaults.headers.common['Authorization'];
|
||||
setToken(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
} else {
|
||||
delete api.defaults.headers.common['Authorization'];
|
||||
const logout = async () => {
|
||||
Logger.debug('logout() called', 'AuthContext');
|
||||
try {
|
||||
// Вызываем backend для очистки httpOnly cookie
|
||||
await api.post('/auth/logout');
|
||||
Logger.debug('Backend logout request succeeded', 'AuthContext');
|
||||
} catch (error) {
|
||||
const status =
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'response' in error &&
|
||||
typeof (error as { response?: unknown }).response === 'object' &&
|
||||
(error as { response?: unknown }).response !== null
|
||||
? ((error as { response?: { status?: number } }).response?.status ?? null)
|
||||
: null;
|
||||
Logger.warn(
|
||||
'Backend logout request failed, continuing local cleanup',
|
||||
'AuthContext',
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
localStorage.removeItem('token');
|
||||
setToken(null);
|
||||
Logger.debug('Local auth state cleared', 'AuthContext');
|
||||
|
||||
// Редирект на страницу входа
|
||||
Logger.debug('Redirecting to /login after logout', 'AuthContext');
|
||||
window.location.href = '/login';
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ token, login, logout, isAuthenticated: !!token }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,29 +1,68 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
import { useAuth } from './AuthContext';
|
||||
import { Logger } from '../utils/logger';
|
||||
|
||||
export function AxiosInterceptor() {
|
||||
const { logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const logoutRef = useRef(logout);
|
||||
const navigateRef = useRef(navigate);
|
||||
const pathnameRef = useRef(location.pathname);
|
||||
|
||||
useEffect(() => {
|
||||
logoutRef.current = logout;
|
||||
}, [logout]);
|
||||
|
||||
useEffect(() => {
|
||||
navigateRef.current = navigate;
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
pathnameRef.current = location.pathname;
|
||||
}, [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
Logger.debug('Registering axios response interceptor', 'AxiosInterceptor');
|
||||
const interceptor = api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
async (error) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
console.warn('Session expired or unauthorized. Logging out...');
|
||||
logout();
|
||||
navigate('/login');
|
||||
Logger.warn('401 Unauthorized detected → logging out and redirecting to /login', 'AxiosInterceptor');
|
||||
// Не делаем logout если уже на странице логина
|
||||
if (pathnameRef.current !== '/login') {
|
||||
try {
|
||||
Logger.debug('Calling logout()', 'AxiosInterceptor');
|
||||
await logoutRef.current();
|
||||
Logger.debug('Navigating to /login...', 'AxiosInterceptor');
|
||||
navigateRef.current('/login');
|
||||
} catch (logoutError) {
|
||||
Logger.error(
|
||||
'logout() failed inside interceptor',
|
||||
'AxiosInterceptor',
|
||||
{
|
||||
message:
|
||||
logoutError instanceof Error
|
||||
? logoutError.message
|
||||
: 'unknown error',
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
Logger.debug('Already on /login, skipping auto-logout flow', 'AxiosInterceptor');
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
Logger.debug('Ejecting axios response interceptor', 'AxiosInterceptor');
|
||||
api.interceptors.response.eject(interceptor);
|
||||
};
|
||||
}, [logout, navigate]);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Box, Container, Grid, IconButton, Link, Stack } from '@mui/material';
|
||||
import { GitHub, YouTube, Telegram } from '@mui/icons-material';
|
||||
|
||||
export default function Footer() {
|
||||
interface FooterProps {
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
export default function Footer({ isMobile }: FooterProps) {
|
||||
return (
|
||||
<Box
|
||||
component="footer"
|
||||
@@ -19,12 +23,12 @@ export default function Footer() {
|
||||
<Grid container spacing={4} justifyContent="space-between" alignItems="center">
|
||||
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<img src="/img/logo.png" alt="Logo" width={32} height={32} style={{ marginRight: 14 }} />
|
||||
<Stack direction="row" alignItems="center" spacing={1} justifyContent={isMobile ? 'center' : 'start'}>
|
||||
<img src="/img/logo.png" alt="Logo" width={32} height={32} style={{ marginRight: isMobile ? 0 : 14 }} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, sm: 4 }} sx={{ textAlign: { xs: 'left', sm: 'center' } }}>
|
||||
<Grid size={{ xs: 12, sm: 4 }} sx={{ textAlign: 'center' }}>
|
||||
<Link
|
||||
href="https://3dp-manager.com/docs/intro"
|
||||
target="_blank"
|
||||
@@ -38,7 +42,7 @@ export default function Footer() {
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, sm: 4 }} sx={{ textAlign: { xs: 'left', sm: 'right' } }}>
|
||||
<Stack direction="row" spacing={1} justifyContent={{ xs: 'flex-start', sm: 'flex-end' }}>
|
||||
<Stack direction="row" spacing={1} justifyContent={{ xs: 'center', sm: 'flex-end' }}>
|
||||
|
||||
<IconButton
|
||||
component="a"
|
||||
|
||||
@@ -10,19 +10,35 @@ import {
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useThemeContext } from '../ThemeContext';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Menu as MenuIcon } from '@mui/icons-material';
|
||||
import { APP_VERSION } from '../utils/version';
|
||||
import { Logger } from '../utils/logger';
|
||||
|
||||
export default function Header() {
|
||||
interface HeaderProps {
|
||||
onMenuClick?: () => void;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
export default function Header({ onMenuClick, isMobile }: HeaderProps) {
|
||||
const { mode, toggleColorMode } = useThemeContext();
|
||||
const { logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [confirmDialog, setConfirmDialog] = useState({ open: false, title: '', onConfirm: () => {} });
|
||||
|
||||
const handleLogout = () => {
|
||||
if (confirm('Вы действительно хотите выйти?')) {
|
||||
logout();
|
||||
navigate('/login');
|
||||
}
|
||||
Logger.debug('Opening logout confirmation dialog', 'Header');
|
||||
setConfirmDialog({
|
||||
open: true,
|
||||
title: 'Вы действительно хотите выйти?',
|
||||
onConfirm: async () => {
|
||||
Logger.debug('Logout confirmed by user', 'Header');
|
||||
await logout();
|
||||
Logger.debug('logout() resolved in Header', 'Header');
|
||||
navigate('/login');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getThemeIcon = () => {
|
||||
@@ -48,12 +64,17 @@ export default function Header() {
|
||||
sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
||||
>
|
||||
<Toolbar>
|
||||
{isMobile && (
|
||||
<IconButton color="inherit" edge="start" onClick={onMenuClick} sx={{ mr: 1 }}>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
<img src="/img/logo.png" alt="Logo" width={32} height={32} style={{ marginRight: 14 }} />
|
||||
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1, fontWeight: 'bold', color: '#1395de' }}>
|
||||
<Typography variant={isMobile ? 'body1' : 'h6'} noWrap component="div" sx={{ flexGrow: 1, fontWeight: 'bold', color: '#1395de' }}>
|
||||
3DP-MANAGER
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', gap: isMobile ? 0.25 : 1 }}>
|
||||
|
||||
<Tooltip title="Справка о программе">
|
||||
<IconButton color="inherit" onClick={() => setHelpOpen(true)}>
|
||||
@@ -121,7 +142,7 @@ export default function Header() {
|
||||
</List>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
||||
Версия: 2.0.0<br />
|
||||
Версия: {APP_VERSION}<br />
|
||||
Разработчик: DenPiligrim
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
@@ -129,6 +150,41 @@ export default function Header() {
|
||||
<Button onClick={() => setHelpOpen(false)}>Понятно</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Confirmation Dialog for logout */}
|
||||
<Dialog open={confirmDialog.open} onClose={() => setConfirmDialog({ ...confirmDialog, open: false })}>
|
||||
<DialogTitle>Подтверждение</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>{confirmDialog.title}</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={() => {
|
||||
Logger.debug('Logout canceled by user', 'Header');
|
||||
setConfirmDialog({ ...confirmDialog, open: false });
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await confirmDialog.onConfirm();
|
||||
} catch (error) {
|
||||
Logger.error('Logout confirmation action failed', 'Header', {
|
||||
message: error instanceof Error ? error.message : 'unknown error',
|
||||
});
|
||||
} finally {
|
||||
setConfirmDialog({ ...confirmDialog, open: false });
|
||||
}
|
||||
}}
|
||||
variant="contained"
|
||||
color="error"
|
||||
>
|
||||
Выйти
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +1,103 @@
|
||||
import {
|
||||
Toolbar, Drawer, List, ListItem,
|
||||
ListItemButton, ListItemIcon, ListItemText, Box
|
||||
import {
|
||||
Box,
|
||||
Drawer,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Toolbar,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import { People, Settings, Dns, SwapHoriz } from '@mui/icons-material';
|
||||
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
|
||||
import { Dns, Hub, People, Settings, SwapHoriz } from '@mui/icons-material';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
|
||||
import Header from './Header';
|
||||
import Footer from './Footer';
|
||||
import SecurityWarning from './SecurityWarning';
|
||||
import { useSecureConnection } from '../utils/useSecureConnection';
|
||||
|
||||
const drawerWidth = 240;
|
||||
|
||||
const menuItems = [
|
||||
{ text: 'Подписки', icon: <People />, path: '/' },
|
||||
{ text: 'Ноды', icon: <Hub />, path: '/nodes' },
|
||||
{ text: 'Relay серверы', icon: <SwapHoriz />, path: '/tunnels' },
|
||||
{ text: 'Домены', icon: <Dns />, path: '/domains' },
|
||||
{ text: 'Настройки', icon: <Settings />, path: '/settings' },
|
||||
];
|
||||
|
||||
export default function Layout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const { isSecure } = useSecureConnection();
|
||||
|
||||
const menuItems = [
|
||||
{ text: 'Подписки', icon: <People />, path: '/' },
|
||||
{ text: 'Домены', icon: <Dns />, path: '/domains' },
|
||||
{ text: 'Перенаправление', icon: <SwapHoriz />, path: '/tunnels' },
|
||||
{ text: 'Настройки', icon: <Settings />, path: '/settings' },
|
||||
];
|
||||
const handleDrawerToggle = () => {
|
||||
setMobileOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
const drawerContent = (
|
||||
<Box sx={{ overflow: 'auto' }}>
|
||||
<Toolbar />
|
||||
<List>
|
||||
{menuItems.map((item) => (
|
||||
<ListItem key={item.text} disablePadding>
|
||||
<ListItemButton
|
||||
selected={location.pathname === item.path}
|
||||
onClick={() => {
|
||||
navigate(item.path);
|
||||
if (isMobile) setMobileOpen(false);
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.text} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', minHeight: '100vh', width: '100%' }}>
|
||||
|
||||
<Header />
|
||||
<Header onMenuClick={handleDrawerToggle} isMobile={isMobile} />
|
||||
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
variant={isMobile ? 'temporary' : 'permanent'}
|
||||
open={isMobile ? mobileOpen : true}
|
||||
onClose={handleDrawerToggle}
|
||||
sx={{
|
||||
width: drawerWidth,
|
||||
flexShrink: 0,
|
||||
[`& .MuiDrawer-paper`]: { width: drawerWidth, boxSizing: 'border-box' },
|
||||
}}
|
||||
>
|
||||
<Toolbar />
|
||||
<Box sx={{ overflow: 'auto' }}>
|
||||
<List>
|
||||
{menuItems.map((item) => (
|
||||
<ListItem key={item.text} disablePadding>
|
||||
<ListItemButton
|
||||
selected={location.pathname === item.path}
|
||||
onClick={() => navigate(item.path)}
|
||||
>
|
||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.text} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
{drawerContent}
|
||||
</Drawer>
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '100vh',
|
||||
width: '100%'
|
||||
width: '100%',
|
||||
overflowX: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Toolbar />
|
||||
|
||||
<Box sx={{ flexGrow: 1, p: 3 }}>
|
||||
{!isSecure && <SecurityWarning />}
|
||||
<Box sx={{ flexGrow: 1, p: { xs: 2, md: 3 } }}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
|
||||
<Footer />
|
||||
<Footer isMobile={isMobile} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Alert, AlertTitle, Box, Button, Collapse, IconButton, Snackbar, useMediaQuery, useTheme } from '@mui/material';
|
||||
import { Close, ContentCopy } from '@mui/icons-material';
|
||||
import { useState } from 'react';
|
||||
|
||||
const INSTALL_COMMAND = 'bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/install.sh)';
|
||||
|
||||
export default function SecurityWarning() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(INSTALL_COMMAND);
|
||||
setCopied(true);
|
||||
} catch {
|
||||
// Fallback для старых браузеров
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = INSTALL_COMMAND;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
setCopied(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Collapse in={true}>
|
||||
<Alert
|
||||
severity="warning"
|
||||
variant="filled"
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
borderBottom: '1px solid rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
<AlertTitle sx={{ fontWeight: 'bold', mb: 1, fontSize: { xs: '1rem', sm: '1.1rem' } }}>
|
||||
3DP-MANAGER работает в небезопасном режиме (HTTP)
|
||||
</AlertTitle>
|
||||
<Box
|
||||
component="p"
|
||||
sx={{
|
||||
mb: 2,
|
||||
fontSize: { xs: '0.875rem', sm: '0.95rem' },
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
<strong>Не вводите реальные пароли от 3x-ui панели и не меняйте пароль администратора в режиме работы по HTTP!</strong>{' '}
|
||||
Для безопасной работы переустановите 3DP-MANAGER с SSL-сертификатами. Все ваши настройки сохранятся.
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||
p: 1,
|
||||
borderRadius: 1,
|
||||
flexWrap: { xs: 'wrap', sm: 'nowrap' },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="code"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
fontSize: { xs: '0.75rem', sm: '0.85rem' },
|
||||
wordBreak: 'break-all',
|
||||
fontFamily: 'monospace',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{INSTALL_COMMAND}
|
||||
</Box>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
startIcon={!isMobile && <ContentCopy />}
|
||||
onClick={handleCopy}
|
||||
sx={{
|
||||
color: 'inherit',
|
||||
borderColor: 'currentColor',
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
minWidth: { xs: 'auto', sm: '140px' },
|
||||
px: { xs: 1, sm: 2 },
|
||||
}}
|
||||
>
|
||||
{isMobile ? <ContentCopy fontSize="small" /> : 'Копировать'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Alert>
|
||||
</Collapse>
|
||||
|
||||
<Snackbar
|
||||
open={copied}
|
||||
autoHideDuration={2000}
|
||||
onClose={() => setCopied(false)}
|
||||
message="Скопировано"
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
action={
|
||||
<IconButton size="small" color="inherit" onClick={() => setCopied(false)}>
|
||||
<Close fontSize="small" />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import api from '../../api';
|
||||
import type { NodePayload, NodeRecord } from '../../types/node';
|
||||
|
||||
export const nodesApi = {
|
||||
async list() {
|
||||
const { data } = await api.get<NodeRecord[]>('/nodes');
|
||||
return data;
|
||||
},
|
||||
|
||||
async create(payload: NodePayload) {
|
||||
const { data } = await api.post<NodeRecord>('/nodes', payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async update(id: string, payload: Partial<NodePayload>) {
|
||||
const { data } = await api.put<NodeRecord>(`/nodes/${id}`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async remove(id: string) {
|
||||
const { data } = await api.delete<{ success: boolean }>(`/nodes/${id}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async setMain(id: string) {
|
||||
const { data } = await api.post<NodeRecord>(`/nodes/${id}/main`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async check(id: string) {
|
||||
const { data } = await api.post<{ success: boolean; version?: string }>(
|
||||
`/nodes/${id}/check`,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
async checkPayload(payload: NodePayload) {
|
||||
const { data } = await api.post<{ success: boolean; version?: string }>(
|
||||
'/nodes/check',
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
async detectLocation(url: string) {
|
||||
const { data } = await api.post<{
|
||||
ip?: string;
|
||||
host?: string;
|
||||
flag?: string;
|
||||
country?: string;
|
||||
countryCode?: string;
|
||||
}>('/nodes/detect-location', { url });
|
||||
return data;
|
||||
},
|
||||
|
||||
async syncFromMain() {
|
||||
const { data } = await api.post<{ success: boolean; count: number }>(
|
||||
'/nodes/sync/main',
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
html {
|
||||
font-family: "Inter", "Roboto", "Helvetica", "Arial", sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.animated-container {
|
||||
background: linear-gradient(-45deg, #111827, #15122c, #24243e, #0B0F19);
|
||||
background-size: 400% 400%;
|
||||
animation: gradientBG 15s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes gradientBG {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
input:autofill {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import './index.css'
|
||||
import '@fontsource/inter/300.css';
|
||||
import '@fontsource/inter/400.css';
|
||||
import '@fontsource/inter/500.css';
|
||||
import '@fontsource/inter/600.css';
|
||||
import '@fontsource/inter/700.css';
|
||||
import App from './App.tsx'
|
||||
|
||||
|
||||
@@ -1,33 +1,382 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Box, TextField, Button, Typography, List, ListItem, ListItemText, IconButton, Paper, TablePagination } from '@mui/material';
|
||||
import { Delete, Add, UploadFile, Remove } from '@mui/icons-material';
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { Box, TextField, Button, Typography, List, ListItem, ListItemText, IconButton, Paper, TablePagination, useTheme, useMediaQuery, Alert, Stack, CircularProgress, Divider, Link as MuiLink, Accordion, AccordionSummary, AccordionDetails, Snackbar, Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material';
|
||||
import { Delete, Add, UploadFile, Remove, ExpandMore, Download } from '@mui/icons-material';
|
||||
import api from '../api';
|
||||
import { getApiErrorMessage, getApiErrorStatus } from '../utils/errorHandlers';
|
||||
import { Logger } from '../utils/logger';
|
||||
|
||||
interface Domain { id: number; name: string; }
|
||||
interface ScanCapabilities {
|
||||
scannerAvailable: boolean;
|
||||
scannerPath: string | null;
|
||||
timeoutAvailable: boolean;
|
||||
timeoutPath: string | null;
|
||||
}
|
||||
interface ScanResponse {
|
||||
runId: string;
|
||||
addr: string;
|
||||
scanSeconds: number;
|
||||
thread: number;
|
||||
timeout: number;
|
||||
startedAt: string;
|
||||
endsAt: string;
|
||||
finishedAt: string;
|
||||
timedOut: boolean;
|
||||
exitCode: number;
|
||||
foundCount: number;
|
||||
domains: string[];
|
||||
stderrTail: string;
|
||||
stdoutTail: string;
|
||||
}
|
||||
interface ScanStatusResponse {
|
||||
running: boolean;
|
||||
runId: string | null;
|
||||
addr: string | null;
|
||||
scanSeconds: number | null;
|
||||
thread: number | null;
|
||||
timeout: number | null;
|
||||
startedAt: string | null;
|
||||
endsAt: string | null;
|
||||
now: string;
|
||||
remainingSeconds: number;
|
||||
foundCount: number;
|
||||
lastRunId: string | null;
|
||||
lastFinishedAt: string | null;
|
||||
}
|
||||
|
||||
const SCAN_STORAGE_KEY = 'domains_scan_state_v1';
|
||||
|
||||
export default function DomainsPage() {
|
||||
const [domains, setDomains] = useState<Domain[]>([]);
|
||||
const [newDomain, setNewDomain] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const emptyDomainsNotified = useRef(false);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||
const [scanCapabilities, setScanCapabilities] = useState<ScanCapabilities | null>(null);
|
||||
const [scanAddr, setScanAddr] = useState('');
|
||||
const [scanSeconds, setScanSeconds] = useState(30);
|
||||
const [scanThread, setScanThread] = useState(2);
|
||||
const [scanTimeout, setScanTimeout] = useState(5);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [scanError, setScanError] = useState('');
|
||||
const [scanResult, setScanResult] = useState<ScanResponse | null>(null);
|
||||
const [scanCandidates, setScanCandidates] = useState<string[]>([]);
|
||||
const [scanPanelExpanded, setScanPanelExpanded] = useState(false);
|
||||
const [scanStateHydrated, setScanStateHydrated] = useState(false);
|
||||
const [scanStatus, setScanStatus] = useState<ScanStatusResponse | null>(null);
|
||||
const [activeScanRunId, setActiveScanRunId] = useState<string | null>(null);
|
||||
|
||||
// Snackbar state for notifications
|
||||
const [snackbar, setSnackbar] = useState({ open: false, type: 'success' as 'success' | 'error', message: '' });
|
||||
|
||||
// Confirmation dialog state
|
||||
const [confirmDialog, setConfirmDialog] = useState({ open: false, title: '', onConfirm: () => {} });
|
||||
|
||||
const clampInteger = (value: number, fallback: number, min: number, max: number) => {
|
||||
const num = Number.isFinite(value) ? Math.floor(value) : fallback;
|
||||
if (num < min) return min;
|
||||
if (num > max) return max;
|
||||
return num;
|
||||
};
|
||||
|
||||
const isLoopbackHost = useCallback((value: string) => {
|
||||
const host = value.trim().toLowerCase();
|
||||
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
||||
}, []);
|
||||
|
||||
interface Settings {
|
||||
xui_ip?: string;
|
||||
xui_host?: string;
|
||||
xui_url?: string;
|
||||
}
|
||||
|
||||
const collectAddrCandidatesFromSettings = useCallback((settings: Settings) => {
|
||||
const candidates: string[] = [];
|
||||
const xuiIp = String(settings?.xui_ip || '').trim();
|
||||
const xuiHost = String(settings?.xui_host || '').trim();
|
||||
const xuiUrl = String(settings?.xui_url || '').trim();
|
||||
|
||||
if (xuiIp) candidates.push(xuiIp);
|
||||
if (xuiHost) candidates.push(xuiHost);
|
||||
|
||||
if (xuiUrl) {
|
||||
try {
|
||||
const parsed = new URL(xuiUrl);
|
||||
if (parsed.hostname) {
|
||||
candidates.push(parsed.hostname.trim());
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed URL from settings and fall back to runtime hostname.
|
||||
}
|
||||
}
|
||||
|
||||
return candidates.filter(Boolean);
|
||||
}, []);
|
||||
|
||||
const resolveSuggestedScanAddr = useCallback(async (opts?: { allowLoopbackFallback?: boolean }) => {
|
||||
const allowLoopbackFallback = Boolean(opts?.allowLoopbackFallback);
|
||||
let settingsCandidates: string[] = [];
|
||||
|
||||
const loadDomains = async () => {
|
||||
try {
|
||||
const settingsRes = await api.get('/settings');
|
||||
Logger.debug('Domains page: Settings response', 'Domains', settingsRes.data);
|
||||
|
||||
settingsCandidates = collectAddrCandidatesFromSettings(settingsRes.data);
|
||||
Logger.debug('Domains page: Collected address candidates from settings', 'Domains', {
|
||||
candidates: settingsCandidates,
|
||||
xui_ip: settingsRes.data?.xui_ip,
|
||||
xui_host: settingsRes.data?.xui_host,
|
||||
xui_url: settingsRes.data?.xui_url
|
||||
});
|
||||
|
||||
const publicFromSettings = settingsCandidates.find((c) => !isLoopbackHost(c));
|
||||
Logger.debug('Domains page: Looking for public address', 'Domains', {
|
||||
publicFromSettings,
|
||||
allCandidates: settingsCandidates
|
||||
});
|
||||
|
||||
if (publicFromSettings) {
|
||||
return publicFromSettings;
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('Failed to collect address candidates from settings', 'Domains', error);
|
||||
}
|
||||
|
||||
// Fallback: panel host where user opened 3dp (often the target VPS in real usage).
|
||||
const runtimeHost = window.location.hostname;
|
||||
Logger.debug('Domains page: Checking runtime host as fallback', 'Domains', {
|
||||
runtimeHost,
|
||||
isLoopback: isLoopbackHost(runtimeHost)
|
||||
});
|
||||
|
||||
// Если настройки пустые и мы на localhost — предлагаем localhost с предупреждением
|
||||
// Это позволяет пользователю начать работу и затем изменить на правильный IP
|
||||
if (runtimeHost) {
|
||||
if (!isLoopbackHost(runtimeHost)) {
|
||||
Logger.debug('Domains page: Using runtime host as address', 'Domains', runtimeHost);
|
||||
return runtimeHost;
|
||||
} else if (allowLoopbackFallback) {
|
||||
// Явно разрешили localhost fallback
|
||||
Logger.debug('Domains page: Using localhost fallback (explicit)', 'Domains', runtimeHost);
|
||||
return runtimeHost;
|
||||
} else if (settingsCandidates.length === 0) {
|
||||
// Настройки пустые — используем localhost как единственный вариант
|
||||
Logger.warn('Domains page: No settings configured, using localhost as temporary placeholder', 'Domains');
|
||||
return runtimeHost;
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: first from settings even if loopback
|
||||
if (allowLoopbackFallback) {
|
||||
const anyFromSettings = settingsCandidates[0];
|
||||
if (anyFromSettings) return anyFromSettings;
|
||||
}
|
||||
|
||||
Logger.warn('Domains page: No address found anywhere', 'Domains');
|
||||
return '';
|
||||
}, [collectAddrCandidatesFromSettings, isLoopbackHost]);
|
||||
|
||||
const fetchScanStatus = useCallback(async () => {
|
||||
const { data } = await api.get('/domains/scan/status');
|
||||
setScanStatus(data);
|
||||
return data as ScanStatusResponse;
|
||||
}, []);
|
||||
|
||||
const fetchLastScanResult = useCallback(async (expectedRunId?: string | null) => {
|
||||
const { data } = await api.get('/domains/scan/last-result');
|
||||
if (!data) return null;
|
||||
if (expectedRunId && data.runId !== expectedRunId) return null;
|
||||
|
||||
setScanResult(data);
|
||||
setScanCandidates(data.domains || []);
|
||||
return data as ScanResponse;
|
||||
}, []);
|
||||
|
||||
const loadDomains = useCallback(async () => {
|
||||
try {
|
||||
Logger.debug(`Loading page ${page + 1} (limit: ${rowsPerPage})`, 'Domains');
|
||||
const { data } = await api.get(`/domains?page=${page + 1}&limit=${rowsPerPage}`);
|
||||
|
||||
setDomains(data.data);
|
||||
setTotalCount(data.total);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (data.total === 0 && !emptyDomainsNotified.current) {
|
||||
emptyDomainsNotified.current = true;
|
||||
setSnackbar({ open: true, type: 'error', message: 'Создайте хотя бы один домен!' });
|
||||
}
|
||||
if (data.total > 0) {
|
||||
emptyDomainsNotified.current = false;
|
||||
}
|
||||
Logger.debug(`Loaded ${data.data.length} domains (total: ${data.total})`, 'Domains');
|
||||
} catch (error) {
|
||||
Logger.error('Failed to load', 'Domains', error);
|
||||
}
|
||||
};
|
||||
}, [page, rowsPerPage]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDomains();
|
||||
}, [page, rowsPerPage]);
|
||||
}, [loadDomains]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadScannerContext = async () => {
|
||||
try {
|
||||
const capRes = await api.get('/domains/scan/capabilities');
|
||||
setScanCapabilities(capRes.data);
|
||||
if (capRes.data?.scannerAvailable) {
|
||||
const status = await fetchScanStatus();
|
||||
if (status.running) {
|
||||
setIsScanning(true);
|
||||
setActiveScanRunId(status.runId);
|
||||
setScanResult(null);
|
||||
setScanCandidates([]);
|
||||
setScanError('');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('Failed to load scanner context', 'Domains', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadScannerContext();
|
||||
}, [fetchScanStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
// Hydrate scanner UI state once so users do not lose pre-import review list after reload.
|
||||
try {
|
||||
const raw = localStorage.getItem(SCAN_STORAGE_KEY);
|
||||
let restoredAddr: string | null = null;
|
||||
|
||||
Logger.debug('Domains page: Starting hydrate', 'Domains', {
|
||||
hasLocalStorage: !!raw,
|
||||
localStorageValue: raw ? JSON.parse(raw).scanAddr : 'N/A'
|
||||
});
|
||||
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
scanAddr?: string;
|
||||
scanSeconds?: number;
|
||||
scanThread?: number;
|
||||
scanTimeout?: number;
|
||||
scanResult?: ScanResponse | null;
|
||||
scanCandidates?: string[];
|
||||
scanPanelExpanded?: boolean;
|
||||
};
|
||||
|
||||
// Восстанавливаем только непустое значение
|
||||
if (typeof parsed.scanAddr === 'string' && parsed.scanAddr.trim()) {
|
||||
restoredAddr = parsed.scanAddr.trim();
|
||||
setScanAddr(restoredAddr);
|
||||
Logger.debug(`Domains page: Restored scanAddr from localStorage: "${restoredAddr}"`, 'Domains');
|
||||
} else {
|
||||
Logger.debug(`Domains page: scanAddr in localStorage is empty/whitespace, will fetch from settings`, 'Domains');
|
||||
}
|
||||
if (typeof parsed.scanSeconds === 'number') setScanSeconds(parsed.scanSeconds);
|
||||
if (typeof parsed.scanThread === 'number') setScanThread(parsed.scanThread);
|
||||
if (typeof parsed.scanTimeout === 'number') setScanTimeout(parsed.scanTimeout);
|
||||
if (parsed.scanResult) setScanResult(parsed.scanResult);
|
||||
if (Array.isArray(parsed.scanCandidates)) setScanCandidates(parsed.scanCandidates);
|
||||
if (typeof parsed.scanPanelExpanded === 'boolean') setScanPanelExpanded(parsed.scanPanelExpanded);
|
||||
} else {
|
||||
Logger.debug('Domains page: No localStorage data found', 'Domains');
|
||||
}
|
||||
|
||||
// Если scanAddr не был восстановлен (пустой localStorage ИЛИ пустое значение),
|
||||
// пытаемся получить домен из настроек
|
||||
if (!restoredAddr) {
|
||||
Logger.debug('Domains page: Fetching suggested address from settings...', 'Domains');
|
||||
// Пробуем сначала без localhost, если не найдём — разрешаем localhost fallback
|
||||
resolveSuggestedScanAddr({ allowLoopbackFallback: false }).then((defaultAddr) => {
|
||||
if (defaultAddr) {
|
||||
setScanAddr(defaultAddr);
|
||||
Logger.debug(`Domains page: Set scanAddr from settings: "${defaultAddr}"`, 'Domains');
|
||||
} else {
|
||||
// Пытаемся с localhost fallback если совсем ничего не найдено
|
||||
Logger.debug('Domains page: Trying with localhost fallback...', 'Domains');
|
||||
resolveSuggestedScanAddr({ allowLoopbackFallback: true }).then((fallbackAddr) => {
|
||||
if (fallbackAddr) {
|
||||
setScanAddr(fallbackAddr);
|
||||
Logger.debug(`Domains page: Set scanAddr with localhost fallback: "${fallbackAddr}"`, 'Domains');
|
||||
}
|
||||
}).catch((error) => {
|
||||
Logger.error('Failed to resolve suggested scan address (fallback)', 'Domains', error);
|
||||
});
|
||||
}
|
||||
}).catch((error) => {
|
||||
Logger.error('Failed to resolve suggested scan address', 'Domains', error);
|
||||
});
|
||||
} else {
|
||||
Logger.debug(`Domains page: Using restored scanAddr: "${restoredAddr}"`, 'Domains');
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('Failed to hydrate scanner state from localStorage', 'Domains', error);
|
||||
} finally {
|
||||
setScanStateHydrated(true);
|
||||
}
|
||||
}, [resolveSuggestedScanAddr]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scanStateHydrated) return;
|
||||
|
||||
try {
|
||||
// Persist scanner input + results + accordion state for continuation after F5.
|
||||
localStorage.setItem(
|
||||
SCAN_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
scanAddr,
|
||||
scanSeconds,
|
||||
scanThread,
|
||||
scanTimeout,
|
||||
scanResult,
|
||||
scanCandidates,
|
||||
scanPanelExpanded,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
Logger.error('Failed to persist scanner state to localStorage', 'Domains', error);
|
||||
}
|
||||
}, [scanAddr, scanSeconds, scanThread, scanTimeout, scanResult, scanCandidates, scanPanelExpanded, scanStateHydrated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isScanning) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const tick = async () => {
|
||||
try {
|
||||
const status = await fetchScanStatus();
|
||||
if (cancelled) return;
|
||||
|
||||
if (status.running) {
|
||||
if (status.runId) {
|
||||
setActiveScanRunId((prev) => prev ?? status.runId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Скан завершён — очищаем ошибку и загружаем результаты
|
||||
setIsScanning(false);
|
||||
setScanError('');
|
||||
const runIdToLoad = activeScanRunId || status.lastRunId;
|
||||
await fetchLastScanResult(runIdToLoad);
|
||||
setActiveScanRunId(null);
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
Logger.error('Failed to fetch scan status', 'Domains', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
tick();
|
||||
const timer = window.setInterval(tick, 1000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [isScanning, activeScanRunId, fetchScanStatus, fetchLastScanResult]);
|
||||
|
||||
const handleChangePage = (_event: unknown, newPage: number) => {
|
||||
setPage(newPage);
|
||||
@@ -40,25 +389,37 @@ export default function DomainsPage() {
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newDomain) return;
|
||||
Logger.debug(`Adding domain: ${newDomain}`, 'Domains');
|
||||
await api.post('/domains', { name: newDomain });
|
||||
Logger.debug(`Added domain: ${newDomain}`, 'Domains');
|
||||
setNewDomain('');
|
||||
loadDomains();
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
Logger.debug(`Deleting domain ID: ${id}`, 'Domains');
|
||||
await api.delete(`/domains/${id}`);
|
||||
Logger.debug(`Deleted domain ID: ${id}`, 'Domains');
|
||||
loadDomains();
|
||||
};
|
||||
|
||||
const handleDeleteAll = async () => {
|
||||
if (confirm('ВНИМАНИЕ! Вы действительно хотите удалить ВСЕ домены из белого списка?')) {
|
||||
if (confirm('Это действие необратимо. Точно удалить?')) {
|
||||
setConfirmDialog({
|
||||
open: true,
|
||||
title: 'ВНИМАНИЕ! Вы действительно хотите удалить ВСЕ домены из белого списка?',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
Logger.debug('Deleting all domains', 'Domains');
|
||||
await api.delete('/domains/all');
|
||||
Logger.debug('All domains deleted', 'Domains');
|
||||
loadDomains();
|
||||
} catch (_e) { alert('Ошибка удаления'); }
|
||||
setSnackbar({ open: true, type: 'success', message: 'Все домены удалены' });
|
||||
} catch {
|
||||
Logger.error('Delete all failed', 'Domains');
|
||||
setSnackbar({ open: true, type: 'error', message: 'Ошибка удаления' });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -74,10 +435,10 @@ export default function DomainsPage() {
|
||||
|
||||
try {
|
||||
const { data } = await api.post('/domains/upload', { domains: lines });
|
||||
alert(`Успешно добавлено доменов: ${data.count}`);
|
||||
setSnackbar({ open: true, type: 'success', message: `Успешно добавлено доменов: ${data.count}` });
|
||||
loadDomains();
|
||||
} catch (_err) {
|
||||
alert('Ошибка при загрузке списка');
|
||||
} catch {
|
||||
setSnackbar({ open: true, type: 'error', message: 'Ошибка при загрузке списка' });
|
||||
} finally {
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
@@ -85,71 +446,434 @@ export default function DomainsPage() {
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleStartScan = async () => {
|
||||
if (!scanAddr.trim()) {
|
||||
setSnackbar({ open: true, type: 'error', message: 'Укажите IP/домен для сканирования' });
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveScanSeconds = clampInteger(scanSeconds, 120, 10, 600);
|
||||
const effectiveThread = clampInteger(scanThread, 2, 1, 20);
|
||||
const effectiveTimeout = clampInteger(scanTimeout, 5, 1, 20);
|
||||
let keepScanning = false;
|
||||
|
||||
try {
|
||||
Logger.debug(`Starting scan: addr=${scanAddr.trim()}, seconds=${effectiveScanSeconds}, threads=${effectiveThread}, timeout=${effectiveTimeout}`, 'Scanner');
|
||||
setIsScanning(true);
|
||||
setScanError('');
|
||||
setScanResult(null);
|
||||
setScanStatus(null);
|
||||
setActiveScanRunId(null);
|
||||
|
||||
const { data } = await api.post('/domains/scan/start', {
|
||||
addr: scanAddr.trim(),
|
||||
scanSeconds: effectiveScanSeconds,
|
||||
thread: effectiveThread,
|
||||
timeout: effectiveTimeout,
|
||||
});
|
||||
|
||||
Logger.debug(`Scan started: runId=${data.runId}, found=${data.foundCount}`, 'Scanner');
|
||||
setScanResult(data);
|
||||
setScanCandidates(data.domains || []);
|
||||
setActiveScanRunId(data.runId || null);
|
||||
await fetchScanStatus();
|
||||
} catch (e) {
|
||||
const message = getApiErrorMessage(e, 'Ошибка запуска сканера');
|
||||
Logger.error(`Start error: ${message}`, 'Scanner');
|
||||
setScanError(message);
|
||||
|
||||
const status = getApiErrorStatus(e);
|
||||
if (status === 429) {
|
||||
try {
|
||||
const status = await fetchScanStatus();
|
||||
if (status.running) {
|
||||
keepScanning = true;
|
||||
setIsScanning(true);
|
||||
setActiveScanRunId(status.runId);
|
||||
setScanError('Скан уже выполняется. Подключились к текущему запуску.');
|
||||
Logger.debug('Connected to existing scan session', 'Scanner');
|
||||
}
|
||||
} catch (statusErr) {
|
||||
Logger.error('Failed to fetch scan status on 429', 'Scanner', statusErr);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!keepScanning) {
|
||||
setIsScanning(false);
|
||||
setActiveScanRunId(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportScannedDomains = async () => {
|
||||
const found = scanCandidates;
|
||||
if (found.length === 0) return;
|
||||
|
||||
try {
|
||||
Logger.debug(`Importing ${found.length} scanned domains`, 'Domains');
|
||||
const { data } = await api.post('/domains/upload', { domains: found });
|
||||
Logger.debug(`Imported ${data.count} new domains`, 'Domains');
|
||||
setSnackbar({ open: true, type: 'success', message: `Скан завершен. Добавлено новых доменов: ${data.count}` });
|
||||
loadDomains();
|
||||
} catch {
|
||||
Logger.error('Import failed', 'Domains');
|
||||
setSnackbar({ open: true, type: 'error', message: 'Ошибка импорта найденных доменов' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveScannedDomain = (domain: string) => {
|
||||
setScanCandidates((prev) => prev.filter((d) => d !== domain));
|
||||
};
|
||||
|
||||
const handleClearScannedDomains = async () => {
|
||||
setScanCandidates([]);
|
||||
setScanResult(null);
|
||||
setScanStatus(null);
|
||||
setActiveScanRunId(null);
|
||||
setScanAddr('');
|
||||
|
||||
const suggestedAddr = await resolveSuggestedScanAddr({ allowLoopbackFallback: true });
|
||||
setScanAddr(suggestedAddr);
|
||||
};
|
||||
|
||||
const downloadDomainsAsTxt = (filename: string, domainNames: string[]) => {
|
||||
const content = `${domainNames.join('\n')}\n`;
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const getExportTimestamp = () => new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
|
||||
|
||||
const handleExportScannedDomains = () => {
|
||||
if (scanCandidates.length === 0) return;
|
||||
downloadDomainsAsTxt(`sni-scanned-${getExportTimestamp()}.txt`, scanCandidates);
|
||||
};
|
||||
|
||||
const handleExportMainDomains = async () => {
|
||||
if (domains.length === 0) return;
|
||||
|
||||
try {
|
||||
const { data } = await api.get('/domains/all');
|
||||
const names = (Array.isArray(data) ? data : [])
|
||||
.map((d: Domain) => d.name)
|
||||
.filter(Boolean);
|
||||
|
||||
if (names.length === 0) return;
|
||||
downloadDomainsAsTxt(`sni-whitelist-${getExportTimestamp()}.txt`, names);
|
||||
} catch {
|
||||
setSnackbar({ open: true, type: 'error', message: 'Ошибка экспорта списка' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>Белый список доменов (SNI)</Typography>
|
||||
<Typography variant={isMobile ? 'h5' : 'h4'} gutterBottom>Белый список доменов (SNI)</Typography>
|
||||
|
||||
<Paper sx={{ p: 2, display: 'flex', gap: 2 }}>
|
||||
<TextField
|
||||
label="Добавить домен" size="small" fullWidth
|
||||
value={newDomain} onChange={(e) => setNewDomain(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<UploadFile />}
|
||||
sx={{ width: '170px' }}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Из файла
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept=".txt"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
<Button variant="contained" sx={{ width: '160px' }} startIcon={<Add />} onClick={handleAdd}>Добавить</Button>
|
||||
</Paper>
|
||||
|
||||
{domains.length > 0 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'end', width: '100%' }}>
|
||||
<Button
|
||||
variant="text"
|
||||
color="error"
|
||||
size='small'
|
||||
startIcon={<Remove />}
|
||||
onClick={handleDeleteAll}
|
||||
{scanCapabilities?.scannerAvailable && (
|
||||
<Paper sx={{ mb: 2 }}>
|
||||
<Accordion
|
||||
expanded={scanPanelExpanded}
|
||||
onChange={(_event, expanded) => setScanPanelExpanded(expanded)}
|
||||
disableGutters
|
||||
sx={{
|
||||
boxShadow: 'none',
|
||||
'&:before': { display: 'none' },
|
||||
}}
|
||||
>
|
||||
Удалить все
|
||||
</Button>
|
||||
</Box>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Typography variant='h6'>Автопоиск SNI (backend scanner)</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ px: 2, pb: 2 }}>
|
||||
|
||||
{scanCapabilities && (!scanCapabilities.scannerAvailable || !scanCapabilities.timeoutAvailable) && (
|
||||
<Alert severity='warning' sx={{ mb: 2 }}>
|
||||
Сканер в контейнере недоступен. scanner: {String(scanCapabilities.scannerAvailable)}, timeout: {String(scanCapabilities.timeoutAvailable)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>
|
||||
<TextField
|
||||
label='IP/домен VPS'
|
||||
value={scanAddr}
|
||||
onChange={(e) => setScanAddr(e.target.value)}
|
||||
fullWidth
|
||||
size='small'
|
||||
/>
|
||||
<TextField
|
||||
label='Секунд скана'
|
||||
type='number'
|
||||
value={scanSeconds}
|
||||
onChange={(e) => setScanSeconds(Number(e.target.value))}
|
||||
size='small'
|
||||
sx={{ minWidth: 140 }}
|
||||
/>
|
||||
<TextField
|
||||
label='Потоков'
|
||||
type='number'
|
||||
value={scanThread}
|
||||
onChange={(e) => setScanThread(Number(e.target.value))}
|
||||
size='small'
|
||||
sx={{ minWidth: 120 }}
|
||||
/>
|
||||
<TextField
|
||||
label='Таймаут, сек'
|
||||
type='number'
|
||||
value={scanTimeout}
|
||||
onChange={(e) => setScanTimeout(Number(e.target.value))}
|
||||
size='small'
|
||||
sx={{ minWidth: 120 }}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack direction='row' spacing={1} sx={{ mt: 2, flexWrap: 'wrap' }}>
|
||||
<Button variant='contained' onClick={handleStartScan} disabled={isScanning}>
|
||||
{isScanning ? 'Сканирование...' : 'Сканировать'}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outlined'
|
||||
onClick={handleImportScannedDomains}
|
||||
disabled={!scanResult || scanCandidates.length === 0 || isScanning}
|
||||
>
|
||||
Добавить найденные в список
|
||||
</Button>
|
||||
{scanCandidates.length > 0 && (
|
||||
<Button
|
||||
variant='outlined'
|
||||
startIcon={<Download />}
|
||||
onClick={handleExportScannedDomains}
|
||||
disabled={isScanning}
|
||||
>
|
||||
Экспорт найденных
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='text'
|
||||
color='error'
|
||||
onClick={handleClearScannedDomains}
|
||||
disabled={scanCandidates.length === 0 && !scanResult}
|
||||
>
|
||||
Очистить предварительный
|
||||
</Button>
|
||||
{isScanning && <CircularProgress size={24} />}
|
||||
</Stack>
|
||||
{isScanning && (
|
||||
<Typography variant='body2' color='text.secondary' sx={{ mt: 1 }}>
|
||||
{scanStatus?.running
|
||||
? scanStatus.remainingSeconds > 0
|
||||
? `Сканирование выполняется. Осталось ${scanStatus.remainingSeconds} сек (по данным сервера). Найдено сейчас: ${scanStatus.foundCount}.`
|
||||
: 'Сканирование завершается, ожидайте...'
|
||||
: 'Сканирование запущено, получаем статус от сервера...'}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{scanError && <Alert severity='error' sx={{ mt: 2 }}>{scanError}</Alert>}
|
||||
|
||||
{scanResult && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Typography variant='body2' sx={{ mb: 1 }}>
|
||||
Найдено доменов: <b>{scanResult.foundCount}</b>. В предварительном списке: <b>{scanCandidates.length}</b>.
|
||||
</Typography>
|
||||
<Typography
|
||||
variant='body2'
|
||||
color={scanResult.timedOut ? 'info.main' : 'success.main'}
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
{scanResult.timedOut
|
||||
? `Скан остановлен по лимиту времени (${scanResult.scanSeconds} сек) - это нормальный режим поиска.`
|
||||
: 'Скан завершен успешно.'}
|
||||
{' '}<Box component='span' sx={{ color: 'text.secondary' }}>(код: {scanResult.exitCode})</Box>
|
||||
</Typography>
|
||||
<Typography variant='body2' color='text.secondary' sx={{ mb: 1 }}>
|
||||
Проверяйте домены кликом и удаляйте лишние перед импортом.
|
||||
</Typography>
|
||||
<Paper variant='outlined' sx={{ maxHeight: 220, overflow: 'auto' }}>
|
||||
<List dense>
|
||||
{scanCandidates.map((d) => (
|
||||
<ListItem
|
||||
key={d}
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
transition: 'background-color 120ms ease',
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover',
|
||||
},
|
||||
}}
|
||||
secondaryAction={
|
||||
<IconButton edge='end' onClick={() => handleRemoveScannedDomain(d)}>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<MuiLink href={`https://${d}`} target='_blank' rel='noopener noreferrer' underline='hover'>
|
||||
{d}
|
||||
</MuiLink>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
{scanCandidates.length === 0 && (
|
||||
<ListItem>
|
||||
<ListItemText primary='Домены не найдены' />
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Paper>
|
||||
<List>
|
||||
{domains.map((d) => (
|
||||
<ListItem key={d.id} secondaryAction={
|
||||
<IconButton edge="end" onClick={() => handleDelete(d.id)}><Delete /></IconButton>
|
||||
}>
|
||||
<ListItemText primary={d.name} />
|
||||
</ListItem>
|
||||
))}
|
||||
{domains.length === 0 && <Typography sx={{ p: 2 }} color='textSecondary' textAlign='center'>Список пуст</Typography>}
|
||||
</List>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||
labelRowsPerPage="Доменов на странице:"
|
||||
labelDisplayedRows={({ from, to, count }) => `${from}–${to} из ${count !== -1 ? count : `более ${to}`}`}
|
||||
/>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant='h6' gutterBottom>
|
||||
Управление белым списком (SNI)
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<TextField
|
||||
label="Доменное имя"
|
||||
size="small"
|
||||
value={newDomain}
|
||||
onChange={(e) => setNewDomain(e.target.value)}
|
||||
sx={{ flex: '1 1 280px' }}
|
||||
/>
|
||||
{isMobile ? (
|
||||
<>
|
||||
<IconButton edge="end" onClick={() => fileInputRef.current?.click()}><UploadFile /></IconButton>
|
||||
<IconButton edge="end" onClick={handleAdd}><Add /></IconButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<UploadFile />}
|
||||
sx={{ width: '170px' }}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Из файла
|
||||
</Button>
|
||||
<Button variant="contained" sx={{ width: '160px' }} startIcon={<Add />} onClick={handleAdd}>Добавить</Button>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept=".txt"
|
||||
data-testid="file-input"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{domains.length > 0 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'end', width: '100%', mt: 1, gap: 1, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size='small'
|
||||
startIcon={<Download />}
|
||||
onClick={handleExportMainDomains}
|
||||
>
|
||||
Экспорт списка
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
color="error"
|
||||
size='small'
|
||||
startIcon={<Remove />}
|
||||
onClick={handleDeleteAll}
|
||||
>
|
||||
Удалить все
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Paper variant='outlined' sx={{ mt: 1 }}>
|
||||
<List>
|
||||
{domains.map((d) => (
|
||||
<ListItem
|
||||
key={d.id}
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
transition: 'background-color 120ms ease',
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover',
|
||||
},
|
||||
}}
|
||||
secondaryAction={
|
||||
<IconButton edge="end" onClick={() => handleDelete(d.id)}><Delete /></IconButton>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<MuiLink href={`https://${d.name}`} target='_blank' rel='noopener noreferrer' underline='hover'>
|
||||
{d.name}
|
||||
</MuiLink>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
{domains.length === 0 && <Typography sx={{ p: 2 }} color='textSecondary' textAlign='center'>Нет доменов</Typography>}
|
||||
</List>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||
labelRowsPerPage="Доменов на странице:"
|
||||
labelDisplayedRows={({ from, to, count }) => `${from}–${to} из ${count !== -1 ? count : `более ${to}`}`}
|
||||
/>
|
||||
</Paper>
|
||||
</Paper>
|
||||
|
||||
<Snackbar
|
||||
open={snackbar.open}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert
|
||||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||||
severity={snackbar.type}
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
{snackbar.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
|
||||
<Dialog open={confirmDialog.open} onClose={() => setConfirmDialog({ ...confirmDialog, open: false })}>
|
||||
<DialogTitle>Подтверждение действия</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>{confirmDialog.title}</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setConfirmDialog({ ...confirmDialog, open: false })}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setConfirmDialog({ ...confirmDialog, open: false });
|
||||
confirmDialog.onConfirm();
|
||||
}}
|
||||
variant="contained"
|
||||
color="error"
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Paper, TextField, Button, Typography, Alert } from '@mui/material';
|
||||
import { Box, Paper, TextField, Button, Typography, Alert, Chip } from '@mui/material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Logger } from '../utils/logger';
|
||||
import { getApiErrorMessage, getApiErrorStatus } from '../utils/errorHandlers';
|
||||
import { APP_VERSION } from '../utils/version';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [creds, setCreds] = useState({ login: '', password: '' });
|
||||
@@ -12,22 +15,57 @@ export default function LoginPage() {
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
Logger.debug(`Form submit → POST /api/auth/login`, 'Login', {
|
||||
login: creds.login,
|
||||
hasPassword: Boolean(creds.password),
|
||||
});
|
||||
try {
|
||||
const res = await api.post('/auth/login', creds);
|
||||
login(res.data.access_token);
|
||||
|
||||
const token = res.data.access_token;
|
||||
Logger.debug(`Success → token received, calling login()`, 'Login');
|
||||
login(token);
|
||||
|
||||
Logger.debug('Navigating to / after successful login', 'Login');
|
||||
navigate('/');
|
||||
} catch (e) {
|
||||
setError('Неверный логин или пароль');
|
||||
} catch (error: unknown) {
|
||||
const status = getApiErrorStatus(error);
|
||||
const message = getApiErrorMessage(error, 'Неверный логин или пароль');
|
||||
|
||||
// Rate limit error
|
||||
if (status === 429) {
|
||||
Logger.warn('Too many login attempts. Please try again later.', 'Login', {
|
||||
status,
|
||||
message,
|
||||
});
|
||||
setError('Слишком много попыток входа. Попробуйте позже.');
|
||||
} else {
|
||||
const logMethod = status === 401 ? Logger.warn : Logger.error;
|
||||
logMethod('Login failed', 'Login', {
|
||||
status: status ?? 'unknown',
|
||||
message,
|
||||
});
|
||||
setError('Неверный логин или пароль');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
<Box className="animated-container" sx={{
|
||||
height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
bgcolor: 'background.default'
|
||||
}}>
|
||||
<Paper sx={{ p: 4, width: '100%', maxWidth: 400 }}>
|
||||
<Typography variant="h5" gutterBottom align="center">Вход в 3DP-MANAGER</Typography>
|
||||
<Paper sx={{
|
||||
p: 4,
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
animation: 'fadeIn 1.5s ease-out',
|
||||
boxShadow: '0 15px 25px rgba(0,0,0,0.5)'
|
||||
}}>
|
||||
<Typography variant="h5" gutterBottom align="center"><span style={{ verticalAlign: 'middle' }}>Вход в 3DP-MANAGER</span> <Chip label={`v${APP_VERSION}`} size="small" sx={{ verticalAlign: 'middle' }} /></Typography>
|
||||
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
@@ -40,11 +78,11 @@ export default function LoginPage() {
|
||||
fullWidth margin="normal" label="Пароль" type="password"
|
||||
value={creds.password} onChange={(e) => setCreds({ ...creds, password: e.target.value })}
|
||||
/>
|
||||
<Button fullWidth variant="contained" size="large" type="submit" sx={{ mt: 3 }}>
|
||||
<Button fullWidth variant="contained" size="large" type="submit" sx={{ mt: 3, transition: 'transform 0.3s ease', '&:hover': { transform: 'scale(1.05)' } }}>
|
||||
Войти
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Snackbar,
|
||||
Stack,
|
||||
Switch,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add,
|
||||
CheckCircle,
|
||||
Delete,
|
||||
Edit,
|
||||
Star,
|
||||
StarBorder,
|
||||
Sync,
|
||||
} from '@mui/icons-material';
|
||||
import api from '../api';
|
||||
import { nodesApi } from '../features/nodes/api';
|
||||
import type { NodeAuthType, NodePayload, NodeRecord } from '../types/node';
|
||||
import { getApiErrorMessage } from '../utils/errorHandlers';
|
||||
import { FlagIcon, FlagOptionLabel } from '../utils/flags';
|
||||
|
||||
const emptyForm: NodePayload = {
|
||||
name: '',
|
||||
url: '',
|
||||
ip: '',
|
||||
flag: '',
|
||||
authType: 'token',
|
||||
login: '',
|
||||
password: '',
|
||||
token: '',
|
||||
isMain: false,
|
||||
};
|
||||
|
||||
interface CountryOption {
|
||||
name: string;
|
||||
code: string;
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
const isValidIp = (value?: string) =>
|
||||
!!value &&
|
||||
(/^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/.test(value.trim()) ||
|
||||
/^([0-9a-f]{1,4}:){2,7}[0-9a-f]{1,4}$/i.test(value.trim()));
|
||||
|
||||
export default function NodesPage() {
|
||||
const [nodes, setNodes] = useState<NodeRecord[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<NodeRecord | null>(null);
|
||||
const [form, setForm] = useState<NodePayload>(emptyForm);
|
||||
const [checkingId, setCheckingId] = useState<string | null>(null);
|
||||
const [countries, setCountries] = useState<CountryOption[]>([]);
|
||||
const [detectingLocation, setDetectingLocation] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<NodeRecord | null>(null);
|
||||
const [formErrors, setFormErrors] = useState<Partial<Record<keyof NodePayload, string>>>({});
|
||||
const [message, setMessage] = useState({
|
||||
open: false,
|
||||
type: 'success' as 'success' | 'error',
|
||||
text: '',
|
||||
});
|
||||
|
||||
const loadNodes = useCallback(async () => {
|
||||
const data = await nodesApi.list();
|
||||
setNodes(Array.isArray(data) ? data : []);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadNodes();
|
||||
api.get<CountryOption[]>('/settings/countries').then((res) => setCountries(Array.isArray(res.data) ? res.data : []));
|
||||
}, [loadNodes]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
setFormErrors({});
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (node: NodeRecord) => {
|
||||
setEditing(node);
|
||||
setForm({
|
||||
name: node.name,
|
||||
url: node.url || '',
|
||||
ip: node.ip || '',
|
||||
flag: node.flag || '',
|
||||
authType: node.authType,
|
||||
login: node.login || '',
|
||||
password: '',
|
||||
token: '',
|
||||
isMain: node.isMain,
|
||||
version: node.version || '',
|
||||
});
|
||||
setFormErrors({});
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const updateField = <K extends keyof NodePayload>(key: K, value: NodePayload[K]) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
setFormErrors((prev) => ({ ...prev, [key]: undefined }));
|
||||
};
|
||||
|
||||
const validateForm = (requireSecrets = !editing) => {
|
||||
const errors: Partial<Record<keyof NodePayload, string>> = {};
|
||||
|
||||
if (!form.name.trim()) errors.name = 'Введите название ноды';
|
||||
if (!form.url.trim()) errors.url = 'Введите URL панели 3x-ui';
|
||||
if (!isValidIp(form.ip)) errors.ip = 'Введите корректный IP ноды';
|
||||
if (!form.flag) errors.flag = 'Выберите флаг ноды';
|
||||
|
||||
if (form.authType === 'password') {
|
||||
if (!form.login?.trim()) errors.login = 'Введите логин';
|
||||
if (requireSecrets && !form.password?.trim()) errors.password = 'Введите пароль';
|
||||
}
|
||||
|
||||
if (form.authType === 'token' && requireSecrets && !form.token?.trim()) {
|
||||
errors.token = 'Введите токен';
|
||||
}
|
||||
|
||||
setFormErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const detectNodeLocation = async () => {
|
||||
const url = form.url.trim();
|
||||
if (!url) return;
|
||||
|
||||
setDetectingLocation(true);
|
||||
try {
|
||||
const result = await nodesApi.detectLocation(url.replace(/\/+$/, ''));
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
ip: result.ip || prev.ip,
|
||||
flag: result.flag || prev.flag,
|
||||
}));
|
||||
if (result.country || result.ip) {
|
||||
setMessage({
|
||||
open: true,
|
||||
type: 'success',
|
||||
text: `Определено: ${result.country || 'страна неизвестна'}${result.ip ? `, IP ${result.ip}` : ''}`,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setMessage({ open: true, type: 'error', text: 'Не удалось определить страну ноды' });
|
||||
} finally {
|
||||
setDetectingLocation(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveNode = async () => {
|
||||
if (!validateForm(!editing)) {
|
||||
setMessage({ open: true, type: 'error', text: 'Заполните обязательные поля' });
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: NodePayload = {
|
||||
...form,
|
||||
url: form.url.replace(/\/+$/, ''),
|
||||
ip: form.ip || undefined,
|
||||
flag: form.flag || undefined,
|
||||
login: form.authType === 'password' ? form.login : undefined,
|
||||
password: form.authType === 'password' && form.password ? form.password : undefined,
|
||||
token: form.authType === 'token' && form.token ? form.token : undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
if (editing) {
|
||||
await nodesApi.update(editing.id, payload);
|
||||
} else {
|
||||
await nodesApi.create(payload);
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
setMessage({
|
||||
open: true,
|
||||
type: 'success',
|
||||
text: editing ? 'Нода обновлена' : 'Нода добавлена',
|
||||
});
|
||||
loadNodes();
|
||||
} catch (error: unknown) {
|
||||
const text =
|
||||
(error as { response?: { data?: { message?: string } } })?.response?.data?.message ||
|
||||
'Не удалось сохранить ноду';
|
||||
setMessage({ open: true, type: 'error', text });
|
||||
}
|
||||
};
|
||||
|
||||
const checkFormConnection = async () => {
|
||||
if (!validateForm(true)) {
|
||||
setMessage({ open: true, type: 'error', text: 'Заполните обязательные поля для проверки подключения' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await nodesApi.checkPayload({
|
||||
...form,
|
||||
url: form.url.replace(/\/+$/, ''),
|
||||
login: form.authType === 'password' ? form.login : undefined,
|
||||
password: form.authType === 'password' ? form.password : undefined,
|
||||
token: form.authType === 'token' ? form.token : undefined,
|
||||
});
|
||||
|
||||
setMessage({
|
||||
open: true,
|
||||
type: result.success ? 'success' : 'error',
|
||||
text: result.success ? 'Подключение успешно' : 'Не удалось подключиться',
|
||||
});
|
||||
};
|
||||
|
||||
const checkNode = async (node: NodeRecord) => {
|
||||
setCheckingId(node.id);
|
||||
try {
|
||||
const result = await nodesApi.check(node.id);
|
||||
setMessage({
|
||||
open: true,
|
||||
type: result.success ? 'success' : 'error',
|
||||
text: result.success ? 'Подключение успешно' : 'Не удалось подключиться',
|
||||
});
|
||||
loadNodes();
|
||||
} finally {
|
||||
setCheckingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const syncNodes = async () => {
|
||||
const result = await nodesApi.syncFromMain();
|
||||
setMessage({
|
||||
open: true,
|
||||
type: 'success',
|
||||
text: `Синхронизировано нод: ${result.count}`,
|
||||
});
|
||||
loadNodes();
|
||||
};
|
||||
|
||||
const removeNode = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await nodesApi.remove(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
setMessage({ open: true, type: 'success', text: 'Нода удалена' });
|
||||
loadNodes();
|
||||
} catch (error) {
|
||||
setMessage({
|
||||
open: true,
|
||||
type: 'error',
|
||||
text: getApiErrorMessage(error, 'Ошибка удаления ноды'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3, gap: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="h4">Ноды</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Stack direction="row" spacing={1}>
|
||||
{/* <Button startIcon={<Sync />} variant="outlined" onClick={syncNodes}>
|
||||
Синхронизировать
|
||||
</Button> */}
|
||||
<Button startIcon={<Add />} variant="contained" onClick={openCreate}>
|
||||
Добавить
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ overflowX: 'auto' }}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Название</TableCell>
|
||||
<TableCell>Флаг</TableCell>
|
||||
<TableCell>IP</TableCell>
|
||||
<TableCell>URL панели</TableCell>
|
||||
<TableCell>Авторизация</TableCell>
|
||||
<TableCell>Статус</TableCell>
|
||||
<TableCell align="right">Действия</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{nodes.map((node) => (
|
||||
<TableRow key={node.id}>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<IconButton size="small" onClick={() => nodesApi.setMain(node.id).then(loadNodes)} title={node.isMain ? '' : 'Сделать основной'}>
|
||||
{node.isMain ? <Star color="warning" /> : <StarBorder />}
|
||||
</IconButton>
|
||||
<Typography fontWeight={700}>{node.name}</Typography>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FlagIcon flag={node.flag} />
|
||||
</TableCell>
|
||||
<TableCell>{node.ip || '-'}</TableCell>
|
||||
<TableCell>{node.url}</TableCell>
|
||||
<TableCell>{node.authType}</TableCell>
|
||||
<TableCell>
|
||||
{node.isMain && <Chip icon={<CheckCircle />} label="Основная" color="success" size="small" />}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{/* <IconButton disabled={checkingId === node.id} onClick={() => checkNode(node)}>
|
||||
{checkingId === node.id ? <CircularProgress size={20} /> : <CheckCircle />}
|
||||
</IconButton> */}
|
||||
<IconButton onClick={() => openEdit(node)}>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
<IconButton color="error" onClick={() => setDeleteTarget(node)}>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{nodes.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} align="center" sx={{ color: 'text.secondary' }}>
|
||||
Ноды не добавлены
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Paper>
|
||||
|
||||
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{editing ? 'Редактировать ноду' : 'Новая нода'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<TextField
|
||||
label="Название"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => updateField('name', e.target.value)}
|
||||
error={!!formErrors.name}
|
||||
helperText={formErrors.name}
|
||||
/>
|
||||
<TextField
|
||||
label="URL панели 3x-ui"
|
||||
required
|
||||
helperText={formErrors.url || 'Например: https://85.198.84.27:35366/2vIsDA5HanQ3R7JyIH'}
|
||||
value={form.url}
|
||||
onChange={(e) => updateField('url', e.target.value)}
|
||||
onBlur={detectNodeLocation}
|
||||
error={!!formErrors.url}
|
||||
InputProps={{ endAdornment: detectingLocation ? <CircularProgress size={18} /> : undefined }}
|
||||
/>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||
<TextField
|
||||
label="IP ноды"
|
||||
required
|
||||
fullWidth
|
||||
value={form.ip || ''}
|
||||
onChange={(e) => updateField('ip', e.target.value)}
|
||||
error={!!formErrors.ip}
|
||||
helperText={formErrors.ip}
|
||||
/>
|
||||
<FormControl fullWidth required error={!!formErrors.flag}>
|
||||
<InputLabel>Флаг</InputLabel>
|
||||
<Select
|
||||
value={form.flag || ''}
|
||||
label="Флаг"
|
||||
onChange={(e) => updateField('flag', e.target.value)}
|
||||
renderValue={(value) => (
|
||||
<FlagOptionLabel flag={value} label={countries.find((country) => country.emoji === value)?.name || 'Флаг'} />
|
||||
)}
|
||||
>
|
||||
<MenuItem value="">Без флага</MenuItem>
|
||||
{countries.map((country) => (
|
||||
<MenuItem key={country.code} value={country.emoji}>
|
||||
<FlagOptionLabel flag={country.emoji} code={country.code} label={country.name} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{formErrors.flag && <FormHelperText>{formErrors.flag}</FormHelperText>}
|
||||
</FormControl>
|
||||
</Stack>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Тип авторизации</InputLabel>
|
||||
<Select
|
||||
value={form.authType}
|
||||
label="Тип авторизации"
|
||||
onChange={(e) => updateField('authType', e.target.value as NodeAuthType)}
|
||||
>
|
||||
<MenuItem value="password">Логин и пароль</MenuItem>
|
||||
<MenuItem value="token">Токен</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{form.authType === 'password' ? (
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||
<TextField
|
||||
label="Логин"
|
||||
required
|
||||
fullWidth
|
||||
value={form.login || ''}
|
||||
onChange={(e) => updateField('login', e.target.value)}
|
||||
error={!!formErrors.login}
|
||||
helperText={formErrors.login}
|
||||
/>
|
||||
<TextField
|
||||
label={editing ? 'Новый пароль' : 'Пароль'}
|
||||
type="password"
|
||||
required={!editing}
|
||||
fullWidth
|
||||
value={form.password || ''}
|
||||
onChange={(e) => updateField('password', e.target.value)}
|
||||
error={!!formErrors.password}
|
||||
helperText={formErrors.password || (editing ? 'Оставьте пустым, чтобы не менять пароль' : undefined)}
|
||||
/>
|
||||
</Stack>
|
||||
) : (
|
||||
<TextField
|
||||
label={editing ? 'Новый токен' : 'Токен'}
|
||||
type="password"
|
||||
required={!editing}
|
||||
value={form.token || ''}
|
||||
onChange={(e) => updateField('token', e.target.value)}
|
||||
error={!!formErrors.token}
|
||||
helperText={formErrors.token || (editing ? 'Оставьте пустым, чтобы не менять токен' : undefined)}
|
||||
/>
|
||||
)}
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Switch checked={!!form.isMain} onChange={(e) => updateField('isMain', e.target.checked)} />
|
||||
<Typography>Сделать основной нодой</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={checkFormConnection}>Проверить подключение</Button>
|
||||
<Button onClick={() => setOpen(false)}>Отмена</Button>
|
||||
<Button variant="contained" onClick={saveNode}>Сохранить</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!deleteTarget} onClose={() => setDeleteTarget(null)}>
|
||||
<DialogTitle>Удалить ноду?</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>Вы уверены, что хотите удалить ноду {deleteTarget?.name}?</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteTarget(null)}>Отмена</Button>
|
||||
<Button color="error" variant="contained" onClick={removeNode}>Удалить</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Snackbar open={message.open} autoHideDuration={5000} onClose={() => setMessage({ ...message, open: false })}>
|
||||
<Alert severity={message.type}>{message.text}</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,360 +1,105 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, TextField, Button, Typography, Paper, Snackbar, Alert, Grid, Divider, InputAdornment, Stack, Chip, Tooltip, IconButton } from '@mui/material';
|
||||
import { type ChangeEvent, useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Paper,
|
||||
Snackbar,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import api from '../api';
|
||||
import { CheckCircle, PauseCircleFilled, PlayCircleFilled, Schedule, Update } from '@mui/icons-material';
|
||||
|
||||
const ROTATION_PRESETS = [
|
||||
{ label: 'Сутки', value: 1440 },
|
||||
{ label: '3 дня', value: 4320 },
|
||||
{ label: 'Неделя', value: 10080 },
|
||||
];
|
||||
import { Logger } from '../utils/logger';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [settings, setSettings] = useState({
|
||||
xui_url: '',
|
||||
xui_login: '',
|
||||
xui_password: '',
|
||||
rotation_interval: '30',
|
||||
rotation_status: 'active',
|
||||
last_rotation_timestamp: '',
|
||||
});
|
||||
|
||||
const [adminProfile, setAdminProfile] = useState({
|
||||
login: '',
|
||||
password: '',
|
||||
});
|
||||
const [message, setMessage] = useState({
|
||||
open: false,
|
||||
type: 'success' as 'success' | 'error',
|
||||
text: '',
|
||||
});
|
||||
|
||||
const [msg, setMsg] = useState({ open: false, type: 'success' as 'success' | 'error', text: '' });
|
||||
const [intervalError, setIntervalError] = useState<string>('');
|
||||
const [loadingRotate, setLoadingRotate] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const val = parseInt(settings.rotation_interval, 10);
|
||||
if (isNaN(val) || val < 10) {
|
||||
setIntervalError('Минимальный интервал — 10 минут');
|
||||
} else {
|
||||
setIntervalError('');
|
||||
}
|
||||
}, [settings.rotation_interval]);
|
||||
|
||||
const cleanData = () => {
|
||||
const cleaned = { ...settings };
|
||||
|
||||
if (cleaned.xui_url) {
|
||||
cleaned.xui_url = cleaned.xui_url.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
if (cleaned.xui_login) cleaned.xui_login = cleaned.xui_login.trim();
|
||||
if (cleaned.xui_password) cleaned.xui_password = cleaned.xui_password.trim();
|
||||
|
||||
setSettings(prev => ({ ...prev, ...cleaned }));
|
||||
|
||||
return cleaned;
|
||||
};
|
||||
|
||||
const handleCheckConnection = async () => {
|
||||
const data = cleanData(); // Сначала чистим
|
||||
|
||||
const loadProfile = useCallback(async () => {
|
||||
try {
|
||||
setMsg({ open: true, type: 'success', text: 'Проверка...' });
|
||||
const res = await api.post('/settings/check', {
|
||||
xui_url: data.xui_url,
|
||||
xui_login: data.xui_login,
|
||||
xui_password: data.xui_password
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
setMsg({ open: true, type: 'success', text: 'Подключение успешно!' });
|
||||
} else {
|
||||
setMsg({ open: true, type: 'error', text: 'Ошибка: Неверные данные или нет доступа' });
|
||||
}
|
||||
} catch (e) {
|
||||
setMsg({ open: true, type: 'error', text: 'Ошибка сети при проверке' });
|
||||
}
|
||||
};
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/settings');
|
||||
setSettings((prev) => ({ ...prev, ...data }));
|
||||
|
||||
const { data } = await api.get<Record<string, string>>('/settings');
|
||||
if (data.admin_login) {
|
||||
setAdminProfile((prev) => ({ ...prev, login: data.admin_login }));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} catch (error) {
|
||||
Logger.error('Failed to load profile settings', 'Settings', error);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSettingChange = (prop: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSettings({ ...settings, [prop]: event.target.value });
|
||||
};
|
||||
useEffect(() => {
|
||||
loadProfile();
|
||||
}, [loadProfile]);
|
||||
|
||||
const handlePresetClick = (minutes: number) => {
|
||||
setSettings(prev => ({ ...prev, rotation_interval: minutes.toString() }));
|
||||
};
|
||||
const handleChange =
|
||||
(field: 'login' | 'password') =>
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
setAdminProfile((prev) => ({ ...prev, [field]: event.target.value }));
|
||||
};
|
||||
|
||||
const handleSaveSettings = async () => {
|
||||
if (intervalError) {
|
||||
setMsg({ open: true, text: 'Исправьте ошибки перед сохранением', type: 'error' });
|
||||
const handleSave = async () => {
|
||||
if (!adminProfile.login.trim()) {
|
||||
setMessage({ open: true, type: 'error', text: 'Login is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const data = cleanData();
|
||||
|
||||
try {
|
||||
await api.post('/settings', data);
|
||||
setMsg({ open: true, type: 'success', text: 'Настройки сохранены!' });
|
||||
} catch (e) {
|
||||
setMsg({ open: true, type: 'error', text: 'Ошибка сохранения' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdminChange = (prop: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setAdminProfile({ ...adminProfile, [prop]: event.target.value });
|
||||
};
|
||||
|
||||
const handleSaveAdmin = async () => {
|
||||
try {
|
||||
await api.post('/auth/update-profile', adminProfile);
|
||||
setMsg({ open: true, type: 'success', text: 'Профиль администратора обновлен!' });
|
||||
setAdminProfile(prev => ({ ...prev, password: '' }));
|
||||
} catch (e) {
|
||||
setMsg({ open: true, type: 'error', text: 'Ошибка обновления профиля' });
|
||||
setAdminProfile((prev) => ({ ...prev, password: '' }));
|
||||
setMessage({ open: true, type: 'success', text: 'Profile updated' });
|
||||
} catch (error) {
|
||||
Logger.error('Update profile error', 'Settings', error);
|
||||
setMessage({ open: true, type: 'error', text: 'Failed to update profile' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleForceRotate = async () => {
|
||||
if (confirm('ВНИМАНИЕ: Это немедленно обновит конфиги в подписках.\n\nИнтервал автоматической ротации НЕ будет сброшен.\n\nПродолжить?')) {
|
||||
try {
|
||||
setLoadingRotate(true);
|
||||
const res = await api.post('/rotation/rotate-all');
|
||||
|
||||
setLoadingRotate(false);
|
||||
if (res.data && res.data.success) {
|
||||
setMsg({ open: true, type: 'success', text: res.data.message || 'Ротация успешно выполнена!' });
|
||||
} else {
|
||||
setMsg({
|
||||
open: true,
|
||||
type: 'error',
|
||||
text: res.data?.message || 'Ошибка выполнения ротации'
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
setLoadingRotate(false);
|
||||
setMsg({ open: true, type: 'error', text: 'Ошибка сети или сервера' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const togglePause = async () => {
|
||||
const newStatus = settings.rotation_status === 'active' ? 'stopped' : 'active';
|
||||
const updatedSettings = { ...settings, rotation_status: newStatus };
|
||||
|
||||
setSettings(updatedSettings);
|
||||
|
||||
try {
|
||||
await api.post('/settings', updatedSettings);
|
||||
|
||||
} catch (e) {
|
||||
setSettings((prev: any) => ({ ...prev, rotation_status: settings.rotation_status }));
|
||||
setMsg({ open: true, type: 'error', text: 'Не удалось изменить статус' });
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (isoString: string) => {
|
||||
if (!isoString) return 'Нет данных';
|
||||
return new Date(+isoString).toLocaleString('ru-RU', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const getNextRotationDate = () => {
|
||||
if (settings.rotation_status === 'stopped') return 'Пауза';
|
||||
if (!settings.last_rotation_timestamp) return 'Ожидание...';
|
||||
|
||||
const last = new Date(+settings.last_rotation_timestamp);
|
||||
const intervalMinutes = parseInt(settings.rotation_interval) || 60;
|
||||
const next = new Date(last.getTime() + intervalMinutes * 60000);
|
||||
|
||||
return next.toLocaleString('ru-RU', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const isPaused = settings.rotation_status === 'stopped';
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>Настройки утилиты</Typography>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Настройки
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Grid container spacing={1}>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Typography variant="subtitle2" color="textSecondary" gutterBottom>
|
||||
Статус сервиса
|
||||
</Typography>
|
||||
{isPaused ?
|
||||
<Chip icon={<PauseCircleFilled />} label="Остановлен" color="warning" size="small" variant="outlined" /> :
|
||||
<Chip icon={<CheckCircle />} label="Активен" color="success" size="small" variant="outlined" />
|
||||
}
|
||||
|
||||
<Tooltip title={isPaused ? "Возобновить ротацию" : "Поставить на паузу"}>
|
||||
<IconButton
|
||||
onClick={togglePause}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
boxShadow: 2,
|
||||
'&:hover': { bgcolor: 'background.paper' },
|
||||
ml: 1
|
||||
}}
|
||||
>
|
||||
{isPaused ? <PlayCircleFilled fontSize="large" /> : <PauseCircleFilled fontSize="large" />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
{/* Последняя генерация */}
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="textSecondary">
|
||||
Последняя генерация
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500, mt: 2 }}>
|
||||
{formatDate(settings.last_rotation_timestamp)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
{/* Следующая генерация */}
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="textSecondary">
|
||||
Следующая генерация
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500, mt: 2 }}>
|
||||
{getNextRotationDate()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Paper sx={{ p: 3, height: '100%' }}>
|
||||
<Typography variant="h6" gutterBottom>Панель 3x-ui</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<TextField
|
||||
fullWidth margin="normal" label="URL панели"
|
||||
value={settings.xui_url} onChange={handleSettingChange('xui_url')}
|
||||
helperText="Например: https://my-vpn.com:2053/wfgpoVHaOF"
|
||||
/>
|
||||
<TextField
|
||||
fullWidth margin="normal" label="Логин 3x-ui"
|
||||
value={settings.xui_login} onChange={handleSettingChange('xui_login')}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth margin="normal" label="Пароль 3x-ui" type="password"
|
||||
value={settings.xui_password} onChange={handleSettingChange('xui_password')}
|
||||
/>
|
||||
|
||||
<Button variant="contained" sx={{ mt: 2 }} onClick={handleSaveSettings}>
|
||||
Сохранить подключение
|
||||
<Paper sx={{ p: 3, maxWidth: 680 }}>
|
||||
<Typography variant="h6">Профиль панели 3dp-manager</Typography>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="Логин"
|
||||
value={adminProfile.login}
|
||||
onChange={handleChange('login')}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Новый пароль"
|
||||
type="password"
|
||||
value={adminProfile.password}
|
||||
onChange={handleChange('password')}
|
||||
helperText="Оставьте пустым, если не хотите менять пароль"
|
||||
fullWidth
|
||||
/>
|
||||
<Box>
|
||||
<Button variant="contained" onClick={handleSave}>
|
||||
Сохранить
|
||||
</Button>
|
||||
{settings.xui_url && settings.xui_login && settings.xui_password && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="info"
|
||||
sx={{ mt: 2, ml: 2 }}
|
||||
onClick={handleCheckConnection}
|
||||
>
|
||||
Проверить
|
||||
</Button>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>Генерация инбаундов</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<TextField
|
||||
fullWidth margin="normal" label="Интервал генерации"
|
||||
type="number"
|
||||
value={settings.rotation_interval}
|
||||
onChange={handleSettingChange('rotation_interval')}
|
||||
slotProps={{
|
||||
input: { endAdornment: <InputAdornment position="end">мин</InputAdornment> }
|
||||
}}
|
||||
helperText="Как часто менять инбаунды (минимум 10 мин)"
|
||||
/>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1, mb: 2 }}>
|
||||
{ROTATION_PRESETS.map((preset) => (
|
||||
<Chip
|
||||
key={preset.value}
|
||||
label={preset.label}
|
||||
onClick={() => handlePresetClick(preset.value)}
|
||||
color={settings.rotation_interval === preset.value.toString() ? "primary" : "default"}
|
||||
variant={settings.rotation_interval === preset.value.toString() ? "filled" : "outlined"}
|
||||
clickable
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Button variant="contained" sx={{ mt: 2 }} onClick={handleSaveSettings}>
|
||||
Применить интервал
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
loading={loadingRotate}
|
||||
color="warning"
|
||||
onClick={handleForceRotate}
|
||||
sx={{ mt: 2, ml: 2 }}
|
||||
>
|
||||
Сгенерировать сейчас
|
||||
</Button>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>Доступ к 3DP-MANAGER</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<TextField
|
||||
fullWidth margin="normal" label="Логин администратора"
|
||||
value={adminProfile.login}
|
||||
onChange={handleAdminChange('login')}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth margin="normal" label="Новый пароль" type="password"
|
||||
value={adminProfile.password}
|
||||
onChange={handleAdminChange('password')}
|
||||
helperText="Оставьте пустым, если не хотите менять"
|
||||
/>
|
||||
<Button variant="contained" color="warning" sx={{ mt: 2 }} onClick={handleSaveAdmin}>
|
||||
Обновить профиль
|
||||
</Button>
|
||||
</Paper>
|
||||
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Snackbar open={msg.open} autoHideDuration={5000} onClose={() => setMsg({ ...msg, open: false })}>
|
||||
<Alert severity={msg.type}>{msg.text}</Alert>
|
||||
<Snackbar
|
||||
open={message.open}
|
||||
autoHideDuration={5000}
|
||||
onClose={() => setMessage({ ...message, open: false })}
|
||||
>
|
||||
<Alert severity={message.type}>{message.text}</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,65 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Box, Button, Typography, Paper, Table, TableBody, TableCell,
|
||||
TableHead, TableRow, IconButton, Dialog, DialogTitle,
|
||||
DialogContent, TextField, DialogActions,
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
FormControl,
|
||||
Select,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Menu,
|
||||
MenuItem,
|
||||
type SelectChangeEvent
|
||||
Paper,
|
||||
Select,
|
||||
Snackbar,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import { Delete, Add, Link as LinkIcon, Refresh, OpenInNew, ContentCopy, Dns, Router } from '@mui/icons-material';
|
||||
import {
|
||||
Add,
|
||||
ContentCopy,
|
||||
Delete,
|
||||
Edit,
|
||||
HelpOutline,
|
||||
Link as LinkIcon,
|
||||
MoreVert,
|
||||
OpenInNew,
|
||||
PauseCircleFilled,
|
||||
PlayCircleFilled,
|
||||
Refresh,
|
||||
Remove,
|
||||
} from '@mui/icons-material';
|
||||
import api from '../api';
|
||||
import { copyToClipboard } from '../utils/copyToClipboard';
|
||||
import { Logger } from '../utils/logger';
|
||||
import type { NodeRecord } from '../types/node';
|
||||
import { FlagOptionLabel } from '../utils/flags';
|
||||
|
||||
interface Subscription {
|
||||
id: string;
|
||||
name: string;
|
||||
uuid: string;
|
||||
inbounds: any[];
|
||||
inbounds: unknown[];
|
||||
inboundsConfig?: InboundConfigUI[];
|
||||
isAutoRotationEnabled?: boolean;
|
||||
}
|
||||
|
||||
interface Tunnel {
|
||||
@@ -25,135 +68,592 @@ interface Tunnel {
|
||||
ip: string;
|
||||
domain: string;
|
||||
isInstalled: boolean;
|
||||
nodeId?: string;
|
||||
}
|
||||
|
||||
const patchLink = function (link: string, newHost: string): string {
|
||||
if (link.startsWith('vmess://')) {
|
||||
try {
|
||||
const base64Part = link.substring(8);
|
||||
const jsonStr = Buffer.from(base64Part, 'base64').toString('utf-8');
|
||||
const config = JSON.parse(jsonStr);
|
||||
|
||||
config.add = newHost;
|
||||
|
||||
const newJsonStr = JSON.stringify(config);
|
||||
const newBase64 = Buffer.from(newJsonStr).toString('base64');
|
||||
return `vmess://${newBase64}`;
|
||||
} catch (e) {
|
||||
return link;
|
||||
}
|
||||
} else if (link.startsWith('vless://') || link.startsWith('trojan://')) {
|
||||
return link.replace(/@.*?:/, `@${newHost}:`);
|
||||
} else if (link.startsWith('ss://')) {
|
||||
if (link.includes('@')) {
|
||||
return link.replace(/@.*?:/, `@${newHost}:`);
|
||||
}
|
||||
return link;
|
||||
}
|
||||
|
||||
return link;
|
||||
interface InboundConfigUI {
|
||||
id: string;
|
||||
type: string;
|
||||
port: string;
|
||||
sni: string;
|
||||
link?: string;
|
||||
nodeId?: string;
|
||||
relayServerId?: string;
|
||||
flag?: string;
|
||||
name?: string;
|
||||
certificateFile?: string;
|
||||
keyFile?: string;
|
||||
}
|
||||
|
||||
interface Domain {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface CountryOption {
|
||||
name: string;
|
||||
code: string;
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
const CONNECTION_OPTIONS = [
|
||||
'vless-tcp-reality',
|
||||
'vless-xhttp-reality',
|
||||
'vless-grpc-reality',
|
||||
'vless-ws',
|
||||
'hysteria2-udp',
|
||||
'vmess-tcp',
|
||||
'shadowsocks-tcp',
|
||||
'trojan-tcp-reality',
|
||||
'custom',
|
||||
];
|
||||
|
||||
const generateId = () => Math.random().toString(36).substring(7);
|
||||
|
||||
const getSubscriptionUrl = (uuid: string) => {
|
||||
const path = `/bus/${uuid}`;
|
||||
return typeof window !== 'undefined' ? `${window.location.origin}${path}` : path;
|
||||
};
|
||||
|
||||
export default function SubscriptionsPage() {
|
||||
const [subs, setSubs] = useState<Subscription[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
||||
|
||||
const [selectedServer, setSelectedServer] = useState<string | number>('main');
|
||||
|
||||
const [nodes, setNodes] = useState<NodeRecord[]>([]);
|
||||
const [domains, setDomains] = useState<Domain[]>([]);
|
||||
const [countries, setCountries] = useState<CountryOption[]>([]);
|
||||
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [activeSub, setActiveSub] = useState<Subscription | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [inbounds, setInbounds] = useState<InboundConfigUI[]>([]);
|
||||
const [portErrors, setPortErrors] = useState<Record<string, string>>({});
|
||||
const [linksOpen, setLinksOpen] = useState(false);
|
||||
const [currentLinks, setCurrentLinks] = useState<string[]>([]);
|
||||
const [createdSubscriptionId, setCreatedSubscriptionId] = useState<string | null>(null);
|
||||
const [rotationLoading, setRotationLoading] = useState(false);
|
||||
const [rotationSettings, setRotationSettings] = useState({
|
||||
rotation_interval: '30',
|
||||
rotation_status: 'active',
|
||||
last_rotation_timestamp: '',
|
||||
});
|
||||
const [snackbar, setSnackbar] = useState({
|
||||
open: false,
|
||||
type: 'success' as 'success' | 'error',
|
||||
message: '',
|
||||
});
|
||||
const [confirmDialog, setConfirmDialog] = useState({
|
||||
open: false,
|
||||
title: '',
|
||||
confirmText: 'Удалить',
|
||||
confirmColor: 'error' as 'error' | 'primary',
|
||||
onConfirm: () => {},
|
||||
});
|
||||
|
||||
useEffect(() => { loadSubs(); }, []);
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const openActionMenu = Boolean(menuAnchorEl);
|
||||
|
||||
const loadSubs = async () => {
|
||||
const { data } = await api.get('/subscriptions');
|
||||
setSubs(data);
|
||||
const tunnelsRes = await api.get('/tunnels');
|
||||
setTunnels(tunnelsRes.data.filter((el: Tunnel) => el.isInstalled));
|
||||
};
|
||||
const loadSubs = useCallback(async () => {
|
||||
try {
|
||||
const [subsRes, tunnelsRes, nodesRes, domainsRes, countriesRes, settingsRes] =
|
||||
await Promise.all([
|
||||
api.get('/subscriptions'),
|
||||
api.get('/tunnels'),
|
||||
api.get<NodeRecord[]>('/nodes'),
|
||||
api.get('/domains/all'),
|
||||
api.get<CountryOption[]>('/settings/countries'),
|
||||
api.get('/settings'),
|
||||
]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
await api.post('/subscriptions', { name });
|
||||
setOpen(false);
|
||||
setName('');
|
||||
setSubs(Array.isArray(subsRes.data) ? subsRes.data : []);
|
||||
setTunnels(
|
||||
Array.isArray(tunnelsRes.data)
|
||||
? tunnelsRes.data.filter((tunnel: Tunnel) => tunnel.isInstalled)
|
||||
: [],
|
||||
);
|
||||
setNodes(Array.isArray(nodesRes.data) ? nodesRes.data : []);
|
||||
setDomains(Array.isArray(domainsRes.data) ? domainsRes.data : []);
|
||||
setCountries(Array.isArray(countriesRes.data) ? countriesRes.data : []);
|
||||
setRotationSettings((prev) => ({ ...prev, ...settingsRes.data }));
|
||||
} catch (error) {
|
||||
Logger.error('Failed to load subscriptions data', 'Subs', error);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadSubs();
|
||||
}, [loadSubs]);
|
||||
|
||||
const getDefaultNodeId = () =>
|
||||
nodes.find((node) => node.isMain)?.id || nodes[0]?.id || '';
|
||||
|
||||
const getNodeAddress = (nodeId?: string) => {
|
||||
const node = nodes.find((item) => item.id === (nodeId || getDefaultNodeId()));
|
||||
if (!node) return '';
|
||||
if (node.domain) return node.domain;
|
||||
if (node.ip) return node.ip;
|
||||
if (node.host) return node.host;
|
||||
try {
|
||||
return new URL(node.url).hostname;
|
||||
} catch {
|
||||
return node.url;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (confirm('Удалить подписку и все соединения?')) {
|
||||
await api.delete(`/subscriptions/${id}`);
|
||||
const getHysteriaCertDefaults = (nodeId?: string) => {
|
||||
const address = getNodeAddress(nodeId);
|
||||
return {
|
||||
certificateFile: address ? `/root/cert/${address}/fullchain.pem` : '',
|
||||
keyFile: address ? `/root/cert/${address}/privkey.pem` : '',
|
||||
};
|
||||
};
|
||||
|
||||
const getNodeFlag = (nodeId?: string) =>
|
||||
nodes.find((node) => node.id === (nodeId || getDefaultNodeId()))?.flag || '';
|
||||
|
||||
const getRelayOptions = (nodeId?: string) =>
|
||||
tunnels.filter((tunnel) => tunnel.nodeId === (nodeId || getDefaultNodeId()));
|
||||
|
||||
const isValidPort = (value: string) =>
|
||||
value === 'random' || (/^\d+$/.test(value) && Number(value) >= 1 && Number(value) <= 65535);
|
||||
|
||||
const createInbound = (type = 'vless-tcp-reality'): InboundConfigUI => {
|
||||
const nodeId = getDefaultNodeId();
|
||||
const certDefaults = type === 'hysteria2-udp' ? getHysteriaCertDefaults(nodeId) : {};
|
||||
return {
|
||||
id: generateId(),
|
||||
type,
|
||||
port: 'random',
|
||||
sni: type === 'hysteria2-udp' ? '' : 'random',
|
||||
link: '',
|
||||
nodeId,
|
||||
flag: getNodeFlag(nodeId),
|
||||
name: '',
|
||||
...certDefaults,
|
||||
};
|
||||
};
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
if (nodes.length === 0) {
|
||||
setSnackbar({ open: true, type: 'error', message: 'Создайте хотя бы одну ноду!' });
|
||||
return;
|
||||
}
|
||||
if (domains.length === 0) {
|
||||
setSnackbar({ open: true, type: 'error', message: 'Создайте хотя бы один домен!' });
|
||||
return;
|
||||
}
|
||||
|
||||
setEditingId(null);
|
||||
setName('');
|
||||
setInbounds([
|
||||
createInbound('hysteria2-udp'),
|
||||
createInbound('vless-xhttp-reality'),
|
||||
createInbound('vless-tcp-reality'),
|
||||
createInbound('vless-tcp-reality'),
|
||||
createInbound('vless-tcp-reality'),
|
||||
createInbound('vless-tcp-reality'),
|
||||
createInbound('vless-grpc-reality'),
|
||||
createInbound('vless-ws'),
|
||||
createInbound('vmess-tcp'),
|
||||
createInbound('shadowsocks-tcp'),
|
||||
]);
|
||||
setPortErrors({});
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEdit = (sub: Subscription) => {
|
||||
setEditingId(sub.id);
|
||||
setName(sub.name);
|
||||
setInbounds(
|
||||
(sub.inboundsConfig?.length ? sub.inboundsConfig : [createInbound()]).map((item) => {
|
||||
const nodeId = item.nodeId || getDefaultNodeId();
|
||||
const certDefaults = getHysteriaCertDefaults(nodeId);
|
||||
return {
|
||||
id: generateId(),
|
||||
type: item.type || 'vless-tcp-reality',
|
||||
port: item.port ? item.port.toString() : 'random',
|
||||
sni: item.type === 'hysteria2-udp' ? '' : item.sni || 'random',
|
||||
link: item.link || '',
|
||||
nodeId,
|
||||
relayServerId: item.relayServerId ? item.relayServerId.toString() : '',
|
||||
flag: item.flag || getNodeFlag(nodeId),
|
||||
name: item.name || '',
|
||||
certificateFile:
|
||||
item.type === 'hysteria2-udp'
|
||||
? item.certificateFile || certDefaults.certificateFile
|
||||
: undefined,
|
||||
keyFile:
|
||||
item.type === 'hysteria2-udp' ? item.keyFile || certDefaults.keyFile : undefined,
|
||||
};
|
||||
}),
|
||||
);
|
||||
setPortErrors({});
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleInboundChange = (id: string, field: keyof InboundConfigUI, value: string) => {
|
||||
setInbounds((prev) =>
|
||||
prev.map((inbound) => {
|
||||
if (inbound.id !== id) return inbound;
|
||||
const next = { ...inbound, [field]: value };
|
||||
|
||||
if (field === 'nodeId') {
|
||||
next.relayServerId = '';
|
||||
next.flag = getNodeFlag(value);
|
||||
if (next.type === 'hysteria2-udp') {
|
||||
Object.assign(next, getHysteriaCertDefaults(value));
|
||||
}
|
||||
}
|
||||
|
||||
if (field === 'type' && value === 'custom') {
|
||||
next.nodeId = '';
|
||||
next.relayServerId = '';
|
||||
next.flag = '';
|
||||
next.name = '';
|
||||
next.certificateFile = '';
|
||||
next.keyFile = '';
|
||||
}
|
||||
|
||||
if (field === 'type' && value === 'hysteria2-udp') {
|
||||
next.sni = '';
|
||||
const defaults = getHysteriaCertDefaults(next.nodeId);
|
||||
next.certificateFile = next.certificateFile || defaults.certificateFile;
|
||||
next.keyFile = next.keyFile || defaults.keyFile;
|
||||
}
|
||||
|
||||
if (field === 'type' && value !== 'custom' && !next.nodeId) {
|
||||
next.nodeId = getDefaultNodeId();
|
||||
next.flag = getNodeFlag(next.nodeId);
|
||||
}
|
||||
|
||||
return next;
|
||||
}),
|
||||
);
|
||||
|
||||
if (field === 'port') {
|
||||
setPortErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
if (isValidPort(value)) delete next[id];
|
||||
else next[id] = 'Порт: число 1-65535 или random';
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addInbound = () => {
|
||||
if (inbounds.length < 20) setInbounds((prev) => [...prev, createInbound()]);
|
||||
};
|
||||
|
||||
const removeInbound = (id?: string) => {
|
||||
if (!id) {
|
||||
setInbounds([{ ...createInbound(), id: crypto.randomUUID() }]);
|
||||
setPortErrors({});
|
||||
return;
|
||||
}
|
||||
if (inbounds.length <= 1) return;
|
||||
setInbounds((prev) => prev.filter((inbound) => inbound.id !== id));
|
||||
setPortErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const nextPortErrors = inbounds.reduce<Record<string, string>>((acc, inbound) => {
|
||||
if (inbound.type !== 'custom' && !isValidPort(inbound.port)) {
|
||||
acc[inbound.id] = 'Порт: число 1-65535 или random';
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
setPortErrors(nextPortErrors);
|
||||
|
||||
if (Object.keys(nextPortErrors).length > 0) {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
type: 'error',
|
||||
message: 'Пожалуйста, исправьте ошибки с портами',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!name.trim()) {
|
||||
setSnackbar({ open: true, type: 'error', message: 'Введите имя подписки' });
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
inboundsConfig: inbounds.map((inbound) =>
|
||||
inbound.type === 'custom'
|
||||
? { type: inbound.type, link: inbound.link }
|
||||
: {
|
||||
type: inbound.type,
|
||||
port: inbound.port === 'random' ? 'random' : parseInt(inbound.port, 10),
|
||||
sni: inbound.type === 'hysteria2-udp' ? undefined : inbound.sni,
|
||||
nodeId: inbound.nodeId || undefined,
|
||||
relayServerId: inbound.relayServerId
|
||||
? parseInt(inbound.relayServerId, 10)
|
||||
: undefined,
|
||||
flag: inbound.flag || getNodeFlag(inbound.nodeId) || undefined,
|
||||
name: inbound.name?.trim() || undefined,
|
||||
certificateFile:
|
||||
inbound.type === 'hysteria2-udp'
|
||||
? inbound.certificateFile?.trim() || undefined
|
||||
: undefined,
|
||||
keyFile:
|
||||
inbound.type === 'hysteria2-udp'
|
||||
? inbound.keyFile?.trim() || undefined
|
||||
: undefined,
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
try {
|
||||
if (editingId) {
|
||||
await api.put(`/subscriptions/${editingId}`, payload);
|
||||
} else {
|
||||
const res = await api.post<{ id?: string }>('/subscriptions', payload);
|
||||
setCreatedSubscriptionId(res.data?.id || null);
|
||||
}
|
||||
setOpen(false);
|
||||
loadSubs();
|
||||
setSnackbar({
|
||||
open: true,
|
||||
type: 'success',
|
||||
message: editingId ? 'Подписка обновлена' : 'Подписка создана',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
(error as { response?: { data?: { message?: string } } })?.response?.data?.message ||
|
||||
'Произошла ошибка при сохранении';
|
||||
setSnackbar({ open: true, type: 'error', message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setConfirmDialog({
|
||||
open: true,
|
||||
title: 'Удалить подписку и все соединения?',
|
||||
confirmText: 'Удалить',
|
||||
confirmColor: 'error',
|
||||
onConfirm: async () => {
|
||||
await api.delete(`/subscriptions/${id}`);
|
||||
loadSubs();
|
||||
setSnackbar({ open: true, type: 'success', message: 'Подписка удалена' });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleAutoRotation = async (subscriptionId: string, enabled: boolean) => {
|
||||
try {
|
||||
await api.put('/subscriptions/bulk-auto-rotation', {
|
||||
subscriptionIds: [subscriptionId],
|
||||
enabled,
|
||||
});
|
||||
setSubs((prev) =>
|
||||
prev.map((sub) =>
|
||||
sub.id === subscriptionId ? { ...sub, isAutoRotationEnabled: enabled } : sub,
|
||||
),
|
||||
);
|
||||
setSnackbar({
|
||||
open: true,
|
||||
type: 'success',
|
||||
message: enabled ? 'Авторотация включена' : 'Авторотация выключена',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
(error as { response?: { data?: { message?: string } } })?.response?.data?.message ||
|
||||
'Ошибка обновления';
|
||||
setSnackbar({ open: true, type: 'error', message });
|
||||
loadSubs();
|
||||
}
|
||||
};
|
||||
|
||||
const handleManualRotate = (sub: Subscription) => {
|
||||
setConfirmDialog({
|
||||
open: true,
|
||||
title: `Обновить подписку "${sub.name}" сейчас?`,
|
||||
confirmText: 'Обновить',
|
||||
confirmColor: 'primary',
|
||||
onConfirm: async () => {
|
||||
const res = await api.post(`/rotation/rotate-one/${sub.id}`);
|
||||
setSnackbar({
|
||||
open: true,
|
||||
type: res.data?.success ? 'success' : 'error',
|
||||
message: res.data?.message || 'Ротация выполнена',
|
||||
});
|
||||
loadSubs();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const saveRotationSettings = async (nextSettings = rotationSettings) => {
|
||||
await api.post('/settings', nextSettings);
|
||||
setRotationSettings(nextSettings);
|
||||
};
|
||||
|
||||
const toggleRotationService = async () => {
|
||||
const nextStatus = rotationSettings.rotation_status === 'stopped' ? 'active' : 'stopped';
|
||||
const nextSettings = { ...rotationSettings, rotation_status: nextStatus };
|
||||
await saveRotationSettings(nextSettings);
|
||||
setSnackbar({
|
||||
open: true,
|
||||
type: 'success',
|
||||
message: nextStatus === 'active' ? 'Ротация включена' : 'Ротация остановлена',
|
||||
});
|
||||
};
|
||||
|
||||
const saveRotationInterval = async () => {
|
||||
const interval = parseInt(rotationSettings.rotation_interval, 10);
|
||||
if (Number.isNaN(interval) || interval < 10) {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
type: 'error',
|
||||
message: 'Минимальный интервал ротации - 10 минут',
|
||||
});
|
||||
return;
|
||||
}
|
||||
await saveRotationSettings(rotationSettings);
|
||||
setSnackbar({ open: true, type: 'success', message: 'Интервал ротации сохранен' });
|
||||
};
|
||||
|
||||
const rotateAllNow = () => {
|
||||
setConfirmDialog({
|
||||
open: true,
|
||||
title: 'Сгенерировать инбаунды сейчас для всех активных подписок?',
|
||||
confirmText: 'Сгенерировать',
|
||||
confirmColor: 'primary',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
setRotationLoading(true);
|
||||
const { data } = await api.post('/rotation/rotate-all');
|
||||
setSnackbar({
|
||||
open: true,
|
||||
type: data?.success ? 'success' : 'error',
|
||||
message: data?.message || 'Ротация завершена',
|
||||
});
|
||||
loadSubs();
|
||||
} finally {
|
||||
setRotationLoading(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const formatRotationDate = (value: string) => {
|
||||
if (!value) return 'Нет данных';
|
||||
return new Date(Number(value)).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const getNextRotationDate = () => {
|
||||
if (rotationSettings.rotation_status === 'stopped') return 'Пауза';
|
||||
if (!rotationSettings.last_rotation_timestamp) return 'Ожидание';
|
||||
const interval = parseInt(rotationSettings.rotation_interval, 10) || 30;
|
||||
return new Date(Number(rotationSettings.last_rotation_timestamp) + interval * 60000).toLocaleString(
|
||||
'ru-RU',
|
||||
{ day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' },
|
||||
);
|
||||
};
|
||||
|
||||
const showLinks = (sub: Subscription) => {
|
||||
let links = [];
|
||||
if (selectedServer === 'main') {
|
||||
links = sub.inbounds?.map(i => i.link).filter(Boolean) || [];
|
||||
} else {
|
||||
const host = tunnels[+selectedServer - 1].domain.length > 0 ? tunnels[+selectedServer - 1].domain : tunnels[+selectedServer - 1].ip;
|
||||
links = sub.inbounds?.map(i => patchLink(i.link, host)).filter(Boolean) || [];
|
||||
}
|
||||
if (links.length === 0) {
|
||||
setCurrentLinks(['Нет активных ссылок (ждите ротации)']);
|
||||
} else {
|
||||
setCurrentLinks(links);
|
||||
}
|
||||
const links = sub.inbounds?.map((item) => (item as { link?: string }).link).filter(Boolean) || [];
|
||||
setCurrentLinks(links.length ? links : ['Нет активных ссылок (ждите ротации)']);
|
||||
setLinksOpen(true);
|
||||
};
|
||||
|
||||
const handleServerChange = (event: SelectChangeEvent<any>) => {
|
||||
setSelectedServer(event.target.value as string);
|
||||
const handleCopyLink = async (uuid: string) => {
|
||||
await copyToClipboard(getSubscriptionUrl(uuid));
|
||||
setSnackbar({ open: true, type: 'success', message: 'Ссылка на подписку скопирована' });
|
||||
};
|
||||
|
||||
const handleGenerateCreatedSubscription = async () => {
|
||||
if (!createdSubscriptionId) return;
|
||||
const res = await api.post(`/rotation/rotate-one/${createdSubscriptionId}`);
|
||||
setSnackbar({
|
||||
open: true,
|
||||
type: res.data?.success ? 'success' : 'error',
|
||||
message: res.data?.message || 'Ротация выполнена',
|
||||
});
|
||||
setCreatedSubscriptionId(null);
|
||||
loadSubs();
|
||||
};
|
||||
|
||||
const openActionMenuFor = (event: React.MouseEvent<HTMLButtonElement>, sub: Subscription) => {
|
||||
setMenuAnchorEl(event.currentTarget);
|
||||
setActiveSub(sub);
|
||||
};
|
||||
|
||||
const closeActionMenu = () => {
|
||||
setMenuAnchorEl(null);
|
||||
setActiveSub(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Typography variant="h4">Подписки</Typography>
|
||||
{tunnels.length > 0 && (
|
||||
<FormControl variant='standard' size="small" sx={{ minWidth: 220, justifyContent: 'center' }}>
|
||||
<Select
|
||||
labelId="server-select-label"
|
||||
value={selectedServer}
|
||||
onChange={handleServerChange}
|
||||
startAdornment={
|
||||
<InputAdornment position="start">
|
||||
{selectedServer === 'main' ? <Dns fontSize="small" /> : <Router fontSize="small" />}
|
||||
</InputAdornment>
|
||||
}
|
||||
>
|
||||
<MenuItem value="main">
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>Основной сервер</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
|
||||
{tunnels.map((t) => (
|
||||
<MenuItem key={t.id} value={t.id.toString()}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{t.name}</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
<Box>
|
||||
<Button startIcon={<Refresh />} onClick={loadSubs} sx={{ mr: 1 }}>Обновить</Button>
|
||||
<Button variant="contained" startIcon={<Add />} onClick={() => setOpen(true)}>Создать</Button>
|
||||
</Box>
|
||||
<Typography variant={isMobile ? 'h5' : 'h4'}>Подписки</Typography>
|
||||
<Button variant="contained" startIcon={<Add />} onClick={handleOpenCreate}>
|
||||
Создать
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ p: 2, mb: 3 }}>
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems={{ xs: 'stretch', md: 'center' }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="text.secondary">Статус ротации</Typography>
|
||||
<Chip
|
||||
icon={rotationSettings.rotation_status === 'stopped' ? <PauseCircleFilled /> : <PlayCircleFilled />}
|
||||
label={rotationSettings.rotation_status === 'stopped' ? 'Остановлена' : 'Активна'}
|
||||
color={rotationSettings.rotation_status === 'stopped' ? 'warning' : 'success'}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
<Tooltip title={rotationSettings.rotation_status === 'stopped' ? 'Возобновить ротацию' : 'Поставить на паузу'}>
|
||||
<IconButton onClick={toggleRotationService} size="small">
|
||||
{rotationSettings.rotation_status === 'stopped' ? <PlayCircleFilled fontSize="large" /> : <PauseCircleFilled fontSize="large" />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Divider flexItem orientation={isMobile ? 'horizontal' : 'vertical'} />
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="text.secondary">Последняя генерация</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>{formatRotationDate(rotationSettings.last_rotation_timestamp)}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="text.secondary">Следующая генерация</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>{getNextRotationDate()}</Typography>
|
||||
</Box>
|
||||
<TextField
|
||||
label="Интервал, мин"
|
||||
type="number"
|
||||
size="small"
|
||||
value={rotationSettings.rotation_interval}
|
||||
onChange={(e) => setRotationSettings((prev) => ({ ...prev, rotation_interval: e.target.value }))}
|
||||
sx={{ width: { xs: '100%', md: 150 } }}
|
||||
/>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Button variant="outlined" onClick={saveRotationInterval}>Сохранить интервал</Button>
|
||||
<Button variant="contained" disabled={rotationLoading} onClick={rotateAllNow}>Обновить все</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper>
|
||||
<Paper sx={{ overflowX: 'auto' }}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Имя</TableCell>
|
||||
<TableCell>UUID</TableCell>
|
||||
<TableCell>Инбаунды</TableCell>
|
||||
<TableCell>Авторотация</TableCell>
|
||||
<TableCell align="right">Действия</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
@@ -163,68 +663,201 @@ export default function SubscriptionsPage() {
|
||||
<TableCell sx={{ fontWeight: 700 }}>{sub.name}</TableCell>
|
||||
<TableCell sx={{ fontFamily: 'monospace' }}>{sub.uuid}</TableCell>
|
||||
<TableCell>{sub.inbounds?.length || 0}</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={sub.isAutoRotationEnabled ?? true}
|
||||
onChange={(e) => handleToggleAutoRotation(sub.id, e.target.checked)}
|
||||
color="primary"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={() => navigator.clipboard.writeText(selectedServer === 'main' ? `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}` : `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}/${selectedServer}`)}
|
||||
title="Копировать ссылку"
|
||||
>
|
||||
<ContentCopy />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={() => window.open(selectedServer === 'main' ? `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}` : `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}/${selectedServer}`, '_blank')}
|
||||
title="Открыть подписку"
|
||||
>
|
||||
<OpenInNew />
|
||||
</IconButton>
|
||||
<IconButton color="primary" onClick={() => showLinks(sub)} title="Показать конфиги">
|
||||
<LinkIcon />
|
||||
</IconButton>
|
||||
<IconButton color="primary" onClick={() => handleDelete(sub.id)} title="Удалить">
|
||||
<Delete />
|
||||
</IconButton>
|
||||
{!isMobile && (
|
||||
<>
|
||||
<IconButton color="primary" onClick={() => handleCopyLink(sub.uuid)} title="Копировать ссылку">
|
||||
<ContentCopy />
|
||||
</IconButton>
|
||||
<IconButton color="primary" onClick={() => window.open(getSubscriptionUrl(sub.uuid), '_blank')} title="Открыть подписку">
|
||||
<OpenInNew />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
<IconButton onClick={(e) => openActionMenuFor(e, sub)}><MoreVert /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{subs.length === 0 && (
|
||||
<Typography sx={{ p: 2 }} color="textSecondary" textAlign="center">
|
||||
Нет подписок
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Dialog open={open} onClose={() => setOpen(false)} disableRestoreFocus>
|
||||
<DialogTitle>Новая подписка</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus margin="dense" label="Имя пользователя" fullWidth
|
||||
value={name} onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleCreate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Menu anchorEl={menuAnchorEl} open={openActionMenu} onClose={closeActionMenu}>
|
||||
{isMobile && activeSub && <MenuItem onClick={() => handleCopyLink(activeSub.uuid)}><ListItemIcon><ContentCopy fontSize="small" color="primary" /></ListItemIcon><ListItemText>Копировать ссылку</ListItemText></MenuItem>}
|
||||
{isMobile && activeSub && <MenuItem onClick={() => window.open(getSubscriptionUrl(activeSub.uuid), '_blank')}><ListItemIcon><OpenInNew fontSize="small" color="primary" /></ListItemIcon><ListItemText>Открыть подписку</ListItemText></MenuItem>}
|
||||
{activeSub && <MenuItem onClick={() => showLinks(activeSub)}><ListItemIcon><LinkIcon fontSize="small" /></ListItemIcon><ListItemText>Показать конфиги</ListItemText></MenuItem>}
|
||||
{activeSub && <MenuItem onClick={() => handleManualRotate(activeSub)}><ListItemIcon><Refresh fontSize="small" color="primary" /></ListItemIcon><ListItemText>Обновить сейчас</ListItemText></MenuItem>}
|
||||
{activeSub && <MenuItem onClick={() => handleOpenEdit(activeSub)}><ListItemIcon><Edit fontSize="small" /></ListItemIcon><ListItemText>Редактировать</ListItemText></MenuItem>}
|
||||
{activeSub && <MenuItem onClick={() => handleDelete(activeSub.id)}><ListItemIcon><Delete fontSize="small" color="error" /></ListItemIcon><ListItemText sx={{ color: 'error.main' }}>Удалить</ListItemText></MenuItem>}
|
||||
</Menu>
|
||||
|
||||
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="md" fullWidth disableRestoreFocus>
|
||||
<DialogTitle variant="h5">{editingId ? 'Редактировать подписку' : 'Новая подписка'}</DialogTitle>
|
||||
<DialogContent dividers sx={{ maxHeight: '72vh' }}>
|
||||
<TextField autoFocus margin="dense" label="Имя подписки" fullWidth value={name} onChange={(e) => setName(e.target.value)} sx={{ mb: 2 }} />
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>Инбаунды ({inbounds.length}/20)</Typography>
|
||||
<Box sx={{ maxHeight: '52vh', overflow: 'auto', pr: 1 }}>
|
||||
{inbounds.map((inbound, index) => (
|
||||
<Box
|
||||
key={inbound.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 2,
|
||||
mb: 2,
|
||||
p: 2,
|
||||
flexWrap: 'nowrap',
|
||||
width: 'fit-content',
|
||||
minWidth: inbound.type === 'custom' ? 780 : inbound.type === 'hysteria2-udp' ? 1540 : 1260,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ mt: 1, width: 34, flexShrink: 0, fontWeight: 'bold' }}>#{index + 1}</Typography>
|
||||
<FormControl size="small" sx={{ width: 185, flexShrink: 0 }}>
|
||||
<InputLabel>Тип</InputLabel>
|
||||
<Select value={inbound.type} label="Тип" onChange={(e) => handleInboundChange(inbound.id, 'type', e.target.value)}>
|
||||
{CONNECTION_OPTIONS.map((option) => <MenuItem key={option} value={option}>{option}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{inbound.type === 'custom' ? (
|
||||
<TextField size="small" label="Ссылка на подключение" placeholder="vless://..." value={inbound.link || ''} onChange={(e) => handleInboundChange(inbound.id, 'link', e.target.value)} sx={{ width: 460, flexShrink: 0 }} />
|
||||
) : (
|
||||
<>
|
||||
<FormControl size="small" sx={{ width: 170, flexShrink: 0 }}>
|
||||
<InputLabel>Нода</InputLabel>
|
||||
<Select value={inbound.nodeId || ''} label="Нода" onChange={(e) => handleInboundChange(inbound.id, 'nodeId', e.target.value)}>
|
||||
<MenuItem value="">Основная нода</MenuItem>
|
||||
{nodes.map((node) => <MenuItem key={node.id} value={node.id}>{node.name}{node.isMain ? ' (основная)' : ''}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ width: 170, flexShrink: 0 }}>
|
||||
<InputLabel>Relay</InputLabel>
|
||||
<Select value={inbound.relayServerId || ''} label="Relay" onChange={(e) => handleInboundChange(inbound.id, 'relayServerId', e.target.value)}>
|
||||
<MenuItem value="">Без relay</MenuItem>
|
||||
{getRelayOptions(inbound.nodeId).map((tunnel) => <MenuItem key={tunnel.id} value={tunnel.id.toString()}>{tunnel.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ width: 170, flexShrink: 0 }}>
|
||||
<InputLabel>Флаг</InputLabel>
|
||||
<Select value={inbound.flag || getNodeFlag(inbound.nodeId)} label="Флаг" onChange={(e) => handleInboundChange(inbound.id, 'flag', e.target.value)} renderValue={(value) => <FlagOptionLabel flag={value} label={countries.find((country) => country.emoji === value)?.name || 'Флаг'} />}>
|
||||
<MenuItem value="">Без флага</MenuItem>
|
||||
{countries.map((country) => <MenuItem key={country.code} value={country.emoji}><FlagOptionLabel flag={country.emoji} code={country.code} label={country.name} /></MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField size="small" label="Название" value={inbound.name || ''} onChange={(e) => handleInboundChange(inbound.id, 'name', e.target.value)} sx={{ width: 180, flexShrink: 0 }} />
|
||||
<TextField size="small" label="Порт" placeholder="random или порт" value={inbound.port} onChange={(e) => handleInboundChange(inbound.id, 'port', e.target.value)} error={!!portErrors[inbound.id]} helperText={portErrors[inbound.id] || ''} sx={{ width: 150, flexShrink: 0 }} />
|
||||
{inbound.type === 'hysteria2-udp' && (
|
||||
<>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Сертификат"
|
||||
value={inbound.certificateFile || ''}
|
||||
onChange={(e) => handleInboundChange(inbound.id, 'certificateFile', e.target.value)}
|
||||
sx={{ width: 360, flexShrink: 0 }}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<Tooltip title="Путь к сертификату должен существовать на выбранной ноде. По умолчанию используется путь Let's Encrypt.">
|
||||
<HelpOutline fontSize="small" color="action" />
|
||||
</Tooltip>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Приватный ключ"
|
||||
value={inbound.keyFile || ''}
|
||||
onChange={(e) => handleInboundChange(inbound.id, 'keyFile', e.target.value)}
|
||||
sx={{ width: 360, flexShrink: 0 }}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<Tooltip title="Путь к приватному ключу должен существовать на выбранной ноде. По умолчанию используется путь Let's Encrypt.">
|
||||
<HelpOutline fontSize="small" color="action" />
|
||||
</Tooltip>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{inbound.type !== 'hysteria2-udp' && (
|
||||
<FormControl size="small" sx={{ width: 150, flexShrink: 0 }}>
|
||||
<InputLabel>SNI</InputLabel>
|
||||
<Select value={inbound.sni} label="SNI" onChange={(e) => handleInboundChange(inbound.id, 'sni', e.target.value)}>
|
||||
<MenuItem value="random">random</MenuItem>
|
||||
{domains.map((domain) => <MenuItem key={domain.id} value={domain.name}>{domain.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<IconButton color="primary" onClick={() => removeInbound(inbound.id)} disabled={inbounds.length <= 1} sx={{ mt: 0.5, flexShrink: 0 }}>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Button variant="outlined" size="small" startIcon={<Add />} onClick={addInbound} disabled={inbounds.length >= 20}>Добавить инбаунд</Button>
|
||||
<Button variant="outlined" color="error" size="small" startIcon={<Remove />} sx={{ ml: 0.5 }} onClick={() => removeInbound()}>Удалить все</Button>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpen(false)}>Отмена</Button>
|
||||
<Button onClick={handleCreate}>Создать</Button>
|
||||
<Button onClick={handleSave} variant="contained" color="primary">Сохранить</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={linksOpen} onClose={() => setLinksOpen(false)} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Активные ссылки</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
multiline fullWidth rows={10}
|
||||
value={currentLinks.join('\n\n')}
|
||||
slotProps={{ input: { readOnly: true, sx: { fontFamily: 'monospace', fontSize: 12 } } }}
|
||||
/>
|
||||
<TextField multiline fullWidth rows={10} value={currentLinks.join('\n\n')} slotProps={{ input: { readOnly: true, sx: { fontFamily: 'monospace', fontSize: 12 } } }} />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => navigator.clipboard.writeText(currentLinks.join('\n'))}>Копировать все</Button>
|
||||
<Button onClick={() => copyToClipboard(currentLinks.join('\n'))}>Копировать все</Button>
|
||||
<Button onClick={() => setLinksOpen(false)}>Закрыть</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={confirmDialog.open} onClose={() => setConfirmDialog({ ...confirmDialog, open: false })}>
|
||||
<DialogTitle>Подтверждение</DialogTitle>
|
||||
<DialogContent><Typography>{confirmDialog.title}</Typography></DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setConfirmDialog({ ...confirmDialog, open: false })}>Отмена</Button>
|
||||
<Button onClick={() => { confirmDialog.onConfirm(); setConfirmDialog({ ...confirmDialog, open: false }); }} variant="contained" color={confirmDialog.confirmColor}>
|
||||
{confirmDialog.confirmText}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={createdSubscriptionId !== null} onClose={() => setCreatedSubscriptionId(null)}>
|
||||
<DialogTitle>Подписка создана</DialogTitle>
|
||||
<DialogContent><Typography>Сгенерировать подключения сейчас?</Typography></DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setCreatedSubscriptionId(null)}>Нет</Button>
|
||||
<Button onClick={handleGenerateCreatedSubscription} variant="contained">Да</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Snackbar open={snackbar.open} autoHideDuration={6000} onClose={() => setSnackbar({ ...snackbar, open: false })} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}>
|
||||
<Alert onClose={() => setSnackbar({ ...snackbar, open: false })} severity={snackbar.type} sx={{ width: '100%' }}>
|
||||
{snackbar.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,145 +1,339 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { type ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Box, Button, Typography, Paper, Table, TableBody, TableCell,
|
||||
TableHead, TableRow, IconButton, Dialog, DialogTitle,
|
||||
DialogContent, TextField, DialogActions, Chip, CircularProgress
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Select,
|
||||
Snackbar,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import { Delete, Add, Terminal, CheckCircle, Error, Dns } from '@mui/icons-material';
|
||||
import { Add, CheckCircle, Delete, Dns, Error, Terminal } from '@mui/icons-material';
|
||||
import api from '../api';
|
||||
import { getApiErrorMessage } from '../utils/errorHandlers';
|
||||
import { Logger } from '../utils/logger';
|
||||
import type { NodeRecord } from '../types/node';
|
||||
|
||||
interface Tunnel {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
domain?: string;
|
||||
sshPort: number;
|
||||
username: string;
|
||||
isInstalled: boolean;
|
||||
nodeId?: string;
|
||||
node?: NodeRecord;
|
||||
}
|
||||
|
||||
const emptyForm = {
|
||||
name: '',
|
||||
nodeId: '',
|
||||
ip: '',
|
||||
sshPort: 22,
|
||||
username: 'root',
|
||||
password: '',
|
||||
privateKey: '',
|
||||
domain: '',
|
||||
};
|
||||
|
||||
export default function TunnelsPage() {
|
||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
||||
const [nodes, setNodes] = useState<NodeRecord[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loadingId, setLoadingId] = useState<number | null>(null);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: '', ip: '', sshPort: 22, username: 'root', password: '', domain: ''
|
||||
const [authMethod, setAuthMethod] = useState<'password' | 'key'>('password');
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
||||
const [snackbar, setSnackbar] = useState({
|
||||
open: false,
|
||||
type: 'success' as 'success' | 'error',
|
||||
message: '',
|
||||
});
|
||||
const [confirmDialog, setConfirmDialog] = useState({
|
||||
open: false,
|
||||
title: '',
|
||||
confirmText: 'Подтвердить',
|
||||
confirmColor: 'primary' as 'primary' | 'error',
|
||||
onConfirm: () => {},
|
||||
});
|
||||
|
||||
useEffect(() => { loadTunnels(); }, []);
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const mainNode = useMemo(() => nodes.find((node) => node.isMain), [nodes]);
|
||||
|
||||
const loadTunnels = async () => {
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const { data } = await api.get('/tunnels');
|
||||
setTunnels(data);
|
||||
} catch (e) { console.error(e); }
|
||||
const [tunnelsRes, nodesRes] = await Promise.all([
|
||||
api.get<Tunnel[]>('/tunnels'),
|
||||
api.get<NodeRecord[]>('/nodes'),
|
||||
]);
|
||||
setTunnels(Array.isArray(tunnelsRes.data) ? tunnelsRes.data : []);
|
||||
setNodes(Array.isArray(nodesRes.data) ? nodesRes.data : []);
|
||||
} catch (error) {
|
||||
Logger.error('Failed to load forwarding data', 'Tunnels', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const isValidIp = (value: string) =>
|
||||
/^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/.test(value.trim()) ||
|
||||
/^([0-9a-f]{1,4}:){2,7}[0-9a-f]{1,4}$/i.test(value.trim());
|
||||
const isValidDomain = (value: string) =>
|
||||
/^(?=.{1,253}$)(?!-)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i.test(value.trim());
|
||||
const isValidAddress = (value: string) => isValidIp(value) || isValidDomain(value);
|
||||
|
||||
const selectedNode = nodes.find((node) => node.id === form.nodeId) || mainNode;
|
||||
|
||||
const validateForm = () => {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (!form.name.trim()) errors.name = 'Введите название relay сервера';
|
||||
if (!selectedNode) errors.nodeId = 'Добавьте или выберите ноду';
|
||||
if (!isValidAddress(form.ip)) errors.ip = 'Введите корректный IP или домен relay сервера';
|
||||
if (!form.sshPort || form.sshPort < 1 || form.sshPort > 65535) {
|
||||
errors.sshPort = 'Порт должен быть от 1 до 65535';
|
||||
}
|
||||
if (!form.username.trim()) errors.username = 'Введите SSH пользователя';
|
||||
if (authMethod === 'password' && !form.password) errors.password = 'Введите SSH пароль';
|
||||
if (authMethod === 'key' && !form.privateKey.trim()) errors.privateKey = 'Введите SSH ключ';
|
||||
|
||||
setFormErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleChange =
|
||||
(prop: keyof typeof emptyForm) => (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setForm((prev) => ({ ...prev, [prop]: event.target.value }));
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setForm({ ...emptyForm, nodeId: mainNode?.id || '' });
|
||||
setAuthMethod('password');
|
||||
setFormErrors({});
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
if (nodes.length === 0) {
|
||||
setSnackbar({ open: true, type: 'error', message: 'Создайте хотя бы одну ноду!' });
|
||||
return;
|
||||
}
|
||||
resetForm();
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
await api.post('/tunnels', form);
|
||||
setOpen(false);
|
||||
setForm({ name: '', ip: '', sshPort: 22, username: 'root', password: '', domain: '' });
|
||||
loadTunnels();
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (confirm('Удалить сервер из списка?')) {
|
||||
await api.delete(`/tunnels/${id}`);
|
||||
loadTunnels();
|
||||
if (!validateForm()) {
|
||||
setSnackbar({ open: true, type: 'error', message: 'Исправьте ошибки в форме' });
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstall = async (id: number) => {
|
||||
if (!confirm('Начать установку перенаправления на этот сервер?')) return;
|
||||
const payload = {
|
||||
...form,
|
||||
nodeId: form.nodeId || mainNode?.id,
|
||||
password: authMethod === 'password' ? form.password : undefined,
|
||||
privateKey: authMethod === 'key' ? form.privateKey : undefined,
|
||||
};
|
||||
|
||||
setLoadingId(id);
|
||||
try {
|
||||
await api.post(`/tunnels/${id}/install`);
|
||||
alert('Скрипт успешно установлен! Трафик перенаправляется.');
|
||||
loadTunnels();
|
||||
} catch (e: any) {
|
||||
alert('Ошибка: ' + (e.response?.data?.message || e.message));
|
||||
} finally {
|
||||
setLoadingId(null);
|
||||
await api.post('/tunnels', payload);
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
loadData();
|
||||
setSnackbar({ open: true, type: 'success', message: 'Relay сервер добавлен' });
|
||||
} catch (error) {
|
||||
setSnackbar({ open: true, type: 'error', message: getApiErrorMessage(error, 'Не удалось добавить relay сервер') });
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (prop: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setForm({ ...form, [prop]: e.target.value });
|
||||
const handleInstall = (id: number) => {
|
||||
setConfirmDialog({
|
||||
open: true,
|
||||
title: 'Установить перенаправление на выбранный сервер?',
|
||||
confirmText: 'Установить',
|
||||
confirmColor: 'primary',
|
||||
onConfirm: async () => {
|
||||
setLoadingId(id);
|
||||
try {
|
||||
await api.post(`/tunnels/${id}/install`);
|
||||
setSnackbar({ open: true, type: 'success', message: 'Перенаправление установлено' });
|
||||
loadData();
|
||||
} catch (error) {
|
||||
setSnackbar({ open: true, type: 'error', message: getApiErrorMessage(error, 'Ошибка установки') });
|
||||
} finally {
|
||||
setLoadingId(null);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (tunnel: Tunnel) => {
|
||||
const deleteForwarding =
|
||||
tunnel.isInstalled &&
|
||||
window.confirm('Удалить перенаправление на сервере через forwarding_delete.sh (приведет сервер к первоначальному состоянию)? Нажмите Ок для подтверждения.');
|
||||
|
||||
setConfirmDialog({
|
||||
open: true,
|
||||
title: deleteForwarding
|
||||
? 'Удалить relay и выполнить удаление перенаправления на сервере?'
|
||||
: 'Удалить relay сервер только из списка?',
|
||||
confirmText: 'Удалить',
|
||||
confirmColor: 'error',
|
||||
onConfirm: async () => {
|
||||
setLoadingId(tunnel.id);
|
||||
try {
|
||||
await api.delete(`/tunnels/${tunnel.id}`, { params: { deleteForwarding } });
|
||||
setSnackbar({ open: true, type: 'success', message: 'Relay сервер удалён' });
|
||||
loadData();
|
||||
} catch (error) {
|
||||
setSnackbar({ open: true, type: 'error', message: getApiErrorMessage(error, 'Ошибка удаления') });
|
||||
} finally {
|
||||
setLoadingId(null);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Typography variant="h4">Relay серверы</Typography>
|
||||
<Button variant="contained" startIcon={<Add />} onClick={() => setOpen(true)}>Добавить</Button>
|
||||
<Typography variant={isMobile ? 'h5' : 'h4'}>Relay серверы</Typography>
|
||||
<Box><Button variant="contained" startIcon={<Add />} onClick={openCreate}>Добавить</Button></Box>
|
||||
|
||||
</Box>
|
||||
|
||||
<Paper>
|
||||
<Paper sx={{ overflowX: 'auto' }}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Название</TableCell>
|
||||
<TableCell>Нода</TableCell>
|
||||
<TableCell>Адрес</TableCell>
|
||||
<TableCell>Статус</TableCell>
|
||||
<TableCell align="right">Действия</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{tunnels.map((t) => (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell>{t.name}</TableCell>
|
||||
{tunnels.map((tunnel) => (
|
||||
<TableRow key={tunnel.id}>
|
||||
<TableCell>{tunnel.name}</TableCell>
|
||||
<TableCell>{tunnel.node?.name || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Dns fontSize="small" color="action" />
|
||||
{t.ip}
|
||||
{tunnel.domain || tunnel.ip}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{t.isInstalled ?
|
||||
<Chip icon={<CheckCircle />} label="Активен" color="success" size="small" variant="outlined" /> :
|
||||
<Chip icon={<Error />} label="Не настроен" color="warning" size="small" variant="outlined" />
|
||||
}
|
||||
{tunnel.isInstalled ? (
|
||||
<Chip icon={<CheckCircle />} label="Активен" color="success" size="small" variant="outlined" />
|
||||
) : (
|
||||
<Chip icon={<Error />} label="Не установлен" color="warning" size="small" variant="outlined" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{!t.isInstalled && (
|
||||
{!tunnel.isInstalled && (
|
||||
<Button
|
||||
startIcon={loadingId === t.id ? <CircularProgress size={20} /> : <Terminal />}
|
||||
startIcon={loadingId === tunnel.id ? <CircularProgress size={20} /> : <Terminal />}
|
||||
disabled={loadingId !== null}
|
||||
onClick={() => handleInstall(t.id)}
|
||||
onClick={() => handleInstall(tunnel.id)}
|
||||
sx={{ mr: 1 }}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
>
|
||||
{loadingId === t.id ? 'Установка...' : 'Установить'}
|
||||
Установить
|
||||
</Button>
|
||||
)}
|
||||
<IconButton color="inherit" onClick={() => handleDelete(t.id)}>
|
||||
<IconButton color="error" disabled={loadingId !== null} onClick={() => handleDelete(tunnel)}>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{tunnels.length === 0 && <TableRow><TableCell colSpan={4} align="center">Список пуст</TableCell></TableRow>}
|
||||
{tunnels.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} align="center" sx={{ color: 'text.secondary' }}>
|
||||
Relay серверы не добавлены
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Paper>
|
||||
|
||||
<Dialog open={open} onClose={() => setOpen(false)}>
|
||||
<DialogTitle>Новый редирект сервер</DialogTitle>
|
||||
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Новый relay сервер</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField margin="dense" label="Название" fullWidth value={form.name} onChange={handleChange('name')} />
|
||||
<TextField margin="dense" label="IP адрес" fullWidth value={form.ip} onChange={handleChange('ip')} />
|
||||
<TextField margin="dense" label="Название" required fullWidth value={form.name} onChange={handleChange('name')} error={!!formErrors.name} helperText={formErrors.name} />
|
||||
<FormControl fullWidth required margin="dense" error={!!formErrors.nodeId}>
|
||||
<InputLabel>Нода</InputLabel>
|
||||
<Select value={form.nodeId || mainNode?.id || ''} label="Нода" onChange={(event) => setForm((prev) => ({ ...prev, nodeId: event.target.value }))}>
|
||||
{nodes.map((node) => (
|
||||
<MenuItem key={node.id} value={node.id}>{node.name}{node.isMain ? ' (основная)' : ''}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{formErrors.nodeId && <Typography variant="caption" color="error" sx={{ mt: 0.5, ml: 1.5 }}>{formErrors.nodeId}</Typography>}
|
||||
</FormControl>
|
||||
<TextField margin="dense" label="IP или домен relay сервера" required fullWidth value={form.ip} onChange={handleChange('ip')} error={!!formErrors.ip} helperText={formErrors.ip} />
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<TextField margin="dense" label="SSH Порт" type="number" fullWidth value={form.sshPort} onChange={handleChange('sshPort')} />
|
||||
<TextField margin="dense" label="SSH User" fullWidth value={form.username} onChange={handleChange('username')} />
|
||||
<TextField margin="dense" label="SSH порт" required type="number" fullWidth value={form.sshPort} onChange={handleChange('sshPort')} error={!!formErrors.sshPort} helperText={formErrors.sshPort} />
|
||||
<TextField margin="dense" label="SSH пользователь" required fullWidth value={form.username} onChange={handleChange('username')} error={!!formErrors.username} helperText={formErrors.username} />
|
||||
</Box>
|
||||
<TextField margin="dense" label="SSH Пароль" type="password" fullWidth value={form.password} onChange={handleChange('password')} />
|
||||
<FormControl component="fieldset" sx={{ mt: 2, mb: 1 }}>
|
||||
<RadioGroup row value={authMethod} onChange={(e) => setAuthMethod(e.target.value as 'password' | 'key')}>
|
||||
<FormControlLabel value="password" control={<Radio />} label="По паролю" />
|
||||
<FormControlLabel value="key" control={<Radio />} label="По SSH ключу" />
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
{authMethod === 'password' ? (
|
||||
<TextField margin="dense" label="SSH пароль" required type="password" fullWidth value={form.password} onChange={handleChange('password')} error={!!formErrors.password} helperText={formErrors.password} />
|
||||
) : (
|
||||
<TextField margin="dense" label="SSH private key" required multiline rows={4} fullWidth value={form.privateKey} onChange={handleChange('privateKey')} placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" slotProps={{ input: { style: { fontFamily: 'monospace', fontSize: '0.875rem' } } }} error={!!formErrors.privateKey} helperText={formErrors.privateKey} />
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpen(false)}>Отмена</Button>
|
||||
<Button variant="contained" onClick={handleCreate}>Сохранить</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={confirmDialog.open} onClose={() => setConfirmDialog({ ...confirmDialog, open: false })}>
|
||||
<DialogTitle>Подтверждение</DialogTitle>
|
||||
<DialogContent><Typography>{confirmDialog.title}</Typography></DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setConfirmDialog({ ...confirmDialog, open: false })}>Отмена</Button>
|
||||
<Button onClick={() => { setConfirmDialog({ ...confirmDialog, open: false }); confirmDialog.onConfirm(); }} variant="contained" color={confirmDialog.confirmColor}>{confirmDialog.confirmText}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Snackbar open={snackbar.open} autoHideDuration={6000} onClose={() => setSnackbar({ ...snackbar, open: false })} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}>
|
||||
<Alert onClose={() => setSnackbar({ ...snackbar, open: false })} severity={snackbar.type} sx={{ width: '100%' }}>{snackbar.message}</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@ import type { PaletteMode } from '@mui/material';
|
||||
|
||||
const lightPalette = {
|
||||
primary: {
|
||||
main: '#2563eb',
|
||||
main: '#1395de',
|
||||
light: '#60a5fa',
|
||||
dark: '#1e40af',
|
||||
},
|
||||
@@ -21,7 +21,7 @@ const lightPalette = {
|
||||
|
||||
const darkPalette = {
|
||||
primary: {
|
||||
main: '#3b82f6',
|
||||
main: '#1395de',
|
||||
light: '#60a5fa',
|
||||
dark: '#1d4ed8',
|
||||
},
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export type AuthContextType = {
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (token: string) => void;
|
||||
logout: () => void;
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
export type NodeAuthType = 'password' | 'token';
|
||||
export type NodeProtocol = 'http' | 'https';
|
||||
|
||||
export interface NodeRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
host?: string;
|
||||
domain?: string;
|
||||
ip?: string;
|
||||
flag?: string;
|
||||
port?: number;
|
||||
protocol?: NodeProtocol;
|
||||
authType: NodeAuthType;
|
||||
login?: string;
|
||||
isMain: boolean;
|
||||
version?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface NodePayload {
|
||||
name: string;
|
||||
url: string;
|
||||
domain?: string;
|
||||
ip?: string;
|
||||
flag?: string;
|
||||
authType: NodeAuthType;
|
||||
login?: string;
|
||||
password?: string;
|
||||
token?: string;
|
||||
isMain?: boolean;
|
||||
version?: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type ColorMode = 'light' | 'dark' | 'system';
|
||||
|
||||
export type ThemeContextType = {
|
||||
mode: ColorMode;
|
||||
toggleColorMode: () => void;
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Универсальная функция копирования текста в буфер обмена.
|
||||
* Работает и в secure (HTTPS/localhost), и в insecure (HTTP по IP) контекстах.
|
||||
* navigator.clipboard недоступен при не-HTTPS/не-localhost соединениях.
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<void> {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
// Fallback для HTTP (не-localhost) — использует execCommand
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
textarea.style.top = '-9999px';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Type guard to check if a value is an API error response
|
||||
*/
|
||||
export function isApiError(error: unknown): error is { response?: { status?: number; data?: { message?: string | string[] } } } {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'response' in error &&
|
||||
typeof (error as { response?: unknown }).response === 'object' &&
|
||||
(error as { response?: unknown }).response !== null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract error message from API error response
|
||||
*/
|
||||
export function getApiErrorMessage(error: unknown, defaultMessage: string = 'Произошла ошибка'): string {
|
||||
if (isApiError(error)) {
|
||||
const data = error.response?.data;
|
||||
const message = data?.message;
|
||||
|
||||
if (Array.isArray(message)) {
|
||||
return message.join('; ');
|
||||
}
|
||||
|
||||
if (typeof message === 'string') {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return defaultMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get HTTP status code from error response
|
||||
*/
|
||||
export function getApiErrorStatus(error: unknown): number | undefined {
|
||||
if (isApiError(error)) {
|
||||
return error.response?.status;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Box, Stack, Typography } from '@mui/material';
|
||||
|
||||
const SPECIAL_CODES: Record<string, string> = {
|
||||
ENGLAND: 'gb-eng',
|
||||
SCOTLAND: 'gb-sct',
|
||||
WALES: 'gb-wls',
|
||||
};
|
||||
|
||||
export const decodeFlag = (flag?: string) => {
|
||||
if (!flag) return '';
|
||||
try {
|
||||
return decodeURIComponent(flag);
|
||||
} catch {
|
||||
return flag;
|
||||
}
|
||||
};
|
||||
|
||||
export const countryCodeFromFlag = (flag?: string) => {
|
||||
const decoded = decodeFlag(flag);
|
||||
const points = Array.from(decoded).map((char) => char.codePointAt(0) || 0);
|
||||
|
||||
if (points.length < 2) return undefined;
|
||||
if (points[0] < 0x1f1e6 || points[0] > 0x1f1ff) return undefined;
|
||||
if (points[1] < 0x1f1e6 || points[1] > 0x1f1ff) return undefined;
|
||||
|
||||
return String.fromCharCode(points[0] - 0x1f1e6 + 65, points[1] - 0x1f1e6 + 65);
|
||||
};
|
||||
|
||||
const normalizeCode = (code?: string) => {
|
||||
if (!code) return undefined;
|
||||
return SPECIAL_CODES[code.toUpperCase()] || code.toLowerCase();
|
||||
};
|
||||
|
||||
export function FlagIcon({
|
||||
flag,
|
||||
code,
|
||||
size = 22,
|
||||
}: {
|
||||
flag?: string;
|
||||
code?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
const normalizedCode = normalizeCode(code || countryCodeFromFlag(flag));
|
||||
|
||||
if (!normalizedCode) {
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
width: size,
|
||||
height: Math.round(size * 0.72),
|
||||
borderRadius: 0.5,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
display: 'inline-block',
|
||||
bgcolor: 'action.hover',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="img"
|
||||
src={`https://flagcdn.com/w40/${normalizedCode}.png`}
|
||||
srcSet={`https://flagcdn.com/w80/${normalizedCode}.png 2x`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
sx={{
|
||||
width: size,
|
||||
height: Math.round(size * 0.72),
|
||||
borderRadius: 0.5,
|
||||
objectFit: 'cover',
|
||||
boxShadow: 'inset 0 0 0 1px rgba(0,0,0,0.12)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function FlagOptionLabel({
|
||||
flag,
|
||||
code,
|
||||
label,
|
||||
}: {
|
||||
flag?: string;
|
||||
code?: string;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<Stack component="span" direction="row" spacing={1} alignItems="center">
|
||||
<FlagIcon flag={flag} code={code} size={22} />
|
||||
<Typography component="span" variant="body2">
|
||||
{label}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'verbose';
|
||||
|
||||
const LOG_LEVELS: Record<LogLevel, number> = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
debug: 3,
|
||||
verbose: 4,
|
||||
};
|
||||
|
||||
const getLogLevel = (): LogLevel => {
|
||||
return (import.meta.env.VITE_LOG_LEVEL as LogLevel) || 'info';
|
||||
};
|
||||
|
||||
const shouldLog = (level: LogLevel): boolean => {
|
||||
const currentLevel = getLogLevel();
|
||||
return LOG_LEVELS[level] <= LOG_LEVELS[currentLevel];
|
||||
};
|
||||
|
||||
const formatMessage = (module: string, message: string, data?: unknown): string => {
|
||||
if (data !== undefined) {
|
||||
return `[${module}] ${message} ${JSON.stringify(data)}`;
|
||||
}
|
||||
return `[${module}] ${message}`;
|
||||
};
|
||||
|
||||
export const Logger = {
|
||||
error: (message: string, module: string = 'App', data?: unknown) => {
|
||||
if (shouldLog('error')) {
|
||||
console.error(formatMessage(module, message), data || '');
|
||||
}
|
||||
},
|
||||
|
||||
warn: (message: string, module: string = 'App', data?: unknown) => {
|
||||
if (shouldLog('warn')) {
|
||||
console.warn(formatMessage(module, message), data || '');
|
||||
}
|
||||
},
|
||||
|
||||
info: (message: string, module: string = 'App', data?: unknown) => {
|
||||
if (shouldLog('info')) {
|
||||
console.info(formatMessage(module, message), data || '');
|
||||
}
|
||||
},
|
||||
|
||||
debug: (message: string, module: string = 'App', data?: unknown) => {
|
||||
if (shouldLog('debug')) {
|
||||
console.log(formatMessage(module, message), data || '');
|
||||
}
|
||||
},
|
||||
|
||||
verbose: (message: string, module: string = 'App', data?: unknown) => {
|
||||
if (shouldLog('verbose')) {
|
||||
console.log(formatMessage(module, message), data || '');
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
/**
|
||||
* Хук для определения безопасного соединения (HTTPS)
|
||||
* @returns {isSecure: boolean} - true если соединение по HTTPS
|
||||
*/
|
||||
export function useSecureConnection() {
|
||||
const isSecure = useMemo(() => {
|
||||
// Проверка в браузере
|
||||
if (typeof window !== 'undefined' && window.location) {
|
||||
return window.location.protocol === 'https:';
|
||||
}
|
||||
// SSR fallback - считаем небезопасным
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
return { isSecure };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const APP_VERSION = '2.2.0';
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import App from '../src/App'
|
||||
|
||||
// Мок для страниц и компонентов
|
||||
vi.mock('../src/pages/LoginPage', () => ({
|
||||
default: () => <div data-testid="login-page">LoginPage</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../src/pages/SubscriptionsPage', () => ({
|
||||
default: () => <div data-testid="subscriptions-page">SubscriptionsPage</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../src/pages/SettingsPage', () => ({
|
||||
default: () => <div data-testid="settings-page">SettingsPage</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../src/pages/DomainsPage', () => ({
|
||||
default: () => <div data-testid="domains-page">DomainsPage</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../src/pages/TunnelsPage', () => ({
|
||||
default: () => <div data-testid="tunnels-page">TunnelsPage</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../src/pages/NotFoundPage', () => ({
|
||||
default: () => <div data-testid="not-found-page">NotFoundPage</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../src/components/Layout', () => ({
|
||||
default: () => <div data-testid="layout">Layout</div>,
|
||||
}))
|
||||
|
||||
// Мок для AuthContext
|
||||
vi.mock('../src/auth/AuthContext', () => ({
|
||||
AuthProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
useAuth: () => ({
|
||||
isAuthenticated: false,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Мок для ThemeContext
|
||||
vi.mock('../src/ThemeContext', () => ({
|
||||
ThemeProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
useThemeContext: () => ({
|
||||
mode: 'light' as const,
|
||||
toggleColorMode: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Мок для AxiosInterceptor
|
||||
vi.mock('../src/auth/AxiosInterceptor', () => ({
|
||||
AxiosInterceptor: () => null,
|
||||
}))
|
||||
|
||||
// Мок для RequireAuth - просто рендерит children
|
||||
vi.mock('../src/auth/RequireAuth', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => children,
|
||||
}))
|
||||
|
||||
// Мок для PublicRoute - просто рендерит children
|
||||
vi.mock('../src/auth/PublicRoute', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => children,
|
||||
}))
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('должен рендериться без ошибок', () => {
|
||||
expect(() => {
|
||||
render(<App />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('должен содержать Layout компонент', () => {
|
||||
render(<App />)
|
||||
expect(screen.getByTestId('layout')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { ThemeProvider, useThemeContext } from '@/ThemeContext'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
// Мокаем localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] || null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key]
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {}
|
||||
}),
|
||||
}
|
||||
})()
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock,
|
||||
})
|
||||
|
||||
// Мокаем useMediaQuery
|
||||
const useMediaQueryMock = vi.fn()
|
||||
vi.mock('@mui/material', async () => {
|
||||
const actual = await vi.importActual('@mui/material')
|
||||
return {
|
||||
...(actual as object),
|
||||
useMediaQuery: () => useMediaQueryMock(),
|
||||
}
|
||||
})
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
)
|
||||
|
||||
describe('ThemeContext', () => {
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear()
|
||||
vi.clearAllMocks()
|
||||
useMediaQueryMock.mockReturnValue(false) // light mode by default
|
||||
})
|
||||
|
||||
describe('useThemeContext', () => {
|
||||
it('должен предоставлять context', () => {
|
||||
const { result } = renderHook(() => useThemeContext(), { wrapper })
|
||||
expect(result.current).toBeDefined()
|
||||
})
|
||||
|
||||
it('должен предоставлять mode и toggleColorMode', () => {
|
||||
const { result } = renderHook(() => useThemeContext(), { wrapper })
|
||||
expect(result.current.mode).toBeDefined()
|
||||
expect(result.current.toggleColorMode).toBeDefined()
|
||||
expect(typeof result.current.toggleColorMode).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
it('должен инициализироваться с mode из localStorage', () => {
|
||||
localStorageMock.getItem.mockReturnValue('dark')
|
||||
|
||||
const { result } = renderHook(() => useThemeContext(), { wrapper })
|
||||
|
||||
expect(result.current.mode).toBe('dark')
|
||||
})
|
||||
|
||||
it('должен инициализироваться с "system" если mode отсутствует в localStorage', () => {
|
||||
localStorageMock.getItem.mockReturnValue(null)
|
||||
|
||||
const { result } = renderHook(() => useThemeContext(), { wrapper })
|
||||
|
||||
expect(result.current.mode).toBe('system')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleColorMode', () => {
|
||||
it('должен переключать light → dark → system → light', () => {
|
||||
localStorageMock.getItem.mockReturnValue('light')
|
||||
|
||||
const { result } = renderHook(() => useThemeContext(), { wrapper })
|
||||
|
||||
expect(result.current.mode).toBe('light')
|
||||
|
||||
act(() => {
|
||||
result.current.toggleColorMode()
|
||||
})
|
||||
expect(result.current.mode).toBe('dark')
|
||||
|
||||
act(() => {
|
||||
result.current.toggleColorMode()
|
||||
})
|
||||
expect(result.current.mode).toBe('system')
|
||||
|
||||
act(() => {
|
||||
result.current.toggleColorMode()
|
||||
})
|
||||
expect(result.current.mode).toBe('light')
|
||||
})
|
||||
|
||||
it('должен сохранять mode в localStorage при переключении', () => {
|
||||
localStorageMock.getItem.mockReturnValue('light')
|
||||
|
||||
const { result } = renderHook(() => useThemeContext(), { wrapper })
|
||||
|
||||
act(() => {
|
||||
result.current.toggleColorMode()
|
||||
})
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('themeMode', 'dark')
|
||||
})
|
||||
})
|
||||
|
||||
describe('system mode', () => {
|
||||
it('должен использовать dark когда prefers-color-scheme: dark', () => {
|
||||
localStorageMock.getItem.mockReturnValue('system')
|
||||
useMediaQueryMock.mockReturnValue(true) // prefers dark
|
||||
|
||||
const { result } = renderHook(() => useThemeContext(), { wrapper })
|
||||
|
||||
// mode должен быть 'system', но тема должна быть dark
|
||||
expect(result.current.mode).toBe('system')
|
||||
})
|
||||
|
||||
it('должен использовать light когда prefers-color-scheme: light', () => {
|
||||
localStorageMock.getItem.mockReturnValue('system')
|
||||
useMediaQueryMock.mockReturnValue(false) // prefers light
|
||||
|
||||
const { result } = renderHook(() => useThemeContext(), { wrapper })
|
||||
|
||||
expect(result.current.mode).toBe('system')
|
||||
})
|
||||
})
|
||||
|
||||
describe('localStorage persistence', () => {
|
||||
it('должен сохранять mode в localStorage при инициализации', () => {
|
||||
localStorageMock.getItem.mockReturnValue(null)
|
||||
|
||||
renderHook(() => useThemeContext(), { wrapper })
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('themeMode', 'system')
|
||||
})
|
||||
|
||||
it('должен читать mode из localStorage', () => {
|
||||
localStorageMock.getItem.mockReturnValue('dark')
|
||||
|
||||
renderHook(() => useThemeContext(), { wrapper })
|
||||
|
||||
expect(localStorageMock.getItem).toHaveBeenCalledWith('themeMode')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mode values', () => {
|
||||
it('должен поддерживать light mode', () => {
|
||||
localStorageMock.getItem.mockReturnValue('light')
|
||||
|
||||
const { result } = renderHook(() => useThemeContext(), { wrapper })
|
||||
|
||||
expect(result.current.mode).toBe('light')
|
||||
})
|
||||
|
||||
it('должен поддерживать dark mode', () => {
|
||||
localStorageMock.getItem.mockReturnValue('dark')
|
||||
|
||||
const { result } = renderHook(() => useThemeContext(), { wrapper })
|
||||
|
||||
expect(result.current.mode).toBe('dark')
|
||||
})
|
||||
|
||||
it('должен поддерживать system mode', () => {
|
||||
localStorageMock.getItem.mockReturnValue('system')
|
||||
|
||||
const { result } = renderHook(() => useThemeContext(), { wrapper })
|
||||
|
||||
expect(result.current.mode).toBe('system')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,298 @@
|
||||
// Mock для всех MUI иконок
|
||||
// Этот файл автоматически используется vitest для мока @mui/icons-material
|
||||
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
// Создаем универсальный мок для любой иконки
|
||||
const IconMock = forwardRef<SVGSVGElement>((props, ref) => {
|
||||
return (
|
||||
<svg
|
||||
ref={ref}
|
||||
data-testid="mui-icon-mock"
|
||||
focusable="false"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<rect width="24" height="24" fill="transparent" />
|
||||
</svg>
|
||||
)
|
||||
})
|
||||
|
||||
IconMock.displayName = 'IconMock'
|
||||
|
||||
// Экспортируем все возможные иконки как один и тот же мок
|
||||
export const GitHub = IconMock
|
||||
export const YouTube = IconMock
|
||||
export const Telegram = IconMock
|
||||
export const Brightness7 = IconMock
|
||||
export const Brightness4 = IconMock
|
||||
export const BrightnessAuto = IconMock
|
||||
export const Logout = IconMock
|
||||
export const HelpOutline = IconMock
|
||||
export const Menu = IconMock
|
||||
export const People = IconMock
|
||||
export const Settings = IconMock
|
||||
export const Dns = IconMock
|
||||
export const SwapHoriz = IconMock
|
||||
export const Delete = IconMock
|
||||
export const Add = IconMock
|
||||
export const Terminal = IconMock
|
||||
export const CheckCircle = IconMock
|
||||
export const Error = IconMock
|
||||
export const LinkIcon = IconMock
|
||||
export const OpenInNew = IconMock
|
||||
export const ContentCopy = IconMock
|
||||
export const Router = IconMock
|
||||
export const Edit = IconMock
|
||||
export const MoreVert = IconMock
|
||||
export const Remove = IconMock
|
||||
export const Refresh = IconMock
|
||||
export const Search = IconMock
|
||||
export const FilterList = IconMock
|
||||
export const RefreshTwoTone = IconMock
|
||||
export const Warning = IconMock
|
||||
export const Info = IconMock
|
||||
export const Close = IconMock
|
||||
export const Check = IconMock
|
||||
export const ArrowDownward = IconMock
|
||||
export const ArrowUpward = IconMock
|
||||
export const MoreHoriz = IconMock
|
||||
export const ContentPaste = IconMock
|
||||
export const QrCode = IconMock
|
||||
export const Usb = IconMock
|
||||
export const VpnKey = IconMock
|
||||
export const Security = IconMock
|
||||
export const Speed = IconMock
|
||||
export const Timeline = IconMock
|
||||
export const Assessment = IconMock
|
||||
export const SettingsApplications = IconMock
|
||||
export const CloudDownload = IconMock
|
||||
export const CloudUpload = IconMock
|
||||
export const Folder = IconMock
|
||||
export const FileCopy = IconMock
|
||||
export const Save = IconMock
|
||||
export const Print = IconMock
|
||||
export const DeleteOutline = IconMock
|
||||
export const Restore = IconMock
|
||||
export const History = IconMock
|
||||
export const Schedule = IconMock
|
||||
export const AccessTime = IconMock
|
||||
export const Today = IconMock
|
||||
export const Event = IconMock
|
||||
export const Notifications = IconMock
|
||||
export const AccountCircle = IconMock
|
||||
export const Person = IconMock
|
||||
export const Group = IconMock
|
||||
export const Public = IconMock
|
||||
export const Language = IconMock
|
||||
export const Translate = IconMock
|
||||
export const Star = IconMock
|
||||
export const Favorite = IconMock
|
||||
export const Home = IconMock
|
||||
export const LocationOn = IconMock
|
||||
export const Place = IconMock
|
||||
export const Email = IconMock
|
||||
export const Phone = IconMock
|
||||
export const Chat = IconMock
|
||||
export const Message = IconMock
|
||||
export const Forum = IconMock
|
||||
export const Share = IconMock
|
||||
export const Send = IconMock
|
||||
export const Inbox = IconMock
|
||||
export const Drafts = IconMock
|
||||
export const Mail = IconMock
|
||||
export const Markunread = IconMock
|
||||
export const Lock = IconMock
|
||||
export const LockOpen = IconMock
|
||||
export const Unlock = IconMock
|
||||
export const Visibility = IconMock
|
||||
export const VisibilityOff = IconMock
|
||||
export const Eye = IconMock
|
||||
export const EyeOff = IconMock
|
||||
export const ToggleOn = IconMock
|
||||
export const ToggleOff = IconMock
|
||||
export const RadioButtonChecked = IconMock
|
||||
export const RadioButtonUnchecked = IconMock
|
||||
export const CheckBox = IconMock
|
||||
export const CheckBoxOutlineBlank = IconMock
|
||||
export const IndeterminateCheckBox = IconMock
|
||||
export const PlusOne = IconMock
|
||||
export const ThumbUp = IconMock
|
||||
export const ThumbDown = IconMock
|
||||
export const Whatshot = IconMock
|
||||
export const FavoriteBorder = IconMock
|
||||
export const StarBorder = IconMock
|
||||
export const Bookmark = IconMock
|
||||
export const BookmarkBorder = IconMock
|
||||
export const Bookmarks = IconMock
|
||||
export const TurnedIn = IconMock
|
||||
export const TurnedInNot = IconMock
|
||||
export const Label = IconMock
|
||||
export const LabelImportant = IconMock
|
||||
export const Grade = IconMock
|
||||
export const Done = IconMock
|
||||
export const Clear = IconMock
|
||||
export const Block = IconMock
|
||||
export const Ban = IconMock
|
||||
export const Stop = IconMock
|
||||
export const Pause = IconMock
|
||||
export const PlayArrow = IconMock
|
||||
export const FastForward = IconMock
|
||||
export const FastRewind = IconMock
|
||||
export const SkipNext = IconMock
|
||||
export const SkipPrevious = IconMock
|
||||
export const FiberManualRecord = IconMock
|
||||
export const Circle = IconMock
|
||||
export const Square = IconMock
|
||||
export const Rectangle = IconMock
|
||||
export const Triangle = IconMock
|
||||
export const NavigateNext = IconMock
|
||||
export const NavigateBefore = IconMock
|
||||
export const ChevronRight = IconMock
|
||||
export const ChevronLeft = IconMock
|
||||
export const ExpandMore = IconMock
|
||||
export const ExpandLess = IconMock
|
||||
export const UnfoldMore = IconMock
|
||||
export const UnfoldLess = IconMock
|
||||
export const ArrowRight = IconMock
|
||||
export const ArrowLeft = IconMock
|
||||
export const ArrowBack = IconMock
|
||||
export const ArrowForward = IconMock
|
||||
export const ArrowDropDown = IconMock
|
||||
export const ArrowDropUp = IconMock
|
||||
export const Expand = IconMock
|
||||
export const SubdirectoryArrowRight = IconMock
|
||||
export const SubdirectoryArrowLeft = IconMock
|
||||
export const FileDownload = IconMock
|
||||
export const FileUpload = IconMock
|
||||
export const Attachment = IconMock
|
||||
export const Link = IconMock
|
||||
export const InsertLink = IconMock
|
||||
export const Photo = IconMock
|
||||
export const Image = IconMock
|
||||
export const PictureAsPdf = IconMock
|
||||
export const ImageIcon = IconMock
|
||||
export const CameraAlt = IconMock
|
||||
export const Videocam = IconMock
|
||||
export const Movie = IconMock
|
||||
export const MusicNote = IconMock
|
||||
export const Mic = IconMock
|
||||
export const VolumeUp = IconMock
|
||||
export const VolumeOff = IconMock
|
||||
export const Headset = IconMock
|
||||
export const Headphones = IconMock
|
||||
export const Speaker = IconMock
|
||||
export const Radio = IconMock
|
||||
export const Podcasts = IconMock
|
||||
export const Tv = IconMock
|
||||
export const DesktopWindows = IconMock
|
||||
export const Laptop = IconMock
|
||||
export const Computer = IconMock
|
||||
export const Tablet = IconMock
|
||||
export const Smartphone = IconMock
|
||||
export const PhoneIphone = IconMock
|
||||
export const PhoneAndroid = IconMock
|
||||
export const Devices = IconMock
|
||||
export const SmartDisplay = IconMock
|
||||
export const Monitor = IconMock
|
||||
export const ScreenShare = IconMock
|
||||
export const StopScreenShare = IconMock
|
||||
export const PresentToAll = IconMock
|
||||
export const Cast = IconMock
|
||||
export const CastConnected = IconMock
|
||||
export const CastForEducation = IconMock
|
||||
export const Wifi = IconMock
|
||||
export const WifiOff = IconMock
|
||||
export const NetworkWifi = IconMock
|
||||
export const NetworkCell = IconMock
|
||||
export const SignalCellular4Bar = IconMock
|
||||
export const SignalWifi4Bar = IconMock
|
||||
export const Bluetooth = IconMock
|
||||
export const BluetoothConnected = IconMock
|
||||
export const BluetoothDisabled = IconMock
|
||||
export const GpsFixed = IconMock
|
||||
export const GpsNotFixed = IconMock
|
||||
export const LocationSearching = IconMock
|
||||
export const MyLocation = IconMock
|
||||
export const Navigation = IconMock
|
||||
export const NearMe = IconMock
|
||||
export const Directions = IconMock
|
||||
export const DirectionsCar = IconMock
|
||||
export const DirectionsBus = IconMock
|
||||
export const DirectionsTrain = IconMock
|
||||
export const DirectionsBike = IconMock
|
||||
export const DirectionsWalk = IconMock
|
||||
export const DirectionsRun = IconMock
|
||||
export const Flight = IconMock
|
||||
export const LocalAirport = IconMock
|
||||
export const Hotel = IconMock
|
||||
export const Restaurant = IconMock
|
||||
export const LocalCafe = IconMock
|
||||
export const LocalBar = IconMock
|
||||
export const LocalPizza = IconMock
|
||||
export const BrunchDining = IconMock
|
||||
export const DinnerDining = IconMock
|
||||
export const LunchDining = IconMock
|
||||
export const Nightlife = IconMock
|
||||
export const LocalHospital = IconMock
|
||||
export const LocalPharmacy = IconMock
|
||||
export const ShoppingBag = IconMock
|
||||
export const ShoppingCart = IconMock
|
||||
export const ShoppingBasket = IconMock
|
||||
export const Store = IconMock
|
||||
export const Shop = IconMock
|
||||
export const Storefront = IconMock
|
||||
export const LocalMall = IconMock
|
||||
export const AccountBalance = IconMock
|
||||
export const Business = IconMock
|
||||
export const CorporateFare = IconMock
|
||||
export const Work = IconMock
|
||||
export const MeetingRoom = IconMock
|
||||
export const Gite = IconMock
|
||||
export const House = IconMock
|
||||
export const Cottage = IconMock
|
||||
export const Apartment = IconMock
|
||||
export const Villa = IconMock
|
||||
export const OtherHouses = IconMock
|
||||
export const Foundation = IconMock
|
||||
export const Fence = IconMock
|
||||
export const Yard = IconMock
|
||||
export const Pool = IconMock
|
||||
export const HotTub = IconMock
|
||||
export const Spa = IconMock
|
||||
export const FitnessCenter = IconMock
|
||||
export const SportsGymnasium = IconMock
|
||||
export const SportsBasketball = IconMock
|
||||
export const SportsFootball = IconMock
|
||||
export const SportsSoccer = IconMock
|
||||
export const SportsTennis = IconMock
|
||||
export const SportsVolleyball = IconMock
|
||||
export const SportsBaseball = IconMock
|
||||
export const SportsCricket = IconMock
|
||||
export const SportsGolf = IconMock
|
||||
export const SportsHockey = IconMock
|
||||
export const SportsMma = IconMock
|
||||
export const SportsMotorsports = IconMock
|
||||
export const SportsRugby = IconMock
|
||||
export const SportsScore = IconMock
|
||||
export const SportsHandball = IconMock
|
||||
export const SportsKabaddi = IconMock
|
||||
export const Rowing = IconMock
|
||||
export const Surfing = IconMock
|
||||
export const Kitesurfing = IconMock
|
||||
export const Snowboarding = IconMock
|
||||
export const DownhillSkiing = IconMock
|
||||
export const Snowshoeing = IconMock
|
||||
export const IceSkating = IconMock
|
||||
export const Curling = IconMock
|
||||
export const Sailing = IconMock
|
||||
export const Kayaking = IconMock
|
||||
export const Rafting = IconMock
|
||||
export const ScubaDiving = IconMock
|
||||
export const Diving = IconMock
|
||||
export const Fishing = IconMock
|
||||
export const Hiking = IconMock
|
||||
export const RunningWithErrors = IconMock
|
||||
|
||||
// Экспорт по умолчанию
|
||||
export default IconMock
|
||||
@@ -0,0 +1,70 @@
|
||||
// Mock для всех MUI иконок
|
||||
const createIconMock = (name) => {
|
||||
const IconMock = (props) => {
|
||||
return <span data-testid={`icon-${name}`} {...props} />
|
||||
}
|
||||
IconMock.displayName = name
|
||||
return IconMock
|
||||
}
|
||||
|
||||
// Экспортируем все иконки динамически
|
||||
const icons = [
|
||||
'GitHub', 'YouTube', 'Telegram', 'Brightness7', 'Brightness4', 'BrightnessAuto',
|
||||
'Logout', 'HelpOutline', 'Menu', 'People', 'Settings', 'Dns', 'SwapHoriz',
|
||||
'Delete', 'Add', 'Terminal', 'CheckCircle', 'Error', 'LinkIcon', 'OpenInNew',
|
||||
'ContentCopy', 'Router', 'Edit', 'MoreVert', 'Remove', 'Refresh', 'Search',
|
||||
'FilterList', 'RefreshTwoTone', 'Warning', 'Info', 'Close', 'Check',
|
||||
'ArrowDownward', 'ArrowUpward', 'MoreHoriz', 'ContentPaste', 'QrCode', 'Usb',
|
||||
'VpnKey', 'Security', 'Speed', 'Timeline', 'Assessment', 'SettingsApplications',
|
||||
'CloudDownload', 'CloudUpload', 'Folder', 'FileCopy', 'Save', 'Print',
|
||||
'DeleteOutline', 'Restore', 'History', 'Schedule', 'AccessTime', 'Today',
|
||||
'Event', 'Notifications', 'AccountCircle', 'Person', 'Group', 'Public',
|
||||
'Language', 'Translate', 'Star', 'Favorite', 'Home', 'LocationOn', 'Place',
|
||||
'Email', 'Phone', 'Chat', 'Message', 'Forum', 'Share', 'Send', 'Inbox',
|
||||
'Drafts', 'Mail', 'Markunread', 'Lock', 'LockOpen', 'Unlock', 'Visibility',
|
||||
'VisibilityOff', 'Eye', 'EyeOff', 'ToggleOn', 'ToggleOff', 'RadioButtonChecked',
|
||||
'RadioButtonUnchecked', 'CheckBox', 'CheckBoxOutlineBlank', 'IndeterminateCheckBox',
|
||||
'PlusOne', 'ThumbUp', 'ThumbDown', 'Whatshot', 'FavoriteBorder', 'StarBorder',
|
||||
'Bookmark', 'BookmarkBorder', 'Bookmarks', 'TurnedIn', 'TurnedInNot', 'Label',
|
||||
'LabelImportant', 'Grade', 'Done', 'Clear', 'Block', 'Ban', 'Stop', 'Pause',
|
||||
'PlayArrow', 'FastForward', 'FastRewind', 'SkipNext', 'SkipPrevious',
|
||||
'FiberManualRecord', 'Circle', 'Square', 'Rectangle', 'Triangle', 'NavigateNext',
|
||||
'NavigateBefore', 'ChevronRight', 'ChevronLeft', 'ExpandMore', 'ExpandLess',
|
||||
'UnfoldMore', 'UnfoldLess', 'ArrowRight', 'ArrowLeft', 'ArrowBack', 'ArrowForward',
|
||||
'ArrowDropDown', 'ArrowDropUp', 'Expand', 'SubdirectoryArrowRight',
|
||||
'SubdirectoryArrowLeft', 'FileDownload', 'FileUpload', 'Attachment', 'Link',
|
||||
'InsertLink', 'Photo', 'Image', 'PictureAsPdf', 'ImageIcon', 'CameraAlt',
|
||||
'Videocam', 'Movie', 'MusicNote', 'Mic', 'VolumeUp', 'VolumeOff', 'Headset',
|
||||
'Headphones', 'Speaker', 'Radio', 'Podcasts', 'Tv', 'DesktopWindows', 'Laptop',
|
||||
'Computer', 'Tablet', 'Smartphone', 'PhoneIphone', 'PhoneAndroid', 'Devices',
|
||||
'SmartDisplay', 'Monitor', 'ScreenShare', 'StopScreenShare', 'PresentToAll',
|
||||
'Cast', 'CastConnected', 'CastForEducation', 'Wifi', 'WifiOff', 'NetworkWifi',
|
||||
'NetworkCell', 'SignalCellular4Bar', 'SignalWifi4Bar', 'Bluetooth',
|
||||
'BluetoothConnected', 'BluetoothDisabled', 'GpsFixed', 'GpsNotFixed',
|
||||
'LocationSearching', 'MyLocation', 'Navigation', 'NearMe', 'Directions',
|
||||
'DirectionsCar', 'DirectionsBus', 'DirectionsTrain', 'DirectionsBike',
|
||||
'DirectionsWalk', 'DirectionsRun', 'Flight', 'LocalAirport', 'Hotel',
|
||||
'Restaurant', 'LocalCafe', 'LocalBar', 'LocalPizza', 'BrunchDining',
|
||||
'DinnerDining', 'LunchDining', 'Nightlife', 'LocalHospital', 'LocalPharmacy',
|
||||
'ShoppingBag', 'ShoppingCart', 'ShoppingBasket', 'Store', 'Shop', 'Storefront',
|
||||
'LocalMall', 'AccountBalance', 'Business', 'CorporateFare', 'Work',
|
||||
'MeetingRoom', 'Gite', 'House', 'Cottage', 'Apartment', 'Villa', 'OtherHouses',
|
||||
'Foundation', 'Fence', 'Yard', 'Pool', 'HotTub', 'Spa', 'FitnessCenter',
|
||||
'SportsGymnasium', 'SportsBasketball', 'SportsFootball', 'SportsSoccer',
|
||||
'SportsTennis', 'SportsVolleyball', 'SportsBaseball', 'SportsCricket',
|
||||
'SportsGolf', 'SportsHockey', 'SportsMma', 'SportsMotorsports', 'SportsRugby',
|
||||
'SportsScore', 'SportsHandball', 'SportsKabaddi', 'Rowing', 'Surfing',
|
||||
'Kitesurfing', 'Snowboarding', 'DownhillSkiing', 'Snowshoeing', 'IceSkating',
|
||||
'Curling', 'Sailing', 'Kayaking', 'Rafting', 'ScubaDiving', 'Diving', 'Fishing',
|
||||
'Hiking', 'RunningWithErrors', 'PlayCircleFilled', 'PauseCircleFilled',
|
||||
'CheckCircle', 'Dns'
|
||||
]
|
||||
|
||||
// Создаем экспорт для каждой иконки
|
||||
const exportsObj = {}
|
||||
icons.forEach(name => {
|
||||
exportsObj[name] = createIconMock(name)
|
||||
})
|
||||
|
||||
module.exports = exportsObj
|
||||
module.exports.default = createIconMock('DefaultIcon')
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "@mui/icons-material",
|
||||
"main": "index.js",
|
||||
"module": "index.js"
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
// Мок для localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] || null),
|
||||
setItem: vi.fn((key: string, value: string) => { store[key] = value }),
|
||||
removeItem: vi.fn((key: string) => { delete store[key] }),
|
||||
clear: vi.fn(() => { store = {} }),
|
||||
}
|
||||
})()
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock,
|
||||
})
|
||||
|
||||
// Динамический импорт после настройки моков
|
||||
let api: typeof import('@/api').default
|
||||
|
||||
describe('api', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
// Очищаем модуль перед каждым тестом
|
||||
await vi.resetModules()
|
||||
const module = await import('@/api')
|
||||
api = module.default
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('API instance', () => {
|
||||
it('должен быть создан с baseURL /api', () => {
|
||||
expect(api.defaults.baseURL).toBe('/api')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Request interceptor', () => {
|
||||
it('должен добавлять токен из localStorage в заголовок Authorization', async () => {
|
||||
localStorageMock.getItem.mockReturnValue('test-token')
|
||||
|
||||
const mockAdapter = vi.fn(() => Promise.resolve({ data: {}, status: 200, headers: {}, config: {} }))
|
||||
api.defaults.adapter = mockAdapter
|
||||
|
||||
await api.get('/test')
|
||||
|
||||
const config = mockAdapter.mock.calls[0][0]
|
||||
expect(config.headers.get('Authorization')).toBe('Bearer test-token')
|
||||
})
|
||||
|
||||
it('не должен добавлять токен если он отсутствует', async () => {
|
||||
localStorageMock.getItem.mockReturnValue(null)
|
||||
|
||||
const mockAdapter = vi.fn(() => Promise.resolve({ data: {}, status: 200, headers: {}, config: {} }))
|
||||
api.defaults.adapter = mockAdapter
|
||||
|
||||
await api.get('/test')
|
||||
|
||||
const config = mockAdapter.mock.calls[0][0]
|
||||
expect(config.headers.get('Authorization')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('должен логировать запрос с существующим токеном', async () => {
|
||||
localStorageMock.getItem.mockReturnValue('test-token')
|
||||
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
const mockAdapter = vi.fn(() => Promise.resolve({ data: {}, status: 200, headers: {}, config: {} }))
|
||||
api.defaults.adapter = mockAdapter
|
||||
|
||||
await api.get('/test')
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Token: EXISTS'), expect.any(String))
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('должен логировать запрос без токена', async () => {
|
||||
localStorageMock.getItem.mockReturnValue(null)
|
||||
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
const mockAdapter = vi.fn(() => Promise.resolve({ data: {}, status: 200, headers: {}, config: {} }))
|
||||
api.defaults.adapter = mockAdapter
|
||||
|
||||
await api.get('/test')
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Token: NULL'), expect.any(String))
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Response interceptor', () => {
|
||||
it('должен логировать успешный ответ', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
const mockAdapter = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
data: { result: 'ok' },
|
||||
status: 200,
|
||||
headers: {},
|
||||
config: { method: 'get', url: '/test' }
|
||||
})
|
||||
)
|
||||
api.defaults.adapter = mockAdapter
|
||||
|
||||
const response = await api.get('/test')
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('200 OK'), expect.any(String))
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('должен логировать ошибку с status кодом', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const mockAdapter = vi.fn(() =>
|
||||
Promise.reject({
|
||||
response: { status: 401, data: { message: 'Unauthorized' } },
|
||||
config: { method: 'get', url: '/test' }
|
||||
})
|
||||
)
|
||||
api.defaults.adapter = mockAdapter
|
||||
|
||||
await expect(api.get('/test')).rejects.toThrow()
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('ERROR 401: Unauthorized'),
|
||||
expect.any(String)
|
||||
)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('должен логировать сетевую ошибку без status', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const mockAdapter = vi.fn(() =>
|
||||
Promise.reject({
|
||||
message: 'Network Error',
|
||||
config: { method: 'get', url: '/test' }
|
||||
})
|
||||
)
|
||||
api.defaults.adapter = mockAdapter
|
||||
|
||||
await expect(api.get('/test')).rejects.toThrow()
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('ERROR NETWORK: Network Error'),
|
||||
expect.any(String)
|
||||
)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('должен возвращать сообщение из response.data.message', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const mockAdapter = vi.fn(() =>
|
||||
Promise.reject({
|
||||
response: { status: 400, data: { message: 'Bad Request' } },
|
||||
config: { method: 'post', url: '/create' }
|
||||
})
|
||||
)
|
||||
api.defaults.adapter = mockAdapter
|
||||
|
||||
await expect(api.post('/create')).rejects.toThrow()
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Bad Request'),
|
||||
expect.any(String)
|
||||
)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,233 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { AuthProvider, useAuth } from '@/auth/AuthContext'
|
||||
import { ReactNode } from 'react'
|
||||
import api from '@/api'
|
||||
|
||||
// Мокаем localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] || null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key]
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {}
|
||||
}),
|
||||
}
|
||||
})()
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock,
|
||||
})
|
||||
|
||||
// Мокаем api.post для logout
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
post: vi.fn().mockResolvedValue({ data: { success: true } }),
|
||||
interceptors: {
|
||||
request: { use: vi.fn(), eject: vi.fn() },
|
||||
response: { use: vi.fn(), eject: vi.fn() },
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
)
|
||||
|
||||
describe('AuthContext', () => {
|
||||
const originalLocation = window.location
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear()
|
||||
vi.clearAllMocks()
|
||||
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
href: 'http://localhost/',
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: originalLocation,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAuth', () => {
|
||||
it('должен выбрасывать ошибку при использовании вне AuthProvider', () => {
|
||||
// Отключаем console.error для этого теста
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useAuth())
|
||||
}).toThrow('useAuth must be used within an AuthProvider')
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
it('должен инициализироваться с token из localStorage', () => {
|
||||
localStorageMock.getItem.mockReturnValue('test-token-123')
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
expect(result.current.token).toBe('test-token-123')
|
||||
expect(result.current.isAuthenticated).toBe(true)
|
||||
expect(localStorageMock.getItem).toHaveBeenCalledWith('token')
|
||||
})
|
||||
|
||||
it('должен инициализироваться с null если token отсутствует в localStorage', () => {
|
||||
localStorageMock.getItem.mockReturnValue(null)
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
expect(result.current.token).toBe(null)
|
||||
expect(result.current.isAuthenticated).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('login', () => {
|
||||
it('должен сохранять токен в localStorage и state', () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
act(() => {
|
||||
result.current.login('new-token-456')
|
||||
})
|
||||
|
||||
expect(result.current.token).toBe('new-token-456')
|
||||
expect(result.current.isAuthenticated).toBe(true)
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('token', 'new-token-456')
|
||||
})
|
||||
|
||||
it('должен обновлять isAuthenticated после login', () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
expect(result.current.isAuthenticated).toBe(false)
|
||||
|
||||
act(() => {
|
||||
result.current.login('another-token')
|
||||
})
|
||||
|
||||
expect(result.current.isAuthenticated).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('logout', () => {
|
||||
it('должен удалять токен из localStorage и state', async () => {
|
||||
localStorageMock.getItem.mockReturnValue('existing-token')
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
expect(result.current.token).toBe('existing-token')
|
||||
|
||||
await act(async () => {
|
||||
await result.current.logout()
|
||||
})
|
||||
|
||||
expect(result.current.token).toBe(null)
|
||||
expect(result.current.isAuthenticated).toBe(false)
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('token')
|
||||
expect(api.post).toHaveBeenCalledWith('/auth/logout')
|
||||
})
|
||||
|
||||
it('должен корректно работать logout когда token уже null', async () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.logout()
|
||||
})
|
||||
|
||||
expect(result.current.token).toBe(null)
|
||||
expect(result.current.isAuthenticated).toBe(false)
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('token')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAuthenticated', () => {
|
||||
it('должен возвращать true когда token существует', () => {
|
||||
localStorageMock.getItem.mockReturnValue('valid-token')
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
expect(result.current.isAuthenticated).toBe(true)
|
||||
})
|
||||
|
||||
it('должен возвращать false когда token null', () => {
|
||||
localStorageMock.getItem.mockReturnValue(null)
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
expect(result.current.isAuthenticated).toBe(false)
|
||||
})
|
||||
|
||||
it('должен возвращать false когда token пустая строка', () => {
|
||||
localStorageMock.getItem.mockReturnValue('')
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
expect(result.current.isAuthenticated).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('context methods', () => {
|
||||
it('должен предоставлять метод login', () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
expect(result.current.login).toBeDefined()
|
||||
expect(typeof result.current.login).toBe('function')
|
||||
})
|
||||
|
||||
it('должен предоставлять метод logout', () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
expect(result.current.logout).toBeDefined()
|
||||
expect(typeof result.current.logout).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('multiple login/logout cycles', () => {
|
||||
it('должен корректно обрабатывать несколько циклов login/logout', async () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
// Первый цикл
|
||||
act(() => {
|
||||
result.current.login('token-1')
|
||||
})
|
||||
expect(result.current.token).toBe('token-1')
|
||||
|
||||
await act(async () => {
|
||||
await result.current.logout()
|
||||
})
|
||||
expect(result.current.token).toBe(null)
|
||||
|
||||
// Второй цикл
|
||||
act(() => {
|
||||
result.current.login('token-2')
|
||||
})
|
||||
expect(result.current.token).toBe('token-2')
|
||||
|
||||
await act(async () => {
|
||||
await result.current.logout()
|
||||
})
|
||||
expect(result.current.token).toBe(null)
|
||||
|
||||
// Третий цикл
|
||||
act(() => {
|
||||
result.current.login('token-3')
|
||||
})
|
||||
expect(result.current.token).toBe('token-3')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { AxiosInterceptor } from '../../src/auth/AxiosInterceptor'
|
||||
import { AuthProvider } from '../../src/auth/AuthContext'
|
||||
import { ThemeProvider } from '../../src/ThemeContext'
|
||||
import { Logger } from '../../src/utils/logger'
|
||||
|
||||
const mockNavigate = vi.fn()
|
||||
const mockLogout = vi.fn()
|
||||
|
||||
// Мок api с интерсепторами - используем vi.hoisted для подъёма
|
||||
const mocks = vi.hoisted(() => ({
|
||||
responseUse: vi.fn(),
|
||||
responseEject: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../src/api', () => ({
|
||||
default: {
|
||||
interceptors: {
|
||||
request: {
|
||||
use: vi.fn(),
|
||||
eject: vi.fn(),
|
||||
},
|
||||
response: {
|
||||
use: mocks.responseUse,
|
||||
eject: mocks.responseEject,
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Мок react-router-dom
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom')
|
||||
return {
|
||||
...(actual as object),
|
||||
useNavigate: () => mockNavigate,
|
||||
useLocation: () => ({ pathname: '/subscriptions' }),
|
||||
}
|
||||
})
|
||||
|
||||
// Мок AuthContext
|
||||
vi.mock('../../src/auth/AuthContext', async () => {
|
||||
const actual = await vi.importActual('../../src/auth/AuthContext')
|
||||
return {
|
||||
...(actual as object),
|
||||
useAuth: () => ({ logout: mockLogout }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../src/utils/logger', () => ({
|
||||
Logger: {
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('AxiosInterceptor', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockNavigate.mockClear()
|
||||
mockLogout.mockClear()
|
||||
vi.mocked(Logger.warn).mockClear()
|
||||
vi.mocked(Logger.debug).mockClear()
|
||||
mocks.responseUse.mockClear()
|
||||
mocks.responseEject.mockClear()
|
||||
})
|
||||
|
||||
it('должен рендериться без ошибок и возвращать null', () => {
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<AuthProvider>
|
||||
<ThemeProvider>
|
||||
<AxiosInterceptor />
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('должен регистрировать interceptor при монтировании', async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AuthProvider>
|
||||
<ThemeProvider>
|
||||
<AxiosInterceptor />
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.responseUse).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Проверяем что был зарегистрирован обработчик
|
||||
const interceptorCall = vi.mocked(mocks.responseUse).mock.calls[0]
|
||||
expect(interceptorCall).toBeDefined()
|
||||
expect(typeof interceptorCall?.[0]).toBe('function') // success handler
|
||||
expect(typeof interceptorCall?.[1]).toBe('function') // error handler
|
||||
})
|
||||
|
||||
it('должен пропускать успешные ответы', () => {
|
||||
const successHandler = (response: unknown) => response
|
||||
|
||||
const response = { data: { test: 'value' }, status: 200 }
|
||||
const result = successHandler(response)
|
||||
|
||||
expect(result).toEqual(response)
|
||||
})
|
||||
|
||||
it('должен отклонять ошибки не 401', async () => {
|
||||
const errorHandler = (error: unknown) => Promise.reject(error)
|
||||
|
||||
const errorResponse = { response: { status: 500 } }
|
||||
|
||||
await expect(errorHandler(errorResponse)).rejects.toEqual(errorResponse)
|
||||
})
|
||||
|
||||
it('должен удалять interceptor при размонтировании', async () => {
|
||||
const { unmount } = render(
|
||||
<MemoryRouter>
|
||||
<AuthProvider>
|
||||
<ThemeProvider>
|
||||
<AxiosInterceptor />
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.responseUse).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mocks.responseEject).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('должен обрабатывать 401 ошибки', async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AuthProvider>
|
||||
<ThemeProvider>
|
||||
<AxiosInterceptor />
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.responseUse).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const interceptorCall = vi.mocked(mocks.responseUse).mock.calls[0]
|
||||
const errorHandler = interceptorCall?.[1]
|
||||
|
||||
if (errorHandler) {
|
||||
try {
|
||||
await errorHandler({ response: { status: 401 } })
|
||||
} catch {
|
||||
// Ожидаем что ошибка будет проброшена дальше
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем что logout и navigate были вызваны
|
||||
expect(mockLogout).toHaveBeenCalled()
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/login')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||
import PublicRoute from '@/auth/PublicRoute'
|
||||
import { AuthProvider } from '@/auth/AuthContext'
|
||||
|
||||
// Мокаем localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] || null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key]
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {}
|
||||
}),
|
||||
}
|
||||
})()
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock,
|
||||
})
|
||||
|
||||
describe('PublicRoute', () => {
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('должен рендерить Outlet когда пользователь не авторизован', () => {
|
||||
localStorageMock.getItem.mockReturnValue(null)
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/login']}>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route element={<PublicRoute />}>
|
||||
<Route path="/login" element={<div data-testid="login-page">Login Page</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('login-page')).toBeInTheDocument()
|
||||
expect(screen.getByText('Login Page')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен перенаправлять на / когда пользователь авторизован', () => {
|
||||
localStorageMock.getItem.mockReturnValue('valid-token')
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/login']}>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route element={<PublicRoute />}>
|
||||
<Route path="/login" element={<div data-testid="login-page">Login Page</div>} />
|
||||
</Route>
|
||||
<Route path="/" element={<div data-testid="home-page">Home Page</div>} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
// Должен показать главную страницу вместо login
|
||||
expect(screen.getByTestId('home-page')).toBeInTheDocument()
|
||||
expect(screen.getByText('Home Page')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен работать с несколькими публичными роутами', () => {
|
||||
localStorageMock.getItem.mockReturnValue(null)
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/register']}>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route element={<PublicRoute />}>
|
||||
<Route path="/login" element={<div data-testid="login-page">Login Page</div>} />
|
||||
<Route path="/register" element={<div data-testid="register-page">Register Page</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('register-page')).toBeInTheDocument()
|
||||
expect(screen.getByText('Register Page')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен перенаправлять авторизованного пользователя с любого публичного роута', () => {
|
||||
localStorageMock.getItem.mockReturnValue('valid-token')
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/register']}>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route element={<PublicRoute />}>
|
||||
<Route path="/login" element={<div data-testid="login-page">Login Page</div>} />
|
||||
<Route path="/register" element={<div data-testid="register-page">Register Page</div>} />
|
||||
</Route>
|
||||
<Route path="/" element={<div data-testid="home-page">Home Page</div>} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
// Должен показать главную страницу
|
||||
expect(screen.getByTestId('home-page')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('register-page')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен использовать replace при перенаправлении', () => {
|
||||
localStorageMock.getItem.mockReturnValue('valid-token')
|
||||
|
||||
const { container } = render(
|
||||
<MemoryRouter initialEntries={['/login']}>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route element={<PublicRoute />}>
|
||||
<Route path="/login" element={<div>Login</div>} />
|
||||
</Route>
|
||||
<Route path="/" element={<div>Home</div>} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
// Проверяем что рендерится Home
|
||||
expect(container.textContent).toContain('Home')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||
import RequireAuth from '@/auth/RequireAuth'
|
||||
import { AuthProvider } from '@/auth/AuthContext'
|
||||
|
||||
// Мокаем localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] || null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key]
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {}
|
||||
}),
|
||||
}
|
||||
})()
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock,
|
||||
})
|
||||
|
||||
describe('RequireAuth', () => {
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('должен рендерить children когда пользователь авторизован', () => {
|
||||
localStorageMock.getItem.mockReturnValue('valid-token')
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/protected']}>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/protected"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<div data-testid="protected-content">Protected Content</div>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('protected-content')).toBeInTheDocument()
|
||||
expect(screen.getByText('Protected Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен перенаправлять на /login когда пользователь не авторизован', () => {
|
||||
localStorageMock.getItem.mockReturnValue(null)
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/protected']}>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/protected"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<div data-testid="protected-content">Protected Content</div>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route path="/login" element={<div data-testid="login-page">Login Page</div>} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('login-page')).toBeInTheDocument()
|
||||
expect(screen.getByText('Login Page')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен сохранять state.from с текущим location при перенаправлении на login', () => {
|
||||
localStorageMock.getItem.mockReturnValue(null)
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/settings']}>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<div data-testid="settings-content">Settings</div>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route path="/login" element={<div data-testid="login-page">Login Page</div>} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
// Должен показать login страницу
|
||||
expect(screen.getByTestId('login-page')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен использовать replace: true при перенаправлении', () => {
|
||||
// Этот тест проверяет поведение Navigate компонента
|
||||
// В реальном сценарии replace предотвращает добавление записи в историю
|
||||
localStorageMock.getItem.mockReturnValue(null)
|
||||
|
||||
const { container } = render(
|
||||
<MemoryRouter initialEntries={['/protected']}>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/protected"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<div>Protected</div>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route path="/login" element={<div>Login</div>} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
// Проверяем что рендерится login
|
||||
expect(container.textContent).toContain('Login')
|
||||
})
|
||||
|
||||
it('должен работать с вложенными роутами', () => {
|
||||
localStorageMock.getItem.mockReturnValue('valid-token')
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/dashboard/profile']}>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/dashboard/*"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Routes>
|
||||
<Route path="profile" element={<div data-testid="profile">Profile</div>} />
|
||||
</Routes>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('profile')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен блокировать доступ к защищённому маршруту без авторизации', () => {
|
||||
localStorageMock.getItem.mockReturnValue(null)
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/admin']}>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<div data-testid="admin-content">Admin Panel</div>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route path="/login" element={<div data-testid="login-page">Login Page</div>} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
// Контент админки не должен быть доступен
|
||||
expect(screen.queryByTestId('admin-content')).not.toBeInTheDocument()
|
||||
// Должна показываться страница логина
|
||||
expect(screen.getByTestId('login-page')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import Footer from '../../src/components/Footer'
|
||||
|
||||
describe('Footer', () => {
|
||||
const renderFooter = (props: Partial<React.ComponentProps<typeof Footer>> = {}) => {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<Footer {...props} />
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
|
||||
it('должен рендериться с логотипом', () => {
|
||||
renderFooter()
|
||||
const logo = screen.getByAltText('Logo')
|
||||
expect(logo).toBeInTheDocument()
|
||||
expect(logo).toHaveAttribute('src', '/img/logo.png')
|
||||
})
|
||||
|
||||
it('должен отображать ссылку на документацию', () => {
|
||||
renderFooter()
|
||||
const docLink = screen.getByText('Документация')
|
||||
expect(docLink).toBeInTheDocument()
|
||||
expect(docLink).toHaveAttribute('href', 'https://3dp-manager.com/docs/intro')
|
||||
expect(docLink).toHaveAttribute('target', '_blank')
|
||||
expect(docLink).toHaveAttribute('rel', 'noopener')
|
||||
})
|
||||
|
||||
it('должен отображать иконку GitHub', () => {
|
||||
renderFooter()
|
||||
const githubButton = screen.getByLabelText('GitHub')
|
||||
expect(githubButton).toBeInTheDocument()
|
||||
expect(githubButton.closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
'https://github.com/denpiligrim/3dp-manager'
|
||||
)
|
||||
})
|
||||
|
||||
it('должен отображать иконку YouTube', () => {
|
||||
renderFooter()
|
||||
const youtubeButton = screen.getByLabelText('YouTube')
|
||||
expect(youtubeButton).toBeInTheDocument()
|
||||
expect(youtubeButton.closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
'https://youtube.com/@denpiligrim'
|
||||
)
|
||||
})
|
||||
|
||||
it('должен отображать иконку Telegram', () => {
|
||||
renderFooter()
|
||||
const telegramButton = screen.getByLabelText('Telegram')
|
||||
expect(telegramButton).toBeInTheDocument()
|
||||
expect(telegramButton.closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
'https://t.me/denpiligrim_web'
|
||||
)
|
||||
})
|
||||
|
||||
it('должен принимать prop isMobile', () => {
|
||||
renderFooter({ isMobile: true })
|
||||
expect(screen.getByAltText('Logo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен иметь правильный семантический тег footer', () => {
|
||||
renderFooter()
|
||||
expect(screen.getByRole('contentinfo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен иметь правильный layout с Grid', () => {
|
||||
renderFooter()
|
||||
// Проверяем, что все три колонки присутствуют
|
||||
expect(screen.getByText('Документация')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('GitHub')).toBeInTheDocument()
|
||||
expect(screen.getByAltText('Logo')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,213 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import Header from '../../src/components/Header'
|
||||
import { useThemeContext } from '../../src/ThemeContext'
|
||||
import { useAuth } from '../../src/auth/AuthContext'
|
||||
|
||||
vi.mock('../../src/ThemeContext', () => ({
|
||||
useThemeContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../src/auth/AuthContext', () => ({
|
||||
useAuth: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('Header', () => {
|
||||
const mockToggleColorMode = vi.fn()
|
||||
const mockLogout = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useThemeContext).mockReturnValue({
|
||||
mode: 'light',
|
||||
toggleColorMode: mockToggleColorMode,
|
||||
})
|
||||
vi.mocked(useAuth).mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
login: vi.fn(),
|
||||
logout: mockLogout,
|
||||
})
|
||||
})
|
||||
|
||||
const renderHeader = (props: Partial<React.ComponentProps<typeof Header>> = {}) => {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<Header {...props} />
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
|
||||
it('должен рендериться с логотипом и названием', () => {
|
||||
renderHeader()
|
||||
expect(screen.getByText('3DP-MANAGER')).toBeInTheDocument()
|
||||
const logo = screen.getByAltText('Logo')
|
||||
expect(logo).toBeInTheDocument()
|
||||
expect(logo).toHaveAttribute('src', '/img/logo.png')
|
||||
})
|
||||
|
||||
it('должен отображать иконку справки', () => {
|
||||
renderHeader()
|
||||
const helpButton = screen.getByLabelText('Справка о программе')
|
||||
expect(helpButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен открывать диалог справки при клике на иконку справки', async () => {
|
||||
renderHeader()
|
||||
const helpButton = screen.getByLabelText('Справка о программе')
|
||||
fireEvent.click(helpButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Об утилите 3DP-MANAGER')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText(/Утилита для автогенерации инбаундов/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен закрывать диалог справки при клике на кнопку "Понятно"', async () => {
|
||||
renderHeader()
|
||||
const helpButton = screen.getByLabelText('Справка о программе')
|
||||
fireEvent.click(helpButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Об утилите 3DP-MANAGER')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const closeButton = screen.getByText('Понятно')
|
||||
fireEvent.click(closeButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Об утилите 3DP-MANAGER')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен отображать иконку темы', () => {
|
||||
renderHeader()
|
||||
const themeButton = screen.getByLabelText('Режим: Светлая тема')
|
||||
expect(themeButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен вызывать toggleColorMode при клике на иконку темы', () => {
|
||||
renderHeader()
|
||||
const themeButton = screen.getByLabelText('Режим: Светлая тема')
|
||||
fireEvent.click(themeButton)
|
||||
expect(mockToggleColorMode).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('должен отображать правильную иконку для light mode', () => {
|
||||
vi.mocked(useThemeContext).mockReturnValue({
|
||||
mode: 'light',
|
||||
toggleColorMode: mockToggleColorMode,
|
||||
})
|
||||
renderHeader()
|
||||
expect(screen.getByLabelText('Режим: Светлая тема')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен отображать правильную иконку для dark mode', () => {
|
||||
vi.mocked(useThemeContext).mockReturnValue({
|
||||
mode: 'dark',
|
||||
toggleColorMode: mockToggleColorMode,
|
||||
})
|
||||
renderHeader()
|
||||
expect(screen.getByLabelText('Режим: Темная тема')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен отображать правильную иконку для system mode', () => {
|
||||
vi.mocked(useThemeContext).mockReturnValue({
|
||||
mode: 'system',
|
||||
toggleColorMode: mockToggleColorMode,
|
||||
})
|
||||
renderHeader()
|
||||
expect(screen.getByLabelText('Режим: Системная тема')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен отображать иконку выхода', () => {
|
||||
renderHeader()
|
||||
const logoutButton = screen.getByLabelText('Выйти из системы')
|
||||
expect(logoutButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен открывать диалог подтверждения при клике на выход', async () => {
|
||||
renderHeader()
|
||||
const logoutButton = screen.getByLabelText('Выйти из системы')
|
||||
fireEvent.click(logoutButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Вы действительно хотите выйти?')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен закрывать диалог подтверждения при клике на "Отмена"', async () => {
|
||||
renderHeader()
|
||||
const logoutButton = screen.getByLabelText('Выйти из системы')
|
||||
fireEvent.click(logoutButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Вы действительно хотите выйти?')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const cancelButton = screen.getByText('Отмена')
|
||||
fireEvent.click(cancelButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Вы действительно хотите выйти?')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен вызывать logout при подтверждении выхода', async () => {
|
||||
renderHeader()
|
||||
const logoutButton = screen.getByLabelText('Выйти из системы')
|
||||
fireEvent.click(logoutButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Вы действительно хотите выйти?')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const confirmButton = screen.getByText('Выйти')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
expect(mockLogout).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('должен принимать prop isMobile', () => {
|
||||
renderHeader({ isMobile: true })
|
||||
expect(screen.getByText('3DP-MANAGER')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен отображать кнопку меню в мобильном режиме', () => {
|
||||
const onMenuClick = vi.fn()
|
||||
renderHeader({ isMobile: true, onMenuClick })
|
||||
|
||||
const menuButton = screen.getByTestId('icon-Menu').closest('button')
|
||||
expect(menuButton).toBeInTheDocument()
|
||||
|
||||
if (menuButton) {
|
||||
fireEvent.click(menuButton)
|
||||
expect(onMenuClick).toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('должен отображать версию в диалоге справки', async () => {
|
||||
renderHeader()
|
||||
const helpButton = screen.getByLabelText('Справка о программе')
|
||||
fireEvent.click(helpButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Разработчик:/)).toBeInTheDocument()
|
||||
})
|
||||
// Версия отображается отдельным текстом с br переносом
|
||||
expect(screen.getByText(/\d+\.\d+\.\d+/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен отображать список возможностей в диалоге справки', async () => {
|
||||
renderHeader()
|
||||
const helpButton = screen.getByLabelText('Справка о программе')
|
||||
fireEvent.click(helpButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Автоматическая генерация')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('Управление подписками')).toBeInTheDocument()
|
||||
expect(screen.getByText('Белый список доменов')).toBeInTheDocument()
|
||||
expect(screen.getByText('Перенаправление')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
import Layout from '../../src/components/Layout'
|
||||
import { useThemeContext } from '../../src/ThemeContext'
|
||||
import { useAuth } from '../../src/auth/AuthContext'
|
||||
|
||||
vi.mock('../../src/ThemeContext', () => ({
|
||||
useThemeContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../src/auth/AuthContext', () => ({
|
||||
useAuth: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('Layout', () => {
|
||||
const mockToggleColorMode = vi.fn()
|
||||
const mockLogout = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useThemeContext).mockReturnValue({
|
||||
mode: 'light',
|
||||
toggleColorMode: mockToggleColorMode,
|
||||
})
|
||||
vi.mocked(useAuth).mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
login: vi.fn(),
|
||||
logout: mockLogout,
|
||||
})
|
||||
})
|
||||
|
||||
const renderLayout = (initialPath = '/') => {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<div data-testid="outlet">SubscriptionsPage</div>} />
|
||||
<Route path="domains" element={<div data-testid="outlet">DomainsPage</div>} />
|
||||
<Route path="tunnels" element={<div data-testid="outlet">TunnelsPage</div>} />
|
||||
<Route path="settings" element={<div data-testid="outlet">SettingsPage</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
|
||||
it('должен рендериться без ошибок', () => {
|
||||
renderLayout()
|
||||
expect(screen.getByTestId('outlet')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен отображать Header', () => {
|
||||
renderLayout()
|
||||
expect(screen.getByText('3DP-MANAGER')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен отображать навигационное меню', () => {
|
||||
renderLayout()
|
||||
expect(screen.getByText('Подписки')).toBeInTheDocument()
|
||||
expect(screen.getByText('Домены')).toBeInTheDocument()
|
||||
expect(screen.getByText('Перенаправление')).toBeInTheDocument()
|
||||
expect(screen.getByText('Настройки')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен выделять активный пункт меню для главной страницы', () => {
|
||||
renderLayout('/')
|
||||
const subscriptionsItem = screen.getByText('Подписки').closest('.Mui-selected')
|
||||
expect(subscriptionsItem).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен выделять активный пункт меню для страницы доменов', () => {
|
||||
renderLayout('/domains')
|
||||
const domainsItem = screen.getByText('Домены').closest('.Mui-selected')
|
||||
expect(domainsItem).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен выделять активный пункт меню для страницы туннелей', () => {
|
||||
renderLayout('/tunnels')
|
||||
const tunnelsItem = screen.getByText('Перенаправление').closest('.Mui-selected')
|
||||
expect(tunnelsItem).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен выделять активный пункт меню для страницы настроек', () => {
|
||||
renderLayout('/settings')
|
||||
const settingsItem = screen.getByText('Настройки').closest('.Mui-selected')
|
||||
expect(settingsItem).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен переходить на главную при клике на "Подписки"', () => {
|
||||
renderLayout('/domains')
|
||||
const subscriptionsLink = screen.getByText('Подписки')
|
||||
fireEvent.click(subscriptionsLink)
|
||||
expect(screen.getByText('SubscriptionsPage')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен переходить на страницу доменов при клике на "Домены"', () => {
|
||||
renderLayout('/')
|
||||
const domainsLink = screen.getByText('Домены')
|
||||
fireEvent.click(domainsLink)
|
||||
expect(screen.getByText('DomainsPage')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен переходить на страницу туннелей при клике на "Перенаправление"', () => {
|
||||
renderLayout('/')
|
||||
const tunnelsLink = screen.getByText('Перенаправление')
|
||||
fireEvent.click(tunnelsLink)
|
||||
expect(screen.getByText('TunnelsPage')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен переходить на страницу настроек при клике на "Настройки"', () => {
|
||||
renderLayout('/')
|
||||
const settingsLink = screen.getByText('Настройки')
|
||||
fireEvent.click(settingsLink)
|
||||
expect(screen.getByText('SettingsPage')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен отображать Footer', () => {
|
||||
renderLayout()
|
||||
expect(screen.getByRole('contentinfo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен иметь правильную структуру с main и Toolbar', () => {
|
||||
renderLayout()
|
||||
const main = screen.getByTestId('outlet').closest('main')
|
||||
expect(main).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен иметь правильные иконки для пунктов меню', () => {
|
||||
renderLayout()
|
||||
expect(screen.getByTestId('icon-People')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('icon-Dns')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('icon-SwapHoriz')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('icon-Settings')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import SecurityWarning from '../../src/components/SecurityWarning'
|
||||
|
||||
// Мок для navigator.clipboard
|
||||
const mockWriteText = vi.fn()
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: mockWriteText,
|
||||
},
|
||||
})
|
||||
|
||||
// Мок для document.execCommand (fallback для старых браузеров)
|
||||
const mockExecCommand = vi.fn()
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
document.execCommand = mockExecCommand
|
||||
})
|
||||
|
||||
describe('SecurityWarning', () => {
|
||||
const INSTALL_COMMAND = 'bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/install.sh)'
|
||||
|
||||
const renderWarning = () => {
|
||||
return render(<SecurityWarning />)
|
||||
}
|
||||
|
||||
describe('рендеринг', () => {
|
||||
it('должен рендериться с заголовком предупреждения', () => {
|
||||
renderWarning()
|
||||
expect(
|
||||
screen.getByText(/3DP-MANAGER работает в небезопасном режиме/i)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен отображать предупреждение о паролях', () => {
|
||||
renderWarning()
|
||||
expect(
|
||||
screen.getByText(/Не вводите реальные пароли от 3x-ui панели/i)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен отображать инструкцию по переустановке', () => {
|
||||
renderWarning()
|
||||
expect(
|
||||
screen.getByText(/Для безопасной работы переустановите 3DP-MANAGER с SSL-сертификатами/i)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен отображать команду установки', () => {
|
||||
renderWarning()
|
||||
expect(screen.getByText(INSTALL_COMMAND)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен отображать кнопку копирования команды', () => {
|
||||
renderWarning()
|
||||
expect(screen.getByText('Копировать')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен отображать иконку копирования', () => {
|
||||
renderWarning()
|
||||
expect(screen.getByTestId('icon-ContentCopy')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен иметь семантически правильный Alert', () => {
|
||||
renderWarning()
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('копирование команды', () => {
|
||||
it('должен копировать команду в буфер обмена при клике', async () => {
|
||||
mockWriteText.mockResolvedValue(undefined)
|
||||
|
||||
renderWarning()
|
||||
const copyButton = screen.getByText('Копировать')
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockWriteText).toHaveBeenCalledWith(INSTALL_COMMAND)
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать уведомление "Скопировано" после копирования', async () => {
|
||||
mockWriteText.mockResolvedValue(undefined)
|
||||
|
||||
renderWarning()
|
||||
const copyButton = screen.getByText('Копировать')
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Скопировано')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен использовать fallback при отсутствии navigator.clipboard', async () => {
|
||||
// Мок ошибки clipboard API
|
||||
mockWriteText.mockRejectedValue(new Error('Not supported'))
|
||||
|
||||
renderWarning()
|
||||
const copyButton = screen.getByText('Копировать')
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExecCommand).toHaveBeenCalledWith('copy')
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать уведомление "Скопировано" при использовании fallback', async () => {
|
||||
mockWriteText.mockRejectedValue(new Error('Not supported'))
|
||||
mockExecCommand.mockReturnValue(true)
|
||||
|
||||
renderWarning()
|
||||
const copyButton = screen.getByText('Копировать')
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Скопировано')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Snackbar уведомление', () => {
|
||||
it('должен показывать уведомление после копирования', async () => {
|
||||
mockWriteText.mockResolvedValue(undefined)
|
||||
|
||||
renderWarning()
|
||||
const copyButton = screen.getByText('Копировать')
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Скопировано')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен закрывать уведомление при клике на кнопку закрытия', async () => {
|
||||
mockWriteText.mockResolvedValue(undefined)
|
||||
|
||||
renderWarning()
|
||||
const copyButton = screen.getByText('Копировать')
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
// Ждём появления уведомления
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Скопировано')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Находим кнопку закрытия по иконке Close
|
||||
const closeButton = screen.getByTestId('icon-Close').closest('button')
|
||||
if (closeButton) {
|
||||
fireEvent.click(closeButton)
|
||||
}
|
||||
|
||||
// Уведомление закрыто
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Скопировано')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('стили компонента', () => {
|
||||
it('должен иметь variant="filled"', () => {
|
||||
renderWarning()
|
||||
const alert = screen.getByRole('alert')
|
||||
// Проверяем что Alert имеет filled стиль (проверка через классы MUI)
|
||||
expect(alert).toHaveClass('MuiAlert-filledWarning')
|
||||
})
|
||||
|
||||
it('должен отображать код в monospace шрифте', () => {
|
||||
renderWarning()
|
||||
const codeBlock = screen.getByText(INSTALL_COMMAND)
|
||||
expect(codeBlock.tagName).toBe('CODE')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
// Мок для window.matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
})
|
||||
|
||||
// Мок для scrollIntoView
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
|
||||
// Мок для IntersectionObserver
|
||||
window.IntersectionObserver = vi.fn(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}))
|
||||
@@ -0,0 +1,623 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import DomainsPage from '../../src/pages/DomainsPage'
|
||||
import { ThemeProvider } from '../../src/ThemeContext'
|
||||
import { AuthProvider } from '../../src/auth/AuthContext'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('../../src/api', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'debug').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
},
|
||||
writable: true,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const setupMockGet = (overrides?: {
|
||||
domains?: { data: unknown[]; total: number }
|
||||
capabilities?: unknown
|
||||
status?: unknown
|
||||
settings?: unknown
|
||||
}) => {
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url.startsWith('/domains?page=')) {
|
||||
return Promise.resolve({ data: overrides?.domains || { data: [], total: 0 } })
|
||||
}
|
||||
if (url === '/domains/scan/capabilities') {
|
||||
return Promise.resolve({ data: overrides?.capabilities || { scannerAvailable: false } })
|
||||
}
|
||||
if (url === '/domains/scan/status') {
|
||||
return Promise.resolve({ data: overrides?.status || { running: false } })
|
||||
}
|
||||
if (url === '/settings') {
|
||||
return Promise.resolve({ data: overrides?.settings || {} })
|
||||
}
|
||||
return Promise.resolve({ data: {} })
|
||||
})
|
||||
}
|
||||
|
||||
const renderDomainsPage = () => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<DomainsPage />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
describe('DomainsPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Рендеринг', () => {
|
||||
it('должен рендериться с заголовком', async () => {
|
||||
setupMockGet()
|
||||
renderDomainsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Белый список доменов (SNI)')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен отображать поле для добавления домена', async () => {
|
||||
setupMockGet()
|
||||
renderDomainsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Доменное имя')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен отображать кнопку "Добавить"', async () => {
|
||||
setupMockGet()
|
||||
renderDomainsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Добавить')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен отображать кнопку "Из файла"', async () => {
|
||||
setupMockGet()
|
||||
renderDomainsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Из файла')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен отображать таблицу доменов', async () => {
|
||||
setupMockGet({
|
||||
domains: { data: [{ id: 1, name: 'example.com' }], total: 1 }
|
||||
})
|
||||
renderDomainsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('example.com')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен отображать сообщение при отсутствии доменов', async () => {
|
||||
setupMockGet({ domains: { data: [], total: 0 } })
|
||||
renderDomainsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Нет доменов')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Загрузка данных', () => {
|
||||
it('должен загружать домены при монтировании', async () => {
|
||||
setupMockGet()
|
||||
renderDomainsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGet).toHaveBeenCalledWith('/domains?page=1&limit=10')
|
||||
})
|
||||
})
|
||||
|
||||
it('должен загружать возможности сканера', async () => {
|
||||
setupMockGet()
|
||||
renderDomainsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGet).toHaveBeenCalledWith('/domains/scan/capabilities')
|
||||
})
|
||||
})
|
||||
|
||||
it('должен загружать настройки', async () => {
|
||||
setupMockGet()
|
||||
renderDomainsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGet).toHaveBeenCalledWith('/settings')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Пагинация', () => {
|
||||
it('должен отображать пагинацию', async () => {
|
||||
setupMockGet({ domains: { data: [{ id: 1, name: 'test.com' }], total: 25 } })
|
||||
renderDomainsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен переключать страницу при клике на следующую', async () => {
|
||||
setupMockGet({ domains: { data: [{ id: 1, name: 'test.com' }], total: 25 } })
|
||||
renderDomainsPage()
|
||||
|
||||
const nextPageButton = await screen.findByLabelText('Go to next page')
|
||||
fireEvent.click(nextPageButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGet).toHaveBeenCalledWith('/domains?page=2&limit=10')
|
||||
})
|
||||
})
|
||||
|
||||
it('должен менять количество строк на странице', async () => {
|
||||
setupMockGet({ domains: { data: [{ id: 1, name: 'test.com' }], total: 25 } })
|
||||
renderDomainsPage()
|
||||
|
||||
const rowsPerPageSelect = await screen.findByLabelText('Доменов на странице:')
|
||||
fireEvent.mouseDown(rowsPerPageSelect)
|
||||
|
||||
const option = await screen.findByText('25')
|
||||
fireEvent.click(option)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGet).toHaveBeenCalledWith('/domains?page=1&limit=25')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Добавление домена', () => {
|
||||
it('должен позволять вводить домен', async () => {
|
||||
setupMockGet()
|
||||
renderDomainsPage()
|
||||
|
||||
const input = await screen.findByLabelText('Доменное имя')
|
||||
fireEvent.change(input, { target: { value: 'newdomain.com' } })
|
||||
|
||||
expect(input).toHaveValue('newdomain.com')
|
||||
})
|
||||
|
||||
it('должен добавлять домен при клике на кнопку', async () => {
|
||||
setupMockGet()
|
||||
mockPost.mockResolvedValue({})
|
||||
|
||||
renderDomainsPage()
|
||||
|
||||
const input = await screen.findByLabelText('Доменное имя')
|
||||
fireEvent.change(input, { target: { value: 'newdomain.com' } })
|
||||
|
||||
const addButton = screen.getByText('Добавить')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalledWith('/domains', { name: 'newdomain.com' })
|
||||
})
|
||||
})
|
||||
|
||||
it('должен очищать поле после добавления', async () => {
|
||||
setupMockGet()
|
||||
mockPost.mockResolvedValue({})
|
||||
|
||||
renderDomainsPage()
|
||||
|
||||
const input = await screen.findByLabelText('Доменное имя')
|
||||
fireEvent.change(input, { target: { value: 'newdomain.com' } })
|
||||
|
||||
const addButton = screen.getByText('Добавить')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(input).toHaveValue('')
|
||||
})
|
||||
})
|
||||
|
||||
it('должен перезагружать список после добавления', async () => {
|
||||
setupMockGet()
|
||||
mockPost.mockResolvedValue({})
|
||||
|
||||
renderDomainsPage()
|
||||
|
||||
const input = await screen.findByLabelText('Доменное имя')
|
||||
fireEvent.change(input, { target: { value: 'newdomain.com' } })
|
||||
|
||||
const addButton = screen.getByText('Добавить')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGet).toHaveBeenCalledWith('/domains?page=1&limit=10')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Удаление домена', () => {
|
||||
it('должен отображать кнопку удаления для домена', async () => {
|
||||
setupMockGet({
|
||||
domains: { data: [{ id: 1, name: 'test.com' }], total: 1 }
|
||||
})
|
||||
renderDomainsPage()
|
||||
|
||||
const deleteButton = await screen.findByTestId('icon-Delete')
|
||||
expect(deleteButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен удалять домен при клике на кнопку удаления', async () => {
|
||||
setupMockGet({
|
||||
domains: { data: [{ id: 1, name: 'test.com' }], total: 1 }
|
||||
})
|
||||
mockDelete.mockResolvedValue({})
|
||||
|
||||
renderDomainsPage()
|
||||
|
||||
const deleteButton = await screen.findByTestId('icon-Delete')
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDelete).toHaveBeenCalledWith('/domains/1')
|
||||
})
|
||||
})
|
||||
|
||||
it('должен перезагружать список после удаления', async () => {
|
||||
setupMockGet({
|
||||
domains: { data: [{ id: 1, name: 'test.com' }], total: 1 }
|
||||
})
|
||||
mockDelete.mockResolvedValue({})
|
||||
|
||||
renderDomainsPage()
|
||||
|
||||
const deleteButton = await screen.findByTestId('icon-Delete')
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGet).toHaveBeenCalledWith('/domains?page=1&limit=10')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Удаление всех доменов', () => {
|
||||
it('должен отображать кнопку "Удалить все"', async () => {
|
||||
setupMockGet({
|
||||
domains: { data: [{ id: 1, name: 'test.com' }], total: 1 }
|
||||
})
|
||||
renderDomainsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Удалить все')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен открывать диалог подтверждения удаления всех', async () => {
|
||||
setupMockGet({
|
||||
domains: { data: [{ id: 1, name: 'test.com' }], total: 1 }
|
||||
})
|
||||
mockDelete.mockResolvedValue({})
|
||||
|
||||
renderDomainsPage()
|
||||
|
||||
const deleteAllButton = await screen.findByText('Удалить все')
|
||||
fireEvent.click(deleteAllButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен закрывать диалог при клике на "Отмена"', async () => {
|
||||
setupMockGet({
|
||||
domains: { data: [{ id: 1, name: 'test.com' }], total: 1 }
|
||||
})
|
||||
mockDelete.mockResolvedValue({})
|
||||
|
||||
renderDomainsPage()
|
||||
|
||||
const deleteAllButton = await screen.findByText('Удалить все')
|
||||
fireEvent.click(deleteAllButton)
|
||||
|
||||
const cancelButton = await screen.findByText('Отмена')
|
||||
fireEvent.click(cancelButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Загрузка из файла', () => {
|
||||
it('должен отображать кнопку "Из файла"', async () => {
|
||||
setupMockGet()
|
||||
renderDomainsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Из файла')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен иметь скрытый input type="file"', async () => {
|
||||
setupMockGet()
|
||||
renderDomainsPage()
|
||||
|
||||
const fileInput = screen.getByTestId('file-input')
|
||||
expect(fileInput).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен загружать файл при выборе', async () => {
|
||||
setupMockGet()
|
||||
mockPost.mockResolvedValue({ data: { count: 5 } })
|
||||
|
||||
renderDomainsPage()
|
||||
|
||||
const fileInput = screen.getByTestId('file-input') as HTMLInputElement
|
||||
const file = new File(['example.com\ntest.org'], 'domains.txt', { type: 'text/plain' })
|
||||
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalledWith('/domains/upload', { domains: expect.any(Array) })
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать успех после загрузки файла', async () => {
|
||||
setupMockGet()
|
||||
mockPost.mockResolvedValue({ data: { count: 5 } })
|
||||
|
||||
renderDomainsPage()
|
||||
|
||||
const fileInput = screen.getByTestId('file-input') as HTMLInputElement
|
||||
const file = new File(['example.com\ntest.org'], 'domains.txt', { type: 'text/plain' })
|
||||
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Успешно добавлено доменов: 5')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Сканер доменов', () => {
|
||||
it('должен отображать кнопку "Сканировать" в аккордеоне', async () => {
|
||||
setupMockGet({
|
||||
capabilities: { scannerAvailable: true }
|
||||
})
|
||||
renderDomainsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Сканировать')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен запускать сканирование при валидных данных', async () => {
|
||||
setupMockGet({
|
||||
capabilities: { scannerAvailable: true },
|
||||
settings: { xui_ip: '1.2.3.4' }
|
||||
})
|
||||
mockPost.mockResolvedValue({ data: { runId: '123', foundCount: 5, domains: ['a.com', 'b.com'] } })
|
||||
|
||||
renderDomainsPage()
|
||||
|
||||
const scanButton = await screen.findByText('Сканировать')
|
||||
fireEvent.click(scanButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalledWith('/domains/scan/start', expect.objectContaining({
|
||||
addr: expect.any(String),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('должен очищать результаты сканирования', async () => {
|
||||
setupMockGet({
|
||||
capabilities: { scannerAvailable: true },
|
||||
settings: { xui_ip: '1.2.3.4' }
|
||||
})
|
||||
mockPost.mockResolvedValue({ data: { runId: '123', foundCount: 2, domains: ['a.com', 'b.com'] } })
|
||||
|
||||
renderDomainsPage()
|
||||
|
||||
const scanButton = await screen.findByText('Сканировать')
|
||||
fireEvent.click(scanButton)
|
||||
|
||||
const clearButton = await screen.findByText('Очистить предварительный')
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGet).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен изменять настройки сканирования', async () => {
|
||||
setupMockGet({
|
||||
capabilities: { scannerAvailable: true },
|
||||
settings: { xui_ip: '1.2.3.4' }
|
||||
})
|
||||
|
||||
renderDomainsPage()
|
||||
|
||||
const secondsInput = await screen.findByLabelText('Секунд скана')
|
||||
fireEvent.change(secondsInput, { target: { value: '120' } })
|
||||
expect(secondsInput).toHaveValue(120)
|
||||
|
||||
const threadInput = screen.getByLabelText('Потоков')
|
||||
fireEvent.change(threadInput, { target: { value: '8' } })
|
||||
expect(threadInput).toHaveValue(8)
|
||||
|
||||
const timeoutInput = screen.getByLabelText('Таймаут, сек')
|
||||
fireEvent.change(timeoutInput, { target: { value: '10' } })
|
||||
expect(timeoutInput).toHaveValue(10)
|
||||
})
|
||||
|
||||
it('должен разворачивать/сворачивать аккордеон сканера', async () => {
|
||||
setupMockGet({
|
||||
capabilities: { scannerAvailable: true }
|
||||
})
|
||||
|
||||
renderDomainsPage()
|
||||
|
||||
const accordionSummary = await screen.findByText('Автопоиск SNI (backend scanner)')
|
||||
|
||||
fireEvent.click(accordionSummary)
|
||||
|
||||
await waitFor(() => {
|
||||
const secondsInput = screen.getByLabelText('Секунд скана')
|
||||
expect(secondsInput).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать сообщение при пустом списке кандидатов после сканирования', async () => {
|
||||
setupMockGet({
|
||||
capabilities: { scannerAvailable: true },
|
||||
settings: { xui_ip: '1.2.3.4' }
|
||||
})
|
||||
mockPost.mockResolvedValue({ data: { runId: '123', foundCount: 0, domains: [] } })
|
||||
|
||||
renderDomainsPage()
|
||||
|
||||
const scanButton = await screen.findByText('Сканировать')
|
||||
fireEvent.click(scanButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Домены не найдены')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен сохранять состояние сканера в localStorage', async () => {
|
||||
const mockSetItem = vi.fn()
|
||||
const mockLocalStorage = {
|
||||
getItem: vi.fn().mockReturnValue(null),
|
||||
setItem: mockSetItem,
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
}
|
||||
Object.defineProperty(window, 'localStorage', { value: mockLocalStorage, writable: true })
|
||||
|
||||
setupMockGet({
|
||||
capabilities: { scannerAvailable: true },
|
||||
settings: { xui_ip: '1.2.3.4' }
|
||||
})
|
||||
|
||||
renderDomainsPage()
|
||||
|
||||
const addrInput = await screen.findByLabelText('IP/домен VPS')
|
||||
fireEvent.change(addrInput, { target: { value: 'new-addr.com' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetItem).toHaveBeenCalledWith(
|
||||
'domains_scan_state_v1',
|
||||
expect.any(String)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать кнопку удаления для домена в основном списке', async () => {
|
||||
setupMockGet({
|
||||
domains: { data: [{ id: 1, name: 'test.com' }], total: 1 }
|
||||
})
|
||||
|
||||
renderDomainsPage()
|
||||
|
||||
const deleteButtons = await screen.findAllByTestId('icon-Delete')
|
||||
expect(deleteButtons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('должен подтверждать удаление всех доменов', async () => {
|
||||
setupMockGet({
|
||||
domains: { data: [{ id: 1, name: 'test.com' }], total: 1 }
|
||||
})
|
||||
mockDelete.mockResolvedValue({})
|
||||
|
||||
renderDomainsPage()
|
||||
|
||||
const deleteAllButton = await screen.findByText('Удалить все')
|
||||
fireEvent.click(deleteAllButton)
|
||||
|
||||
const confirmButton = await screen.findByText('Удалить')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDelete).toHaveBeenCalledWith('/domains/all')
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать успех после удаления всех доменов', async () => {
|
||||
setupMockGet({
|
||||
domains: { data: [{ id: 1, name: 'test.com' }], total: 1 }
|
||||
})
|
||||
mockDelete.mockResolvedValue({})
|
||||
|
||||
renderDomainsPage()
|
||||
|
||||
const deleteAllButton = await screen.findByText('Удалить все')
|
||||
fireEvent.click(deleteAllButton)
|
||||
|
||||
const confirmButton = await screen.findByText('Удалить')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
const alerts = screen.getAllByRole('alert')
|
||||
const successAlert = alerts.find(alert => alert.textContent?.includes('Все домены удалены'))
|
||||
expect(successAlert).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать ошибку при неудачном удалении всех доменов', async () => {
|
||||
setupMockGet({
|
||||
domains: { data: [{ id: 1, name: 'test.com' }], total: 1 }
|
||||
})
|
||||
mockDelete.mockRejectedValue({ response: { status: 500 } })
|
||||
|
||||
renderDomainsPage()
|
||||
|
||||
const deleteAllButton = await screen.findByText('Удалить все')
|
||||
fireEvent.click(deleteAllButton)
|
||||
|
||||
const confirmButton = await screen.findByText('Удалить')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
const alerts = screen.getAllByRole('alert')
|
||||
const errorAlert = alerts.find(alert => alert.textContent?.includes('Ошибка удаления'))
|
||||
expect(errorAlert).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import LoginPage from '../../src/pages/LoginPage'
|
||||
import { ThemeProvider } from '../../src/ThemeContext'
|
||||
import { AuthProvider } from '../../src/auth/AuthContext'
|
||||
|
||||
const mockNavigate = vi.fn()
|
||||
const mockLogin = vi.fn()
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom')
|
||||
return {
|
||||
...(actual as object),
|
||||
useNavigate: () => mockNavigate,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../src/auth/AuthContext', async () => {
|
||||
const actual = await vi.importActual('../../src/auth/AuthContext')
|
||||
return {
|
||||
...(actual as object),
|
||||
useAuth: () => ({ login: mockLogin }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../src/api', () => ({
|
||||
default: {
|
||||
post: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const renderLoginPage = () => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<LoginPage />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
describe('LoginPage', () => {
|
||||
beforeEach(() => {
|
||||
mockNavigate.mockClear()
|
||||
mockLogin.mockClear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('должен отображать заголовок с версией', () => {
|
||||
renderLoginPage()
|
||||
expect(screen.getByText(/Вход в 3DP-MANAGER/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен отображать поля ввода логина и пароля', () => {
|
||||
renderLoginPage()
|
||||
expect(screen.getByLabelText('Логин')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Пароль')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен отображать кнопку "Войти"', () => {
|
||||
renderLoginPage()
|
||||
expect(screen.getByText('Войти')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен позволять вводить текст в поля', () => {
|
||||
renderLoginPage()
|
||||
const loginField = screen.getByLabelText('Логин')
|
||||
const passwordField = screen.getByLabelText('Пароль')
|
||||
|
||||
fireEvent.change(loginField, { target: { value: 'admin' } })
|
||||
fireEvent.change(passwordField, { target: { value: 'password123' } })
|
||||
|
||||
expect(loginField).toHaveValue('admin')
|
||||
expect(passwordField).toHaveValue('password123')
|
||||
})
|
||||
|
||||
it('должен показывать ошибку при неверных учетных данных', async () => {
|
||||
const api = await import('../../src/api')
|
||||
vi.mocked(api.default.post).mockRejectedValue({ response: { status: 401 } })
|
||||
|
||||
renderLoginPage()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Логин'), { target: { value: 'admin' } })
|
||||
fireEvent.change(screen.getByLabelText('Пароль'), { target: { value: 'wrong' } })
|
||||
fireEvent.click(screen.getByText('Войти'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Неверный логин или пароль')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен выполнять навигацию на главную при успешном входе', async () => {
|
||||
const api = await import('../../src/api')
|
||||
vi.mocked(api.default.post).mockResolvedValue({ data: { access_token: 'fake-token' } })
|
||||
|
||||
renderLoginPage()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Логин'), { target: { value: 'admin' } })
|
||||
fireEvent.change(screen.getByLabelText('Пароль'), { target: { value: 'password' } })
|
||||
fireEvent.click(screen.getByText('Войти'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLogin).toHaveBeenCalledWith('fake-token')
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import NotFoundPage from '../../src/pages/NotFoundPage'
|
||||
|
||||
const mockNavigate = vi.fn()
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom')
|
||||
return {
|
||||
...(actual as object),
|
||||
useNavigate: () => mockNavigate,
|
||||
}
|
||||
})
|
||||
|
||||
const renderNotFoundPage = () => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<NotFoundPage />
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
describe('NotFoundPage', () => {
|
||||
it('должен отображать код ошибки 404', () => {
|
||||
renderNotFoundPage()
|
||||
expect(screen.getByText('404')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен отображать сообщение "Страница не найдена"', () => {
|
||||
renderNotFoundPage()
|
||||
expect(screen.getByText('Страница не найдена')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен иметь кнопку "На главную"', () => {
|
||||
renderNotFoundPage()
|
||||
expect(screen.getByText('На главную')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('должен выполнять навигацию на главную при клике на кнопку', () => {
|
||||
renderNotFoundPage()
|
||||
fireEvent.click(screen.getByText('На главную'))
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,651 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import SettingsPage from '../../src/pages/SettingsPage'
|
||||
import { ThemeProvider } from '../../src/ThemeContext'
|
||||
import { AuthProvider } from '../../src/auth/AuthContext'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
|
||||
vi.mock('../../src/api', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
// Хелпер для настройки мока get по умолчанию
|
||||
const setupMockGet = (overrides?: { settings?: Record<string, unknown>, subscriptions?: unknown[] }) => {
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/settings') return Promise.resolve({ data: overrides?.settings || {} })
|
||||
if (url === '/subscriptions') return Promise.resolve({ data: overrides?.subscriptions || [] })
|
||||
return Promise.resolve({ data: {} })
|
||||
})
|
||||
}
|
||||
|
||||
const renderSettingsPage = () => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<SettingsPage />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
describe('SettingsPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Рендеринг', () => {
|
||||
it('должен рендериться с заголовком', async () => {
|
||||
setupMockGet()
|
||||
renderSettingsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Настройки утилиты')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен отображать секцию панели 3x-ui', async () => {
|
||||
setupMockGet()
|
||||
renderSettingsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Панель 3x-ui')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен отображать секцию генерации инбаундов', async () => {
|
||||
setupMockGet()
|
||||
renderSettingsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Генерация инбаундов')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен отображать поля ввода для 3x-ui', async () => {
|
||||
setupMockGet()
|
||||
renderSettingsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('URL панели')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Логин 3x-ui')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Пароль 3x-ui')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен отображать поле интервала генерации', async () => {
|
||||
setupMockGet()
|
||||
renderSettingsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Интервал генерации')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен отображать пресеты интервалов', async () => {
|
||||
setupMockGet()
|
||||
renderSettingsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Сутки')).toBeInTheDocument()
|
||||
expect(screen.getByText('3 дня')).toBeInTheDocument()
|
||||
expect(screen.getByText('Неделя')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Загрузка настроек', () => {
|
||||
it('должен загружать настройки при монтировании', async () => {
|
||||
const mockSettings = {
|
||||
xui_url: 'https://test.com:2053',
|
||||
xui_login: 'admin',
|
||||
xui_password: 'password',
|
||||
rotation_interval: '60',
|
||||
rotation_status: 'active',
|
||||
last_rotation_timestamp: '1234567890',
|
||||
}
|
||||
setupMockGet({ settings: mockSettings })
|
||||
renderSettingsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGet).toHaveBeenCalledWith('/settings')
|
||||
})
|
||||
})
|
||||
|
||||
it('должен загружать подписки при монтировании', async () => {
|
||||
const mockSubs = [
|
||||
{ id: '1', name: 'Test Sub', uuid: 'abc-123', isAutoRotationEnabled: true }
|
||||
]
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/subscriptions') return Promise.resolve({ data: mockSubs })
|
||||
return Promise.resolve({ data: {} })
|
||||
})
|
||||
|
||||
renderSettingsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGet).toHaveBeenCalledWith('/subscriptions')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Изменение полей', () => {
|
||||
it('должен позволять изменять URL панели', async () => {
|
||||
setupMockGet()
|
||||
renderSettingsPage()
|
||||
|
||||
const urlField = await screen.findByLabelText('URL панели')
|
||||
fireEvent.change(urlField, { target: { value: 'https://new-url.com:2053' } })
|
||||
|
||||
expect(urlField).toHaveValue('https://new-url.com:2053')
|
||||
})
|
||||
|
||||
it('должен позволять изменять логин 3x-ui', async () => {
|
||||
setupMockGet()
|
||||
renderSettingsPage()
|
||||
|
||||
const loginField = await screen.findByLabelText('Логин 3x-ui')
|
||||
fireEvent.change(loginField, { target: { value: 'newadmin' } })
|
||||
|
||||
expect(loginField).toHaveValue('newadmin')
|
||||
})
|
||||
|
||||
it('должен позволять изменять пароль 3x-ui', async () => {
|
||||
setupMockGet()
|
||||
renderSettingsPage()
|
||||
|
||||
const passwordField = await screen.findByLabelText('Пароль 3x-ui')
|
||||
fireEvent.change(passwordField, { target: { value: 'newpassword' } })
|
||||
|
||||
expect(passwordField).toHaveValue('newpassword')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Пресеты интервалов', () => {
|
||||
it('должен отображать пресеты интервалов', async () => {
|
||||
setupMockGet({ settings: { rotation_interval: '30' } })
|
||||
renderSettingsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Сутки')).toBeInTheDocument()
|
||||
expect(screen.getByText('3 дня')).toBeInTheDocument()
|
||||
expect(screen.getByText('Неделя')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Сохранение настроек подключения', () => {
|
||||
it('должен показывать ошибку при пустых полях', async () => {
|
||||
setupMockGet()
|
||||
renderSettingsPage()
|
||||
|
||||
const saveButton = await screen.findByText('Сохранить подключение')
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Заполните все поля/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен сохранять настройки при валидных данных', async () => {
|
||||
setupMockGet()
|
||||
mockPost.mockResolvedValue({ data: {} })
|
||||
|
||||
renderSettingsPage()
|
||||
|
||||
const urlField = await screen.findByLabelText('URL панели')
|
||||
const loginField = screen.getByLabelText('Логин 3x-ui')
|
||||
const passwordField = screen.getByLabelText('Пароль 3x-ui')
|
||||
|
||||
fireEvent.change(urlField, { target: { value: 'https://test.com:2053' } })
|
||||
fireEvent.change(loginField, { target: { value: 'admin' } })
|
||||
fireEvent.change(passwordField, { target: { value: 'password' } })
|
||||
|
||||
const saveButton = screen.getByText('Сохранить подключение')
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalledWith('/settings', expect.objectContaining({
|
||||
xui_url: 'https://test.com:2053',
|
||||
xui_login: 'admin',
|
||||
xui_password: 'password',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать сообщение об успехе после сохранения', async () => {
|
||||
setupMockGet()
|
||||
mockPost.mockResolvedValue({ data: {} })
|
||||
|
||||
renderSettingsPage()
|
||||
|
||||
const urlField = await screen.findByLabelText('URL панели')
|
||||
const loginField = screen.getByLabelText('Логин 3x-ui')
|
||||
const passwordField = screen.getByLabelText('Пароль 3x-ui')
|
||||
|
||||
fireEvent.change(urlField, { target: { value: 'https://test.com:2053' } })
|
||||
fireEvent.change(loginField, { target: { value: 'admin' } })
|
||||
fireEvent.change(passwordField, { target: { value: 'password' } })
|
||||
|
||||
const saveButton = screen.getByText('Сохранить подключение')
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Настройки сохранены!')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Проверка подключения', () => {
|
||||
it('должен проверять подключение при клике на кнопку "Проверить"', async () => {
|
||||
setupMockGet()
|
||||
mockPost.mockResolvedValue({ data: { success: true } })
|
||||
|
||||
renderSettingsPage()
|
||||
|
||||
const urlField = await screen.findByLabelText('URL панели')
|
||||
const loginField = screen.getByLabelText('Логин 3x-ui')
|
||||
const passwordField = screen.getByLabelText('Пароль 3x-ui')
|
||||
|
||||
fireEvent.change(urlField, { target: { value: 'https://test.com:2053' } })
|
||||
fireEvent.change(loginField, { target: { value: 'admin' } })
|
||||
fireEvent.change(passwordField, { target: { value: 'password' } })
|
||||
|
||||
const checkButton = screen.getByText('Проверить')
|
||||
fireEvent.click(checkButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalledWith('/settings/check', expect.objectContaining({
|
||||
xui_url: 'https://test.com:2053',
|
||||
xui_login: 'admin',
|
||||
xui_password: 'password',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать успех при успешной проверке', async () => {
|
||||
setupMockGet()
|
||||
mockPost.mockResolvedValue({ data: { success: true } })
|
||||
|
||||
renderSettingsPage()
|
||||
|
||||
const urlField = await screen.findByLabelText('URL панели')
|
||||
const loginField = screen.getByLabelText('Логин 3x-ui')
|
||||
const passwordField = screen.getByLabelText('Пароль 3x-ui')
|
||||
|
||||
fireEvent.change(urlField, { target: { value: 'https://test.com:2053' } })
|
||||
fireEvent.change(loginField, { target: { value: 'admin' } })
|
||||
fireEvent.change(passwordField, { target: { value: 'password' } })
|
||||
|
||||
const checkButton = screen.getByText('Проверить')
|
||||
fireEvent.click(checkButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Подключение успешно!')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать ошибку при неудачной проверке', async () => {
|
||||
setupMockGet()
|
||||
mockPost.mockResolvedValue({ data: { success: false } })
|
||||
|
||||
renderSettingsPage()
|
||||
|
||||
const urlField = await screen.findByLabelText('URL панели')
|
||||
const loginField = screen.getByLabelText('Логин 3x-ui')
|
||||
const passwordField = screen.getByLabelText('Пароль 3x-ui')
|
||||
|
||||
fireEvent.change(urlField, { target: { value: 'https://test.com:2053' } })
|
||||
fireEvent.change(loginField, { target: { value: 'admin' } })
|
||||
fireEvent.change(passwordField, { target: { value: 'wrong' } })
|
||||
|
||||
const checkButton = screen.getByText('Проверить')
|
||||
fireEvent.click(checkButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Ошибка/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Сохранение интервала', () => {
|
||||
it('должен сохранять интервал при клике на кнопку', async () => {
|
||||
setupMockGet()
|
||||
mockPost.mockResolvedValue({ data: {} })
|
||||
|
||||
renderSettingsPage()
|
||||
|
||||
const intervalField = await screen.findByLabelText('Интервал генерации')
|
||||
fireEvent.change(intervalField, { target: { value: '120' } })
|
||||
|
||||
const saveButton = screen.getByText('Применить интервал')
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalledWith('/settings', {
|
||||
rotation_interval: '120',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать сообщение об успехе после сохранения интервала', async () => {
|
||||
setupMockGet()
|
||||
mockPost.mockResolvedValue({ data: {} })
|
||||
|
||||
renderSettingsPage()
|
||||
|
||||
const intervalField = await screen.findByLabelText('Интервал генерации')
|
||||
fireEvent.change(intervalField, { target: { value: '120' } })
|
||||
|
||||
const saveButton = screen.getByText('Применить интервал')
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Интервал генерации применён!')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Принудительная ротация', () => {
|
||||
it('должен показывать диалог подтверждения при клике на "Сгенерировать сейчас"', async () => {
|
||||
setupMockGet()
|
||||
renderSettingsPage()
|
||||
|
||||
const rotateButton = await screen.findByText('Сгенерировать сейчас')
|
||||
fireEvent.click(rotateButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/ВНИМАНИЕ/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен выполнять ротацию при подтверждении', async () => {
|
||||
setupMockGet()
|
||||
mockPost.mockResolvedValue({ data: { success: true, message: 'Ротация выполнена' } })
|
||||
|
||||
renderSettingsPage()
|
||||
|
||||
const rotateButton = await screen.findByText('Сгенерировать сейчас')
|
||||
fireEvent.click(rotateButton)
|
||||
|
||||
const confirmButton = await screen.findByText('Сгенерировать')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalledWith('/rotation/rotate-all')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Управление авторотацией подписок', () => {
|
||||
it('должен отображать список подписок с чекбоксами', async () => {
|
||||
const mockSubs = [
|
||||
{ id: '1', name: 'Test Sub', uuid: 'abc-123', isAutoRotationEnabled: true }
|
||||
]
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/subscriptions') return Promise.resolve({ data: mockSubs })
|
||||
return Promise.resolve({ data: {} })
|
||||
})
|
||||
|
||||
renderSettingsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Sub')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен переключать авторотацию при клике на чекбокс', async () => {
|
||||
const mockSubs = [
|
||||
{ id: '1', name: 'Test Sub', uuid: 'abc-123', isAutoRotationEnabled: true }
|
||||
]
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/subscriptions') return Promise.resolve({ data: mockSubs })
|
||||
return Promise.resolve({ data: {} })
|
||||
})
|
||||
mockPut.mockResolvedValue({ data: {} })
|
||||
|
||||
renderSettingsPage()
|
||||
|
||||
const checkbox = await screen.findByRole('checkbox')
|
||||
fireEvent.click(checkbox)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPut).toHaveBeenCalledWith('/subscriptions/bulk-auto-rotation', expect.anything())
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать сообщение при включении авторотации', async () => {
|
||||
const mockSubs = [
|
||||
{ id: '1', name: 'Test Sub', uuid: 'abc-123', isAutoRotationEnabled: false }
|
||||
]
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/subscriptions') return Promise.resolve({ data: mockSubs })
|
||||
return Promise.resolve({ data: {} })
|
||||
})
|
||||
mockPut.mockResolvedValue({ data: {} })
|
||||
|
||||
renderSettingsPage()
|
||||
|
||||
const checkbox = await screen.findByRole('checkbox')
|
||||
fireEvent.click(checkbox)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Авторотация включена')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен выполнять ручную ротацию при клике на кнопку обновления', async () => {
|
||||
const mockSubs = [
|
||||
{ id: '1', name: 'Test Sub', uuid: 'abc-123', isAutoRotationEnabled: true }
|
||||
]
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/subscriptions') return Promise.resolve({ data: mockSubs })
|
||||
return Promise.resolve({ data: {} })
|
||||
})
|
||||
mockPost.mockResolvedValue({ data: { message: 'Ротация выполнена' } })
|
||||
|
||||
renderSettingsPage()
|
||||
|
||||
const refreshButton = await screen.findByRole('button', { name: /Обновить подписку вручную/i })
|
||||
fireEvent.click(refreshButton)
|
||||
|
||||
const confirmButton = await screen.findByText('Обновить')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalledWith('/rotation/rotate-one/1')
|
||||
})
|
||||
})
|
||||
|
||||
it('должен выполнять массовое включение авторотации', async () => {
|
||||
const mockSubs = [
|
||||
{ id: '1', name: 'Test Sub', uuid: 'abc-123', isAutoRotationEnabled: false }
|
||||
]
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/subscriptions') return Promise.resolve({ data: mockSubs })
|
||||
return Promise.resolve({ data: {} })
|
||||
})
|
||||
mockPut.mockResolvedValue({ data: { message: 'Настройки обновлены' } })
|
||||
|
||||
renderSettingsPage()
|
||||
|
||||
const enableAllButton = await screen.findByText('Включить для всех')
|
||||
fireEvent.click(enableAllButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPut).toHaveBeenCalledWith('/subscriptions/bulk-auto-rotation', expect.objectContaining({
|
||||
enabled: true,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('должен выполнять массовое выключение авторотации', async () => {
|
||||
const mockSubs = [
|
||||
{ id: '1', name: 'Test Sub', uuid: 'abc-123', isAutoRotationEnabled: true }
|
||||
]
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/subscriptions') return Promise.resolve({ data: mockSubs })
|
||||
return Promise.resolve({ data: {} })
|
||||
})
|
||||
mockPut.mockResolvedValue({ data: { message: 'Настройки обновлены' } })
|
||||
|
||||
renderSettingsPage()
|
||||
|
||||
const disableAllButton = await screen.findByText('Выключить для всех')
|
||||
fireEvent.click(disableAllButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPut).toHaveBeenCalledWith('/subscriptions/bulk-auto-rotation', expect.objectContaining({
|
||||
enabled: false,
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Обновление профиля администратора', () => {
|
||||
it('должен позволять изменять логин администратора', async () => {
|
||||
setupMockGet()
|
||||
renderSettingsPage()
|
||||
|
||||
const loginField = await screen.findByLabelText('Логин администратора')
|
||||
fireEvent.change(loginField, { target: { value: 'newadmin' } })
|
||||
|
||||
expect(loginField).toHaveValue('newadmin')
|
||||
})
|
||||
|
||||
it('должен позволять изменять пароль администратора', async () => {
|
||||
setupMockGet()
|
||||
renderSettingsPage()
|
||||
|
||||
const passwordField = await screen.findByLabelText('Новый пароль')
|
||||
fireEvent.change(passwordField, { target: { value: 'newpassword' } })
|
||||
|
||||
expect(passwordField).toHaveValue('newpassword')
|
||||
})
|
||||
|
||||
it('должен сохранять профиль администратора', async () => {
|
||||
setupMockGet()
|
||||
mockPost.mockResolvedValue({ data: {} })
|
||||
|
||||
renderSettingsPage()
|
||||
|
||||
const loginField = await screen.findByLabelText('Логин администратора')
|
||||
const passwordField = screen.getByLabelText('Новый пароль')
|
||||
|
||||
fireEvent.change(loginField, { target: { value: 'newadmin' } })
|
||||
fireEvent.change(passwordField, { target: { value: 'newpassword' } })
|
||||
|
||||
const saveButton = screen.getByText('Обновить профиль')
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalledWith('/auth/update-profile', expect.objectContaining({
|
||||
login: 'newadmin',
|
||||
password: 'newpassword',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать сообщение об успехе после обновления профиля', async () => {
|
||||
setupMockGet()
|
||||
mockPost.mockResolvedValue({ data: {} })
|
||||
|
||||
renderSettingsPage()
|
||||
|
||||
const loginField = await screen.findByLabelText('Логин администратора')
|
||||
const passwordField = screen.getByLabelText('Новый пароль')
|
||||
|
||||
fireEvent.change(loginField, { target: { value: 'newadmin' } })
|
||||
fireEvent.change(passwordField, { target: { value: 'newpassword' } })
|
||||
|
||||
const saveButton = screen.getByText('Обновить профиль')
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Профиль администратора обновлен!')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Пауза/возобновление ротации', () => {
|
||||
it('должен отображать статус "Активен" при active статусе', async () => {
|
||||
setupMockGet({ settings: { rotation_status: 'active' } })
|
||||
renderSettingsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Активен')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен отображать статус "Остановлен" при stopped статусе', async () => {
|
||||
setupMockGet({ settings: { rotation_status: 'stopped' } })
|
||||
renderSettingsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Остановлен')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен переключать статус при клике на кнопку паузы', async () => {
|
||||
setupMockGet({ settings: { rotation_status: 'active' } })
|
||||
mockPost.mockResolvedValue({ data: {} })
|
||||
|
||||
renderSettingsPage()
|
||||
|
||||
const pauseButton = await screen.findByRole('button', { name: 'Поставить на паузу' })
|
||||
fireEvent.click(pauseButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalledWith('/settings', expect.objectContaining({
|
||||
rotation_status: 'stopped',
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Отображение дат ротации', () => {
|
||||
it('должен отображать дату последней ротации', async () => {
|
||||
setupMockGet({ settings: { last_rotation_timestamp: '1234567890' } })
|
||||
renderSettingsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Последняя генерация')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен отображать дату следующей ротации', async () => {
|
||||
setupMockGet({ settings: {
|
||||
rotation_status: 'active',
|
||||
last_rotation_timestamp: '1234567890',
|
||||
rotation_interval: '60'
|
||||
} })
|
||||
renderSettingsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Следующая генерация')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен отображать "Пауза" для следующей ротации при stopped статусе', async () => {
|
||||
setupMockGet({ settings: { rotation_status: 'stopped' } })
|
||||
renderSettingsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Пауза')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,793 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import SubscriptionsPage from '../../src/pages/SubscriptionsPage'
|
||||
import { ThemeProvider } from '../../src/ThemeContext'
|
||||
import { AuthProvider } from '../../src/auth/AuthContext'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('../../src/api', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
const mockClipboardWriteText = vi.fn()
|
||||
Object.assign(navigator, { clipboard: { writeText: mockClipboardWriteText } })
|
||||
|
||||
const mockWindowOpen = vi.fn()
|
||||
const originalWindowOpen = window.open
|
||||
beforeEach(() => {
|
||||
window.open = mockWindowOpen
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'debug').mockImplementation(() => {})
|
||||
})
|
||||
afterEach(() => {
|
||||
window.open = originalWindowOpen
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const setupMockGet = (overrides?: {
|
||||
subscriptions?: unknown[]
|
||||
tunnels?: unknown[]
|
||||
domains?: unknown[]
|
||||
}) => {
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/subscriptions') return Promise.resolve({ data: overrides?.subscriptions || [] })
|
||||
if (url === '/tunnels') return Promise.resolve({ data: overrides?.tunnels || [] })
|
||||
if (url === '/domains/all') return Promise.resolve({ data: overrides?.domains || [] })
|
||||
return Promise.resolve({ data: {} })
|
||||
})
|
||||
}
|
||||
|
||||
const renderSubscriptionsPage = () => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<SubscriptionsPage />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
describe('SubscriptionsPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockClipboardWriteText.mockClear()
|
||||
mockWindowOpen.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Рендеринг', () => {
|
||||
it('должен рендериться с заголовком', async () => {
|
||||
setupMockGet()
|
||||
renderSubscriptionsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Подписки')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен отображать кнопку "Создать"', async () => {
|
||||
setupMockGet()
|
||||
renderSubscriptionsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Создать')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен отображать сообщение при отсутствии подписок', async () => {
|
||||
setupMockGet({ subscriptions: [] })
|
||||
renderSubscriptionsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Нет подписок')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен отображать список подписок', async () => {
|
||||
const mockSubs = [
|
||||
{ id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [{}, {}], isAutoRotationEnabled: true }
|
||||
]
|
||||
setupMockGet({ subscriptions: mockSubs })
|
||||
renderSubscriptionsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Sub')).toBeInTheDocument()
|
||||
expect(screen.getByText('abc-123')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен отображать количество инбаундов', async () => {
|
||||
const mockSubs = [
|
||||
{ id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [{}, {}, {}] }
|
||||
]
|
||||
setupMockGet({ subscriptions: mockSubs })
|
||||
renderSubscriptionsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('3')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен отображать чекбокс авторотации', async () => {
|
||||
const mockSubs = [
|
||||
{ id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [], isAutoRotationEnabled: true }
|
||||
]
|
||||
setupMockGet({ subscriptions: mockSubs })
|
||||
renderSubscriptionsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
const checkbox = screen.getByRole('checkbox')
|
||||
expect(checkbox).toBeChecked()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Загрузка данных', () => {
|
||||
it('должен загружать подписки при монтировании', async () => {
|
||||
setupMockGet()
|
||||
renderSubscriptionsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGet).toHaveBeenCalledWith('/subscriptions')
|
||||
})
|
||||
})
|
||||
|
||||
it('должен загружать туннели при монтировании', async () => {
|
||||
setupMockGet()
|
||||
renderSubscriptionsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGet).toHaveBeenCalledWith('/tunnels')
|
||||
})
|
||||
})
|
||||
|
||||
it('должен загружать домены при монтировании', async () => {
|
||||
setupMockGet()
|
||||
renderSubscriptionsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGet).toHaveBeenCalledWith('/domains/all')
|
||||
})
|
||||
})
|
||||
|
||||
it('должен фильтровать только установленные туннели', async () => {
|
||||
const mockTunnels = [
|
||||
{ id: 1, name: 'Tunnel 1', ip: '1.1.1.1', domain: '', isInstalled: true },
|
||||
{ id: 2, name: 'Tunnel 2', ip: '2.2.2.2', domain: '', isInstalled: false }
|
||||
]
|
||||
setupMockGet({ tunnels: mockTunnels })
|
||||
renderSubscriptionsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGet).toHaveBeenCalledWith('/tunnels')
|
||||
expect(mockGet).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Список подписок без выбора сервера', () => {
|
||||
it('не должен отображать верхний селектор сервера при наличии туннелей', async () => {
|
||||
const mockTunnels = [
|
||||
{ id: 1, name: 'Tunnel 1', ip: '1.1.1.1', domain: '', isInstalled: true }
|
||||
]
|
||||
setupMockGet({ tunnels: mockTunnels, subscriptions: [] })
|
||||
renderSubscriptionsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Основной сервер')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Диалог создания подписки', () => {
|
||||
it('должен открывать диалог при клике на "Создать"', async () => {
|
||||
setupMockGet()
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const createButton = await screen.findByText('Создать')
|
||||
fireEvent.click(createButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Новая подписка')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен закрывать диалог при клике на "Отмена"', async () => {
|
||||
setupMockGet()
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const createButton = await screen.findByText('Создать')
|
||||
fireEvent.click(createButton)
|
||||
|
||||
const cancelButton = await screen.findByText('Отмена')
|
||||
fireEvent.click(cancelButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Новая подписка')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен отображать поле имени подписки', async () => {
|
||||
setupMockGet()
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const createButton = await screen.findByText('Создать')
|
||||
fireEvent.click(createButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Имя подписки')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен отображать инбаунды по умолчанию', async () => {
|
||||
setupMockGet()
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const createButton = await screen.findByText('Создать')
|
||||
fireEvent.click(createButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Инбаунды (9/20)')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен позволять вводить имя подписки', async () => {
|
||||
setupMockGet()
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const createButton = await screen.findByText('Создать')
|
||||
fireEvent.click(createButton)
|
||||
|
||||
const nameField = await screen.findByLabelText('Имя подписки')
|
||||
fireEvent.change(nameField, { target: { value: 'My Subscription' } })
|
||||
|
||||
expect(nameField).toHaveValue('My Subscription')
|
||||
})
|
||||
|
||||
it('должен показывать ошибку при пустом имени', async () => {
|
||||
setupMockGet()
|
||||
mockPost.mockRejectedValue({})
|
||||
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const createButton = await screen.findByText('Создать')
|
||||
fireEvent.click(createButton)
|
||||
|
||||
const saveButton = await screen.findByText('Сохранить')
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Введите имя подписки')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Управление инбаундами в диалоге', () => {
|
||||
it('должен отображать инбаунды по умолчанию', async () => {
|
||||
setupMockGet()
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const createButton = await screen.findByText('Создать')
|
||||
fireEvent.click(createButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Инбаунды (9/20)')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен позволять вводить имя подписки', async () => {
|
||||
setupMockGet()
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const createButton = await screen.findByText('Создать')
|
||||
fireEvent.click(createButton)
|
||||
|
||||
const nameField = await screen.findByLabelText('Имя подписки')
|
||||
fireEvent.change(nameField, { target: { value: 'My Subscription' } })
|
||||
|
||||
expect(nameField).toHaveValue('My Subscription')
|
||||
})
|
||||
|
||||
it('должен добавлять новый инбаунд', async () => {
|
||||
setupMockGet()
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const createButton = await screen.findByText('Создать')
|
||||
fireEvent.click(createButton)
|
||||
|
||||
const addButton = await screen.findByText('Добавить инбаунд')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Инбаунды (10/20)')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен удалять все инбаунды кнопкой "Удалить все"', async () => {
|
||||
setupMockGet()
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const createButton = await screen.findByText('Создать')
|
||||
fireEvent.click(createButton)
|
||||
|
||||
const removeAllButton = await screen.findByText('Удалить все')
|
||||
fireEvent.click(removeAllButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Инбаунды (1/20)')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен запрещать добавление более 20 инбаундов', async () => {
|
||||
setupMockGet()
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const createButton = await screen.findByText('Создать')
|
||||
fireEvent.click(createButton)
|
||||
|
||||
for (let i = 0; i < 11; i++) {
|
||||
const addButton = screen.getByText('Добавить инбаунд')
|
||||
fireEvent.click(addButton)
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
const addButton = screen.getByText('Добавить инбаунд')
|
||||
expect(addButton).toBeDisabled()
|
||||
})
|
||||
}, 15000)
|
||||
})
|
||||
|
||||
describe('Сохранение подписки', () => {
|
||||
it('должен создавать новую подписку', async () => {
|
||||
setupMockGet()
|
||||
mockPost.mockResolvedValue({})
|
||||
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const createButton = await screen.findByText('Создать')
|
||||
fireEvent.click(createButton)
|
||||
|
||||
const nameField = await screen.findByLabelText('Имя подписки')
|
||||
fireEvent.change(nameField, { target: { value: 'New Sub' } })
|
||||
|
||||
const saveButton = screen.getByText('Сохранить')
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalledWith('/subscriptions', expect.objectContaining({
|
||||
name: 'New Sub',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать успех после создания', async () => {
|
||||
setupMockGet()
|
||||
mockPost.mockResolvedValue({})
|
||||
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const createButton = await screen.findByText('Создать')
|
||||
fireEvent.click(createButton)
|
||||
|
||||
const nameField = await screen.findByLabelText('Имя подписки')
|
||||
fireEvent.change(nameField, { target: { value: 'New Sub' } })
|
||||
|
||||
const saveButton = screen.getByText('Сохранить')
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Подписка создана')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать ошибку при неудачном сохранении', async () => {
|
||||
setupMockGet()
|
||||
mockPost.mockRejectedValue({ response: { data: { message: 'Ошибка сервера' } } })
|
||||
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const createButton = await screen.findByText('Создать')
|
||||
fireEvent.click(createButton)
|
||||
|
||||
const nameField = await screen.findByLabelText('Имя подписки')
|
||||
fireEvent.change(nameField, { target: { value: 'New Sub' } })
|
||||
|
||||
const saveButton = screen.getByText('Сохранить')
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Ошибка сервера')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Редактирование подписки', () => {
|
||||
it('должен открывать диалог редактирования', async () => {
|
||||
const mockSubs = [
|
||||
{ id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [{ type: 'vless', port: 443, sni: 'example.com' }], isAutoRotationEnabled: true }
|
||||
]
|
||||
setupMockGet({ subscriptions: mockSubs })
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const menuButton = await screen.findByTestId('icon-MoreVert')
|
||||
fireEvent.click(menuButton)
|
||||
|
||||
const editOption = await screen.findByText('Редактировать')
|
||||
fireEvent.click(editOption)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Редактировать подписку')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен загружать данные подписки в диалог', async () => {
|
||||
const mockSubs = [
|
||||
{ id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [{ type: 'vless', port: 443, sni: 'example.com' }], isAutoRotationEnabled: true }
|
||||
]
|
||||
setupMockGet({ subscriptions: mockSubs })
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const menuButton = await screen.findByTestId('icon-MoreVert')
|
||||
fireEvent.click(menuButton)
|
||||
|
||||
const editOption = await screen.findByText('Редактировать')
|
||||
fireEvent.click(editOption)
|
||||
|
||||
const nameField = await screen.findByLabelText('Имя подписки')
|
||||
expect(nameField).toHaveValue('Test Sub')
|
||||
})
|
||||
|
||||
it('должен обновлять подписку', async () => {
|
||||
const mockSubs = [
|
||||
{ id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [{ type: 'vless', port: 443, sni: 'example.com' }], isAutoRotationEnabled: true }
|
||||
]
|
||||
setupMockGet({ subscriptions: mockSubs })
|
||||
mockPut.mockResolvedValue({})
|
||||
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const menuButton = await screen.findByTestId('icon-MoreVert')
|
||||
fireEvent.click(menuButton)
|
||||
|
||||
const editOption = await screen.findByText('Редактировать')
|
||||
fireEvent.click(editOption)
|
||||
|
||||
const nameField = await screen.findByLabelText('Имя подписки')
|
||||
fireEvent.change(nameField, { target: { value: 'Updated Sub' } })
|
||||
|
||||
const saveButton = screen.getByText('Сохранить')
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPut).toHaveBeenCalledWith('/subscriptions/1', expect.objectContaining({
|
||||
name: 'Updated Sub',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать успех после обновления', async () => {
|
||||
const mockSubs = [
|
||||
{ id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [{ type: 'vless', port: 443, sni: 'example.com' }], isAutoRotationEnabled: true }
|
||||
]
|
||||
setupMockGet({ subscriptions: mockSubs })
|
||||
mockPut.mockResolvedValue({})
|
||||
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const menuButton = await screen.findByTestId('icon-MoreVert')
|
||||
fireEvent.click(menuButton)
|
||||
|
||||
const editOption = await screen.findByText('Редактировать')
|
||||
fireEvent.click(editOption)
|
||||
|
||||
const nameField = await screen.findByLabelText('Имя подписки')
|
||||
fireEvent.change(nameField, { target: { value: 'Updated Sub' } })
|
||||
|
||||
const saveButton = screen.getByText('Сохранить')
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Подписка обновлена')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Удаление подписки', () => {
|
||||
it('должен открывать диалог подтверждения удаления', async () => {
|
||||
const mockSubs = [
|
||||
{ id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [], isAutoRotationEnabled: true }
|
||||
]
|
||||
setupMockGet({ subscriptions: mockSubs })
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const menuButton = await screen.findByTestId('icon-MoreVert')
|
||||
fireEvent.click(menuButton)
|
||||
|
||||
const deleteOption = await screen.findByText('Удалить')
|
||||
fireEvent.click(deleteOption)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Удалить подписку и все соединения?')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен закрывать диалог при клике на "Отмена"', async () => {
|
||||
const mockSubs = [
|
||||
{ id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [], isAutoRotationEnabled: true }
|
||||
]
|
||||
setupMockGet({ subscriptions: mockSubs })
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const menuButton = await screen.findByTestId('icon-MoreVert')
|
||||
fireEvent.click(menuButton)
|
||||
|
||||
const deleteOption = await screen.findByText('Удалить')
|
||||
fireEvent.click(deleteOption)
|
||||
|
||||
const cancelButton = await screen.findByText('Отмена')
|
||||
fireEvent.click(cancelButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Удалить подписку и все соединения?')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен удалять подписку при подтверждении', async () => {
|
||||
const mockSubs = [
|
||||
{ id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [], isAutoRotationEnabled: true }
|
||||
]
|
||||
setupMockGet({ subscriptions: mockSubs })
|
||||
mockDelete.mockResolvedValue({})
|
||||
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const menuButton = await screen.findByTestId('icon-MoreVert')
|
||||
fireEvent.click(menuButton)
|
||||
|
||||
const deleteOption = await screen.findByText('Удалить')
|
||||
fireEvent.click(deleteOption)
|
||||
|
||||
const confirmButton = screen.getAllByText('Удалить')[1] as HTMLElement
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDelete).toHaveBeenCalledWith('/subscriptions/1')
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать успех после удаления', async () => {
|
||||
const mockSubs = [
|
||||
{ id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [], isAutoRotationEnabled: true }
|
||||
]
|
||||
setupMockGet({ subscriptions: mockSubs })
|
||||
mockDelete.mockResolvedValue({})
|
||||
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const menuButton = await screen.findByTestId('icon-MoreVert')
|
||||
fireEvent.click(menuButton)
|
||||
|
||||
const deleteOption = await screen.findByText('Удалить')
|
||||
fireEvent.click(deleteOption)
|
||||
|
||||
const confirmButton = screen.getAllByText('Удалить')[1] as HTMLElement
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Подписка удалена')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Переключение авторотации', () => {
|
||||
it('должен переключать авторотацию при клике на чекбокс', async () => {
|
||||
const mockSubs = [
|
||||
{ id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [], isAutoRotationEnabled: true }
|
||||
]
|
||||
setupMockGet({ subscriptions: mockSubs })
|
||||
mockPut.mockResolvedValue({})
|
||||
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const checkbox = await screen.findByRole('checkbox')
|
||||
fireEvent.click(checkbox)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPut).toHaveBeenCalledWith('/subscriptions/bulk-auto-rotation', expect.objectContaining({
|
||||
subscriptionIds: ['1'],
|
||||
enabled: false
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать успех при включении авторотации', async () => {
|
||||
const mockSubs = [
|
||||
{ id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [], isAutoRotationEnabled: false }
|
||||
]
|
||||
setupMockGet({ subscriptions: mockSubs })
|
||||
mockPut.mockResolvedValue({})
|
||||
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const checkbox = await screen.findByRole('checkbox')
|
||||
fireEvent.click(checkbox)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Авторотация включена')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать успех при выключении авторотации', async () => {
|
||||
const mockSubs = [
|
||||
{ id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [], isAutoRotationEnabled: true }
|
||||
]
|
||||
setupMockGet({ subscriptions: mockSubs })
|
||||
mockPut.mockResolvedValue({})
|
||||
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const checkbox = await screen.findByRole('checkbox')
|
||||
fireEvent.click(checkbox)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Авторотация выключена')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать ошибку при неудачном переключении', async () => {
|
||||
const mockSubs = [
|
||||
{ id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [], isAutoRotationEnabled: true }
|
||||
]
|
||||
setupMockGet({ subscriptions: mockSubs })
|
||||
mockPut.mockRejectedValue({ response: { data: { message: 'Ошибка' } } })
|
||||
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const checkbox = await screen.findByRole('checkbox')
|
||||
fireEvent.click(checkbox)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Ошибка')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Утилитные функции', () => {
|
||||
it('должен генерировать уникальный ID', async () => {
|
||||
setupMockGet()
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const createButton = await screen.findByText('Создать')
|
||||
fireEvent.click(createButton)
|
||||
|
||||
// Проверяем что ID генерируется (просто что форма открывается)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Новая подписка')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Просмотр ссылок', () => {
|
||||
it('должен открывать диалог ссылок при клике на опцию меню', async () => {
|
||||
const mockSubs = [
|
||||
{ id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [{ type: 'vless', port: 443, sni: 'example.com', link: 'vless://test' }], isAutoRotationEnabled: true }
|
||||
]
|
||||
setupMockGet({ subscriptions: mockSubs })
|
||||
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const menuButton = await screen.findByTestId('icon-MoreVert')
|
||||
fireEvent.click(menuButton)
|
||||
|
||||
const linksOption = await screen.findByText('Показать конфиги')
|
||||
fireEvent.click(linksOption)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Активные ссылки')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен закрывать диалог ссылок при клике на "Закрыть"', async () => {
|
||||
const mockSubs = [
|
||||
{ id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [{ type: 'vless', port: 443, sni: 'example.com', link: 'vless://test' }], isAutoRotationEnabled: true }
|
||||
]
|
||||
setupMockGet({ subscriptions: mockSubs })
|
||||
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const menuButton = await screen.findByTestId('icon-MoreVert')
|
||||
fireEvent.click(menuButton)
|
||||
|
||||
const linksOption = await screen.findByText('Показать конфиги')
|
||||
fireEvent.click(linksOption)
|
||||
|
||||
const closeButton = await screen.findByText('Закрыть')
|
||||
fireEvent.click(closeButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Активные ссылки')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен копировать ссылку при клике на кнопку копирования', async () => {
|
||||
const mockSubs = [
|
||||
{ id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [{ type: 'vless', port: 443, sni: 'example.com', link: 'vless://test' }], isAutoRotationEnabled: true }
|
||||
]
|
||||
setupMockGet({ subscriptions: mockSubs })
|
||||
mockClipboardWriteText.mockResolvedValue(undefined)
|
||||
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const menuButton = await screen.findByTestId('icon-MoreVert')
|
||||
fireEvent.click(menuButton)
|
||||
|
||||
const linksOption = await screen.findByText('Показать конфиги')
|
||||
fireEvent.click(linksOption)
|
||||
|
||||
const copyButton = await screen.findByText('Копировать все')
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockClipboardWriteText).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен открывать ссылку в новой вкладке при клике на иконку открытия', async () => {
|
||||
const mockSubs = [
|
||||
{ id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [{ type: 'vless', port: 443, sni: 'example.com', link: 'vless://test' }], isAutoRotationEnabled: true }
|
||||
]
|
||||
setupMockGet({ subscriptions: mockSubs })
|
||||
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const menuButton = await screen.findByTestId('icon-MoreVert')
|
||||
fireEvent.click(menuButton)
|
||||
|
||||
const linksOption = await screen.findByText('Показать конфиги')
|
||||
fireEvent.click(linksOption)
|
||||
|
||||
const openButtons = await screen.findAllByTestId('icon-OpenInNew')
|
||||
if (openButtons.length > 0) {
|
||||
fireEvent.click(openButtons[0])
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockWindowOpen).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать сообщение при отсутствии ссылок', async () => {
|
||||
const mockSubs = [
|
||||
{ id: '1', name: 'Test Sub', uuid: 'abc-123', inbounds: [], isAutoRotationEnabled: true }
|
||||
]
|
||||
setupMockGet({ subscriptions: mockSubs })
|
||||
|
||||
renderSubscriptionsPage()
|
||||
|
||||
const menuButton = await screen.findByTestId('icon-MoreVert')
|
||||
fireEvent.click(menuButton)
|
||||
|
||||
const linksOption = await screen.findByText('Показать конфиги')
|
||||
fireEvent.click(linksOption)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Нет активных ссылок (ждите ротации)')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,764 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import TunnelsPage from '../../src/pages/TunnelsPage'
|
||||
import { ThemeProvider } from '../../src/ThemeContext'
|
||||
import { AuthProvider } from '../../src/auth/AuthContext'
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('../../src/api', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'debug').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const setupMockGet = (overrides?: {
|
||||
tunnels?: unknown[]
|
||||
}) => {
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/tunnels') return Promise.resolve({ data: overrides?.tunnels || [] })
|
||||
return Promise.resolve({ data: {} })
|
||||
})
|
||||
}
|
||||
|
||||
const renderTunnelsPage = () => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<TunnelsPage />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
describe('TunnelsPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Рендеринг', () => {
|
||||
it('должен рендериться с заголовком', async () => {
|
||||
setupMockGet()
|
||||
renderTunnelsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Relay серверы')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен отображать кнопку "Добавить"', async () => {
|
||||
setupMockGet()
|
||||
renderTunnelsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Добавить')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен отображать сообщение при отсутствии серверов', async () => {
|
||||
setupMockGet({ tunnels: [] })
|
||||
renderTunnelsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Нет серверов')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен отображать список серверов', async () => {
|
||||
const mockTunnels = [
|
||||
{ id: 1, name: 'Test Server', ip: '192.168.1.1', sshPort: 22, username: 'root', isInstalled: true }
|
||||
]
|
||||
setupMockGet({ tunnels: mockTunnels })
|
||||
renderTunnelsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Server')).toBeInTheDocument()
|
||||
expect(screen.getByText('192.168.1.1')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен отображать статус "Активен" для установленного сервера', async () => {
|
||||
const mockTunnels = [
|
||||
{ id: 1, name: 'Active Server', ip: '192.168.1.1', sshPort: 22, username: 'root', isInstalled: true }
|
||||
]
|
||||
setupMockGet({ tunnels: mockTunnels })
|
||||
renderTunnelsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Активен')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен отображать статус "Не настроен" для неустановленного сервера', async () => {
|
||||
const mockTunnels = [
|
||||
{ id: 1, name: 'Inactive Server', ip: '192.168.1.1', sshPort: 22, username: 'root', isInstalled: false }
|
||||
]
|
||||
setupMockGet({ tunnels: mockTunnels })
|
||||
renderTunnelsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Не настроен')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Загрузка данных', () => {
|
||||
it('должен загружать туннели при монтировании', async () => {
|
||||
setupMockGet()
|
||||
renderTunnelsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGet).toHaveBeenCalledWith('/tunnels')
|
||||
})
|
||||
})
|
||||
|
||||
it('должен отображать несколько серверов', async () => {
|
||||
const mockTunnels = [
|
||||
{ id: 1, name: 'Server 1', ip: '1.1.1.1', sshPort: 22, username: 'root', isInstalled: true },
|
||||
{ id: 2, name: 'Server 2', ip: '2.2.2.2', sshPort: 2222, username: 'admin', isInstalled: false }
|
||||
]
|
||||
setupMockGet({ tunnels: mockTunnels })
|
||||
renderTunnelsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Server 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Server 2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Диалог создания сервера', () => {
|
||||
it('должен открывать диалог при клике на "Добавить"', async () => {
|
||||
setupMockGet()
|
||||
renderTunnelsPage()
|
||||
|
||||
const addButton = await screen.findByText('Добавить')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Новый редирект сервер')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен закрывать диалог при клике на "Отмена"', async () => {
|
||||
setupMockGet()
|
||||
renderTunnelsPage()
|
||||
|
||||
const addButton = await screen.findByText('Добавить')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
const cancelButton = await screen.findByText('Отмена')
|
||||
fireEvent.click(cancelButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Новый редирект сервер')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен отображать поля формы', async () => {
|
||||
setupMockGet()
|
||||
renderTunnelsPage()
|
||||
|
||||
const addButton = await screen.findByText('Добавить')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Название')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('IP адрес')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('SSH Порт')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('SSH User')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('SSH Пароль')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен отображать переключатель метода аутентификации', async () => {
|
||||
setupMockGet()
|
||||
renderTunnelsPage()
|
||||
|
||||
const addButton = await screen.findByText('Добавить')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('По паролю')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('По SSH ключу')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен переключаться на ввод SSH ключа', async () => {
|
||||
setupMockGet()
|
||||
renderTunnelsPage()
|
||||
|
||||
const addButton = await screen.findByText('Добавить')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
const keyRadio = await screen.findByLabelText('По SSH ключу')
|
||||
fireEvent.click(keyRadio)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/SSH Private Key/i)).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('SSH Пароль')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен позволять вводить название сервера', async () => {
|
||||
setupMockGet()
|
||||
renderTunnelsPage()
|
||||
|
||||
const addButton = await screen.findByText('Добавить')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
const nameField = await screen.findByLabelText('Название')
|
||||
fireEvent.change(nameField, { target: { value: 'My Server' } })
|
||||
|
||||
expect(nameField).toHaveValue('My Server')
|
||||
})
|
||||
|
||||
it('должен позволять вводить IP адрес', async () => {
|
||||
setupMockGet()
|
||||
renderTunnelsPage()
|
||||
|
||||
const addButton = await screen.findByText('Добавить')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
const ipField = await screen.findByLabelText('IP адрес')
|
||||
fireEvent.change(ipField, { target: { value: '10.0.0.1' } })
|
||||
|
||||
expect(ipField).toHaveValue('10.0.0.1')
|
||||
})
|
||||
|
||||
it('должен позволять вводить SSH порт', async () => {
|
||||
setupMockGet()
|
||||
renderTunnelsPage()
|
||||
|
||||
const addButton = await screen.findByText('Добавить')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
const portField = await screen.findByLabelText('SSH Порт')
|
||||
fireEvent.change(portField, { target: { value: '2222' } })
|
||||
|
||||
expect(portField).toHaveValue(2222)
|
||||
})
|
||||
|
||||
it('должен позволять вводить SSH пользователя', async () => {
|
||||
setupMockGet()
|
||||
renderTunnelsPage()
|
||||
|
||||
const addButton = await screen.findByText('Добавить')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
const userField = await screen.findByLabelText('SSH User')
|
||||
fireEvent.change(userField, { target: { value: 'admin' } })
|
||||
|
||||
expect(userField).toHaveValue('admin')
|
||||
})
|
||||
|
||||
it('должен позволять вводить SSH пароль', async () => {
|
||||
setupMockGet()
|
||||
renderTunnelsPage()
|
||||
|
||||
const addButton = await screen.findByText('Добавить')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
const passwordField = await screen.findByLabelText('SSH Пароль')
|
||||
fireEvent.change(passwordField, { target: { value: 'secret123' } })
|
||||
|
||||
expect(passwordField).toHaveValue('secret123')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Валидация формы', () => {
|
||||
it('должен показывать ошибку при пустом названии', async () => {
|
||||
setupMockGet()
|
||||
renderTunnelsPage()
|
||||
|
||||
const addButton = await screen.findByText('Добавить')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
const saveButton = await screen.findByText('Сохранить')
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Введите название сервера')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать ошибку при пустом IP', async () => {
|
||||
setupMockGet()
|
||||
renderTunnelsPage()
|
||||
|
||||
const addButton = await screen.findByText('Добавить')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
const nameField = await screen.findByLabelText('Название')
|
||||
fireEvent.change(nameField, { target: { value: 'Test' } })
|
||||
|
||||
const saveButton = screen.getByText('Сохранить')
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Введите IP адрес')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать ошибку при неверном формате IP', async () => {
|
||||
setupMockGet()
|
||||
renderTunnelsPage()
|
||||
|
||||
const addButton = await screen.findByText('Добавить')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
const nameField = await screen.findByLabelText('Название')
|
||||
fireEvent.change(nameField, { target: { value: 'Test' } })
|
||||
|
||||
const ipField = await screen.findByLabelText('IP адрес')
|
||||
fireEvent.change(ipField, { target: { value: 'invalid-ip' } })
|
||||
|
||||
const saveButton = screen.getByText('Сохранить')
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Неверный формат IP адреса')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать ошибку при неверном порте', async () => {
|
||||
setupMockGet()
|
||||
renderTunnelsPage()
|
||||
|
||||
const addButton = await screen.findByText('Добавить')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
const nameField = await screen.findByLabelText('Название')
|
||||
fireEvent.change(nameField, { target: { value: 'Test' } })
|
||||
|
||||
const ipField = await screen.findByLabelText('IP адрес')
|
||||
fireEvent.change(ipField, { target: { value: '1.1.1.1' } })
|
||||
|
||||
const portField = await screen.findByLabelText('SSH Порт')
|
||||
fireEvent.change(portField, { target: { value: '99999' } })
|
||||
|
||||
const saveButton = screen.getByText('Сохранить')
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Порт должен быть от 1 до 65535')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать ошибку при пустом пароле', async () => {
|
||||
setupMockGet()
|
||||
renderTunnelsPage()
|
||||
|
||||
const addButton = await screen.findByText('Добавить')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
const nameField = await screen.findByLabelText('Название')
|
||||
fireEvent.change(nameField, { target: { value: 'Test' } })
|
||||
|
||||
const ipField = await screen.findByLabelText('IP адрес')
|
||||
fireEvent.change(ipField, { target: { value: '1.1.1.1' } })
|
||||
|
||||
const saveButton = screen.getByText('Сохранить')
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Введите SSH пароль')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать ошибку при пустом SSH ключе', async () => {
|
||||
setupMockGet()
|
||||
renderTunnelsPage()
|
||||
|
||||
const addButton = await screen.findByText('Добавить')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
const keyRadio = await screen.findByLabelText('По SSH ключу')
|
||||
fireEvent.click(keyRadio)
|
||||
|
||||
const nameField = await screen.findByLabelText('Название')
|
||||
fireEvent.change(nameField, { target: { value: 'Test' } })
|
||||
|
||||
const ipField = await screen.findByLabelText('IP адрес')
|
||||
fireEvent.change(ipField, { target: { value: '1.1.1.1' } })
|
||||
|
||||
const saveButton = screen.getByText('Сохранить')
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Введите SSH ключ')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать ошибку при неверном формате SSH ключа', async () => {
|
||||
setupMockGet()
|
||||
renderTunnelsPage()
|
||||
|
||||
const addButton = await screen.findByText('Добавить')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
const keyRadio = await screen.findByLabelText('По SSH ключу')
|
||||
fireEvent.click(keyRadio)
|
||||
|
||||
const nameField = await screen.findByLabelText('Название')
|
||||
fireEvent.change(nameField, { target: { value: 'Test' } })
|
||||
|
||||
const ipField = await screen.findByLabelText('IP адрес')
|
||||
fireEvent.change(ipField, { target: { value: '1.1.1.1' } })
|
||||
|
||||
const keyField = await screen.findByLabelText(/SSH Private Key/i)
|
||||
fireEvent.change(keyField, { target: { value: 'invalid-key' } })
|
||||
|
||||
const saveButton = screen.getByText('Сохранить')
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Неверный формат SSH ключа')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Создание сервера', () => {
|
||||
it('должен создавать сервер с паролем', async () => {
|
||||
setupMockGet()
|
||||
mockPost.mockResolvedValue({})
|
||||
|
||||
renderTunnelsPage()
|
||||
|
||||
const addButton = await screen.findByText('Добавить')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
const nameField = await screen.findByLabelText('Название')
|
||||
fireEvent.change(nameField, { target: { value: 'New Server' } })
|
||||
|
||||
const ipField = await screen.findByLabelText('IP адрес')
|
||||
fireEvent.change(ipField, { target: { value: '1.2.3.4' } })
|
||||
|
||||
const portField = await screen.findByLabelText('SSH Порт')
|
||||
fireEvent.change(portField, { target: { value: '22' } })
|
||||
|
||||
const userField = await screen.findByLabelText('SSH User')
|
||||
fireEvent.change(userField, { target: { value: 'root' } })
|
||||
|
||||
const passwordField = await screen.findByLabelText('SSH Пароль')
|
||||
fireEvent.change(passwordField, { target: { value: 'secret123' } })
|
||||
|
||||
const saveButton = screen.getByText('Сохранить')
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalledWith('/tunnels', expect.objectContaining({
|
||||
name: 'New Server',
|
||||
ip: '1.2.3.4',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать успех после создания', async () => {
|
||||
setupMockGet()
|
||||
mockPost.mockResolvedValue({})
|
||||
|
||||
renderTunnelsPage()
|
||||
|
||||
const addButton = await screen.findByText('Добавить')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
const nameField = await screen.findByLabelText('Название')
|
||||
fireEvent.change(nameField, { target: { value: 'New Server' } })
|
||||
|
||||
const ipField = await screen.findByLabelText('IP адрес')
|
||||
fireEvent.change(ipField, { target: { value: '1.2.3.4' } })
|
||||
|
||||
const portField = await screen.findByLabelText('SSH Порт')
|
||||
fireEvent.change(portField, { target: { value: '22' } })
|
||||
|
||||
const userField = await screen.findByLabelText('SSH User')
|
||||
fireEvent.change(userField, { target: { value: 'root' } })
|
||||
|
||||
const passwordField = await screen.findByLabelText('SSH Пароль')
|
||||
fireEvent.change(passwordField, { target: { value: 'secret123' } })
|
||||
|
||||
const saveButton = screen.getByText('Сохранить')
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Сервер добавлен')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен закрывать диалог после создания', async () => {
|
||||
setupMockGet()
|
||||
mockPost.mockResolvedValue({})
|
||||
|
||||
renderTunnelsPage()
|
||||
|
||||
const addButton = await screen.findByText('Добавить')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
const nameField = await screen.findByLabelText('Название')
|
||||
fireEvent.change(nameField, { target: { value: 'New Server' } })
|
||||
|
||||
const ipField = await screen.findByLabelText('IP адрес')
|
||||
fireEvent.change(ipField, { target: { value: '1.2.3.4' } })
|
||||
|
||||
const portField = await screen.findByLabelText('SSH Порт')
|
||||
fireEvent.change(portField, { target: { value: '22' } })
|
||||
|
||||
const userField = await screen.findByLabelText('SSH User')
|
||||
fireEvent.change(userField, { target: { value: 'root' } })
|
||||
|
||||
const passwordField = await screen.findByLabelText('SSH Пароль')
|
||||
fireEvent.change(passwordField, { target: { value: 'secret123' } })
|
||||
|
||||
const saveButton = screen.getByText('Сохранить')
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Новый редирект сервер')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен очищать форму после создания', async () => {
|
||||
setupMockGet()
|
||||
mockPost.mockResolvedValue({})
|
||||
|
||||
renderTunnelsPage()
|
||||
|
||||
const addButton = await screen.findByText('Добавить')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
const nameField = await screen.findByLabelText('Название')
|
||||
fireEvent.change(nameField, { target: { value: 'New Server' } })
|
||||
|
||||
const ipField = await screen.findByLabelText('IP адрес')
|
||||
fireEvent.change(ipField, { target: { value: '1.2.3.4' } })
|
||||
|
||||
const portField = await screen.findByLabelText('SSH Порт')
|
||||
fireEvent.change(portField, { target: { value: '22' } })
|
||||
|
||||
const userField = await screen.findByLabelText('SSH User')
|
||||
fireEvent.change(userField, { target: { value: 'root' } })
|
||||
|
||||
const passwordField = await screen.findByLabelText('SSH Пароль')
|
||||
fireEvent.change(passwordField, { target: { value: 'secret123' } })
|
||||
|
||||
const saveButton = screen.getByText('Сохранить')
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Новый редирект сервер')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(addButton)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Название')).toHaveValue('')
|
||||
expect(screen.getByLabelText('IP адрес')).toHaveValue('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Удаление сервера', () => {
|
||||
it('должен открывать диалог подтверждения удаления', async () => {
|
||||
const mockTunnels = [
|
||||
{ id: 1, name: 'Test Server', ip: '1.1.1.1', sshPort: 22, username: 'root', isInstalled: true }
|
||||
]
|
||||
setupMockGet({ tunnels: mockTunnels })
|
||||
renderTunnelsPage()
|
||||
|
||||
const deleteButton = await screen.findByTestId('icon-Delete')
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Удалить сервер из списка?')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен закрывать диалог при клике на "Отмена"', async () => {
|
||||
const mockTunnels = [
|
||||
{ id: 1, name: 'Test Server', ip: '1.1.1.1', sshPort: 22, username: 'root', isInstalled: true }
|
||||
]
|
||||
setupMockGet({ tunnels: mockTunnels })
|
||||
renderTunnelsPage()
|
||||
|
||||
const deleteButton = await screen.findByTestId('icon-Delete')
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
const cancelButton = await screen.findByText('Отмена')
|
||||
fireEvent.click(cancelButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Удалить сервер из списка?')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен удалять сервер при подтверждении', async () => {
|
||||
const mockTunnels = [
|
||||
{ id: 1, name: 'Test Server', ip: '1.1.1.1', sshPort: 22, username: 'root', isInstalled: true }
|
||||
]
|
||||
setupMockGet({ tunnels: mockTunnels })
|
||||
mockDelete.mockResolvedValue({})
|
||||
|
||||
renderTunnelsPage()
|
||||
|
||||
const deleteButton = await screen.findByTestId('icon-Delete')
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
const confirmButton = screen.getByText('Подтвердить')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDelete).toHaveBeenCalledWith('/tunnels/1')
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать успех после удаления', async () => {
|
||||
const mockTunnels = [
|
||||
{ id: 1, name: 'Test Server', ip: '1.1.1.1', sshPort: 22, username: 'root', isInstalled: true }
|
||||
]
|
||||
setupMockGet({ tunnels: mockTunnels })
|
||||
mockDelete.mockResolvedValue({})
|
||||
|
||||
renderTunnelsPage()
|
||||
|
||||
const deleteButton = await screen.findByTestId('icon-Delete')
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
const confirmButton = screen.getByText('Подтвердить')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Сервер удалён')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Установка перенаправления', () => {
|
||||
it('должен отображать кнопку "Установить" для неустановленного сервера', async () => {
|
||||
const mockTunnels = [
|
||||
{ id: 1, name: 'Test Server', ip: '1.1.1.1', sshPort: 22, username: 'root', isInstalled: false }
|
||||
]
|
||||
setupMockGet({ tunnels: mockTunnels })
|
||||
renderTunnelsPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Установить')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен открывать диалог подтверждения установки', async () => {
|
||||
const mockTunnels = [
|
||||
{ id: 1, name: 'Test Server', ip: '1.1.1.1', sshPort: 22, username: 'root', isInstalled: false }
|
||||
]
|
||||
setupMockGet({ tunnels: mockTunnels })
|
||||
renderTunnelsPage()
|
||||
|
||||
const installButton = await screen.findByText('Установить')
|
||||
fireEvent.click(installButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Начать установку перенаправления на этот сервер?')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен закрывать диалог установки при клике на "Отмена"', async () => {
|
||||
const mockTunnels = [
|
||||
{ id: 1, name: 'Test Server', ip: '1.1.1.1', sshPort: 22, username: 'root', isInstalled: false }
|
||||
]
|
||||
setupMockGet({ tunnels: mockTunnels })
|
||||
renderTunnelsPage()
|
||||
|
||||
const installButton = await screen.findByText('Установить')
|
||||
fireEvent.click(installButton)
|
||||
|
||||
const cancelButton = await screen.findByText('Отмена')
|
||||
fireEvent.click(cancelButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Начать установку перенаправления')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен устанавливать перенаправление при подтверждении', async () => {
|
||||
const mockTunnels = [
|
||||
{ id: 1, name: 'Test Server', ip: '1.1.1.1', sshPort: 22, username: 'root', isInstalled: false }
|
||||
]
|
||||
setupMockGet({ tunnels: mockTunnels })
|
||||
mockPost.mockResolvedValue({})
|
||||
|
||||
renderTunnelsPage()
|
||||
|
||||
const installButton = await screen.findByText('Установить')
|
||||
fireEvent.click(installButton)
|
||||
|
||||
const confirmButton = screen.getByText('Подтвердить')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalledWith('/tunnels/1/install')
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать успех после установки', async () => {
|
||||
const mockTunnels = [
|
||||
{ id: 1, name: 'Test Server', ip: '1.1.1.1', sshPort: 22, username: 'root', isInstalled: false }
|
||||
]
|
||||
setupMockGet({ tunnels: mockTunnels })
|
||||
mockPost.mockResolvedValue({})
|
||||
|
||||
renderTunnelsPage()
|
||||
|
||||
const installButton = await screen.findByText('Установить')
|
||||
fireEvent.click(installButton)
|
||||
|
||||
const confirmButton = screen.getByText('Подтвердить')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Скрипт успешно установлен! Трафик перенаправляется.')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('должен показывать ошибку при неудачной установке', async () => {
|
||||
const mockTunnels = [
|
||||
{ id: 1, name: 'Test Server', ip: '1.1.1.1', sshPort: 22, username: 'root', isInstalled: false }
|
||||
]
|
||||
setupMockGet({ tunnels: mockTunnels })
|
||||
mockPost.mockRejectedValue({ response: { data: { message: 'Ошибка подключения' } } })
|
||||
|
||||
renderTunnelsPage()
|
||||
|
||||
const installButton = await screen.findByText('Установить')
|
||||
fireEvent.click(installButton)
|
||||
|
||||
const confirmButton = screen.getByText('Подтвердить')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Ошибка: Ошибка подключения')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { afterEach, vi } from 'vitest'
|
||||
import './mocks'
|
||||
|
||||
// Очищать DOM после каждого теста
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
// Подавляем шумные console.log/console.error во время тестов
|
||||
// Оставляем только важные ошибки через test.skip()
|
||||
const originalConsoleLog = console.log
|
||||
const originalConsoleError = console.error
|
||||
const originalConsoleWarn = console.warn
|
||||
|
||||
beforeAll(() => {
|
||||
// Фильтруем шумные логи от приложений
|
||||
console.log = (...args) => {
|
||||
const message = args.join(' ')
|
||||
// Пропускаем логи от компонентов которые шумят
|
||||
if (
|
||||
message.includes('[Tunnels]') ||
|
||||
message.includes('[Settings]') ||
|
||||
message.includes('[API]') ||
|
||||
message.includes('[Login]') ||
|
||||
message.includes('[AuthContext]') ||
|
||||
message.includes('[AxiosInterceptor]') ||
|
||||
message.includes('[Rotation]') ||
|
||||
message.includes('[Domains]') ||
|
||||
message.includes('[Subs]') ||
|
||||
message.includes('[Scanner]')
|
||||
) {
|
||||
return
|
||||
}
|
||||
originalConsoleLog(...args)
|
||||
}
|
||||
|
||||
// Подавляем console.warn для известных предупреждений
|
||||
console.warn = (...args) => {
|
||||
const message = args.join(' ')
|
||||
if (
|
||||
message.includes('[Tunnels]') ||
|
||||
message.includes('[Settings]') ||
|
||||
message.includes('[AuthContext]') ||
|
||||
message.includes('[AxiosInterceptor]') ||
|
||||
message.includes('[Domains]') ||
|
||||
message.includes('[Subs]') ||
|
||||
message.includes('[Scanner]')
|
||||
) {
|
||||
return
|
||||
}
|
||||
originalConsoleWarn(...args)
|
||||
}
|
||||
|
||||
// Подавляем console.error для известных предупреждений React
|
||||
console.error = (...args) => {
|
||||
const message = args.join(' ')
|
||||
// Пропускаем предупреждения act(...) - они не критичны
|
||||
if (
|
||||
message.includes('act(...)') ||
|
||||
message.includes('An update to') ||
|
||||
message.includes('Not implemented: navigation to another Document') ||
|
||||
message.includes('[Login]') ||
|
||||
message.includes('[Settings]') ||
|
||||
message.includes('[AuthContext]') ||
|
||||
message.includes('[AxiosInterceptor]') ||
|
||||
message.includes('[Subs]')
|
||||
) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
}
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
// Восстанавливаем console после всех тестов
|
||||
console.log = originalConsoleLog
|
||||
console.error = originalConsoleError
|
||||
console.warn = originalConsoleWarn
|
||||
})
|
||||
|
||||
// Мок для MUI icons-material - используем vi.mock с factory
|
||||
vi.mock('@mui/icons-material', async () => {
|
||||
const React = await import('react')
|
||||
|
||||
const createIconMock = (name: string) => {
|
||||
const IconMock = (props: Record<string, unknown>) => {
|
||||
return React.createElement('span', {
|
||||
'data-testid': `icon-${name}`,
|
||||
...props
|
||||
}, name)
|
||||
}
|
||||
IconMock.displayName = name
|
||||
return IconMock
|
||||
}
|
||||
|
||||
// Создаем мок для всех иконок
|
||||
const mock: Record<string, unknown> = {}
|
||||
const icons = [
|
||||
'GitHub', 'YouTube', 'Telegram', 'Brightness7', 'Brightness4', 'BrightnessAuto',
|
||||
'Logout', 'HelpOutline', 'Menu', 'People', 'Settings', 'Dns', 'SwapHoriz',
|
||||
'Delete', 'Add', 'Terminal', 'CheckCircle', 'Error', 'LinkIcon', 'OpenInNew',
|
||||
'ContentCopy', 'Router', 'Edit', 'MoreVert', 'Remove', 'Refresh', 'Search',
|
||||
'FilterList', 'Warning', 'Info', 'Close', 'Check', 'ArrowDownward', 'ArrowUpward',
|
||||
'MoreHoriz', 'ContentPaste', 'QrCode', 'Usb', 'VpnKey', 'Security', 'Speed',
|
||||
'Timeline', 'Assessment', 'SettingsApplications', 'CloudDownload', 'CloudUpload',
|
||||
'Folder', 'FileCopy', 'Save', 'Print', 'DeleteOutline', 'Restore', 'History',
|
||||
'Schedule', 'AccessTime', 'Today', 'Event', 'Notifications', 'AccountCircle',
|
||||
'Person', 'Group', 'Public', 'Language', 'Translate', 'Star', 'Favorite',
|
||||
'Home', 'LocationOn', 'Place', 'Email', 'Phone', 'Chat', 'Message', 'Forum',
|
||||
'Share', 'Send', 'Inbox', 'Drafts', 'Mail', 'Markunread', 'Lock', 'LockOpen',
|
||||
'Unlock', 'Visibility', 'VisibilityOff', 'ToggleOn', 'ToggleOff',
|
||||
'RadioButtonChecked', 'RadioButtonUnchecked', 'CheckBox', 'CheckBoxOutlineBlank',
|
||||
'PlusOne', 'ThumbUp', 'ThumbDown', 'Whatshot', 'FavoriteBorder', 'StarBorder',
|
||||
'Bookmark', 'BookmarkBorder', 'Bookmarks', 'TurnedIn', 'TurnedInNot', 'Label',
|
||||
'LabelImportant', 'Grade', 'Done', 'Clear', 'Block', 'Stop', 'Pause', 'PlayArrow',
|
||||
'FastForward', 'FastRewind', 'SkipNext', 'SkipPrevious', 'FiberManualRecord',
|
||||
'Circle', 'NavigateNext', 'NavigateBefore', 'ChevronRight', 'ChevronLeft',
|
||||
'ExpandMore', 'ExpandLess', 'UnfoldMore', 'UnfoldLess', 'ArrowRight', 'ArrowLeft',
|
||||
'ArrowBack', 'ArrowForward', 'ArrowDropDown', 'ArrowDropUp', 'Expand',
|
||||
'FileDownload', 'FileUpload', 'UploadFile', 'Download', 'Attachment', 'Link', 'InsertLink', 'Photo',
|
||||
'Image', 'PictureAsPdf', 'ImageIcon', 'CameraAlt', 'Videocam', 'Movie',
|
||||
'MusicNote', 'Mic', 'VolumeUp', 'VolumeOff', 'Headset', 'Headphones', 'Speaker',
|
||||
'Radio', 'Podcasts', 'Tv', 'DesktopWindows', 'Laptop', 'Computer', 'Tablet',
|
||||
'Smartphone', 'PhoneIphone', 'PhoneAndroid', 'Devices', 'SmartDisplay', 'Monitor',
|
||||
'ScreenShare', 'StopScreenShare', 'PresentToAll', 'Cast', 'CastConnected',
|
||||
'Wifi', 'WifiOff', 'NetworkWifi', 'NetworkCell', 'SignalCellular4Bar',
|
||||
'SignalWifi4Bar', 'Bluetooth', 'BluetoothConnected', 'BluetoothDisabled',
|
||||
'GpsFixed', 'GpsNotFixed', 'LocationSearching', 'MyLocation', 'Navigation',
|
||||
'NearMe', 'Directions', 'DirectionsCar', 'DirectionsBus', 'DirectionsTrain',
|
||||
'DirectionsBike', 'DirectionsWalk', 'DirectionsRun', 'Flight', 'LocalAirport',
|
||||
'Hotel', 'Restaurant', 'LocalCafe', 'LocalBar', 'LocalPizza', 'BrunchDining',
|
||||
'DinnerDining', 'LunchDining', 'Nightlife', 'LocalHospital', 'LocalPharmacy',
|
||||
'ShoppingBag', 'ShoppingCart', 'ShoppingBasket', 'Store', 'Shop', 'Storefront',
|
||||
'LocalMall', 'AccountBalance', 'Business', 'CorporateFare', 'Work', 'MeetingRoom',
|
||||
'Gite', 'House', 'Cottage', 'Apartment', 'Villa', 'OtherHouses', 'Foundation',
|
||||
'Fence', 'Yard', 'Pool', 'HotTub', 'Spa', 'FitnessCenter', 'SportsGymnasium',
|
||||
'SportsBasketball', 'SportsFootball', 'SportsSoccer', 'SportsTennis',
|
||||
'SportsVolleyball', 'SportsBaseball', 'SportsCricket', 'SportsGolf', 'SportsHockey',
|
||||
'SportsMma', 'SportsMotorsports', 'SportsRugby', 'SportsScore', 'SportsHandball',
|
||||
'SportsKabaddi', 'Rowing', 'Surfing', 'Kitesurfing', 'Snowboarding',
|
||||
'DownhillSkiing', 'Snowshoeing', 'IceSkating', 'Curling', 'Sailing', 'Kayaking',
|
||||
'Rafting', 'ScubaDiving', 'Diving', 'Fishing', 'Hiking', 'RunningWithErrors',
|
||||
'PlayCircleFilled', 'PauseCircleFilled', 'RefreshTwoTone', 'SubdirectoryArrowRight',
|
||||
'SubdirectoryArrowLeft', 'SettingsInputComponent', 'SettingsInputComponentOutlined',
|
||||
'Dns',
|
||||
]
|
||||
|
||||
icons.forEach(name => {
|
||||
mock[name] = createIconMock(name)
|
||||
})
|
||||
|
||||
mock.default = createIconMock('DefaultIcon')
|
||||
return mock
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { RenderOptions, render } from '@testing-library/react'
|
||||
import { ReactElement, ReactNode } from 'react'
|
||||
import { ThemeProvider } from '../src/ThemeContext'
|
||||
import { AuthProvider } from '../src/auth/AuthContext'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
|
||||
interface AllProvidersProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
function AllProviders({ children }: AllProvidersProps) {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
|
||||
wrapper?: ReactElement
|
||||
}
|
||||
|
||||
export function customRender(
|
||||
ui: ReactElement,
|
||||
options?: CustomRenderOptions
|
||||
) {
|
||||
return render(ui, {
|
||||
wrapper: AllProviders,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
// Переэкспортируем всё из @testing-library/react
|
||||
export * from '@testing-library/react'
|
||||
|
||||
// Переопределяем render с нашими провайдерами
|
||||
export { customRender as render }
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getDesignTokens } from '@/theme'
|
||||
|
||||
describe('theme', () => {
|
||||
describe('getDesignTokens', () => {
|
||||
it('должен возвращать объект с palette, typography, shape и components для light mode', () => {
|
||||
const tokens = getDesignTokens('light')
|
||||
|
||||
expect(tokens).toHaveProperty('palette')
|
||||
expect(tokens).toHaveProperty('typography')
|
||||
expect(tokens).toHaveProperty('shape')
|
||||
expect(tokens).toHaveProperty('components')
|
||||
})
|
||||
|
||||
it('должен возвращать объект с palette, typography, shape и components для dark mode', () => {
|
||||
const tokens = getDesignTokens('dark')
|
||||
|
||||
expect(tokens).toHaveProperty('palette')
|
||||
expect(tokens).toHaveProperty('typography')
|
||||
expect(tokens).toHaveProperty('shape')
|
||||
expect(tokens).toHaveProperty('components')
|
||||
})
|
||||
|
||||
it('должен устанавливать правильный mode в palette', () => {
|
||||
const lightTokens = getDesignTokens('light')
|
||||
const darkTokens = getDesignTokens('dark')
|
||||
|
||||
expect(lightTokens.palette.mode).toBe('light')
|
||||
expect(darkTokens.palette.mode).toBe('dark')
|
||||
})
|
||||
|
||||
it('должен использовать lightPalette для light mode', () => {
|
||||
const tokens = getDesignTokens('light')
|
||||
|
||||
expect(tokens.palette.primary.main).toBe('#1395de')
|
||||
expect(tokens.palette.background.default).toBe('#f3f4f6')
|
||||
expect(tokens.palette.background.paper).toBe('#ffffff')
|
||||
})
|
||||
|
||||
it('должен использовать darkPalette для dark mode', () => {
|
||||
const tokens = getDesignTokens('dark')
|
||||
|
||||
expect(tokens.palette.primary.main).toBe('#1395de')
|
||||
expect(tokens.palette.background.default).toBe('#0B0F19')
|
||||
expect(tokens.palette.background.paper).toBe('#111827')
|
||||
})
|
||||
|
||||
it('должен устанавливать fontFamily', () => {
|
||||
const tokens = getDesignTokens('light')
|
||||
|
||||
expect(tokens.typography.fontFamily).toBe('"Inter", "Roboto", "Helvetica", "Arial", sans-serif')
|
||||
})
|
||||
|
||||
it('должен устанавливать fontWeight для заголовков', () => {
|
||||
const tokens = getDesignTokens('light')
|
||||
|
||||
expect(tokens.typography.h1.fontWeight).toBe(700)
|
||||
expect(tokens.typography.h2.fontWeight).toBe(700)
|
||||
expect(tokens.typography.h3.fontWeight).toBe(600)
|
||||
expect(tokens.typography.h4.fontWeight).toBe(600)
|
||||
expect(tokens.typography.h5.fontWeight).toBe(600)
|
||||
expect(tokens.typography.h6.fontWeight).toBe(600)
|
||||
})
|
||||
|
||||
it('должен устанавливать textTransform none для кнопок', () => {
|
||||
const tokens = getDesignTokens('light')
|
||||
|
||||
expect(tokens.typography.button.textTransform).toBe('none')
|
||||
expect(tokens.typography.button.fontWeight).toBe(600)
|
||||
})
|
||||
|
||||
it('должен устанавливать borderRadius 12', () => {
|
||||
const tokens = getDesignTokens('light')
|
||||
|
||||
expect(tokens.shape.borderRadius).toBe(12)
|
||||
})
|
||||
|
||||
it('должен настраивать MuiCssBaseline для кастомных скроллбаров', () => {
|
||||
const lightTokens = getDesignTokens('light')
|
||||
const darkTokens = getDesignTokens('dark')
|
||||
|
||||
expect(lightTokens.components.MuiCssBaseline).toBeDefined()
|
||||
expect(darkTokens.components.MuiCssBaseline).toBeDefined()
|
||||
|
||||
// Проверяем что styleOverrides существуют
|
||||
expect(lightTokens.components.MuiCssBaseline.styleOverrides).toBeDefined()
|
||||
expect(darkTokens.components.MuiCssBaseline.styleOverrides).toBeDefined()
|
||||
})
|
||||
|
||||
it('должен настраивать MuiButton с borderRadius 8', () => {
|
||||
const tokens = getDesignTokens('light')
|
||||
|
||||
expect(tokens.components.MuiButton.styleOverrides.root.borderRadius).toBe(8)
|
||||
expect(tokens.components.MuiButton.styleOverrides.root.boxShadow).toBe('none')
|
||||
})
|
||||
|
||||
it('должен настраивать MuiPaper без backgroundImage', () => {
|
||||
const tokens = getDesignTokens('light')
|
||||
|
||||
expect(tokens.components.MuiPaper.styleOverrides.root.backgroundImage).toBe('none')
|
||||
})
|
||||
|
||||
it('должен настраивать MuiPaper с border в зависимости от mode', () => {
|
||||
const lightTokens = getDesignTokens('light')
|
||||
const darkTokens = getDesignTokens('dark')
|
||||
|
||||
expect(lightTokens.components.MuiPaper.styleOverrides.elevation1.border).toBe('1px solid #e5e7eb')
|
||||
expect(darkTokens.components.MuiPaper.styleOverrides.elevation1.border).toBe('1px solid #374151')
|
||||
})
|
||||
|
||||
it('должен настраивать MuiOutlinedInput с правильными borderColor', () => {
|
||||
const lightTokens = getDesignTokens('light')
|
||||
const darkTokens = getDesignTokens('dark')
|
||||
|
||||
expect(lightTokens.components.MuiOutlinedInput.styleOverrides.root['& .MuiOutlinedInput-notchedOutline'].borderColor).toBe('#e5e7eb')
|
||||
expect(darkTokens.components.MuiOutlinedInput.styleOverrides.root['& .MuiOutlinedInput-notchedOutline'].borderColor).toBe('#374151')
|
||||
})
|
||||
|
||||
it('должен настраивать MuiAppBar с backdropFilter', () => {
|
||||
const tokens = getDesignTokens('light')
|
||||
|
||||
expect(tokens.components.MuiAppBar.styleOverrides.root.backdropFilter).toBe('blur(8px)')
|
||||
expect(tokens.components.MuiAppBar.styleOverrides.root.boxShadow).toBe('none')
|
||||
})
|
||||
|
||||
it('должен настраивать MuiTableRow без border у последнего элемента', () => {
|
||||
const tokens = getDesignTokens('light')
|
||||
|
||||
expect(tokens.components.MuiTableRow.styleOverrides.root['&:last-child td'].borderBottom).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
isApiError,
|
||||
getApiErrorMessage,
|
||||
getApiErrorStatus,
|
||||
} from '@/utils/errorHandlers'
|
||||
|
||||
describe('errorHandlers', () => {
|
||||
describe('isApiError', () => {
|
||||
it('должен возвращать true для API ошибки с response', () => {
|
||||
const error = { response: { status: 400, data: { message: 'Error' } } }
|
||||
expect(isApiError(error)).toBe(true)
|
||||
})
|
||||
|
||||
it('должен возвращать true для API ошибки без response.data', () => {
|
||||
const error = { response: {} }
|
||||
expect(isApiError(error)).toBe(true)
|
||||
})
|
||||
|
||||
it('должен возвращать false для null', () => {
|
||||
expect(isApiError(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('должен возвращать false для строки', () => {
|
||||
expect(isApiError('error')).toBe(false)
|
||||
})
|
||||
|
||||
it('должен возвращать false для объекта без response', () => {
|
||||
expect(isApiError({ message: 'Error' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getApiErrorMessage', () => {
|
||||
it('должен извлекать строковое сообщение из ошибки', () => {
|
||||
const error = { response: { data: { message: 'Custom error' } } }
|
||||
expect(getApiErrorMessage(error)).toBe('Custom error')
|
||||
})
|
||||
|
||||
it('должен извлекать массив сообщений и объединять через точку с запятой', () => {
|
||||
const error = { response: { data: { message: ['Error 1', 'Error 2'] } } }
|
||||
expect(getApiErrorMessage(error)).toBe('Error 1; Error 2')
|
||||
})
|
||||
|
||||
it('должен возвращать сообщение по умолчанию для обычной ошибки', () => {
|
||||
expect(getApiErrorMessage('string error')).toBe('Произошла ошибка')
|
||||
})
|
||||
|
||||
it('должен извлекать message из Error объекта', () => {
|
||||
const error = new Error('Native error')
|
||||
expect(getApiErrorMessage(error)).toBe('Native error')
|
||||
})
|
||||
|
||||
it('должен использовать кастомное сообщение по умолчанию', () => {
|
||||
expect(getApiErrorMessage(null, 'Custom default')).toBe('Custom default')
|
||||
})
|
||||
|
||||
it('должен возвращать undefined message как сообщение по умолчанию', () => {
|
||||
const error = { response: { data: { message: undefined } } }
|
||||
expect(getApiErrorMessage(error)).toBe('Произошла ошибка')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getApiErrorStatus', () => {
|
||||
it('должен извлекать status код из ошибки', () => {
|
||||
const error = { response: { status: 404 } }
|
||||
expect(getApiErrorStatus(error)).toBe(404)
|
||||
})
|
||||
|
||||
it('должен возвращать undefined для ошибки без status', () => {
|
||||
const error = { response: {} }
|
||||
expect(getApiErrorStatus(error)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('должен возвращать undefined для не API ошибки', () => {
|
||||
expect(getApiErrorStatus('error')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('должен возвращать undefined для null', () => {
|
||||
expect(getApiErrorStatus(null)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { Logger } from '@/utils/logger'
|
||||
|
||||
describe('Logger', () => {
|
||||
describe('Logger.error', () => {
|
||||
it('должен вызывать console.error с правильным форматом', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
Logger.error('Test error', 'TestModule')
|
||||
expect(consoleSpy).toHaveBeenCalledWith('[TestModule] Test error', '')
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('должен вызывать console.error с данными', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const data = { code: 500, message: 'Internal error' }
|
||||
Logger.error('Test error', 'TestModule', data)
|
||||
expect(consoleSpy).toHaveBeenCalledWith('[TestModule] Test error', data)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('должен использовать модуль по умолчанию "App"', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
Logger.error('Test error')
|
||||
expect(consoleSpy).toHaveBeenCalledWith('[App] Test error', '')
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Logger.warn', () => {
|
||||
it('должен вызывать console.warn с правильным форматом', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
Logger.warn('Test warning', 'TestModule')
|
||||
expect(consoleSpy).toHaveBeenCalledWith('[TestModule] Test warning', '')
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('должен вызывать console.warn с данными', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const data = { code: 400, field: 'email' }
|
||||
Logger.warn('Test warning', 'TestModule', data)
|
||||
expect(consoleSpy).toHaveBeenCalledWith('[TestModule] Test warning', data)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Logger.info', () => {
|
||||
it('должен вызывать console.info с правильным форматом', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
|
||||
Logger.info('Test info', 'TestModule')
|
||||
expect(consoleSpy).toHaveBeenCalledWith('[TestModule] Test info', '')
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('должен вызывать console.info с данными', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
|
||||
const data = { user: 'admin', action: 'login' }
|
||||
Logger.info('Test info', 'TestModule', data)
|
||||
expect(consoleSpy).toHaveBeenCalledWith('[TestModule] Test info', data)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Logger.debug', () => {
|
||||
it('должен вызывать console.log с правильным форматом', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
Logger.debug('Test debug', 'TestModule')
|
||||
expect(consoleSpy).toHaveBeenCalledWith('[TestModule] Test debug', '')
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('должен вызывать console.log с данными', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const data = { state: 'loading', progress: 50 }
|
||||
Logger.debug('Test debug', 'TestModule', data)
|
||||
expect(consoleSpy).toHaveBeenCalledWith('[TestModule] Test debug', data)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Logger.verbose', () => {
|
||||
it('должен вызывать console.log с правильным форматом', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
Logger.verbose('Test verbose', 'TestModule')
|
||||
expect(consoleSpy).toHaveBeenCalledWith('[TestModule] Test verbose', '')
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('должен вызывать console.log с данными', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const data = { detailed: 'trace', step: 3 }
|
||||
Logger.verbose('Test verbose', 'TestModule', data)
|
||||
expect(consoleSpy).toHaveBeenCalledWith('[TestModule] Test verbose', data)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatMessage', () => {
|
||||
it('должен форматировать сообщение без данных', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
|
||||
Logger.info('Simple message', 'Module')
|
||||
expect(consoleSpy).toHaveBeenCalledWith('[Module] Simple message', '')
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('должен форматировать сообщение с объектом данных', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
|
||||
Logger.info('Message with data', 'Module', { key: 'value' })
|
||||
expect(consoleSpy).toHaveBeenCalledWith('[Module] Message with data', { key: 'value' })
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('должен форматировать сообщение с массивом данных', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
|
||||
Logger.info('Message with array', 'Module', [1, 2, 3])
|
||||
expect(consoleSpy).toHaveBeenCalledWith('[Module] Message with array', [1, 2, 3])
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useSecureConnection } from '../../src/utils/useSecureConnection'
|
||||
|
||||
describe('useSecureConnection', () => {
|
||||
const originalLocation = window.location
|
||||
|
||||
beforeEach(() => {
|
||||
// Очищаем моки перед каждым тестом
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Восстанавливаем оригинальный location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: originalLocation,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
describe('при HTTPS соединении', () => {
|
||||
it('должен возвращать isSecure: true когда protocol https:', () => {
|
||||
// Мок для HTTPS
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
protocol: 'https:',
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useSecureConnection())
|
||||
|
||||
expect(result.current.isSecure).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('при HTTP соединении', () => {
|
||||
it('должен возвращать isSecure: false когда protocol http:', () => {
|
||||
// Мок для HTTP
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
protocol: 'http:',
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useSecureConnection())
|
||||
|
||||
expect(result.current.isSecure).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('при localhost', () => {
|
||||
it('должен возвращать isSecure: false для localhost без HTTPS', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
protocol: 'http:',
|
||||
hostname: 'localhost',
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useSecureConnection())
|
||||
|
||||
expect(result.current.isSecure).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('мемозация', () => {
|
||||
it('должен возвращать одно и то же значение при повторных рендерах', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
protocol: 'https:',
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
const { result, rerender } = renderHook(() => useSecureConnection())
|
||||
const firstValue = result.current.isSecure
|
||||
|
||||
rerender()
|
||||
|
||||
expect(result.current.isSecure).toBe(firstValue)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { APP_VERSION } from '@/utils/version'
|
||||
|
||||
describe('version', () => {
|
||||
describe('APP_VERSION', () => {
|
||||
it('должен быть строкой', () => {
|
||||
expect(typeof APP_VERSION).toBe('string')
|
||||
})
|
||||
|
||||
it('должен соответствовать формату семантического версионирования', () => {
|
||||
const semverRegex = /^\d+\.\d+\.\d+$/
|
||||
expect(APP_VERSION).toMatch(semverRegex)
|
||||
})
|
||||
|
||||
it('должен быть непустой строкой', () => {
|
||||
expect(APP_VERSION.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
+26
-1
@@ -2,5 +2,30 @@ import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()]
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 8080,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3100',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/bus': {
|
||||
target: 'http://localhost:3100',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './test/setup.ts',
|
||||
pool: 'forks',
|
||||
poolOptions: {
|
||||
forks: {
|
||||
maxForks: 4,
|
||||
minForks: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 8080,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3100',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/bus': {
|
||||
target: 'http://localhost:3100',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './test/setup.ts',
|
||||
css: true,
|
||||
singleThread: true,
|
||||
env: {
|
||||
VITE_LOG_LEVEL: 'verbose',
|
||||
},
|
||||
coverage: {
|
||||
enabled: true,
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html', 'lcov'],
|
||||
reportsDirectory: './coverage',
|
||||
include: ['src/**/*.{ts,tsx}'],
|
||||
exclude: [
|
||||
'src/main.tsx',
|
||||
'src/**/*.d.ts',
|
||||
'src/types/**',
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -17,6 +17,21 @@ need_root() {
|
||||
[[ $EUID -eq 0 ]] || die "Запускать только от root"
|
||||
}
|
||||
|
||||
resolve_compose_cmd() {
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
COMPOSE_CMD=("docker" "compose")
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v docker-compose >/dev/null 2>&1; then
|
||||
COMPOSE_CMD=("docker-compose")
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "Перед удалением самостоятельно удалите подписки в 3DP-MANAGER, чтобы инбунды удалились в панели 3x-ui"
|
||||
read -r -p "Вы уверены, что хотите удалить? (y/n): " answer
|
||||
|
||||
case "$answer" in
|
||||
@@ -38,44 +53,56 @@ PROJECT_DIR="/opt/3dp-manager"
|
||||
|
||||
log "Начинаем удаление 3dp-manager"
|
||||
|
||||
if [[ ! -d "$PROJECT_DIR" ]]; then
|
||||
warn "Директория $PROJECT_DIR не найдена — удалять нечего"
|
||||
exit 0
|
||||
if [[ -d "$PROJECT_DIR" ]]; then
|
||||
cd "$PROJECT_DIR"
|
||||
else
|
||||
warn "Директория $PROJECT_DIR не найдена — проверяем Docker-ресурсы по известным именам"
|
||||
fi
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
#################################
|
||||
# DOCKER COMPOSE DOWN
|
||||
#################################
|
||||
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
|
||||
if [[ -f docker-compose.yml ]]; then
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
if [[ -d "$PROJECT_DIR" && -f docker-compose.yml ]] && resolve_compose_cmd; then
|
||||
log "Останавливаем контейнеры 3dp-manager"
|
||||
docker compose down --volumes --remove-orphans || warn "Ошибка при docker compose down"
|
||||
"${COMPOSE_CMD[@]}" down --volumes --remove-orphans || warn "Ошибка при docker compose down"
|
||||
else
|
||||
warn "docker-compose.yml не найден"
|
||||
warn "docker-compose.yml или Docker Compose не найден — удаляем контейнеры и volumes по известным именам"
|
||||
fi
|
||||
|
||||
docker rm -f 3dp-postgres 3dp-backend 3dp-frontend >/dev/null 2>&1 || true
|
||||
|
||||
for volume in 3dp-manager_pg_data 3dpmanager_pg_data; do
|
||||
if docker volume inspect "$volume" >/dev/null 2>&1; then
|
||||
log "Удаляем volume $volume"
|
||||
docker volume rm "$volume" >/dev/null 2>&1 || warn "Не удалось удалить volume $volume"
|
||||
fi
|
||||
done
|
||||
else
|
||||
warn "Docker или docker compose не установлен — пропуск"
|
||||
warn "Docker не установлен — пропуск удаления Docker-ресурсов"
|
||||
fi
|
||||
|
||||
#################################
|
||||
# CLEAN IMAGES
|
||||
#################################
|
||||
log "Удаляем образы 3dp-manager (если есть)"
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
log "Удаляем образы 3dp-manager (если есть)"
|
||||
|
||||
docker images --format '{{.Repository}} {{.ID}}' \
|
||||
| grep 3dp-manager \
|
||||
| awk '{print $2}' \
|
||||
| xargs -r docker rmi -f || true
|
||||
docker images --format '{{.Repository}} {{.ID}}' \
|
||||
| grep 3dp-manager \
|
||||
| awk '{print $2}' \
|
||||
| xargs -r docker rmi -f || true
|
||||
fi
|
||||
|
||||
#################################
|
||||
# REMOVE DIRECTORY
|
||||
#################################
|
||||
log "Удаляем $PROJECT_DIR"
|
||||
rm -rf "$PROJECT_DIR"
|
||||
if [[ -d "$PROJECT_DIR" ]]; then
|
||||
log "Удаляем $PROJECT_DIR"
|
||||
rm -rf "$PROJECT_DIR"
|
||||
fi
|
||||
|
||||
#################################
|
||||
# DONE
|
||||
#################################
|
||||
log "✔ 3dp-manager полностью удалён"
|
||||
log "✔ 3dp-manager полностью удалён"
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18-alpine
|
||||
container_name: 3dp-postgres-local
|
||||
environment:
|
||||
POSTGRES_USER: admin
|
||||
POSTGRES_PASSWORD: admin
|
||||
POSTGRES_DB: 3dp_manager
|
||||
ports:
|
||||
- "15432:5432"
|
||||
volumes:
|
||||
- postgres_local_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- 3dp-local-network
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./server
|
||||
dockerfile: Dockerfile
|
||||
container_name: 3dp-backend-local
|
||||
environment:
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
DB_USERNAME: admin
|
||||
DB_PASSWORD: admin
|
||||
DB_NAME: 3dp_manager
|
||||
JWT_SECRET: localDevSecretKey12345678901234567890
|
||||
ADMIN_LOGIN: admin
|
||||
ADMIN_PASSWORD: admin
|
||||
PORT: 3100
|
||||
LOG_LEVEL: debug
|
||||
ALLOWED_ORIGINS: http://localhost:8080,http://localhost
|
||||
ports:
|
||||
- "3100:3100"
|
||||
depends_on:
|
||||
- postgres
|
||||
networks:
|
||||
- 3dp-local-network
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./client
|
||||
dockerfile: Dockerfile
|
||||
container_name: 3dp-frontend-local
|
||||
ports:
|
||||
- "8080:80"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- 3dp-local-network
|
||||
|
||||
networks:
|
||||
3dp-local-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres_local_data:
|
||||
+17
-12
@@ -1,14 +1,14 @@
|
||||
name: 3dp-manager
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18-alpine
|
||||
container_name: 3dp-postgres
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-admin}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-admin}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-3dp_manager}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
@@ -23,12 +23,17 @@ services:
|
||||
environment:
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
DB_USERNAME: ${POSTGRES_USER:-admin}
|
||||
DB_PASSWORD: ${POSTGRES_PASSWORD:-admin}
|
||||
DB_NAME: ${POSTGRES_DB:-3dp_manager}
|
||||
JWT_SECRET: ${JWT_SECRET:-secretKey}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
DB_USERNAME: ${POSTGRES_USER}
|
||||
DB_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
DB_NAME: ${POSTGRES_DB}
|
||||
DB_SYNCHRONIZE: "true"
|
||||
DB_MIGRATIONS_RUN: "false"
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
ADMIN_LOGIN: ${ADMIN_LOGIN}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||
PORT: ${PORT}
|
||||
LOG_LEVEL: ${LOG_LEVEL}
|
||||
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-}
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
@@ -48,4 +53,4 @@ volumes:
|
||||
|
||||
networks:
|
||||
app-network:
|
||||
driver: bridge
|
||||
driver: bridge
|
||||
|
||||
+5
-40
@@ -14,43 +14,6 @@ warn() { echo -e "\033[1;33m[WARN]\033[0m $1"; }
|
||||
|
||||
[[ $EUID -eq 0 ]] || { echo "Запускать нужно от root"; exit 1; }
|
||||
|
||||
read -r -p "Вы уверены, что хотите удалить? (y/n): " answer
|
||||
|
||||
case "$answer" in
|
||||
y|Y)
|
||||
echo "Начинаю удаление..."
|
||||
;;
|
||||
*)
|
||||
echo "Удаление отменено"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
#################################
|
||||
# PATHS
|
||||
#################################
|
||||
BASE_DIR="/opt/3dp-manager"
|
||||
UFW_BEFORE="/etc/ufw/before.rules"
|
||||
UFW_NAT_MARKER="3dp-manager NAT"
|
||||
NGINX_PORT=$(cd "$BASE_DIR" && docker compose exec -T node printenv ORIGIN_PORT | tr -d '\r')
|
||||
|
||||
#################################
|
||||
# DOCKER CLEAN
|
||||
#################################
|
||||
if [[ -d "$BASE_DIR" ]]; then
|
||||
log "Остановка Docker"
|
||||
cd "$BASE_DIR"
|
||||
docker compose down --remove-orphans || true
|
||||
else
|
||||
warn "Каталог проекта не найден"
|
||||
fi
|
||||
|
||||
#################################
|
||||
# REMOVE FILES
|
||||
#################################
|
||||
log "Удаление файлов проекта"
|
||||
rm -rf "$BASE_DIR"
|
||||
|
||||
#################################
|
||||
# UFW NAT CLEAN
|
||||
#################################
|
||||
@@ -71,7 +34,6 @@ ufw delete allow 443/tcp
|
||||
ufw delete allow 443/udp
|
||||
ufw delete allow 8443/tcp
|
||||
ufw delete allow 8443/udp
|
||||
ufw delete allow "$NGINX_PORT"/tcp
|
||||
ufw delete allow 10000:60000/tcp
|
||||
ufw delete allow 10000:60000/udp
|
||||
|
||||
@@ -85,7 +47,10 @@ ufw reload
|
||||
# RESULT
|
||||
#################################
|
||||
|
||||
echo "Docker контейнеры удалены"
|
||||
echo "UFW NAT очищен"
|
||||
echo "ip_forward отключён"
|
||||
log "Откат завершён. Для окончательного применения изменений перезагрузите систему!"
|
||||
log "Откат завершён. Для окончательного применения изменений система будет перезагружена!"
|
||||
|
||||
(sleep 3 && reboot) >/dev/null 2>&1 &
|
||||
|
||||
exit 0
|
||||
+319
-77
@@ -19,6 +19,102 @@ NC='\033[0m'
|
||||
log() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
|
||||
die() { error "$1"; }
|
||||
|
||||
resolve_compose_cmd() {
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
COMPOSE_CMD=("docker" "compose")
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v docker-compose >/dev/null 2>&1; then
|
||||
COMPOSE_CMD=("docker-compose")
|
||||
return 0
|
||||
fi
|
||||
|
||||
warn "Не найден Docker Compose (ни v2 plugin, ни v1 binary). Пытаемся установить..."
|
||||
apt-get update
|
||||
apt-get install -y docker-compose-plugin || true
|
||||
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
COMPOSE_CMD=("docker" "compose")
|
||||
return 0
|
||||
fi
|
||||
|
||||
apt-get install -y docker-compose || true
|
||||
if command -v docker-compose >/dev/null 2>&1; then
|
||||
COMPOSE_CMD=("docker-compose")
|
||||
return 0
|
||||
fi
|
||||
|
||||
die "Не удалось установить Docker Compose. Установите docker compose plugin (v2) или docker-compose (v1)."
|
||||
}
|
||||
|
||||
check_containers_running() {
|
||||
log "Проверка статуса контейнеров..."
|
||||
local timeout=${1:-60}
|
||||
local elapsed=0
|
||||
local failed=0
|
||||
|
||||
while [ $elapsed -lt $timeout ]; do
|
||||
failed=0
|
||||
# Получаем статус всех контейнеров текущего compose проекта
|
||||
# Формат: NAME\tSTATUS (например: "3dp-postgres\tUp 2 days" или "3dp-postgres\tError")
|
||||
while IFS=$'\t' read -r container_name status; do
|
||||
if [ -n "$container_name" ] && [ -n "$status" ]; then
|
||||
# Проверяем, что статус содержит Up/running/healthy/restarting
|
||||
# Up, Up 2 days, Up Less than a second, (healthy), running, restarting
|
||||
if ! echo "$status" | grep -qiE "^up|running|healthy|restarting"; then
|
||||
failed=1
|
||||
warn "Контейнер $container_name в статусе: $status"
|
||||
fi
|
||||
fi
|
||||
done < <("${COMPOSE_CMD[@]}" ps --format "table {{.Name}}\t{{.Status}}" --all 2>/dev/null | tail -n +2)
|
||||
|
||||
if [ $failed -eq 0 ]; then
|
||||
log "Все контейнеры запущены успешно"
|
||||
return 0
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
elapsed=$((elapsed + 2))
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
get_random_port() {
|
||||
local MIN=${1:-3000}
|
||||
local MAX=${2:-6999}
|
||||
|
||||
while :; do
|
||||
PORT=$(shuf -i "$MIN-$MAX" -n 1)
|
||||
if ! ss -ltun | awk '{print $4}' | grep -q ":$PORT\$"; then
|
||||
echo "$PORT"
|
||||
return
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
cleanup_previous_install_data() {
|
||||
log "Checking for previous 3dp-manager installation data..."
|
||||
|
||||
if [[ -f docker-compose.yml ]]; then
|
||||
warn "Existing docker-compose.yml found. Stopping previous installation and removing its volumes so newly generated credentials are used."
|
||||
"${COMPOSE_CMD[@]}" down --volumes --remove-orphans || true
|
||||
else
|
||||
warn "docker-compose.yml not found. Removing known 3dp-manager containers if they still exist."
|
||||
fi
|
||||
|
||||
docker rm -f 3dp-postgres 3dp-backend 3dp-frontend >/dev/null 2>&1 || true
|
||||
|
||||
for volume in 3dp-manager_pg_data 3dpmanager_pg_data; do
|
||||
if docker volume inspect "$volume" >/dev/null 2>&1; then
|
||||
warn "Removing stale Postgres volume: $volume"
|
||||
docker volume rm "$volume" >/dev/null 2>&1 || true
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
#################################
|
||||
# ASCII-баннер
|
||||
@@ -101,6 +197,9 @@ EOF
|
||||
systemctl start docker
|
||||
fi
|
||||
|
||||
resolve_compose_cmd
|
||||
log "Compose команда: ${COMPOSE_CMD[*]}"
|
||||
|
||||
#################################
|
||||
# ЗАГРУЗКА ПРОЕКТА
|
||||
#################################
|
||||
@@ -110,73 +209,174 @@ mkdir -p "$PROJECT_DIR/server"
|
||||
mkdir -p "$PROJECT_DIR/client"
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
cleanup_previous_install_data
|
||||
|
||||
#################################
|
||||
# СБОР ДАННЫХ
|
||||
# СБОР ДАННЫХ: SSL / HTTPS
|
||||
#################################
|
||||
read -rp "Введите домен сервера (если пропустить, будет использоваться IP без HTTPS): " INPUT_HOST
|
||||
|
||||
UI_HOST=""
|
||||
USE_SSL=false
|
||||
CERT_PATH=""
|
||||
KEY_PATH=""
|
||||
SKIP_SSL_SETUP=false
|
||||
|
||||
if [ -z "$INPUT_HOST" ]; then
|
||||
UI_HOST=$(hostname -I | awk '{print $1}')
|
||||
log "Домен не указан. Используется локальный IP: $UI_HOST"
|
||||
log "Режим HTTPS принудительно отключен для IP-адреса."
|
||||
|
||||
USE_SSL=false
|
||||
SKIP_SSL_SETUP=true
|
||||
else
|
||||
UI_HOST=$INPUT_HOST
|
||||
SKIP_SSL_SETUP=false
|
||||
fi
|
||||
echo ""
|
||||
echo -e "${GREEN}Выберите тип SSL/HTTPS сертификации:${NC}"
|
||||
echo " 1) HTTPS — Let's Encrypt (нужен реальный домен, привязанный к IP, + открыты порты 80/443 в UFW)"
|
||||
echo " 2) HTTPS — Self-signed (самоподписанный на IP сервера, для тестов/VM)"
|
||||
echo " 3) HTTPS — Свои сертификаты (указать пути)"
|
||||
echo " 4) HTTP — без шифрования"
|
||||
echo ""
|
||||
printf "Ваш выбор (1/2/3/4) [\033[0;31m4\033[0m]: "
|
||||
read -r ssl_choice
|
||||
|
||||
# --- 2. Настройка SSL (Только если введен домен) ---
|
||||
if [[ "$SKIP_SSL_SETUP" == "false" ]]; then
|
||||
# Пытаемся автоматически найти сертификаты Let's Encrypt
|
||||
LE_CERT="/etc/letsencrypt/live/$UI_HOST/fullchain.pem"
|
||||
LE_KEY="/etc/letsencrypt/live/$UI_HOST/privkey.pem"
|
||||
case "$ssl_choice" in
|
||||
1)
|
||||
echo ""
|
||||
read -rp "Введите домен (должен быть привязан к IP этого сервера): " INPUT_HOST
|
||||
|
||||
if [[ -f "$LE_CERT" && -f "$LE_KEY" ]]; then
|
||||
log "Найдены сертификаты Let's Encrypt."
|
||||
USE_SSL=true
|
||||
CERT_PATH="$LE_CERT"
|
||||
KEY_PATH="$LE_KEY"
|
||||
if [ -z "$INPUT_HOST" ]; then
|
||||
warn "Домен не указан. Переключение на HTTP."
|
||||
else
|
||||
# Спрашиваем пользователя, если авто-поиск не дал результата
|
||||
read -rp "Использовать SSL (свои сертификаты)? (y/n): " ssl_ans
|
||||
if [[ "$ssl_ans" =~ ^[Yy]$ ]]; then
|
||||
read -rp "Путь к fullchain.pem: " user_cert
|
||||
read -rp "Путь к privkey.pem: " user_key
|
||||
if [[ -f "$user_cert" && -f "$user_key" ]]; then
|
||||
USE_SSL=true
|
||||
CERT_PATH="$user_cert"
|
||||
KEY_PATH="$user_key"
|
||||
else
|
||||
warn "Файлы сертификатов не найдены. Будет использоваться HTTP."
|
||||
fi
|
||||
UI_HOST="$INPUT_HOST"
|
||||
LE_CERT="/etc/letsencrypt/live/$UI_HOST/fullchain.pem"
|
||||
LE_KEY="/etc/letsencrypt/live/$UI_HOST/privkey.pem"
|
||||
|
||||
NEED_NEW_CERT=true
|
||||
|
||||
if [[ -f "$LE_CERT" && -f "$LE_KEY" ]]; then
|
||||
# Проверяем что сертификат не истёк и ключ соответствует
|
||||
if openssl x509 -checkend 0 -noout -in "$LE_CERT" 2>/dev/null && \
|
||||
openssl x509 -noout -pubkey -in "$LE_CERT" 2>/dev/null > /tmp/cert_pub && \
|
||||
openssl pkey -pubout -in "$LE_KEY" 2>/dev/null > /tmp/key_pub && \
|
||||
diff -q /tmp/cert_pub /tmp/key_pub >/dev/null 2>&1; then
|
||||
|
||||
log "Найдены валидные сертификаты для $UI_HOST."
|
||||
USE_SSL=true
|
||||
CERT_PATH="$LE_CERT"
|
||||
KEY_PATH="$LE_KEY"
|
||||
NEED_NEW_CERT=false
|
||||
else
|
||||
warn "Найдены сертификаты для $UI_HOST, но они истекли или невалидны. Очистка..."
|
||||
# Удаляем невалидные файлы
|
||||
rm -rf "/etc/letsencrypt/live/$UI_HOST"
|
||||
rm -rf "/etc/letsencrypt/archive/$UI_HOST"
|
||||
rm -rf "/etc/letsencrypt/renewal/$UI_HOST.conf"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$NEED_NEW_CERT" = true ]; then
|
||||
log "Получение Let's Encrypt сертификата для $UI_HOST..."
|
||||
read -e -p "Email для уведомлений Let's Encrypt: " LE_EMAIL
|
||||
LE_EMAIL=$(echo "$LE_EMAIL" | tr -cd 'a-zA-Z0-9.@_-')
|
||||
|
||||
if command -v certbot &>/dev/null; then
|
||||
log "Certbot уже установлен."
|
||||
else
|
||||
log "Установка certbot..."
|
||||
apt update
|
||||
apt install -y certbot
|
||||
fi
|
||||
|
||||
# КРИТИЧЕСКИЙ ШАГ: Принудительно удаляем старые сертификаты из базы Certbot
|
||||
certbot delete --cert-name "$UI_HOST" --non-interactive >/dev/null 2>&1 || true
|
||||
|
||||
# Запрашиваем новый сертификат принудительно (--force-renewal и --cert-name)
|
||||
certbot certonly --standalone \
|
||||
--agree-tos \
|
||||
--non-interactive \
|
||||
--force-renewal \
|
||||
--cert-name "$UI_HOST" \
|
||||
--email "$LE_EMAIL" \
|
||||
-d "$UI_HOST" || warn "Не удалось получить сертификат Let's Encrypt"
|
||||
|
||||
if [[ -f "$LE_CERT" && -f "$LE_KEY" ]]; then
|
||||
USE_SSL=true
|
||||
CERT_PATH="$LE_CERT"
|
||||
KEY_PATH="$LE_KEY"
|
||||
log "Let's Encrypt сертификат получен для $UI_HOST"
|
||||
else
|
||||
warn "Сертификат не получен. Переключение на HTTP."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
|
||||
2)
|
||||
UI_HOST=$(hostname -I | awk '{print $1}')
|
||||
log "Генерация самоподписанного сертификата для $UI_HOST..."
|
||||
mkdir -p "/etc/letsencrypt/live/$UI_HOST"
|
||||
openssl req -x509 -nodes -days 365 \
|
||||
-newkey rsa:2048 \
|
||||
-keyout "/etc/letsencrypt/live/$UI_HOST/privkey.pem" \
|
||||
-out "/etc/letsencrypt/live/$UI_HOST/fullchain.pem" \
|
||||
-subj "/CN=$UI_HOST" \
|
||||
-addext "subjectAltName=IP:$UI_HOST" 2>/dev/null
|
||||
|
||||
if [[ -f "/etc/letsencrypt/live/$UI_HOST/fullchain.pem" && -f "/etc/letsencrypt/live/$UI_HOST/privkey.pem" ]]; then
|
||||
USE_SSL=true
|
||||
CERT_PATH="/etc/letsencrypt/live/$UI_HOST/fullchain.pem"
|
||||
KEY_PATH="/etc/letsencrypt/live/$UI_HOST/privkey.pem"
|
||||
log "Self-signed сертификат сгенерирован для $UI_HOST"
|
||||
warn "Браузер будет предупреждать — это нормально для тестов."
|
||||
else
|
||||
warn "Не удалось сгенерировать сертификат. Переключение на HTTP."
|
||||
fi
|
||||
;;
|
||||
|
||||
3)
|
||||
read -rp "Введите домен для панели (например panel.example.com; Enter — использовать IP): " INPUT_HOST
|
||||
read -rp "Путь к fullchain.pem: " user_cert
|
||||
read -rp "Путь к privkey.pem: " user_key
|
||||
if [[ -f "$user_cert" && -f "$user_key" ]]; then
|
||||
USE_SSL=true
|
||||
CERT_PATH="$user_cert"
|
||||
KEY_PATH="$user_key"
|
||||
if [ -n "$INPUT_HOST" ]; then
|
||||
UI_HOST="$INPUT_HOST"
|
||||
else
|
||||
UI_HOST=$(hostname -I | awk '{print $1}')
|
||||
warn "Домен не указан. В итоговом URL будет использован IP: $UI_HOST"
|
||||
fi
|
||||
else
|
||||
warn "Файлы не найдены. Переключение на HTTP."
|
||||
fi
|
||||
;;
|
||||
|
||||
*)
|
||||
UI_HOST=$(hostname -I | awk '{print $1}')
|
||||
log "Будет использоваться HTTP."
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -z "$UI_HOST" ]; then
|
||||
UI_HOST=$(hostname -I | awk '{print $1}')
|
||||
log "Используется IP сервера: $UI_HOST"
|
||||
fi
|
||||
|
||||
get_random_port() {
|
||||
while :; do
|
||||
PORT=$((RANDOM % 4000 + 3000))
|
||||
if ! ss -ltn | awk '{print $4}' | grep -q ":$PORT\$"; then
|
||||
echo "$PORT"
|
||||
return
|
||||
fi
|
||||
done
|
||||
}
|
||||
#################################
|
||||
# СБОР ДАННЫХ
|
||||
################################
|
||||
|
||||
FINAL_PORT=$(get_random_port)
|
||||
|
||||
# --- 4. Генерация паролей ---
|
||||
DB_PASS=$(openssl rand -base64 12)
|
||||
JWT_SECRET=$(openssl rand -base64 32)
|
||||
ADMIN_USER=$(openssl rand -base64 8)
|
||||
ADMIN_PASS=$(openssl rand -base64 12)
|
||||
# Определяем ALLOWED_ORIGINS из домена или IP
|
||||
if [[ -n "${UI_HOST:-}" ]]; then
|
||||
URL_SCHEME="http"
|
||||
if [[ "$USE_SSL" == "true" ]]; then
|
||||
URL_SCHEME="https"
|
||||
fi
|
||||
ALLOWED_ORIGINS="${URL_SCHEME}://${UI_HOST}"
|
||||
else
|
||||
ALLOWED_ORIGINS=""
|
||||
fi
|
||||
log "Сгенерированы секретные ключи для БД и JWT."
|
||||
|
||||
|
||||
#################################
|
||||
# ГЕНЕРАЦИЯ ФАЙЛОВ DOCKER
|
||||
#################################
|
||||
@@ -186,6 +386,11 @@ DB_PORT=5432
|
||||
DB_USERNAME=admin
|
||||
DB_PASSWORD=${DB_PASS}
|
||||
DB_NAME=3dp_manager
|
||||
ADMIN_LOGIN=${ADMIN_USER}
|
||||
ADMIN_PASSWORD=${ADMIN_PASS}
|
||||
PORT=3100
|
||||
LOG_LEVEL=error
|
||||
ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
|
||||
EOF
|
||||
|
||||
if [[ "$USE_SSL" == "true" ]]; then
|
||||
@@ -207,32 +412,31 @@ server {
|
||||
try_files \$uri \$uri/ /index.html;
|
||||
}
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3000/api/;
|
||||
proxy_pass http://backend:3100/api/;
|
||||
proxy_set_header Host \$http_host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 650s;
|
||||
proxy_read_timeout 650s;
|
||||
}
|
||||
}
|
||||
server {
|
||||
listen 3000 ssl;
|
||||
server_name $UI_HOST;
|
||||
client_max_body_size 50M;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://backend:3000/;
|
||||
location /bus/ {
|
||||
proxy_pass http://backend:3100/bus/;
|
||||
proxy_set_header Host \$http_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 https;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 650s;
|
||||
proxy_read_timeout 650s;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# 2. Docker Compose
|
||||
cat > docker-compose.yml <<EOF
|
||||
name: 3dp-manager
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18-alpine
|
||||
@@ -243,7 +447,7 @@ services:
|
||||
POSTGRES_PASSWORD: ${DB_PASS}
|
||||
POSTGRES_DB: 3dp_manager
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
- pg_data:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U admin -d 3dp_manager"]
|
||||
interval: 5s
|
||||
@@ -265,8 +469,12 @@ services:
|
||||
DB_USERNAME: admin
|
||||
DB_PASSWORD: ${DB_PASS}
|
||||
DB_NAME: 3dp_manager
|
||||
DB_SYNCHRONIZE: "true"
|
||||
DB_MIGRATIONS_RUN: "false"
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
PORT: 3000
|
||||
ADMIN_LOGIN: ${ADMIN_USER}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASS}
|
||||
PORT: 3100
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
@@ -278,7 +486,6 @@ services:
|
||||
- backend
|
||||
ports:
|
||||
- "${FINAL_PORT}:443"
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./client/nginx-client.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ${CERT_PATH}:/etc/nginx/certs/fullchain.pem:ro
|
||||
@@ -310,7 +517,7 @@ server {
|
||||
try_files \$uri \$uri/ /index.html;
|
||||
}
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3000/api/;
|
||||
proxy_pass http://backend:3100/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
@@ -318,20 +525,31 @@ server {
|
||||
proxy_cache_bypass \$http_upgrade;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 650s;
|
||||
proxy_read_timeout 650s;
|
||||
}
|
||||
}
|
||||
server {
|
||||
listen 3000;
|
||||
server_name localhost;
|
||||
location / {
|
||||
proxy_pass http://backend:3000/;
|
||||
location /bus/ {
|
||||
proxy_pass http://backend:3100/bus/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host \$http_host;
|
||||
proxy_cache_bypass \$http_upgrade;
|
||||
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_connect_timeout 10s;
|
||||
proxy_send_timeout 650s;
|
||||
proxy_read_timeout 650s;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# 2. Docker Compose
|
||||
cat > docker-compose.yml <<EOF
|
||||
name: 3dp-manager
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18-alpine
|
||||
@@ -342,7 +560,7 @@ services:
|
||||
POSTGRES_PASSWORD: ${DB_PASS}
|
||||
POSTGRES_DB: 3dp_manager
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
- pg_data:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U admin -d 3dp_manager"]
|
||||
interval: 5s
|
||||
@@ -364,8 +582,12 @@ services:
|
||||
DB_USERNAME: admin
|
||||
DB_PASSWORD: ${DB_PASS}
|
||||
DB_NAME: 3dp_manager
|
||||
DB_SYNCHRONIZE: "true"
|
||||
DB_MIGRATIONS_RUN: "false"
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
PORT: 3000
|
||||
ADMIN_LOGIN: ${ADMIN_USER}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASS}
|
||||
PORT: 3100
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
@@ -377,7 +599,6 @@ services:
|
||||
- backend
|
||||
ports:
|
||||
- "${FINAL_PORT}:80"
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./client/nginx-client.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
networks:
|
||||
@@ -397,14 +618,35 @@ fi
|
||||
#################################
|
||||
log "Сборка и запуск контейнеров..."
|
||||
# Останавливаем старые, если были
|
||||
docker compose down --remove-orphans || true
|
||||
"${COMPOSE_CMD[@]}" down || true
|
||||
|
||||
# Подтягиваем свежие образы, потому что тег релиза переиспользуется.
|
||||
"${COMPOSE_CMD[@]}" pull || warn "Не удалось обновить образы перед запуском. Будет использован локальный кэш, если он есть."
|
||||
|
||||
# Запускаем сборку и старт
|
||||
docker compose up --build -d
|
||||
"${COMPOSE_CMD[@]}" up --build -d --remove-orphans
|
||||
|
||||
# Проверка: все ли контейнеры запустились
|
||||
if ! check_containers_running 60; then
|
||||
error "Не удалось запустить контейнеры. Логи:"
|
||||
"${COMPOSE_CMD[@]}" logs --tail=50
|
||||
die "Установка прервана из-за ошибки запуска контейнеров"
|
||||
fi
|
||||
|
||||
log "Очистка кэша сборки..."
|
||||
docker image prune -f
|
||||
|
||||
if LC_ALL=C ufw status 2>/dev/null | grep -q "Status: active"; then
|
||||
echo "Найден активный UFW. Настраиваю правила..."
|
||||
|
||||
ufw allow 443/tcp
|
||||
ufw allow 443/udp
|
||||
ufw allow 8443/tcp
|
||||
ufw allow 8443/udp
|
||||
ufw allow 10000:60000/tcp
|
||||
ufw allow 10000:60000/udp
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "==================================================="
|
||||
if [[ "$USE_SSL" == "true" ]]; then
|
||||
@@ -412,8 +654,8 @@ if [[ "$USE_SSL" == "true" ]]; then
|
||||
else
|
||||
echo -e "${GREEN}✔ Установка завершена! Доступно по адресу: http://${UI_HOST}:${FINAL_PORT}${NC}"
|
||||
fi
|
||||
echo "Логин: admin"
|
||||
echo "Пароль: admin"
|
||||
echo -e "${GREEN}Логин: ${ADMIN_USER}${NC}"
|
||||
echo -e "${GREEN}Пароль: ${ADMIN_PASS}${NC}"
|
||||
echo ""
|
||||
echo "Немедленно измените пароль в Настройках утилиты!"
|
||||
echo "==================================================="
|
||||
echo "==================================================="
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "3dp-manager",
|
||||
"version": "2.2.0",
|
||||
"description": "Inbound generator for 3x-ui",
|
||||
"private": false,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/denpiligrim/3dp-manager.git"
|
||||
},
|
||||
"author": "DenPiligrim",
|
||||
"license": "GPL-3.0"
|
||||
}
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
echo "[PATCH] Applying 3dp-manager rotation fixes..."
|
||||
for i in $(seq 1 30); do
|
||||
docker ps --format "{{.Names}}" 2>/dev/null | grep -q "^3dp-backend$" && break
|
||||
sleep 1
|
||||
done
|
||||
SD="$(cd "$(dirname "$0")" && pwd)"
|
||||
docker cp "$SD/rotation.service.js" 3dp-backend:/app/dist/src/rotation/rotation.service.js
|
||||
echo "[PATCH] Fixes applied: create-first + orphaned cleanup via API"
|
||||
@@ -0,0 +1,518 @@
|
||||
"use strict";
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||
};
|
||||
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
||||
return function (target, key) { decorator(target, key, paramIndex); }
|
||||
};
|
||||
var RotationService_1;
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.RotationService = void 0;
|
||||
const common_1 = require("@nestjs/common");
|
||||
const schedule_1 = require("@nestjs/schedule");
|
||||
const typeorm_1 = require("@nestjs/typeorm");
|
||||
const typeorm_2 = require("typeorm");
|
||||
const subscription_entity_1 = require("../subscriptions/entities/subscription.entity");
|
||||
const inbound_entity_1 = require("../inbounds/entities/inbound.entity");
|
||||
const domain_entity_1 = require("../domains/entities/domain.entity");
|
||||
const setting_entity_1 = require("../settings/entities/setting.entity");
|
||||
const node_entity_1 = require("../nodes/entities/node.entity");
|
||||
const tunnel_entity_1 = require("../tunnels/entities/tunnel.entity");
|
||||
const xui_service_1 = require("../xui/xui.service");
|
||||
const inbound_builder_service_1 = require("../inbounds/inbound-builder.service");
|
||||
const uuid_1 = require("uuid");
|
||||
let RotationService = RotationService_1 = class RotationService {
|
||||
subRepo;
|
||||
inboundRepo;
|
||||
domainRepo;
|
||||
settingRepo;
|
||||
nodeRepo;
|
||||
tunnelRepo;
|
||||
xuiService;
|
||||
inboundBuilder;
|
||||
logger = new common_1.Logger(RotationService_1.name);
|
||||
constructor(subRepo, inboundRepo, domainRepo, settingRepo, nodeRepo, tunnelRepo, xuiService, inboundBuilder) {
|
||||
this.subRepo = subRepo;
|
||||
this.inboundRepo = inboundRepo;
|
||||
this.domainRepo = domainRepo;
|
||||
this.settingRepo = settingRepo;
|
||||
this.nodeRepo = nodeRepo;
|
||||
this.tunnelRepo = tunnelRepo;
|
||||
this.xuiService = xuiService;
|
||||
this.inboundBuilder = inboundBuilder;
|
||||
}
|
||||
async onModuleInit() {
|
||||
await this.initDefaultSettings();
|
||||
}
|
||||
async initDefaultSettings() {
|
||||
const statusKey = 'rotation_status';
|
||||
const intervalKey = 'rotation_interval';
|
||||
const lastRunKey = 'last_rotation_timestamp';
|
||||
const existingStatus = await this.settingRepo.findOne({
|
||||
where: { key: statusKey },
|
||||
});
|
||||
if (!existingStatus) {
|
||||
this.logger.debug(`Инициализация настройки: ${statusKey} = active`);
|
||||
const newSetting = this.settingRepo.create({
|
||||
key: statusKey,
|
||||
value: 'active',
|
||||
});
|
||||
await this.settingRepo.save(newSetting);
|
||||
}
|
||||
else {
|
||||
this.logger.debug(`Текущий статус ротации: ${existingStatus.value}`);
|
||||
}
|
||||
const existingInterval = await this.settingRepo.findOne({
|
||||
where: { key: intervalKey },
|
||||
});
|
||||
if (!existingInterval) {
|
||||
this.logger.debug(`Инициализация настройки: ${intervalKey} = 30`);
|
||||
const newSetting = this.settingRepo.create({
|
||||
key: intervalKey,
|
||||
value: '30',
|
||||
});
|
||||
await this.settingRepo.save(newSetting);
|
||||
}
|
||||
const existingLastRun = await this.settingRepo.findOne({
|
||||
where: { key: lastRunKey },
|
||||
});
|
||||
if (!existingLastRun) {
|
||||
const now = Date.now();
|
||||
this.logger.debug(`Инициализация настройки: ${lastRunKey} = ${now}`);
|
||||
const newSetting = this.settingRepo.create({
|
||||
key: lastRunKey,
|
||||
value: now.toString(),
|
||||
});
|
||||
await this.settingRepo.save(newSetting);
|
||||
}
|
||||
else {
|
||||
this.logger.debug(`Последняя ротация: ${existingLastRun.value}`);
|
||||
}
|
||||
}
|
||||
async handleTicker() {
|
||||
const intervalSetting = await this.settingRepo.findOne({
|
||||
where: { key: 'rotation_interval' },
|
||||
});
|
||||
const intervalMinutes = intervalSetting
|
||||
? parseInt(intervalSetting.value, 10)
|
||||
: 30;
|
||||
const lastRunSetting = await this.settingRepo.findOne({
|
||||
where: { key: 'last_rotation_timestamp' },
|
||||
});
|
||||
const lastRun = lastRunSetting ? parseInt(lastRunSetting.value, 10) : 0;
|
||||
const now = Date.now();
|
||||
const diffMinutes = (now - lastRun) / 1000 / 60;
|
||||
const statusSetting = await this.settingRepo.findOne({
|
||||
where: { key: 'rotation_status' },
|
||||
});
|
||||
const isStopped = statusSetting?.value === 'stopped';
|
||||
this.logger.debug(`Планировщик: интервал=${intervalMinutes}мин, прошло=${diffMinutes.toFixed(1)}мин, статус=${isStopped ? 'stopped' : 'active'}`);
|
||||
if (diffMinutes < intervalMinutes || isStopped) {
|
||||
return;
|
||||
}
|
||||
this.logger.debug(`Запуск ротации (прошло ${diffMinutes.toFixed(1)}мин при интервале ${intervalMinutes}мин)`);
|
||||
await this.performRotation();
|
||||
await this.saveSetting('last_rotation_timestamp', now.toString());
|
||||
}
|
||||
async saveSetting(key, value) {
|
||||
let s = await this.settingRepo.findOne({ where: { key } });
|
||||
if (!s)
|
||||
s = this.settingRepo.create({ key });
|
||||
s.value = value;
|
||||
await this.settingRepo.save(s);
|
||||
}
|
||||
async performRotation() {
|
||||
this.logger.debug('Запуск плановой ротации...');
|
||||
const defaultNode = await this.getDefaultNode();
|
||||
const isLoginSuccess = defaultNode ? true : await this.xuiService.login();
|
||||
if (!isLoginSuccess) {
|
||||
this.logger.error('Отмена ротации: Не удалось войти в панель 3x-ui');
|
||||
return { success: false, message: 'Не удалось войти в панель 3x-ui' };
|
||||
}
|
||||
const subscriptions = await this.subRepo.find({
|
||||
where: {
|
||||
isEnabled: true,
|
||||
isAutoRotationEnabled: true,
|
||||
},
|
||||
relations: ['inbounds', 'inbounds.node', 'node', 'relayServer'],
|
||||
});
|
||||
if (subscriptions.length === 0) {
|
||||
return { success: false, message: 'Нет активных подписок для ротации' };
|
||||
}
|
||||
const domains = await this.domainRepo.find({ where: { isEnabled: true } });
|
||||
if (domains.length === 0) {
|
||||
this.logger.warn('Список доменов пуст! Ротация невозможна.');
|
||||
return { success: false, message: 'Список доменов пуст!' };
|
||||
}
|
||||
for (const sub of subscriptions) {
|
||||
const rotated = await this.rotateSubscription(sub, domains, defaultNode);
|
||||
if (!rotated) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to delete old inbounds',
|
||||
};
|
||||
}
|
||||
}
|
||||
this.logger.debug('Ротация завершена.');
|
||||
return { success: true, message: 'Ротация успешно выполнена' };
|
||||
}
|
||||
async rotateSubscription(sub, domains, defaultNode) {
|
||||
this.logger.debug(`Ротация для подписки: ${sub.name} (${sub.uuid})`);
|
||||
|
||||
const baseNode = sub.node ?? defaultNode ?? undefined;
|
||||
const keys = await this.xuiService.getNewX25519Cert(baseNode);
|
||||
if (!keys) {
|
||||
this.logger.error('Не удалось получить Reality ключи, пропускаем подписку');
|
||||
return false;
|
||||
}
|
||||
const usedPorts = new Set();
|
||||
const host = await this.settingRepo.findOne({ where: { key: 'xui_host' } });
|
||||
const serverAddress = this.getNodeAddress(baseNode) || host?.value || 'localhost';
|
||||
const flag = await this.settingRepo.findOne({
|
||||
where: { key: 'xui_geo_flag' },
|
||||
});
|
||||
const defaultFlagEmoji = flag?.value ?? '%F0%9F%92%AF';
|
||||
const inboundsConfig = sub.inboundsConfig || [];
|
||||
for (const config of inboundsConfig) {
|
||||
const type = config.type;
|
||||
const uuid = (0, uuid_1.v4)();
|
||||
const targetNode = await this.resolveNode(config.nodeId, sub.node, defaultNode);
|
||||
const resolvedRelay = await this.resolveRelay(config.relayServerId, sub.relayServer);
|
||||
const relayServer = resolvedRelay && this.isRelayAvailableForNode(resolvedRelay, targetNode)
|
||||
? resolvedRelay
|
||||
: undefined;
|
||||
const targetAddress = relayServer?.domain ||
|
||||
relayServer?.ip ||
|
||||
this.getNodeAddress(targetNode) ||
|
||||
serverAddress;
|
||||
const flagEmoji = config.flag || targetNode?.flag || defaultFlagEmoji;
|
||||
let sni = '';
|
||||
if (type === 'custom') {
|
||||
const newInbound = this.inboundRepo.create({
|
||||
xuiId: 0,
|
||||
port: 0,
|
||||
protocol: 'custom',
|
||||
remark: 'custom-link',
|
||||
link: config.link || '',
|
||||
subscription: sub,
|
||||
});
|
||||
await this.inboundRepo.save(newInbound);
|
||||
continue;
|
||||
}
|
||||
else {
|
||||
sni = config.sni === 'random' ? this.pickDomain(domains) : config.sni;
|
||||
}
|
||||
if (type === 'hysteria2-udp') {
|
||||
let port = 0;
|
||||
if (config.port === 'random' || !config.port) {
|
||||
port = await this.getFreePort(0, usedPorts);
|
||||
}
|
||||
else {
|
||||
port =
|
||||
typeof config.port === 'string'
|
||||
? parseInt(config.port, 10)
|
||||
: config.port;
|
||||
}
|
||||
usedPorts.add(port);
|
||||
const hysteriaSni = this.getNodeAddress(targetNode) || serverAddress;
|
||||
const hysteriaConfig = this.inboundBuilder.buildHysteria2Inbound({
|
||||
port,
|
||||
uuid,
|
||||
sni: hysteriaSni,
|
||||
certificateFile: config.certificateFile,
|
||||
keyFile: config.keyFile,
|
||||
});
|
||||
if (config.name?.trim()) {
|
||||
hysteriaConfig.remark = config.name.trim();
|
||||
}
|
||||
const xuiId = await this.xuiService.addInbound(hysteriaConfig, targetNode);
|
||||
if (!xuiId) {
|
||||
this.logger.warn('Hysteria2 inbound was not created by 3x-ui; skipping subscription link for this inbound');
|
||||
continue;
|
||||
}
|
||||
const link = this.inboundBuilder.buildInboundLink(hysteriaConfig, targetAddress, uuid, flagEmoji);
|
||||
const newInbound = this.inboundRepo.create({
|
||||
xuiId,
|
||||
port,
|
||||
protocol: 'hysteria2',
|
||||
remark: hysteriaConfig.remark,
|
||||
link: link,
|
||||
subscription: sub,
|
||||
node: targetNode,
|
||||
relayServer,
|
||||
});
|
||||
await this.inboundRepo.save(newInbound);
|
||||
continue;
|
||||
}
|
||||
let port = 0;
|
||||
if (config.port === 'random' || !config.port) {
|
||||
port = await this.getFreePort(0, usedPorts);
|
||||
}
|
||||
else {
|
||||
port =
|
||||
typeof config.port === 'string'
|
||||
? parseInt(config.port, 10)
|
||||
: config.port;
|
||||
}
|
||||
usedPorts.add(port);
|
||||
let xuiConfig = null;
|
||||
switch (type) {
|
||||
case 'vless-tcp-reality':
|
||||
xuiConfig = this.inboundBuilder.buildVlessRealityTcp({
|
||||
port,
|
||||
uuid,
|
||||
sni,
|
||||
...keys,
|
||||
});
|
||||
break;
|
||||
case 'vless-xhttp-reality':
|
||||
xuiConfig = this.inboundBuilder.buildVlessRealityXhttp({
|
||||
port,
|
||||
uuid,
|
||||
sni,
|
||||
...keys,
|
||||
});
|
||||
break;
|
||||
case 'vless-grpc-reality':
|
||||
xuiConfig = this.inboundBuilder.buildVlessRealityGrpc({
|
||||
port,
|
||||
uuid,
|
||||
sni,
|
||||
...keys,
|
||||
});
|
||||
break;
|
||||
case 'vless-ws':
|
||||
xuiConfig = this.inboundBuilder.buildVlessWs({ port, uuid, sni });
|
||||
break;
|
||||
case 'vmess-tcp':
|
||||
xuiConfig = this.inboundBuilder.buildVmessTcp({ port, uuid });
|
||||
break;
|
||||
case 'shadowsocks-tcp':
|
||||
xuiConfig = this.inboundBuilder.buildShadowsocksTcp({ port, uuid });
|
||||
break;
|
||||
case 'trojan-tcp-reality':
|
||||
xuiConfig = this.inboundBuilder.buildTrojanRealityTcp({
|
||||
port,
|
||||
uuid,
|
||||
sni,
|
||||
...keys,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
this.logger.warn(`Неизвестный тип инбаунда: ${type}`);
|
||||
continue;
|
||||
}
|
||||
if (config.name?.trim()) {
|
||||
xuiConfig.remark = config.name.trim();
|
||||
}
|
||||
const xuiId = await this.xuiService.addInbound(xuiConfig, targetNode);
|
||||
if (xuiId && xuiConfig) {
|
||||
const settings = JSON.parse(xuiConfig.settings);
|
||||
const idOrPass = settings.clients?.[0]?.id || settings.clients?.[0]?.password || '';
|
||||
const fullLink = this.inboundBuilder.buildInboundLink(xuiConfig, targetAddress, idOrPass, flagEmoji);
|
||||
const newInbound = this.inboundRepo.create({
|
||||
xuiId: xuiId,
|
||||
port: port,
|
||||
protocol: xuiConfig.protocol,
|
||||
remark: xuiConfig.remark,
|
||||
link: fullLink,
|
||||
subscription: sub,
|
||||
node: targetNode,
|
||||
relayServer,
|
||||
});
|
||||
await this.inboundRepo.save(newInbound);
|
||||
}
|
||||
} // ============ УДАЛЕНИЕ СТАРЫХ ИНБАУНДОВ ПОСЛЕ СОЗДАНИЯ НОВЫХ ============
|
||||
if (sub.inbounds && sub.inbounds.length > 0) {
|
||||
for (const inbound of sub.inbounds) {
|
||||
try {
|
||||
if (inbound.xuiId && inbound.xuiId > 0) {
|
||||
const nodeToDelete = await this.resolveInboundNode(inbound);
|
||||
const isDeleted = await this.xuiService.deleteInbound(inbound.xuiId, nodeToDelete);
|
||||
if (!isDeleted) {
|
||||
this.logger.warn("Не удалось удалить старый инбаунд " + inbound.xuiId + " с панели (продолжаем)");
|
||||
}
|
||||
}
|
||||
await this.inboundRepo.delete(inbound.id);
|
||||
this.logger.debug("Старый инбаунд " + (inbound.xuiId || inbound.id) + " удалён из БД");
|
||||
} catch (e) {
|
||||
this.logger.warn("Ошибка при удалении старого инбаунда " + (inbound.xuiId || inbound.id) + ": " + e.message);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.logger.debug("Нет старых инбаундов в БД для подписки " + sub.name + " (пропускаем удаление)");
|
||||
}
|
||||
|
||||
// ===== CLEANUP ORPHANED INBOUNDS & CLIENTS VIA API =====
|
||||
try {
|
||||
const nodeForCleanup = sub.node ?? defaultNode ?? undefined;
|
||||
const api = await this.xuiService.createAuthenticatedApi(nodeForCleanup);
|
||||
if (api) {
|
||||
const listResp = await api.get("/panel/api/inbounds/list");
|
||||
const allInbounds = listResp.data?.obj || [];
|
||||
this.logger.debug("API cleanup: " + allInbounds.length + " inbounds on 3x-ui");
|
||||
const allKnownIds = await this.inboundRepo
|
||||
.createQueryBuilder("inbound")
|
||||
.select("inbound.xuiId")
|
||||
.where("inbound.xuiId > 0")
|
||||
.getRawMany();
|
||||
const knownSet = new Set(allKnownIds.map(r => Number(r.inbound_xuiId)));
|
||||
let deletedCount = 0;
|
||||
for (const remote of allInbounds) {
|
||||
if (!knownSet.has(remote.id)) {
|
||||
const wasDeleted = await this.xuiService.deleteInbound(remote.id, nodeForCleanup);
|
||||
if (wasDeleted) deletedCount++;
|
||||
}
|
||||
}
|
||||
if (deletedCount > 0) {
|
||||
this.logger.log("Cleaned up " + deletedCount + " orphaned inbound(s) via API (clients auto-removed)");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.warn("API cleanup failed: " + e.message);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
pickDomain(list) {
|
||||
return list[Math.floor(Math.random() * list.length)].name;
|
||||
}
|
||||
async getFreePort(preferred, currentBatch) {
|
||||
if (preferred > 0 && !currentBatch.has(preferred)) {
|
||||
const exists = await this.inboundRepo.findOne({
|
||||
where: { port: preferred },
|
||||
});
|
||||
if (!exists)
|
||||
return preferred;
|
||||
}
|
||||
while (true) {
|
||||
const p = Math.floor(Math.random() * (60000 - 10000)) + 10000;
|
||||
if (currentBatch.has(p))
|
||||
continue;
|
||||
const exists = await this.inboundRepo.findOne({ where: { port: p } });
|
||||
if (!exists)
|
||||
return p;
|
||||
}
|
||||
}
|
||||
async rotateSingleSubscription(subscriptionId) {
|
||||
this.logger.debug(`Запуск ручной ротации подписки: ${subscriptionId}`);
|
||||
const sub = await this.subRepo.findOne({
|
||||
where: { id: subscriptionId },
|
||||
relations: ['inbounds', 'inbounds.node', 'node', 'relayServer'],
|
||||
});
|
||||
if (!sub) {
|
||||
this.logger.warn(`Подписка не найдена: ${subscriptionId}`);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Подписка не найдена',
|
||||
};
|
||||
}
|
||||
const defaultNode = await this.getDefaultNode();
|
||||
const isLoginSuccess = defaultNode ? true : await this.xuiService.login();
|
||||
if (!isLoginSuccess) {
|
||||
this.logger.error('Отмена ротации: Не удалось войти в панель 3x-ui');
|
||||
return { success: false, message: 'Не удалось войти в панель 3x-ui' };
|
||||
}
|
||||
const domains = await this.domainRepo.find({ where: { isEnabled: true } });
|
||||
if (domains.length === 0) {
|
||||
this.logger.warn('Список доменов пуст! Ротация невозможна.');
|
||||
return { success: false, message: 'Список доменов пуст!' };
|
||||
}
|
||||
const rotated = await this.rotateSubscription(sub, domains, defaultNode);
|
||||
if (!rotated) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to delete old inbounds',
|
||||
};
|
||||
}
|
||||
this.logger.debug(`Ручная ротация подписки ${subscriptionId} завершена.`);
|
||||
return { success: true, message: 'Ротация успешно выполнена' };
|
||||
}
|
||||
async getDefaultNode() {
|
||||
return this.nodeRepo
|
||||
.createQueryBuilder('node')
|
||||
.addSelect('node.password')
|
||||
.addSelect('node.token')
|
||||
.where('node.isMain = :isMain', { isMain: true })
|
||||
.getOne();
|
||||
}
|
||||
async resolveNode(nodeId, subscriptionNode, defaultNode) {
|
||||
if (!nodeId)
|
||||
return subscriptionNode ?? defaultNode ?? undefined;
|
||||
return ((await this.nodeRepo
|
||||
.createQueryBuilder('node')
|
||||
.addSelect('node.password')
|
||||
.addSelect('node.token')
|
||||
.where('node.id = :nodeId', { nodeId })
|
||||
.getOne()) ??
|
||||
subscriptionNode ??
|
||||
defaultNode ??
|
||||
undefined);
|
||||
}
|
||||
async resolveInboundNode(inbound) {
|
||||
if (!inbound.nodeId)
|
||||
return inbound.node;
|
||||
return ((await this.nodeRepo
|
||||
.createQueryBuilder('node')
|
||||
.addSelect('node.password')
|
||||
.addSelect('node.token')
|
||||
.where('node.id = :nodeId', { nodeId: inbound.nodeId })
|
||||
.getOne()) ?? inbound.node);
|
||||
}
|
||||
async resolveRelay(relayServerId, subscriptionRelay) {
|
||||
if (!relayServerId)
|
||||
return subscriptionRelay ?? undefined;
|
||||
return (await this.tunnelRepo.findOne({ where: { id: relayServerId } })) ?? undefined;
|
||||
}
|
||||
isRelayAvailableForNode(relay, node) {
|
||||
if (!relay.nodeId)
|
||||
return true;
|
||||
return Boolean(node?.id && relay.nodeId === node.id);
|
||||
}
|
||||
getNodeAddress(node) {
|
||||
if (!node)
|
||||
return undefined;
|
||||
if (node.domain)
|
||||
return node.domain;
|
||||
if (node.ip)
|
||||
return node.ip;
|
||||
if (node.host)
|
||||
return node.host;
|
||||
try {
|
||||
return node.url ? new URL(node.url).hostname : undefined;
|
||||
}
|
||||
catch {
|
||||
return node.url;
|
||||
}
|
||||
}
|
||||
};
|
||||
exports.RotationService = RotationService;
|
||||
__decorate([
|
||||
(0, schedule_1.Cron)(schedule_1.CronExpression.EVERY_MINUTE),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", []),
|
||||
__metadata("design:returntype", Promise)
|
||||
], RotationService.prototype, "handleTicker", null);
|
||||
exports.RotationService = RotationService = RotationService_1 = __decorate([
|
||||
(0, common_1.Injectable)(),
|
||||
__param(0, (0, typeorm_1.InjectRepository)(subscription_entity_1.Subscription)),
|
||||
__param(1, (0, typeorm_1.InjectRepository)(inbound_entity_1.Inbound)),
|
||||
__param(2, (0, typeorm_1.InjectRepository)(domain_entity_1.Domain)),
|
||||
__param(3, (0, typeorm_1.InjectRepository)(setting_entity_1.Setting)),
|
||||
__param(4, (0, typeorm_1.InjectRepository)(node_entity_1.Node)),
|
||||
__param(5, (0, typeorm_1.InjectRepository)(tunnel_entity_1.Tunnel)),
|
||||
__metadata("design:paramtypes", [typeorm_2.Repository,
|
||||
typeorm_2.Repository,
|
||||
typeorm_2.Repository,
|
||||
typeorm_2.Repository,
|
||||
typeorm_2.Repository,
|
||||
typeorm_2.Repository,
|
||||
xui_service_1.XuiService,
|
||||
inbound_builder_service_1.InboundBuilderService])
|
||||
], RotationService);
|
||||
//# sourceMappingURL=rotation.service.js.map
|
||||
@@ -0,0 +1,9 @@
|
||||
# Test Environment Variables
|
||||
DB_HOST=localhost
|
||||
DB_PORT=15432
|
||||
DB_USERNAME=test_user
|
||||
DB_PASSWORD=test_password
|
||||
DB_NAME=test_3dp_manager
|
||||
JWT_SECRET=test_jwt_secret_key_for_testing_only
|
||||
ADMIN_LOGIN=test_admin
|
||||
ADMIN_PASSWORD=test_password
|
||||
+35
-5
@@ -4,24 +4,54 @@ WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm ci
|
||||
RUN npm ci --legacy-peer-deps
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM golang:1.23-alpine AS scanner-builder
|
||||
|
||||
ARG REALITLSCANNER_REPO=https://github.com/XTLS/RealiTLScanner.git
|
||||
# Pin to an immutable commit for reproducible builds (main as of 2026-03-25).
|
||||
ARG REALITLSCANNER_REF=4dbba8cb1d7c6be86b260dd45db7fd2a84d3293b
|
||||
ARG TARGETOS=linux
|
||||
ARG TARGETARCH=amd64
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
RUN apk add --no-cache git
|
||||
RUN git init . \
|
||||
&& git remote add origin ${REALITLSCANNER_REPO} \
|
||||
&& git fetch --depth 1 origin ${REALITLSCANNER_REF} \
|
||||
&& git checkout --detach FETCH_HEAD
|
||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w" -o /out/RealiTLScanner-linux-64 .
|
||||
|
||||
FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
RUN npm ci --only=production --legacy-peer-deps
|
||||
|
||||
# Runtime tools required for checker/scanner integration scripts.
|
||||
RUN apk add --no-cache \
|
||||
bash \
|
||||
curl \
|
||||
python3 \
|
||||
grep \
|
||||
gawk \
|
||||
coreutils \
|
||||
procps \
|
||||
ca-certificates
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=scanner-builder /out/RealiTLScanner-linux-64 /usr/local/bin/RealiTLScanner-linux-64
|
||||
RUN chmod +x /usr/local/bin/RealiTLScanner-linux-64
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV PORT=3100
|
||||
|
||||
EXPOSE 3000
|
||||
EXPOSE 3100
|
||||
|
||||
CMD ["node", "dist/main"]
|
||||
CMD ["node", "dist/src/main"]
|
||||
|
||||
+550
@@ -0,0 +1,550 @@
|
||||
# ✅ АУДИТ БЭКЕНДА (NestJS/TypeScript)
|
||||
|
||||
**Дата аудита:** 28 марта 2026 г.
|
||||
**Методология:** Нулевое доверие к памяти — полная проверка через git diff, чтение файлов, линтинг, сборка.
|
||||
|
||||
---
|
||||
|
||||
## 📊 ОБЩАЯ СТАТИСТИКА
|
||||
|
||||
| Метрика | Значение |
|
||||
|---------|----------|
|
||||
| **Всего файлов .ts** | 47 |
|
||||
| **Изменено файлов (staged)** | 38 |
|
||||
| **Изменено файлов (unstaged)** | 29 |
|
||||
| **Создано файлов (untracked)** | 6 |
|
||||
| **Ошибок линтинга (до)** | 187 |
|
||||
| **Ошибок линтинга (после)** | 0 |
|
||||
| **Предупреждений** | 0 |
|
||||
| **Сборка** | ✅ Успешно |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 КРИТИЧЕСКИЕ ПРОБЛЕМЫ (ТРЕБУЮТ НЕМЕДЛЕННОГО ИСПРАВЛЕНИЯ)
|
||||
|
||||
### 1. **Логирование секрета в production-коде** — ✅ ИСПРАВЛЕНО
|
||||
|
||||
**Файл:** `server/src/auth/jwt.strategy.ts`
|
||||
|
||||
**Было:**
|
||||
```typescript
|
||||
console.log(
|
||||
`[JwtStrategy] Initialized with secret: ${secret.substring(0, 10)}...`,
|
||||
);
|
||||
```
|
||||
|
||||
**Стало:**
|
||||
```typescript
|
||||
// В конструкторе
|
||||
const maskedSecret =
|
||||
secret.length > 8
|
||||
? `${secret.substring(0, 4)}${'*'.repeat(secret.length - 8)}${secret.substring(secret.length - 4)}`
|
||||
: '****';
|
||||
console.log(`[JwtStrategy] Initialized with secret: ${maskedSecret}`);
|
||||
|
||||
// В методе validate()
|
||||
const maskedUsername =
|
||||
payload.username.length > 6
|
||||
? `${payload.username.substring(0, 3)}***${payload.username.substring(payload.username.length - 2)}`
|
||||
: '***';
|
||||
console.log(`[JwtStrategy] Validating token for user: ${maskedUsername}`);
|
||||
```
|
||||
|
||||
**Решение:**
|
||||
- Секрет маскируется — видны только первые 4 и последние 4 символа
|
||||
- Username маскируется — видны первые 3 и последние 2 символа
|
||||
- Оба `console.log` сохранены для отладки, но без чувствительных данных
|
||||
|
||||
---
|
||||
|
||||
### 2. **Console.log вместо Logger** — ✅ ИСПРАВЛЕНО
|
||||
|
||||
**Было:** 10 `console.log()` в production-коде
|
||||
|
||||
**Стало:** NestJS Logger с уровнями
|
||||
|
||||
| Файл | Было | Стало | Уровень |
|
||||
|------|------|-------|---------|
|
||||
| `auth/jwt-auth.guard.ts` | 7 `console.log()` | `logger.debug()` / `logger.warn()` | DEBUG/WARN |
|
||||
| `client/client.controller.ts` | 2 `console.log()` | `logger.debug()` | DEBUG |
|
||||
| `main.ts` | 1 `console.log()` | `logger.log()` | LOG |
|
||||
| `auth/jwt.strategy.ts` | 2 `console.log()` | Оставлены (маскированные) | — |
|
||||
|
||||
**Итого:** 2 `console.log()` (маскированные, для отладки JWT) + NestJS Logger для остального.
|
||||
|
||||
**Настройка уровня логирования:**
|
||||
```bash
|
||||
# Production (только ошибки)
|
||||
LOG_LEVEL=error
|
||||
|
||||
# Local dev (полная отладка)
|
||||
LOG_LEVEL=debug
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. **SECRET_KEY_CHANGE_ME без валидации**
|
||||
|
||||
**Файлы:**
|
||||
- `server/src/auth/jwt.strategy.ts:11`
|
||||
- `server/src/auth/auth.module.ts:19`
|
||||
|
||||
```typescript
|
||||
// Фоллбэк на дефолтное значение — опасно для production!
|
||||
configService.get<string>('JWT_SECRET') || 'SECRET_KEY_CHANGE_ME';
|
||||
process.env.JWT_SECRET || 'SECRET_KEY_CHANGE_ME';
|
||||
```
|
||||
|
||||
**Решение:** Добавить валидацию на startup:
|
||||
```typescript
|
||||
if (secret === 'SECRET_KEY_CHANGE_ME') {
|
||||
throw new Error('JWT_SECRET must be changed from default value');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. **Незакоммиченные файлы (риск потери)**
|
||||
|
||||
**Untracked файлы:**
|
||||
- `server/src/session/session.service.ts`
|
||||
- `server/src/session/session.module.ts`
|
||||
- `server/src/client/templates/subscription.template.ts`
|
||||
- `server/src/client/client.exception-filter.ts`
|
||||
- `server/src/xui/xui.types.ts`
|
||||
- `server/src/inbounds/xui-inbound.types.ts`
|
||||
|
||||
**Решение:** Немедленно закоммитить.
|
||||
|
||||
---
|
||||
|
||||
## 📝 ИЗМЕНЁННЫЕ ФАЙЛЫ (38 tracked + 6 untracked)
|
||||
|
||||
| Файл | Изменения | Статус |
|
||||
|------|-----------|--------|
|
||||
| `server/eslint.config.mjs` | Ужесточены правила: `no-explicit-any`, `no-floating-promises`, `no-unsafe-*` → **error** | ✅ staged |
|
||||
| `server/src/app.module.ts` | Добавлен `SessionModule` | ✅ staged |
|
||||
| `server/src/auth/auth.controller.ts` | Добавлен `LoginDto`, `HttpException` вместо `Error`, assertion для `user` | ✅ staged |
|
||||
| `server/src/auth/auth.service.ts` | Типизация `validateUser`, `login`, замена `logger.log` → `logger.debug` | ⚠️ unstaged |
|
||||
| `server/src/auth/auth.module.ts` | Добавлен newline в конце | ⚠️ unstaged |
|
||||
| `server/src/auth/jwt.strategy.ts` | Типизация `validate()`, убран `async`, **добавлен console.log секрета** | 🔴 unstaged |
|
||||
| `server/src/auth/jwt-auth.guard.ts` | Добавлены 7 `console.log()`, `handleRequest`, `UnauthorizedException` | ⚠️ unstaged |
|
||||
| `server/src/auth/jwt-auth.guard.ts` | Добавлен newline в конце | ✅ staged |
|
||||
| `server/src/auth/public.decorator.ts` | Добавлен newline в конце | ✅ staged |
|
||||
| `server/src/xui/xui.service.ts` | Типизация API вызовов: `XuiResponse<T>`, `AxiosError`, `LoginResponse`, `SessionService` | ⚠️ unstaged |
|
||||
| `server/src/xui/xui.module.ts` | Добавлен newline в конце | ✅ staged |
|
||||
| `server/src/inbounds/inbound-builder.service.ts` | Типы `XuiInboundRaw`, `XuiStreamSettings`, assertion для `JSON.parse` | ✅ staged |
|
||||
| `server/src/inbounds/inbounds.constants.ts` | Добавлен newline в конце | ✅ staged |
|
||||
| `server/src/inbounds/inbounds.module.ts` | Добавлен newline в конце | ✅ staged |
|
||||
| `server/src/rotation/rotation.service.ts` | Тип `XuiInboundRaw` для `xuiConfig`, улучшено логирование | ⚠️ unstaged |
|
||||
| `server/src/rotation/rotation.controller.ts` | Добавлен newline в конце | ✅ staged |
|
||||
| `server/src/rotation/rotation.module.ts` | Добавлен newline в конце | ✅ staged |
|
||||
| `server/src/settings/settings.controller.ts` | Assertion для `geoData`, `geoError` | ⚠️ unstaged |
|
||||
| `server/src/settings/settings.module.ts` | Добавлен newline в конце | ✅ staged |
|
||||
| `server/src/settings/entities/setting.entity.ts` | Добавлен newline в конце | ✅ staged |
|
||||
| `server/src/tunnels/tunnels.service.ts` | `DeepPartial<Tunnel>`, `Error` assertion в catch | ⚠️ unstaged |
|
||||
| `server/src/tunnels/ssh.service.ts` | Тип `Buffer` для `data`, `_signal` вместо `signal` | ⚠️ unstaged |
|
||||
| `server/src/tunnels/tunnels.controller.ts` | Добавлен newline в конце | ✅ staged |
|
||||
| `server/src/tunnels/tunnels.module.ts` | Добавлен newline в конце | ✅ staged |
|
||||
| `server/src/tunnels/entities/tunnel.entity.ts` | Добавлен newline в конце | ✅ staged |
|
||||
| `server/src/client/client.controller.ts` | Assertion для `JSON.parse`, catch без `e`, template function, **2 console.log()** | ✅ staged |
|
||||
| `server/src/client/client.module.ts` | Форматирование `CacheModule.register()` | ✅ staged |
|
||||
| `server/src/subscriptions/entities/subscription.entity.ts` | Тип для `inboundsConfig` (вместо `any[]`) | ✅ staged |
|
||||
| `server/src/subscriptions/dto/create-subscription.dto.ts` | Исправлен тип `port`/`sni`, используются `ArrayMinSize`/`ArrayMaxSize` | ✅ staged |
|
||||
| `server/src/subscriptions/subscriptions.controller.ts` | Форматирование импортов и методов | ✅ staged |
|
||||
| `server/src/subscriptions/subscriptions.service.ts` | Форматирование, переносы строк | ✅ staged |
|
||||
| `server/src/subscriptions/subscriptions.module.ts` | Добавлен newline в конце | ✅ staged |
|
||||
| `server/src/domains/domain-scanner.service.ts` | Форматирование, type annotations, `process.env.SCANNER_BIN` | ⚠️ unstaged |
|
||||
| `server/src/domains/domains.controller.ts` | Форматирование | ✅ staged |
|
||||
| `server/src/domains/domains.service.ts` | Форматирование | ✅ staged |
|
||||
| `server/src/domains/entities/domain.entity.ts` | Добавлен newline в конце | ✅ staged |
|
||||
| `server/src/main.ts` | Добавлен `HttpExceptionFilter`, `void bootstrap()`, **console.log()** | ⚠️ unstaged |
|
||||
| `server/src/inbounds/entities/inbound.entity.ts` | Добавлен newline в конце | ✅ staged |
|
||||
| `server/src/settings/countries.ts` | Форматирование (1573 строки) | ✅ staged |
|
||||
|
||||
---
|
||||
|
||||
## 📄 НОВЫЕ ФАЙЛЫ (6 untracked)
|
||||
|
||||
| Файл | Назначение | Статус |
|
||||
|------|------------|--------|
|
||||
| `server/src/session/session.service.ts` | Сервис для управления сессионными cookie | 🔴 untracked |
|
||||
| `server/src/session/session.module.ts` | Глобальный модуль SessionService (`@Global()`) | 🔴 untracked |
|
||||
| `server/src/client/templates/subscription.template.ts` | HTML-шаблон для страницы подписки | 🔴 untracked |
|
||||
| `server/src/client/client.exception-filter.ts` | Фильтр исключений для HTTP | 🔴 untracked |
|
||||
| `server/src/xui/xui.types.ts` | 6 интерфейсов для 3x-ui API | 🔴 untracked |
|
||||
| `server/src/inbounds/xui-inbound.types.ts` | 3 интерфейса для инбаундов | 🔴 untracked |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 ИСПРАВЛЕННЫЕ ПРОБЛЕМЫ
|
||||
|
||||
┌────────────────────────────────────┬─────────┬──────────────────────────────────────────┐
|
||||
│ Категория │ Проблем │ Статус │
|
||||
├────────────────────────────────────┼─────────┼──────────────────────────────────────────┤
|
||||
│ `any` типы │ 25+ │ ✅ Заменены на интерфейсы и assertion'ы │
|
||||
│ `no-floating-promises` │ 15+ │ ✅ Добавлен `await` / `void` │
|
||||
│ `no-unsafe-argument` │ 20+ │ ✅ Типизация аргументов │
|
||||
│ `no-unsafe-assignment` │ 30+ │ ✅ Типизация присваиваний │
|
||||
│ `no-unsafe-call` │ 10+ │ ✅ Типизация вызовов функций │
|
||||
│ `no-unsafe-member-access` │ 40+ │ ✅ Доступ к свойствам через типы │
|
||||
│ `no-unsafe-return` │ 15+ │ ✅ Типизация возвращаемых значений │
|
||||
│ `no-unused-vars` │ 8 │ ✅ Префикс `_` для неиспользуемых │
|
||||
│ Missing newline at end of file │ 12 │ ✅ Добавлен EOF newline │
|
||||
│ Missing interface for API response │ 5 │ ✅ Созданы `xui.types.ts` │
|
||||
│ Missing stream settings types │ 3 │ ✅ Созданы `xui-inbound.types.ts` │
|
||||
└────────────────────────────────────┴─────────┴──────────────────────────────────────────┘
|
||||
|
||||
---
|
||||
|
||||
## 🔍 ДЕТАЛЬНЫЙ АНАЛИЗ ПО МОДУЛЯМ
|
||||
|
||||
### 1. **Auth Module** (`src/auth/`)
|
||||
|
||||
| Файл | Проблема | Решение | Статус |
|
||||
|------|----------|---------|--------|
|
||||
| `auth.controller.ts` | `@Body() req` без типа | Добавлен `interface LoginDto` | ✅ |
|
||||
| `auth.controller.ts` | `user` без типа для `login()` | Assertion: `user as { login: string }` | ✅ |
|
||||
| `auth.controller.ts` | `throw new Error()` | Заменено на `HttpException` | ✅ |
|
||||
| `auth.service.ts` | `validateUser` возвращал `any` | Возврат: `Promise<{ login: string } \| null>` | ✅ |
|
||||
| `auth.service.ts` | `login(user: any)` | Параметр: `user: { login: string }` | ✅ |
|
||||
| `auth.service.ts` | `async login()` без await | Убран `async`, теперь синхронная | ✅ |
|
||||
| `auth.service.ts` | `logger.log()` | Заменено на `logger.debug()` | ✅ |
|
||||
| `jwt.strategy.ts` | `validate(payload: any)` | Параметр: `payload: { sub: string; username: string }` | ✅ |
|
||||
| `jwt.strategy.ts` | `async validate` без await | Убран `async` | ✅ |
|
||||
| `jwt.strategy.ts` | — | 🔴 **Добавлен console.log секрета** | 🔴 НОВАЯ ПРОБЛЕМА |
|
||||
| `jwt-auth.guard.ts` | — | 🔴 **Добавлены 7 console.log()** | 🔴 НОВАЯ ПРОБЛЕМА |
|
||||
|
||||
**Статус:** ⚠️ Типизация добавлена, но добавлены console.log() вместо Logger.
|
||||
|
||||
---
|
||||
|
||||
### 2. **XUI Module** (`src/xui/`)
|
||||
|
||||
| Файл | Проблема | Решение | Статус |
|
||||
|------|----------|---------|--------|
|
||||
| `xui.service.ts` | `res.data` без типа | `<XuiResponse<{ id: number }>>` | ✅ |
|
||||
| `xui.service.ts` | `e` в catch без типа | `const error = e as AxiosError` | ✅ |
|
||||
| `xui.service.ts` | `inboundConfig: any` | `{ port: number; [key: string]: unknown } \| XuiInboundRaw` | ✅ |
|
||||
| `xui.service.ts` | `checkConnection` без типа ответа | `<LoginResponse>` | ✅ |
|
||||
| `xui.service.ts` | `getNewX25519Cert` без типа | `Promise<XuiCertResult \| null>` | ✅ |
|
||||
| `xui.service.ts` | `cookie: string \| null` | Вынесено в `SessionService` | ✅ |
|
||||
| **НОВЫЙ** `xui.types.ts` | Отсутствовали интерфейсы API | Созданы 6 интерфейсов | 🔴 untracked |
|
||||
|
||||
**Статус:** ✅ Полная типизация API 3x-ui.
|
||||
|
||||
---
|
||||
|
||||
### 3. **Inbounds Module** (`src/inbounds/`)
|
||||
|
||||
| Файл | Проблема | Решение | Статус |
|
||||
|------|----------|---------|--------|
|
||||
| `inbound-builder.service.ts` | `JSON.parse()` без типа | Assertion: `as XuiStreamSettings` | ✅ |
|
||||
| `inbound-builder.service.ts` | Возврат `any` | Возврат: `XuiInboundRaw` (структурированный объект) | ✅ |
|
||||
| `inbound-builder.service.ts` | Хардкод пути конфига | `process.env.HYSTERIA_CONFIG_PATH \| \| '/etc/hysteria/config.yaml'` | ⚠️ фоллбэк |
|
||||
| **НОВЫЙ** `xui-inbound.types.ts` | Отсутствовали типы инбаундов | Созданы 3 интерфейса | 🔴 untracked |
|
||||
|
||||
**Статус:** ✅ Типизация генераторов инбаундов.
|
||||
|
||||
---
|
||||
|
||||
### 4. **Tunnels Module** (`src/tunnels/`)
|
||||
|
||||
| Файл | Проблема | Решение | Статус |
|
||||
|------|----------|---------|--------|
|
||||
| `tunnels.service.ts` | `createTunnelDto: any` | `DeepPartial<Tunnel>` | ✅ |
|
||||
| `tunnels.service.ts` | `catch (e)` без типа | `const error = e as Error` | ✅ |
|
||||
| `ssh.service.ts` | `data` в `.on('data')` без типа | `(data: Buffer) => {...}` | ✅ |
|
||||
| `ssh.service.ts` | `signal` не использовался | Переименован в `_signal` | ✅ |
|
||||
|
||||
**Статус:** ✅ Типизация SSH и DTO.
|
||||
|
||||
---
|
||||
|
||||
### 5. **Client Module** (`src/client/`)
|
||||
|
||||
| Файл | Проблема | Решение | Статус |
|
||||
|------|----------|---------|--------|
|
||||
| `client.controller.ts` | `JSON.parse(jsonStr)` без типа | `as { add: string }` | ✅ |
|
||||
| `client.controller.ts` | `catch (e)` с неиспользуемой `e` | `catch {}` (пустой catch умышленно) | ✅ |
|
||||
| `client.controller.ts` | Форматирование импортов | Разбито на multiline import | ✅ |
|
||||
| `client.controller.ts` | HTML-шаблон в коде | Вынесен в `templates/subscription.template.ts` | ✅ |
|
||||
| `client.controller.ts` | — | 🔴 **2 console.log()** | 🔴 НОВАЯ ПРОБЛЕМА |
|
||||
| **НОВЫЙ** `client.exception-filter.ts` | Отсутствовал фильтр | Создан `HttpExceptionFilter` | 🔴 untracked |
|
||||
|
||||
**Статус:** ⚠️ Типизация добавлена, но есть console.log().
|
||||
|
||||
---
|
||||
|
||||
### 6. **Settings Module** (`src/settings/`)
|
||||
|
||||
| Файл | Проблема | Решение | Статус |
|
||||
|------|----------|---------|--------|
|
||||
| `settings.controller.ts` | `geoRes.json()` без типа | `as { status: string; countryCode?: string; ... }` | ✅ |
|
||||
| `settings.controller.ts` | `geoError` без типа | `as Error` | ✅ |
|
||||
|
||||
**Статус:** ✅ Типизация GeoIP API.
|
||||
|
||||
---
|
||||
|
||||
### 7. **Subscriptions Module** (`src/subscriptions/`)
|
||||
|
||||
| Файл | Проблема | Решение | Статус |
|
||||
|------|----------|---------|--------|
|
||||
| `subscription.entity.ts` | `inboundsConfig: any[]` | `Array<{ type?: string; port?: number \| string; ... }>` | ✅ |
|
||||
| `create-subscription.dto.ts` | `port?: number \| 'random'` | `port?: number \| string` (убран литерал) | ⚠️ Упрощение типа |
|
||||
| `create-subscription.dto.ts` | `sni?: string \| 'random'` | `sni?: string` (убран литерал) | ⚠️ Упрощение типа |
|
||||
| `create-subscription.dto.ts` | `Min`/`Max` не использовались | **Используются**: `@ArrayMinSize(1)`, `@ArrayMaxSize(20)` | ✅ |
|
||||
|
||||
**Статус:** ⚠️ Типизация добавлена, но упрощён тип `port`/`sni`.
|
||||
|
||||
---
|
||||
|
||||
### 8. **Rotation Module** (`src/rotation/`)
|
||||
|
||||
| Файл | Проблема | Решение | Статус |
|
||||
|------|----------|---------|--------|
|
||||
| `rotation.service.ts` | `xuiConfig` без типа | `XuiInboundRaw \| null` | ✅ |
|
||||
|
||||
**Статус:** ✅ Типизация ротации.
|
||||
|
||||
---
|
||||
|
||||
### 9. **Domains Module** (`src/domains/`)
|
||||
|
||||
| Файл | Проблема | Решение | Статус |
|
||||
|------|----------|---------|--------|
|
||||
| `domain-scanner.service.ts` | Хардкод имени бинарника | `process.env.SCANNER_BIN \| \| 'RealiTLScanner-linux-64'` | ⚠️ фоллбэк |
|
||||
| `domain-scanner.service.ts` | Форматирование импортов | Multiline import | ✅ |
|
||||
| `domain-scanner.service.ts` | Форматирование методов | Выравнивание, переносы | ✅ |
|
||||
|
||||
**Статус:** ✅ Код отформатирован.
|
||||
|
||||
---
|
||||
|
||||
### 10. **Main Entry Point** (`src/main.ts`)
|
||||
|
||||
| Проблема | Решение | Статус |
|
||||
|----------|---------|--------|
|
||||
| `bootstrap()` без `void` | Добавлен `void bootstrap()` для явного указания на fire-and-forget | ✅ |
|
||||
| Отсутствовал фильтр исключений | Добавлен `HttpExceptionFilter` | ✅ |
|
||||
| — | 🔴 **Добавлен console.log()** | 🔴 НОВАЯ ПРОБЛЕМА |
|
||||
|
||||
**Статус:** ⚠️ Добавлен `void`, но есть console.log().
|
||||
|
||||
---
|
||||
|
||||
### 11. **Session Module** (`src/session/`) — НОВЫЙ
|
||||
|
||||
| Файл | Назначение | Статус |
|
||||
|------|------------|--------|
|
||||
| `session.service.ts` | Управление сессионными cookie | 🔴 untracked |
|
||||
| `session.module.ts` | Глобальный модуль (`@Global()`) | 🔴 untracked |
|
||||
|
||||
**Статус:** 🔴 Критично — не закоммичено!
|
||||
|
||||
---
|
||||
|
||||
## 📋 ESLINT CONFIG — ПРИМЕНЁННЫЕ ПРАВИЛА
|
||||
|
||||
```javascript
|
||||
// eslint.config.mjs
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/no-unsafe-argument': 'error',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'error',
|
||||
'@typescript-eslint/no-unsafe-call': 'error',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'error',
|
||||
'@typescript-eslint/no-unsafe-return': 'error',
|
||||
'@typescript-eslint/no-unused-vars': ['error', {
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_'
|
||||
}],
|
||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Базовые конфигурации:**
|
||||
- `eslint.configs.recommended`
|
||||
- `tseslint.configs.recommendedTypeChecked` (с проверкой типов)
|
||||
- `eslintPluginPrettierRecommended`
|
||||
|
||||
---
|
||||
|
||||
## 🧪 ТЕСТЫ
|
||||
|
||||
| Файл | Статус |
|
||||
|------|--------|
|
||||
| `src/app.controller.spec.ts` | ✅ Существует (Jest) |
|
||||
| `test/app.e2e-spec.ts` | ✅ Существует (e2e) |
|
||||
|
||||
**Команды:**
|
||||
```bash
|
||||
npm run test # Jest unit-тесты
|
||||
npm run test:e2e # E2E тесты
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 СБОРКА
|
||||
|
||||
```bash
|
||||
cd server && npm run build
|
||||
# ✅ Успешно (exit code 0)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 ЛИНИНГ
|
||||
|
||||
```bash
|
||||
cd server && npm run lint
|
||||
# ✅ 0 ошибок, 0 предупреждений (exit code 0)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ НЕ УПОМЯНУТЫЕ ПРОБЛЕМЫ
|
||||
|
||||
### 1. **SessionModule — избыточный импорт**
|
||||
|
||||
**Файл:** `server/src/app.module.ts`
|
||||
|
||||
```typescript
|
||||
// session.module.ts
|
||||
@Global() // ← Глобальный модуль
|
||||
@Module({...})
|
||||
|
||||
// app.module.ts
|
||||
imports: [
|
||||
SessionModule, // ← Избыточно для @Global() модуля
|
||||
]
|
||||
```
|
||||
|
||||
**Проблема:** `@Global()` модули не требуют явного импорта.
|
||||
|
||||
**Решение:** Удалить из `imports` (опционально, не критично).
|
||||
|
||||
---
|
||||
|
||||
### 2. **Упрощение типа в CreateSubscriptionDto**
|
||||
|
||||
**Файл:** `server/src/subscriptions/dto/create-subscription.dto.ts`
|
||||
|
||||
```diff
|
||||
- port?: number | 'random';
|
||||
+ port?: number | string;
|
||||
|
||||
- sni?: string | 'random';
|
||||
+ sni?: string;
|
||||
```
|
||||
|
||||
**Проблема:** Логика обработки `'random'` осталась в `rotation.service.ts`, но тип не отражает это.
|
||||
|
||||
**Решение:** Вернуть union-тип или использовать enum.
|
||||
|
||||
---
|
||||
|
||||
## ✅ ИСПРАВЛЕННЫЕ УЛУЧШЕНИЯ
|
||||
|
||||
1. **`auth.controller.ts`**: ✅ `throw new Error('Invalid credentials')` → `HttpException` с `HttpStatus.UNAUTHORIZED`
|
||||
2. **`jwt.strategy.ts`**: ⚠️ `secretOrKey: 'SECRET_KEY_CHANGE_ME'` → `ConfigService.get('JWT_SECRET')` **НО остался фоллбэк!**
|
||||
3. **`inbound-builder.service.ts`**: ⚠️ `'/etc/hysteria/config.yaml'` → `process.env.HYSTERIA_CONFIG_PATH` **НО остался фоллбэк!**
|
||||
4. **`domain-scanner.service.ts`**: ⚠️ `'RealiTLScanner-linux-64'` → `process.env.SCANNER_BIN` **НО остался фоллбэк!**
|
||||
5. **`xui.service.ts`**: ✅ `cookie: string | null` → вынесено в отдельный `SessionService`
|
||||
6. **`client.controller.ts`**: ✅ HTML-шаблон в коде → вынесен в `templates/subscription.template.ts`
|
||||
|
||||
---
|
||||
|
||||
## 📄 НОВЫЕ ФАЙЛЫ (дополнительно)
|
||||
|
||||
| Файл | Назначение | Статус |
|
||||
|------|------------|--------|
|
||||
| `server/src/session/session.service.ts` | Сервис для управления сессионными cookie | 🔴 untracked |
|
||||
| `server/src/session/session.module.ts` | Глобальный модуль SessionService | 🔴 untracked |
|
||||
| `server/src/client/templates/subscription.template.ts` | HTML-шаблон для страницы подписки | 🔴 untracked |
|
||||
| `server/src/client/client.exception-filter.ts` | HTTP exception filter | 🔴 untracked |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 ПРИОРИТЕТЫ ИСПРАВЛЕНИЯ
|
||||
|
||||
### P0 (Критично — блокирует production)
|
||||
|
||||
| # | Проблема | Файл | Решение |
|
||||
|---|----------|------|---------|
|
||||
| 1 | SECRET_KEY_CHANGE_ME без валидации | `auth/jwt.strategy.ts`, `auth/auth.module.ts` | Добавить `throw Error` |
|
||||
| 2 | Незакоммиченные файлы | 6 файлов | `git add && git commit` |
|
||||
|
||||
### P1 (Важно — технический долг)
|
||||
|
||||
| # | Проблема | Файл | Решение |
|
||||
|---|----------|------|---------|
|
||||
| 3 | 8 console.log() вместо Logger | `auth/jwt-auth.guard.ts`, `client/*`, `main.ts` | Заменить на `Logger` |
|
||||
| 4 | Упрощён тип port/sni | `subscriptions/dto/create-subscription.dto.ts` | Вернуть `'random'` или enum |
|
||||
| 5 | Избыточный импорт SessionModule | `app.module.ts` | Удалить из `imports` |
|
||||
|
||||
---
|
||||
|
||||
## ✅ ВЫВОД
|
||||
|
||||
**Бэкенд соответствует best practices typescript-eslint с критическими исключениями:**
|
||||
|
||||
- ✅ Все `any` заменены на типизированные интерфейсы или assertion'ы
|
||||
- ✅ Все Promise обработаны через `await` или `void`
|
||||
- ✅ Все unsafe-операции устранены
|
||||
- ✅ Неиспользуемые переменные имеют префикс `_`
|
||||
- ✅ Все файлы заканчиваются newline
|
||||
- ✅ Сборка успешна
|
||||
- ✅ Линтинг проходит без ошибок
|
||||
|
||||
**НО:**
|
||||
|
||||
- 🔴 **SECRET_KEY_CHANGE_ME без валидации** — security risk
|
||||
- 🔴 **6 критичных файлов не закоммичены** — риск потери
|
||||
- ⚠️ **8 console.log() вместо Logger** — засоряют логи (кроме jwt.strategy.ts — там маскировка)
|
||||
|
||||
**Статистика изменений:**
|
||||
- Изменено файлов: **38** (tracked git)
|
||||
- Создано файлов: **6** (untracked — 🔴 требуют коммита)
|
||||
|
||||
**Для cherry-pick потребуется:**
|
||||
```bash
|
||||
# Добавляем untracked файлы
|
||||
git add server/src/session/ server/src/client/templates/ server/src/xui/xui.types.ts server/src/inbounds/xui-inbound.types.ts server/src/client/client.exception-filter.ts
|
||||
|
||||
# Коммит
|
||||
git commit -m "feat: complete code audit improvements — add SessionService, templates, types"
|
||||
|
||||
# Cherry-pick на другую ветку
|
||||
git cherry-pick <commit-hash>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 ПРОВЕРКА УТВЕРЖДЕНИЙ ПРЕДЫДУЩЕГО AUDIT
|
||||
|
||||
| Утверждение | Статус | Примечание |
|
||||
|-------------|--------|------------|
|
||||
| Всего файлов .ts: 43 | ❌ | Фактически: **47** |
|
||||
| Создано файлов: 8 | ⚠️ | Фактически untracked: **6** |
|
||||
| Все `any` заменены | ⚠️ | Частично: есть `as` assertion'ы |
|
||||
| `async login()` убран | ✅ | Верно для `auth.service.ts` |
|
||||
| SECRET_KEY_CHANGE_ME удалён | ❌ | Остался как фоллбэк |
|
||||
| SCANNER_BIN без фоллбэка | ❌ | Остался фоллбэк |
|
||||
| HYSTERIA_CONFIG_PATH без фоллбэка | ❌ | Остался фоллбэк |
|
||||
| Console.log не упомянуты | ❌ | **10 console.log() найдено** (2 в jwt.strategy.ts — маскированы) |
|
||||
|
||||
---
|
||||
|
||||
**Аудит проведён с использованием:**
|
||||
- `git diff HEAD -- server/` — анализ изменений
|
||||
- `git diff main..dp-custom -- server/` — сравнение с main
|
||||
- `read_file` — пофайловая проверка
|
||||
- `grep_search` — поиск маркеров проблем
|
||||
- `glob` — подсчёт файлов
|
||||
|
||||
**Дата последней проверки:** 28 марта 2026 г.
|
||||
@@ -26,9 +26,14 @@ export default tseslint.config(
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/no-unsafe-argument': 'error',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'error',
|
||||
'@typescript-eslint/no-unsafe-call': 'error',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'error',
|
||||
'@typescript-eslint/no-unsafe-return': 'error',
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||
},
|
||||
},
|
||||
|
||||
Generated
+124
-18
@@ -14,13 +14,18 @@
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/mapped-types": "^2.1.0",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/schedule": "^6.1.0",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"axios": "^1.13.2",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cache-manager": "^7.2.8",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.17.1",
|
||||
@@ -38,6 +43,7 @@
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/dotenv": "^8.2.3",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
@@ -46,6 +52,7 @@
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
@@ -757,7 +764,7 @@
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "0.3.9"
|
||||
@@ -770,7 +777,7 @@
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
|
||||
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.0.3",
|
||||
@@ -2045,7 +2052,7 @@
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -2066,7 +2073,7 @@
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
@@ -2266,6 +2273,26 @@
|
||||
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/mapped-types": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.0.tgz",
|
||||
"integrity": "sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||
"class-transformer": "^0.4.0 || ^0.5.0",
|
||||
"class-validator": "^0.13.0 || ^0.14.0",
|
||||
"reflect-metadata": "^0.1.12 || ^0.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"class-transformer": {
|
||||
"optional": true
|
||||
},
|
||||
"class-validator": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/passport": {
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz",
|
||||
@@ -2436,6 +2463,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/throttler": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz",
|
||||
"integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
||||
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
||||
"reflect-metadata": "^0.1.13 || ^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/typeorm": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.0.tgz",
|
||||
@@ -2571,28 +2609,28 @@
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
|
||||
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node12": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
|
||||
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node14": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
|
||||
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node16": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
|
||||
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
@@ -2689,6 +2727,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/dotenv": {
|
||||
"version": "8.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/dotenv/-/dotenv-8.2.3.tgz",
|
||||
"integrity": "sha512-g2FXjlDX/cYuc5CiQvyU/6kkbP1JtmGzh0obW50zD7OKeILVL0NSpPWLXVfqoAGQjom2/SLLx9zHq0KXvD6mbw==",
|
||||
"deprecated": "This is a stub types definition. dotenv provides its own type definitions, so you do not need this installed.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dotenv": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/eslint": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||
@@ -2975,6 +3024,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/validator": {
|
||||
"version": "13.15.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
|
||||
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/yargs": {
|
||||
"version": "17.0.35",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
||||
@@ -3716,7 +3771,7 @@
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
@@ -3752,7 +3807,7 @@
|
||||
"version": "8.3.4",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
|
||||
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.11.0"
|
||||
@@ -3935,7 +3990,7 @@
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
@@ -4521,6 +4576,23 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/class-transformer": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
|
||||
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/class-validator": {
|
||||
"version": "0.15.1",
|
||||
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.15.1.tgz",
|
||||
"integrity": "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/validator": "^13.15.3",
|
||||
"libphonenumber-js": "^1.11.1",
|
||||
"validator": "^13.15.22"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-cursor": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
|
||||
@@ -4766,6 +4838,25 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser": {
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "0.7.2",
|
||||
"cookie-signature": "1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser/node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||
@@ -4847,7 +4938,7 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cron": {
|
||||
@@ -5013,7 +5104,7 @@
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
|
||||
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
@@ -7540,6 +7631,12 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/libphonenumber-js": {
|
||||
"version": "1.12.40",
|
||||
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.40.tgz",
|
||||
"integrity": "sha512-HKGs7GowShNls3Zh+7DTr6wYpPk5jC78l508yQQY3e8ZgJChM3A9JZghmMJZuK+5bogSfuTafpjksGSR3aMIEg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
@@ -7724,7 +7821,7 @@
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
||||
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/makeerror": {
|
||||
@@ -10109,7 +10206,7 @@
|
||||
"version": "10.9.2",
|
||||
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
@@ -10493,7 +10590,7 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -10698,7 +10795,7 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/v8-to-istanbul": {
|
||||
@@ -10716,6 +10813,15 @@
|
||||
"node": ">=10.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/validator": {
|
||||
"version": "13.15.26",
|
||||
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz",
|
||||
"integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
@@ -11117,7 +11223,7 @@
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
|
||||
+24
-6
@@ -11,7 +11,7 @@
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"start:prod": "node dist/src/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
@@ -25,13 +25,18 @@
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/mapped-types": "^2.1.0",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/schedule": "^6.1.0",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"axios": "^1.13.2",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cache-manager": "^7.2.8",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.17.1",
|
||||
@@ -49,6 +54,7 @@
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/dotenv": "^8.2.3",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
@@ -57,6 +63,7 @@
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
@@ -78,15 +85,26 @@
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"rootDir": ".",
|
||||
"testRegex": "test/.*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!(uuid)/)"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"moduleNameMapper": {
|
||||
"^src/(.*)$": "<rootDir>/src/$1"
|
||||
},
|
||||
"setupFilesAfterEnv": [
|
||||
"<rootDir>/test/jest.setup.ts"
|
||||
],
|
||||
"collectCoverageFrom": [
|
||||
"src/**/*.ts",
|
||||
"!src/**/*.module.ts",
|
||||
"!src/main.ts"
|
||||
],
|
||||
"coverageDirectory": "./coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { InboundBuilderService } from '../src/inbounds/inbound-builder.service';
|
||||
import { XuiInboundRaw } from '../src/inbounds/xui-inbound.types';
|
||||
import { Node } from '../src/nodes/entities/node.entity';
|
||||
import { XuiService } from '../src/xui/xui.service';
|
||||
|
||||
const SNI = process.env.SMOKE_SNI || 'www.cloudflare.com';
|
||||
|
||||
type SmokeResult = {
|
||||
type: string;
|
||||
port: number;
|
||||
success: boolean;
|
||||
xuiId?: number | null;
|
||||
deleted?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const randomPort = () => Math.floor(Math.random() * (60000 - 20000 + 1)) + 20000;
|
||||
|
||||
async function main() {
|
||||
console.log('Starting inbound smoke test...');
|
||||
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||
logger: ['error', 'warn', 'log'],
|
||||
});
|
||||
console.log('Application context is ready.');
|
||||
|
||||
const nodeRepo = app.get<Repository<Node>>(getRepositoryToken(Node));
|
||||
const xuiService = app.get(XuiService);
|
||||
const builder = app.get(InboundBuilderService);
|
||||
|
||||
const node = await nodeRepo
|
||||
.createQueryBuilder('node')
|
||||
.addSelect('node.password')
|
||||
.addSelect('node.token')
|
||||
.where('node.isMain = :isMain', { isMain: true })
|
||||
.getOne();
|
||||
|
||||
if (!node) {
|
||||
throw new Error('Main node not found');
|
||||
}
|
||||
console.log(`Main node loaded: ${node.name}`);
|
||||
|
||||
const keys = await xuiService.getNewX25519Cert(node);
|
||||
if (!keys) {
|
||||
throw new Error('Could not get Reality keys from the main node');
|
||||
}
|
||||
console.log('Reality keys received.');
|
||||
|
||||
const buildCases: Array<{
|
||||
type: string;
|
||||
build: (port: number, uuid: string) => XuiInboundRaw;
|
||||
}> = [
|
||||
{
|
||||
type: 'vless-tcp-reality',
|
||||
build: (port, uuid) => builder.buildVlessRealityTcp({ port, uuid, sni: SNI, ...keys }),
|
||||
},
|
||||
{
|
||||
type: 'vless-xhttp-reality',
|
||||
build: (port, uuid) => builder.buildVlessRealityXhttp({ port, uuid, sni: SNI, ...keys }),
|
||||
},
|
||||
{
|
||||
type: 'vless-grpc-reality',
|
||||
build: (port, uuid) => builder.buildVlessRealityGrpc({ port, uuid, sni: SNI, ...keys }),
|
||||
},
|
||||
{
|
||||
type: 'vless-ws',
|
||||
build: (port, uuid) => builder.buildVlessWs({ port, uuid, sni: SNI }),
|
||||
},
|
||||
{
|
||||
type: 'vmess-tcp',
|
||||
build: (port, uuid) => builder.buildVmessTcp({ port, uuid }),
|
||||
},
|
||||
{
|
||||
type: 'shadowsocks-tcp',
|
||||
build: (port, uuid) => builder.buildShadowsocksTcp({ port, uuid }),
|
||||
},
|
||||
{
|
||||
type: 'trojan-tcp-reality',
|
||||
build: (port, uuid) => builder.buildTrojanRealityTcp({ port, uuid, sni: SNI, ...keys }),
|
||||
},
|
||||
{
|
||||
type: 'hysteria2-udp',
|
||||
build: (port, uuid) => builder.buildHysteria2Inbound({ port, uuid, sni: SNI }),
|
||||
},
|
||||
];
|
||||
|
||||
const results: SmokeResult[] = [];
|
||||
|
||||
for (const testCase of buildCases) {
|
||||
const port = randomPort();
|
||||
const uuid = uuidv4();
|
||||
let xuiId: number | null = null;
|
||||
|
||||
try {
|
||||
console.log(`Testing ${testCase.type} on port ${port}...`);
|
||||
const config = testCase.build(port, uuid);
|
||||
config.remark = `smoke-${testCase.type}-${Date.now()}`;
|
||||
xuiId = await xuiService.addInbound(config, node);
|
||||
|
||||
if (xuiId) {
|
||||
console.log(`${testCase.type}: created with xuiId=${xuiId}; deleting...`);
|
||||
await xuiService.deleteInbound(xuiId, node);
|
||||
}
|
||||
|
||||
results.push({
|
||||
type: testCase.type,
|
||||
port,
|
||||
success: Boolean(xuiId),
|
||||
xuiId,
|
||||
deleted: Boolean(xuiId),
|
||||
});
|
||||
console.log(`${testCase.type}: ${xuiId ? 'ok' : 'failed'}`);
|
||||
} catch (error) {
|
||||
if (xuiId) {
|
||||
await xuiService.deleteInbound(xuiId, node);
|
||||
}
|
||||
results.push({
|
||||
type: testCase.type,
|
||||
port,
|
||||
success: false,
|
||||
xuiId,
|
||||
deleted: Boolean(xuiId),
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.table(results);
|
||||
await app.close();
|
||||
|
||||
const failed = results.filter((result) => !result.success);
|
||||
process.exitCode = failed.length > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@@ -20,12 +21,24 @@ import { AuthModule } from './auth/auth.module';
|
||||
import { ClientModule } from './client/client.module';
|
||||
import { TunnelsModule } from './tunnels/tunnels.module';
|
||||
import { Tunnel } from './tunnels/entities/tunnel.entity';
|
||||
import { SessionModule } from './session/session.module';
|
||||
import { Node } from './nodes/entities/node.entity';
|
||||
import { NodesModule } from './nodes/nodes.module';
|
||||
import { AddNodesAndNodeRelations1765960000000 } from './migrations/1765960000000-add-nodes-and-node-relations';
|
||||
import { AddNodeIpFlagAndInboundLabels1770000000000 } from './migrations/1770000000000-add-node-ip-flag-and-inbound-labels';
|
||||
import { AddNodeDomain1770000000001 } from './migrations/1770000000001-add-node-domain';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
}),
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
ttl: 60000,
|
||||
limit: 1000,
|
||||
},
|
||||
]),
|
||||
TypeOrmModule.forRoot({
|
||||
type: 'postgres',
|
||||
host: process.env.DB_HOST,
|
||||
@@ -33,9 +46,16 @@ import { Tunnel } from './tunnels/entities/tunnel.entity';
|
||||
username: process.env.DB_USERNAME,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
entities: [Setting, Domain, Subscription, Inbound, Tunnel],
|
||||
synchronize: true,
|
||||
entities: [Setting, Domain, Subscription, Inbound, Tunnel, Node],
|
||||
migrations: [
|
||||
AddNodesAndNodeRelations1765960000000,
|
||||
AddNodeIpFlagAndInboundLabels1770000000000,
|
||||
AddNodeDomain1770000000001,
|
||||
],
|
||||
synchronize: process.env.DB_SYNCHRONIZE !== 'false',
|
||||
migrationsRun: process.env.DB_MIGRATIONS_RUN === 'true',
|
||||
}),
|
||||
SessionModule,
|
||||
XuiModule,
|
||||
InboundsModule,
|
||||
RotationModule,
|
||||
@@ -44,15 +64,20 @@ import { Tunnel } from './tunnels/entities/tunnel.entity';
|
||||
SettingsModule,
|
||||
AuthModule,
|
||||
ClientModule,
|
||||
TunnelsModule
|
||||
TunnelsModule,
|
||||
NodesModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
AppService,
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ThrottlerGuard,
|
||||
},
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: JwtAuthGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule { }
|
||||
export class AppModule {}
|
||||
|
||||
@@ -1,19 +1,93 @@
|
||||
import { Controller, Post, Body } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Res,
|
||||
Logger,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { AuthService } from './auth.service';
|
||||
import { Public } from './public.decorator';
|
||||
import type { Request, Response } from 'express';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
interface LoginDto {
|
||||
login: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private authService: AuthService) {}
|
||||
private readonly logger = new Logger(AuthController.name);
|
||||
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Throttle({
|
||||
default: { limit: 5, ttl: 60000 },
|
||||
})
|
||||
@Post('login')
|
||||
async login(@Body() req) {
|
||||
async login(
|
||||
@Body() req: LoginDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
this.logger.debug(`Login request received for user: ${req.login}`);
|
||||
const user = await this.authService.validateUser(req.login, req.password);
|
||||
if (!user) {
|
||||
throw new Error('Invalid credentials');
|
||||
this.logger.warn(`Login failed for user: ${req.login}`);
|
||||
throw new HttpException(
|
||||
'Неверный логин или пароль',
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
);
|
||||
}
|
||||
return this.authService.login(user);
|
||||
const { access_token } = this.authService.login(user as { login: string });
|
||||
|
||||
// Устанавливаем httpOnly cookie
|
||||
const isProduction =
|
||||
this.configService.get<string>('NODE_ENV') === 'production';
|
||||
this.logger.debug(
|
||||
`Login succeeded for user: ${req.login}. Setting auth cookie (secure=${isProduction}, sameSite=lax, maxAgeMs=86400000)`,
|
||||
);
|
||||
res.cookie('access_token', access_token, {
|
||||
httpOnly: true,
|
||||
secure: isProduction, // HTTPS только в production
|
||||
sameSite: 'lax',
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 часа
|
||||
path: '/',
|
||||
});
|
||||
|
||||
this.logger.debug(`Login response prepared for user: ${req.login}`);
|
||||
return { access_token };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('logout')
|
||||
logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
const hadCookie = Boolean(
|
||||
(req.cookies as Record<string, unknown> | undefined)?.access_token,
|
||||
);
|
||||
this.logger.debug(`Logout request received. Cookie present: ${hadCookie}`);
|
||||
|
||||
// Очищаем httpOnly cookie
|
||||
const isProduction =
|
||||
this.configService.get<string>('NODE_ENV') === 'production';
|
||||
res.clearCookie('access_token', {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
});
|
||||
this.logger.debug(
|
||||
`Auth cookie cleared (secure=${isProduction}, sameSite=lax, path=/)`,
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('change-password')
|
||||
@@ -27,4 +101,4 @@ export class AuthController {
|
||||
await this.authService.updateAdminProfile(body.login, body.password);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,18 +6,23 @@ import { Setting } from '../settings/entities/setting.entity';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Setting]),
|
||||
PassportModule,
|
||||
JwtModule.register({
|
||||
secret: 'SECRET_KEY_CHANGE_ME',
|
||||
signOptions: { expiresIn: '24h' },
|
||||
ConfigModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: () => ({
|
||||
secret: process.env.JWT_SECRET || 'SECRET_KEY_CHANGE_ME',
|
||||
signOptions: { expiresIn: '24h' },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
controllers: [AuthController],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { Setting } from '../settings/entities/setting.entity';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -13,30 +14,34 @@ export class AuthService {
|
||||
@InjectRepository(Setting)
|
||||
private settingsRepo: Repository<Setting>,
|
||||
private jwtService: JwtService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async validateUser(login: string, pass: string): Promise<any> {
|
||||
this.logger.log(`Попытка входа с логином: ${login}`);
|
||||
async validateUser(
|
||||
login: string,
|
||||
pass: string,
|
||||
): Promise<{ login: string } | null> {
|
||||
this.logger.debug(`Попытка входа с логином: ${login}`);
|
||||
|
||||
const dbLogin = await this.settingsRepo.findOne({ where: { key: 'admin_login' } });
|
||||
const dbPass = await this.settingsRepo.findOne({ where: { key: 'admin_password' } });
|
||||
const dbLogin = await this.settingsRepo.findOne({
|
||||
where: { key: 'admin_login' },
|
||||
});
|
||||
const dbPass = await this.settingsRepo.findOne({
|
||||
where: { key: 'admin_password' },
|
||||
});
|
||||
|
||||
if (!dbLogin) {
|
||||
this.logger.error('Пользователь admin_login не найден в базе данных!');
|
||||
// Проверяем наличие учётных данных (без деталей для безопасности)
|
||||
if (!dbLogin || !dbPass) {
|
||||
this.logger.error('Учётные данные не найдены в базе данных');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!dbPass) {
|
||||
this.logger.error('Пароль admin_password не найден в базе данных!');
|
||||
return null;
|
||||
}
|
||||
this.logger.debug(`Пользователь найден, проверяем хеш пароля...`);
|
||||
|
||||
this.logger.log(`Пользователь найден, проверяем хеш пароля...`);
|
||||
|
||||
const isMatch = await bcrypt.compare(pass, dbPass.value);
|
||||
|
||||
|
||||
if (isMatch) {
|
||||
this.logger.log('Пароль верный!');
|
||||
this.logger.debug('Пароль верный!');
|
||||
return { login: dbLogin.value };
|
||||
} else {
|
||||
this.logger.warn('Пароль неверный.');
|
||||
@@ -44,8 +49,9 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
async login(user: any) {
|
||||
login(user: { login: string }) {
|
||||
const payload = { username: user.login };
|
||||
this.logger.debug(`Генерация access token для пользователя: ${user.login}`);
|
||||
return {
|
||||
access_token: this.jwtService.sign(payload),
|
||||
};
|
||||
@@ -53,50 +59,79 @@ export class AuthService {
|
||||
|
||||
async changePassword(newPass: string) {
|
||||
const hash = await bcrypt.hash(newPass, 10);
|
||||
let setting = await this.settingsRepo.findOne({ where: { key: 'admin_password' } });
|
||||
let setting = await this.settingsRepo.findOne({
|
||||
where: { key: 'admin_password' },
|
||||
});
|
||||
if (!setting) {
|
||||
setting = this.settingsRepo.create({ key: 'admin_password' });
|
||||
}
|
||||
setting.value = hash;
|
||||
await this.settingsRepo.save(setting);
|
||||
this.logger.log('Пароль администратора изменен.');
|
||||
this.logger.debug('Пароль администратора изменен.');
|
||||
}
|
||||
|
||||
async updateAdminProfile(login: string, password?: string) {
|
||||
let loginSetting = await this.settingsRepo.findOne({ where: { key: 'admin_login' } });
|
||||
if (!loginSetting) loginSetting = this.settingsRepo.create({ key: 'admin_login' });
|
||||
|
||||
let loginSetting = await this.settingsRepo.findOne({
|
||||
where: { key: 'admin_login' },
|
||||
});
|
||||
if (!loginSetting)
|
||||
loginSetting = this.settingsRepo.create({ key: 'admin_login' });
|
||||
|
||||
loginSetting.value = login;
|
||||
await this.settingsRepo.save(loginSetting);
|
||||
|
||||
if (password && password.trim().length > 0) {
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
let passSetting = await this.settingsRepo.findOne({ where: { key: 'admin_password' } });
|
||||
if (!passSetting) passSetting = this.settingsRepo.create({ key: 'admin_password' });
|
||||
|
||||
let passSetting = await this.settingsRepo.findOne({
|
||||
where: { key: 'admin_password' },
|
||||
});
|
||||
if (!passSetting)
|
||||
passSetting = this.settingsRepo.create({ key: 'admin_password' });
|
||||
|
||||
passSetting.value = hash;
|
||||
await this.settingsRepo.save(passSetting);
|
||||
}
|
||||
|
||||
this.logger.log(`Профиль администратора обновлен. Новый логин: ${login}`);
|
||||
|
||||
this.logger.debug(`Профиль администратора обновлен. Новый логин: ${login}`);
|
||||
}
|
||||
|
||||
async seedAdmin() {
|
||||
const login = await this.settingsRepo.findOne({ where: { key: 'admin_login' } });
|
||||
|
||||
const login = await this.settingsRepo.findOne({
|
||||
where: { key: 'admin_login' },
|
||||
});
|
||||
|
||||
if (!login) {
|
||||
this.logger.log('Инициализация администратора (admin / admin)...');
|
||||
|
||||
const loginSetting = this.settingsRepo.create({ key: 'admin_login', value: 'admin' });
|
||||
this.logger.debug('Инициализация администратора...');
|
||||
const envLogin = this.configService.get<string>('ADMIN_LOGIN');
|
||||
const envPass = this.configService.get<string>('ADMIN_PASSWORD');
|
||||
|
||||
// Проверяем, что переменные окружения установлены
|
||||
if (!envLogin || !envPass) {
|
||||
this.logger.error(
|
||||
'Критическая ошибка: ADMIN_LOGIN и ADMIN_PASSWORD должны быть установлены в переменных окружения',
|
||||
);
|
||||
this.logger.error(
|
||||
'Проверьте .env файл или docker-compose.yml на наличие этих переменных',
|
||||
);
|
||||
throw new Error('ADMIN_LOGIN и ADMIN_PASSWORD должны быть установлены');
|
||||
}
|
||||
|
||||
const loginSetting = this.settingsRepo.create({
|
||||
key: 'admin_login',
|
||||
value: envLogin,
|
||||
});
|
||||
await this.settingsRepo.save(loginSetting);
|
||||
|
||||
const hash = await bcrypt.hash('admin', 10);
|
||||
const passSetting = this.settingsRepo.create({ key: 'admin_password', value: hash });
|
||||
const hash = await bcrypt.hash(envPass, 10);
|
||||
const passSetting = this.settingsRepo.create({
|
||||
key: 'admin_password',
|
||||
value: hash,
|
||||
});
|
||||
await this.settingsRepo.save(passSetting);
|
||||
|
||||
this.logger.log('Администратор успешно создан.');
|
||||
|
||||
this.logger.debug('Администратор успешно создан.');
|
||||
} else {
|
||||
this.logger.log('Администратор уже существует в базе.');
|
||||
this.logger.debug('Администратор уже существует в базе.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,107 @@
|
||||
import { Injectable, ExecutionContext } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Request } from 'express';
|
||||
|
||||
type RequestWithCookies = Request & {
|
||||
cookies?: unknown;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
private readonly logger = new Logger(JwtAuthGuard.name);
|
||||
|
||||
constructor(private reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
const request = context.switchToHttp().getRequest<RequestWithCookies>();
|
||||
const cookies: Record<string, unknown> =
|
||||
request.cookies && typeof request.cookies === 'object'
|
||||
? (request.cookies as Record<string, unknown>)
|
||||
: {};
|
||||
this.logger.debug(
|
||||
`canActivate called for: ${request.url} ${request.method}`,
|
||||
);
|
||||
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
this.logger.debug(`isPublic: ${isPublic}`);
|
||||
|
||||
if (isPublic) {
|
||||
this.logger.debug(`Skipping public route`);
|
||||
return true;
|
||||
}
|
||||
return super.canActivate(context);
|
||||
|
||||
// Поддержка токена из cookie (httpOnly)
|
||||
const tokenFromCookieValue = cookies.access_token;
|
||||
const tokenFromCookie =
|
||||
typeof tokenFromCookieValue === 'string'
|
||||
? tokenFromCookieValue
|
||||
: undefined;
|
||||
if (tokenFromCookie && !request.headers.authorization) {
|
||||
this.logger.debug(
|
||||
`Token found in cookie, adding to Authorization header`,
|
||||
);
|
||||
request.headers.authorization = `Bearer ${tokenFromCookie}`;
|
||||
} else if (tokenFromCookie) {
|
||||
this.logger.debug(
|
||||
`Token found in cookie, but Authorization header already exists`,
|
||||
);
|
||||
}
|
||||
|
||||
// Support token from query parameter (for SSE connections)
|
||||
const tokenFromQuery = request.query.token as string | undefined;
|
||||
if (tokenFromQuery && !request.headers.authorization) {
|
||||
this.logger.debug(
|
||||
`Token found in query parameter, adding to Authorization header`,
|
||||
);
|
||||
request.headers.authorization = `Bearer ${tokenFromQuery}`;
|
||||
} else if (tokenFromQuery) {
|
||||
this.logger.debug(
|
||||
`Token found in query parameter, but Authorization header already exists`,
|
||||
);
|
||||
} else if (!request.headers.authorization) {
|
||||
this.logger.debug(
|
||||
`No token found in Authorization header, cookie, or query`,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.debug(`Calling super.canActivate()`);
|
||||
const result = super.canActivate(context);
|
||||
this.logger.debug(
|
||||
`canActivate result: ${typeof result === 'boolean' ? result : 'PENDING'}`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
handleRequest<TUser = unknown>(
|
||||
err: unknown,
|
||||
user: TUser,
|
||||
_info: unknown,
|
||||
_context?: unknown,
|
||||
_status?: unknown,
|
||||
): TUser {
|
||||
if (err || !user) {
|
||||
const errMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: typeof err === 'string'
|
||||
? err
|
||||
: err
|
||||
? JSON.stringify(err)
|
||||
: 'null';
|
||||
this.logger.warn(`handleRequest: ${errMessage || 'Unauthorized'}`);
|
||||
throw err || new UnauthorizedException();
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,35 @@
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { IncomingHttpHeaders } from 'http';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor() {
|
||||
constructor(private configService: ConfigService) {
|
||||
const secret =
|
||||
configService.get<string>('JWT_SECRET') || 'SECRET_KEY_CHANGE_ME';
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
jwtFromRequest: (req: { headers?: IncomingHttpHeaders }) => {
|
||||
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(req);
|
||||
return token;
|
||||
},
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: 'SECRET_KEY_CHANGE_ME',
|
||||
secretOrKey: secret,
|
||||
});
|
||||
const maskedSecret =
|
||||
secret.length > 8
|
||||
? `${secret.substring(0, 4)}${'*'.repeat(secret.length - 8)}${secret.substring(secret.length - 4)}`
|
||||
: '****';
|
||||
console.log(`[JwtStrategy] Initialized with secret: ${maskedSecret}`);
|
||||
}
|
||||
|
||||
async validate(payload: any) {
|
||||
validate(payload: { sub: string; username: string }) {
|
||||
const maskedUsername =
|
||||
payload.username.length > 6
|
||||
? `${payload.username.substring(0, 3)}***${payload.username.substring(payload.username.length - 2)}`
|
||||
: '***';
|
||||
console.log(`[JwtStrategy] Validating token for user: ${maskedUsername}`);
|
||||
return { userId: payload.sub, username: payload.username };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
export const Public = () => SetMetadata('isPublic', true);
|
||||
export const Public = () => SetMetadata('isPublic', true);
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import { Controller, Get, Param, HttpException, HttpStatus, Res, Req, Inject, Query } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Res,
|
||||
Req,
|
||||
Inject,
|
||||
Query,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import type { Response, Request } from 'express';
|
||||
@@ -8,36 +20,39 @@ import type { Cache } from 'cache-manager';
|
||||
import { Subscription } from '../subscriptions/entities/subscription.entity';
|
||||
import { Public } from '../auth/public.decorator';
|
||||
import { Tunnel } from 'src/tunnels/entities/tunnel.entity';
|
||||
import { generateSubscriptionHtmlWithQr } from './templates/subscription.template';
|
||||
|
||||
@Controller()
|
||||
export class ClientController {
|
||||
private readonly logger = new Logger(ClientController.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Subscription)
|
||||
private subRepo: Repository<Subscription>,
|
||||
@InjectRepository(Tunnel)
|
||||
private tunnelRepo: Repository<Tunnel>,
|
||||
@Inject(CACHE_MANAGER) private cacheManager: Cache
|
||||
) { }
|
||||
@Inject(CACHE_MANAGER) private cacheManager: Cache,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Throttle({ default: { limit: 300, ttl: 60000 } })
|
||||
@Get('bus/:uuid')
|
||||
async getSubscription(
|
||||
@Param('uuid') uuid: string,
|
||||
@Req() req: Request,
|
||||
@Res() res: Response
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const sub = await this.subRepo.findOne({
|
||||
where: { uuid },
|
||||
relations: ['inbounds']
|
||||
relations: ['inbounds'],
|
||||
});
|
||||
|
||||
if (!sub || !sub.isEnabled) {
|
||||
throw new HttpException('Subscription not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
const links = sub.inbounds
|
||||
?.map(i => i.link)
|
||||
.filter(l => l && l.length > 0) || [];
|
||||
const links =
|
||||
sub.inbounds?.map((i) => i.link).filter((l) => l && l.length > 0) || [];
|
||||
|
||||
const plainTextList = links.join('\n');
|
||||
const base64Config = Buffer.from(plainTextList).toString('base64');
|
||||
@@ -49,7 +64,6 @@ export class ClientController {
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.send(base64Config);
|
||||
} else {
|
||||
|
||||
const currentUrl = `${req.protocol}://${req.get('host')}/bus/${uuid}`;
|
||||
|
||||
const cacheKey = `qr_${uuid}`;
|
||||
@@ -57,73 +71,22 @@ export class ClientController {
|
||||
let qrDataUrl = await this.cacheManager.get<string>(cacheKey);
|
||||
|
||||
if (!qrDataUrl) {
|
||||
qrDataUrl = await QRCode.toDataURL(currentUrl, { width: 300, margin: 2 });
|
||||
qrDataUrl = await QRCode.toDataURL(currentUrl, {
|
||||
width: 300,
|
||||
margin: 2,
|
||||
});
|
||||
|
||||
await this.cacheManager.set(cacheKey, qrDataUrl, 86400000);
|
||||
} else {
|
||||
console.log(`Взяли QR из кэша для ${uuid}`);
|
||||
this.logger.debug(`QR loaded from cache for ${uuid}`);
|
||||
}
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${sub.name} | 3DP-MANAGER</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background-color: #f4f6f8; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
|
||||
.card { background: white; padding: 2rem; border-radius: 16px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); text-align: center; max-width: 400px; width: 90%; }
|
||||
h2 { margin-top: 0; color: #333; }
|
||||
.qr-box { background: #fff; padding: 10px; border: 1px solid #eee; border-radius: 8px; display: inline-block; margin: 20px 0; }
|
||||
.link-box { background: #f5f5f5; padding: 10px; border-radius: 6px; font-family: monospace; word-break: break-all; font-size: 12px; color: #666; margin-bottom: 20px; border: 1px solid #e0e0e0; }
|
||||
button { background-color: #1976d2; color: white; border: none; padding: 12px 24px; border-radius: 8px; font-size: 16px; cursor: pointer; transition: background 0.2s; width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px; }
|
||||
button:hover { background-color: #1565c0; }
|
||||
button:active { transform: scale(0.98); }
|
||||
.note { margin-top: 20px; font-size: 12px; color: #999; }
|
||||
|
||||
#subscription-links { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2>Ваша подписка</h2>
|
||||
<p style="color: #666;">Отсканируйте QR-код в приложении Happ, v2RayTun или Streisand</p>
|
||||
|
||||
<div class="qr-box">
|
||||
<img src="${qrDataUrl}" alt="QR Code" />
|
||||
</div>
|
||||
|
||||
<div class="link-box" id="link-text">${currentUrl}</div>
|
||||
|
||||
<button onclick="copyLink()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="white"><path d="M16 1H4C2.9 1 2 1.9 2 3V17H4V3H16V1ZM19 5H8C6.9 5 6 5.9 6 7V21C6 22.1 6.9 23 8 23H19C20.1 23 21 22.1 21 21V7C21 5.9 20.1 5 19 5ZM19 21H8V7H19V21Z"/></svg>
|
||||
Копировать ссылку
|
||||
</button>
|
||||
|
||||
<div class="note">Для автоматического обновления конфигов используйте эту ссылку</div>
|
||||
|
||||
</div>
|
||||
<textarea id="subscription-links">${base64Config}</textarea>
|
||||
|
||||
<script>
|
||||
function copyLink() {
|
||||
const link = document.getElementById('link-text').innerText;
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
const btn = document.querySelector('button');
|
||||
const originalText = btn.innerHTML;
|
||||
btn.innerHTML = 'Скопировано!';
|
||||
btn.style.backgroundColor = '#2e7d32';
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = originalText;
|
||||
btn.style.backgroundColor = '#1976d2';
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const html = generateSubscriptionHtmlWithQr(
|
||||
currentUrl,
|
||||
qrDataUrl,
|
||||
base64Config,
|
||||
sub.name,
|
||||
);
|
||||
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.send(html);
|
||||
@@ -131,13 +94,14 @@ export class ClientController {
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Throttle({ default: { limit: 300, ttl: 60000 } })
|
||||
@Get('bus/:uuid/:tunnelId')
|
||||
async getRelaySubscription(
|
||||
@Param('uuid') uuid: string,
|
||||
@Param('tunnelId') tunnelId: string,
|
||||
@Query('format') format: string,
|
||||
@Req() req: Request,
|
||||
@Res() res: Response
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const tunnel = await this.tunnelRepo.findOne({ where: { id: +tunnelId } });
|
||||
if (!tunnel) {
|
||||
@@ -148,18 +112,22 @@ export class ClientController {
|
||||
|
||||
const sub = await this.subRepo.findOne({
|
||||
where: { uuid },
|
||||
relations: ['inbounds']
|
||||
relations: ['inbounds'],
|
||||
});
|
||||
|
||||
if (!sub || !sub.isEnabled) {
|
||||
throw new HttpException('Subscription not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
let links = sub.inbounds
|
||||
?.map(i => i.link)
|
||||
.filter(l => l && l.length > 0) || [];
|
||||
|
||||
links = links.map(link => this.patchLink(link, relayHost));
|
||||
const links =
|
||||
sub.inbounds
|
||||
?.filter((i) => i.link && i.link.length > 0)
|
||||
.map((i) => {
|
||||
if (i.protocol === 'custom') {
|
||||
return i.link;
|
||||
}
|
||||
return this.patchLink(i.link, relayHost);
|
||||
}) || [];
|
||||
|
||||
const plainTextList = links.join('\n');
|
||||
const base64Config = Buffer.from(plainTextList).toString('base64');
|
||||
@@ -171,7 +139,6 @@ export class ClientController {
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.send(base64Config);
|
||||
} else {
|
||||
|
||||
const currentUrl = `${req.protocol}://${req.get('host')}/bus/${uuid}/${tunnelId}`;
|
||||
|
||||
const cacheKey = `qr_${uuid}_${relayHost || 'direct'}`;
|
||||
@@ -179,73 +146,22 @@ export class ClientController {
|
||||
let qrDataUrl = await this.cacheManager.get<string>(cacheKey);
|
||||
|
||||
if (!qrDataUrl) {
|
||||
qrDataUrl = await QRCode.toDataURL(currentUrl, { width: 300, margin: 2 });
|
||||
qrDataUrl = await QRCode.toDataURL(currentUrl, {
|
||||
width: 300,
|
||||
margin: 2,
|
||||
});
|
||||
|
||||
await this.cacheManager.set(cacheKey, qrDataUrl, 86400000);
|
||||
} else {
|
||||
console.log(`Взяли QR из кэша для ${uuid}`);
|
||||
this.logger.debug(`QR loaded from cache for ${uuid}`);
|
||||
}
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${sub.name} | 3DP-MANAGER</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background-color: #f4f6f8; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
|
||||
.card { background: white; padding: 2rem; border-radius: 16px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); text-align: center; max-width: 400px; width: 90%; }
|
||||
h2 { margin-top: 0; color: #333; }
|
||||
.qr-box { background: #fff; padding: 10px; border: 1px solid #eee; border-radius: 8px; display: inline-block; margin: 20px 0; }
|
||||
.link-box { background: #f5f5f5; padding: 10px; border-radius: 6px; font-family: monospace; word-break: break-all; font-size: 12px; color: #666; margin-bottom: 20px; border: 1px solid #e0e0e0; }
|
||||
button { background-color: #1976d2; color: white; border: none; padding: 12px 24px; border-radius: 8px; font-size: 16px; cursor: pointer; transition: background 0.2s; width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px; }
|
||||
button:hover { background-color: #1565c0; }
|
||||
button:active { transform: scale(0.98); }
|
||||
.note { margin-top: 20px; font-size: 12px; color: #999; }
|
||||
|
||||
#subscription-links { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2>Ваша подписка</h2>
|
||||
<p style="color: #666;">Отсканируйте QR-код в приложении Happ, v2RayTun или Streisand</p>
|
||||
|
||||
<div class="qr-box">
|
||||
<img src="${qrDataUrl}" alt="QR Code" />
|
||||
</div>
|
||||
|
||||
<div class="link-box" id="link-text">${currentUrl}</div>
|
||||
|
||||
<button onclick="copyLink()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="white"><path d="M16 1H4C2.9 1 2 1.9 2 3V17H4V3H16V1ZM19 5H8C6.9 5 6 5.9 6 7V21C6 22.1 6.9 23 8 23H19C20.1 23 21 22.1 21 21V7C21 5.9 20.1 5 19 5ZM19 21H8V7H19V21Z"/></svg>
|
||||
Копировать ссылку
|
||||
</button>
|
||||
|
||||
<div class="note">Для автоматического обновления конфигов используйте эту ссылку</div>
|
||||
|
||||
</div>
|
||||
<textarea id="subscription-links">${base64Config}</textarea>
|
||||
|
||||
<script>
|
||||
function copyLink() {
|
||||
const link = document.getElementById('link-text').innerText;
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
const btn = document.querySelector('button');
|
||||
const originalText = btn.innerHTML;
|
||||
btn.innerHTML = 'Скопировано!';
|
||||
btn.style.backgroundColor = '#2e7d32';
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = originalText;
|
||||
btn.style.backgroundColor = '#1976d2';
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const html = generateSubscriptionHtmlWithQr(
|
||||
currentUrl,
|
||||
qrDataUrl,
|
||||
base64Config,
|
||||
sub.name,
|
||||
);
|
||||
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.send(html);
|
||||
@@ -257,17 +173,21 @@ export class ClientController {
|
||||
try {
|
||||
const base64Part = link.substring(8);
|
||||
const jsonStr = Buffer.from(base64Part, 'base64').toString('utf-8');
|
||||
const config = JSON.parse(jsonStr);
|
||||
const config = JSON.parse(jsonStr) as { add: string };
|
||||
|
||||
config.add = newHost;
|
||||
|
||||
const newJsonStr = JSON.stringify(config);
|
||||
const newBase64 = Buffer.from(newJsonStr).toString('base64');
|
||||
return `vmess://${newBase64}`;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return link;
|
||||
}
|
||||
} else if (link.startsWith('vless://') || link.startsWith('trojan://')) {
|
||||
} else if (
|
||||
link.startsWith('vless://') ||
|
||||
link.startsWith('trojan://') ||
|
||||
link.startsWith('hy2://')
|
||||
) {
|
||||
return link.replace(/@.*?:/, `@${newHost}:`);
|
||||
} else if (link.startsWith('ss://')) {
|
||||
if (link.includes('@')) {
|
||||
@@ -278,4 +198,4 @@ export class ClientController {
|
||||
|
||||
return link;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
} from '@nestjs/common';
|
||||
import { Response, Request } from 'express';
|
||||
import { generateErrorHtml } from '../client/templates/subscription.template';
|
||||
|
||||
@Catch(HttpException)
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: HttpException, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
const status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
const message =
|
||||
typeof exceptionResponse === 'string'
|
||||
? exceptionResponse
|
||||
: (exceptionResponse as { message?: string | string[] })?.message;
|
||||
|
||||
const errorMessage = Array.isArray(message) ? message[0] : message;
|
||||
|
||||
// Проверяем, что это браузер (не API запрос)
|
||||
const userAgent = request.headers['user-agent'] || '';
|
||||
const isBrowser = /Mozilla|Chrome|Safari|Firefox|Edge/.test(userAgent);
|
||||
|
||||
// Для endpoint подписки /bus/* возвращаем HTML
|
||||
if (isBrowser && request.url.includes('/bus/')) {
|
||||
const html = generateErrorHtml('Подписка не найдена', errorMessage);
|
||||
response.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
response.status(status).send(html);
|
||||
} else {
|
||||
// Для API запросов возвращаем JSON
|
||||
response.status(status).json({
|
||||
statusCode: status,
|
||||
message: errorMessage,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user