Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ef22f94c9 | |||
| ec9fbae645 | |||
| e26cf1d3ed | |||
| 01ce2bcecb | |||
| c9e62451e6 | |||
| 5008906c4c | |||
| 5fe4f241c1 | |||
| e8bab17c2f | |||
| 14b92fbcff | |||
| 1d85ef138e | |||
| 3fa44915c1 | |||
| 7fc86f87de | |||
| 3c1498d806 | |||
| d1c4e0261b | |||
| bc49c1a68f | |||
| 3c8cf35734 | |||
| dea7cd9cc1 | |||
| 56bb876d8d | |||
| eb11e8c85a | |||
| a84bbeab2e | |||
| ea66aa4971 | |||
| cfa8350d10 | |||
| af466b6a24 | |||
| 789a03065a | |||
| bc424f0968 | |||
| ac3fc12077 | |||
| d9c7c76fb0 | |||
| d440c2b932 | |||
| e790f46757 | |||
| d089adeeea | |||
| 4a8fdceed6 | |||
| 574caa63e9 | |||
| baef3cdd07 | |||
| 43e64993fc | |||
| d52b598abf | |||
| 2d8d304850 | |||
| a036ddd66f | |||
| 78ab7a9246 | |||
| a810f497e6 |
@@ -3,6 +3,6 @@ import { llms } from 'fumadocs-core/source';
|
|||||||
|
|
||||||
export const revalidate = false;
|
export const revalidate = false;
|
||||||
|
|
||||||
export function GET() {
|
export async function GET() {
|
||||||
return new Response(llms(source).index());
|
return new Response(await llms(source).index());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ inbounds** at once, with per-client traffic accounting.
|
|||||||
| **Comment** | all | Free-text note. |
|
| **Comment** | all | Free-text note. |
|
||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
Reaching the **traffic** or **expiry** limit disables the client; the panel can
|
Reaching the **traffic** or **expiry** limit disables the client, and a client
|
||||||
restart Xray automatically when clients are auto-disabled
|
disabled or deleted by hand counts too; the panel restarts Xray then
|
||||||
(`restartXrayOnClientDisable`, on by default).
|
(`restartXrayOnClientDisable`, on by default).
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,25 @@ Subscriptions return standard headers that compatible apps read:
|
|||||||
- **`Profile-Title`**, **`Support-Url`**, **`Profile-Web-Page-Url`**,
|
- **`Profile-Title`**, **`Support-Url`**, **`Profile-Web-Page-Url`**,
|
||||||
**`Announce`** — optional branding shown by some clients.
|
**`Announce`** — optional branding shown by some clients.
|
||||||
|
|
||||||
|
### Profile page links and upgrades
|
||||||
|
|
||||||
|
In **Subscription → Profile → Profile page**, choose `subProfileMode` for all
|
||||||
|
subscription clients:
|
||||||
|
|
||||||
|
- **No link** (`none`, default): omit `Profile-Web-Page-Url`.
|
||||||
|
- **Built-in subscription page** (`builtin`): link to the client's built-in page.
|
||||||
|
- **Custom website** (`custom`): use `subProfileUrl`; a blank URL omits the header.
|
||||||
|
|
||||||
|
**Upgrade note:** previously, an empty `subProfileUrl` automatically linked to
|
||||||
|
the built-in page. After upgrading, an unset mode with an empty or whitespace-only
|
||||||
|
URL becomes **No link**; an existing nonempty URL remains a **Custom website**.
|
||||||
|
To restore the built-in link, select **Built-in subscription page** above and
|
||||||
|
save the settings.
|
||||||
|
|
||||||
|
The built-in page exposes subscription URLs and node configurations, including
|
||||||
|
for Happ encrypted subscriptions. Enable it only if you intend to provide that
|
||||||
|
access.
|
||||||
|
|
||||||
### Optional month-end expiry display
|
### Optional month-end expiry display
|
||||||
|
|
||||||
Under **Subscription → Information**, **Month-end subscription expiry display**
|
Under **Subscription → Information**, **Month-end subscription expiry display**
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"title": "Reference",
|
"title": "Reference",
|
||||||
"icon": "BookMarked",
|
"icon": "BookBookmark",
|
||||||
"pages": ["env-vars", "database", "ports-firewall", "api"]
|
"pages": ["env-vars", "database", "ports-firewall", "api"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ icon: Users
|
|||||||
| **Comment** | همه | یادداشت متنی آزاد. |
|
| **Comment** | همه | یادداشت متنی آزاد. |
|
||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
رسیدن به محدودیت **ترافیک** یا **انقضا** کلاینت را غیرفعال میکند؛ پنل میتواند
|
رسیدن به محدودیت **ترافیک** یا **انقضا** کلاینت را غیرفعال میکند؛ غیرفعالسازی یا
|
||||||
هنگام غیرفعالشدن خودکار کلاینتها، Xray را بهصورت خودکار راهاندازی مجدد کند
|
حذف دستی کلاینت هم همین اثر را دارد؛ در این حالت پنل Xray را راهاندازی مجدد میکند
|
||||||
(`restartXrayOnClientDisable`، بهصورت پیشفرض فعال).
|
(`restartXrayOnClientDisable`، بهصورت پیشفرض فعال).
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,23 @@ SOCKS/HTTP روی 127.0.0.1، DNS، مسیریابی، policy) بهعلاوه
|
|||||||
- **`Profile-Title`**، **`Support-Url`**، **`Profile-Web-Page-Url`**،
|
- **`Profile-Title`**، **`Support-Url`**، **`Profile-Web-Page-Url`**،
|
||||||
**`Announce`** — برندینگ اختیاری که برخی کلاینتها نمایش میدهند.
|
**`Announce`** — برندینگ اختیاری که برخی کلاینتها نمایش میدهند.
|
||||||
|
|
||||||
|
### لینک صفحه پروفایل
|
||||||
|
|
||||||
|
در تنظیمات **سابسکریپشن ← پروفایل**، گزینه **صفحه پروفایل** (`subProfileMode`)
|
||||||
|
لینک را برای همه کلاینتهای اشتراک کنترل میکند:
|
||||||
|
|
||||||
|
- **بدون لینک** (`none`، پیشفرض) — هدر `Profile-Web-Page-Url` ارسال نمیشود.
|
||||||
|
- **صفحه اشتراک داخلی** (`builtin`) — لینک صفحه اشتراک داخلی ارائه میشود.
|
||||||
|
- **وبسایت سفارشی** (`custom`) — آدرس `subProfileUrl` استفاده میشود؛ اگر خالی باشد، هدر ارسال نمیشود.
|
||||||
|
|
||||||
|
**پس از ارتقا:** اگر `subProfileMode` هنوز تنظیم نشده و مقدار قبلی `subProfileUrl`
|
||||||
|
خالی یا فقط شامل فاصله باشد، بهجای لینک خودکار صفحه داخلی، حالت **بدون لینک**
|
||||||
|
انتخاب میشود. آدرس سفارشی غیرخالی قبلی در حالت **وبسایت سفارشی** حفظ میشود.
|
||||||
|
|
||||||
|
برای بازگرداندن لینک قبلی، در همین بخش **صفحه اشتراک داخلی** را انتخاب و تنظیمات
|
||||||
|
را ذخیره کنید. این صفحه آدرسهای اشتراک و پیکربندی گرهها را آشکار میکند، حتی
|
||||||
|
برای اشتراکهای رمزگذاریشده Happ.
|
||||||
|
|
||||||
## قالبهای سفارشی صفحه
|
## قالبهای سفارشی صفحه
|
||||||
|
|
||||||
برای برندینگ صفحهی HTML اشتراک، `subThemeDir` را به یک پوشهی حاوی قالب سفارشیِ
|
برای برندینگ صفحهی HTML اشتراک، `subThemeDir` را به یک پوشهی حاوی قالب سفارشیِ
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"title": "مرجع",
|
"title": "مرجع",
|
||||||
"icon": "BookMarked",
|
"icon": "BookBookmark",
|
||||||
"pages": ["env-vars", "database", "ports-firewall", "api"]
|
"pages": ["env-vars", "database", "ports-firewall", "api"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ icon: Users
|
|||||||
| **Comment** | все | Произвольная текстовая заметка. |
|
| **Comment** | все | Произвольная текстовая заметка. |
|
||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
Достижение лимита **трафика** или **срока действия** отключает клиента; при
|
Достижение лимита **трафика** или **срока действия** отключает клиента, как и
|
||||||
автоматическом отключении клиентов панель может автоматически перезапускать
|
ручное отключение или удаление; тогда панель перезапускает Xray
|
||||||
Xray (`restartXrayOnClientDisable`, включено по умолчанию).
|
(`restartXrayOnClientDisable`, включено по умолчанию).
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
## Лимиты и контроль IP
|
## Лимиты и контроль IP
|
||||||
|
|||||||
@@ -76,6 +76,24 @@ policy) плюс исходящее соединение `proxy`, указыва
|
|||||||
- **`Profile-Title`**, **`Support-Url`**, **`Profile-Web-Page-Url`**,
|
- **`Profile-Title`**, **`Support-Url`**, **`Profile-Web-Page-Url`**,
|
||||||
**`Announce`** — необязательный брендинг, отображаемый некоторыми клиентами.
|
**`Announce`** — необязательный брендинг, отображаемый некоторыми клиентами.
|
||||||
|
|
||||||
|
### Ссылка на страницу профиля
|
||||||
|
|
||||||
|
В настройках **Подписка → Профиль** поле **Страница профиля** (`subProfileMode`)
|
||||||
|
управляет ссылкой для всех клиентов подписки:
|
||||||
|
|
||||||
|
- **Без ссылки** (`none`, по умолчанию) — заголовок `Profile-Web-Page-Url` не отправляется.
|
||||||
|
- **Встроенная страница подписки** (`builtin`) — ссылка на встроенную страницу подписки.
|
||||||
|
- **Свой сайт** (`custom`) — адрес из `subProfileUrl`; если он пуст, заголовок не отправляется.
|
||||||
|
|
||||||
|
**После обновления:** если `subProfileMode` ещё не задан, а прежний `subProfileUrl`
|
||||||
|
пуст или содержит только пробелы, вместо автоматической ссылки на встроенную
|
||||||
|
страницу теперь используется **Без ссылки**. Существующий непустой пользовательский
|
||||||
|
адрес сохраняется в режиме **Свой сайт**.
|
||||||
|
|
||||||
|
Чтобы вернуть прежнюю ссылку, выберите **Встроенная страница подписки** в этом поле
|
||||||
|
и сохраните настройки. Эта страница раскрывает URL-адреса подписок и конфигурации
|
||||||
|
узлов, в том числе для зашифрованных подписок Happ.
|
||||||
|
|
||||||
## Пользовательские шаблоны страниц
|
## Пользовательские шаблоны страниц
|
||||||
|
|
||||||
Укажите в `subThemeDir` папку с пользовательским шаблоном информационной
|
Укажите в `subThemeDir` папку с пользовательским шаблоном информационной
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"title": "Справочник",
|
"title": "Справочник",
|
||||||
"icon": "BookMarked",
|
"icon": "BookBookmark",
|
||||||
"pages": ["env-vars", "database", "ports-firewall", "api"]
|
"pages": ["env-vars", "database", "ports-firewall", "api"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ icon: Users
|
|||||||
| **Comment** | 全部 | 自由文本备注。 |
|
| **Comment** | 全部 | 自由文本备注。 |
|
||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
达到**流量**或**到期**限制会禁用客户端;当客户端被自动禁用时,面板可以
|
达到**流量**或**到期**限制会禁用客户端,手动禁用或删除客户端同样如此;
|
||||||
自动重启 Xray(`restartXrayOnClientDisable`,默认开启)。
|
此时面板会重启 Xray(`restartXrayOnClientDisable`,默认开启)。
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
## 限制与 IP 控制
|
## 限制与 IP 控制
|
||||||
|
|||||||
@@ -73,6 +73,18 @@ Clash 格式自动识别保留原有的 `(?i)(clash|mihomo)` 默认匹配器,
|
|||||||
- **`Profile-Update-Interval`** —— 刷新间隔,以小时为单位(`subUpdates`)。
|
- **`Profile-Update-Interval`** —— 刷新间隔,以小时为单位(`subUpdates`)。
|
||||||
- **`Profile-Title`**、**`Support-Url`**、**`Profile-Web-Page-Url`**、**`Announce`** —— 部分客户端会显示的可选品牌信息。
|
- **`Profile-Title`**、**`Support-Url`**、**`Profile-Web-Page-Url`**、**`Announce`** —— 部分客户端会显示的可选品牌信息。
|
||||||
|
|
||||||
|
### 资料页链接与升级说明
|
||||||
|
|
||||||
|
在 **订阅 → 资料 → 资料页方式** 中选择 `subProfileMode`,对所有订阅客户端生效:
|
||||||
|
|
||||||
|
- **不提供**(`none`,默认):不发送 `Profile-Web-Page-Url`。
|
||||||
|
- **内置订阅页**(`builtin`):提供该客户端的内置订阅页链接。
|
||||||
|
- **自定义网站**(`custom`):使用 `subProfileUrl`;地址留空时不发送该响应头。
|
||||||
|
|
||||||
|
**升级提示:** 旧版在 `subProfileUrl` 留空时会自动提供内置订阅页链接。升级后,尚未设置模式且地址为空或仅含空白字符的配置会使用 **不提供**;已有非空地址继续使用 **自定义网站**。需要恢复内置入口时,在上述位置选择 **内置订阅页** 并保存设置。
|
||||||
|
|
||||||
|
内置订阅页会公开订阅地址和节点配置,Happ 加密订阅也不例外;请在确定需要提供这些内容时开启。
|
||||||
|
|
||||||
## 自定义页面模板
|
## 自定义页面模板
|
||||||
|
|
||||||
将 `subThemeDir` 指向一个包含自定义信息页模板的文件夹,即可为 HTML 订阅页面定制品牌。每条链接上的客户端备注完全支持模板化 —— 参见[分享链接 → 备注变量](/docs/config/share-links#remark-template-variables)。
|
将 `subThemeDir` 指向一个包含自定义信息页模板的文件夹,即可为 HTML 订阅页面定制品牌。每条链接上的客户端备注完全支持模板化 —— 参见[分享链接 → 备注变量](/docs/config/share-links#remark-template-variables)。
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"title": "参考",
|
"title": "参考",
|
||||||
"icon": "BookMarked",
|
"icon": "BookBookmark",
|
||||||
"pages": ["env-vars", "database", "ports-firewall", "api"]
|
"pages": ["env-vars", "database", "ports-firewall", "api"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,34 +18,34 @@
|
|||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fumadocs-core": "^16.15.5",
|
"fumadocs-core": "^16.15.11",
|
||||||
"fumadocs-docgen": "^3.1.0",
|
"fumadocs-docgen": "^3.1.1",
|
||||||
"fumadocs-mdx": "^15.4.0",
|
"fumadocs-mdx": "^15.4.1",
|
||||||
"fumadocs-openapi": "^11.4.0",
|
"fumadocs-openapi": "^11.4.3",
|
||||||
"fumadocs-ui": "^16.15.5",
|
"fumadocs-ui": "^16.15.11",
|
||||||
"lucide-react": "^1.39.0",
|
"lucide-react": "^1.46.0",
|
||||||
"mermaid": "^11.17.2",
|
"mermaid": "^12.0.0",
|
||||||
"next": "16.3.4",
|
"next": "16.3.5",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.8",
|
"react": "^19.3.0",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.3.0",
|
||||||
"react-qr-code": "^2.2.0",
|
"react-qr-code": "^2.2.0",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.7.0",
|
||||||
"zbsearch": "4.0.0",
|
"zbsearch": "4.0.0",
|
||||||
"zod": "^4.5.4"
|
"zod": "^4.6.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@types/mdx": "^2.0.14",
|
"@types/mdx": "^2.0.14",
|
||||||
"@types/node": "^26.4.1",
|
"@types/node": "^26.5.1",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.2.5",
|
"@types/react-dom": "^19.3.0",
|
||||||
"oxfmt": "0.66.0",
|
"oxfmt": "0.68.0",
|
||||||
"oxlint": "1.81.0",
|
"oxlint": "1.83.0",
|
||||||
"postcss": "^8.5.26",
|
"postcss": "^8.5.28",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
"vitest": "^4.1.11"
|
"vitest": "^5.0.1"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.25.0"
|
"packageManager": "pnpm@12.4.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,16 @@ minimumReleaseAgeExclude:
|
|||||||
- mermaid@11.17.0
|
- mermaid@11.17.0
|
||||||
- lucide-react@1.33.0
|
- lucide-react@1.33.0
|
||||||
- postcss@8.5.26
|
- postcss@8.5.26
|
||||||
- fumadocs-mdx@15.3.0
|
- fumadocs-mdx@15.3.0 || 15.4.1
|
||||||
- '@fumadocs/api-docs@0.2.7'
|
- '@fumadocs/api-docs@0.2.7 || 0.2.9'
|
||||||
- '@types/node@26.4.1'
|
- '@types/node@26.4.1'
|
||||||
- fumadocs-core@16.15.5
|
- fumadocs-core@16.15.5 || 16.15.11
|
||||||
- fumadocs-openapi@11.4.0
|
- fumadocs-openapi@11.4.0 || 11.4.3
|
||||||
- fumadocs-ui@16.15.5
|
- fumadocs-ui@16.15.5 || 16.15.11
|
||||||
|
- '@fumadocs/tailwind@0.1.2'
|
||||||
|
- '@fumari/image-size@0.1.1'
|
||||||
|
- '@fumari/stf@1.1.1'
|
||||||
|
- '@vitest/mocker@5.0.1'
|
||||||
|
- '@vitest/spy@5.0.1'
|
||||||
|
- fumadocs-docgen@3.1.1
|
||||||
|
- vitest@5.0.1
|
||||||
|
|||||||
@@ -395,6 +395,9 @@
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"subProfileMode": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subProfileUrl": {
|
"subProfileUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -618,6 +621,7 @@
|
|||||||
"subListen",
|
"subListen",
|
||||||
"subPath",
|
"subPath",
|
||||||
"subPort",
|
"subPort",
|
||||||
|
"subProfileMode",
|
||||||
"subProfileUrl",
|
"subProfileUrl",
|
||||||
"subRoutingRules",
|
"subRoutingRules",
|
||||||
"subShowIdentityOnAllLinks",
|
"subShowIdentityOnAllLinks",
|
||||||
@@ -1046,6 +1050,9 @@
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"subProfileMode": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subProfileUrl": {
|
"subProfileUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1277,6 +1284,7 @@
|
|||||||
"subListen",
|
"subListen",
|
||||||
"subPath",
|
"subPath",
|
||||||
"subPort",
|
"subPort",
|
||||||
|
"subProfileMode",
|
||||||
"subProfileUrl",
|
"subProfileUrl",
|
||||||
"subRoutingRules",
|
"subRoutingRules",
|
||||||
"subShowIdentityOnAllLinks",
|
"subShowIdentityOnAllLinks",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const withTheme: Decorator = (Story, context) => {
|
|||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
document.body.classList.remove('dark', 'light');
|
document.body.classList.remove('dark', 'light');
|
||||||
document.body.classList.add(dark ? 'dark' : 'light');
|
document.body.classList.add(dark ? 'dark' : 'light');
|
||||||
|
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
|
||||||
document.documentElement.removeAttribute('data-theme');
|
document.documentElement.removeAttribute('data-theme');
|
||||||
}, [dark]);
|
}, [dark]);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -395,6 +395,9 @@
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"subProfileMode": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subProfileUrl": {
|
"subProfileUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -618,6 +621,7 @@
|
|||||||
"subListen",
|
"subListen",
|
||||||
"subPath",
|
"subPath",
|
||||||
"subPort",
|
"subPort",
|
||||||
|
"subProfileMode",
|
||||||
"subProfileUrl",
|
"subProfileUrl",
|
||||||
"subRoutingRules",
|
"subRoutingRules",
|
||||||
"subShowIdentityOnAllLinks",
|
"subShowIdentityOnAllLinks",
|
||||||
@@ -1046,6 +1050,9 @@
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"subProfileMode": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subProfileUrl": {
|
"subProfileUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1277,6 +1284,7 @@
|
|||||||
"subListen",
|
"subListen",
|
||||||
"subPath",
|
"subPath",
|
||||||
"subPort",
|
"subPort",
|
||||||
|
"subProfileMode",
|
||||||
"subProfileUrl",
|
"subProfileUrl",
|
||||||
"subRoutingRules",
|
"subRoutingRules",
|
||||||
"subShowIdentityOnAllLinks",
|
"subShowIdentityOnAllLinks",
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ interface SubPageData {
|
|||||||
subJsonUrl?: string;
|
subJsonUrl?: string;
|
||||||
subClashUrl?: string;
|
subClashUrl?: string;
|
||||||
subTitle?: string;
|
subTitle?: string;
|
||||||
|
subSupportUrl?: string;
|
||||||
|
subUpdates?: number;
|
||||||
links?: string[];
|
links?: string[];
|
||||||
emails?: string[];
|
emails?: string[];
|
||||||
datepicker?: 'gregorian' | 'jalalian';
|
datepicker?: 'gregorian' | 'jalalian';
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"subListen": "",
|
"subListen": "",
|
||||||
"subPath": "",
|
"subPath": "",
|
||||||
"subPort": 1,
|
"subPort": 1,
|
||||||
|
"subProfileMode": "",
|
||||||
"subProfileUrl": "",
|
"subProfileUrl": "",
|
||||||
"subRoutingRules": "",
|
"subRoutingRules": "",
|
||||||
"subShowIdentityOnAllLinks": false,
|
"subShowIdentityOnAllLinks": false,
|
||||||
@@ -271,6 +272,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"subListen": "",
|
"subListen": "",
|
||||||
"subPath": "",
|
"subPath": "",
|
||||||
"subPort": 1,
|
"subPort": 1,
|
||||||
|
"subProfileMode": "",
|
||||||
"subProfileUrl": "",
|
"subProfileUrl": "",
|
||||||
"subRoutingRules": "",
|
"subRoutingRules": "",
|
||||||
"subShowIdentityOnAllLinks": false,
|
"subShowIdentityOnAllLinks": false,
|
||||||
|
|||||||
@@ -369,6 +369,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"subProfileMode": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subProfileUrl": {
|
"subProfileUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -592,6 +595,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subListen",
|
"subListen",
|
||||||
"subPath",
|
"subPath",
|
||||||
"subPort",
|
"subPort",
|
||||||
|
"subProfileMode",
|
||||||
"subProfileUrl",
|
"subProfileUrl",
|
||||||
"subRoutingRules",
|
"subRoutingRules",
|
||||||
"subShowIdentityOnAllLinks",
|
"subShowIdentityOnAllLinks",
|
||||||
@@ -1020,6 +1024,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"subProfileMode": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subProfileUrl": {
|
"subProfileUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1251,6 +1258,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subListen",
|
"subListen",
|
||||||
"subPath",
|
"subPath",
|
||||||
"subPort",
|
"subPort",
|
||||||
|
"subProfileMode",
|
||||||
"subProfileUrl",
|
"subProfileUrl",
|
||||||
"subRoutingRules",
|
"subRoutingRules",
|
||||||
"subShowIdentityOnAllLinks",
|
"subShowIdentityOnAllLinks",
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ export interface AllSetting {
|
|||||||
subListen: string;
|
subListen: string;
|
||||||
subPath: string;
|
subPath: string;
|
||||||
subPort: number;
|
subPort: number;
|
||||||
|
subProfileMode: string;
|
||||||
subProfileUrl: string;
|
subProfileUrl: string;
|
||||||
subRoutingRules: string;
|
subRoutingRules: string;
|
||||||
subShowIdentityOnAllLinks: boolean;
|
subShowIdentityOnAllLinks: boolean;
|
||||||
@@ -279,6 +280,7 @@ export interface AllSettingView {
|
|||||||
subListen: string;
|
subListen: string;
|
||||||
subPath: string;
|
subPath: string;
|
||||||
subPort: number;
|
subPort: number;
|
||||||
|
subProfileMode: string;
|
||||||
subProfileUrl: string;
|
subProfileUrl: string;
|
||||||
subRoutingRules: string;
|
subRoutingRules: string;
|
||||||
subShowIdentityOnAllLinks: boolean;
|
subShowIdentityOnAllLinks: boolean;
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ export const AllSettingSchema = z.object({
|
|||||||
subListen: z.string(),
|
subListen: z.string(),
|
||||||
subPath: z.string(),
|
subPath: z.string(),
|
||||||
subPort: z.number().int().min(1).max(65535),
|
subPort: z.number().int().min(1).max(65535),
|
||||||
|
subProfileMode: z.string(),
|
||||||
subProfileUrl: z.string(),
|
subProfileUrl: z.string(),
|
||||||
subRoutingRules: z.string(),
|
subRoutingRules: z.string(),
|
||||||
subShowIdentityOnAllLinks: z.boolean(),
|
subShowIdentityOnAllLinks: z.boolean(),
|
||||||
@@ -294,6 +295,7 @@ export const AllSettingViewSchema = z.object({
|
|||||||
subListen: z.string(),
|
subListen: z.string(),
|
||||||
subPath: z.string(),
|
subPath: z.string(),
|
||||||
subPort: z.number().int().min(1).max(65535),
|
subPort: z.number().int().min(1).max(65535),
|
||||||
|
subProfileMode: z.string(),
|
||||||
subProfileUrl: z.string(),
|
subProfileUrl: z.string(),
|
||||||
subRoutingRules: z.string(),
|
subRoutingRules: z.string(),
|
||||||
subShowIdentityOnAllLinks: z.boolean(),
|
subShowIdentityOnAllLinks: z.boolean(),
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ function readBool(key: string, fallback: boolean): boolean {
|
|||||||
function applyDom(isDark: boolean, isUltra: boolean) {
|
function applyDom(isDark: boolean, isUltra: boolean) {
|
||||||
document.body.classList.remove('dark', 'light');
|
document.body.classList.remove('dark', 'light');
|
||||||
document.body.classList.add(isDark ? 'dark' : 'light');
|
document.body.classList.add(isDark ? 'dark' : 'light');
|
||||||
|
// Native scrollbars read color-scheme, not the body class.
|
||||||
|
document.documentElement.style.colorScheme = isDark ? 'dark' : 'light';
|
||||||
if (isUltra) {
|
if (isUltra) {
|
||||||
document.documentElement.setAttribute('data-theme', 'ultra-dark');
|
document.documentElement.setAttribute('data-theme', 'ultra-dark');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export function formatPanelVersion(version: string | undefined | null): string {
|
|||||||
|
|
||||||
export function isPanelUpdateAvailable(latest: string, current: string): boolean {
|
export function isPanelUpdateAvailable(latest: string, current: string): boolean {
|
||||||
if (!latest || !current) return false;
|
if (!latest || !current) return false;
|
||||||
|
// A dev+<sha> label and a release tag sit on different channels and carry no
|
||||||
|
// order, so a node moved to the other channel is not "behind" the master's latest.
|
||||||
|
if (latest.trim().startsWith('dev+') !== current.trim().startsWith('dev+')) return false;
|
||||||
const a = parseVersionParts(latest);
|
const a = parseVersionParts(latest);
|
||||||
const b = parseVersionParts(current);
|
const b = parseVersionParts(current);
|
||||||
if (!a || !b) {
|
if (!a || !b) {
|
||||||
|
|||||||
@@ -786,7 +786,8 @@ function dnsRuleToWire(r: DnsRuleForm) {
|
|||||||
const result: Raw = { action };
|
const result: Raw = { action };
|
||||||
const qType = r.qType.trim();
|
const qType = r.qType.trim();
|
||||||
if (qType) {
|
if (qType) {
|
||||||
result.qType = /^\d+$/.test(qType) ? Number(qType) : qType;
|
// The core reads a numeric 0 as no qType at all, which matches every query.
|
||||||
|
result.qType = /^\d+$/.test(qType) && Number(qType) > 0 ? Number(qType) : qType;
|
||||||
}
|
}
|
||||||
const domains = r.domain
|
const domains = r.domain
|
||||||
.split(',')
|
.split(',')
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ObjectUtil } from '@/utils';
|
import { ObjectUtil } from '@/utils';
|
||||||
|
import type { SubProfileMode } from '@/schemas/setting';
|
||||||
|
|
||||||
export class AllSetting {
|
export class AllSetting {
|
||||||
webListen = '';
|
webListen = '';
|
||||||
@@ -46,6 +47,7 @@ export class AllSetting {
|
|||||||
subClashUserAgentRegex = '';
|
subClashUserAgentRegex = '';
|
||||||
subTitle = '';
|
subTitle = '';
|
||||||
subSupportUrl = '';
|
subSupportUrl = '';
|
||||||
|
subProfileMode: SubProfileMode = 'none';
|
||||||
subProfileUrl = '';
|
subProfileUrl = '';
|
||||||
subAnnounce = '';
|
subAnnounce = '';
|
||||||
subEnableRouting = false;
|
subEnableRouting = false;
|
||||||
@@ -167,6 +169,15 @@ export class AllSetting {
|
|||||||
if (data != null) {
|
if (data != null) {
|
||||||
ObjectUtil.cloneProps(this, data);
|
ObjectUtil.cloneProps(this, data);
|
||||||
}
|
}
|
||||||
|
// Legacy settings with a custom URL retain it until an explicit mode is saved.
|
||||||
|
if (
|
||||||
|
typeof data === 'object' &&
|
||||||
|
data !== null &&
|
||||||
|
(!('subProfileMode' in data) || data.subProfileMode === undefined) &&
|
||||||
|
this.subProfileUrl.trim() !== ''
|
||||||
|
) {
|
||||||
|
this.subProfileMode = 'custom';
|
||||||
|
}
|
||||||
const cpu = Math.round(Number(this.tgCpu));
|
const cpu = Math.round(Number(this.tgCpu));
|
||||||
this.tgCpu = Number.isFinite(cpu) ? Math.min(100, Math.max(0, cpu)) : 80;
|
this.tgCpu = Number.isFinite(cpu) ? Math.min(100, Math.max(0, cpu)) : 80;
|
||||||
const threshold = Math.round(Number(this.outboundDownThreshold));
|
const threshold = Math.round(Number(this.outboundDownThreshold));
|
||||||
|
|||||||
@@ -45,15 +45,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.api-docs-page .websocket-events {
|
.api-docs-page .websocket-events {
|
||||||
margin-bottom: 16px;
|
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--ant-color-border-secondary);
|
border: 1px solid var(--ant-color-border-secondary);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.api-docs-page .websocket-events h2 {
|
.api-docs-page .swagger-ui .section-tabs {
|
||||||
margin-top: 0;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.api-docs-page .websocket-events pre {
|
.api-docs-page .websocket-events pre {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card, Col, ConfigProvider, Layout, Row, Typography } from 'antd';
|
import { Card, Col, ConfigProvider, Layout, Row, Tabs, Typography } from 'antd';
|
||||||
import SwaggerUI from 'swagger-ui-react';
|
import SwaggerUI from 'swagger-ui-react';
|
||||||
import 'swagger-ui-react/swagger-ui.css';
|
import 'swagger-ui-react/swagger-ui.css';
|
||||||
|
|
||||||
@@ -14,6 +14,61 @@ const basePath = window.X_UI_BASE_PATH || '';
|
|||||||
const openApiUrl = `${basePath}panel/api/openapi.json`;
|
const openApiUrl = `${basePath}panel/api/openapi.json`;
|
||||||
const websocketEvents = buildWebSocketEvents(EXAMPLES);
|
const websocketEvents = buildWebSocketEvents(EXAMPLES);
|
||||||
|
|
||||||
|
interface TaggedOperations {
|
||||||
|
keySeq: () => { first: () => string | undefined };
|
||||||
|
filter: (keep: (operations: unknown, tag: string) => boolean) => TaggedOperations;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LayoutSelectors {
|
||||||
|
currentFilter: () => string | false;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectionTabsProps {
|
||||||
|
specSelectors: { tags: () => { toJS: () => { name: string }[] } };
|
||||||
|
layoutSelectors: LayoutSelectors;
|
||||||
|
layoutActions: { updateFilter: (tag: string) => void };
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionTabs({ specSelectors, layoutSelectors, layoutActions }: SectionTabsProps) {
|
||||||
|
const tags = specSelectors
|
||||||
|
.tags()
|
||||||
|
.toJS()
|
||||||
|
.map((tag) => tag.name);
|
||||||
|
return (
|
||||||
|
<div className="wrapper section-tabs">
|
||||||
|
<Tabs
|
||||||
|
size="small"
|
||||||
|
activeKey={layoutSelectors.currentFilter() || tags[0]}
|
||||||
|
onChange={layoutActions.updateFilter}
|
||||||
|
items={tags.map((tag) => ({ key: tag, label: tag }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shows one tag at a time, the first until a tab is picked. Swagger's own filter is a
|
||||||
|
// substring match ("Settings" would also show "Xray Settings") and no-op while unset.
|
||||||
|
const sectionTabsPlugin = {
|
||||||
|
statePlugins: {
|
||||||
|
spec: {
|
||||||
|
wrapSelectors: {
|
||||||
|
taggedOperations:
|
||||||
|
(
|
||||||
|
select: (...args: unknown[]) => TaggedOperations,
|
||||||
|
system: { getSystem: () => { layoutSelectors: LayoutSelectors } },
|
||||||
|
) =>
|
||||||
|
(...args: unknown[]) => {
|
||||||
|
const operations = select(...args);
|
||||||
|
const active =
|
||||||
|
system.getSystem().layoutSelectors.currentFilter() || operations.keySeq().first();
|
||||||
|
return operations.filter((_, tag) => tag === active);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: { FilterContainer: SectionTabs },
|
||||||
|
};
|
||||||
|
|
||||||
export default function ApiDocsPage() {
|
export default function ApiDocsPage() {
|
||||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -32,36 +87,54 @@ export default function ApiDocsPage() {
|
|||||||
|
|
||||||
<Layout className="content-shell">
|
<Layout className="content-shell">
|
||||||
<Layout.Content className="content-area">
|
<Layout.Content className="content-area">
|
||||||
<section className="websocket-events" aria-labelledby="websocket-events-title">
|
<Tabs
|
||||||
<Typography.Title id="websocket-events-title" level={2}>
|
items={[
|
||||||
WebSocket events
|
{
|
||||||
</Typography.Title>
|
key: 'panel-api',
|
||||||
<Typography.Paragraph>
|
label: '3X-UI Panel API',
|
||||||
After the cookie-authenticated <Typography.Text code>GET /ws</Typography.Text>{' '}
|
children: (
|
||||||
upgrade, every server message uses{' '}
|
<div className="docs-wrapper" role="region" aria-label={t('menu.apiDocs')}>
|
||||||
<Typography.Text code>{'{ type, payload, time }'}</Typography.Text>. The time value
|
<SwaggerUI
|
||||||
is Unix milliseconds.
|
url={openApiUrl}
|
||||||
</Typography.Paragraph>
|
docExpansion="list"
|
||||||
<Row gutter={[12, 12]}>
|
deepLinking={false}
|
||||||
{websocketEvents.map((event) => (
|
plugins={[sectionTabsPlugin]}
|
||||||
<Col key={event.type} xs={24} sm={12} xl={8}>
|
tryItOutEnabled
|
||||||
<Card size="small" title={<Typography.Text code>{event.type}</Typography.Text>}>
|
persistAuthorization
|
||||||
<Typography.Paragraph>{event.summary}</Typography.Paragraph>
|
/>
|
||||||
<pre>{JSON.stringify(event.example, null, 2)}</pre>
|
</div>
|
||||||
</Card>
|
),
|
||||||
</Col>
|
},
|
||||||
))}
|
{
|
||||||
</Row>
|
key: 'websocket-events',
|
||||||
</section>
|
label: 'WebSocket events',
|
||||||
<div className="docs-wrapper" role="region" aria-label={t('menu.apiDocs')}>
|
children: (
|
||||||
<SwaggerUI
|
<section className="websocket-events">
|
||||||
url={openApiUrl}
|
<Typography.Paragraph>
|
||||||
docExpansion="list"
|
After the cookie-authenticated{' '}
|
||||||
deepLinking={false}
|
<Typography.Text code>GET /ws</Typography.Text> upgrade, every server
|
||||||
tryItOutEnabled
|
message uses{' '}
|
||||||
persistAuthorization
|
<Typography.Text code>{'{ type, payload, time }'}</Typography.Text>. The
|
||||||
/>
|
time value is Unix milliseconds.
|
||||||
</div>
|
</Typography.Paragraph>
|
||||||
|
<Row gutter={[12, 12]}>
|
||||||
|
{websocketEvents.map((event) => (
|
||||||
|
<Col key={event.type} xs={24} sm={12} xl={8}>
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={<Typography.Text code>{event.type}</Typography.Text>}
|
||||||
|
>
|
||||||
|
<Typography.Paragraph>{event.summary}</Typography.Paragraph>
|
||||||
|
<pre>{JSON.stringify(event.example, null, 2)}</pre>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</section>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/* The body is capped at the viewport and scrolls; a trailing item margin
|
||||||
|
alone must not push it past the cap and summon a scrollbar. */
|
||||||
|
.client-form-modal .ant-tabs-content > .ant-form-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ import type {
|
|||||||
} from '@/hooks/useClients';
|
} from '@/hooks/useClients';
|
||||||
import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
|
import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
|
||||||
import { ClientFormSchema, ClientCreateFormSchema, type ClientFormValues } from '@/schemas/client';
|
import { ClientFormSchema, ClientCreateFormSchema, type ClientFormValues } from '@/schemas/client';
|
||||||
|
import './ClientFormModal.css';
|
||||||
|
|
||||||
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||||
const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305'] as const;
|
const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305'] as const;
|
||||||
@@ -803,6 +804,7 @@ export default function ClientFormModal({
|
|||||||
open={open}
|
open={open}
|
||||||
title={isEdit ? t('pages.clients.editClient') : t('pages.clients.addClient')}
|
title={isEdit ? t('pages.clients.editClient') : t('pages.clients.addClient')}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
|
className="client-form-modal"
|
||||||
width={720}
|
width={720}
|
||||||
zIndex={CLIENT_FORM_MODAL_Z_INDEX}
|
zIndex={CLIENT_FORM_MODAL_Z_INDEX}
|
||||||
style={{ top: 20 }}
|
style={{ top: 20 }}
|
||||||
|
|||||||
@@ -61,6 +61,23 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.summary-stat {
|
||||||
|
margin: -4px -8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-stat:hover,
|
||||||
|
.summary-stat:focus-visible {
|
||||||
|
background: var(--ant-color-fill-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-stat.selected {
|
||||||
|
background: var(--ant-color-primary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
.dot {
|
.dot {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 8px;
|
width: 8px;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
import { useLocation, useSearchParams } from 'react-router';
|
import { useLocation, useSearchParams } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
@@ -153,6 +154,40 @@ function ClientEmailList({ emails, total }: { emails: string[]; total: number })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SummaryStatProps {
|
||||||
|
title: string;
|
||||||
|
value: number;
|
||||||
|
prefix: ReactNode;
|
||||||
|
emails?: string[];
|
||||||
|
selected?: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryStat({ title, value, prefix, emails, selected, onSelect }: SummaryStatProps) {
|
||||||
|
const stat = (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-pressed={selected}
|
||||||
|
className={selected ? 'summary-stat selected' : 'summary-stat'}
|
||||||
|
onClick={onSelect}
|
||||||
|
onKeyDown={activateOnKey(onSelect)}
|
||||||
|
>
|
||||||
|
<Statistic title={title} value={String(value)} prefix={prefix} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (!emails) return stat;
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
title={title}
|
||||||
|
open={value ? undefined : false}
|
||||||
|
content={<ClientEmailList emails={emails} total={value} />}
|
||||||
|
>
|
||||||
|
{stat}
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
|
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
|
||||||
|
|
||||||
interface PersistedFilterState {
|
interface PersistedFilterState {
|
||||||
@@ -1224,6 +1259,15 @@ export default function ClientsPage() {
|
|||||||
const someSelected =
|
const someSelected =
|
||||||
selectedRowKeys.length > 0 && selectedRowKeys.length < filteredClients.length;
|
selectedRowKeys.length > 0 && selectedRowKeys.length < filteredClients.length;
|
||||||
|
|
||||||
|
const isOnlyBucket = (bucket: string) =>
|
||||||
|
filters.buckets.length === 1 && filters.buckets[0] === bucket;
|
||||||
|
|
||||||
|
// Clicking the card that is already the sole status filter clears it again.
|
||||||
|
function selectBucket(bucket: string | null) {
|
||||||
|
const buckets = bucket && !isOnlyBucket(bucket) ? [bucket] : [];
|
||||||
|
setFilters({ ...filters, buckets });
|
||||||
|
}
|
||||||
|
|
||||||
function clearOneFilter<K extends keyof ClientFilters>(key: K) {
|
function clearOneFilter<K extends keyof ClientFilters>(key: K) {
|
||||||
if (key === 'expiryFrom' || key === 'expiryTo') {
|
if (key === 'expiryFrom' || key === 'expiryTo') {
|
||||||
setFilters({ ...filters, expiryFrom: undefined, expiryTo: undefined });
|
setFilters({ ...filters, expiryFrom: undefined, expiryTo: undefined });
|
||||||
@@ -1265,89 +1309,60 @@ export default function ClientsPage() {
|
|||||||
<Card size="small" hoverable className="summary-card">
|
<Card size="small" hoverable className="summary-card">
|
||||||
<Row gutter={[16, 12]}>
|
<Row gutter={[16, 12]}>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<Statistic
|
<SummaryStat
|
||||||
title={t('clients')}
|
title={t('clients')}
|
||||||
value={String(summary.total)}
|
value={summary.total}
|
||||||
prefix={<TeamOutlined />}
|
prefix={<TeamOutlined />}
|
||||||
|
onSelect={() => selectBucket(null)}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<Popover
|
<SummaryStat
|
||||||
title={t('online')}
|
title={t('online')}
|
||||||
open={summary.onlineCount ? undefined : false}
|
value={summary.onlineCount}
|
||||||
content={
|
emails={summary.online}
|
||||||
<ClientEmailList
|
prefix={<span className="dot dot-blue" />}
|
||||||
emails={summary.online}
|
selected={isOnlyBucket('online')}
|
||||||
total={summary.onlineCount}
|
onSelect={() => selectBucket('online')}
|
||||||
/>
|
/>
|
||||||
}
|
|
||||||
>
|
|
||||||
<Statistic
|
|
||||||
title={t('online')}
|
|
||||||
value={String(summary.onlineCount)}
|
|
||||||
prefix={<span className="dot dot-blue" />}
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<Popover
|
<SummaryStat
|
||||||
title={t('depleted')}
|
title={t('depleted')}
|
||||||
open={summary.depletedCount ? undefined : false}
|
value={summary.depletedCount}
|
||||||
content={
|
emails={summary.depleted}
|
||||||
<ClientEmailList
|
prefix={<span className="dot dot-red" />}
|
||||||
emails={summary.depleted}
|
selected={isOnlyBucket('depleted')}
|
||||||
total={summary.depletedCount}
|
onSelect={() => selectBucket('depleted')}
|
||||||
/>
|
/>
|
||||||
}
|
|
||||||
>
|
|
||||||
<Statistic
|
|
||||||
title={t('depleted')}
|
|
||||||
value={String(summary.depletedCount)}
|
|
||||||
prefix={<span className="dot dot-red" />}
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<Popover
|
<SummaryStat
|
||||||
title={t('depletingSoon')}
|
title={t('depletingSoon')}
|
||||||
open={summary.expiringCount ? undefined : false}
|
value={summary.expiringCount}
|
||||||
content={
|
emails={summary.expiring}
|
||||||
<ClientEmailList
|
prefix={<span className="dot dot-orange" />}
|
||||||
emails={summary.expiring}
|
selected={isOnlyBucket('expiring')}
|
||||||
total={summary.expiringCount}
|
onSelect={() => selectBucket('expiring')}
|
||||||
/>
|
/>
|
||||||
}
|
|
||||||
>
|
|
||||||
<Statistic
|
|
||||||
title={t('depletingSoon')}
|
|
||||||
value={String(summary.expiringCount)}
|
|
||||||
prefix={<span className="dot dot-orange" />}
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<Popover
|
<SummaryStat
|
||||||
title={t('disabled')}
|
title={t('disabled')}
|
||||||
open={summary.deactiveCount ? undefined : false}
|
value={summary.deactiveCount}
|
||||||
content={
|
emails={summary.deactive}
|
||||||
<ClientEmailList
|
prefix={<span className="dot dot-gray" />}
|
||||||
emails={summary.deactive}
|
selected={isOnlyBucket('deactive')}
|
||||||
total={summary.deactiveCount}
|
onSelect={() => selectBucket('deactive')}
|
||||||
/>
|
/>
|
||||||
}
|
|
||||||
>
|
|
||||||
<Statistic
|
|
||||||
title={t('disabled')}
|
|
||||||
value={String(summary.deactiveCount)}
|
|
||||||
prefix={<span className="dot dot-gray" />}
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<Statistic
|
<SummaryStat
|
||||||
title={t('subscription.active')}
|
title={t('subscription.active')}
|
||||||
value={String(summary.active)}
|
value={summary.active}
|
||||||
prefix={<span className="dot dot-green" />}
|
prefix={<span className="dot dot-green" />}
|
||||||
|
selected={isOnlyBucket('active')}
|
||||||
|
onSelect={() => selectBucket('active')}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
.qr-panel-canvas .qr-code {
|
.qr-panel-canvas .qr-code {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ export default function QrPanel({
|
|||||||
value={value}
|
value={value}
|
||||||
size={size}
|
size={size}
|
||||||
errorLevel="L"
|
errorLevel="L"
|
||||||
marginSize={4}
|
marginSize={2}
|
||||||
type="svg"
|
type="svg"
|
||||||
bordered={false}
|
bordered={false}
|
||||||
color="#000000"
|
color="#000000"
|
||||||
|
|||||||
@@ -119,6 +119,18 @@ function toGuidOnlineMap(data: Record<string, string[]>): Map<string, Set<string
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Most pushes repeat the previous online sets; handing back a new Map anyway
|
||||||
|
// re-ran the client rollup over every inbound on each traffic event.
|
||||||
|
function sameGuidSets(a: Map<string, Set<string>>, b: Map<string, Set<string>>): boolean {
|
||||||
|
if (a.size !== b.size) return false;
|
||||||
|
for (const [key, set] of b) {
|
||||||
|
const prev = a.get(key);
|
||||||
|
if (!prev || prev.size !== set.size) return false;
|
||||||
|
for (const value of set) if (!prev.has(value)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchLastOnlineMap(): Promise<Record<string, number>> {
|
async function fetchLastOnlineMap(): Promise<Record<string, number>> {
|
||||||
const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true });
|
const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true });
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch lastOnline');
|
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch lastOnline');
|
||||||
@@ -440,10 +452,12 @@ export function useInbounds() {
|
|||||||
setOnlineClients(p.onlineClients);
|
setOnlineClients(p.onlineClients);
|
||||||
}
|
}
|
||||||
if (p.onlineByGuid && typeof p.onlineByGuid === 'object') {
|
if (p.onlineByGuid && typeof p.onlineByGuid === 'object') {
|
||||||
setOnlineByGuid(toGuidOnlineMap(p.onlineByGuid));
|
const next = toGuidOnlineMap(p.onlineByGuid);
|
||||||
|
setOnlineByGuid((prev) => (sameGuidSets(prev, next) ? prev : next));
|
||||||
}
|
}
|
||||||
if (p.activeInbounds && typeof p.activeInbounds === 'object') {
|
if (p.activeInbounds && typeof p.activeInbounds === 'object') {
|
||||||
setActiveByGuid(toGuidOnlineMap(p.activeInbounds));
|
const next = toGuidOnlineMap(p.activeInbounds);
|
||||||
|
setActiveByGuid((prev) => (sameGuidSets(prev, next) ? prev : next));
|
||||||
}
|
}
|
||||||
if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
|
if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
|
||||||
setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
|
setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
|
||||||
@@ -537,8 +551,7 @@ export function useInbounds() {
|
|||||||
? stats.map((stat) => {
|
? stats.map((stat) => {
|
||||||
const su = byEmail.get(stat.email);
|
const su = byEmail.get(stat.email);
|
||||||
if (!su) return stat;
|
if (!su) return stat;
|
||||||
statsTouched = true;
|
const merged = {
|
||||||
return {
|
|
||||||
...stat,
|
...stat,
|
||||||
up: typeof su.up === 'number' ? su.up : stat.up,
|
up: typeof su.up === 'number' ? su.up : stat.up,
|
||||||
down: typeof su.down === 'number' ? su.down : stat.down,
|
down: typeof su.down === 'number' ? su.down : stat.down,
|
||||||
@@ -546,9 +559,27 @@ export function useInbounds() {
|
|||||||
expiryTime: typeof su.expiryTime === 'number' ? su.expiryTime : stat.expiryTime,
|
expiryTime: typeof su.expiryTime === 'number' ? su.expiryTime : stat.expiryTime,
|
||||||
enable: typeof su.enable === 'boolean' ? su.enable : stat.enable,
|
enable: typeof su.enable === 'boolean' ? su.enable : stat.enable,
|
||||||
} as ClientStats;
|
} as ClientStats;
|
||||||
|
if (
|
||||||
|
merged.up === stat.up &&
|
||||||
|
merged.down === stat.down &&
|
||||||
|
merged.total === stat.total &&
|
||||||
|
merged.expiryTime === stat.expiryTime &&
|
||||||
|
merged.enable === stat.enable
|
||||||
|
) {
|
||||||
|
return stat;
|
||||||
|
}
|
||||||
|
statsTouched = true;
|
||||||
|
return merged;
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
if (!upd && !statsTouched) return ib;
|
// Every push lists all inbounds' totals, so only a row whose numbers moved counts.
|
||||||
|
const inboundMoved =
|
||||||
|
!!upd &&
|
||||||
|
((typeof upd.up === 'number' && upd.up !== ib.up) ||
|
||||||
|
(typeof upd.down === 'number' && upd.down !== ib.down) ||
|
||||||
|
(typeof upd.total === 'number' && upd.total !== ib.total) ||
|
||||||
|
(typeof upd.enable === 'boolean' && upd.enable !== ib.enable));
|
||||||
|
if (!inboundMoved && !statsTouched) return ib;
|
||||||
touched = true;
|
touched = true;
|
||||||
const row = new DBInbound(ib as DBInboundInit) as DBInboundInstance;
|
const row = new DBInbound(ib as DBInboundInit) as DBInboundInstance;
|
||||||
if (upd) {
|
if (upd) {
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export default function AmneziaWGLogModal({ open, onClose }: AmneziaWGLogModalPr
|
|||||||
<Select
|
<Select
|
||||||
value={rows}
|
value={rows}
|
||||||
size="small"
|
size="small"
|
||||||
style={{ width: 70 }}
|
style={{ width: 100 }}
|
||||||
onChange={setRows}
|
onChange={setRows}
|
||||||
options={[
|
options={[
|
||||||
{ value: '20', label: '20' },
|
{ value: '20', label: '20' },
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export default function LogModal({ open, onClose }: LogModalProps) {
|
|||||||
<Select
|
<Select
|
||||||
value={rows}
|
value={rows}
|
||||||
size="small"
|
size="small"
|
||||||
style={{ width: 70 }}
|
style={{ width: 100 }}
|
||||||
onChange={setRows}
|
onChange={setRows}
|
||||||
options={[
|
options={[
|
||||||
{ value: '20', label: '20' },
|
{ value: '20', label: '20' },
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export default function OverviewActionBar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ov-bar">
|
<div className="ov-bar">
|
||||||
{status.xray.state === 'error' && status.xray.errorMsg ? (
|
{status.xray.errorMsg ? (
|
||||||
<Tooltip title={<span className="ov-error-detail">{status.xray.errorMsg}</span>}>
|
<Tooltip title={<span className="ov-error-detail">{status.xray.errorMsg}</span>}>
|
||||||
{statePill}
|
{statePill}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -167,6 +167,12 @@ export default function OverviewActionBar({
|
|||||||
statePill
|
statePill
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{status.xray.state === 'running' && status.xray.errorMsg ? (
|
||||||
|
<Tooltip title={<span className="ov-error-detail">{status.xray.errorMsg}</span>}>
|
||||||
|
<Tag color="error">{t('pages.index.xrayStatusError')}</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{updateAvailable ? (
|
{updateAvailable ? (
|
||||||
<Tag
|
<Tag
|
||||||
className="ov-update-tag"
|
className="ov-update-tag"
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
|
|||||||
<Select
|
<Select
|
||||||
value={rows}
|
value={rows}
|
||||||
size="small"
|
size="small"
|
||||||
style={{ width: 70 }}
|
style={{ width: 100 }}
|
||||||
onChange={setRows}
|
onChange={setRows}
|
||||||
options={[
|
options={[
|
||||||
{ value: '20', label: '20' },
|
{ value: '20', label: '20' },
|
||||||
|
|||||||
@@ -145,17 +145,22 @@ function formatUptime(secs?: number): string {
|
|||||||
return `${mins}m`;
|
return `${mins}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stable per language: the columns memo depends on it, and a fresh function each
|
||||||
|
// render rebuilt every column, re-rendering all rows on each heartbeat push.
|
||||||
function useRelativeTime() {
|
function useRelativeTime() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (unixSeconds?: number) => {
|
return useMemo(
|
||||||
if (!unixSeconds) return t('pages.nodes.never');
|
() => (unixSeconds?: number) => {
|
||||||
const diffSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSeconds));
|
if (!unixSeconds) return t('pages.nodes.never');
|
||||||
if (diffSec < 5) return t('pages.nodes.justNow');
|
const diffSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSeconds));
|
||||||
if (diffSec < 60) return `${diffSec}s`;
|
if (diffSec < 5) return t('pages.nodes.justNow');
|
||||||
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m`;
|
if (diffSec < 60) return `${diffSec}s`;
|
||||||
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h`;
|
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m`;
|
||||||
return `${Math.floor(diffSec / 86400)}d`;
|
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h`;
|
||||||
};
|
return `${Math.floor(diffSec / 86400)}d`;
|
||||||
|
},
|
||||||
|
[t],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NodeList({
|
export default function NodeList({
|
||||||
@@ -530,6 +535,47 @@ export default function NodeList({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// rc-table re-runs every cell renderer whenever the Table re-renders, so keep the
|
||||||
|
// same element until its inputs change rather than re-rendering all rows each time.
|
||||||
|
const nodeTable = useMemo(
|
||||||
|
() => (
|
||||||
|
<Table<NodeRow>
|
||||||
|
dataSource={dataSource}
|
||||||
|
columns={columns}
|
||||||
|
pagination={false}
|
||||||
|
loading={loading}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
size="middle"
|
||||||
|
rowKey="key"
|
||||||
|
rowSelection={
|
||||||
|
dataSource.length > 1
|
||||||
|
? {
|
||||||
|
selectedRowKeys: selectedIds,
|
||||||
|
onChange: (keys) =>
|
||||||
|
onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
|
||||||
|
getCheckboxProps: (record) => ({
|
||||||
|
disabled: !!record.transitive || !isUpdateEligible(record),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
locale={{
|
||||||
|
emptyText: (
|
||||||
|
<div className="card-empty">
|
||||||
|
<ClusterOutlined style={{ fontSize: 32, marginBottom: 8 }} />
|
||||||
|
<div>{t('noData')}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
expandable={{
|
||||||
|
expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
|
||||||
|
rowExpandable: (record) => !record.transitive,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[dataSource, columns, loading, selectedIds, onSelectionChange, t],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card size="small" hoverable>
|
<Card size="small" hoverable>
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
@@ -806,39 +852,7 @@ export default function NodeList({
|
|||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Table<NodeRow>
|
nodeTable
|
||||||
dataSource={dataSource}
|
|
||||||
columns={columns}
|
|
||||||
pagination={false}
|
|
||||||
loading={loading}
|
|
||||||
scroll={{ x: 'max-content' }}
|
|
||||||
size="middle"
|
|
||||||
rowKey="key"
|
|
||||||
rowSelection={
|
|
||||||
dataSource.length > 1
|
|
||||||
? {
|
|
||||||
selectedRowKeys: selectedIds,
|
|
||||||
onChange: (keys) =>
|
|
||||||
onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
|
|
||||||
getCheckboxProps: (record) => ({
|
|
||||||
disabled: !!record.transitive || !isUpdateEligible(record),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
locale={{
|
|
||||||
emptyText: (
|
|
||||||
<div className="card-empty">
|
|
||||||
<ClusterOutlined style={{ fontSize: 32, marginBottom: 8 }} />
|
|
||||||
<div>{t('noData')}</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
expandable={{
|
|
||||||
expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
|
|
||||||
rowExpandable: (record) => !record.transitive,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Alert, Button, Input, InputNumber, Switch, Tabs } from 'antd';
|
import { Alert, Button, Input, InputNumber, Select, Switch, Tabs } from 'antd';
|
||||||
import {
|
import {
|
||||||
BranchesOutlined,
|
BranchesOutlined,
|
||||||
CompassOutlined,
|
CompassOutlined,
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate, useSearchParams } from 'react-router';
|
import { useNavigate, useSearchParams } from 'react-router';
|
||||||
import type { AllSetting } from '@/models/setting';
|
import type { AllSetting } from '@/models/setting';
|
||||||
|
import type { SubProfileMode } from '@/schemas/setting';
|
||||||
import { onNumber } from '@/utils/onNumber';
|
import { onNumber } from '@/utils/onNumber';
|
||||||
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
|
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
|
||||||
import { RemarkTemplateField } from '@/components/form';
|
import { RemarkTemplateField } from '@/components/form';
|
||||||
@@ -279,16 +280,44 @@ export default function SubscriptionGeneralTab({
|
|||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
<SettingListItem
|
<SettingListItem
|
||||||
paddings="small"
|
paddings="small"
|
||||||
title={t('pages.settings.subProfileUrl')}
|
title={t('pages.settings.subProfileMode')}
|
||||||
description={t('pages.settings.subProfileUrlDesc')}
|
description={t('pages.settings.subProfileModeDesc')}
|
||||||
>
|
>
|
||||||
<RemarkTemplateField
|
<Select<SubProfileMode>
|
||||||
value={allSetting.subProfileUrl}
|
id="sub-profile-mode"
|
||||||
placeholder="https://example.com"
|
aria-label={t('pages.settings.subProfileMode')}
|
||||||
onChange={(v) => updateSetting({ subProfileUrl: v })}
|
value={allSetting.subProfileMode}
|
||||||
metadataOnly
|
style={{ width: '100%' }}
|
||||||
|
onChange={(value) => updateSetting({ subProfileMode: value })}
|
||||||
|
options={[
|
||||||
|
{ value: 'none', label: t('pages.settings.subProfileModeNone') },
|
||||||
|
{ value: 'builtin', label: t('pages.settings.subProfileModeBuiltin') },
|
||||||
|
{ value: 'custom', label: t('pages.settings.subProfileModeCustom') },
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
{allSetting.subProfileMode === 'builtin' ? (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
style={{ margin: '12px 20px' }}
|
||||||
|
title={t('pages.settings.subProfileBuiltinWarning')}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{allSetting.subProfileMode === 'custom' ? (
|
||||||
|
<SettingListItem
|
||||||
|
paddings="small"
|
||||||
|
title={t('pages.settings.subProfileUrl')}
|
||||||
|
description={t('pages.settings.subProfileUrlDesc')}
|
||||||
|
>
|
||||||
|
<RemarkTemplateField
|
||||||
|
value={allSetting.subProfileUrl}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
onChange={(v) => updateSetting({ subProfileUrl: v })}
|
||||||
|
metadataOnly
|
||||||
|
/>
|
||||||
|
</SettingListItem>
|
||||||
|
) : null}
|
||||||
<SettingListItem
|
<SettingListItem
|
||||||
paddings="small"
|
paddings="small"
|
||||||
title={t('pages.settings.subAnnounce')}
|
title={t('pages.settings.subAnnounce')}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Segmented } from 'antd';
|
||||||
|
import { AndroidOutlined, AppleOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { APP_ICONS } from './appIcons';
|
||||||
|
import type { AppPlatform, SubApp } from './subPageModel';
|
||||||
|
|
||||||
|
interface SubAppsTabProps {
|
||||||
|
apps: Record<AppPlatform, SubApp[]>;
|
||||||
|
initialPlatform: AppPlatform;
|
||||||
|
onOpen: (url: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLATFORM_OPTIONS = [
|
||||||
|
{ value: 'android' as const, label: 'Android', icon: <AndroidOutlined /> },
|
||||||
|
{ value: 'ios' as const, label: 'iOS', icon: <AppleOutlined /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
function AppIcon({ name }: { name: string }) {
|
||||||
|
const icon = APP_ICONS[name];
|
||||||
|
if (!icon) {
|
||||||
|
return (
|
||||||
|
<span className="sub-app-mark" aria-hidden="true">
|
||||||
|
{name.charAt(0)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (icon.tinted) {
|
||||||
|
const mask = `url("${icon.src}")`;
|
||||||
|
return (
|
||||||
|
<span className="sub-app-mark" aria-hidden="true">
|
||||||
|
<span className="sub-app-glyph" style={{ maskImage: mask, WebkitMaskImage: mask }} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <img className="sub-app-logo" src={icon.src} alt="" width={32} height={32} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubAppsTab({ apps, initialPlatform, onOpen }: SubAppsTabProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [platform, setPlatform] = useState<AppPlatform>(initialPlatform);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sub-apps">
|
||||||
|
<Segmented<AppPlatform> value={platform} onChange={setPlatform} options={PLATFORM_OPTIONS} />
|
||||||
|
<div className="sub-app-grid">
|
||||||
|
{apps[platform].map((app) => (
|
||||||
|
<div key={app.name} className="sub-row">
|
||||||
|
<AppIcon name={app.name} />
|
||||||
|
<span className="sub-app-name">{app.name}</span>
|
||||||
|
<Button type="primary" size="small" onClick={() => onOpen(app.url)}>
|
||||||
|
{t('add')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { Fragment } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Tag } from 'antd';
|
||||||
|
import { CopyOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import ConfigBlock from '@/components/clients/ConfigBlock';
|
||||||
|
import {
|
||||||
|
amneziawgConfigFromLink,
|
||||||
|
isPostQuantumLink,
|
||||||
|
wireguardConfigFromLink,
|
||||||
|
} from '@/lib/xray/inbound-link';
|
||||||
|
import { LinkTags, parseLinkParts } from '@/lib/xray/link-label';
|
||||||
|
import SubQrButton from './SubQrButton';
|
||||||
|
|
||||||
|
interface SubConfigsTabProps {
|
||||||
|
links: string[];
|
||||||
|
onCopy: (value: string, toast?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubConfigsTab({ links, onCopy }: SubConfigsTabProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sub-rows">
|
||||||
|
<div className="sub-configs-bar">
|
||||||
|
<Button
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={() => onCopy(links.join('\n'), t('subscription.copyAllConfigsCopied'))}
|
||||||
|
>
|
||||||
|
{t('subscription.copyAllConfigs')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{links.map((link, idx) => {
|
||||||
|
const parts = parseLinkParts(link);
|
||||||
|
const rowTitle = parts?.remark || `Link ${idx + 1}`;
|
||||||
|
const isWireguardLink = link.startsWith('wireguard://') || link.startsWith('wg://');
|
||||||
|
const isAmneziawgLink = link.startsWith('vpn://');
|
||||||
|
return (
|
||||||
|
<Fragment key={link}>
|
||||||
|
<div className="sub-row">
|
||||||
|
{parts ? <LinkTags parts={parts} /> : <Tag className="sub-row-tag">LINK</Tag>}
|
||||||
|
<span className="sub-row-title" dir="auto" title={rowTitle}>
|
||||||
|
{rowTitle}
|
||||||
|
</span>
|
||||||
|
<div className="sub-row-actions">
|
||||||
|
<Button
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={() => onCopy(link)}
|
||||||
|
aria-label={t('copy')}
|
||||||
|
title={t('copy')}
|
||||||
|
/>
|
||||||
|
{!isPostQuantumLink(link) && (
|
||||||
|
<SubQrButton value={link} label={rowTitle} onCopy={onCopy} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isWireguardLink && (
|
||||||
|
<ConfigBlock
|
||||||
|
label={t('pages.clients.wireguardConfig')}
|
||||||
|
text={wireguardConfigFromLink(link, rowTitle)}
|
||||||
|
fileName={`${rowTitle || 'peer'}.conf`}
|
||||||
|
qrRemark={rowTitle}
|
||||||
|
tagColor="cyan"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isAmneziawgLink && (
|
||||||
|
<ConfigBlock
|
||||||
|
label={t('pages.clients.amneziaWgConfig')}
|
||||||
|
text={amneziawgConfigFromLink(link)}
|
||||||
|
fileName={`${rowTitle || 'peer'}.conf`}
|
||||||
|
qrRemark={rowTitle}
|
||||||
|
tagColor="purple"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Menu, Popover, Space } from 'antd';
|
||||||
|
import {
|
||||||
|
MoonFilled,
|
||||||
|
MoonOutlined,
|
||||||
|
SunOutlined,
|
||||||
|
TranslationOutlined,
|
||||||
|
WifiOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { LanguageManager } from '@/utils';
|
||||||
|
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||||
|
|
||||||
|
interface SubHeaderProps {
|
||||||
|
title: string;
|
||||||
|
sId: string;
|
||||||
|
email: string;
|
||||||
|
lang: string;
|
||||||
|
onLangChange: (lang: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubHeader({ title, sId, email, lang, onLangChange }: SubHeaderProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
|
||||||
|
|
||||||
|
const cycleTheme = () => {
|
||||||
|
pauseAnimationsUntilLeave('sub-theme-cycle');
|
||||||
|
if (!isDark) {
|
||||||
|
toggleTheme();
|
||||||
|
if (isUltra) toggleUltra();
|
||||||
|
} else if (!isUltra) {
|
||||||
|
toggleUltra();
|
||||||
|
} else {
|
||||||
|
toggleUltra();
|
||||||
|
toggleTheme();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const langMenuItems = useMemo(
|
||||||
|
() =>
|
||||||
|
(LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map(
|
||||||
|
(l) => ({
|
||||||
|
key: l.value,
|
||||||
|
label: (
|
||||||
|
<Space size={8}>
|
||||||
|
<span aria-hidden="true">{l.icon}</span>
|
||||||
|
<span>{l.name}</span>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const themeIcon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />;
|
||||||
|
const initial = Array.from(title)[0]?.toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sub-header">
|
||||||
|
<div className="sub-brand">
|
||||||
|
<span className="sub-brand-mark" aria-hidden="true">
|
||||||
|
{initial ?? <WifiOutlined />}
|
||||||
|
</span>
|
||||||
|
<div className="sub-brand-text">
|
||||||
|
<div className="sub-brand-title" dir="auto">
|
||||||
|
{title || t('subscription.title')}
|
||||||
|
</div>
|
||||||
|
<div className="sub-brand-id">
|
||||||
|
<bdi>{email ? `${sId} - ${email}` : sId}</bdi>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sub-toolbar">
|
||||||
|
<Button
|
||||||
|
id="sub-theme-cycle"
|
||||||
|
shape="circle"
|
||||||
|
size="large"
|
||||||
|
className="toolbar-btn"
|
||||||
|
aria-label={t('menu.theme')}
|
||||||
|
title={t('menu.theme')}
|
||||||
|
icon={themeIcon}
|
||||||
|
onClick={cycleTheme}
|
||||||
|
/>
|
||||||
|
<Popover
|
||||||
|
rootClassName={isDark ? 'dark' : 'light'}
|
||||||
|
placement="bottomRight"
|
||||||
|
trigger="click"
|
||||||
|
styles={{ content: { padding: 4 } }}
|
||||||
|
content={
|
||||||
|
<Menu
|
||||||
|
mode="vertical"
|
||||||
|
selectable
|
||||||
|
selectedKeys={[lang]}
|
||||||
|
items={langMenuItems}
|
||||||
|
onClick={({ key }) => onLangChange(key)}
|
||||||
|
style={{ border: 'none', minWidth: 160 }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
shape="circle"
|
||||||
|
size="large"
|
||||||
|
className="toolbar-btn"
|
||||||
|
aria-label={t('pages.settings.language')}
|
||||||
|
icon={<TranslationOutlined />}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Progress, Tag, theme } from 'antd';
|
||||||
|
|
||||||
|
import { IntlUtil } from '@/utils';
|
||||||
|
import type { CalendarKind } from '@/utils';
|
||||||
|
import { usagePercent } from './subPageModel';
|
||||||
|
import type { SubStatus } from './subPageModel';
|
||||||
|
|
||||||
|
interface SubHeroProps {
|
||||||
|
status: SubStatus;
|
||||||
|
daysLeft: number | null;
|
||||||
|
usedByte: number;
|
||||||
|
totalByte: number;
|
||||||
|
expireMs: number;
|
||||||
|
lastOnlineMs: number;
|
||||||
|
download: string;
|
||||||
|
upload: string;
|
||||||
|
used: string;
|
||||||
|
total: string;
|
||||||
|
remained: string;
|
||||||
|
datepicker: CalendarKind;
|
||||||
|
lang: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_TAGS: Record<SubStatus, { color: string; label: string }> = {
|
||||||
|
active: { color: 'green', label: 'subscription.active' },
|
||||||
|
unlimited: { color: 'purple', label: 'subscription.unlimited' },
|
||||||
|
expired: { color: 'red', label: 'subscription.expired' },
|
||||||
|
depleted: { color: 'red', label: 'subscription.depleted' },
|
||||||
|
disabled: { color: 'red', label: 'subscription.inactive' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// FormatTraffic renders "37.60GB"; the amount and unit are sized apart.
|
||||||
|
function splitSize(label: string): [string, string] {
|
||||||
|
const match = /^([\d.,]+)\s*(\D*)$/.exec(label.trim());
|
||||||
|
return match ? [match[1], match[2]] : [label, ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubHero({
|
||||||
|
status,
|
||||||
|
daysLeft,
|
||||||
|
usedByte,
|
||||||
|
totalByte,
|
||||||
|
expireMs,
|
||||||
|
lastOnlineMs,
|
||||||
|
download,
|
||||||
|
upload,
|
||||||
|
used,
|
||||||
|
total,
|
||||||
|
remained,
|
||||||
|
datepicker,
|
||||||
|
lang,
|
||||||
|
}: SubHeroProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
const hasQuota = totalByte > 0;
|
||||||
|
const healthy = status === 'active' || status === 'unlimited';
|
||||||
|
const pct = usagePercent(usedByte, totalByte);
|
||||||
|
const ringColor =
|
||||||
|
!healthy || pct >= 90 ? token.colorError : pct >= 75 ? token.colorWarning : token.colorPrimary;
|
||||||
|
const [amount, unit] = splitSize(hasQuota ? remained : used);
|
||||||
|
const formatDate = (ms: number) => IntlUtil.formatDate(ms, datepicker, lang);
|
||||||
|
const statusTag = STATUS_TAGS[status];
|
||||||
|
|
||||||
|
const stats: { key: string; label: string; value: ReactNode }[] = [
|
||||||
|
{ key: 'days', label: t('subscription.daysLeft'), value: daysLeft ?? '∞' },
|
||||||
|
{
|
||||||
|
key: 'expiry',
|
||||||
|
label: t('subscription.expiry'),
|
||||||
|
value: expireMs > 0 ? formatDate(expireMs) : t('subscription.noExpiry'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: t('subscription.status'),
|
||||||
|
value: <Tag color={statusTag.color}>{t(statusTag.label)}</Tag>,
|
||||||
|
},
|
||||||
|
{ key: 'down', label: t('subscription.downloaded'), value: <bdi>{download}</bdi> },
|
||||||
|
{ key: 'up', label: t('subscription.uploaded'), value: <bdi>{upload}</bdi> },
|
||||||
|
{ key: 'total', label: t('subscription.totalQuota'), value: <bdi>{total}</bdi> },
|
||||||
|
{
|
||||||
|
key: 'lastOnline',
|
||||||
|
label: t('lastOnline'),
|
||||||
|
value: lastOnlineMs > 0 ? formatDate(lastOnlineMs) : '-',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={healthy ? 'sub-hero' : 'sub-hero is-alert'}>
|
||||||
|
<Progress
|
||||||
|
type="circle"
|
||||||
|
className="sub-ring"
|
||||||
|
percent={pct}
|
||||||
|
status="normal"
|
||||||
|
size={156}
|
||||||
|
strokeColor={ringColor}
|
||||||
|
format={() => (
|
||||||
|
<span className="sub-ring-center">
|
||||||
|
<span className="sub-ring-value">{hasQuota ? `${pct.toFixed(1)}%` : '∞'}</span>
|
||||||
|
<span className="sub-ring-label">
|
||||||
|
{hasQuota ? t('usage') : t('subscription.unlimited')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="sub-hero-summary">
|
||||||
|
<div className="sub-label">{hasQuota ? t('remained') : t('usage')}</div>
|
||||||
|
<bdi className="sub-big">
|
||||||
|
<span className="sub-big-num">{amount}</span>
|
||||||
|
{unit && <span className="sub-big-unit">{unit}</span>}
|
||||||
|
</bdi>
|
||||||
|
<div className="sub-muted">
|
||||||
|
{hasQuota ? t('subscription.ofTotal', { total }) : t('subscription.unlimited')}
|
||||||
|
</div>
|
||||||
|
<dl className="sub-stats">
|
||||||
|
{stats.map((stat) => (
|
||||||
|
<div key={stat.key} className="sub-stat">
|
||||||
|
<dt className="sub-label">{stat.label}</dt>
|
||||||
|
<dd className="sub-stat-value">{stat.value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, QRCode, Tag } from 'antd';
|
||||||
|
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import SubQrButton from './SubQrButton';
|
||||||
|
|
||||||
|
interface SubLinksTabProps {
|
||||||
|
subUrl: string;
|
||||||
|
subJsonUrl: string;
|
||||||
|
subClashUrl: string;
|
||||||
|
onCopy: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendRawView = (url: string) => `${url}${url.includes('?') ? '&' : '?'}view=raw`;
|
||||||
|
|
||||||
|
export default function SubLinksTab({ subUrl, subJsonUrl, subClashUrl, onCopy }: SubLinksTabProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const subLabel = t('pages.settings.subSettings');
|
||||||
|
const rows = [
|
||||||
|
{ kind: 'SUB', color: 'green', url: subUrl, title: subLabel, downloadable: false },
|
||||||
|
{
|
||||||
|
kind: 'JSON',
|
||||||
|
color: 'purple',
|
||||||
|
url: subJsonUrl,
|
||||||
|
title: `${subLabel} JSON`,
|
||||||
|
downloadable: true,
|
||||||
|
},
|
||||||
|
{ kind: 'CLASH', color: 'gold', url: subClashUrl, title: 'Clash / Mihomo', downloadable: true },
|
||||||
|
].filter((row) => row.url);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sub-rows">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<div key={row.kind} className="sub-row">
|
||||||
|
<Tag color={row.color} className="sub-row-tag">
|
||||||
|
{row.kind}
|
||||||
|
</Tag>
|
||||||
|
<div className="sub-row-main">
|
||||||
|
<a href={row.url} target="_blank" rel="noopener noreferrer" className="sub-row-title">
|
||||||
|
{row.title}
|
||||||
|
</a>
|
||||||
|
<div className="sub-row-url" dir="ltr" title={row.url}>
|
||||||
|
{row.url}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sub-row-actions">
|
||||||
|
{row.downloadable && (
|
||||||
|
<Button
|
||||||
|
href={appendRawView(row.url)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
aria-label={t('download')}
|
||||||
|
title={t('download')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={() => onCopy(row.url)}
|
||||||
|
aria-label={t('copy')}
|
||||||
|
title={t('copy')}
|
||||||
|
/>
|
||||||
|
<SubQrButton value={row.url} label={row.title} onCopy={onCopy} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{subUrl && (
|
||||||
|
<div className="sub-qr-card">
|
||||||
|
<div className="sub-qr-code">
|
||||||
|
<QRCode
|
||||||
|
value={subUrl}
|
||||||
|
size={112}
|
||||||
|
type="svg"
|
||||||
|
bordered={false}
|
||||||
|
color="#000000"
|
||||||
|
bgColor="#ffffff"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="sub-qr-title">{t('subscription.scanTitle')}</div>
|
||||||
|
<div className="sub-muted">{t('subscription.scanHint')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,78 @@
|
|||||||
.subscription-page {
|
.subscription-page {
|
||||||
--bg-page: #e6e8ec;
|
/* --sub-grad-* paints graphics, --sub-ink-* paints text: the cyan end darkens
|
||||||
--bg-card: #ffffff;
|
so text clears 4.5:1. --sub-accent is ACCENT.primary in SubPage.tsx. */
|
||||||
|
--sub-grad-from: #8b5cf6;
|
||||||
|
--sub-grad-to: #06b6d4;
|
||||||
|
--sub-ink-from: #6d28d9;
|
||||||
|
--sub-ink-to: #0e7490;
|
||||||
|
--sub-accent: #7c3aed;
|
||||||
|
--bg-page: linear-gradient(135deg, #e9e4ff 0%, #ddeefc 52%, #e2f7f2 100%);
|
||||||
|
--sub-card-bg: rgba(255, 255, 255, 0.72);
|
||||||
|
--sub-card-border: rgba(255, 255, 255, 0.7);
|
||||||
|
--sub-card-shadow: 0 1px 3px rgba(15, 23, 42, 0.05), 0 20px 56px rgba(124, 58, 237, 0.16);
|
||||||
|
--sub-card-sheen: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(255, 255, 255, 0.75),
|
||||||
|
rgba(255, 255, 255, 0) 42%,
|
||||||
|
rgba(124, 58, 237, 0.22) 88%
|
||||||
|
);
|
||||||
|
--sub-blob-1: rgba(139, 92, 246, 0.55);
|
||||||
|
--sub-blob-2: rgba(6, 182, 212, 0.45);
|
||||||
|
--sub-grid: rgba(124, 58, 237, 0.06);
|
||||||
|
--sub-hairline: linear-gradient(90deg, rgba(139, 92, 246, 0.4), rgba(6, 182, 212, 0.4));
|
||||||
|
--sub-tile-bg: rgba(124, 58, 237, 0.05);
|
||||||
|
--sub-tile-border: rgba(124, 58, 237, 0.12);
|
||||||
|
--sub-row-bg: rgba(124, 58, 237, 0.045);
|
||||||
|
--sub-row-border: rgba(124, 58, 237, 0.11);
|
||||||
|
--sub-row-bg-hover: rgba(124, 58, 237, 0.09);
|
||||||
|
--sub-row-border-hover: rgba(124, 58, 237, 0.3);
|
||||||
|
--sub-row-glow: rgba(124, 58, 237, 0.28);
|
||||||
|
--sub-glass-bg: rgba(255, 255, 255, 0.6);
|
||||||
|
|
||||||
|
position: relative;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: var(--bg-page);
|
background: var(--bg-page);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subscription-page.is-dark {
|
.subscription-page.is-dark {
|
||||||
--bg-page: #1a1b1f;
|
--sub-grad-from: #a78bfa;
|
||||||
--bg-card: #23252b;
|
--sub-grad-to: #22d3ee;
|
||||||
|
--sub-ink-from: #c4b5fd;
|
||||||
|
--sub-ink-to: #67e8f9;
|
||||||
|
--sub-accent: #a78bfa;
|
||||||
|
--bg-page: radial-gradient(ellipse 120% 90% at 18% -10%, #1f1740 0%, #16171d 52%, #101116 100%);
|
||||||
|
--sub-card-bg: rgba(35, 37, 43, 0.62);
|
||||||
|
--sub-card-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--sub-card-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 24px 64px rgba(109, 40, 217, 0.24);
|
||||||
|
--sub-card-sheen: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(255, 255, 255, 0.16),
|
||||||
|
rgba(255, 255, 255, 0) 42%,
|
||||||
|
rgba(167, 139, 250, 0.4) 88%
|
||||||
|
);
|
||||||
|
--sub-blob-1: rgba(139, 92, 246, 0.4);
|
||||||
|
--sub-blob-2: rgba(34, 211, 238, 0.26);
|
||||||
|
--sub-grid: rgba(255, 255, 255, 0.035);
|
||||||
|
--sub-hairline: linear-gradient(90deg, rgba(167, 139, 250, 0.45), rgba(34, 211, 238, 0.45));
|
||||||
|
--sub-tile-bg: rgba(167, 139, 250, 0.07);
|
||||||
|
--sub-tile-border: rgba(167, 139, 250, 0.14);
|
||||||
|
--sub-row-bg: rgba(167, 139, 250, 0.06);
|
||||||
|
--sub-row-border: rgba(167, 139, 250, 0.12);
|
||||||
|
--sub-row-bg-hover: rgba(167, 139, 250, 0.12);
|
||||||
|
--sub-row-border-hover: rgba(167, 139, 250, 0.35);
|
||||||
|
--sub-row-glow: rgba(139, 92, 246, 0.45);
|
||||||
|
--sub-glass-bg: rgba(255, 255, 255, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subscription-page.is-dark.is-ultra {
|
.subscription-page.is-dark.is-ultra {
|
||||||
--bg-page: #000;
|
--bg-page: radial-gradient(ellipse 120% 90% at 18% -10%, #120a2b 0%, #050509 55%, #000 100%);
|
||||||
--bg-card: #101013;
|
--sub-card-bg: rgba(16, 16, 19, 0.68);
|
||||||
|
--sub-card-border: rgba(255, 255, 255, 0.055);
|
||||||
|
--sub-card-shadow: 0 1px 3px rgba(0, 0, 0, 0.6), 0 24px 64px rgba(88, 28, 135, 0.3);
|
||||||
|
--sub-blob-1: rgba(139, 92, 246, 0.22);
|
||||||
|
--sub-blob-2: rgba(34, 211, 238, 0.14);
|
||||||
|
--sub-grid: rgba(255, 255, 255, 0.022);
|
||||||
|
--sub-glass-bg: rgba(255, 255, 255, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subscription-page .ant-layout,
|
.subscription-page .ant-layout,
|
||||||
@@ -20,104 +80,204 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subscription-page .content {
|
/* aurora backdrop */
|
||||||
padding: 24px 12px;
|
.sub-aurora {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subscription-card {
|
.sub-aurora-grid {
|
||||||
margin-top: 8px;
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(var(--sub-grid) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--sub-grid) 1px, transparent 1px);
|
||||||
|
background-size: 48px 48px;
|
||||||
|
background-position: center;
|
||||||
|
-webkit-mask-image: radial-gradient(ellipse at 50% 30%, black 20%, transparent 72%);
|
||||||
|
mask-image: radial-gradient(ellipse at 50% 30%, black 20%, transparent 72%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-tag {
|
.sub-aurora::before,
|
||||||
width: 100%;
|
.sub-aurora::after {
|
||||||
text-align: center;
|
content: '';
|
||||||
margin: 0;
|
position: absolute;
|
||||||
|
width: 70vmax;
|
||||||
|
height: 70vmax;
|
||||||
|
max-width: 820px;
|
||||||
|
max-height: 820px;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(80px);
|
||||||
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-table {
|
.sub-aurora::before {
|
||||||
margin-top: 4px;
|
top: -22vmax;
|
||||||
|
left: -16vmax;
|
||||||
|
background: radial-gradient(circle, var(--sub-blob-1) 0%, transparent 65%);
|
||||||
|
animation: sub-blob-a 28s ease-in-out infinite alternate;
|
||||||
}
|
}
|
||||||
|
|
||||||
.links-section {
|
.sub-aurora::after {
|
||||||
display: flex;
|
bottom: -24vmax;
|
||||||
flex-direction: column;
|
right: -18vmax;
|
||||||
gap: 8px;
|
background: radial-gradient(circle, var(--sub-blob-2) 0%, transparent 65%);
|
||||||
|
animation: sub-blob-b 34s ease-in-out infinite alternate;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sub-link-anchor {
|
@keyframes sub-blob-a {
|
||||||
color: inherit;
|
0% {
|
||||||
text-decoration: none;
|
transform: translate(0, 0) scale(1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate(16vw, 14vh) scale(1.18);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sub-link-anchor:hover {
|
@keyframes sub-blob-b {
|
||||||
text-decoration: underline;
|
0% {
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate(-14vw, -12vh) scale(1.15);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sub-link-row {
|
.sub-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 32px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-card {
|
||||||
|
max-width: 880px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-page .sub-card {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--sub-card-border);
|
||||||
|
background: var(--sub-card-bg);
|
||||||
|
box-shadow: var(--sub-card-shadow);
|
||||||
|
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
||||||
|
backdrop-filter: blur(24px) saturate(180%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hairline gradient rim: a padded sheen layer with its own middle masked out. */
|
||||||
|
.subscription-page .sub-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
padding: 1px;
|
||||||
|
background: var(--sub-card-sheen);
|
||||||
|
-webkit-mask:
|
||||||
|
linear-gradient(#000 0 0) content-box,
|
||||||
|
linear-gradient(#000 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask-composite: exclude;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-card > .ant-card-body {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-label,
|
||||||
|
.sub-muted {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ant-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-muted {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient hairline shared by the header, the stats grid and the footer. */
|
||||||
|
.sub-header::after,
|
||||||
|
.sub-stats::before,
|
||||||
|
.sub-footer::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset-inline: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--sub-hairline);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* header */
|
||||||
|
.sub-header {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
justify-content: space-between;
|
||||||
padding: 8px 12px;
|
gap: 12px;
|
||||||
border-radius: 10px;
|
padding-bottom: 20px;
|
||||||
background: rgba(0, 0, 0, 0.03);
|
margin-bottom: 24px;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
|
||||||
transition:
|
|
||||||
background 120ms ease,
|
|
||||||
border-color 120ms ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sub-link-row:hover {
|
.sub-header::after {
|
||||||
background: rgba(0, 0, 0, 0.05);
|
bottom: 0;
|
||||||
border-color: rgba(0, 0, 0, 0.14);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.is-dark .sub-link-row {
|
.sub-brand {
|
||||||
background: rgba(0, 0, 0, 0.2);
|
display: flex;
|
||||||
border-color: rgba(255, 255, 255, 0.1);
|
align-items: center;
|
||||||
}
|
gap: 12px;
|
||||||
|
|
||||||
.is-dark .sub-link-row:hover {
|
|
||||||
background: rgba(0, 0, 0, 0.3);
|
|
||||||
border-color: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sub-link-tag {
|
|
||||||
margin: 0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sub-link-title {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font-size: 13px;
|
}
|
||||||
|
|
||||||
|
.sub-brand-mark {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 13px;
|
||||||
|
background: linear-gradient(135deg, var(--sub-grad-from), var(--sub-grad-to));
|
||||||
|
box-shadow: 0 8px 20px -8px var(--sub-row-glow);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-brand-text {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-brand-title,
|
||||||
|
.sub-brand-id {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sub-link-actions {
|
.sub-brand-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-brand-id {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ant-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 8px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sub-link-qr-popover {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.apps-row {
|
|
||||||
margin-top: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-col {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-btn {
|
.toolbar-btn {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
@@ -129,3 +289,436 @@
|
|||||||
.toolbar-btn .anticon {
|
.toolbar-btn .anticon {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subscription-page .toolbar-btn {
|
||||||
|
border-color: var(--sub-row-border);
|
||||||
|
background: var(--sub-glass-bg);
|
||||||
|
color: var(--sub-accent);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-page .toolbar-btn:hover {
|
||||||
|
border-color: var(--sub-row-border-hover);
|
||||||
|
background: var(--sub-row-bg-hover);
|
||||||
|
color: var(--sub-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-announce {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* usage hero */
|
||||||
|
.sub-hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: 32px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-ring .ant-progress-text {
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-ring-center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-ring-value {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-ring-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ant-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-big {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 2px 0;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-big-num {
|
||||||
|
font-size: 44px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
background: linear-gradient(135deg, var(--sub-ink-from), var(--sub-ink-to));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
color: var(--sub-ink-from);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-big-unit {
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--ant-color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-hero.is-alert .sub-big-num {
|
||||||
|
background: none;
|
||||||
|
-webkit-text-fill-color: var(--ant-color-error);
|
||||||
|
color: var(--ant-color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-stats {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin: 20px 0 0;
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-stats::before {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-stat {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--sub-tile-border);
|
||||||
|
background: var(--sub-tile-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-stat-value {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-stat-value .ant-tag {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* tabs */
|
||||||
|
.sub-tabs {
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-tabs.ant-tabs .ant-tabs-ink-bar {
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: linear-gradient(90deg, var(--sub-grad-from), var(--sub-grad-to));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-tab-count {
|
||||||
|
margin-inline-start: 6px;
|
||||||
|
padding: 0 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--sub-tile-bg);
|
||||||
|
border: 1px solid var(--sub-tile-border);
|
||||||
|
color: var(--sub-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--sub-row-bg);
|
||||||
|
border: 1px solid var(--sub-row-border);
|
||||||
|
transition:
|
||||||
|
background 160ms ease,
|
||||||
|
border-color 160ms ease,
|
||||||
|
box-shadow 160ms ease,
|
||||||
|
transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-row:hover {
|
||||||
|
background: var(--sub-row-bg-hover);
|
||||||
|
border-color: var(--sub-row-border-hover);
|
||||||
|
box-shadow: 0 8px 20px -14px var(--sub-row-glow);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-row-tag {
|
||||||
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-row-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-row-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-row > .sub-row-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.sub-row-title:hover {
|
||||||
|
color: var(--sub-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-row-url {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ant-color-text-tertiary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir='rtl'] .sub-row-url,
|
||||||
|
[dir='rtl'] .sub-row > .sub-row-title {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-row-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-qr-modal .ant-modal-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-qr-modal .ant-modal-close {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid var(--ant-color-border-secondary);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-qr-modal-hint {
|
||||||
|
margin: 2px 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-qr-modal-code {
|
||||||
|
width: fit-content;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-qr-modal-code canvas {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-qr-modal-link {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-qr-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-qr-modal-actions > .ant-btn:first-child {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-qr-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px dashed var(--sub-row-border-hover);
|
||||||
|
background: var(--sub-row-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-qr-code {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-qr-title {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* apps */
|
||||||
|
.sub-apps {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-app-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-app-mark {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 9px;
|
||||||
|
border: 1px solid var(--sub-tile-border);
|
||||||
|
background: linear-gradient(135deg, var(--sub-row-bg-hover), var(--sub-tile-bg));
|
||||||
|
color: var(--sub-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-app-glyph {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
background: currentColor;
|
||||||
|
-webkit-mask-position: center;
|
||||||
|
mask-position: center;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
mask-size: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-app-logo {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 9px;
|
||||||
|
object-fit: cover;
|
||||||
|
box-shadow: 0 0 0 1px var(--sub-tile-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-app-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* configs */
|
||||||
|
.sub-configs-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* footer */
|
||||||
|
.sub-footer {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px 16px;
|
||||||
|
margin-top: 28px;
|
||||||
|
padding-top: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ant-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-footer::before {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-footer > span,
|
||||||
|
.sub-footer > a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.sub-aurora::before,
|
||||||
|
.sub-aurora::after {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-row:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.sub-content {
|
||||||
|
padding: 16px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-card > .ant-card-body {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One static blob: two animated 70vmax blurs drop frames on low-end phones. */
|
||||||
|
.sub-aurora::before {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-aurora::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-hero {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-ring {
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-big-num {
|
||||||
|
font-size: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-stats {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-app-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-tabs .ant-tabs-tab-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-qr-card {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,98 +1,89 @@
|
|||||||
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Alert, Card, ConfigProvider, Layout, Tabs, message } from 'antd';
|
||||||
|
import type { TabsProps } from 'antd';
|
||||||
import {
|
import {
|
||||||
Alert,
|
AppstoreOutlined,
|
||||||
Button,
|
ClockCircleOutlined,
|
||||||
Card,
|
CustomerServiceOutlined,
|
||||||
Col,
|
LinkOutlined,
|
||||||
ConfigProvider,
|
UnorderedListOutlined,
|
||||||
Descriptions,
|
|
||||||
Divider,
|
|
||||||
Dropdown,
|
|
||||||
Layout,
|
|
||||||
Menu,
|
|
||||||
message,
|
|
||||||
Popover,
|
|
||||||
QRCode,
|
|
||||||
Row,
|
|
||||||
Space,
|
|
||||||
Tag,
|
|
||||||
Tooltip,
|
|
||||||
} from 'antd';
|
|
||||||
import {
|
|
||||||
AndroidOutlined,
|
|
||||||
AppleOutlined,
|
|
||||||
CopyOutlined,
|
|
||||||
DownOutlined,
|
|
||||||
DownloadOutlined,
|
|
||||||
MoonFilled,
|
|
||||||
MoonOutlined,
|
|
||||||
QrcodeOutlined,
|
|
||||||
SunOutlined,
|
|
||||||
TranslationOutlined,
|
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
|
import { ClipboardManager, LanguageManager } from '@/utils';
|
||||||
import {
|
|
||||||
amneziawgConfigFromLink,
|
|
||||||
isPostQuantumLink,
|
|
||||||
wireguardConfigFromLink,
|
|
||||||
} from '@/lib/xray/inbound-link';
|
|
||||||
import { LinkTags, parseLinkParts } from '@/lib/xray/link-label';
|
|
||||||
import ConfigBlock from '@/components/clients/ConfigBlock';
|
|
||||||
import { setMessageInstance } from '@/utils/messageBus';
|
import { setMessageInstance } from '@/utils/messageBus';
|
||||||
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import SubAppsTab from './SubAppsTab';
|
||||||
import SubUsageSummary from './SubUsageSummary';
|
import SubConfigsTab from './SubConfigsTab';
|
||||||
|
import SubHeader from './SubHeader';
|
||||||
|
import SubHero from './SubHero';
|
||||||
|
import SubLinksTab from './SubLinksTab';
|
||||||
|
import { buildSubApps, daysUntil, detectPlatform, resolveSubStatus } from './subPageModel';
|
||||||
import './SubPage.css';
|
import './SubPage.css';
|
||||||
|
|
||||||
const QR_SIZE = 240;
|
|
||||||
|
|
||||||
const subData = window.__SUB_PAGE_DATA__ || {};
|
const subData = window.__SUB_PAGE_DATA__ || {};
|
||||||
|
|
||||||
const sId = subData.sId || '';
|
const sId = subData.sId || '';
|
||||||
const enabled = !!subData.enabled;
|
|
||||||
const download = subData.download || '0';
|
|
||||||
const upload = subData.upload || '0';
|
|
||||||
const total = subData.total || '∞';
|
|
||||||
const used = subData.used || '0';
|
|
||||||
const remained = subData.remained || '';
|
|
||||||
const totalByte = Number(subData.totalByte || 0);
|
|
||||||
const expireMs = Number(subData.expire || 0) * 1000;
|
|
||||||
const lastOnlineMs = Number(subData.lastOnline || 0);
|
|
||||||
const subUrl = subData.subUrl || '';
|
const subUrl = subData.subUrl || '';
|
||||||
const subJsonUrl = subData.subJsonUrl || '';
|
const subJsonUrl = subData.subJsonUrl || '';
|
||||||
const subClashUrl = subData.subClashUrl || '';
|
const subClashUrl = subData.subClashUrl || '';
|
||||||
const subTitle = subData.subTitle || '';
|
const subTitle = subData.subTitle || '';
|
||||||
|
const subSupportUrl = subData.subSupportUrl || '';
|
||||||
|
const updateHours = Number(subData.subUpdates || 0);
|
||||||
|
const announce = subData.announce || '';
|
||||||
const links: string[] = Array.isArray(subData.links) ? subData.links : [];
|
const links: string[] = Array.isArray(subData.links) ? subData.links : [];
|
||||||
const linkEmails: string[] = Array.isArray(subData.emails) ? subData.emails : [];
|
const linkEmails: string[] = Array.isArray(subData.emails) ? subData.emails : [];
|
||||||
const subEmail = [...new Set(linkEmails.filter(Boolean))].join(', ');
|
const totalByte = Number(subData.totalByte || 0);
|
||||||
const datepicker = subData.datepicker || 'gregorian';
|
const usedByte =
|
||||||
const announce = subData.announce || '';
|
Number(subData.usedByte || 0) ||
|
||||||
|
Number(subData.downloadByte || 0) + Number(subData.uploadByte || 0);
|
||||||
|
const expireMs = Number(subData.expire || 0) * 1000;
|
||||||
|
const clientEmail = [...new Set(linkEmails.filter(Boolean))].join(', ');
|
||||||
|
const loadedAt = Date.now();
|
||||||
|
|
||||||
const appendRawView = (url: string) => `${url}${url.includes('?') ? '&' : '?'}view=raw`;
|
const heroData = {
|
||||||
|
status: resolveSubStatus({ enabled: !!subData.enabled, usedByte, totalByte, expireMs }, loadedAt),
|
||||||
|
daysLeft: daysUntil(expireMs, loadedAt),
|
||||||
|
usedByte,
|
||||||
|
totalByte,
|
||||||
|
expireMs,
|
||||||
|
lastOnlineMs: Number(subData.lastOnline || 0),
|
||||||
|
download: subData.download || '0',
|
||||||
|
upload: subData.upload || '0',
|
||||||
|
used: subData.used || '0',
|
||||||
|
total: subData.total || '∞',
|
||||||
|
remained: subData.remained || '',
|
||||||
|
datepicker: subData.datepicker || 'gregorian',
|
||||||
|
};
|
||||||
|
|
||||||
const isUnlimited = totalByte <= 0 && expireMs === 0;
|
const apps = buildSubApps({ subUrl, sId, subTitle });
|
||||||
const isActive = (() => {
|
const initialPlatform = detectPlatform(navigator.userAgent);
|
||||||
if (!enabled) return false;
|
const RTL_LANGUAGES = new Set(['fa-IR', 'ar-EG']);
|
||||||
if (totalByte > 0) {
|
|
||||||
const usedByteCalc =
|
// The sub page runs its own violet accent, so every antd control on it picks the
|
||||||
Number(subData.usedByte || 0) ||
|
// hue up instead of the panel blue useTheme pins. Mirrored in SubPage.css.
|
||||||
Number(subData.downloadByte || 0) + Number(subData.uploadByte || 0);
|
const ACCENT = {
|
||||||
if (usedByteCalc >= totalByte) return false;
|
light: {
|
||||||
}
|
primary: '#7c3aed',
|
||||||
if (expireMs > 0 && Date.now() >= expireMs) return false;
|
hover: '#8b5cf6',
|
||||||
return true;
|
active: '#6d28d9',
|
||||||
})();
|
rail: 'rgba(124, 58, 237, 0.16)',
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
primary: '#a78bfa',
|
||||||
|
hover: '#c4b5fd',
|
||||||
|
active: '#8b5cf6',
|
||||||
|
rail: 'rgba(167, 139, 250, 0.18)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default function SubPage() {
|
export default function SubPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
|
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||||
const [messageApi, messageContextHolder] = message.useMessage();
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMessageInstance(messageApi);
|
setMessageInstance(messageApi);
|
||||||
}, [messageApi]);
|
}, [messageApi]);
|
||||||
const { isMobile } = useMediaQuery(576);
|
|
||||||
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage('subscription'));
|
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage('subscription'));
|
||||||
|
|
||||||
const onLangChange = useCallback((next: string) => {
|
const onLangChange = useCallback((next: string) => {
|
||||||
@@ -100,538 +91,124 @@ export default function SubPage() {
|
|||||||
LanguageManager.setLanguage(next, 'subscription');
|
LanguageManager.setLanguage(next, 'subscription');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const cycleTheme = useCallback(() => {
|
|
||||||
pauseAnimationsUntilLeave('sub-theme-cycle');
|
|
||||||
if (!isDark) {
|
|
||||||
toggleTheme();
|
|
||||||
if (isUltra) toggleUltra();
|
|
||||||
} else if (!isUltra) {
|
|
||||||
toggleUltra();
|
|
||||||
} else {
|
|
||||||
toggleUltra();
|
|
||||||
toggleTheme();
|
|
||||||
}
|
|
||||||
}, [isDark, isUltra, toggleTheme, toggleUltra]);
|
|
||||||
|
|
||||||
const copy = useCallback(
|
const copy = useCallback(
|
||||||
async (value: string) => {
|
async (value: string, toast?: string) => {
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
const ok = await ClipboardManager.copyText(value);
|
const ok = await ClipboardManager.copyText(value);
|
||||||
if (ok) messageApi.success(t('copied'));
|
if (ok) messageApi.success(toast ?? t('copied'));
|
||||||
},
|
},
|
||||||
[t, messageApi],
|
[t, messageApi],
|
||||||
);
|
);
|
||||||
|
|
||||||
const copyAll = useCallback(async () => {
|
|
||||||
if (links.length === 0) return;
|
|
||||||
const allLinks = links.join('\n');
|
|
||||||
const ok = await ClipboardManager.copyText(allLinks);
|
|
||||||
if (ok) messageApi.success(t('subscription.copyAllConfigsCopied'));
|
|
||||||
}, [t, messageApi]);
|
|
||||||
|
|
||||||
const open = useCallback((url: string) => {
|
const open = useCallback((url: string) => {
|
||||||
if (!url) return;
|
if (url) window.open(url, '_blank');
|
||||||
window.open(url, '_blank');
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const shadowrocketUrl = useMemo(() => {
|
const tabs = useMemo(() => {
|
||||||
if (!subUrl) return '';
|
const items: NonNullable<TabsProps['items']> = [];
|
||||||
const separator = subUrl.includes('?') ? '&' : '?';
|
if (subUrl || subJsonUrl || subClashUrl) {
|
||||||
const rawUrl = subUrl + separator + 'flag=shadowrocket';
|
items.push({
|
||||||
const base64Url = btoa(rawUrl);
|
key: 'subscription',
|
||||||
const remark = encodeURIComponent(subTitle || sId || 'Subscription');
|
icon: <LinkOutlined />,
|
||||||
return `shadowrocket://add/sub://${base64Url}?remark=${remark}`;
|
label: t('subscription.tabLinks'),
|
||||||
}, []);
|
children: (
|
||||||
|
<SubLinksTab
|
||||||
const v2boxUrl = useMemo(
|
subUrl={subUrl}
|
||||||
() => `v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`,
|
subJsonUrl={subJsonUrl}
|
||||||
[],
|
subClashUrl={subClashUrl}
|
||||||
);
|
onCopy={copy}
|
||||||
const streisandUrl = useMemo(() => `streisand://import/${encodeURIComponent(subUrl)}`, []);
|
|
||||||
const happUrl = useMemo(() => `happ://add/${subUrl}`, []);
|
|
||||||
const incyUrl = useMemo(() => `incy://add/${subUrl}`, []);
|
|
||||||
|
|
||||||
const pageClass = useMemo(() => {
|
|
||||||
const classes = ['subscription-page'];
|
|
||||||
if (isDark) classes.push('is-dark');
|
|
||||||
if (isUltra) classes.push('is-ultra');
|
|
||||||
return classes.join(' ');
|
|
||||||
}, [isDark, isUltra]);
|
|
||||||
|
|
||||||
const descriptionsItems = useMemo(() => {
|
|
||||||
const items = [
|
|
||||||
{ key: 'subId', label: t('subscription.subId'), children: sId },
|
|
||||||
...(subEmail ? [{ key: 'email', label: t('subscription.email'), children: subEmail }] : []),
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
label: t('subscription.status'),
|
|
||||||
children: !enabled ? (
|
|
||||||
<Tag color="red">{t('subscription.inactive')}</Tag>
|
|
||||||
) : isUnlimited ? (
|
|
||||||
<Tag color="purple">{t('subscription.unlimited')}</Tag>
|
|
||||||
) : (
|
|
||||||
<Tag color={isActive ? 'green' : 'red'}>
|
|
||||||
{isActive ? t('subscription.active') : t('subscription.inactive')}
|
|
||||||
</Tag>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ key: 'down', label: t('subscription.downloaded'), children: download },
|
|
||||||
{ key: 'up', label: t('subscription.uploaded'), children: upload },
|
|
||||||
{ key: 'used', label: t('usage'), children: used },
|
|
||||||
{ key: 'total', label: t('subscription.totalQuota'), children: total },
|
|
||||||
];
|
|
||||||
if (totalByte > 0) {
|
|
||||||
items.push({ key: 'remained', label: t('remained'), children: remained });
|
|
||||||
}
|
|
||||||
items.push({
|
|
||||||
key: 'lastOnline',
|
|
||||||
label: t('lastOnline'),
|
|
||||||
children: lastOnlineMs > 0 ? IntlUtil.formatDate(lastOnlineMs, datepicker, lang) : '-',
|
|
||||||
});
|
|
||||||
items.push({
|
|
||||||
key: 'expiry',
|
|
||||||
label: t('subscription.expiry'),
|
|
||||||
children:
|
|
||||||
expireMs === 0
|
|
||||||
? t('subscription.noExpiry')
|
|
||||||
: IntlUtil.formatDate(expireMs, datepicker, lang),
|
|
||||||
});
|
|
||||||
return items;
|
|
||||||
}, [t, lang]);
|
|
||||||
|
|
||||||
const androidMenuItems = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
key: 'android-v2box',
|
|
||||||
label: 'V2Box',
|
|
||||||
onClick: () =>
|
|
||||||
open(
|
|
||||||
`v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'android-v2rayng',
|
|
||||||
label: 'V2RayNG',
|
|
||||||
onClick: () => open(`v2rayng://install-config?url=${encodeURIComponent(subUrl)}`),
|
|
||||||
},
|
|
||||||
{ key: 'android-singbox', label: 'Sing-box', onClick: () => copy(subUrl) },
|
|
||||||
{ key: 'android-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) },
|
|
||||||
{ key: 'android-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) },
|
|
||||||
{ key: 'android-happ', label: 'Happ', onClick: () => open(`happ://add/${subUrl}`) },
|
|
||||||
{ key: 'android-incy', label: 'Incy', onClick: () => open(`incy://add/${subUrl}`) },
|
|
||||||
],
|
|
||||||
[copy, open],
|
|
||||||
);
|
|
||||||
|
|
||||||
const iosMenuItems = useMemo(
|
|
||||||
() => [
|
|
||||||
{ key: 'ios-shadowrocket', label: 'Shadowrocket', onClick: () => open(shadowrocketUrl) },
|
|
||||||
{ key: 'ios-v2box', label: 'V2Box', onClick: () => open(v2boxUrl) },
|
|
||||||
{ key: 'ios-streisand', label: 'Streisand', onClick: () => open(streisandUrl) },
|
|
||||||
{ key: 'ios-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) },
|
|
||||||
{ key: 'ios-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) },
|
|
||||||
{ key: 'ios-happ', label: 'Happ', onClick: () => open(happUrl) },
|
|
||||||
{ key: 'ios-incy', label: 'Incy', onClick: () => open(incyUrl) },
|
|
||||||
],
|
|
||||||
[copy, open, shadowrocketUrl, v2boxUrl, streisandUrl, happUrl, incyUrl],
|
|
||||||
);
|
|
||||||
|
|
||||||
const langMenuItems = useMemo(
|
|
||||||
() =>
|
|
||||||
(LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map(
|
|
||||||
(l) => ({
|
|
||||||
key: l.value,
|
|
||||||
label: (
|
|
||||||
<Space size={8}>
|
|
||||||
<span aria-hidden="true">{l.icon}</span>
|
|
||||||
<span>{l.name}</span>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const themeIcon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />;
|
|
||||||
|
|
||||||
const cardTitle = (
|
|
||||||
<Space>
|
|
||||||
<span>{t('subscription.title')}</span>
|
|
||||||
<Tag>{sId}</Tag>
|
|
||||||
</Space>
|
|
||||||
);
|
|
||||||
|
|
||||||
const cardExtra = (
|
|
||||||
<Space size={8} align="center">
|
|
||||||
<Button
|
|
||||||
shape="circle"
|
|
||||||
size="large"
|
|
||||||
className="toolbar-btn"
|
|
||||||
aria-label={t('menu.theme')}
|
|
||||||
title={t('menu.theme')}
|
|
||||||
icon={themeIcon}
|
|
||||||
onClick={cycleTheme}
|
|
||||||
/>
|
|
||||||
<Popover
|
|
||||||
rootClassName={isDark ? 'dark' : 'light'}
|
|
||||||
placement="bottomRight"
|
|
||||||
trigger="click"
|
|
||||||
styles={{ content: { padding: 4 } }}
|
|
||||||
content={
|
|
||||||
<Menu
|
|
||||||
mode="vertical"
|
|
||||||
selectable
|
|
||||||
selectedKeys={[lang]}
|
|
||||||
items={langMenuItems}
|
|
||||||
onClick={({ key }) => onLangChange(key)}
|
|
||||||
style={{ border: 'none', minWidth: 160 }}
|
|
||||||
/>
|
/>
|
||||||
}
|
),
|
||||||
>
|
});
|
||||||
<Button
|
}
|
||||||
shape="circle"
|
if (subUrl) {
|
||||||
size="large"
|
items.push({
|
||||||
className="toolbar-btn"
|
key: 'apps',
|
||||||
aria-label={t('pages.settings.language')}
|
icon: <AppstoreOutlined />,
|
||||||
icon={<TranslationOutlined />}
|
label: t('subscription.tabApps'),
|
||||||
/>
|
children: <SubAppsTab apps={apps} initialPlatform={initialPlatform} onOpen={open} />,
|
||||||
</Popover>
|
});
|
||||||
</Space>
|
}
|
||||||
);
|
if (links.length > 0) {
|
||||||
|
items.push({
|
||||||
|
key: 'configs',
|
||||||
|
icon: <UnorderedListOutlined />,
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
{t('subscription.tabConfigs')}
|
||||||
|
<span className="sub-tab-count">{links.length}</span>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
children: <SubConfigsTab links={links} onCopy={copy} />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}, [t, copy, open]);
|
||||||
|
|
||||||
|
const direction = RTL_LANGUAGES.has(lang) ? 'rtl' : 'ltr';
|
||||||
|
const pageClass = ['subscription-page', isDark && 'is-dark', isUltra && 'is-ultra']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
const themeConfig = useMemo(() => {
|
||||||
|
const accent = isDark ? ACCENT.dark : ACCENT.light;
|
||||||
|
const primary = {
|
||||||
|
colorPrimary: accent.primary,
|
||||||
|
colorPrimaryHover: accent.hover,
|
||||||
|
colorPrimaryActive: accent.active,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...antdThemeConfig,
|
||||||
|
token: {
|
||||||
|
...antdThemeConfig.token,
|
||||||
|
...primary,
|
||||||
|
colorLink: accent.primary,
|
||||||
|
colorInfo: accent.primary,
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
...antdThemeConfig.components,
|
||||||
|
Button: { ...antdThemeConfig.components?.Button, ...primary },
|
||||||
|
Progress: { ...antdThemeConfig.components?.Progress, remainingColor: accent.rail },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [antdThemeConfig, isDark]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigProvider theme={antdThemeConfig}>
|
<ConfigProvider theme={themeConfig} direction={direction}>
|
||||||
{messageContextHolder}
|
{messageContextHolder}
|
||||||
<Layout className={pageClass}>
|
<Layout className={pageClass} dir={direction}>
|
||||||
<Layout.Content className="content">
|
<div className="sub-aurora" aria-hidden="true">
|
||||||
<Row justify="center">
|
<span className="sub-aurora-grid" />
|
||||||
<Col xs={24} sm={22} md={18} lg={14} xl={12}>
|
</div>
|
||||||
<Card hoverable className="subscription-card" title={cardTitle} extra={cardExtra}>
|
<Layout.Content className="sub-content">
|
||||||
{announce && (
|
<Card className="sub-card">
|
||||||
<Alert type="info" showIcon title={announce} style={{ marginBottom: 16 }} />
|
<SubHeader
|
||||||
|
title={subTitle}
|
||||||
|
sId={sId}
|
||||||
|
email={clientEmail}
|
||||||
|
lang={lang}
|
||||||
|
onLangChange={onLangChange}
|
||||||
|
/>
|
||||||
|
{announce && <Alert type="info" showIcon title={announce} className="sub-announce" />}
|
||||||
|
<SubHero {...heroData} lang={lang} />
|
||||||
|
{tabs.length > 0 && <Tabs className="sub-tabs" tabBarGutter={24} items={tabs} />}
|
||||||
|
{(updateHours > 0 || subSupportUrl) && (
|
||||||
|
<footer className="sub-footer">
|
||||||
|
{updateHours > 0 && (
|
||||||
|
<span>
|
||||||
|
<ClockCircleOutlined />
|
||||||
|
{t('subscription.updateInterval', { hours: updateHours })}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<Descriptions
|
{subSupportUrl && (
|
||||||
bordered
|
<a href={subSupportUrl} target="_blank" rel="noopener noreferrer">
|
||||||
column={1}
|
<CustomerServiceOutlined />
|
||||||
size="small"
|
{t('subscription.support')}
|
||||||
className="info-table"
|
</a>
|
||||||
items={descriptionsItems}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SubUsageSummary
|
|
||||||
usedByte={
|
|
||||||
Number(subData.usedByte || 0) ||
|
|
||||||
Number(subData.downloadByte || 0) + Number(subData.uploadByte || 0)
|
|
||||||
}
|
|
||||||
totalByte={totalByte}
|
|
||||||
usedLabel={used}
|
|
||||||
totalLabel={total}
|
|
||||||
remainedLabel={remained}
|
|
||||||
expireMs={expireMs}
|
|
||||||
isActive={isActive}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{(subUrl || subJsonUrl || subClashUrl) && (
|
|
||||||
<>
|
|
||||||
<Divider>{t('subscription.title')}</Divider>
|
|
||||||
<div className="links-section">
|
|
||||||
{subUrl && (
|
|
||||||
<div className="sub-link-row">
|
|
||||||
<Tag color="green" className="sub-link-tag">
|
|
||||||
SUB
|
|
||||||
</Tag>
|
|
||||||
<a
|
|
||||||
href={subUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="sub-link-title sub-link-anchor"
|
|
||||||
title={subUrl}
|
|
||||||
>
|
|
||||||
{sId}
|
|
||||||
</a>
|
|
||||||
<div className="sub-link-actions">
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
onClick={() => copy(subUrl)}
|
|
||||||
aria-label={t('copy')}
|
|
||||||
title={t('copy')}
|
|
||||||
/>
|
|
||||||
<Popover
|
|
||||||
trigger="click"
|
|
||||||
placement="left"
|
|
||||||
destroyOnHidden
|
|
||||||
content={
|
|
||||||
<div className="sub-link-qr-popover">
|
|
||||||
<Tag color="green" className="qr-tag">
|
|
||||||
{t('pages.settings.subSettings')}
|
|
||||||
</Tag>
|
|
||||||
<QRCode
|
|
||||||
value={subUrl}
|
|
||||||
size={QR_SIZE}
|
|
||||||
type="svg"
|
|
||||||
bordered={false}
|
|
||||||
color="#000000"
|
|
||||||
bgColor="#ffffff"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<QrcodeOutlined />}
|
|
||||||
aria-label="QR"
|
|
||||||
title="QR"
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{subJsonUrl && (
|
|
||||||
<div className="sub-link-row">
|
|
||||||
<Tag color="purple" className="sub-link-tag">
|
|
||||||
JSON
|
|
||||||
</Tag>
|
|
||||||
<a
|
|
||||||
href={subJsonUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="sub-link-title sub-link-anchor"
|
|
||||||
title={subJsonUrl}
|
|
||||||
>
|
|
||||||
{sId}
|
|
||||||
</a>
|
|
||||||
<div className="sub-link-actions">
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
href={appendRawView(subJsonUrl)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
icon={<DownloadOutlined />}
|
|
||||||
aria-label={t('download')}
|
|
||||||
title={t('download')}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
onClick={() => copy(subJsonUrl)}
|
|
||||||
aria-label={t('copy')}
|
|
||||||
title={t('copy')}
|
|
||||||
/>
|
|
||||||
<Popover
|
|
||||||
trigger="click"
|
|
||||||
placement="left"
|
|
||||||
destroyOnHidden
|
|
||||||
content={
|
|
||||||
<div className="sub-link-qr-popover">
|
|
||||||
<Tag color="purple" className="qr-tag">
|
|
||||||
{t('pages.settings.subSettings')} JSON
|
|
||||||
</Tag>
|
|
||||||
<QRCode
|
|
||||||
value={subJsonUrl}
|
|
||||||
size={QR_SIZE}
|
|
||||||
type="svg"
|
|
||||||
bordered={false}
|
|
||||||
color="#000000"
|
|
||||||
bgColor="#ffffff"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<QrcodeOutlined />}
|
|
||||||
aria-label="QR"
|
|
||||||
title="QR"
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{subClashUrl && (
|
|
||||||
<div className="sub-link-row">
|
|
||||||
<Tooltip title="Clash / Mihomo">
|
|
||||||
<Tag color="gold" className="sub-link-tag">
|
|
||||||
CLASH
|
|
||||||
</Tag>
|
|
||||||
</Tooltip>
|
|
||||||
<a
|
|
||||||
href={subClashUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="sub-link-title sub-link-anchor"
|
|
||||||
title={subClashUrl}
|
|
||||||
>
|
|
||||||
{sId}
|
|
||||||
</a>
|
|
||||||
<div className="sub-link-actions">
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
href={appendRawView(subClashUrl)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
icon={<DownloadOutlined />}
|
|
||||||
aria-label={t('download')}
|
|
||||||
title={t('download')}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
onClick={() => copy(subClashUrl)}
|
|
||||||
aria-label={t('copy')}
|
|
||||||
title={t('copy')}
|
|
||||||
/>
|
|
||||||
<Popover
|
|
||||||
trigger="click"
|
|
||||||
placement="left"
|
|
||||||
destroyOnHidden
|
|
||||||
content={
|
|
||||||
<div className="sub-link-qr-popover">
|
|
||||||
<Tag color="gold" className="qr-tag">
|
|
||||||
Clash / Mihomo
|
|
||||||
</Tag>
|
|
||||||
<QRCode
|
|
||||||
value={subClashUrl}
|
|
||||||
size={QR_SIZE}
|
|
||||||
type="svg"
|
|
||||||
bordered={false}
|
|
||||||
color="#000000"
|
|
||||||
bgColor="#ffffff"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<QrcodeOutlined />}
|
|
||||||
aria-label="QR"
|
|
||||||
title="QR"
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
</footer>
|
||||||
{links.length > 0 && (
|
)}
|
||||||
<>
|
</Card>
|
||||||
<Divider>{t('pages.inbounds.copyLink')}</Divider>
|
|
||||||
<div className="links-section">
|
|
||||||
<div className="sub-link-row">
|
|
||||||
<span className="sub-link-title">{t('subscription.copyAllConfigs')}</span>
|
|
||||||
<div className="sub-link-actions">
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
onClick={copyAll}
|
|
||||||
aria-label={t('subscription.copyAllConfigs')}
|
|
||||||
title={t('subscription.copyAllConfigs')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{links.map((link, idx) => {
|
|
||||||
const parts = parseLinkParts(link);
|
|
||||||
const fallback = `Link ${idx + 1}`;
|
|
||||||
const rowTitle = parts?.remark || fallback;
|
|
||||||
const qrLabel = parts?.remark || rowTitle;
|
|
||||||
const canQr = !isPostQuantumLink(link);
|
|
||||||
const isWireguardLink =
|
|
||||||
link.startsWith('wireguard://') || link.startsWith('wg://');
|
|
||||||
const isAmneziawgLink = link.startsWith('vpn://');
|
|
||||||
return (
|
|
||||||
<Fragment key={link}>
|
|
||||||
<div className="sub-link-row">
|
|
||||||
{parts ? (
|
|
||||||
<LinkTags parts={parts} />
|
|
||||||
) : (
|
|
||||||
<Tag className="sub-link-tag">LINK</Tag>
|
|
||||||
)}
|
|
||||||
<span className="sub-link-title" title={rowTitle}>
|
|
||||||
{rowTitle}
|
|
||||||
</span>
|
|
||||||
<div className="sub-link-actions">
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
onClick={() => copy(link)}
|
|
||||||
aria-label={t('copy')}
|
|
||||||
title={t('copy')}
|
|
||||||
/>
|
|
||||||
{canQr && (
|
|
||||||
<Popover
|
|
||||||
trigger="click"
|
|
||||||
placement="left"
|
|
||||||
destroyOnHidden
|
|
||||||
content={
|
|
||||||
<div className="sub-link-qr-popover">
|
|
||||||
<Tag className="qr-tag">{qrLabel}</Tag>
|
|
||||||
<QRCode
|
|
||||||
value={link}
|
|
||||||
size={220}
|
|
||||||
type="svg"
|
|
||||||
bordered={false}
|
|
||||||
color="#000000"
|
|
||||||
bgColor="#ffffff"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<QrcodeOutlined />}
|
|
||||||
aria-label="QR"
|
|
||||||
title="QR"
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{isWireguardLink && (
|
|
||||||
<ConfigBlock
|
|
||||||
label={t('pages.clients.wireguardConfig')}
|
|
||||||
text={wireguardConfigFromLink(link, rowTitle)}
|
|
||||||
fileName={`${rowTitle || 'peer'}.conf`}
|
|
||||||
qrRemark={rowTitle}
|
|
||||||
tagColor="cyan"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isAmneziawgLink && (
|
|
||||||
<ConfigBlock
|
|
||||||
label={t('pages.clients.amneziaWgConfig')}
|
|
||||||
text={amneziawgConfigFromLink(link)}
|
|
||||||
fileName={`${rowTitle || 'peer'}.conf`}
|
|
||||||
qrRemark={rowTitle}
|
|
||||||
tagColor="purple"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Row gutter={[8, 8]} justify="center" className="apps-row">
|
|
||||||
<Col xs={24} sm={12} className="app-col">
|
|
||||||
<Dropdown trigger={['click']} menu={{ items: androidMenuItems }}>
|
|
||||||
<Button block={isMobile} size="large" type="primary">
|
|
||||||
<AndroidOutlined /> Android <DownOutlined />
|
|
||||||
</Button>
|
|
||||||
</Dropdown>
|
|
||||||
</Col>
|
|
||||||
<Col xs={24} sm={12} className="app-col">
|
|
||||||
<Dropdown trigger={['click']} menu={{ items: iosMenuItems }}>
|
|
||||||
<Button block={isMobile} size="large" type="primary">
|
|
||||||
<AppleOutlined /> iOS <DownOutlined />
|
|
||||||
</Button>
|
|
||||||
</Dropdown>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Input, Modal, QRCode } from 'antd';
|
||||||
|
import { CopyOutlined, DownloadOutlined, QrcodeOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
interface SubQrButtonProps {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
onCopy: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubQrButton({ value, label, onCopy }: SubQrButtonProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const qrRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const saveQr = () => {
|
||||||
|
const canvas = qrRef.current?.querySelector('canvas');
|
||||||
|
if (!canvas) return;
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = canvas.toDataURL('image/png');
|
||||||
|
link.download = `${label || 'qrcode'}.png`;
|
||||||
|
link.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button icon={<QrcodeOutlined />} aria-label="QR" title="QR" onClick={() => setOpen(true)} />
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onCancel={() => setOpen(false)}
|
||||||
|
footer={null}
|
||||||
|
width={440}
|
||||||
|
centered
|
||||||
|
destroyOnHidden
|
||||||
|
rootClassName="sub-qr-modal"
|
||||||
|
title={t('subscription.qrTitle')}
|
||||||
|
>
|
||||||
|
<p className="sub-muted sub-qr-modal-hint">{t('subscription.qrHint')}</p>
|
||||||
|
<div ref={qrRef} className="sub-qr-modal-code">
|
||||||
|
<QRCode
|
||||||
|
value={value}
|
||||||
|
size={240}
|
||||||
|
type="canvas"
|
||||||
|
marginSize={2}
|
||||||
|
bordered={false}
|
||||||
|
color="#000000"
|
||||||
|
bgColor="#ffffff"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input.TextArea
|
||||||
|
className="sub-qr-modal-link"
|
||||||
|
value={value}
|
||||||
|
readOnly
|
||||||
|
dir="ltr"
|
||||||
|
autoSize={{ minRows: 2, maxRows: 5 }}
|
||||||
|
/>
|
||||||
|
<div className="sub-qr-modal-actions">
|
||||||
|
<Button type="primary" size="large" icon={<CopyOutlined />} onClick={() => onCopy(value)}>
|
||||||
|
{t('copy')}
|
||||||
|
</Button>
|
||||||
|
<Button size="large" icon={<DownloadOutlined />} onClick={saveQr}>
|
||||||
|
{t('subscription.saveQr')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
.usage-summary {
|
|
||||||
margin-top: 12px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
background: var(--ant-color-fill-alter);
|
|
||||||
border: 1px solid var(--ant-color-border-secondary);
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-summary.is-inactive {
|
|
||||||
opacity: 0.7;
|
|
||||||
border-color: var(--ant-color-error-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-summary-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-summary-labels {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 6px;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-summary-used {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--ant-color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-summary-sep {
|
|
||||||
color: var(--ant-color-text-quaternary);
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-summary-total {
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--ant-color-text-secondary);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-summary-chips {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-summary-chips .ant-tag {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-summary-bar.ant-progress {
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-summary-bar .ant-progress-outer {
|
|
||||||
padding-inline-end: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-summary-bar .ant-progress-inner {
|
|
||||||
background: var(--ant-color-fill-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-summary-foot {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ant-color-text-tertiary);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
min-height: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-summary-remained::before {
|
|
||||||
content: '';
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-summary-pct {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--ant-color-text-secondary);
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Progress, Tag } from 'antd';
|
|
||||||
import { ClockCircleOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
|
||||||
|
|
||||||
import './SubUsageSummary.css';
|
|
||||||
|
|
||||||
interface SubUsageSummaryProps {
|
|
||||||
usedByte: number;
|
|
||||||
totalByte: number;
|
|
||||||
usedLabel: string;
|
|
||||||
totalLabel: string;
|
|
||||||
remainedLabel: string;
|
|
||||||
expireMs: number;
|
|
||||||
isActive: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickStrokeColor(pct: number): { from: string; to: string } {
|
|
||||||
if (pct >= 90) return { from: '#ff7875', to: '#ff4d4f' };
|
|
||||||
if (pct >= 75) return { from: '#ffc53d', to: '#fa8c16' };
|
|
||||||
return { from: '#5fc983', to: '#36b37e' };
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatExpiryChip(expireMs: number): { label: string; color: string } | null {
|
|
||||||
if (expireMs <= 0) return null;
|
|
||||||
const diff = expireMs - Date.now();
|
|
||||||
if (diff <= 0) return { label: 'Expired', color: 'red' };
|
|
||||||
const days = Math.floor(diff / 86400000);
|
|
||||||
if (days >= 1) return { label: `${days}d`, color: days <= 3 ? 'orange' : 'blue' };
|
|
||||||
const hours = Math.max(1, Math.floor(diff / 3600000));
|
|
||||||
return { label: `${hours}h`, color: 'orange' };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SubUsageSummary({
|
|
||||||
usedByte,
|
|
||||||
totalByte,
|
|
||||||
usedLabel,
|
|
||||||
totalLabel,
|
|
||||||
remainedLabel,
|
|
||||||
expireMs,
|
|
||||||
isActive,
|
|
||||||
}: SubUsageSummaryProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const pct = useMemo(() => {
|
|
||||||
if (totalByte <= 0) return 0;
|
|
||||||
const v = (usedByte / totalByte) * 100;
|
|
||||||
if (!Number.isFinite(v)) return 0;
|
|
||||||
return Math.max(0, Math.min(100, v));
|
|
||||||
}, [usedByte, totalByte]);
|
|
||||||
|
|
||||||
const expiry = formatExpiryChip(expireMs);
|
|
||||||
const isUnlimited = totalByte <= 0;
|
|
||||||
const stroke = pickStrokeColor(pct);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`usage-summary ${!isActive ? 'is-inactive' : ''}`}>
|
|
||||||
<div className="usage-summary-head">
|
|
||||||
<div className="usage-summary-labels">
|
|
||||||
<span className="usage-summary-used">{usedLabel}</span>
|
|
||||||
<span className="usage-summary-sep">/</span>
|
|
||||||
<span className="usage-summary-total">{isUnlimited ? '∞' : totalLabel}</span>
|
|
||||||
</div>
|
|
||||||
<div className="usage-summary-chips">
|
|
||||||
{isUnlimited && (
|
|
||||||
<Tag color="purple" icon={<ThunderboltOutlined />}>
|
|
||||||
{t('subscription.unlimited')}
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
{expiry && (
|
|
||||||
<Tag color={expiry.color} icon={<ClockCircleOutlined />}>
|
|
||||||
{expiry.label}
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{!isUnlimited && (
|
|
||||||
<Progress
|
|
||||||
percent={pct}
|
|
||||||
showInfo={false}
|
|
||||||
strokeColor={{ '0%': stroke.from, '100%': stroke.to }}
|
|
||||||
railColor="var(--ant-color-fill-secondary)"
|
|
||||||
strokeWidth={10}
|
|
||||||
className="usage-summary-bar"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="usage-summary-foot">
|
|
||||||
{!isUnlimited && (
|
|
||||||
<>
|
|
||||||
<span className="usage-summary-remained">{remainedLabel}</span>
|
|
||||||
<span className="usage-summary-pct">{pct.toFixed(1)}%</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Subscription page app icons
|
||||||
|
|
||||||
|
## Line icons — Arcticons (CC BY-SA 4.0)
|
||||||
|
|
||||||
|
`happ.svg`, `sing-box.svg`, `v2rayng.svg` and `v2raytun.svg` come from
|
||||||
|
[Arcticons](https://github.com/Arcticons-Team/Arcticons) by Donnnno and the
|
||||||
|
Arcticons contributors (as listed on svgicons.com), licensed under
|
||||||
|
[CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/).
|
||||||
|
|
||||||
|
Changes from the originals: the stroke colour is `currentColor` so the page can
|
||||||
|
tint them to its theme, the stroke width is raised from 1 to 2 so they stay
|
||||||
|
legible at tile size, and the style classes and ids were removed. These modified
|
||||||
|
files are distributed under CC BY-SA 4.0 as well.
|
||||||
|
|
||||||
|
## App icons
|
||||||
|
|
||||||
|
The remaining files are each app's own icon, downscaled to 96 px, used only to
|
||||||
|
identify the app a button opens. They remain the trademarks of their owners.
|
||||||
|
|
||||||
|
| File | Source |
|
||||||
|
| ------------------- | ------------------------------------------------------------------------- |
|
||||||
|
| `shadowrocket.webp` | App Store listing [id932747118](https://apps.apple.com/app/id932747118) |
|
||||||
|
| `streisand.webp` | App Store listing [id6450534064](https://apps.apple.com/app/id6450534064) |
|
||||||
|
| `v2box.webp` | App Store listing [id6446814690](https://apps.apple.com/app/id6446814690) |
|
||||||
|
| `incy.webp` | App Store listing [id6756943388](https://apps.apple.com/app/id6756943388) |
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><polyline points="11.9387 18.636 11.2805 19.2322 13.2164 6.3148 21.6618 6.3148 21.082 10.1846"/><polyline points="20.2585 28.1999 18.113 42.5 9.6674 42.5 10.3198 38.1088"/><polyline points="28.1728 36.5585 27.2773 42.5 35.7228 42.5 37.9603 27.5876 36.0158 29.2999"/><polyline points="21.0144 27.3598 27.8555 27.3598 26.2122 38.3731 36.0158 29.2999 39.1998 8.0038 29.2593 17.99 28.8029 21.0714 27.2773 21.0714 26.4784 21.8658"/><polygon points="26.4784 21.8658 20.2243 28.1543 18.9689 28.1543 18.752 29.6379 8.8002 39.6355 11.9387 18.636 21.7423 9.5743 19.9047 21.8658 26.4784 21.8658"/><polyline points="38.0794 9.1294 38.6261 5.5 30.1808 5.5 28.1701 18.9112 29.2593 17.99"/></svg>
|
||||||
|
After Width: | Height: | Size: 840 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M40.7241,15.6976l-15.4896-10.8095c-.7416-.5175-1.7273-.5175-2.4689,0L7.2759,15.6976c-.9141.6379-.9141,1.9909,0,2.6288l15.4896,10.8095c.7416.5175,1.7273.5175,2.4689,0l15.4896-10.8095c.9141-.6379.9141-1.9909,0-2.6288Z"/><path d="M41.4096,17.012v13.976c0,.4977-.2285.9954-.6855,1.3144l-15.4896,10.8095c-.7416.5175-1.7273.5175-2.4689,0l-15.4896-10.8095c-.457-.3189-.6855-.8167-.6855-1.3144h0s0-13.976,0-13.976"/><line x1="24" y1="29.524" x2="24" y2="43.5"/><path d="M11.8734,12.4893l18.3951,13.1335v3.8047c0,.6869.7673,1.0951,1.3369.7111l4.3991-2.9651c.2961-.1996.4735-.5332.4735-.8903v-4.9939l-18.3951-13.1335"/></svg>
|
||||||
|
After Width: | Height: | Size: 783 B |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m9.6185,41.4866V10.6142h-4.1185v-4.0722h10.2641v14.3936c.6957-.6404,1.2872-1.1794,1.8728-1.7248,3.5152-3.2745,7.0291-6.5504,10.5432-9.826.9416-.8777,1.8767-1.7624,2.8301-2.6271.1437-.1303.3712-.2375.5603-.2382,3.5668-.0136,10.9295,0,10.9295,0-10.9555,11.6366-21.8724,23.2736-32.8815,34.9672Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 468 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><rect x="5.5" y="5.5" width="37" height="37" rx="4" ry="4"/><polyline points="28.9436 11.2023 20.4652 36.7977 11.9867 11.2023"/><path d="M28.3091,29.0207c0-2.3774,2.1537-4.2518,4.6165-3.7785,1.6155.3105,2.9055,1.7076,3.0663,3.3447.1195,1.2178-.2658,2.4194-1.1069,3.1576-1.5583,1.3675-6.5759,5.053-6.5759,5.053h7.7042"/></svg>
|
||||||
|
After Width: | Height: | Size: 484 B |
@@ -0,0 +1,20 @@
|
|||||||
|
import happ from './app-icons/happ.svg';
|
||||||
|
import incy from './app-icons/incy.webp';
|
||||||
|
import shadowrocket from './app-icons/shadowrocket.webp';
|
||||||
|
import singBox from './app-icons/sing-box.svg';
|
||||||
|
import streisand from './app-icons/streisand.webp';
|
||||||
|
import v2box from './app-icons/v2box.webp';
|
||||||
|
import v2rayng from './app-icons/v2rayng.svg';
|
||||||
|
import v2raytun from './app-icons/v2raytun.svg';
|
||||||
|
|
||||||
|
// Tinted entries are Arcticons line art drawn in the theme colour; the rest are full-colour app icons.
|
||||||
|
export const APP_ICONS: Record<string, { src: string; tinted: boolean }> = {
|
||||||
|
V2Box: { src: v2box, tinted: false },
|
||||||
|
V2RayNG: { src: v2rayng, tinted: true },
|
||||||
|
'Sing-box': { src: singBox, tinted: true },
|
||||||
|
V2RayTun: { src: v2raytun, tinted: true },
|
||||||
|
Happ: { src: happ, tinted: true },
|
||||||
|
Incy: { src: incy, tinted: false },
|
||||||
|
Shadowrocket: { src: shadowrocket, tinted: false },
|
||||||
|
Streisand: { src: streisand, tinted: false },
|
||||||
|
};
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
|
export type SubStatus = 'active' | 'unlimited' | 'expired' | 'depleted' | 'disabled';
|
||||||
|
|
||||||
|
export interface SubUsage {
|
||||||
|
enabled: boolean;
|
||||||
|
usedByte: number;
|
||||||
|
totalByte: number;
|
||||||
|
expireMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSubStatus(sub: SubUsage, now: number): SubStatus {
|
||||||
|
if (!sub.enabled) return 'disabled';
|
||||||
|
if (sub.expireMs > 0 && now >= sub.expireMs) return 'expired';
|
||||||
|
if (sub.totalByte > 0 && sub.usedByte >= sub.totalByte) return 'depleted';
|
||||||
|
if (sub.totalByte <= 0 && sub.expireMs === 0) return 'unlimited';
|
||||||
|
return 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function daysUntil(expireMs: number, now: number): number | null {
|
||||||
|
if (expireMs <= 0) return null;
|
||||||
|
return Math.max(0, Math.ceil((expireMs - now) / DAY_MS));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usagePercent(usedByte: number, totalByte: number): number {
|
||||||
|
if (totalByte <= 0) return 0;
|
||||||
|
const pct = (usedByte / totalByte) * 100;
|
||||||
|
return Number.isFinite(pct) ? Math.min(100, Math.max(0, pct)) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppPlatform = 'android' | 'ios';
|
||||||
|
|
||||||
|
export function detectPlatform(userAgent: string): AppPlatform {
|
||||||
|
// iPadOS sends a Macintosh UA, and App Store clients also run on Apple-silicon Macs.
|
||||||
|
if (/iphone|ipad|ipod|macintosh/i.test(userAgent)) return 'ios';
|
||||||
|
return 'android';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubApp {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubAppSource {
|
||||||
|
subUrl: string;
|
||||||
|
sId: string;
|
||||||
|
subTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSubApps({
|
||||||
|
subUrl,
|
||||||
|
sId,
|
||||||
|
subTitle,
|
||||||
|
}: SubAppSource): Record<AppPlatform, SubApp[]> {
|
||||||
|
const encSub = encodeURIComponent(subUrl);
|
||||||
|
const profileName = encodeURIComponent(subTitle || sId);
|
||||||
|
|
||||||
|
const v2box = {
|
||||||
|
name: 'V2Box',
|
||||||
|
url: `v2box://install-sub?url=${encSub}&name=${encodeURIComponent(sId)}`,
|
||||||
|
};
|
||||||
|
const singBox = {
|
||||||
|
name: 'Sing-box',
|
||||||
|
url: `sing-box://import-remote-profile?url=${encSub}#${profileName}`,
|
||||||
|
};
|
||||||
|
const v2raytun = { name: 'V2RayTun', url: `v2raytun://import/${subUrl}` };
|
||||||
|
const happ = { name: 'Happ', url: `happ://add/${subUrl}` };
|
||||||
|
const incy = { name: 'Incy', url: `incy://add/${subUrl}` };
|
||||||
|
const rocketSource = `${subUrl}${subUrl.includes('?') ? '&' : '?'}flag=shadowrocket`;
|
||||||
|
const rocketRemark = encodeURIComponent(subTitle || sId || 'Subscription');
|
||||||
|
|
||||||
|
return {
|
||||||
|
android: [
|
||||||
|
v2box,
|
||||||
|
{ name: 'V2RayNG', url: `v2rayng://install-config?url=${encSub}` },
|
||||||
|
singBox,
|
||||||
|
v2raytun,
|
||||||
|
happ,
|
||||||
|
incy,
|
||||||
|
],
|
||||||
|
ios: [
|
||||||
|
{
|
||||||
|
name: 'Shadowrocket',
|
||||||
|
url: `shadowrocket://add/sub://${btoa(rocketSource)}?remark=${rocketRemark}`,
|
||||||
|
},
|
||||||
|
v2box,
|
||||||
|
{ name: 'Streisand', url: `streisand://import/${encSub}` },
|
||||||
|
v2raytun,
|
||||||
|
happ,
|
||||||
|
incy,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ const port = z.number().int().min(1).max(65535);
|
|||||||
const nonNegativeInt = z.number().int().min(0);
|
const nonNegativeInt = z.number().int().min(0);
|
||||||
const absolutePath = z.string().regex(/^\//, 'pages.settings.validation.pathLeadingSlash');
|
const absolutePath = z.string().regex(/^\//, 'pages.settings.validation.pathLeadingSlash');
|
||||||
|
|
||||||
|
export const SubProfileModeSchema = z.enum(['none', 'builtin', 'custom']);
|
||||||
|
export type SubProfileMode = z.infer<typeof SubProfileModeSchema>;
|
||||||
|
|
||||||
export const AllSettingSchema = z
|
export const AllSettingSchema = z
|
||||||
.object({
|
.object({
|
||||||
webListen: z.string().optional(),
|
webListen: z.string().optional(),
|
||||||
@@ -50,6 +53,7 @@ export const AllSettingSchema = z
|
|||||||
subClashUserAgentRegex: z.string().max(2048).optional(),
|
subClashUserAgentRegex: z.string().max(2048).optional(),
|
||||||
subTitle: z.string().optional(),
|
subTitle: z.string().optional(),
|
||||||
subSupportUrl: z.string().optional(),
|
subSupportUrl: z.string().optional(),
|
||||||
|
subProfileMode: SubProfileModeSchema.optional(),
|
||||||
subProfileUrl: z.string().optional(),
|
subProfileUrl: z.string().optional(),
|
||||||
subAnnounce: z.string().optional(),
|
subAnnounce: z.string().optional(),
|
||||||
subEnableRouting: z.boolean().optional(),
|
subEnableRouting: z.boolean().optional(),
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { keys } from '@/api/queryKeys';
|
||||||
|
import { useInbounds } from '@/pages/inbounds/useInbounds';
|
||||||
|
|
||||||
|
import { makeTestQueryClient } from './test-utils';
|
||||||
|
|
||||||
|
function seedInbounds() {
|
||||||
|
const rows = [1, 2].map((id) => ({
|
||||||
|
id,
|
||||||
|
protocol: 'vless',
|
||||||
|
tag: `in-${id}`,
|
||||||
|
enable: true,
|
||||||
|
up: 10,
|
||||||
|
down: 20,
|
||||||
|
total: 0,
|
||||||
|
expiryTime: 0,
|
||||||
|
settings: JSON.stringify({ clients: [{ email: `c${id}@x`, enable: true }] }),
|
||||||
|
clientStats: [
|
||||||
|
{ email: `c${id}@x`, up: 1, down: 2, total: 0, expiryTime: 0, enable: true, inboundId: id },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
const queryClient = makeTestQueryClient();
|
||||||
|
queryClient.setQueryData(keys.inbounds.slim(), rows);
|
||||||
|
queryClient.setQueryData(keys.clients.onlines(), []);
|
||||||
|
queryClient.setQueryData(keys.clients.onlinesByGuid(), {});
|
||||||
|
queryClient.setQueryData(keys.clients.activeInbounds(), {});
|
||||||
|
queryClient.setQueryData(keys.clients.lastOnline(), {});
|
||||||
|
queryClient.setQueryData(keys.settings.defaults(), {});
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
return { rows, wrapper };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderInbounds() {
|
||||||
|
const { rows, wrapper } = seedInbounds();
|
||||||
|
const hook = renderHook(() => useInbounds(), { wrapper });
|
||||||
|
await waitFor(() => expect(hook.result.current.dbInbounds).toHaveLength(2));
|
||||||
|
return { rows, result: hook.result };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every client_stats push carries all inbounds' totals, so rebuilding a row whether or
|
||||||
|
// not its numbers moved re-ran the client rollup and the whole table on each push.
|
||||||
|
describe('inbound websocket merges keep unchanged state', () => {
|
||||||
|
it('keeps rows and the client rollup when a client_stats push changes nothing', async () => {
|
||||||
|
const { rows, result } = await renderInbounds();
|
||||||
|
const before = result.current.dbInbounds;
|
||||||
|
const rollup = result.current.clientCount;
|
||||||
|
|
||||||
|
act(() =>
|
||||||
|
result.current.applyClientStatsEvent({
|
||||||
|
inbounds: rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
up: r.up,
|
||||||
|
down: r.down,
|
||||||
|
total: r.total,
|
||||||
|
enable: r.enable,
|
||||||
|
})),
|
||||||
|
clients: [{ email: 'c1@x', up: 1, down: 2, total: 0, expiryTime: 0, enable: true }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.dbInbounds).toBe(before);
|
||||||
|
expect(result.current.clientCount).toBe(rollup);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still rebuilds exactly the rows whose numbers moved', async () => {
|
||||||
|
const { result } = await renderInbounds();
|
||||||
|
const before = result.current.dbInbounds;
|
||||||
|
|
||||||
|
act(() =>
|
||||||
|
result.current.applyClientStatsEvent({
|
||||||
|
inbounds: [
|
||||||
|
{ id: 1, up: 99, down: 20, total: 0, enable: true },
|
||||||
|
{ id: 2, up: 10, down: 20, total: 0, enable: true },
|
||||||
|
],
|
||||||
|
clients: [{ email: 'c2@x', up: 5, down: 2, total: 0, expiryTime: 0, enable: true }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [first, second] = result.current.dbInbounds;
|
||||||
|
expect(first).not.toBe(before[0]);
|
||||||
|
expect(first.up).toBe(99);
|
||||||
|
expect(second).not.toBe(before[1]);
|
||||||
|
expect(second.clientStats?.[0]?.up).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the client rollup when a traffic push repeats the same online sets', async () => {
|
||||||
|
const { result } = await renderInbounds();
|
||||||
|
const push = () =>
|
||||||
|
result.current.applyTrafficEvent({
|
||||||
|
onlineClients: ['c1@x'],
|
||||||
|
onlineByGuid: { 'node:1': ['c1@x'] },
|
||||||
|
activeInbounds: { 'node:1': ['in-1'] },
|
||||||
|
});
|
||||||
|
act(push);
|
||||||
|
const rollup = result.current.clientCount;
|
||||||
|
|
||||||
|
act(push);
|
||||||
|
|
||||||
|
expect(result.current.clientCount).toBe(rollup);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { ThemeProvider } from '@/hooks/useTheme';
|
||||||
|
import NodeList from '@/pages/nodes/NodeList';
|
||||||
|
import type { NodeRecord } from '@/schemas/node';
|
||||||
|
|
||||||
|
import { makeTestQueryClient } from './test-utils';
|
||||||
|
|
||||||
|
const updateChecks = vi.hoisted(() => ({ count: 0 }));
|
||||||
|
|
||||||
|
vi.mock('@/lib/panel-version', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/lib/panel-version')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
isPanelUpdateAvailable: (...args: Parameters<typeof actual.isPanelUpdateAvailable>) => {
|
||||||
|
updateChecks.count++;
|
||||||
|
return actual.isPanelUpdateAvailable(...args);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Every heartbeat push re-rendered all rows, unchanged ones too: the columns and
|
||||||
|
// table props were rebuilt on each render, so every cell re-ran its renderer.
|
||||||
|
describe('NodeList re-render', () => {
|
||||||
|
it('leaves the rows alone when its parent re-renders with the same nodes', () => {
|
||||||
|
const queryClient = makeTestQueryClient();
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ThemeProvider>{children}</ThemeProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
const nodes: NodeRecord[] = [1, 2, 3].map((id) => ({
|
||||||
|
id,
|
||||||
|
name: `node-${id}`,
|
||||||
|
guid: `g${id}`,
|
||||||
|
transitive: false,
|
||||||
|
enable: true,
|
||||||
|
status: 'online',
|
||||||
|
panelVersion: '3.0.0',
|
||||||
|
}));
|
||||||
|
const noop = () => {};
|
||||||
|
const props = {
|
||||||
|
nodes,
|
||||||
|
isMobile: false,
|
||||||
|
latestVersion: '3.0.1',
|
||||||
|
selectedIds: [] as number[],
|
||||||
|
onSelectionChange: noop,
|
||||||
|
onAdd: noop,
|
||||||
|
onMtls: noop,
|
||||||
|
onEdit: noop,
|
||||||
|
onDelete: noop,
|
||||||
|
onProbe: noop,
|
||||||
|
onToggleEnable: noop,
|
||||||
|
onUpdateNode: noop,
|
||||||
|
onUpdateSelected: noop,
|
||||||
|
};
|
||||||
|
const view = render(<NodeList {...props} />, { wrapper });
|
||||||
|
expect(updateChecks.count).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
updateChecks.count = 0;
|
||||||
|
view.rerender(<NodeList {...props} />);
|
||||||
|
|
||||||
|
expect(updateChecks.count).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -336,6 +336,19 @@ describe('outbound-form-adapter: round-trip', () => {
|
|||||||
expect(rules[1]).toEqual({ action: 'return', qType: 28, domain: ['blocked.com'], rCode: 3 });
|
expect(rules[1]).toEqual({ action: 'return', qType: 28, domain: ['blocked.com'], rCode: 3 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('dns rules keep qType 0 a string, since the core reads a numeric 0 as every query', () => {
|
||||||
|
const back = formValuesToWirePayload(
|
||||||
|
rawOutboundToFormValues({
|
||||||
|
protocol: 'dns',
|
||||||
|
settings: { rules: [{ action: 'drop', qType: 0 }] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const rules = (back.settings as Record<string, unknown>).rules as Array<
|
||||||
|
Record<string, unknown>
|
||||||
|
>;
|
||||||
|
expect(rules[0]).toEqual({ action: 'drop', qType: '0' });
|
||||||
|
});
|
||||||
|
|
||||||
it('dns rules read the legacy qtype wire key for back-compat', () => {
|
it('dns rules read the legacy qtype wire key for back-compat', () => {
|
||||||
const wire = {
|
const wire = {
|
||||||
protocol: 'dns',
|
protocol: 'dns',
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ describe('isPanelUpdateAvailable', () => {
|
|||||||
expect(isPanelUpdateAvailable('nightly-2', 'nightly-1')).toBe(true);
|
expect(isPanelUpdateAvailable('nightly-2', 'nightly-1')).toBe(true);
|
||||||
expect(isPanelUpdateAvailable('nightly-1', 'nightly-1')).toBe(false);
|
expect(isPanelUpdateAvailable('nightly-1', 'nightly-1')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('compares dev builds by commit and never across channels', () => {
|
||||||
|
expect(isPanelUpdateAvailable('dev+1a2b3c4d', 'dev+0f0f0f0f')).toBe(true);
|
||||||
|
expect(isPanelUpdateAvailable('dev+1a2b3c4d', 'dev+1a2b3c4d')).toBe(false);
|
||||||
|
expect(isPanelUpdateAvailable('v3.5.0', 'dev+1a2b3c4d')).toBe(false);
|
||||||
|
expect(isPanelUpdateAvailable('dev+1a2b3c4d', '3.5.0')).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('formatPanelVersion', () => {
|
describe('formatPanelVersion', () => {
|
||||||
|
|||||||
@@ -81,9 +81,9 @@ describe('QrPanel dense AmneziaWG config', () => {
|
|||||||
const completeQr = qrGeometry(complete);
|
const completeQr = qrGeometry(complete);
|
||||||
const shorterQr = qrGeometry(withoutDisableCookies);
|
const shorterQr = qrGeometry(withoutDisableCookies);
|
||||||
|
|
||||||
expect(completeQr.viewBox).toBe('0 0 105 105');
|
expect(completeQr.viewBox).toBe('0 0 101 101');
|
||||||
expect(shorterQr.viewBox).toBe('0 0 101 101');
|
expect(shorterQr.viewBox).toBe('0 0 97 97');
|
||||||
expect(completeQr.foreground).toMatch(/^M4 4h7/);
|
expect(completeQr.foreground).toMatch(/^M2 2h7/);
|
||||||
expect(shorterQr.foreground).toMatch(/^M4 4h7/);
|
expect(shorterQr.foreground).toMatch(/^M2 2h7/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { AllSetting } from '@/models/setting';
|
||||||
|
import { AllSettingSchema } from '@/schemas/setting';
|
||||||
|
|
||||||
|
describe('subscription profile mode', () => {
|
||||||
|
it.each([undefined, {}, { subProfileUrl: '' }, { subProfileUrl: ' ' }])(
|
||||||
|
'defaults to no link without a legacy URL: %j',
|
||||||
|
(data) => {
|
||||||
|
expect(new AllSetting(data).subProfileMode).toBe('none');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('preserves a legacy custom URL when its mode is missing', () => {
|
||||||
|
const setting = new AllSetting({ subProfileUrl: 'https://example.com/profile' });
|
||||||
|
expect(setting.subProfileMode).toBe('custom');
|
||||||
|
expect(setting.subProfileUrl).toBe('https://example.com/profile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['none', 'builtin', 'custom'])(
|
||||||
|
'honors the explicit %s mode with a stored URL',
|
||||||
|
(mode) => {
|
||||||
|
const result = AllSettingSchema.safeParse({
|
||||||
|
subProfileMode: mode,
|
||||||
|
subProfileUrl: 'https://example.com/profile',
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (!result.success) return;
|
||||||
|
expect(new AllSetting(result.data).subProfileMode).toBe(mode);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(['auto', '', null, true])('rejects an invalid mode: %j', (mode) => {
|
||||||
|
expect(AllSettingSchema.safeParse({ subProfileMode: mode }).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { render } from '@testing-library/react';
|
import { fireEvent, render } from '@testing-library/react';
|
||||||
import { afterEach, expect, test } from 'vitest';
|
import { afterEach, expect, test } from 'vitest';
|
||||||
|
|
||||||
import { withTheme } from '../../.storybook/preview';
|
import { withTheme } from '../../.storybook/preview';
|
||||||
import { ThemeProvider } from '@/hooks/useTheme';
|
import { ThemeProvider, useTheme } from '@/hooks/useTheme';
|
||||||
|
|
||||||
function Story() {
|
function Story() {
|
||||||
return <div>Story</div>;
|
return <div>Story</div>;
|
||||||
@@ -14,9 +14,37 @@ function StorybookTheme({ theme }: { theme: 'light' | 'dark' }) {
|
|||||||
> as Parameters<typeof withTheme>[1]);
|
> as Parameters<typeof withTheme>[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ThemeToggle() {
|
||||||
|
const { toggleTheme } = useTheme();
|
||||||
|
return <button onClick={toggleTheme}>toggle</button>;
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.className = '';
|
document.body.className = '';
|
||||||
document.documentElement.removeAttribute('data-theme');
|
document.documentElement.removeAttribute('data-theme');
|
||||||
|
document.documentElement.style.colorScheme = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Without color-scheme the browser paints native scrollbars light inside dark
|
||||||
|
// modals, e.g. the Edit Client body.
|
||||||
|
test('native scrollbars follow the panel theme', () => {
|
||||||
|
const { getByRole } = render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<ThemeToggle />
|
||||||
|
</ThemeProvider>,
|
||||||
|
);
|
||||||
|
expect(document.documentElement.style.colorScheme).toBe('dark');
|
||||||
|
|
||||||
|
fireEvent.click(getByRole('button'));
|
||||||
|
expect(document.documentElement.style.colorScheme).toBe('light');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('native scrollbars follow the Storybook theme', () => {
|
||||||
|
const { rerender } = render(<StorybookTheme theme="light" />);
|
||||||
|
expect(document.documentElement.style.colorScheme).toBe('light');
|
||||||
|
|
||||||
|
rerender(<StorybookTheme theme="dark" />);
|
||||||
|
expect(document.documentElement.style.colorScheme).toBe('dark');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('preserves unrelated body classes when applying the Storybook theme', () => {
|
test('preserves unrelated body classes when applying the Storybook theme', () => {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { APP_ICONS } from '@/pages/sub/appIcons';
|
||||||
|
import { buildSubApps } from '@/pages/sub/subPageModel';
|
||||||
|
|
||||||
|
describe('APP_ICONS', () => {
|
||||||
|
it('has an icon for every app the subscription page offers on every platform', () => {
|
||||||
|
const apps = buildSubApps({
|
||||||
|
subUrl: 'https://sub.example.com/sub/abc',
|
||||||
|
sId: 'abc',
|
||||||
|
subTitle: '',
|
||||||
|
});
|
||||||
|
const names = [...new Set(Object.values(apps).flatMap((list) => list.map((app) => app.name)))];
|
||||||
|
|
||||||
|
expect(names.filter((name) => !APP_ICONS[name]?.src)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries no icon for an app the page no longer offers', () => {
|
||||||
|
const apps = buildSubApps({
|
||||||
|
subUrl: 'https://sub.example.com/sub/abc',
|
||||||
|
sId: 'abc',
|
||||||
|
subTitle: '',
|
||||||
|
});
|
||||||
|
const offered = new Set(Object.values(apps).flatMap((list) => list.map((app) => app.name)));
|
||||||
|
|
||||||
|
expect(Object.keys(APP_ICONS).filter((name) => !offered.has(name))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildSubApps,
|
||||||
|
daysUntil,
|
||||||
|
detectPlatform,
|
||||||
|
resolveSubStatus,
|
||||||
|
usagePercent,
|
||||||
|
} from '@/pages/sub/subPageModel';
|
||||||
|
|
||||||
|
const DAY = 86_400_000;
|
||||||
|
const NOW = Date.UTC(2026, 8, 15, 12, 0, 0);
|
||||||
|
|
||||||
|
describe('resolveSubStatus', () => {
|
||||||
|
const base = { enabled: true, usedByte: 10, totalByte: 100, expireMs: NOW + DAY };
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
'disabled wins over expiry and quota',
|
||||||
|
{ ...base, enabled: false, expireMs: NOW - DAY },
|
||||||
|
'disabled',
|
||||||
|
],
|
||||||
|
['expired at the expiry instant', { ...base, expireMs: NOW }, 'expired'],
|
||||||
|
['expired beats depleted', { ...base, usedByte: 100, expireMs: NOW - DAY }, 'expired'],
|
||||||
|
['depleted once usage reaches the quota', { ...base, usedByte: 100 }, 'depleted'],
|
||||||
|
[
|
||||||
|
'unlimited with neither quota nor expiry',
|
||||||
|
{ ...base, totalByte: 0, expireMs: 0 },
|
||||||
|
'unlimited',
|
||||||
|
],
|
||||||
|
['active without quota but with a future expiry', { ...base, totalByte: 0 }, 'active'],
|
||||||
|
['active inside quota and before expiry', base, 'active'],
|
||||||
|
] as const)('%s', (_name, input, want) => {
|
||||||
|
expect(resolveSubStatus(input, NOW)).toBe(want);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('daysUntil', () => {
|
||||||
|
it('is null for a subscription that never expires', () => {
|
||||||
|
expect(daysUntil(0, NOW)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rounds the last partial day up to 1', () => {
|
||||||
|
expect(daysUntil(NOW + 3 * 3_600_000, NOW)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts whole days', () => {
|
||||||
|
expect(daysUntil(NOW + 23 * DAY, NOW)).toBe(23);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays at 0 after expiry', () => {
|
||||||
|
expect(daysUntil(NOW - DAY, NOW)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('usagePercent', () => {
|
||||||
|
it('is 0 without a quota instead of NaN or Infinity', () => {
|
||||||
|
expect(usagePercent(5_000, 0)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps an over-quota client to 100', () => {
|
||||||
|
expect(usagePercent(150, 100)).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detectPlatform', () => {
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/128.0 Mobile Safari/537.36',
|
||||||
|
'android',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148',
|
||||||
|
'ios',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/17.5 Safari/605.1.15',
|
||||||
|
'ios',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/128.0 Safari/537.36',
|
||||||
|
'android',
|
||||||
|
],
|
||||||
|
['Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/128.0 Safari/537.36', 'android'],
|
||||||
|
])('%s -> %s', (ua, want) => {
|
||||||
|
expect(detectPlatform(ua)).toBe(want);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildSubApps', () => {
|
||||||
|
const subUrl = 'https://sub.example.com/sub/abc';
|
||||||
|
const encSub = encodeURIComponent(subUrl);
|
||||||
|
const sub = { subUrl, sId: 'abc', subTitle: 'Nova Net' };
|
||||||
|
|
||||||
|
it('offers Android and iOS app lists only', () => {
|
||||||
|
expect(Object.keys(buildSubApps(sub))).toEqual(['android', 'ios']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives every Android app a one-tap import link', () => {
|
||||||
|
expect(buildSubApps(sub).android).toEqual([
|
||||||
|
{ name: 'V2Box', url: `v2box://install-sub?url=${encSub}&name=abc` },
|
||||||
|
{ name: 'V2RayNG', url: `v2rayng://install-config?url=${encSub}` },
|
||||||
|
{ name: 'Sing-box', url: `sing-box://import-remote-profile?url=${encSub}#Nova%20Net` },
|
||||||
|
{ name: 'V2RayTun', url: `v2raytun://import/${subUrl}` },
|
||||||
|
{ name: 'Happ', url: `happ://add/${subUrl}` },
|
||||||
|
{ name: 'Incy', url: `incy://add/${subUrl}` },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives every iOS app a one-tap import link', () => {
|
||||||
|
const rocket = Buffer.from(`${subUrl}?flag=shadowrocket`).toString('base64');
|
||||||
|
expect(buildSubApps(sub).ios).toEqual([
|
||||||
|
{ name: 'Shadowrocket', url: `shadowrocket://add/sub://${rocket}?remark=Nova%20Net` },
|
||||||
|
{ name: 'V2Box', url: `v2box://install-sub?url=${encSub}&name=abc` },
|
||||||
|
{ name: 'Streisand', url: `streisand://import/${encSub}` },
|
||||||
|
{ name: 'V2RayTun', url: `v2raytun://import/${subUrl}` },
|
||||||
|
{ name: 'Happ', url: `happ://add/${subUrl}` },
|
||||||
|
{ name: 'Incy', url: `incy://add/${subUrl}` },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the sing-box profile after the subscription id when there is no title', () => {
|
||||||
|
expect(buildSubApps({ ...sub, subTitle: '' }).android[2].url).toBe(
|
||||||
|
`sing-box://import-remote-profile?url=${encSub}#abc`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends flag=shadowrocket with & when the subscription URL already has a query', () => {
|
||||||
|
const withQuery = { ...sub, subUrl: `${subUrl}?token=1` };
|
||||||
|
const rocket = Buffer.from(`${subUrl}?token=1&flag=shadowrocket`).toString('base64');
|
||||||
|
expect(buildSubApps(withQuery).ios[0].url).toBe(
|
||||||
|
`shadowrocket://add/sub://${rocket}?remark=Nova%20Net`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,29 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { fireEvent, screen } from '@testing-library/react';
|
import { fireEvent, screen } from '@testing-library/react';
|
||||||
import { MemoryRouter, useLocation } from 'react-router';
|
import { MemoryRouter, useLocation } from 'react-router';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { AllSetting } from '@/models/setting';
|
import { AllSetting } from '@/models/setting';
|
||||||
import SubscriptionGeneralTab from '@/pages/settings/SubscriptionGeneralTab';
|
import SubscriptionGeneralTab from '@/pages/settings/SubscriptionGeneralTab';
|
||||||
import { renderWithProviders } from './test-utils';
|
import { chooseSelectOption, renderWithProviders } from './test-utils';
|
||||||
|
|
||||||
|
function ProfileSettingsHarness({ initial }: { initial?: unknown }) {
|
||||||
|
const [allSetting, setAllSetting] = useState(() => new AllSetting(initial));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SubscriptionGeneralTab
|
||||||
|
allSetting={allSetting}
|
||||||
|
updateSetting={(patch) =>
|
||||||
|
setAllSetting((current) => new AllSetting({ ...current, ...patch }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<output data-testid="profile-settings">
|
||||||
|
{allSetting.subProfileMode}|{allSetting.subProfileUrl}
|
||||||
|
</output>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function LocationProbe() {
|
function LocationProbe() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -18,6 +37,60 @@ function LocationProbe() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('SubscriptionGeneralTab', () => {
|
describe('SubscriptionGeneralTab', () => {
|
||||||
|
it('switches profile modes without losing the custom URL and warns only for the built-in page', () => {
|
||||||
|
const storedUrl = 'https://example.com/profile/{{SUB_ID}}';
|
||||||
|
const editedUrl = 'https://example.com/account/{{SUB_ID}}';
|
||||||
|
const warning =
|
||||||
|
'This page exposes subscription URLs and node configurations, including for Happ encrypted subscriptions.';
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<MemoryRouter initialEntries={['/settings#subscription']}>
|
||||||
|
<ProfileSettingsHarness initial={{ subProfileMode: 'none', subProfileUrl: storedUrl }} />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: /Profile/ }));
|
||||||
|
expect(screen.getByRole('combobox', { name: 'Profile page' })).toBeTruthy();
|
||||||
|
expect(screen.getByTestId('profile-settings').textContent).toBe(`none|${storedUrl}`);
|
||||||
|
expect(screen.queryByDisplayValue(storedUrl)).toBeNull();
|
||||||
|
expect(screen.queryByText(warning)).toBeNull();
|
||||||
|
|
||||||
|
chooseSelectOption('sub-profile-mode', 'Built-in subscription page');
|
||||||
|
expect(screen.getByTestId('profile-settings').textContent).toBe(`builtin|${storedUrl}`);
|
||||||
|
expect(screen.getByRole('alert').textContent).toContain(warning);
|
||||||
|
expect(screen.queryByDisplayValue(storedUrl)).toBeNull();
|
||||||
|
|
||||||
|
chooseSelectOption('sub-profile-mode', 'Custom website');
|
||||||
|
expect(screen.queryByText(warning)).toBeNull();
|
||||||
|
fireEvent.change(screen.getByDisplayValue(storedUrl), { target: { value: editedUrl } });
|
||||||
|
expect(screen.getByTestId('profile-settings').textContent).toBe(`custom|${editedUrl}`);
|
||||||
|
|
||||||
|
chooseSelectOption('sub-profile-mode', 'No link');
|
||||||
|
expect(screen.getByTestId('profile-settings').textContent).toBe(`none|${editedUrl}`);
|
||||||
|
expect(screen.queryByDisplayValue(editedUrl)).toBeNull();
|
||||||
|
expect(screen.queryByText(warning)).toBeNull();
|
||||||
|
|
||||||
|
chooseSelectOption('sub-profile-mode', 'Custom website');
|
||||||
|
expect(screen.getByTestId('profile-settings').textContent).toBe(`custom|${editedUrl}`);
|
||||||
|
expect(screen.getByDisplayValue(editedUrl)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens a legacy custom profile URL with custom mode selected', () => {
|
||||||
|
const storedUrl = 'https://example.com/profile/{{SUB_ID}}';
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<MemoryRouter initialEntries={['/settings#subscription']}>
|
||||||
|
<ProfileSettingsHarness initial={{ subProfileUrl: storedUrl }} />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: /Profile/ }));
|
||||||
|
expect(screen.getByRole('combobox', { name: 'Profile page' })).toBeTruthy();
|
||||||
|
expect(screen.getByText('Custom website')).toBeTruthy();
|
||||||
|
expect(screen.getByDisplayValue(storedUrl)).toBeTruthy();
|
||||||
|
expect(screen.getByTestId('profile-settings').textContent).toBe(`custom|${storedUrl}`);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps the stored subscription port when the field is cleared', () => {
|
it('keeps the stored subscription port when the field is cleared', () => {
|
||||||
const updateSetting = vi.fn();
|
const updateSetting = vi.fn();
|
||||||
|
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ func IsAmneziaWGOutbound(raw []byte) bool {
|
|||||||
if err := json.Unmarshal(raw, &probe); err != nil {
|
if err := json.Unmarshal(raw, &probe); err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return probe.Protocol == "amneziawg"
|
return strings.EqualFold(probe.Protocol, "amneziawg")
|
||||||
}
|
}
|
||||||
|
|
||||||
// outboundSettingsOf extracts the nested "settings" block from a raw
|
// outboundSettingsOf extracts the nested "settings" block from a raw
|
||||||
|
|||||||
@@ -134,14 +134,11 @@ func desiredPeerTargets(inst amneziawg.Instance) map[string]netip.Addr {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// desiredPortForwardKeys returns the full set of listener keys inst wants
|
// forwardingPeers is the one gate a host listener comes from: no email, port
|
||||||
// right now: one tcpForward and one udpForward key per port in every peer's
|
// spec and resolvable target (see desiredPeerTargets), no socket.
|
||||||
// ForwardedPorts spec, for every peer that also has a resolvable target
|
func forwardingPeers(inst amneziawg.Instance) []amneziawg.Peer {
|
||||||
// (see desiredPeerTargets) -- a key never exists without a target, so
|
|
||||||
// Reconcile can always resolve one for any key it opens.
|
|
||||||
func desiredPortForwardKeys(inst amneziawg.Instance) map[portForwardKey]struct{} {
|
|
||||||
out := map[portForwardKey]struct{}{}
|
|
||||||
targets := desiredPeerTargets(inst)
|
targets := desiredPeerTargets(inst)
|
||||||
|
out := make([]amneziawg.Peer, 0, len(inst.Peers))
|
||||||
for _, p := range inst.Peers {
|
for _, p := range inst.Peers {
|
||||||
if p.Email == "" || p.ForwardedPorts == "" {
|
if p.Email == "" || p.ForwardedPorts == "" {
|
||||||
continue
|
continue
|
||||||
@@ -149,6 +146,16 @@ func desiredPortForwardKeys(inst amneziawg.Instance) map[portForwardKey]struct{}
|
|||||||
if _, ok := targets[p.Email]; !ok {
|
if _, ok := targets[p.Email]; !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// desiredPortForwardKeys returns every listener key inst wants right now: one
|
||||||
|
// tcpForward and one udpForward per forwarded port of the forwarding peers.
|
||||||
|
func desiredPortForwardKeys(inst amneziawg.Instance) map[portForwardKey]struct{} {
|
||||||
|
out := map[portForwardKey]struct{}{}
|
||||||
|
for _, p := range forwardingPeers(inst) {
|
||||||
for _, port := range amneziawg.ExpandForwardedPorts(p.ForwardedPorts) {
|
for _, port := range amneziawg.ExpandForwardedPorts(p.ForwardedPorts) {
|
||||||
out[portForwardKey{email: p.Email, port: port, proto: tcpForward}] = struct{}{}
|
out[portForwardKey{email: p.Email, port: port, proto: tcpForward}] = struct{}{}
|
||||||
out[portForwardKey{email: p.Email, port: port, proto: udpForward}] = struct{}{}
|
out[portForwardKey{email: p.Email, port: port, proto: udpForward}] = struct{}{}
|
||||||
@@ -157,6 +164,19 @@ func desiredPortForwardKeys(inst amneziawg.Instance) map[portForwardKey]struct{}
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ForwardedPortOwner names the peer Reconcile opens a listener on port for --
|
||||||
|
// the same peers and expansion as desiredPortForwardKeys, never a silent one.
|
||||||
|
func ForwardedPortOwner(inst amneziawg.Instance, port int) (string, bool) {
|
||||||
|
for _, p := range forwardingPeers(inst) {
|
||||||
|
for _, candidate := range amneziawg.ExpandForwardedPorts(p.ForwardedPorts) {
|
||||||
|
if candidate == port {
|
||||||
|
return p.Email, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
// Reconcile brings the supervisor's open listeners in line with what inst
|
// Reconcile brings the supervisor's open listeners in line with what inst
|
||||||
// currently wants: closes anything no longer desired, opens anything newly
|
// currently wants: closes anything no longer desired, opens anything newly
|
||||||
// desired, leaves everything else untouched. Never returns an error --
|
// desired, leaves everything else untouched. Never returns an error --
|
||||||
|
|||||||
@@ -11,10 +11,13 @@ import (
|
|||||||
// own Xray SOCKS5 relay inbound (see relay.go/SocksInboundSettings).
|
// own Xray SOCKS5 relay inbound (see relay.go/SocksInboundSettings).
|
||||||
const SOCKSBasePort = 65100
|
const SOCKSBasePort = 65100
|
||||||
|
|
||||||
|
// relayPortSlots is how many ids fit above SOCKSBasePort before wrapping.
|
||||||
|
const relayPortSlots = 65535 - SOCKSBasePort
|
||||||
|
|
||||||
// SOCKSPortForInbound derives one inbound's loopback SOCKS5 relay port from
|
// SOCKSPortForInbound derives one inbound's loopback SOCKS5 relay port from
|
||||||
// its id, so config generation and the dialing relay never need to negotiate.
|
// its id, wrapping ids past relayPortSlots so no id ever lacks a port.
|
||||||
func SOCKSPortForInbound(inboundID int) int {
|
func SOCKSPortForInbound(inboundID int) int {
|
||||||
return SOCKSBasePort + inboundID
|
return SOCKSBasePort + 1 + (inboundID-1)%relayPortSlots
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package amneziawgnet
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// An inbound id past the slot count used to be refused outright, which capped a
|
||||||
|
// database at 435 AmneziaWG inbounds for its whole life (#6537).
|
||||||
|
func TestSOCKSPortForInboundKeepsEverySlotInsideTheWindow(t *testing.T) {
|
||||||
|
t.Run("no id derives a port outside the window", func(t *testing.T) {
|
||||||
|
for _, id := range []int{1, 2, 434, 435, 436, 437, 870, 871, 6537, 70350, 1_000_000} {
|
||||||
|
port := SOCKSPortForInbound(id)
|
||||||
|
if port < SOCKSBasePort+1 || port > 65535 {
|
||||||
|
t.Errorf("id %d derives relay port %d, outside %d..65535", id, port, SOCKSBasePort+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Every id the old formula reached must keep its exact port, or upgrading
|
||||||
|
// moves a running relay. Ids 1..435 also leave SOCKSBasePort itself unused.
|
||||||
|
t.Run("ids up to the slot count keep the port they always had", func(t *testing.T) {
|
||||||
|
for id := 1; id <= 435; id++ {
|
||||||
|
if got, want := SOCKSPortForInbound(id), SOCKSBasePort+id; got != want {
|
||||||
|
t.Errorf("id %d moved from relay port %d to %d", id, want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
3.8.0
|
3.8.5
|
||||||
@@ -1254,7 +1254,7 @@ func runSeeders(isUsersEmpty bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if empty && isUsersEmpty {
|
if empty && isUsersEmpty {
|
||||||
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"}
|
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"}
|
||||||
for _, name := range seeders {
|
for _, name := range seeders {
|
||||||
if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
|
if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1383,6 +1383,12 @@ func runSeeders(isUsersEmpty bool) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(seedersHistory, "DNSOutboundQTypeZeroFix") {
|
||||||
|
if err := migrateDNSOutboundQTypeZero(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !slices.Contains(seedersHistory, "NodeInboundsAdopted") {
|
if !slices.Contains(seedersHistory, "NodeInboundsAdopted") {
|
||||||
if err := seedNodeInboundsAdopted(); err != nil {
|
if err := seedNodeInboundsAdopted(); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1920,9 +1926,10 @@ func legacyDNSOutboundRules(mode string, blockTypes []int) []any {
|
|||||||
return append(rules, fallback)
|
return append(rules, fallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
// dnsQTypeValue keeps a lone qType a number, the way the core marshals one.
|
// dnsQTypeValue keeps a lone qType a number the way the core marshals one, except
|
||||||
|
// 0: the core drops a numeric 0, and a rule with no qTypes matches every query.
|
||||||
func dnsQTypeValue(blockTypes []int) any {
|
func dnsQTypeValue(blockTypes []int) any {
|
||||||
if len(blockTypes) == 1 {
|
if len(blockTypes) == 1 && blockTypes[0] != 0 {
|
||||||
return blockTypes[0]
|
return blockTypes[0]
|
||||||
}
|
}
|
||||||
parts := make([]string, 0, len(blockTypes))
|
parts := make([]string, 0, len(blockTypes))
|
||||||
@@ -1932,6 +1939,72 @@ func dnsQTypeValue(blockTypes []int) any {
|
|||||||
return strings.Join(parts, ",")
|
return strings.Join(parts, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// migrateDNSOutboundQTypeZero repairs the numeric qType 0 that 3.8.0's legacy-keys
|
||||||
|
// seeder stored, which that seeder's own history row keeps it from revisiting.
|
||||||
|
func migrateDNSOutboundQTypeZero() error {
|
||||||
|
var setting model.Setting
|
||||||
|
err := db.Model(model.Setting{}).Where("key = ?", "xrayTemplateConfig").First(&setting).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return db.Create(&model.HistoryOfSeeders{SeederName: "DNSOutboundQTypeZeroFix"}).Error
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, changed, rErr := RewriteDNSOutboundQTypeZero(setting.Value)
|
||||||
|
if rErr != nil {
|
||||||
|
log.Printf("DNSOutboundQTypeZeroFix: skip (invalid xrayTemplateConfig json): %v", rErr)
|
||||||
|
return db.Create(&model.HistoryOfSeeders{SeederName: "DNSOutboundQTypeZeroFix"}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if changed {
|
||||||
|
if err := tx.Model(&model.Setting{}).Where("key = ?", "xrayTemplateConfig").
|
||||||
|
Update("value", updated).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Create(&model.HistoryOfSeeders{SeederName: "DNSOutboundQTypeZeroFix"}).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RewriteDNSOutboundQTypeZero spells a dns rule's numeric qType 0 as "0", the one
|
||||||
|
// form the core reads as query type 0 rather than as every query.
|
||||||
|
func RewriteDNSOutboundQTypeZero(raw string) (string, bool, error) {
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return raw, false, nil
|
||||||
|
}
|
||||||
|
var cfg map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||||
|
return raw, false, err
|
||||||
|
}
|
||||||
|
outbounds, _ := cfg["outbounds"].([]any)
|
||||||
|
changed := false
|
||||||
|
for _, ob := range outbounds {
|
||||||
|
obj, _ := ob.(map[string]any)
|
||||||
|
if proto, _ := obj["protocol"].(string); !strings.EqualFold(proto, "dns") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
settings, _ := obj["settings"].(map[string]any)
|
||||||
|
rules, _ := settings["rules"].([]any)
|
||||||
|
for _, r := range rules {
|
||||||
|
rule, _ := r.(map[string]any)
|
||||||
|
if qType, ok := rule["qType"].(float64); ok && qType == 0 {
|
||||||
|
rule["qType"] = "0"
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return raw, false, nil
|
||||||
|
}
|
||||||
|
out, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return raw, false, err
|
||||||
|
}
|
||||||
|
return string(out), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeSettingPaths() error {
|
func normalizeSettingPaths() error {
|
||||||
pathKeys := []string{"webBasePath", "subPath", "subJsonPath", "subClashPath"}
|
pathKeys := []string{"webBasePath", "subPath", "subJsonPath", "subClashPath"}
|
||||||
var rows []model.Setting
|
var rows []model.Setting
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/xtls/xray-core/infra/conf"
|
||||||
|
"github.com/xtls/xray-core/proxy/dns"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The core drops a lone numeric qType 0 from its PortList, and a rule with no
|
||||||
|
// qTypes matches every query, so blockTypes [0] must not be written that way.
|
||||||
|
func TestRewriteDNSOutboundLegacyKeysKeepsQTypeZeroPolicy(t *testing.T) {
|
||||||
|
for _, mode := range []string{"skip", "reject"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
legacy := `{"protocol":"dns","tag":"dns-out","settings":{"nonIPQuery":"` + mode + `","blockTypes":[0]}}`
|
||||||
|
updated, changed, err := rewriteDNSOutboundLegacyKeys(`{"outbounds":[` + legacy + `]}`)
|
||||||
|
if err != nil || !changed {
|
||||||
|
t.Fatalf("rewrite: changed=%v err=%v", changed, err)
|
||||||
|
}
|
||||||
|
got := coreDNSOutboundPolicy(t, firstTemplateOutbound(t, updated))
|
||||||
|
if want := coreDNSOutboundPolicy(t, []byte(legacy)); !proto.Equal(got, want) {
|
||||||
|
t.Fatalf("rewritten policy = %v, want the legacy policy %v", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Installs that already ran the legacy-keys seeder store the match-all rule, and
|
||||||
|
// that seeder never runs again, so the repair has to reach them on its own.
|
||||||
|
func TestSeedersRepairStoredDNSQTypeZero(t *testing.T) {
|
||||||
|
t.Setenv("XUI_DB_FOLDER", t.TempDir())
|
||||||
|
if err := InitDB(config.GetDBPath()); err != nil {
|
||||||
|
if strings.Contains(err.Error(), "CGO_ENABLED=0") {
|
||||||
|
t.Skipf("sqlite needs cgo: %v", err)
|
||||||
|
}
|
||||||
|
t.Fatalf("init db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = CloseDB() })
|
||||||
|
|
||||||
|
// The legacy-keys seeder matched the protocol id without case, so it wrote both.
|
||||||
|
for _, protocol := range []string{"dns", "DNS"} {
|
||||||
|
t.Run(protocol, func(t *testing.T) {
|
||||||
|
legacy := `{"protocol":"` + protocol + `","tag":"dns-out","settings":{"nonIPQuery":"drop","blockTypes":[0]}}`
|
||||||
|
stored := `{"protocol":"` + protocol + `","tag":"dns-out","settings":{"rules":[{"action":"drop","qType":0},{"action":"hijack","qType":"1,28"},{"action":"drop"}]}}`
|
||||||
|
seedDNSOutboundTemplate(t, `{"outbounds":[`+stored+`]}`)
|
||||||
|
if err := db.Where("seeder_name = ?", "DNSOutboundQTypeZeroFix").
|
||||||
|
Delete(&model.HistoryOfSeeders{}).Error; err != nil {
|
||||||
|
t.Fatalf("clear seeder history: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runSeeders(false); err != nil {
|
||||||
|
t.Fatalf("runSeeders: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := coreDNSOutboundPolicy(t, firstTemplateOutbound(t, storedDNSOutboundTemplate(t)))
|
||||||
|
if want := coreDNSOutboundPolicy(t, []byte(legacy)); !proto.Equal(got, want) {
|
||||||
|
t.Fatalf("repaired policy = %v, want the legacy policy %v", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstTemplateOutbound(t *testing.T, template string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var cfg struct {
|
||||||
|
Outbounds []json.RawMessage `json:"outbounds"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(template), &cfg); err != nil || len(cfg.Outbounds) == 0 {
|
||||||
|
t.Fatalf("template has no outbound (%v): %s", err, template)
|
||||||
|
}
|
||||||
|
return cfg.Outbounds[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func coreDNSOutboundPolicy(t *testing.T, raw []byte) *dns.Config {
|
||||||
|
t.Helper()
|
||||||
|
var outbound conf.OutboundDetourConfig
|
||||||
|
if err := json.Unmarshal(raw, &outbound); err != nil {
|
||||||
|
t.Fatalf("unmarshal outbound: %v", err)
|
||||||
|
}
|
||||||
|
handler, err := outbound.Build()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("core build: %v", err)
|
||||||
|
}
|
||||||
|
instance, err := handler.ProxySettings.GetInstance()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("core settings: %v", err)
|
||||||
|
}
|
||||||
|
cfg, ok := instance.(*dns.Config)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("core settings type = %T, want *dns.Config", instance)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/op/go-logging"
|
"github.com/op/go-logging"
|
||||||
@@ -30,10 +31,13 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// Initialized to a usable default so logging never nil-derefs before InitLogger
|
// InitLogger swaps the handle while other goroutines are logging, so it is
|
||||||
// runs — the "migrate" and "setting" CLI subcommands log without calling it.
|
// published atomically — a plain assignment is an unsafe publication.
|
||||||
logger = logging.MustGetLogger("x-ui")
|
logger atomic.Pointer[logging.Logger]
|
||||||
fileRotate *lumberjack.Logger // nil when file backend disabled
|
|
||||||
|
// fileRotateMu guards fileRotate against a concurrent InitLogger/CloseLogger.
|
||||||
|
fileRotateMu sync.Mutex
|
||||||
|
fileRotate *lumberjack.Logger // nil when file backend disabled
|
||||||
|
|
||||||
// logBuffer maintains recent log entries in memory for web UI retrieval;
|
// logBuffer maintains recent log entries in memory for web UI retrieval;
|
||||||
// logBufferMu guards it — written from many goroutines, read by the web UI.
|
// logBufferMu guards it — written from many goroutines, read by the web UI.
|
||||||
@@ -45,6 +49,12 @@ var (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// A usable default so logging never nil-derefs before InitLogger runs — the
|
||||||
|
// "migrate" and "setting" CLI subcommands log without calling it.
|
||||||
|
func init() {
|
||||||
|
logger.Store(logging.MustGetLogger("x-ui"))
|
||||||
|
}
|
||||||
|
|
||||||
// InitLogger initializes dual logging backends: console/syslog and file.
|
// InitLogger initializes dual logging backends: console/syslog and file.
|
||||||
// Console logging uses the specified level, file logging always uses DEBUG level.
|
// Console logging uses the specified level, file logging always uses DEBUG level.
|
||||||
func InitLogger(level logging.Level) {
|
func InitLogger(level logging.Level) {
|
||||||
@@ -66,7 +76,7 @@ func InitLogger(level logging.Level) {
|
|||||||
|
|
||||||
multiBackend := logging.MultiLogger(backends...)
|
multiBackend := logging.MultiLogger(backends...)
|
||||||
newLogger.SetBackend(multiBackend)
|
newLogger.SetBackend(multiBackend)
|
||||||
logger = newLogger
|
logger.Store(newLogger)
|
||||||
}
|
}
|
||||||
|
|
||||||
// initDefaultBackend creates the console/syslog logging backend.
|
// initDefaultBackend creates the console/syslog logging backend.
|
||||||
@@ -104,7 +114,7 @@ func initFileBackend() logging.Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logPath := filepath.Join(logDir, logFileName)
|
logPath := filepath.Join(logDir, logFileName)
|
||||||
fileRotate = &lumberjack.Logger{
|
rotate := &lumberjack.Logger{
|
||||||
Filename: logPath,
|
Filename: logPath,
|
||||||
MaxSize: maxLogFileMB,
|
MaxSize: maxLogFileMB,
|
||||||
MaxBackups: maxLogBackups,
|
MaxBackups: maxLogBackups,
|
||||||
@@ -112,8 +122,11 @@ func initFileBackend() logging.Backend {
|
|||||||
LocalTime: true,
|
LocalTime: true,
|
||||||
Compress: compressRotated,
|
Compress: compressRotated,
|
||||||
}
|
}
|
||||||
|
fileRotateMu.Lock()
|
||||||
|
fileRotate = rotate
|
||||||
|
fileRotateMu.Unlock()
|
||||||
|
|
||||||
backend := logging.NewLogBackend(fileRotate, "", 0)
|
backend := logging.NewLogBackend(rotate, "", 0)
|
||||||
return logging.NewBackendFormatter(backend, newFormatter(true))
|
return logging.NewBackendFormatter(backend, newFormatter(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +142,8 @@ func newFormatter(withTime bool) logging.Formatter {
|
|||||||
// CloseLogger closes the rotating log writer and cleans up resources.
|
// CloseLogger closes the rotating log writer and cleans up resources.
|
||||||
// Should be called during application shutdown.
|
// Should be called during application shutdown.
|
||||||
func CloseLogger() {
|
func CloseLogger() {
|
||||||
|
fileRotateMu.Lock()
|
||||||
|
defer fileRotateMu.Unlock()
|
||||||
if fileRotate != nil {
|
if fileRotate != nil {
|
||||||
_ = fileRotate.Close()
|
_ = fileRotate.Close()
|
||||||
fileRotate = nil
|
fileRotate = nil
|
||||||
@@ -137,61 +152,61 @@ func CloseLogger() {
|
|||||||
|
|
||||||
// Debug logs a debug message and adds it to the log buffer.
|
// Debug logs a debug message and adds it to the log buffer.
|
||||||
func Debug(args ...any) {
|
func Debug(args ...any) {
|
||||||
logger.Debug(args...)
|
logger.Load().Debug(args...)
|
||||||
addToBuffer("DEBUG", fmt.Sprint(args...))
|
addToBuffer("DEBUG", fmt.Sprint(args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debugf logs a formatted debug message and adds it to the log buffer.
|
// Debugf logs a formatted debug message and adds it to the log buffer.
|
||||||
func Debugf(format string, args ...any) {
|
func Debugf(format string, args ...any) {
|
||||||
logger.Debugf(format, args...)
|
logger.Load().Debugf(format, args...)
|
||||||
addToBuffer("DEBUG", fmt.Sprintf(format, args...))
|
addToBuffer("DEBUG", fmt.Sprintf(format, args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Info logs an info message and adds it to the log buffer.
|
// Info logs an info message and adds it to the log buffer.
|
||||||
func Info(args ...any) {
|
func Info(args ...any) {
|
||||||
logger.Info(args...)
|
logger.Load().Info(args...)
|
||||||
addToBuffer("INFO", fmt.Sprint(args...))
|
addToBuffer("INFO", fmt.Sprint(args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Infof logs a formatted info message and adds it to the log buffer.
|
// Infof logs a formatted info message and adds it to the log buffer.
|
||||||
func Infof(format string, args ...any) {
|
func Infof(format string, args ...any) {
|
||||||
logger.Infof(format, args...)
|
logger.Load().Infof(format, args...)
|
||||||
addToBuffer("INFO", fmt.Sprintf(format, args...))
|
addToBuffer("INFO", fmt.Sprintf(format, args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notice logs a notice message and adds it to the log buffer.
|
// Notice logs a notice message and adds it to the log buffer.
|
||||||
func Notice(args ...any) {
|
func Notice(args ...any) {
|
||||||
logger.Notice(args...)
|
logger.Load().Notice(args...)
|
||||||
addToBuffer("NOTICE", fmt.Sprint(args...))
|
addToBuffer("NOTICE", fmt.Sprint(args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Noticef logs a formatted notice message and adds it to the log buffer.
|
// Noticef logs a formatted notice message and adds it to the log buffer.
|
||||||
func Noticef(format string, args ...any) {
|
func Noticef(format string, args ...any) {
|
||||||
logger.Noticef(format, args...)
|
logger.Load().Noticef(format, args...)
|
||||||
addToBuffer("NOTICE", fmt.Sprintf(format, args...))
|
addToBuffer("NOTICE", fmt.Sprintf(format, args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warning logs a warning message and adds it to the log buffer.
|
// Warning logs a warning message and adds it to the log buffer.
|
||||||
func Warning(args ...any) {
|
func Warning(args ...any) {
|
||||||
logger.Warning(args...)
|
logger.Load().Warning(args...)
|
||||||
addToBuffer("WARNING", fmt.Sprint(args...))
|
addToBuffer("WARNING", fmt.Sprint(args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warningf logs a formatted warning message and adds it to the log buffer.
|
// Warningf logs a formatted warning message and adds it to the log buffer.
|
||||||
func Warningf(format string, args ...any) {
|
func Warningf(format string, args ...any) {
|
||||||
logger.Warningf(format, args...)
|
logger.Load().Warningf(format, args...)
|
||||||
addToBuffer("WARNING", fmt.Sprintf(format, args...))
|
addToBuffer("WARNING", fmt.Sprintf(format, args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error logs an error message and adds it to the log buffer.
|
// Error logs an error message and adds it to the log buffer.
|
||||||
func Error(args ...any) {
|
func Error(args ...any) {
|
||||||
logger.Error(args...)
|
logger.Load().Error(args...)
|
||||||
addToBuffer("ERROR", fmt.Sprint(args...))
|
addToBuffer("ERROR", fmt.Sprint(args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Errorf logs a formatted error message and adds it to the log buffer.
|
// Errorf logs a formatted error message and adds it to the log buffer.
|
||||||
func Errorf(format string, args ...any) {
|
func Errorf(format string, args ...any) {
|
||||||
logger.Errorf(format, args...)
|
logger.Load().Errorf(format, args...)
|
||||||
addToBuffer("ERROR", fmt.Sprintf(format, args...))
|
addToBuffer("ERROR", fmt.Sprintf(format, args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ package logger
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
golog "github.com/op/go-logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestGetLogs_ReturnsAtMostC guards the documented "up to c entries" contract.
|
// TestGetLogs_ReturnsAtMostC guards the documented "up to c entries" contract.
|
||||||
@@ -28,3 +31,30 @@ func TestGetLogs_ReturnsAtMostC(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InitLogger replaces the package logger while other goroutines are already
|
||||||
|
// logging — CI caught that as a data race between InitLogger and Warningf.
|
||||||
|
func TestInitLoggerConcurrentWithLogging(t *testing.T) {
|
||||||
|
t.Setenv("XUI_LOG_FOLDER", t.TempDir())
|
||||||
|
|
||||||
|
stop := make(chan struct{})
|
||||||
|
var logging sync.WaitGroup
|
||||||
|
logging.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer logging.Done()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
Warningf("concurrent %s", "log")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for range 10 {
|
||||||
|
InitLogger(golog.CRITICAL)
|
||||||
|
}
|
||||||
|
close(stop)
|
||||||
|
logging.Wait()
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -53,6 +54,7 @@ type cachedSubTemplate struct {
|
|||||||
type SUBController struct {
|
type SUBController struct {
|
||||||
subTitle string
|
subTitle string
|
||||||
subSupportUrl string
|
subSupportUrl string
|
||||||
|
subProfileMode string
|
||||||
subProfileUrl string
|
subProfileUrl string
|
||||||
subAnnounce string
|
subAnnounce string
|
||||||
subEnableRouting bool
|
subEnableRouting bool
|
||||||
@@ -115,6 +117,7 @@ type subControllerConfig struct {
|
|||||||
|
|
||||||
subTitle string
|
subTitle string
|
||||||
subSupportURL string
|
subSupportURL string
|
||||||
|
subProfileMode string
|
||||||
subProfileURL string
|
subProfileURL string
|
||||||
subAnnounce string
|
subAnnounce string
|
||||||
subEnableRouting bool
|
subEnableRouting bool
|
||||||
@@ -224,6 +227,10 @@ func WithSUBProfileURL(value string) SUBControllerOption {
|
|||||||
return func(config *subControllerConfig) { config.subProfileURL = value }
|
return func(config *subControllerConfig) { config.subProfileURL = value }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WithSUBProfileMode(value string) SUBControllerOption {
|
||||||
|
return func(config *subControllerConfig) { config.subProfileMode = value }
|
||||||
|
}
|
||||||
|
|
||||||
func WithSUBAnnounce(value string) SUBControllerOption {
|
func WithSUBAnnounce(value string) SUBControllerOption {
|
||||||
return func(config *subControllerConfig) { config.subAnnounce = value }
|
return func(config *subControllerConfig) { config.subAnnounce = value }
|
||||||
}
|
}
|
||||||
@@ -260,6 +267,7 @@ func defaultSUBControllerConfig() subControllerConfig {
|
|||||||
subEncrypt: true,
|
subEncrypt: true,
|
||||||
remarkTemplate: service.DefaultRemarkTemplate,
|
remarkTemplate: service.DefaultRemarkTemplate,
|
||||||
updateInterval: "12",
|
updateInterval: "12",
|
||||||
|
subProfileMode: service.SubProfileModeNone,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,6 +285,7 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo
|
|||||||
a := &SUBController{
|
a := &SUBController{
|
||||||
subTitle: config.subTitle,
|
subTitle: config.subTitle,
|
||||||
subSupportUrl: config.subSupportURL,
|
subSupportUrl: config.subSupportURL,
|
||||||
|
subProfileMode: config.subProfileMode,
|
||||||
subProfileUrl: config.subProfileURL,
|
subProfileUrl: config.subProfileURL,
|
||||||
subAnnounce: config.subAnnounce,
|
subAnnounce: config.subAnnounce,
|
||||||
subEnableRouting: config.subEnableRouting,
|
subEnableRouting: config.subEnableRouting,
|
||||||
@@ -485,8 +494,7 @@ func (a *SUBController) subs(c *gin.Context) {
|
|||||||
|
|
||||||
// Add headers
|
// Add headers
|
||||||
header := subReq.subscriptionUserinfo(traffic)
|
header := subReq.subscriptionUserinfo(traffic)
|
||||||
profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
metadata := a.metadataForSubRequest(func() *SubService { return subReq }, subId, builtinProfileURL(c, scheme, hostWithPort))
|
||||||
metadata := a.metadataForSubRequest(func() *SubService { return subReq }, subId, profileURL)
|
|
||||||
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
|
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
|
||||||
|
|
||||||
if a.subIncyEnableRouting && a.subIncyRoutingRules != "" {
|
if a.subIncyEnableRouting && a.subIncyRoutingRules != "" {
|
||||||
@@ -653,6 +661,8 @@ func (a *SUBController) subPageContext(page PageData) map[string]any {
|
|||||||
if datepicker == "" {
|
if datepicker == "" {
|
||||||
datepicker = "gregorian"
|
datepicker = "gregorian"
|
||||||
}
|
}
|
||||||
|
subUpdates, _ := a.settingService.GetSubUpdates()
|
||||||
|
updateHours, _ := strconv.Atoi(subUpdates)
|
||||||
|
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"sId": page.SId,
|
"sId": page.SId,
|
||||||
@@ -673,6 +683,7 @@ func (a *SUBController) subPageContext(page PageData) map[string]any {
|
|||||||
"subClashUrl": page.SubClashUrl,
|
"subClashUrl": page.SubClashUrl,
|
||||||
"subTitle": page.SubTitle,
|
"subTitle": page.SubTitle,
|
||||||
"subSupportUrl": page.SubSupportUrl,
|
"subSupportUrl": page.SubSupportUrl,
|
||||||
|
"subUpdates": updateHours,
|
||||||
"links": page.Result,
|
"links": page.Result,
|
||||||
"emails": page.Emails,
|
"emails": page.Emails,
|
||||||
"datepicker": datepicker,
|
"datepicker": datepicker,
|
||||||
@@ -818,14 +829,13 @@ func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, co
|
|||||||
if len(jsonSub) == 0 && header == "" {
|
if len(jsonSub) == 0 && header == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
|
||||||
var subReq *SubService
|
var subReq *SubService
|
||||||
metadata := a.metadataForSubRequest(func() *SubService {
|
metadata := a.metadataForSubRequest(func() *SubService {
|
||||||
if subReq == nil {
|
if subReq == nil {
|
||||||
subReq = a.subService.ForRequest(host)
|
subReq = a.subService.ForRequest(host)
|
||||||
}
|
}
|
||||||
return subReq
|
return subReq
|
||||||
}, subId, profileURL)
|
}, subId, builtinProfileURL(c, scheme, hostWithPort))
|
||||||
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
|
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
|
||||||
if rawDownload {
|
if rawDownload {
|
||||||
c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.json"`)
|
c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.json"`)
|
||||||
@@ -887,14 +897,13 @@ func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool, legacy
|
|||||||
if len(clashSub) == 0 && header == "" {
|
if len(clashSub) == 0 && header == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
|
||||||
var subReq *SubService
|
var subReq *SubService
|
||||||
metadata := a.metadataForSubRequest(func() *SubService {
|
metadata := a.metadataForSubRequest(func() *SubService {
|
||||||
if subReq == nil {
|
if subReq == nil {
|
||||||
subReq = a.subService.ForRequest(host)
|
subReq = a.subService.ForRequest(host)
|
||||||
}
|
}
|
||||||
return subReq
|
return subReq
|
||||||
}, subId, profileURL)
|
}, subId, builtinProfileURL(c, scheme, hostWithPort))
|
||||||
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
|
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
|
||||||
if rawDownload {
|
if rawDownload {
|
||||||
c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.yaml"`)
|
c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.yaml"`)
|
||||||
@@ -906,6 +915,11 @@ func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool, legacy
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func builtinProfileURL(c *gin.Context, scheme, hostWithPort string) string {
|
||||||
|
// Drop download/format selectors so the opt-in link always opens the HTML page.
|
||||||
|
return fmt.Sprintf("%s://%s%s?html=1", scheme, hostWithPort, c.Request.URL.EscapedPath())
|
||||||
|
}
|
||||||
|
|
||||||
// ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title.
|
// ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title.
|
||||||
func (a *SUBController) ApplyCommonHeaders(
|
func (a *SUBController) ApplyCommonHeaders(
|
||||||
c *gin.Context,
|
c *gin.Context,
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ func TestSubInfoEndpoint_ServesStatusJSONEvenForBrowsers(t *testing.T) {
|
|||||||
t.Fatalf("info payload missing %q; body=%s", key, w.Body.String())
|
t.Fatalf("info payload missing %q; body=%s", key, w.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if info["subUpdates"] != float64(12) {
|
||||||
|
t.Fatalf("subUpdates = %v, want the default 12-hour interval", info["subUpdates"])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSubInfoEndpoint_UnknownSubIs404(t *testing.T) {
|
func TestSubInfoEndpoint_UnknownSubIs404(t *testing.T) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
type subPlaceholderData struct {
|
type subPlaceholderData struct {
|
||||||
@@ -76,10 +77,17 @@ func subMetadataUsesPlaceholders(values ...string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *SUBController) metadataForSubRequest(getSubReq func() *SubService, subID string, fallbackProfileURL string) renderedSubMetadata {
|
func (a *SUBController) metadataForSubRequest(getSubReq func() *SubService, subID, builtinURL string) renderedSubMetadata {
|
||||||
|
profileURL := ""
|
||||||
|
switch a.subProfileMode {
|
||||||
|
case service.SubProfileModeBuiltin:
|
||||||
|
profileURL = builtinURL
|
||||||
|
case service.SubProfileModeCustom:
|
||||||
|
profileURL = strings.TrimSpace(a.subProfileUrl)
|
||||||
|
}
|
||||||
var context remarkContext
|
var context remarkContext
|
||||||
var hasContext bool
|
var hasContext bool
|
||||||
if subMetadataUsesPlaceholders(a.subTitle, a.subSupportUrl, a.subProfileUrl, a.subAnnounce) {
|
if subMetadataUsesPlaceholders(a.subTitle, a.subSupportUrl, profileURL, a.subAnnounce) {
|
||||||
var err error
|
var err error
|
||||||
subReq := getSubReq()
|
subReq := getSubReq()
|
||||||
context, hasContext, err = subReq.subscriptionTemplateContextBySubID(subID)
|
context, hasContext, err = subReq.subscriptionTemplateContextBySubID(subID)
|
||||||
@@ -87,12 +95,8 @@ func (a *SUBController) metadataForSubRequest(getSubReq func() *SubService, subI
|
|||||||
logger.Warning("sub: load template contexts for subscription metadata:", err)
|
logger.Warning("sub: load template contexts for subscription metadata:", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
profileURL := a.subProfileUrl
|
// Disabled modes ignore the retained custom URL and never fall back to the request URL.
|
||||||
if profileURL == "" {
|
profileURL = renderSubPlaceholders(profileURL, subPlaceholderData{SubID: subID, Context: context, HasCtx: hasContext, Escape: true})
|
||||||
profileURL = fallbackProfileURL
|
|
||||||
} else {
|
|
||||||
profileURL = renderSubPlaceholders(profileURL, subPlaceholderData{SubID: subID, Context: context, HasCtx: hasContext, Escape: true})
|
|
||||||
}
|
|
||||||
data := subPlaceholderData{SubID: subID, Context: context, HasCtx: hasContext}
|
data := subPlaceholderData{SubID: subID, Context: context, HasCtx: hasContext}
|
||||||
return renderedSubMetadata{
|
return renderedSubMetadata{
|
||||||
Title: renderSubPlaceholders(a.subTitle, data),
|
Title: renderSubPlaceholders(a.subTitle, data),
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
package sub
|
package sub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRenderSubPlaceholders(t *testing.T) {
|
func TestRenderSubPlaceholders(t *testing.T) {
|
||||||
@@ -68,20 +73,75 @@ func TestRenderSubPlaceholders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMetadataForSubRequestDoesNotExpandFallbackProfileURL(t *testing.T) {
|
func TestMetadataForSubRequestOmitsUnconfiguredProfileURL(t *testing.T) {
|
||||||
a := &SUBController{
|
a := &SUBController{
|
||||||
subTitle: "isVPN",
|
subTitle: "isVPN",
|
||||||
subSupportUrl: "https://support.example/",
|
subSupportUrl: "https://support.example/",
|
||||||
}
|
}
|
||||||
fallback := "https://sub.example.com/sub/sub-123?x={{EMAIL}}"
|
|
||||||
|
|
||||||
metadata := a.metadataForSubRequest(func() *SubService {
|
metadata := a.metadataForSubRequest(func() *SubService {
|
||||||
t.Fatal("metadataForSubRequest loaded a subscription context without configured placeholders")
|
t.Fatal("metadataForSubRequest loaded a subscription context without configured placeholders")
|
||||||
return nil
|
return nil
|
||||||
}, "sub-123", fallback)
|
}, "sub-123", "https://sub.example/sub-123")
|
||||||
|
|
||||||
if metadata.ProfileURL != fallback {
|
if metadata.ProfileURL != "" {
|
||||||
t.Fatalf("ProfileURL = %q, want untouched fallback %q", metadata.ProfileURL, fallback)
|
t.Fatalf("ProfileURL = %q, want no link when unconfigured", metadata.ProfileURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubscriptionProfileURLRequiresExplicitConfiguration(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
initSubDB(t)
|
||||||
|
seedInfoEndpointSub(t, "profile-sub", "profile@example.com")
|
||||||
|
|
||||||
|
for _, config := range []struct {
|
||||||
|
name, profileURL, want string
|
||||||
|
}{
|
||||||
|
{name: "empty"},
|
||||||
|
{name: "whitespace", profileURL: " "},
|
||||||
|
{name: "explicit", profileURL: "https://portal.example.com/account", want: "https://portal.example.com/account"},
|
||||||
|
{name: "template", profileURL: "https://portal.example.com/account?sub={{SUB_ID}}", want: "https://portal.example.com/account?sub=profile-sub"},
|
||||||
|
} {
|
||||||
|
t.Run(config.name, func(t *testing.T) {
|
||||||
|
for _, client := range []struct {
|
||||||
|
name, userAgent string
|
||||||
|
happAutoDetect bool
|
||||||
|
}{
|
||||||
|
{name: "Happ", userAgent: "Happ/3.22.0 (Android)", happAutoDetect: true},
|
||||||
|
{name: "Happ without auto-detection", userAgent: "Happ/3.22.0 (Android)"},
|
||||||
|
{name: "standard", userAgent: "v2rayNG/1.8.5", happAutoDetect: true},
|
||||||
|
} {
|
||||||
|
t.Run(client.name, func(t *testing.T) {
|
||||||
|
// All formats must honor the opt-in, independently of Happ customization.
|
||||||
|
router := gin.New()
|
||||||
|
NewSUBController(router.Group("/"),
|
||||||
|
WithSUBJsonEnabled(true), WithSUBClashEnabled(true),
|
||||||
|
WithSUBProfileURL(config.profileURL),
|
||||||
|
WithSUBProfileMode(service.SubProfileModeCustom),
|
||||||
|
WithSUBHappConfig(HappConfig{AutoDetect: client.happAutoDetect}),
|
||||||
|
)
|
||||||
|
for _, path := range []string{"/sub/profile-sub", "/json/profile-sub", "/clash/profile-sub"} {
|
||||||
|
t.Run(path, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
req.Host = "sub.example.com"
|
||||||
|
req.Header.Set("User-Agent", client.userAgent)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if got := resp.Header().Get("Profile-Web-Page-Url"); got != config.want {
|
||||||
|
t.Fatalf("Profile-Web-Page-Url = %q, want %q", got, config.want)
|
||||||
|
}
|
||||||
|
if config.want == "" {
|
||||||
|
if _, present := resp.Header()["Profile-Web-Page-Url"]; present {
|
||||||
|
t.Fatal("unconfigured profile header must be omitted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,12 +170,13 @@ func TestMetadataForSubRequestUsesStableClientIdentity(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a := &SUBController{
|
a := &SUBController{
|
||||||
subTitle: "isVPN — {{EMAIL}}",
|
subTitle: "isVPN — {{EMAIL}}",
|
||||||
subSupportUrl: "https://support.example/?email={{EMAIL}}&tg={{TELEGRAM_ID}}",
|
subSupportUrl: "https://support.example/?email={{EMAIL}}&tg={{TELEGRAM_ID}}",
|
||||||
subProfileUrl: "https://profile.example/account/{{ID}}",
|
subProfileUrl: "https://profile.example/account/{{ID}}",
|
||||||
subAnnounce: "Subscription {{SUB_ID}}",
|
subProfileMode: service.SubProfileModeCustom,
|
||||||
|
subAnnounce: "Subscription {{SUB_ID}}",
|
||||||
}
|
}
|
||||||
metadata := a.metadataForSubRequest(func() *SubService { return &SubService{} }, "sub-123", "https://fallback.example/{{EMAIL}}")
|
metadata := a.metadataForSubRequest(func() *SubService { return &SubService{} }, "sub-123", "https://sub.example/sub-123")
|
||||||
|
|
||||||
if metadata.Title != "isVPN — john doe@example.com" {
|
if metadata.Title != "isVPN — john doe@example.com" {
|
||||||
t.Fatalf("Title = %q", metadata.Title)
|
t.Fatalf("Title = %q", metadata.Title)
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package sub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSubscriptionProfileModesFromSavedSettings(t *testing.T) {
|
||||||
|
oldFS, oldMode := distFS, gin.Mode()
|
||||||
|
oldWriter, oldErrorWriter := gin.DefaultWriter, gin.DefaultErrorWriter
|
||||||
|
SetDistFS(testDistFS)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
SetDistFS(oldFS)
|
||||||
|
gin.SetMode(oldMode)
|
||||||
|
gin.DefaultWriter, gin.DefaultErrorWriter = oldWriter, oldErrorWriter
|
||||||
|
})
|
||||||
|
for _, config := range []struct {
|
||||||
|
name, mode, profileURL, want string
|
||||||
|
}{
|
||||||
|
{name: "new installation"},
|
||||||
|
{name: "legacy whitespace", profileURL: " "},
|
||||||
|
{name: "legacy custom", profileURL: "https://portal.example/account", want: "https://portal.example/account"},
|
||||||
|
{name: "none retains custom", mode: "none", profileURL: "https://portal.example/account"},
|
||||||
|
{name: "builtin", mode: "builtin", profileURL: "https://portal.example/account"},
|
||||||
|
{name: "custom", mode: "custom", profileURL: "https://portal.example/?sub={{SUB_ID}}", want: "https://portal.example/?sub=profile-sub"},
|
||||||
|
{name: "empty custom", mode: "custom"},
|
||||||
|
{name: "invalid mode", mode: "invalid", profileURL: "https://portal.example/account"},
|
||||||
|
} {
|
||||||
|
t.Run(config.name, func(t *testing.T) {
|
||||||
|
initSubDB(t)
|
||||||
|
seedInfoEndpointSub(t, "profile-sub", "profile@example.com")
|
||||||
|
settings := []model.Setting{
|
||||||
|
{Key: "subPath", Value: "/sub/"},
|
||||||
|
{Key: "subJsonPath", Value: "/json/"},
|
||||||
|
{Key: "subClashPath", Value: "/clash/"},
|
||||||
|
{Key: "subJsonEnable", Value: "true"},
|
||||||
|
{Key: "subClashEnable", Value: "true"},
|
||||||
|
{Key: "subProfileUrl", Value: config.profileURL},
|
||||||
|
}
|
||||||
|
if config.mode != "" {
|
||||||
|
settings = append(settings, model.Setting{Key: "subProfileMode", Value: config.mode})
|
||||||
|
}
|
||||||
|
for _, setting := range settings {
|
||||||
|
if err := database.GetDB().Where("key = ?", setting.Key).Delete(&model.Setting{}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.GetDB().Create(&setting).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
router, err := (&Server{}).initRouter()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, path := range []string{"/sub/profile-sub", "/json/profile-sub", "/clash/profile-sub", "/json/profile-sub?view=raw", "/clash/profile-sub?view=raw", "/mihomo/profile-sub"} {
|
||||||
|
for _, userAgent := range []string{"Happ/3.22.0 (Android)", "v2rayNG/1.8.5"} {
|
||||||
|
t.Run(path+"/"+userAgent, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "https://sub.example.com:8443"+path, nil)
|
||||||
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d; body=%s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
want := config.want
|
||||||
|
if config.mode == "builtin" {
|
||||||
|
want = "https://sub.example.com:8443" + req.URL.EscapedPath() + "?html=1"
|
||||||
|
}
|
||||||
|
if got := resp.Header().Get("Profile-Web-Page-Url"); got != want {
|
||||||
|
t.Fatalf("Profile-Web-Page-Url = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if want == "" {
|
||||||
|
if _, present := resp.Header()["Profile-Web-Page-Url"]; present {
|
||||||
|
t.Fatal("disabled profile header must be absent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if config.mode == "builtin" {
|
||||||
|
// The restored link must open the page, even when copied from a raw download.
|
||||||
|
page := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(page, httptest.NewRequest(http.MethodGet, want, nil))
|
||||||
|
if page.Code != http.StatusOK || !strings.Contains(page.Header().Get("Content-Type"), "text/html") {
|
||||||
|
t.Fatalf("builtin link did not serve HTML: status=%d, type=%q", page.Code, page.Header().Get("Content-Type"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -194,6 +194,10 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
SubProfileUrl = ""
|
SubProfileUrl = ""
|
||||||
}
|
}
|
||||||
|
SubProfileMode, err := s.settingService.GetSubProfileMode()
|
||||||
|
if err != nil {
|
||||||
|
SubProfileMode = service.SubProfileModeNone
|
||||||
|
}
|
||||||
|
|
||||||
SubAnnounce, err := s.settingService.GetSubAnnounce()
|
SubAnnounce, err := s.settingService.GetSubAnnounce()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -329,6 +333,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
|||||||
WithSUBTitle(SubTitle),
|
WithSUBTitle(SubTitle),
|
||||||
WithSUBSupportURL(SubSupportUrl),
|
WithSUBSupportURL(SubSupportUrl),
|
||||||
WithSUBProfileURL(SubProfileUrl),
|
WithSUBProfileURL(SubProfileUrl),
|
||||||
|
WithSUBProfileMode(SubProfileMode),
|
||||||
WithSUBAnnounce(SubAnnounce),
|
WithSUBAnnounce(SubAnnounce),
|
||||||
WithSUBEnableRouting(SubEnableRouting),
|
WithSUBEnableRouting(SubEnableRouting),
|
||||||
WithSUBRoutingRules(SubRoutingRules),
|
WithSUBRoutingRules(SubRoutingRules),
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ func ParseSubscriptionBody(body []byte) ([]Outbound, []string, error) {
|
|||||||
lines := splitLines(text)
|
lines := splitLines(text)
|
||||||
var outbounds []Outbound
|
var outbounds []Outbound
|
||||||
var identities []string
|
var identities []string
|
||||||
|
seen := map[string]int{}
|
||||||
|
|
||||||
for _, ln := range lines {
|
for _, ln := range lines {
|
||||||
ln = strings.TrimSpace(ln)
|
ln = strings.TrimSpace(ln)
|
||||||
@@ -59,8 +60,14 @@ func ParseSubscriptionBody(body []byte) ([]Outbound, []string, error) {
|
|||||||
// Ignore unparseable lines (comments, unsupported protocols, etc.)
|
// Ignore unparseable lines (comments, unsupported protocols, etc.)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
identity := res.Identity
|
||||||
|
// A repeated identity would share one stored tag, shifting both tags on every refresh.
|
||||||
|
if n := seen[res.Identity]; n > 0 {
|
||||||
|
identity = fmt.Sprintf("%s#%d", res.Identity, n)
|
||||||
|
}
|
||||||
|
seen[res.Identity]++
|
||||||
outbounds = append(outbounds, res.Outbound)
|
outbounds = append(outbounds, res.Outbound)
|
||||||
identities = append(identities, res.Identity)
|
identities = append(identities, identity)
|
||||||
}
|
}
|
||||||
return outbounds, identities, nil
|
return outbounds, identities, nil
|
||||||
}
|
}
|
||||||
@@ -1047,10 +1054,18 @@ func firstParam(p url.Values, keys ...string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// realityPerRequestParams are picked per request by subscription servers (3x-ui randomizes
|
||||||
|
// sid/sni, older releases spx too), so they must not split one server into new identities.
|
||||||
|
var realityPerRequestParams = map[string]bool{"sid": true, "sni": true, "spx": true}
|
||||||
|
|
||||||
func canonicalQuery(p url.Values) string {
|
func canonicalQuery(p url.Values) string {
|
||||||
// Sort keys for stable identity
|
// Sort keys for stable identity
|
||||||
|
reality := p.Get("security") == "reality"
|
||||||
keys := make([]string, 0, len(p))
|
keys := make([]string, 0, len(p))
|
||||||
for k := range p {
|
for k := range p {
|
||||||
|
if reality && realityPerRequestParams[k] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
keys = append(keys, k)
|
keys = append(keys, k)
|
||||||
}
|
}
|
||||||
// simple sort
|
// simple sort
|
||||||
|
|||||||
@@ -24,6 +24,17 @@ func TestParseVmessLink(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLinkIdentityKeepsTLSServerName(t *testing.T) {
|
||||||
|
a, errA := ParseLink("vless://uuid@1.2.3.4:443?type=ws&security=tls&sni=a.example.com#node")
|
||||||
|
b, errB := ParseLink("vless://uuid@1.2.3.4:443?type=ws&security=tls&sni=b.example.com#node")
|
||||||
|
if errA != nil || errB != nil {
|
||||||
|
t.Fatalf("parse vless: %v, %v", errA, errB)
|
||||||
|
}
|
||||||
|
if a.Identity == b.Identity {
|
||||||
|
t.Fatalf("TLS links for different SNIs share identity %q", a.Identity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseVlessLink(t *testing.T) {
|
func TestParseVlessLink(t *testing.T) {
|
||||||
link := "vless://uuid@1.2.3.4:443?type=ws&security=tls&path=/&host=ex.com#node1"
|
link := "vless://uuid@1.2.3.4:443?type=ws&security=tls&path=/&host=ex.com#node1"
|
||||||
res, err := ParseLink(link)
|
res, err := ParseLink(link)
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package totp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/xlzd/gotp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SkewWindows is how many 30s steps around now VerifyWithSkew accepts.
|
||||||
|
// Standard TOTP clock-drift tolerance, see MHSanaei/3x-ui#6535.
|
||||||
|
const SkewWindows = 1
|
||||||
|
|
||||||
|
// VerifyWithSkew accepts the code for the current step plus/minus SkewWindows.
|
||||||
|
func VerifyWithSkew(secret, code string, now time.Time) bool {
|
||||||
|
totp := gotp.NewDefaultTOTP(secret)
|
||||||
|
for i := -SkewWindows; i <= SkewWindows; i++ {
|
||||||
|
if totp.AtTime(now.Add(time.Duration(i*30)*time.Second)) == code {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package totp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/xlzd/gotp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVerifyWithSkew(t *testing.T) {
|
||||||
|
secret := "JBSWY3DPEHPK3PXP"
|
||||||
|
totp := gotp.NewDefaultTOTP(secret)
|
||||||
|
// Anchor mid-window so a step boundary can't fall between sampling and verify.
|
||||||
|
now := time.Unix((time.Now().Unix()/30)*30+15, 0).UTC()
|
||||||
|
|
||||||
|
if !VerifyWithSkew(secret, totp.AtTime(now), now) {
|
||||||
|
t.Fatal("current window code should verify")
|
||||||
|
}
|
||||||
|
if !VerifyWithSkew(secret, totp.AtTime(now.Add(-30*time.Second)), now) {
|
||||||
|
t.Fatal("previous window code should verify (clock skew)")
|
||||||
|
}
|
||||||
|
if !VerifyWithSkew(secret, totp.AtTime(now.Add(30*time.Second)), now) {
|
||||||
|
t.Fatal("next window code should verify (clock skew)")
|
||||||
|
}
|
||||||
|
if VerifyWithSkew(secret, totp.AtTime(now.Add(-60*time.Second)), now) {
|
||||||
|
t.Fatal("code two windows old should not verify")
|
||||||
|
}
|
||||||
|
if VerifyWithSkew(secret, totp.AtTime(now.Add(60*time.Second)), now) {
|
||||||
|
t.Fatal("code two windows ahead should not verify")
|
||||||
|
}
|
||||||
|
if VerifyWithSkew(secret, "000000", now) {
|
||||||
|
t.Fatal("wrong code should not verify")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,6 +94,7 @@ type AllSetting struct {
|
|||||||
SubClashUserAgentRegex string `json:"subClashUserAgentRegex" form:"subClashUserAgentRegex"`
|
SubClashUserAgentRegex string `json:"subClashUserAgentRegex" form:"subClashUserAgentRegex"`
|
||||||
SubTitle string `json:"subTitle" form:"subTitle"`
|
SubTitle string `json:"subTitle" form:"subTitle"`
|
||||||
SubSupportUrl string `json:"subSupportUrl" form:"subSupportUrl"`
|
SubSupportUrl string `json:"subSupportUrl" form:"subSupportUrl"`
|
||||||
|
SubProfileMode string `json:"subProfileMode" form:"subProfileMode"`
|
||||||
SubProfileUrl string `json:"subProfileUrl" form:"subProfileUrl"`
|
SubProfileUrl string `json:"subProfileUrl" form:"subProfileUrl"`
|
||||||
SubAnnounce string `json:"subAnnounce" form:"subAnnounce"`
|
SubAnnounce string `json:"subAnnounce" form:"subAnnounce"`
|
||||||
SubEnableRouting bool `json:"subEnableRouting" form:"subEnableRouting"`
|
SubEnableRouting bool `json:"subEnableRouting" form:"subEnableRouting"`
|
||||||
|
|||||||
@@ -612,7 +612,8 @@ func (j *CheckClientIpJob) filterAdvancedSinceLastBan(email string, banned []IPW
|
|||||||
return actionable
|
return actionable
|
||||||
}
|
}
|
||||||
|
|
||||||
// disconnectClientTemporarily removes and re-adds a client to force disconnect banned connections
|
// disconnectClientTemporarily drops a client's credential for a moment, so new
|
||||||
|
// handshakes are refused; the fail2ban ban is what ends live traffic.
|
||||||
func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) {
|
func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) {
|
||||||
var xrayAPI xray.XrayAPI
|
var xrayAPI xray.XrayAPI
|
||||||
apiPort := j.resolveXrayAPIPort()
|
apiPort := j.resolveXrayAPIPort()
|
||||||
@@ -626,11 +627,13 @@ func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, c
|
|||||||
|
|
||||||
// Find the client config
|
// Find the client config
|
||||||
var clientConfig map[string]any
|
var clientConfig map[string]any
|
||||||
|
var reverseClient bool
|
||||||
for _, client := range clients {
|
for _, client := range clients {
|
||||||
if client.Email == clientEmail {
|
if client.Email == clientEmail {
|
||||||
// Convert client to map for API
|
// Convert client to map for API
|
||||||
clientBytes, _ := json.Marshal(client)
|
clientBytes, _ := json.Marshal(client)
|
||||||
_ = json.Unmarshal(clientBytes, &clientConfig)
|
_ = json.Unmarshal(clientBytes, &clientConfig)
|
||||||
|
reverseClient = client.Reverse != nil
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -650,6 +653,13 @@ func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, c
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoveUser drops a reverse client's outbound handler and the re-add below
|
||||||
|
// cannot restore it, so its tunnel would stay down until Xray restarts.
|
||||||
|
if reverseClient {
|
||||||
|
logger.Warningf("[LIMIT_IP] Not disconnecting %s: its reverse proxy config does not survive a temporary removal", clientEmail)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// For Shadowsocks, ensure the required "cipher" field is present by
|
// For Shadowsocks, ensure the required "cipher" field is present by
|
||||||
// reading it from the inbound settings (e.g., settings["method"]).
|
// reading it from the inbound settings (e.g., settings["method"]).
|
||||||
if string(inbound.Protocol) == "shadowsocks" {
|
if string(inbound.Protocol) == "shadowsocks" {
|
||||||
@@ -663,14 +673,16 @@ func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, c
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove user to disconnect all connections
|
// The core's RemoveUser clears its validator: a session already up keeps
|
||||||
|
// running, except a reverse vless client, which is skipped above.
|
||||||
err = xrayAPI.RemoveUser(inbound.Tag, clientEmail)
|
err = xrayAPI.RemoveUser(inbound.Tag, clientEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warningf("[LIMIT_IP] Failed to remove user %s: %v", clientEmail, err)
|
logger.Warningf("[LIMIT_IP] Failed to remove user %s: %v", clientEmail, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait a moment for disconnection to take effect
|
// Nothing is pending here: AlterInbound applies the removal inline, so this
|
||||||
|
// only widens the window in which new handshakes fail.
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
// Re-add user to allow new connections
|
// Re-add user to allow new connections
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ldapHungNodeInbound seeds an online node whose client writes hang until the gate
|
||||||
|
// opens, plus one inbound on it holding the given enabled clients.
|
||||||
|
func ldapHungNodeInbound(t *testing.T, emails []string) (*resetGate, *model.Inbound) {
|
||||||
|
t.Helper()
|
||||||
|
initLdapJobDB(t)
|
||||||
|
runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}}))
|
||||||
|
t.Cleanup(func() { runtime.SetManager(nil) })
|
||||||
|
gate := &resetGate{release: make(chan struct{})}
|
||||||
|
const tag = "ldap-node-in"
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = io.Copy(io.Discard, r.Body)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "inbounds/list") {
|
||||||
|
_, _ = w.Write([]byte(`{"success":true,"obj":[{"id":1,"tag":"` + tag + `"}]}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
gate.entered.Add(1)
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
case <-gate.release:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"success":true}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
t.Cleanup(gate.open)
|
||||||
|
host, port, _ := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":")
|
||||||
|
portNum, _ := strconv.Atoi(port)
|
||||||
|
db := database.GetDB()
|
||||||
|
node := &model.Node{
|
||||||
|
Name: "ldap-node", Scheme: "http", Address: host, Port: portNum, BasePath: "/", ApiToken: "tok",
|
||||||
|
Enable: true, Status: "online", AllowPrivateAddress: true, TlsVerifyMode: "verify",
|
||||||
|
}
|
||||||
|
if err := db.Create(node).Error; err != nil {
|
||||||
|
t.Fatalf("create node: %v", err)
|
||||||
|
}
|
||||||
|
clients := make([]model.Client, 0, len(emails))
|
||||||
|
for i, email := range emails {
|
||||||
|
clients = append(clients, model.Client{Email: email, ID: fmt.Sprintf("00000000-0000-4000-8000-%012d", i), Enable: true})
|
||||||
|
}
|
||||||
|
settings, _ := json.Marshal(map[string]any{"clients": clients, "decryption": "none"})
|
||||||
|
ib := &model.Inbound{
|
||||||
|
UserId: 1, Enable: true, Port: 47200, Protocol: model.VLESS, NodeID: &node.Id,
|
||||||
|
Tag: tag, Settings: string(settings), StreamSettings: `{"network":"tcp"}`,
|
||||||
|
}
|
||||||
|
if err := db.Create(ib).Error; err != nil {
|
||||||
|
t.Fatalf("create inbound: %v", err)
|
||||||
|
}
|
||||||
|
for _, c := range clients {
|
||||||
|
rec := model.ClientRecord{Email: c.Email, UUID: c.ID, Enable: true}
|
||||||
|
if err := db.Create(&rec).Error; err != nil {
|
||||||
|
t.Fatalf("create client record: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil {
|
||||||
|
t.Fatalf("link client: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&xray.ClientTraffic{InboundId: ib.Id, Email: c.Email, Enable: true}).Error; err != nil {
|
||||||
|
t.Fatalf("create client traffic: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return gate, ib
|
||||||
|
}
|
||||||
|
|
||||||
|
func inboundClientEnables(t *testing.T, inboundID int) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
|
var ib model.Inbound
|
||||||
|
if err := database.GetDB().First(&ib, inboundID).Error; err != nil {
|
||||||
|
t.Fatalf("reload inbound: %v", err)
|
||||||
|
}
|
||||||
|
var settings struct {
|
||||||
|
Clients []model.Client `json:"clients"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
|
||||||
|
t.Fatalf("parse settings: %v", err)
|
||||||
|
}
|
||||||
|
out := make(map[string]bool, len(settings.Clients))
|
||||||
|
for _, c := range settings.Clients {
|
||||||
|
out[c.Email] = c.Enable
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each LDAP user was disabled on its own, and every per-user push held the inbound
|
||||||
|
// lock for the push timeout, so users sharing a hung node inbound queued behind it.
|
||||||
|
func TestLdapBatchSetEnableDoesNotQueueUsersOnHungNode(t *testing.T) {
|
||||||
|
emails := []string{"u1@ldap", "u2@ldap", "u3@ldap", "u4@ldap", "u5@ldap"}
|
||||||
|
gate, ib := ldapHungNodeInbound(t, emails)
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
NewLdapSyncJob().batchSetEnable(emails, false)
|
||||||
|
}()
|
||||||
|
t.Cleanup(func() { gate.open(); <-done })
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(9 * time.Second):
|
||||||
|
t.Fatalf("disabling %d LDAP users on one hung node inbound took over 9s (%d pushes started)", len(emails), gate.entered.Load())
|
||||||
|
}
|
||||||
|
for email, enabled := range inboundClientEnables(t, ib.Id) {
|
||||||
|
if enabled {
|
||||||
|
t.Errorf("client %s still enabled after the LDAP disable", email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clients missing from LDAP were detached one at a time, each waiting out the push
|
||||||
|
// timeout on a hung node inbound, so a directory cleanup could run for hours.
|
||||||
|
func TestLdapDeleteDoesNotQueueClientsOnHungNode(t *testing.T) {
|
||||||
|
emails := []string{"gone1@ldap", "gone2@ldap", "gone3@ldap", "gone4@ldap", "gone5@ldap"}
|
||||||
|
gate, ib := ldapHungNodeInbound(t, emails)
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
NewLdapSyncJob().deleteClientsNotInLDAP(ib.Tag, map[string]struct{}{})
|
||||||
|
}()
|
||||||
|
t.Cleanup(func() { gate.open(); <-done })
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(9 * time.Second):
|
||||||
|
t.Fatalf("detaching %d clients from one hung node inbound took over 9s (%d pushes started)", len(emails), gate.entered.Load())
|
||||||
|
}
|
||||||
|
if left := inboundClientEnables(t, ib.Id); len(left) != 0 {
|
||||||
|
t.Errorf("clients still on the inbound after the LDAP cleanup: %v", left)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -131,8 +131,7 @@ func (j *LdapSyncJob) Run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clientsToCreate := []model.Client{}
|
clientsToCreate := []model.Client{}
|
||||||
clientsToEnable := map[string][]string{} // tag -> []email
|
var clientsToEnable, clientsToDisable []string
|
||||||
clientsToDisable := map[string][]string{} // tag -> []email
|
|
||||||
|
|
||||||
for email, allowed := range flags {
|
for email, allowed := range flags {
|
||||||
existing := allClients[email]
|
existing := allClients[email]
|
||||||
@@ -142,24 +141,21 @@ func (j *LdapSyncJob) Run() {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, tag := range resolvedTags {
|
if len(resolvedTags) == 0 {
|
||||||
if allowed && !existing.Enable {
|
continue
|
||||||
clientsToEnable[tag] = append(clientsToEnable[tag], email)
|
}
|
||||||
} else if !allowed && existing.Enable {
|
if allowed && !existing.Enable {
|
||||||
clientsToDisable[tag] = append(clientsToDisable[tag], email)
|
clientsToEnable = append(clientsToEnable, email)
|
||||||
}
|
} else if !allowed && existing.Enable {
|
||||||
|
clientsToDisable = append(clientsToDisable, email)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
j.createClients(clientsToCreate, resolvedInboundIds, resolvedTags)
|
j.createClients(clientsToCreate, resolvedInboundIds, resolvedTags)
|
||||||
|
|
||||||
// --- Execute enable/disable batch ---
|
// --- Execute enable/disable batch ---
|
||||||
for tag, emails := range clientsToEnable {
|
j.batchSetEnable(clientsToEnable, true)
|
||||||
j.batchSetEnable(inboundMap[tag], emails, true)
|
j.batchSetEnable(clientsToDisable, false)
|
||||||
}
|
|
||||||
for tag, emails := range clientsToDisable {
|
|
||||||
j.batchSetEnable(inboundMap[tag], emails, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Auto delete clients not in LDAP ---
|
// --- Auto delete clients not in LDAP ---
|
||||||
autoDelete := mustGetBool(j.settingService.GetLdapAutoDelete)
|
autoDelete := mustGetBool(j.settingService.GetLdapAutoDelete)
|
||||||
@@ -257,34 +253,28 @@ func (j *LdapSyncJob) createClients(newClients []model.Client, inboundIds []int,
|
|||||||
logger.Infof("LDAP auto-create: %d clients for %s", created, tagList)
|
logger.Infof("LDAP auto-create: %d clients for %s", created, tagList)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *LdapSyncJob) batchSetEnable(ib *model.Inbound, emails []string, enable bool) {
|
// batchSetEnable takes the bulk path: per-user calls held each inbound's lock through
|
||||||
|
// its node push, so users sharing a hung node inbound queued one push timeout apiece.
|
||||||
|
func (j *LdapSyncJob) batchSetEnable(emails []string, enable bool) {
|
||||||
if len(emails) == 0 {
|
if len(emails) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
restartNeeded := false
|
result, needRestart, err := j.clientService.BulkSetEnable(&j.inboundService, emails, enable)
|
||||||
changed := 0
|
if err != nil {
|
||||||
for _, email := range emails {
|
logger.Warningf("Batch set enable=%v failed: %v", enable, err)
|
||||||
ok, needRestart, err := j.clientService.SetClientEnableByEmail(&j.inboundService, email, enable)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warningf("Batch set enable failed for %s in inbound %s: %v", email, ib.Tag, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if ok {
|
|
||||||
changed++
|
|
||||||
}
|
|
||||||
if needRestart {
|
|
||||||
restartNeeded = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if changed > 0 {
|
for _, skipped := range result.Skipped {
|
||||||
logger.Infof("Batch set enable=%v for %d clients in inbound %s", enable, changed, ib.Tag)
|
logger.Warningf("Batch set enable failed for %s: %s", skipped.Email, skipped.Reason)
|
||||||
}
|
}
|
||||||
if restartNeeded {
|
if result.Changed > 0 {
|
||||||
|
logger.Infof("Batch set enable=%v for %d clients", enable, result.Changed)
|
||||||
|
}
|
||||||
|
if needRestart {
|
||||||
j.xrayService.SetToNeedRestart()
|
j.xrayService.SetToNeedRestart()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteClientsNotInLDAP deletes clients not in LDAP using batches and a single restart
|
// deleteClientsNotInLDAP detaches clients not in LDAP, one bulk detach per inbound
|
||||||
func (j *LdapSyncJob) deleteClientsNotInLDAP(inboundTag string, ldapEmails map[string]struct{}) {
|
func (j *LdapSyncJob) deleteClientsNotInLDAP(inboundTag string, ldapEmails map[string]struct{}) {
|
||||||
inbounds, err := j.inboundService.GetAllInbounds()
|
inbounds, err := j.inboundService.GetAllInbounds()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -292,7 +282,6 @@ func (j *LdapSyncJob) deleteClientsNotInLDAP(inboundTag string, ldapEmails map[s
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
batchSize := 50 // clients in 1 batch
|
|
||||||
restartNeeded := false
|
restartNeeded := false
|
||||||
|
|
||||||
for _, ib := range inbounds {
|
for _, ib := range inbounds {
|
||||||
@@ -317,23 +306,23 @@ func (j *LdapSyncJob) deleteClientsNotInLDAP(inboundTag string, ldapEmails map[s
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < len(toDelete); i += batchSize {
|
emails := make([]string, len(toDelete))
|
||||||
end := min(i+batchSize, len(toDelete))
|
for i, c := range toDelete {
|
||||||
batch := toDelete[i:end]
|
emails[i] = c.Email
|
||||||
|
}
|
||||||
for _, c := range batch {
|
result, nr, err := j.clientService.BulkDetach(&j.inboundService, emails, []int{ib.Id})
|
||||||
nr, err := j.clientService.DetachByEmail(&j.inboundService, ib.Id, c.Email)
|
if err != nil {
|
||||||
if err != nil {
|
logger.Warningf("Failed to delete clients from inbound id=%d(tag=%s): %v", ib.Id, ib.Tag, err)
|
||||||
logger.Warningf("Failed to delete client %s from inbound id=%d(tag=%s): %v",
|
continue
|
||||||
c.Email, ib.Id, ib.Tag, err)
|
}
|
||||||
continue
|
for _, msg := range result.Errors {
|
||||||
}
|
logger.Warningf("Failed to delete client from inbound id=%d(tag=%s): %s", ib.Id, ib.Tag, msg)
|
||||||
logger.Infof("Deleted client %s from inbound id=%d(tag=%s)",
|
}
|
||||||
c.Email, ib.Id, ib.Tag)
|
for _, email := range result.Detached {
|
||||||
if nr {
|
logger.Infof("Deleted client %s from inbound id=%d(tag=%s)", email, ib.Id, ib.Tag)
|
||||||
restartNeeded = true
|
}
|
||||||
}
|
if nr {
|
||||||
}
|
restartNeeded = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,39 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// A reverse client has to be left alone: the cycle drops its reverse outbound
|
||||||
|
// handler and re-adds an account without a reverse, which no retry restores.
|
||||||
|
func TestDisconnectClientTemporarilySkipsReverseClient(t *testing.T) {
|
||||||
|
setupIntegrationDB(t)
|
||||||
|
|
||||||
|
const email = "rev-limit-probe"
|
||||||
|
inbound := &model.Inbound{
|
||||||
|
Id: 1,
|
||||||
|
Protocol: model.VLESS,
|
||||||
|
Tag: "rev-limit-probe-tag",
|
||||||
|
Settings: `{"clients":[]}`,
|
||||||
|
}
|
||||||
|
clients := []model.Client{{Email: email, ID: "11111111-1111-1111-1111-111111111111", Reverse: &model.ClientReverse{Tag: "rev-out"}}}
|
||||||
|
|
||||||
|
(&CheckClientIpJob{}).disconnectClientTemporarily(inbound, email, clients)
|
||||||
|
|
||||||
|
var skipped, attempted bool
|
||||||
|
for _, line := range logger.GetLogs(500, "warning") {
|
||||||
|
if strings.Contains(line, "Not disconnecting "+email) {
|
||||||
|
skipped = true
|
||||||
|
}
|
||||||
|
if strings.Contains(line, "Failed to remove user "+email) {
|
||||||
|
attempted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if attempted {
|
||||||
|
t.Fatal("a reverse client must not be removed and re-added")
|
||||||
|
}
|
||||||
|
if !skipped {
|
||||||
|
t.Fatal("the skip must be reported, not silent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The protocol gate must let hysteria through: XrayAPI supports it, and the
|
// The protocol gate must let hysteria through: XrayAPI supports it, and the
|
||||||
// skip left over-limit Hysteria2 sessions alive until the fail2ban ban caught up.
|
// skip left over-limit Hysteria2 sessions alive until the fail2ban ban caught up.
|
||||||
func TestDisconnectClientTemporarilyAllowsHysteria(t *testing.T) {
|
func TestDisconnectClientTemporarilyAllowsHysteria(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/op/go-logging"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func transitiveGuids(t *testing.T) []string {
|
||||||
|
t.Helper()
|
||||||
|
tree, err := (&service.NodeService{}).GetNodeTree()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetNodeTree: %v", err)
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, n := range tree {
|
||||||
|
if n.Transitive {
|
||||||
|
out = append(out, n.Guid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// The heartbeat skips a disabled node and never sees a deleted one, so the
|
||||||
|
// sub-nodes it had learned from them stayed on the Nodes page for good.
|
||||||
|
func TestHeartbeatDropsSubNodesOfNodesItNoLongerProbes(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
retire func(t *testing.T, nodeID int)
|
||||||
|
}{
|
||||||
|
{"disabled", func(t *testing.T, nodeID int) {
|
||||||
|
if err := (&service.NodeService{}).SetEnable(nodeID, false); err != nil {
|
||||||
|
t.Fatalf("SetEnable: %v", err)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{"deleted", func(t *testing.T, nodeID int) {
|
||||||
|
if err := (&service.NodeService{}).Delete(nodeID); err != nil {
|
||||||
|
t.Fatalf("Delete: %v", err)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
xuilogger.InitLogger(logging.ERROR)
|
||||||
|
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
||||||
|
t.Fatalf("InitDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.CloseDB() })
|
||||||
|
runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}}))
|
||||||
|
t.Cleanup(func() { runtime.SetManager(nil) })
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(r.URL.Path, "server/status"):
|
||||||
|
_, _ = w.Write([]byte(`{"success":true,"obj":{"panelGuid":"direct-guid","xray":{"state":"running"}}}`))
|
||||||
|
case strings.HasSuffix(r.URL.Path, "server/descendants"):
|
||||||
|
_, _ = w.Write([]byte(`{"success":true,"obj":[{"guid":"sub-guid","parentGuid":"direct-guid","name":"sub","status":"online"}]}`))
|
||||||
|
default:
|
||||||
|
_, _ = w.Write([]byte(`{"success":true}`))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
host, port, _ := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":")
|
||||||
|
portNum, _ := strconv.Atoi(port)
|
||||||
|
node := &model.Node{
|
||||||
|
Name: "direct", Scheme: "http", Address: host, Port: portNum, BasePath: "/", ApiToken: "tok",
|
||||||
|
Enable: true, Status: "unknown", AllowPrivateAddress: true, TlsVerifyMode: "verify",
|
||||||
|
}
|
||||||
|
if err := database.GetDB().Create(node).Error; err != nil {
|
||||||
|
t.Fatalf("create node: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hb := NewNodeHeartbeatJob()
|
||||||
|
hb.Run()
|
||||||
|
if got := transitiveGuids(t); len(got) != 1 || got[0] != "sub-guid" {
|
||||||
|
t.Fatalf("sub-nodes after first heartbeat = %v, want [sub-guid]", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
tc.retire(t, node.Id)
|
||||||
|
hb.Run()
|
||||||
|
if got := transitiveGuids(t); len(got) != 0 {
|
||||||
|
t.Fatalf("sub-nodes after the node was %s = %v, want none", tc.name, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||