Browse Source

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
NgaiYeanCoi 10 hours ago
parent
commit
1d85ef138e
36 changed files with 747 additions and 47 deletions
  1. 19 0
      docs/content/docs/en/config/subscription.mdx
  2. 17 0
      docs/content/docs/fa/config/subscription.mdx
  3. 18 0
      docs/content/docs/ru/config/subscription.mdx
  4. 12 0
      docs/content/docs/zh/config/subscription.mdx
  5. 8 0
      docs/public/openapi.json
  6. 8 0
      frontend/public/openapi.json
  7. 2 0
      frontend/src/generated/examples.ts
  8. 8 0
      frontend/src/generated/schemas.ts
  9. 2 0
      frontend/src/generated/types.ts
  10. 2 0
      frontend/src/generated/zod.ts
  11. 11 0
      frontend/src/models/setting.ts
  12. 37 8
      frontend/src/pages/settings/SubscriptionGeneralTab.tsx
  13. 4 0
      frontend/src/schemas/setting.ts
  14. 36 0
      frontend/src/test/setting-profile-mode.test.ts
  15. 74 1
      frontend/src/test/subscription-general-tab.test.tsx
  16. 16 6
      internal/sub/controller.go
  17. 12 8
      internal/sub/placeholders.go
  18. 72 11
      internal/sub/placeholders_test.go
  19. 97 0
      internal/sub/profile_mode_test.go
  20. 5 0
      internal/sub/sub.go
  21. 1 0
      internal/web/entity/entity.go
  22. 47 0
      internal/web/service/setting.go
  23. 148 0
      internal/web/service/setting_sub_profile_test.go
  24. 7 1
      internal/web/translation/ar-EG.json
  25. 7 1
      internal/web/translation/en-US.json
  26. 7 1
      internal/web/translation/es-ES.json
  27. 7 1
      internal/web/translation/fa-IR.json
  28. 7 1
      internal/web/translation/id-ID.json
  29. 7 1
      internal/web/translation/ja-JP.json
  30. 7 1
      internal/web/translation/pt-BR.json
  31. 7 1
      internal/web/translation/ru-RU.json
  32. 7 1
      internal/web/translation/tr-TR.json
  33. 7 1
      internal/web/translation/uk-UA.json
  34. 7 1
      internal/web/translation/vi-VN.json
  35. 7 1
      internal/web/translation/zh-CN.json
  36. 7 1
      internal/web/translation/zh-TW.json

+ 19 - 0
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`**,
 - **`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**

+ 17 - 0
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`**،
 - **`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` را به یک پوشه‌ی حاوی قالب سفارشیِ

+ 18 - 0
docs/content/docs/ru/config/subscription.mdx

@@ -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` папку с пользовательским шаблоном информационной

+ 12 - 0
docs/content/docs/zh/config/subscription.mdx

@@ -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)。

+ 8 - 0
docs/public/openapi.json

@@ -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",

+ 8 - 0
frontend/public/openapi.json

@@ -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",

+ 2 - 0
frontend/src/generated/examples.ts

@@ -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,

+ 8 - 0
frontend/src/generated/schemas.ts

@@ -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",

+ 2 - 0
frontend/src/generated/types.ts

@@ -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;

+ 2 - 0
frontend/src/generated/zod.ts

@@ -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(),

+ 11 - 0
frontend/src/models/setting.ts

@@ -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));

+ 37 - 8
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 {
 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')}
-                description={t('pages.settings.subProfileUrlDesc')}
+                title={t('pages.settings.subProfileMode')}
+                description={t('pages.settings.subProfileModeDesc')}
               >
               >
-                <RemarkTemplateField
-                  value={allSetting.subProfileUrl}
-                  placeholder="https://example.com"
-                  onChange={(v) => updateSetting({ subProfileUrl: v })}
-                  metadataOnly
+                <Select<SubProfileMode>
+                  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') },
+                  ]}
                 />
                 />
               </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')}

+ 4 - 0
frontend/src/schemas/setting.ts

@@ -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(),

+ 36 - 0
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);
+  });
+});

+ 74 - 1
frontend/src/test/subscription-general-tab.test.tsx

@@ -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();
 
 

+ 16 - 6
internal/sub/controller.go

@@ -53,6 +53,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 +116,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 +226,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 +266,7 @@ func defaultSUBControllerConfig() subControllerConfig {
 		subEncrypt:     true,
 		subEncrypt:     true,
 		remarkTemplate: service.DefaultRemarkTemplate,
 		remarkTemplate: service.DefaultRemarkTemplate,
 		updateInterval: "12",
 		updateInterval: "12",
+		subProfileMode: service.SubProfileModeNone,
 	}
 	}
 }
 }
 
 
@@ -277,6 +284,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 +493,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, 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)
 		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 != "" {
@@ -818,14 +825,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 +893,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 +911,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,

+ 12 - 8
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"
 	"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
-	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}
 	data := subPlaceholderData{SubID: subID, Context: context, HasCtx: hasContext}
 	return renderedSubMetadata{
 	return renderedSubMetadata{
 		Title:      renderSubPlaceholders(a.subTitle, data),
 		Title:      renderSubPlaceholders(a.subTitle, data),

+ 72 - 11
internal/sub/placeholders_test.go

@@ -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 != "" {
+		t.Fatalf("ProfileURL = %q, want no link when unconfigured", metadata.ProfileURL)
+	}
+}
 
 
-	if metadata.ProfileURL != fallback {
-		t.Fatalf("ProfileURL = %q, want untouched fallback %q", metadata.ProfileURL, fallback)
+func TestSubscriptionProfileURLRequiresExplicitConfiguration(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	initSubDB(t)
+	seedInfoEndpointSub(t, "profile-sub", "[email protected]")
+
+	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}}",
-		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 [email protected]" {
 	if metadata.Title != "isVPN — john [email protected]" {
 		t.Fatalf("Title = %q", metadata.Title)
 		t.Fatalf("Title = %q", metadata.Title)

+ 97 - 0
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", "[email protected]")
+			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"))
+							}
+						}
+					})
+				}
+			}
+		})
+	}
+}

+ 5 - 0
internal/sub/sub.go

@@ -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),

+ 1 - 0
internal/web/entity/entity.go

@@ -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"`

+ 47 - 0
internal/web/service/setting.go

@@ -44,6 +44,13 @@ const (
 	maxRegexLength                    = 2048
 	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{
 var defaultValueMap = map[string]string{
 	"xrayTemplateConfig": xrayTemplateConfig,
 	"xrayTemplateConfig": xrayTemplateConfig,
 	"webListen":          "",
 	"webListen":          "",
@@ -101,6 +108,7 @@ var defaultValueMap = map[string]string{
 	"subClashUserAgentRegex":      "",
 	"subClashUserAgentRegex":      "",
 	"subTitle":                    "",
 	"subTitle":                    "",
 	"subSupportUrl":               "",
 	"subSupportUrl":               "",
+	"subProfileMode":              SubProfileModeNone,
 	"subProfileUrl":               "",
 	"subProfileUrl":               "",
 	"subAnnounce":                 "",
 	"subAnnounce":                 "",
 	"subEnableRouting":            "false",
 	"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
 	return allSetting, nil
 }
 }
 
 
@@ -850,6 +863,34 @@ func (s *SettingService) GetSubProfileUrl() (string, error) {
 	return common.EnsureURLScheme(value), err
 	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) {
 func (s *SettingService) GetSubAnnounce() (string, error) {
 	return s.getString("subAnnounce")
 	return s.getString("subAnnounce")
 }
 }
@@ -1448,6 +1489,12 @@ type SecretClears struct {
 }
 }
 
 
 func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting, clears SecretClears) error {
 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 {
 	if err := s.preserveRedactedSecrets(allSetting, clears); err != nil {
 		return err
 		return err
 	}
 	}

+ 148 - 0
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)
+	}
+}

+ 7 - 1
internal/web/translation/ar-EG.json

@@ -1200,8 +1200,14 @@
       "subTitleDesc": "العنوان اللي هيظهر في عميل VPN. يدعم رموز هوية العميل: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
       "subTitleDesc": "العنوان اللي هيظهر في عميل VPN. يدعم رموز هوية العميل: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
       "subSupportUrl": "رابط الدعم",
       "subSupportUrl": "رابط الدعم",
       "subSupportUrlDesc": "رابط الدعم الفني المعروض في عميل VPN. يدعم رموز هوية العميل: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
       "subSupportUrlDesc": "رابط الدعم الفني المعروض في عميل VPN. يدعم رموز هوية العميل: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
+      "subProfileMode": "صفحة الملف الشخصي",
+      "subProfileModeDesc": "اختر رابط الموقع الإلكتروني الذي يظهر في عميل VPN.",
+      "subProfileModeNone": "بدون رابط",
+      "subProfileModeBuiltin": "صفحة الاشتراك المدمجة",
+      "subProfileModeCustom": "موقع إلكتروني مخصص",
+      "subProfileBuiltinWarning": "تكشف هذه الصفحة روابط الاشتراك وإعدادات العقد، بما في ذلك اشتراكات Happ المشفرة.",
       "subProfileUrl": "رابط الملف الشخصي",
       "subProfileUrl": "رابط الملف الشخصي",
-      "subProfileUrlDesc": "رابط لموقعك الإلكتروني يظهر في عميل VPN. يدعم رموز هوية العميل: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
+      "subProfileUrlDesc": "رابط لموقعك الإلكتروني يظهر في عميل VPN. يدعم رموز هوية العميل: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}. اتركه فارغًا لعدم عرض رابط الموقع في عميل VPN.",
       "subAnnounce": "إعلان",
       "subAnnounce": "إعلان",
       "subAnnounceDesc": "نص الإعلان المعروض في عميل VPN. يدعم رموز هوية العميل: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
       "subAnnounceDesc": "نص الإعلان المعروض في عميل VPN. يدعم رموز هوية العميل: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
       "subThemeDir": "مجلد قالب الاشتراك",
       "subThemeDir": "مجلد قالب الاشتراك",

+ 7 - 1
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}}.",
       "subTitleDesc": "Title shown in VPN client. Supports client identity tokens: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
       "subSupportUrl": "Support URL",
       "subSupportUrl": "Support URL",
       "subSupportUrlDesc": "Technical support link shown in the VPN client. Supports client identity tokens: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
       "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",
       "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",
       "subAnnounce": "Announce",
       "subAnnounceDesc": "The announcement text displayed in the VPN client. Supports client identity tokens: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
       "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",
       "subThemeDir": "Sub Theme Directory",

+ 7 - 1
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}}.",
       "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",
       "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}}.",
       "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",
       "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",
       "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}}.",
       "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",
       "subThemeDir": "Directorio del tema de suscripción",

+ 7 - 1
internal/web/translation/fa-IR.json

@@ -1204,8 +1204,14 @@
       "subTitleDesc": "عنوان نمایش داده شده در کلاینت VPN. از توکن‌های هویت کلاینت پشتیبانی می‌کند: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
       "subTitleDesc": "عنوان نمایش داده شده در کلاینت VPN. از توکن‌های هویت کلاینت پشتیبانی می‌کند: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
       "subSupportUrl": "آدرس پشتیبانی",
       "subSupportUrl": "آدرس پشتیبانی",
       "subSupportUrlDesc": "لینک پشتیبانی فنی که در کلاینت VPN نمایش داده می‌شود. از توکن‌های هویت کلاینت پشتیبانی می‌کند: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
       "subSupportUrlDesc": "لینک پشتیبانی فنی که در کلاینت VPN نمایش داده می‌شود. از توکن‌های هویت کلاینت پشتیبانی می‌کند: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
+      "subProfileMode": "صفحه پروفایل",
+      "subProfileModeDesc": "انتخاب کنید کدام لینک وب‌سایت در کلاینت VPN نمایش داده شود.",
+      "subProfileModeNone": "بدون لینک",
+      "subProfileModeBuiltin": "صفحه اشتراک داخلی",
+      "subProfileModeCustom": "وب‌سایت سفارشی",
+      "subProfileBuiltinWarning": "این صفحه آدرس‌های اشتراک و پیکربندی گره‌ها را آشکار می‌کند، حتی برای اشتراک‌های رمزگذاری‌شده Happ.",
       "subProfileUrl": "آدرس پروفایل",
       "subProfileUrl": "آدرس پروفایل",
-      "subProfileUrlDesc": "لینک وب‌سایت شما که در کلاینت VPN نمایش داده می‌شود. از توکن‌های هویت کلاینت پشتیبانی می‌کند: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
+      "subProfileUrlDesc": "لینک وب‌سایت شما که در کلاینت VPN نمایش داده می‌شود. از توکن‌های هویت کلاینت پشتیبانی می‌کند: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}. برای عدم نمایش لینک وب‌سایت در کلاینت VPN، این فیلد را خالی بگذارید.",
       "subAnnounce": "اعلان",
       "subAnnounce": "اعلان",
       "subAnnounceDesc": "متن اعلانی که در کلاینت VPN نمایش داده می‌شود. از توکن‌های هویت کلاینت پشتیبانی می‌کند: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
       "subAnnounceDesc": "متن اعلانی که در کلاینت VPN نمایش داده می‌شود. از توکن‌های هویت کلاینت پشتیبانی می‌کند: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
       "subThemeDir": "پوشه قالب صفحه اشتراک",
       "subThemeDir": "پوشه قالب صفحه اشتراک",

+ 7 - 1
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}}.",
       "subTitleDesc": "Judul yang ditampilkan di klien VPN. Mendukung token identitas klien: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
       "subSupportUrl": "URL Dukungan",
       "subSupportUrl": "URL Dukungan",
       "subSupportUrlDesc": "Tautan dukungan teknis yang ditampilkan di klien VPN. Mendukung token identitas klien: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
       "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",
       "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",
       "subAnnounce": "Pengumuman",
       "subAnnounceDesc": "Teks pengumuman yang ditampilkan di klien VPN. Mendukung token identitas klien: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
       "subAnnounceDesc": "Teks pengumuman yang ditampilkan di klien VPN. Mendukung token identitas klien: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
       "subThemeDir": "Direktori Tema Langganan",
       "subThemeDir": "Direktori Tema Langganan",

+ 7 - 1
internal/web/translation/ja-JP.json

@@ -1200,8 +1200,14 @@
       "subTitleDesc": "VPNクライアントに表示されるタイトル。クライアント識別トークンをサポートします: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}。",
       "subTitleDesc": "VPNクライアントに表示されるタイトル。クライアント識別トークンをサポートします: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}。",
       "subSupportUrl": "サポートURL",
       "subSupportUrl": "サポートURL",
       "subSupportUrlDesc": "VPNクライアントに表示されるテクニカルサポートへのリンク。クライアント識別トークンをサポートします: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}。",
       "subSupportUrlDesc": "VPNクライアントに表示されるテクニカルサポートへのリンク。クライアント識別トークンをサポートします: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}。",
+      "subProfileMode": "プロフィールページ",
+      "subProfileModeDesc": "VPNクライアントに表示するWebサイトへのリンクを選択します。",
+      "subProfileModeNone": "リンクなし",
+      "subProfileModeBuiltin": "組み込みのサブスクリプションページ",
+      "subProfileModeCustom": "カスタムWebサイト",
+      "subProfileBuiltinWarning": "このページでは、Happで暗号化されたサブスクリプションも含め、サブスクリプションURLとノード設定が公開されます。",
       "subProfileUrl": "プロフィール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": "お知らせ",
       "subAnnounce": "お知らせ",
       "subAnnounceDesc": "VPNクライアントに表示されるお知らせのテキスト。クライアント識別トークンをサポートします: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}。",
       "subAnnounceDesc": "VPNクライアントに表示されるお知らせのテキスト。クライアント識別トークンをサポートします: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}。",
       "subThemeDir": "サブスクリプションテーマディレクトリ",
       "subThemeDir": "サブスクリプションテーマディレクトリ",

+ 7 - 1
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}}.",
       "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",
       "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}}.",
       "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",
       "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",
       "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}}.",
       "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",
       "subThemeDir": "Diretório do tema de assinatura",

+ 7 - 1
internal/web/translation/ru-RU.json

@@ -1200,8 +1200,14 @@
       "subTitleDesc": "Название подписки, которое видит клиент в VPN-клиенте. Поддерживает токены идентификации клиента: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
       "subTitleDesc": "Название подписки, которое видит клиент в VPN-клиенте. Поддерживает токены идентификации клиента: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
       "subSupportUrl": "URL поддержки",
       "subSupportUrl": "URL поддержки",
       "subSupportUrlDesc": "Ссылка на техническую поддержку, отображаемая в VPN-клиенте. Поддерживает токены идентификации клиента: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
       "subSupportUrlDesc": "Ссылка на техническую поддержку, отображаемая в VPN-клиенте. Поддерживает токены идентификации клиента: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
+      "subProfileMode": "Страница профиля",
+      "subProfileModeDesc": "Выберите, какая ссылка на сайт будет отображаться в VPN-клиенте.",
+      "subProfileModeNone": "Без ссылки",
+      "subProfileModeBuiltin": "Встроенная страница подписки",
+      "subProfileModeCustom": "Свой сайт",
+      "subProfileBuiltinWarning": "Эта страница раскрывает URL-адреса подписок и конфигурации узлов, в том числе для зашифрованных подписок Happ.",
       "subProfileUrl": "URL профиля",
       "subProfileUrl": "URL профиля",
-      "subProfileUrlDesc": "Ссылка на ваш сайт, отображаемая в VPN-клиенте. Поддерживает токены идентификации клиента: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
+      "subProfileUrlDesc": "Ссылка на ваш сайт, отображаемая в VPN-клиенте. Поддерживает токены идентификации клиента: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}. Оставьте поле пустым, чтобы не отображать ссылку на сайт в VPN-клиенте.",
       "subAnnounce": "Объявление",
       "subAnnounce": "Объявление",
       "subAnnounceDesc": "Текст объявления, отображаемый в VPN-клиенте. Поддерживает токены идентификации клиента: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
       "subAnnounceDesc": "Текст объявления, отображаемый в VPN-клиенте. Поддерживает токены идентификации клиента: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
       "subThemeDir": "Каталог темы подписки",
       "subThemeDir": "Каталог темы подписки",

+ 7 - 1
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}}.",
       "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",
       "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}}.",
       "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",
       "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",
       "subAnnounce": "Duyuru",
       "subAnnounceDesc": "VPN istemcisinde görüntülenen duyuru metni. İstemci kimlik tokenlarını destekler: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
       "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",
       "subThemeDir": "Abonelik Tema Dizini",

+ 7 - 1
internal/web/translation/uk-UA.json

@@ -1200,8 +1200,14 @@
       "subTitleDesc": "Назва, яка відображається у VPN-клієнті. Підтримує токени ідентифікації клієнта: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
       "subTitleDesc": "Назва, яка відображається у VPN-клієнті. Підтримує токени ідентифікації клієнта: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
       "subSupportUrl": "URL підтримки",
       "subSupportUrl": "URL підтримки",
       "subSupportUrlDesc": "Посилання на технічну підтримку, що відображається у VPN-клієнті. Підтримує токени ідентифікації клієнта: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
       "subSupportUrlDesc": "Посилання на технічну підтримку, що відображається у VPN-клієнті. Підтримує токени ідентифікації клієнта: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
+      "subProfileMode": "Сторінка профілю",
+      "subProfileModeDesc": "Виберіть, яке посилання на вебсайт відображатиметься у VPN-клієнті.",
+      "subProfileModeNone": "Без посилання",
+      "subProfileModeBuiltin": "Вбудована сторінка підписки",
+      "subProfileModeCustom": "Власний вебсайт",
+      "subProfileBuiltinWarning": "Ця сторінка розкриває URL-адреси підписок і конфігурації вузлів, зокрема для зашифрованих підписок Happ.",
       "subProfileUrl": "URL профілю",
       "subProfileUrl": "URL профілю",
-      "subProfileUrlDesc": "Посилання на ваш вебсайт, що відображається у VPN-клієнті. Підтримує токени ідентифікації клієнта: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
+      "subProfileUrlDesc": "Посилання на ваш вебсайт, що відображається у VPN-клієнті. Підтримує токени ідентифікації клієнта: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}. Залиште поле порожнім, щоб не відображати посилання на вебсайт у VPN-клієнті.",
       "subAnnounce": "Оголошення",
       "subAnnounce": "Оголошення",
       "subAnnounceDesc": "Текст оголошення, що відображається у VPN-клієнті. Підтримує токени ідентифікації клієнта: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
       "subAnnounceDesc": "Текст оголошення, що відображається у VPN-клієнті. Підтримує токени ідентифікації клієнта: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
       "subThemeDir": "Каталог теми підписки",
       "subThemeDir": "Каталог теми підписки",

+ 7 - 1
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}}.",
       "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ợ",
       "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}}.",
       "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ơ",
       "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",
       "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}}.",
       "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ý",
       "subThemeDir": "Thư mục giao diện Đăng ký",

+ 7 - 1
internal/web/translation/zh-CN.json

@@ -1200,8 +1200,14 @@
       "subTitleDesc": "在 VPN 客户端中显示的标题。支持客户端身份令牌:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。",
       "subTitleDesc": "在 VPN 客户端中显示的标题。支持客户端身份令牌:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。",
       "subSupportUrl": "支持链接",
       "subSupportUrl": "支持链接",
       "subSupportUrlDesc": "VPN 客户端中显示的技术支持链接。支持客户端身份令牌:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。",
       "subSupportUrlDesc": "VPN 客户端中显示的技术支持链接。支持客户端身份令牌:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。",
+      "subProfileMode": "资料页方式",
+      "subProfileModeDesc": "选择在 VPN 客户端中提供的资料页入口。",
+      "subProfileModeNone": "不提供",
+      "subProfileModeBuiltin": "内置订阅页",
+      "subProfileModeCustom": "自定义网站",
+      "subProfileBuiltinWarning": "此页面会公开订阅地址和节点配置,HAPP 加密订阅也不例外。",
       "subProfileUrl": "个人资料链接",
       "subProfileUrl": "个人资料链接",
-      "subProfileUrlDesc": "VPN 客户端中显示的网站链接。支持客户端身份令牌:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。",
+      "subProfileUrlDesc": "VPN 客户端中显示的网站链接。支持客户端身份令牌:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。留空则不在 VPN 客户端提供网站链接。",
       "subAnnounce": "公告",
       "subAnnounce": "公告",
       "subAnnounceDesc": "VPN 客户端中显示的公告文本。支持客户端身份令牌:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。",
       "subAnnounceDesc": "VPN 客户端中显示的公告文本。支持客户端身份令牌:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。",
       "subThemeDir": "订阅主题目录",
       "subThemeDir": "订阅主题目录",

+ 7 - 1
internal/web/translation/zh-TW.json

@@ -1200,8 +1200,14 @@
       "subTitleDesc": "在 VPN 客戶端中顯示的標題。支援用戶端身分權杖:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。",
       "subTitleDesc": "在 VPN 客戶端中顯示的標題。支援用戶端身分權杖:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。",
       "subSupportUrl": "支援連結",
       "subSupportUrl": "支援連結",
       "subSupportUrlDesc": "VPN 用戶端中顯示的技術支援連結。支援用戶端身分權杖:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。",
       "subSupportUrlDesc": "VPN 用戶端中顯示的技術支援連結。支援用戶端身分權杖:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。",
+      "subProfileMode": "資料頁方式",
+      "subProfileModeDesc": "選擇在 VPN 用戶端中提供的資料頁入口。",
+      "subProfileModeNone": "不提供",
+      "subProfileModeBuiltin": "內建訂閱頁",
+      "subProfileModeCustom": "自訂網站",
+      "subProfileBuiltinWarning": "此頁面會公開訂閱網址和節點設定,HAPP 加密訂閱也不例外。",
       "subProfileUrl": "個人資料連結",
       "subProfileUrl": "個人資料連結",
-      "subProfileUrlDesc": "VPN 用戶端中顯示的網站連結。支援用戶端身分權杖:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。",
+      "subProfileUrlDesc": "VPN 用戶端中顯示的網站連結。支援用戶端身分權杖:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。留空則不在 VPN 用戶端提供網站連結。",
       "subAnnounce": "公告",
       "subAnnounce": "公告",
       "subAnnounceDesc": "VPN 用戶端中顯示的公告文字。支援用戶端身分權杖:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。",
       "subAnnounceDesc": "VPN 用戶端中顯示的公告文字。支援用戶端身分權杖:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。",
       "subThemeDir": "訂閱主題目錄",
       "subThemeDir": "訂閱主題目錄",