feat(sub): auto-detect subscription format by User-Agent (Updated) (#5826)

* feat(settings): add subscription format controls

* feat(sub): auto-detect subscription formats

* fix(xray): validate balancer regexes before save

* Revert "fix(xray): validate balancer regexes before save"

This reverts commit 8a208ce71b.

* doc(endpoints): align indent spaces

* doc(settings): improve error message formatting in validateSubUserAgentRegex

- Use NewErrorf with proper formatting instead of NewError with string concatenation
- Add comment explaining the rationale for returning original pattern value
- This preserves the intentional design where empty input is stored as empty
  in the DB and inherited as the runtime default at read time

---------

Co-authored-by: Tomilla <5007859+Tomilla@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
Tomi lla
2026-07-14 19:01:40 +08:00
committed by GitHub
parent f2b17397f4
commit 129f50d92a
40 changed files with 1588 additions and 89 deletions
+9 -2
View File
@@ -1145,7 +1145,7 @@ export const sections: readonly Section[] = [
method: 'POST',
path: '/panel/api/setting/all',
summary: 'Return every panel setting: web server, Telegram bot, subscription, security, LDAP. The full JSON blob that the Settings page edits.',
response: '{\n "success": true,\n "obj": {\n "webPort": 2053,\n "webCertFile": "",\n "webKeyFile": "",\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "tgBotEnable": false,\n "tgBotToken": "",\n ...\n }\n}',
response: '{\n "success": true,\n "obj": {\n "webPort": 2053,\n "webCertFile": "",\n "webKeyFile": "",\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "subClashAutoDetect": false,\n "subClashUserAgentRegex": "",\n "subJsonEnable": false,\n "subJsonAutoDetect": false,\n "subJsonAlwaysArray": false,\n "subJsonUserAgentRegex": "",\n "subJsonPath": "/json/",\n "subJsonURI": "https://sub.example.com/json/",\n "subClashEnable": true,\n "subClashPath": "/clash/",\n "subClashURI": "https://sub.example.com/clash/",\n "tgBotEnable": false,\n "tgBotToken": "",\n ...\n }\n}',
},
{
method: 'POST',
@@ -1156,7 +1156,14 @@ export const sections: readonly Section[] = [
method: 'POST',
path: '/panel/api/setting/update',
summary: 'Persist every setting at once. The body mirrors the shape returned by /all. Invalid values (bad ports, missing cert pairs, etc.) are rejected before write.',
body: '{\n "webPort": 2053,\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "tgBotEnable": false,\n ...\n}',
body: '{\n "webPort": 2053,\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "subClashAutoDetect": false,\n "subClashUserAgentRegex": "",\n "subJsonEnable": false,\n "subJsonAutoDetect": false,\n "subJsonAlwaysArray": false,\n "subJsonUserAgentRegex": "",\n "subJsonPath": "/json/",\n "subJsonURI": "https://sub.example.com/json/",\n "subClashEnable": true,\n "subClashPath": "/clash/",\n "subClashURI": "https://sub.example.com/clash/",\n "tgBotEnable": false,\n ...\n}',
},
{
method: 'POST',
path: '/panel/api/setting/validateRegex',
summary: 'Validate any regular expression with the backend Go RE2 compiler without saving it.',
body: '{\n "regex": "(?m)^general-purpose$"\n}',
response: '{\n "success": true,\n "msg": ""\n}',
},
{
method: 'POST',
@@ -1,3 +1,18 @@
.subscription-format-sections {
display: grid;
gap: 16px;
}
.subscription-format-card-title {
display: inline-flex;
align-items: center;
gap: 8px;
}
.subscription-format-card .ant-card-body {
padding: 0;
}
.format-settings {
margin-bottom: 8px;
border: 1px solid var(--ant-color-border-secondary);
@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Card,
Input,
InputNumber,
Select,
@@ -8,6 +9,8 @@ import {
Tabs,
} from 'antd';
import {
FileTextOutlined,
NodeIndexOutlined,
PartitionOutlined,
RocketOutlined,
SendOutlined,
@@ -15,6 +18,7 @@ import {
} from '@ant-design/icons';
import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui';
import { GoRegexInput } from '@/components/form';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
import { sanitizePath, normalizePath } from './uriPath';
@@ -148,9 +152,18 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
children: (
<>
<div className="subscription-format-sections">
{allSetting.subJsonEnable && (
<>
<Card
size="small"
className="subscription-format-card"
title={(
<span className="subscription-format-card-title">
<FileTextOutlined />
{t('pages.settings.subJsonEnableTitle')}
</span>
)}
>
<SettingListItem paddings="small" title={<>JSON {t('pages.settings.subPath')}</>} description={t('pages.settings.subPathDesc')}>
<Input
value={allSetting.subJsonPath}
@@ -166,10 +179,40 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
onChange={(e) => updateSetting({ subJsonURI: e.target.value })}
/>
</SettingListItem>
</>
<SettingListItem
paddings="small"
title={t('pages.settings.subJsonAlwaysArray')}
description={t('pages.settings.subJsonAlwaysArrayDesc')}
>
<Switch checked={allSetting.subJsonAlwaysArray} onChange={(value) => updateSetting({ subJsonAlwaysArray: value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subJsonAutoDetect')} description={t('pages.settings.subJsonAutoDetectDesc')}>
<Switch checked={allSetting.subJsonAutoDetect} onChange={(v) => updateSetting({ subJsonAutoDetect: v })} />
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subJsonUserAgentRegex')}
description={t('pages.settings.subJsonUserAgentRegexDesc')}
>
<GoRegexInput
value={allSetting.subJsonUserAgentRegex}
placeholder="(?i)^myclient([ /]|$)"
onChange={(value) => updateSetting({ subJsonUserAgentRegex: value })}
/>
</SettingListItem>
</Card>
)}
{allSetting.subClashEnable && (
<>
<Card
size="small"
className="subscription-format-card"
title={(
<span className="subscription-format-card-title">
<NodeIndexOutlined />
{t('pages.settings.subClashEnableTitle')}
</span>
)}
>
<SettingListItem paddings="small" title={<>Clash {t('pages.settings.subPath')}</>} description={t('pages.settings.subPathDesc')}>
<Input
value={allSetting.subClashPath}
@@ -185,9 +228,30 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
onChange={(e) => updateSetting({ subClashURI: e.target.value })}
/>
</SettingListItem>
</>
<SettingListItem
paddings="small"
title={t('pages.settings.subClashAutoDetect')}
description={t('pages.settings.subClashAutoDetectDesc')}
>
<Switch
checked={allSetting.subClashAutoDetect}
onChange={(v) => updateSetting({ subClashAutoDetect: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subClashUserAgentRegex')}
description={t('pages.settings.subClashUserAgentRegexDesc')}
>
<GoRegexInput
value={allSetting.subClashUserAgentRegex}
placeholder="(?i)(clash|mihomo)"
onChange={(value) => updateSetting({ subClashUserAgentRegex: value })}
/>
</SettingListItem>
</Card>
)}
</>
</div>
),
},
{
@@ -1,6 +1,7 @@
import { Input, InputNumber, Switch, Tabs } from 'antd';
import { Alert, Button, Input, InputNumber, Switch, Tabs } from 'antd';
import { BranchesOutlined, CompassOutlined, IdcardOutlined, InfoCircleOutlined, NodeIndexOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui';
import { RemarkTemplateField } from '@/components/form';
@@ -15,6 +16,7 @@ interface SubscriptionGeneralTabProps {
export default function SubscriptionGeneralTab({ allSetting, updateSetting }: SubscriptionGeneralTabProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const { isMobile } = useMediaQuery();
return (
@@ -33,6 +35,20 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
<SettingListItem paddings="small" title={t('pages.settings.subClashEnableTitle')}>
<Switch checked={allSetting.subClashEnable} onChange={(v) => updateSetting({ subClashEnable: v })} />
</SettingListItem>
{(allSetting.subJsonEnable || allSetting.subClashEnable) && (
<Alert
type="info"
showIcon
style={{ margin: '12px 20px' }}
title={t('pages.settings.subFormatsTipTitle')}
description={t('pages.settings.subFormatsTipDesc')}
action={(
<Button size="small" onClick={() => navigate('/settings#subscription-formats')}>
{t('pages.settings.subFormatsTipAction')}
</Button>
)}
/>
)}
<SettingListItem paddings="small" title={t('pages.settings.subListen')} description={t('pages.settings.subListenDesc')}>
<Input value={allSetting.subListen} onChange={(e) => updateSetting({ subListen: e.target.value })} />
</SettingListItem>