From 1d85ef138ed823969c80409af596dd9400df6c10 Mon Sep 17 00:00:00 2001 From: NgaiYeanCoi Date: Wed, 16 Sep 2026 03:13:29 +0800 Subject: [PATCH] fix(sub): prevent default profile page URL disclosure (#6538) * fix(sub): prevent default profile page URL disclosure Add explicit none, builtin, and custom profile page modes. Preserve existing custom URLs and warn before exposing the built-in page. Cover mode selection, legacy settings, and subscription response headers. * fix(subscription): add profile page link options and upgrade notes --- docs/content/docs/en/config/subscription.mdx | 19 +++ docs/content/docs/fa/config/subscription.mdx | 17 ++ docs/content/docs/ru/config/subscription.mdx | 18 +++ docs/content/docs/zh/config/subscription.mdx | 12 ++ docs/public/openapi.json | 8 + frontend/public/openapi.json | 8 + frontend/src/generated/examples.ts | 2 + frontend/src/generated/schemas.ts | 8 + frontend/src/generated/types.ts | 2 + frontend/src/generated/zod.ts | 2 + frontend/src/models/setting.ts | 11 ++ .../pages/settings/SubscriptionGeneralTab.tsx | 45 +++++- frontend/src/schemas/setting.ts | 4 + .../src/test/setting-profile-mode.test.ts | 36 +++++ .../test/subscription-general-tab.test.tsx | 75 ++++++++- internal/sub/controller.go | 22 ++- internal/sub/placeholders.go | 20 ++- internal/sub/placeholders_test.go | 83 ++++++++-- internal/sub/profile_mode_test.go | 97 ++++++++++++ internal/sub/sub.go | 5 + internal/web/entity/entity.go | 1 + internal/web/service/setting.go | 47 ++++++ .../web/service/setting_sub_profile_test.go | 148 ++++++++++++++++++ internal/web/translation/ar-EG.json | 8 +- internal/web/translation/en-US.json | 8 +- internal/web/translation/es-ES.json | 8 +- internal/web/translation/fa-IR.json | 8 +- internal/web/translation/id-ID.json | 8 +- internal/web/translation/ja-JP.json | 8 +- internal/web/translation/pt-BR.json | 8 +- internal/web/translation/ru-RU.json | 8 +- internal/web/translation/tr-TR.json | 8 +- internal/web/translation/uk-UA.json | 8 +- internal/web/translation/vi-VN.json | 8 +- internal/web/translation/zh-CN.json | 8 +- internal/web/translation/zh-TW.json | 8 +- 36 files changed, 747 insertions(+), 47 deletions(-) create mode 100644 frontend/src/test/setting-profile-mode.test.ts create mode 100644 internal/sub/profile_mode_test.go create mode 100644 internal/web/service/setting_sub_profile_test.go diff --git a/docs/content/docs/en/config/subscription.mdx b/docs/content/docs/en/config/subscription.mdx index d9464bb79..fd7cfda30 100644 --- a/docs/content/docs/en/config/subscription.mdx +++ b/docs/content/docs/en/config/subscription.mdx @@ -94,6 +94,25 @@ Subscriptions return standard headers that compatible apps read: - **`Profile-Title`**, **`Support-Url`**, **`Profile-Web-Page-Url`**, **`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 Under **Subscription → Information**, **Month-end subscription expiry display** diff --git a/docs/content/docs/fa/config/subscription.mdx b/docs/content/docs/fa/config/subscription.mdx index f20092495..376d3957b 100644 --- a/docs/content/docs/fa/config/subscription.mdx +++ b/docs/content/docs/fa/config/subscription.mdx @@ -72,6 +72,23 @@ SOCKS/HTTP روی 127.0.0.1، DNS، مسیریابی، policy) به‌علاوه - **`Profile-Title`**، **`Support-Url`**، **`Profile-Web-Page-Url`**، **`Announce`** — برندینگ اختیاری که برخی کلاینت‌ها نمایش می‌دهند. +### لینک صفحه پروفایل + +در تنظیمات **سابسکریپشن ← پروفایل**، گزینه **صفحه پروفایل** (`subProfileMode`) +لینک را برای همه کلاینت‌های اشتراک کنترل می‌کند: + +- **بدون لینک** (`none`، پیش‌فرض) — هدر `Profile-Web-Page-Url` ارسال نمی‌شود. +- **صفحه اشتراک داخلی** (`builtin`) — لینک صفحه اشتراک داخلی ارائه می‌شود. +- **وب‌سایت سفارشی** (`custom`) — آدرس `subProfileUrl` استفاده می‌شود؛ اگر خالی باشد، هدر ارسال نمی‌شود. + +**پس از ارتقا:** اگر `subProfileMode` هنوز تنظیم نشده و مقدار قبلی `subProfileUrl` +خالی یا فقط شامل فاصله باشد، به‌جای لینک خودکار صفحه داخلی، حالت **بدون لینک** +انتخاب می‌شود. آدرس سفارشی غیرخالی قبلی در حالت **وب‌سایت سفارشی** حفظ می‌شود. + +برای بازگرداندن لینک قبلی، در همین بخش **صفحه اشتراک داخلی** را انتخاب و تنظیمات +را ذخیره کنید. این صفحه آدرس‌های اشتراک و پیکربندی گره‌ها را آشکار می‌کند، حتی +برای اشتراک‌های رمزگذاری‌شده Happ. + ## قالب‌های سفارشی صفحه برای برندینگ صفحه‌ی HTML اشتراک، `subThemeDir` را به یک پوشه‌ی حاوی قالب سفارشیِ diff --git a/docs/content/docs/ru/config/subscription.mdx b/docs/content/docs/ru/config/subscription.mdx index cce0d8118..e0c2fbb36 100644 --- a/docs/content/docs/ru/config/subscription.mdx +++ b/docs/content/docs/ru/config/subscription.mdx @@ -76,6 +76,24 @@ policy) плюс исходящее соединение `proxy`, указыва - **`Profile-Title`**, **`Support-Url`**, **`Profile-Web-Page-Url`**, **`Announce`** — необязательный брендинг, отображаемый некоторыми клиентами. +### Ссылка на страницу профиля + +В настройках **Подписка → Профиль** поле **Страница профиля** (`subProfileMode`) +управляет ссылкой для всех клиентов подписки: + +- **Без ссылки** (`none`, по умолчанию) — заголовок `Profile-Web-Page-Url` не отправляется. +- **Встроенная страница подписки** (`builtin`) — ссылка на встроенную страницу подписки. +- **Свой сайт** (`custom`) — адрес из `subProfileUrl`; если он пуст, заголовок не отправляется. + +**После обновления:** если `subProfileMode` ещё не задан, а прежний `subProfileUrl` +пуст или содержит только пробелы, вместо автоматической ссылки на встроенную +страницу теперь используется **Без ссылки**. Существующий непустой пользовательский +адрес сохраняется в режиме **Свой сайт**. + +Чтобы вернуть прежнюю ссылку, выберите **Встроенная страница подписки** в этом поле +и сохраните настройки. Эта страница раскрывает URL-адреса подписок и конфигурации +узлов, в том числе для зашифрованных подписок Happ. + ## Пользовательские шаблоны страниц Укажите в `subThemeDir` папку с пользовательским шаблоном информационной diff --git a/docs/content/docs/zh/config/subscription.mdx b/docs/content/docs/zh/config/subscription.mdx index 85593052d..56b37221b 100644 --- a/docs/content/docs/zh/config/subscription.mdx +++ b/docs/content/docs/zh/config/subscription.mdx @@ -73,6 +73,18 @@ Clash 格式自动识别保留原有的 `(?i)(clash|mihomo)` 默认匹配器, - **`Profile-Update-Interval`** —— 刷新间隔,以小时为单位(`subUpdates`)。 - **`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)。 diff --git a/docs/public/openapi.json b/docs/public/openapi.json index da2fa60ad..d1f9b499e 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -395,6 +395,9 @@ "minimum": 1, "type": "integer" }, + "subProfileMode": { + "type": "string" + }, "subProfileUrl": { "type": "string" }, @@ -618,6 +621,7 @@ "subListen", "subPath", "subPort", + "subProfileMode", "subProfileUrl", "subRoutingRules", "subShowIdentityOnAllLinks", @@ -1046,6 +1050,9 @@ "minimum": 1, "type": "integer" }, + "subProfileMode": { + "type": "string" + }, "subProfileUrl": { "type": "string" }, @@ -1277,6 +1284,7 @@ "subListen", "subPath", "subPort", + "subProfileMode", "subProfileUrl", "subRoutingRules", "subShowIdentityOnAllLinks", diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index da2fa60ad..d1f9b499e 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -395,6 +395,9 @@ "minimum": 1, "type": "integer" }, + "subProfileMode": { + "type": "string" + }, "subProfileUrl": { "type": "string" }, @@ -618,6 +621,7 @@ "subListen", "subPath", "subPort", + "subProfileMode", "subProfileUrl", "subRoutingRules", "subShowIdentityOnAllLinks", @@ -1046,6 +1050,9 @@ "minimum": 1, "type": "integer" }, + "subProfileMode": { + "type": "string" + }, "subProfileUrl": { "type": "string" }, @@ -1277,6 +1284,7 @@ "subListen", "subPath", "subPort", + "subProfileMode", "subProfileUrl", "subRoutingRules", "subShowIdentityOnAllLinks", diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 2daa0fd8d..4bc4bd071 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -115,6 +115,7 @@ export const EXAMPLES: Record = { "subListen": "", "subPath": "", "subPort": 1, + "subProfileMode": "", "subProfileUrl": "", "subRoutingRules": "", "subShowIdentityOnAllLinks": false, @@ -271,6 +272,7 @@ export const EXAMPLES: Record = { "subListen": "", "subPath": "", "subPort": 1, + "subProfileMode": "", "subProfileUrl": "", "subRoutingRules": "", "subShowIdentityOnAllLinks": false, diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 8b425dc56..0b360d4c9 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -369,6 +369,9 @@ export const SCHEMAS: Record = { "minimum": 1, "type": "integer" }, + "subProfileMode": { + "type": "string" + }, "subProfileUrl": { "type": "string" }, @@ -592,6 +595,7 @@ export const SCHEMAS: Record = { "subListen", "subPath", "subPort", + "subProfileMode", "subProfileUrl", "subRoutingRules", "subShowIdentityOnAllLinks", @@ -1020,6 +1024,9 @@ export const SCHEMAS: Record = { "minimum": 1, "type": "integer" }, + "subProfileMode": { + "type": "string" + }, "subProfileUrl": { "type": "string" }, @@ -1251,6 +1258,7 @@ export const SCHEMAS: Record = { "subListen", "subPath", "subPort", + "subProfileMode", "subProfileUrl", "subRoutingRules", "subShowIdentityOnAllLinks", diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 901d5b44a..0fea615b4 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -122,6 +122,7 @@ export interface AllSetting { subListen: string; subPath: string; subPort: number; + subProfileMode: string; subProfileUrl: string; subRoutingRules: string; subShowIdentityOnAllLinks: boolean; @@ -279,6 +280,7 @@ export interface AllSettingView { subListen: string; subPath: string; subPort: number; + subProfileMode: string; subProfileUrl: string; subRoutingRules: string; subShowIdentityOnAllLinks: boolean; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index b42b4bd47..39d89b8a0 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -136,6 +136,7 @@ export const AllSettingSchema = z.object({ subListen: z.string(), subPath: z.string(), subPort: z.number().int().min(1).max(65535), + subProfileMode: z.string(), subProfileUrl: z.string(), subRoutingRules: z.string(), subShowIdentityOnAllLinks: z.boolean(), @@ -294,6 +295,7 @@ export const AllSettingViewSchema = z.object({ subListen: z.string(), subPath: z.string(), subPort: z.number().int().min(1).max(65535), + subProfileMode: z.string(), subProfileUrl: z.string(), subRoutingRules: z.string(), subShowIdentityOnAllLinks: z.boolean(), diff --git a/frontend/src/models/setting.ts b/frontend/src/models/setting.ts index f7a9331f5..38fffa794 100644 --- a/frontend/src/models/setting.ts +++ b/frontend/src/models/setting.ts @@ -1,4 +1,5 @@ import { ObjectUtil } from '@/utils'; +import type { SubProfileMode } from '@/schemas/setting'; export class AllSetting { webListen = ''; @@ -46,6 +47,7 @@ export class AllSetting { subClashUserAgentRegex = ''; subTitle = ''; subSupportUrl = ''; + subProfileMode: SubProfileMode = 'none'; subProfileUrl = ''; subAnnounce = ''; subEnableRouting = false; @@ -167,6 +169,15 @@ export class AllSetting { if (data != null) { 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)); this.tgCpu = Number.isFinite(cpu) ? Math.min(100, Math.max(0, cpu)) : 80; const threshold = Math.round(Number(this.outboundDownThreshold)); diff --git a/frontend/src/pages/settings/SubscriptionGeneralTab.tsx b/frontend/src/pages/settings/SubscriptionGeneralTab.tsx index 7028f1f3f..a7a28ce63 100644 --- a/frontend/src/pages/settings/SubscriptionGeneralTab.tsx +++ b/frontend/src/pages/settings/SubscriptionGeneralTab.tsx @@ -1,4 +1,4 @@ -import { Alert, Button, Input, InputNumber, Switch, Tabs } from 'antd'; +import { Alert, Button, Input, InputNumber, Select, Switch, Tabs } from 'antd'; import { BranchesOutlined, CompassOutlined, @@ -11,6 +11,7 @@ import { import { useTranslation } from 'react-i18next'; import { useNavigate, useSearchParams } from 'react-router'; import type { AllSetting } from '@/models/setting'; +import type { SubProfileMode } from '@/schemas/setting'; import { onNumber } from '@/utils/onNumber'; import { DefaultSettingTag, SettingListItem } from '@/components/ui'; import { RemarkTemplateField } from '@/components/form'; @@ -279,16 +280,44 @@ export default function SubscriptionGeneralTab({ - updateSetting({ subProfileUrl: v })} - metadataOnly + + id="sub-profile-mode" + aria-label={t('pages.settings.subProfileMode')} + value={allSetting.subProfileMode} + 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') }, + ]} /> + {allSetting.subProfileMode === 'builtin' ? ( + + ) : null} + {allSetting.subProfileMode === 'custom' ? ( + + updateSetting({ subProfileUrl: v })} + metadataOnly + /> + + ) : null} ; + export const AllSettingSchema = z .object({ webListen: z.string().optional(), @@ -50,6 +53,7 @@ export const AllSettingSchema = z subClashUserAgentRegex: z.string().max(2048).optional(), subTitle: z.string().optional(), subSupportUrl: z.string().optional(), + subProfileMode: SubProfileModeSchema.optional(), subProfileUrl: z.string().optional(), subAnnounce: z.string().optional(), subEnableRouting: z.boolean().optional(), diff --git a/frontend/src/test/setting-profile-mode.test.ts b/frontend/src/test/setting-profile-mode.test.ts new file mode 100644 index 000000000..348cfeb65 --- /dev/null +++ b/frontend/src/test/setting-profile-mode.test.ts @@ -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); + }); +}); diff --git a/frontend/src/test/subscription-general-tab.test.tsx b/frontend/src/test/subscription-general-tab.test.tsx index a3506cf06..839b34cfe 100644 --- a/frontend/src/test/subscription-general-tab.test.tsx +++ b/frontend/src/test/subscription-general-tab.test.tsx @@ -1,10 +1,29 @@ +import { useState } from 'react'; import { fireEvent, screen } from '@testing-library/react'; import { MemoryRouter, useLocation } from 'react-router'; import { describe, expect, it, vi } from 'vitest'; import { AllSetting } from '@/models/setting'; 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 ( + <> + + setAllSetting((current) => new AllSetting({ ...current, ...patch })) + } + /> + + {allSetting.subProfileMode}|{allSetting.subProfileUrl} + + + ); +} function LocationProbe() { const location = useLocation(); @@ -18,6 +37,60 @@ function LocationProbe() { } 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( + + + , + ); + + 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( + + + , + ); + + 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', () => { const updateSetting = vi.fn(); diff --git a/internal/sub/controller.go b/internal/sub/controller.go index 6e1b32646..531de17b1 100644 --- a/internal/sub/controller.go +++ b/internal/sub/controller.go @@ -53,6 +53,7 @@ type cachedSubTemplate struct { type SUBController struct { subTitle string subSupportUrl string + subProfileMode string subProfileUrl string subAnnounce string subEnableRouting bool @@ -115,6 +116,7 @@ type subControllerConfig struct { subTitle string subSupportURL string + subProfileMode string subProfileURL string subAnnounce string subEnableRouting bool @@ -224,6 +226,10 @@ func WithSUBProfileURL(value string) SUBControllerOption { return func(config *subControllerConfig) { config.subProfileURL = value } } +func WithSUBProfileMode(value string) SUBControllerOption { + return func(config *subControllerConfig) { config.subProfileMode = value } +} + func WithSUBAnnounce(value string) SUBControllerOption { return func(config *subControllerConfig) { config.subAnnounce = value } } @@ -260,6 +266,7 @@ func defaultSUBControllerConfig() subControllerConfig { subEncrypt: true, remarkTemplate: service.DefaultRemarkTemplate, updateInterval: "12", + subProfileMode: service.SubProfileModeNone, } } @@ -277,6 +284,7 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo a := &SUBController{ subTitle: config.subTitle, subSupportUrl: config.subSupportURL, + subProfileMode: config.subProfileMode, subProfileUrl: config.subProfileURL, subAnnounce: config.subAnnounce, subEnableRouting: config.subEnableRouting, @@ -485,8 +493,7 @@ func (a *SUBController) subs(c *gin.Context) { // Add headers header := subReq.subscriptionUserinfo(traffic) - profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI) - metadata := a.metadataForSubRequest(func() *SubService { return subReq }, subId, profileURL) + metadata := a.metadataForSubRequest(func() *SubService { return subReq }, 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) if a.subIncyEnableRouting && a.subIncyRoutingRules != "" { @@ -818,14 +825,13 @@ func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, co if len(jsonSub) == 0 && header == "" { return false } - profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI) var subReq *SubService metadata := a.metadataForSubRequest(func() *SubService { if subReq == nil { subReq = a.subService.ForRequest(host) } 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) if rawDownload { c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.json"`) @@ -887,14 +893,13 @@ func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool, legacy if len(clashSub) == 0 && header == "" { return false } - profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI) var subReq *SubService metadata := a.metadataForSubRequest(func() *SubService { if subReq == nil { subReq = a.subService.ForRequest(host) } 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) if rawDownload { c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.yaml"`) @@ -906,6 +911,11 @@ func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool, legacy 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. func (a *SUBController) ApplyCommonHeaders( c *gin.Context, diff --git a/internal/sub/placeholders.go b/internal/sub/placeholders.go index f3384fc5e..2140cc82d 100644 --- a/internal/sub/placeholders.go +++ b/internal/sub/placeholders.go @@ -10,6 +10,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" ) type subPlaceholderData struct { @@ -76,10 +77,17 @@ func subMetadataUsesPlaceholders(values ...string) bool { 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 hasContext bool - if subMetadataUsesPlaceholders(a.subTitle, a.subSupportUrl, a.subProfileUrl, a.subAnnounce) { + if subMetadataUsesPlaceholders(a.subTitle, a.subSupportUrl, profileURL, a.subAnnounce) { var err error subReq := getSubReq() 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) } } - profileURL := a.subProfileUrl - if profileURL == "" { - profileURL = fallbackProfileURL - } else { - profileURL = renderSubPlaceholders(profileURL, subPlaceholderData{SubID: subID, Context: context, HasCtx: hasContext, Escape: true}) - } + // Disabled modes ignore the retained custom URL and never fall back to the request URL. + profileURL = renderSubPlaceholders(profileURL, subPlaceholderData{SubID: subID, Context: context, HasCtx: hasContext, Escape: true}) data := subPlaceholderData{SubID: subID, Context: context, HasCtx: hasContext} return renderedSubMetadata{ Title: renderSubPlaceholders(a.subTitle, data), diff --git a/internal/sub/placeholders_test.go b/internal/sub/placeholders_test.go index a10e793fd..db735ec34 100644 --- a/internal/sub/placeholders_test.go +++ b/internal/sub/placeholders_test.go @@ -1,10 +1,15 @@ package sub import ( + "net/http" + "net/http/httptest" "testing" + "github.com/gin-gonic/gin" + "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/service" ) 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{ subTitle: "isVPN", subSupportUrl: "https://support.example/", } - fallback := "https://sub.example.com/sub/sub-123?x={{EMAIL}}" - metadata := a.metadataForSubRequest(func() *SubService { t.Fatal("metadataForSubRequest loaded a subscription context without configured placeholders") return nil - }, "sub-123", fallback) + }, "sub-123", "https://sub.example/sub-123") - if metadata.ProfileURL != fallback { - t.Fatalf("ProfileURL = %q, want untouched fallback %q", metadata.ProfileURL, fallback) + if metadata.ProfileURL != "" { + 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{ - subTitle: "isVPN — {{EMAIL}}", - subSupportUrl: "https://support.example/?email={{EMAIL}}&tg={{TELEGRAM_ID}}", - subProfileUrl: "https://profile.example/account/{{ID}}", - subAnnounce: "Subscription {{SUB_ID}}", + subTitle: "isVPN — {{EMAIL}}", + subSupportUrl: "https://support.example/?email={{EMAIL}}&tg={{TELEGRAM_ID}}", + subProfileUrl: "https://profile.example/account/{{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" { t.Fatalf("Title = %q", metadata.Title) diff --git a/internal/sub/profile_mode_test.go b/internal/sub/profile_mode_test.go new file mode 100644 index 000000000..996843211 --- /dev/null +++ b/internal/sub/profile_mode_test.go @@ -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")) + } + } + }) + } + } + }) + } +} diff --git a/internal/sub/sub.go b/internal/sub/sub.go index e6596ea0d..566705f66 100644 --- a/internal/sub/sub.go +++ b/internal/sub/sub.go @@ -194,6 +194,10 @@ func (s *Server) initRouter() (*gin.Engine, error) { if err != nil { SubProfileUrl = "" } + SubProfileMode, err := s.settingService.GetSubProfileMode() + if err != nil { + SubProfileMode = service.SubProfileModeNone + } SubAnnounce, err := s.settingService.GetSubAnnounce() if err != nil { @@ -329,6 +333,7 @@ func (s *Server) initRouter() (*gin.Engine, error) { WithSUBTitle(SubTitle), WithSUBSupportURL(SubSupportUrl), WithSUBProfileURL(SubProfileUrl), + WithSUBProfileMode(SubProfileMode), WithSUBAnnounce(SubAnnounce), WithSUBEnableRouting(SubEnableRouting), WithSUBRoutingRules(SubRoutingRules), diff --git a/internal/web/entity/entity.go b/internal/web/entity/entity.go index 3b80f39ad..552844325 100644 --- a/internal/web/entity/entity.go +++ b/internal/web/entity/entity.go @@ -94,6 +94,7 @@ type AllSetting struct { SubClashUserAgentRegex string `json:"subClashUserAgentRegex" form:"subClashUserAgentRegex"` SubTitle string `json:"subTitle" form:"subTitle"` SubSupportUrl string `json:"subSupportUrl" form:"subSupportUrl"` + SubProfileMode string `json:"subProfileMode" form:"subProfileMode"` SubProfileUrl string `json:"subProfileUrl" form:"subProfileUrl"` SubAnnounce string `json:"subAnnounce" form:"subAnnounce"` SubEnableRouting bool `json:"subEnableRouting" form:"subEnableRouting"` diff --git a/internal/web/service/setting.go b/internal/web/service/setting.go index 6848cd19a..097dda4e8 100644 --- a/internal/web/service/setting.go +++ b/internal/web/service/setting.go @@ -44,6 +44,13 @@ const ( maxRegexLength = 2048 ) +// Built-in profile links expose the subscription URL and require an explicit opt-in. +const ( + SubProfileModeNone = "none" + SubProfileModeBuiltin = "builtin" + SubProfileModeCustom = "custom" +) + var defaultValueMap = map[string]string{ "xrayTemplateConfig": xrayTemplateConfig, "webListen": "", @@ -101,6 +108,7 @@ var defaultValueMap = map[string]string{ "subClashUserAgentRegex": "", "subTitle": "", "subSupportUrl": "", + "subProfileMode": SubProfileModeNone, "subProfileUrl": "", "subAnnounce": "", "subEnableRouting": "false", @@ -298,6 +306,11 @@ func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) { } } + // A missing mode must still preserve URLs configured before modes existed. + if !keyMap["subProfileMode"] { + allSetting.SubProfileMode = "" + } + allSetting.SubProfileMode = effectiveSubProfileMode(allSetting.SubProfileMode, allSetting.SubProfileUrl) return allSetting, nil } @@ -850,6 +863,34 @@ func (s *SettingService) GetSubProfileUrl() (string, error) { return common.EnsureURLScheme(value), err } +func (s *SettingService) GetSubProfileMode() (string, error) { + setting, err := s.getSetting("subProfileMode") + if err != nil && !database.IsNotFound(err) { + return SubProfileModeNone, err + } + if err == nil && setting.Value != "" { + return effectiveSubProfileMode(setting.Value, ""), nil + } + profileURL, err := s.getString("subProfileUrl") + if err != nil { + return SubProfileModeNone, err + } + return effectiveSubProfileMode("", profileURL), nil +} + +func effectiveSubProfileMode(mode, profileURL string) string { + switch mode { + case SubProfileModeNone, SubProfileModeBuiltin, SubProfileModeCustom: + return mode + case "": + // Older settings have no mode; only an existing custom URL opts them in. + if strings.TrimSpace(profileURL) != "" { + return SubProfileModeCustom + } + } + return SubProfileModeNone +} + func (s *SettingService) GetSubAnnounce() (string, error) { return s.getString("subAnnounce") } @@ -1448,6 +1489,12 @@ type SecretClears struct { } func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting, clears SecretClears) error { + switch allSetting.SubProfileMode { + case "", SubProfileModeNone, SubProfileModeBuiltin, SubProfileModeCustom: + allSetting.SubProfileMode = effectiveSubProfileMode(allSetting.SubProfileMode, allSetting.SubProfileUrl) + default: + return errors.New("subscription profile mode must be none, builtin, or custom") + } if err := s.preserveRedactedSecrets(allSetting, clears); err != nil { return err } diff --git a/internal/web/service/setting_sub_profile_test.go b/internal/web/service/setting_sub_profile_test.go new file mode 100644 index 000000000..8043b526b --- /dev/null +++ b/internal/web/service/setting_sub_profile_test.go @@ -0,0 +1,148 @@ +package service + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +func TestSubProfileModeReadsLegacyAndExplicitSettings(t *testing.T) { + tests := []struct { + name string + storedMode string + storedURL string + modeExists bool + want string + }{ + {name: "fresh installation", want: "none"}, + {name: "legacy URL", storedURL: " https://profile.example/account ", want: "custom"}, + {name: "legacy blank URL", storedURL: " \t ", want: "none"}, + {name: "legacy empty mode with URL", modeExists: true, storedURL: "https://profile.example/account", want: "custom"}, + {name: "legacy empty mode without URL", modeExists: true, want: "none"}, + {name: "explicit none preserves saved URL", modeExists: true, storedMode: "none", storedURL: "https://profile.example/account", want: "none"}, + {name: "explicit builtin", modeExists: true, storedMode: "builtin", storedURL: "https://profile.example/account", want: "builtin"}, + {name: "explicit custom", modeExists: true, storedMode: "custom", storedURL: "https://profile.example/account", want: "custom"}, + {name: "custom with empty URL", modeExists: true, storedMode: "custom", want: "custom"}, + {name: "invalid stored mode fails closed", modeExists: true, storedMode: "automatic", storedURL: "https://profile.example/account", want: "none"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setupSettingTestDB(t) + s := &SettingService{} + if tt.modeExists { + if err := s.saveSetting("subProfileMode", tt.storedMode); err != nil { + t.Fatal(err) + } + } + if err := s.saveSetting("subProfileUrl", tt.storedURL); err != nil { + t.Fatal(err) + } + assertSubProfileSettings(t, s, tt.want, tt.storedURL) + }) + } +} + +func TestSubProfileModeUpdatesPreserveURLAndLegacyPayloads(t *testing.T) { + setupSettingTestDB(t) + s := &SettingService{} + if got := s.GetFactoryDefaults()["subProfileMode"]; got != "none" { + t.Errorf("factory profile mode = %q, want none", got) + } + tests := []struct { + name string + mode string + url string + want string + }{ + {name: "custom", mode: "custom", url: "https://profile.example/account", want: "custom"}, + {name: "none retains custom URL", mode: "none", url: "https://profile.example/account", want: "none"}, + {name: "builtin retains custom URL", mode: "builtin", url: "https://profile.example/account", want: "builtin"}, + {name: "custom restores saved URL", mode: "custom", url: "https://profile.example/account", want: "custom"}, + {name: "legacy URL submission", url: "https://legacy.example/account", want: "custom"}, + {name: "legacy empty URL submission", want: "none"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + settings, err := s.GetAllSetting() + if err != nil { + t.Fatal(err) + } + payload, err := json.Marshal(map[string]string{"subProfileMode": tt.mode, "subProfileUrl": tt.url}) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(payload, settings); err != nil { + t.Fatal(err) + } + if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil { + t.Fatal(err) + } + assertSubProfileSettings(t, s, tt.want, tt.url) + stored, err := s.getSetting("subProfileMode") + if err != nil { + t.Fatal(err) + } + if stored.Value != tt.want { + t.Fatalf("persisted mode = %q, want %q", stored.Value, tt.want) + } + }) + } +} + +func TestSubProfileModeRejectsInvalidUpdateBeforeWrites(t *testing.T) { + setupSettingTestDB(t) + s := &SettingService{} + if err := s.saveSetting("subTitle", "Original title"); err != nil { + t.Fatal(err) + } + var before []model.Setting + if err := database.GetDB().Order("id").Find(&before).Error; err != nil { + t.Fatal(err) + } + settings, err := s.GetAllSetting() + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(`{"subProfileMode":"automatic","subTitle":"Changed title","subProfileUrl":"https://profile.example/account"}`), settings); err != nil { + t.Fatal(err) + } + err = s.UpdateAllSetting(settings, SecretClears{}) + if err == nil || err.Error() != "subscription profile mode must be none, builtin, or custom" { + t.Errorf("UpdateAllSetting error = %v, want invalid profile mode error", err) + } + var after []model.Setting + if err := database.GetDB().Order("id").Find(&after).Error; err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(before, after) { + t.Fatal("invalid profile mode update modified stored settings") + } +} + +func assertSubProfileSettings(t *testing.T, s *SettingService, wantMode, wantURL string) { + t.Helper() + if mode, err := s.GetSubProfileMode(); err != nil || mode != wantMode { + t.Fatalf("GetSubProfileMode = %q, %v; want %q, nil", mode, err, wantMode) + } + settings, err := s.GetAllSetting() + if err != nil { + t.Fatal(err) + } + payload, err := json.Marshal(settings) + if err != nil { + t.Fatal(err) + } + var profile struct { + Mode string `json:"subProfileMode"` + URL string `json:"subProfileUrl"` + } + if err := json.Unmarshal(payload, &profile); err != nil { + t.Fatal(err) + } + if profile.Mode != wantMode || profile.URL != wantURL { + t.Fatalf("profile settings = (%q, %q), want (%q, %q)", profile.Mode, profile.URL, wantMode, wantURL) + } +} diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index abc1bcc7e..5654ff94b 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -1200,8 +1200,14 @@ "subTitleDesc": "العنوان اللي هيظهر في عميل VPN. يدعم رموز هوية العميل: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", "subSupportUrl": "رابط الدعم", "subSupportUrlDesc": "رابط الدعم الفني المعروض في عميل VPN. يدعم رموز هوية العميل: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", + "subProfileMode": "صفحة الملف الشخصي", + "subProfileModeDesc": "اختر رابط الموقع الإلكتروني الذي يظهر في عميل VPN.", + "subProfileModeNone": "بدون رابط", + "subProfileModeBuiltin": "صفحة الاشتراك المدمجة", + "subProfileModeCustom": "موقع إلكتروني مخصص", + "subProfileBuiltinWarning": "تكشف هذه الصفحة روابط الاشتراك وإعدادات العقد، بما في ذلك اشتراكات Happ المشفرة.", "subProfileUrl": "رابط الملف الشخصي", - "subProfileUrlDesc": "رابط لموقعك الإلكتروني يظهر في عميل VPN. يدعم رموز هوية العميل: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", + "subProfileUrlDesc": "رابط لموقعك الإلكتروني يظهر في عميل VPN. يدعم رموز هوية العميل: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}. اتركه فارغًا لعدم عرض رابط الموقع في عميل VPN.", "subAnnounce": "إعلان", "subAnnounceDesc": "نص الإعلان المعروض في عميل VPN. يدعم رموز هوية العميل: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", "subThemeDir": "مجلد قالب الاشتراك", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index c15d6ff2e..bfb27f234 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -1322,8 +1322,14 @@ "subTitleDesc": "Title shown in VPN client. Supports client identity tokens: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", "subSupportUrl": "Support URL", "subSupportUrlDesc": "Technical support link shown in the VPN client. Supports client identity tokens: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", + "subProfileMode": "Profile page", + "subProfileModeDesc": "Choose which website link is shown in the VPN client.", + "subProfileModeNone": "No link", + "subProfileModeBuiltin": "Built-in subscription page", + "subProfileModeCustom": "Custom website", + "subProfileBuiltinWarning": "This page exposes subscription URLs and node configurations, including for Happ encrypted subscriptions.", "subProfileUrl": "Profile URL", - "subProfileUrlDesc": "A link to your website displayed in the VPN client. Supports client identity tokens: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", + "subProfileUrlDesc": "A link to your website displayed in the VPN client. Supports client identity tokens: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}. Leave empty to omit the website link.", "subAnnounce": "Announce", "subAnnounceDesc": "The announcement text displayed in the VPN client. Supports client identity tokens: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", "subThemeDir": "Sub Theme Directory", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 8c16e2e57..3dc602073 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -1200,8 +1200,14 @@ "subTitleDesc": "Título mostrado en el cliente VPN. Admite tokens de identidad del cliente: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", "subSupportUrl": "URL de soporte", "subSupportUrlDesc": "Enlace de soporte técnico mostrado en el cliente VPN. Admite tokens de identidad del cliente: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", + "subProfileMode": "Página de perfil", + "subProfileModeDesc": "Elige qué enlace al sitio web se muestra en el cliente VPN.", + "subProfileModeNone": "Sin enlace", + "subProfileModeBuiltin": "Página de suscripción integrada", + "subProfileModeCustom": "Sitio web personalizado", + "subProfileBuiltinWarning": "Esta página expone las URL de suscripción y las configuraciones de los nodos, incluso para las suscripciones cifradas de Happ.", "subProfileUrl": "URL del perfil", - "subProfileUrlDesc": "Un enlace a tu sitio web mostrado en el cliente VPN. Admite tokens de identidad del cliente: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", + "subProfileUrlDesc": "Un enlace a tu sitio web mostrado en el cliente VPN. Admite tokens de identidad del cliente: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}. Déjalo vacío para omitir el enlace al sitio web en el cliente VPN.", "subAnnounce": "Anuncio", "subAnnounceDesc": "El texto del anuncio mostrado en el cliente VPN. Admite tokens de identidad del cliente: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", "subThemeDir": "Directorio del tema de suscripción", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 46cf48ed6..c3d77fadd 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -1204,8 +1204,14 @@ "subTitleDesc": "عنوان نمایش داده شده در کلاینت VPN. از توکن‌های هویت کلاینت پشتیبانی می‌کند: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", "subSupportUrl": "آدرس پشتیبانی", "subSupportUrlDesc": "لینک پشتیبانی فنی که در کلاینت VPN نمایش داده می‌شود. از توکن‌های هویت کلاینت پشتیبانی می‌کند: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", + "subProfileMode": "صفحه پروفایل", + "subProfileModeDesc": "انتخاب کنید کدام لینک وب‌سایت در کلاینت VPN نمایش داده شود.", + "subProfileModeNone": "بدون لینک", + "subProfileModeBuiltin": "صفحه اشتراک داخلی", + "subProfileModeCustom": "وب‌سایت سفارشی", + "subProfileBuiltinWarning": "این صفحه آدرس‌های اشتراک و پیکربندی گره‌ها را آشکار می‌کند، حتی برای اشتراک‌های رمزگذاری‌شده Happ.", "subProfileUrl": "آدرس پروفایل", - "subProfileUrlDesc": "لینک وب‌سایت شما که در کلاینت VPN نمایش داده می‌شود. از توکن‌های هویت کلاینت پشتیبانی می‌کند: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", + "subProfileUrlDesc": "لینک وب‌سایت شما که در کلاینت VPN نمایش داده می‌شود. از توکن‌های هویت کلاینت پشتیبانی می‌کند: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}. برای عدم نمایش لینک وب‌سایت در کلاینت VPN، این فیلد را خالی بگذارید.", "subAnnounce": "اعلان", "subAnnounceDesc": "متن اعلانی که در کلاینت VPN نمایش داده می‌شود. از توکن‌های هویت کلاینت پشتیبانی می‌کند: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", "subThemeDir": "پوشه قالب صفحه اشتراک", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 40a79a218..d036ddd40 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -1200,8 +1200,14 @@ "subTitleDesc": "Judul yang ditampilkan di klien VPN. Mendukung token identitas klien: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", "subSupportUrl": "URL Dukungan", "subSupportUrlDesc": "Tautan dukungan teknis yang ditampilkan di klien VPN. Mendukung token identitas klien: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", + "subProfileMode": "Halaman profil", + "subProfileModeDesc": "Pilih tautan situs web yang ditampilkan di klien VPN.", + "subProfileModeNone": "Tanpa tautan", + "subProfileModeBuiltin": "Halaman langganan bawaan", + "subProfileModeCustom": "Situs web kustom", + "subProfileBuiltinWarning": "Halaman ini menampilkan URL langganan dan konfigurasi node, termasuk untuk langganan terenkripsi Happ.", "subProfileUrl": "URL Profil", - "subProfileUrlDesc": "Tautan ke situs web Anda yang ditampilkan di klien VPN. Mendukung token identitas klien: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", + "subProfileUrlDesc": "Tautan ke situs web Anda yang ditampilkan di klien VPN. Mendukung token identitas klien: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}. Biarkan kosong agar tautan situs web tidak ditampilkan di klien VPN.", "subAnnounce": "Pengumuman", "subAnnounceDesc": "Teks pengumuman yang ditampilkan di klien VPN. Mendukung token identitas klien: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", "subThemeDir": "Direktori Tema Langganan", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index fa462ba71..26adaaca3 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -1200,8 +1200,14 @@ "subTitleDesc": "VPNクライアントに表示されるタイトル。クライアント識別トークンをサポートします: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}。", "subSupportUrl": "サポートURL", "subSupportUrlDesc": "VPNクライアントに表示されるテクニカルサポートへのリンク。クライアント識別トークンをサポートします: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}。", + "subProfileMode": "プロフィールページ", + "subProfileModeDesc": "VPNクライアントに表示するWebサイトへのリンクを選択します。", + "subProfileModeNone": "リンクなし", + "subProfileModeBuiltin": "組み込みのサブスクリプションページ", + "subProfileModeCustom": "カスタムWebサイト", + "subProfileBuiltinWarning": "このページでは、Happで暗号化されたサブスクリプションも含め、サブスクリプションURLとノード設定が公開されます。", "subProfileUrl": "プロフィールURL", - "subProfileUrlDesc": "VPNクライアントに表示されるWebサイトへのリンク。クライアント識別トークンをサポートします: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}。", + "subProfileUrlDesc": "VPNクライアントに表示されるWebサイトへのリンク。クライアント識別トークンをサポートします: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}。空欄にすると、VPNクライアントにWebサイトへのリンクを表示しません。", "subAnnounce": "お知らせ", "subAnnounceDesc": "VPNクライアントに表示されるお知らせのテキスト。クライアント識別トークンをサポートします: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}。", "subThemeDir": "サブスクリプションテーマディレクトリ", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 6a971e4cd..96cd5a621 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -1200,8 +1200,14 @@ "subTitleDesc": "Título exibido no cliente VPN. Suporta tokens de identidade do cliente: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", "subSupportUrl": "URL de Suporte", "subSupportUrlDesc": "Link de suporte técnico exibido no cliente VPN. Suporta tokens de identidade do cliente: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", + "subProfileMode": "Página de perfil", + "subProfileModeDesc": "Escolha qual link de site é exibido no cliente VPN.", + "subProfileModeNone": "Sem link", + "subProfileModeBuiltin": "Página de assinatura integrada", + "subProfileModeCustom": "Site personalizado", + "subProfileBuiltinWarning": "Esta página expõe as URLs de assinatura e as configurações dos nós, inclusive para assinaturas criptografadas do Happ.", "subProfileUrl": "URL de Perfil", - "subProfileUrlDesc": "Um link para o seu site exibido no cliente VPN. Suporta tokens de identidade do cliente: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", + "subProfileUrlDesc": "Um link para o seu site exibido no cliente VPN. Suporta tokens de identidade do cliente: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}. Deixe em branco para omitir o link do site no cliente VPN.", "subAnnounce": "Anúncio", "subAnnounceDesc": "O texto do anúncio exibido no cliente VPN. Suporta tokens de identidade do cliente: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", "subThemeDir": "Diretório do tema de assinatura", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 6ed46153f..e98fd4b93 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -1200,8 +1200,14 @@ "subTitleDesc": "Название подписки, которое видит клиент в VPN-клиенте. Поддерживает токены идентификации клиента: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", "subSupportUrl": "URL поддержки", "subSupportUrlDesc": "Ссылка на техническую поддержку, отображаемая в VPN-клиенте. Поддерживает токены идентификации клиента: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", + "subProfileMode": "Страница профиля", + "subProfileModeDesc": "Выберите, какая ссылка на сайт будет отображаться в VPN-клиенте.", + "subProfileModeNone": "Без ссылки", + "subProfileModeBuiltin": "Встроенная страница подписки", + "subProfileModeCustom": "Свой сайт", + "subProfileBuiltinWarning": "Эта страница раскрывает URL-адреса подписок и конфигурации узлов, в том числе для зашифрованных подписок Happ.", "subProfileUrl": "URL профиля", - "subProfileUrlDesc": "Ссылка на ваш сайт, отображаемая в VPN-клиенте. Поддерживает токены идентификации клиента: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", + "subProfileUrlDesc": "Ссылка на ваш сайт, отображаемая в VPN-клиенте. Поддерживает токены идентификации клиента: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}. Оставьте поле пустым, чтобы не отображать ссылку на сайт в VPN-клиенте.", "subAnnounce": "Объявление", "subAnnounceDesc": "Текст объявления, отображаемый в VPN-клиенте. Поддерживает токены идентификации клиента: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", "subThemeDir": "Каталог темы подписки", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 21b3c2d13..d2163f804 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -1200,8 +1200,14 @@ "subTitleDesc": "VPN istemcisinde gösterilen başlık. İstemci kimlik tokenlarını destekler: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", "subSupportUrl": "Destek URL'si", "subSupportUrlDesc": "VPN istemcisinde gösterilen teknik destek bağlantısı. İstemci kimlik tokenlarını destekler: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", + "subProfileMode": "Profil sayfası", + "subProfileModeDesc": "VPN istemcisinde hangi web sitesi bağlantısının gösterileceğini seçin.", + "subProfileModeNone": "Bağlantı yok", + "subProfileModeBuiltin": "Yerleşik abonelik sayfası", + "subProfileModeCustom": "Özel web sitesi", + "subProfileBuiltinWarning": "Bu sayfa, Happ ile şifrelenmiş abonelikler dahil olmak üzere abonelik URL'lerini ve düğüm yapılandırmalarını açığa çıkarır.", "subProfileUrl": "Profil URL'si", - "subProfileUrlDesc": "VPN istemcisinde görüntülenen web sitenize giden bağlantı. İstemci kimlik tokenlarını destekler: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", + "subProfileUrlDesc": "VPN istemcisinde görüntülenen web sitenize giden bağlantı. İstemci kimlik tokenlarını destekler: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}. Web sitesi bağlantısının VPN istemcisinde gösterilmemesi için boş bırakın.", "subAnnounce": "Duyuru", "subAnnounceDesc": "VPN istemcisinde görüntülenen duyuru metni. İstemci kimlik tokenlarını destekler: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", "subThemeDir": "Abonelik Tema Dizini", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 3c511bcf5..b004ddd2c 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -1200,8 +1200,14 @@ "subTitleDesc": "Назва, яка відображається у VPN-клієнті. Підтримує токени ідентифікації клієнта: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", "subSupportUrl": "URL підтримки", "subSupportUrlDesc": "Посилання на технічну підтримку, що відображається у VPN-клієнті. Підтримує токени ідентифікації клієнта: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", + "subProfileMode": "Сторінка профілю", + "subProfileModeDesc": "Виберіть, яке посилання на вебсайт відображатиметься у VPN-клієнті.", + "subProfileModeNone": "Без посилання", + "subProfileModeBuiltin": "Вбудована сторінка підписки", + "subProfileModeCustom": "Власний вебсайт", + "subProfileBuiltinWarning": "Ця сторінка розкриває URL-адреси підписок і конфігурації вузлів, зокрема для зашифрованих підписок Happ.", "subProfileUrl": "URL профілю", - "subProfileUrlDesc": "Посилання на ваш вебсайт, що відображається у VPN-клієнті. Підтримує токени ідентифікації клієнта: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", + "subProfileUrlDesc": "Посилання на ваш вебсайт, що відображається у VPN-клієнті. Підтримує токени ідентифікації клієнта: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}. Залиште поле порожнім, щоб не відображати посилання на вебсайт у VPN-клієнті.", "subAnnounce": "Оголошення", "subAnnounceDesc": "Текст оголошення, що відображається у VPN-клієнті. Підтримує токени ідентифікації клієнта: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", "subThemeDir": "Каталог теми підписки", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 05a9995ff..c6308ff79 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -1200,8 +1200,14 @@ "subTitleDesc": "Tiêu đề hiển thị trong ứng dụng VPN. Hỗ trợ token định danh khách hàng: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", "subSupportUrl": "URL Hỗ trợ", "subSupportUrlDesc": "Liên kết hỗ trợ kỹ thuật hiển thị trong ứng dụng VPN. Hỗ trợ token định danh khách hàng: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", + "subProfileMode": "Trang hồ sơ", + "subProfileModeDesc": "Chọn liên kết trang web được hiển thị trong ứng dụng VPN.", + "subProfileModeNone": "Không cung cấp liên kết", + "subProfileModeBuiltin": "Trang đăng ký tích hợp", + "subProfileModeCustom": "Trang web tùy chỉnh", + "subProfileBuiltinWarning": "Trang này công khai URL đăng ký và cấu hình nút, kể cả đối với các đăng ký được mã hóa bằng Happ.", "subProfileUrl": "URL Hồ sơ", - "subProfileUrlDesc": "Liên kết đến trang web của bạn hiển thị trong ứng dụng VPN. Hỗ trợ token định danh khách hàng: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", + "subProfileUrlDesc": "Liên kết đến trang web của bạn hiển thị trong ứng dụng VPN. Hỗ trợ token định danh khách hàng: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}. Để trống để không hiển thị liên kết trang web trong ứng dụng VPN.", "subAnnounce": "Thông báo", "subAnnounceDesc": "Văn bản thông báo hiển thị trong ứng dụng VPN. Hỗ trợ token định danh khách hàng: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.", "subThemeDir": "Thư mục giao diện Đăng ký", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 13a5bfc57..d5ba80846 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -1200,8 +1200,14 @@ "subTitleDesc": "在 VPN 客户端中显示的标题。支持客户端身份令牌:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。", "subSupportUrl": "支持链接", "subSupportUrlDesc": "VPN 客户端中显示的技术支持链接。支持客户端身份令牌:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。", + "subProfileMode": "资料页方式", + "subProfileModeDesc": "选择在 VPN 客户端中提供的资料页入口。", + "subProfileModeNone": "不提供", + "subProfileModeBuiltin": "内置订阅页", + "subProfileModeCustom": "自定义网站", + "subProfileBuiltinWarning": "此页面会公开订阅地址和节点配置,HAPP 加密订阅也不例外。", "subProfileUrl": "个人资料链接", - "subProfileUrlDesc": "VPN 客户端中显示的网站链接。支持客户端身份令牌:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。", + "subProfileUrlDesc": "VPN 客户端中显示的网站链接。支持客户端身份令牌:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。留空则不在 VPN 客户端提供网站链接。", "subAnnounce": "公告", "subAnnounceDesc": "VPN 客户端中显示的公告文本。支持客户端身份令牌:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。", "subThemeDir": "订阅主题目录", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index f0101c9b7..40cef2383 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -1200,8 +1200,14 @@ "subTitleDesc": "在 VPN 客戶端中顯示的標題。支援用戶端身分權杖:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。", "subSupportUrl": "支援連結", "subSupportUrlDesc": "VPN 用戶端中顯示的技術支援連結。支援用戶端身分權杖:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。", + "subProfileMode": "資料頁方式", + "subProfileModeDesc": "選擇在 VPN 用戶端中提供的資料頁入口。", + "subProfileModeNone": "不提供", + "subProfileModeBuiltin": "內建訂閱頁", + "subProfileModeCustom": "自訂網站", + "subProfileBuiltinWarning": "此頁面會公開訂閱網址和節點設定,HAPP 加密訂閱也不例外。", "subProfileUrl": "個人資料連結", - "subProfileUrlDesc": "VPN 用戶端中顯示的網站連結。支援用戶端身分權杖:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。", + "subProfileUrlDesc": "VPN 用戶端中顯示的網站連結。支援用戶端身分權杖:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。留空則不在 VPN 用戶端提供網站連結。", "subAnnounce": "公告", "subAnnounceDesc": "VPN 用戶端中顯示的公告文字。支援用戶端身分權杖:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。", "subThemeDir": "訂閱主題目錄",