Selaa lähdekoodia

feat(sub): allow identity tokens on every subscription link (#5935)

Keep usage tokens first-link-only while adding an opt-in setting for repeating EMAIL and USERNAME in subscription-body remarks.

Co-authored-by: x06579 <x06579@ai-dashboard>
H-TTTTT 7 tuntia sitten
vanhempi
sitoutus
8f49327efb

+ 8 - 0
docs/public/openapi.json

@@ -411,6 +411,9 @@
             "maximum": 65535,
             "minimum": 1,
             "type": "integer"
+          },
+          "subShowIdentityOnAllLinks": {
+            "type": "boolean"
           }
         },
         "required": [
@@ -479,6 +482,7 @@
           "subPort",
           "subProfileUrl",
           "subRoutingRules",
+          "subShowIdentityOnAllLinks",
           "subSupportUrl",
           "subThemeDir",
           "subTitle",
@@ -916,6 +920,9 @@
             "maximum": 65535,
             "minimum": 1,
             "type": "integer"
+          },
+          "subShowIdentityOnAllLinks": {
+            "type": "boolean"
           }
         },
         "required": [
@@ -991,6 +998,7 @@
           "subPort",
           "subProfileUrl",
           "subRoutingRules",
+          "subShowIdentityOnAllLinks",
           "subSupportUrl",
           "subThemeDir",
           "subTitle",

+ 8 - 0
frontend/public/openapi.json

@@ -270,6 +270,9 @@
           "subRoutingRules": {
             "type": "string"
           },
+          "subShowIdentityOnAllLinks": {
+            "type": "boolean"
+          },
           "subSupportUrl": {
             "type": "string"
           },
@@ -441,6 +444,7 @@
           "subPort",
           "subProfileUrl",
           "subRoutingRules",
+          "subShowIdentityOnAllLinks",
           "subSupportUrl",
           "subThemeDir",
           "subTitle",
@@ -737,6 +741,9 @@
           "subRoutingRules": {
             "type": "string"
           },
+          "subShowIdentityOnAllLinks": {
+            "type": "boolean"
+          },
           "subSupportUrl": {
             "type": "string"
           },
@@ -915,6 +922,7 @@
           "subPort",
           "subProfileUrl",
           "subRoutingRules",
+          "subShowIdentityOnAllLinks",
           "subSupportUrl",
           "subThemeDir",
           "subTitle",

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

@@ -75,6 +75,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "subPort": 1,
     "subProfileUrl": "",
     "subRoutingRules": "",
+    "subShowIdentityOnAllLinks": false,
     "subSupportUrl": "",
     "subThemeDir": "",
     "subTitle": "",
@@ -186,6 +187,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "subPort": 1,
     "subProfileUrl": "",
     "subRoutingRules": "",
+    "subShowIdentityOnAllLinks": false,
     "subSupportUrl": "",
     "subThemeDir": "",
     "subTitle": "",

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

@@ -244,6 +244,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subRoutingRules": {
         "type": "string"
       },
+      "subShowIdentityOnAllLinks": {
+        "type": "boolean"
+      },
       "subSupportUrl": {
         "type": "string"
       },
@@ -415,6 +418,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "subPort",
       "subProfileUrl",
       "subRoutingRules",
+      "subShowIdentityOnAllLinks",
       "subSupportUrl",
       "subThemeDir",
       "subTitle",
@@ -711,6 +715,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subRoutingRules": {
         "type": "string"
       },
+      "subShowIdentityOnAllLinks": {
+        "type": "boolean"
+      },
       "subSupportUrl": {
         "type": "string"
       },
@@ -889,6 +896,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "subPort",
       "subProfileUrl",
       "subRoutingRules",
+      "subShowIdentityOnAllLinks",
       "subSupportUrl",
       "subThemeDir",
       "subTitle",

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

@@ -81,6 +81,7 @@ export interface AllSetting {
   subPort: number;
   subProfileUrl: string;
   subRoutingRules: string;
+  subShowIdentityOnAllLinks: boolean;
   subSupportUrl: string;
   subThemeDir: string;
   subTitle: string;
@@ -193,6 +194,7 @@ export interface AllSettingView {
   subPort: number;
   subProfileUrl: string;
   subRoutingRules: string;
+  subShowIdentityOnAllLinks: boolean;
   subSupportUrl: string;
   subThemeDir: string;
   subTitle: string;

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

@@ -93,6 +93,7 @@ export const AllSettingSchema = z.object({
   subPort: z.number().int().min(1).max(65535),
   subProfileUrl: z.string(),
   subRoutingRules: z.string(),
+  subShowIdentityOnAllLinks: z.boolean(),
   subSupportUrl: z.string(),
   subThemeDir: z.string(),
   subTitle: z.string(),
@@ -206,6 +207,7 @@ export const AllSettingViewSchema = z.object({
   subPort: z.number().int().min(1).max(65535),
   subProfileUrl: z.string(),
   subRoutingRules: z.string(),
+  subShowIdentityOnAllLinks: z.boolean(),
   subSupportUrl: z.string(),
   subThemeDir: z.string(),
   subTitle: z.string(),

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

@@ -14,6 +14,7 @@ export class AllSetting {
   expireDiff = 0;
   trafficDiff = 0;
   remarkTemplate = '{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
+  subShowIdentityOnAllLinks = false;
   datepicker: 'gregorian' | 'jalalian' = 'gregorian';
   tgBotEnable = false;
   tgBotToken = '';

+ 10 - 0
frontend/src/pages/settings/SubscriptionGeneralTab.tsx

@@ -93,6 +93,16 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
                 maxLength={256}
               />
             </SettingListItem>
+            <SettingListItem
+              paddings="small"
+              title={t('pages.settings.subShowIdentityOnAllLinks')}
+              description={t('pages.settings.subShowIdentityOnAllLinksDesc')}
+            >
+              <Switch
+                checked={allSetting.subShowIdentityOnAllLinks}
+                onChange={(v) => updateSetting({ subShowIdentityOnAllLinks: v })}
+              />
+            </SettingListItem>
 
             <SettingListItem paddings="small" title={t('pages.settings.subUpdates')} description={t('pages.settings.subUpdatesDesc')}>
               <InputNumber value={allSetting.subUpdates} min={0} max={525600} style={{ width: '100%' }}

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

@@ -18,6 +18,7 @@ export const AllSettingSchema = z.object({
   expireDiff: nonNegativeInt.optional(),
   trafficDiff: nonNegativeInt.max(100).optional(),
   remarkTemplate: z.string().optional(),
+  subShowIdentityOnAllLinks: z.boolean().optional(),
   datepicker: z.enum(['gregorian', 'jalalian']).optional(),
   tgBotEnable: z.boolean().optional(),
   tgBotToken: z.string().optional(),

+ 22 - 0
frontend/src/test/setting-sub-identity.test.ts

@@ -0,0 +1,22 @@
+import { describe, it, expect } from 'vitest';
+import { AllSettingSchema } from '@/schemas/setting';
+import { AllSetting } from '@/models/setting';
+
+describe('subShowIdentityOnAllLinks', () => {
+  it('defaults to false on AllSetting', () => {
+    expect(new AllSetting().subShowIdentityOnAllLinks).toBe(false);
+  });
+
+  it('accepts boolean values in the settings schema', () => {
+    for (const v of [true, false]) {
+      const r = AllSettingSchema.safeParse({ subShowIdentityOnAllLinks: v });
+      expect(r.success, `subShowIdentityOnAllLinks=${v}`).toBe(true);
+    }
+  });
+
+  it('rejects non-boolean values', () => {
+    for (const v of ['true', 1, null]) {
+      expect(AllSettingSchema.safeParse({ subShowIdentityOnAllLinks: v }).success).toBe(false);
+    }
+  });
+});

+ 5 - 1
internal/sub/remark_vars.go

@@ -560,7 +560,11 @@ func (s *SubService) effectiveTemplate(email string) string {
 		s.usageShown = map[string]bool{}
 	}
 	if s.usageShown[email] {
-		return filterRemarkTemplate(translated, firstLinkOnlyBodyTokens)
+		remove := firstLinkOnlyBodyTokens
+		if s.showIdentityOnAllLinks {
+			remove = usageInfoTokens
+		}
+		return filterRemarkTemplate(translated, remove)
 	}
 	s.usageShown[email] = true
 	return translated

+ 37 - 0
internal/sub/remark_vars_test.go

@@ -652,3 +652,40 @@ func TestEmailOnFirstLinkOnly(t *testing.T) {
 		t.Fatalf("second link should still carry the inbound name: %q", second)
 	}
 }
+
+func TestIdentityOnAllLinks(t *testing.T) {
+	const template = "{{INBOUND}}-{{EMAIL}}|{{USERNAME}}|📊{{TRAFFIC_LEFT}}|{{STATUS_EMOJI}}"
+	inbound := &model.Inbound{
+		Remark: "DE",
+		ClientStats: []xray.ClientTraffic{{
+			Email:  "alice@x",
+			Enable: true,
+			Total:  100 * gb,
+			Up:     20 * gb,
+		}},
+	}
+	tests := []struct {
+		name       string
+		enabled    bool
+		wantSecond string
+	}{
+		{name: "disabled", wantSecond: "DE"},
+		{name: "enabled", enabled: true, wantSecond: "DE-alice@x|alice@x"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			s := &SubService{
+				remarkTemplate:         template,
+				subscriptionBody:       true,
+				showIdentityOnAllLinks: tt.enabled,
+			}
+			client := model.Client{Email: "alice@x"}
+			if got := s.genTemplatedRemark(inbound, client, "", "ws"); got != "DE-alice@x|alice@x|📊80.00GB|✅" {
+				t.Fatalf("first link = %q", got)
+			}
+			if got := s.genTemplatedRemark(inbound, client, "", "ws"); got != tt.wantSecond {
+				t.Fatalf("second link = %q, want %q", got, tt.wantSecond)
+			}
+		})
+	}
+}

+ 8 - 3
internal/sub/service.go

@@ -40,9 +40,10 @@ type SubService struct {
 	// usageShown tracks, per client email, whether the info part of the template
 	// has already been emitted this request, so it appears on the first body
 	// link only. Per-request state; reset in PrepareForRequest.
-	usageShown     map[string]bool
-	inboundService service.InboundService
-	settingService service.SettingService
+	usageShown             map[string]bool
+	showIdentityOnAllLinks bool
+	inboundService         service.InboundService
+	settingService         service.SettingService
 	// nodesByID is populated per request from the Node table so
 	// resolveInboundAddress can return the node's address for any
 	// inbound whose NodeID is set. Keeps the per-link host derivation
@@ -197,6 +198,10 @@ func (s *SubService) loadRemarkSettings() {
 	if err != nil {
 		s.datepicker = "gregorian"
 	}
+	s.showIdentityOnAllLinks, err = s.settingService.GetSubShowIdentityOnAllLinks()
+	if err != nil {
+		s.showIdentityOnAllLinks = false
+	}
 }
 
 func (s *SubService) configuredPublicHost() string {

+ 6 - 5
internal/web/entity/entity.go

@@ -28,11 +28,12 @@ type AllSetting struct {
 	TrustedProxyCIDRs string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"`
 	PanelOutbound     string `json:"panelOutbound" form:"panelOutbound"`
 
-	PageSize       int    `json:"pageSize" form:"pageSize" validate:"gte=0,lte=1000"`
-	ExpireDiff     int    `json:"expireDiff" form:"expireDiff" validate:"gte=0"`
-	TrafficDiff    int    `json:"trafficDiff" form:"trafficDiff" validate:"gte=0,lte=100"`
-	RemarkTemplate string `json:"remarkTemplate" form:"remarkTemplate"`
-	Datepicker     string `json:"datepicker" form:"datepicker"`
+	PageSize                  int    `json:"pageSize" form:"pageSize" validate:"gte=0,lte=1000"`
+	ExpireDiff                int    `json:"expireDiff" form:"expireDiff" validate:"gte=0"`
+	TrafficDiff               int    `json:"trafficDiff" form:"trafficDiff" validate:"gte=0,lte=100"`
+	RemarkTemplate            string `json:"remarkTemplate" form:"remarkTemplate"`
+	SubShowIdentityOnAllLinks bool   `json:"subShowIdentityOnAllLinks" form:"subShowIdentityOnAllLinks"`
+	Datepicker                string `json:"datepicker" form:"datepicker"`
 
 	TgBotEnable     bool   `json:"tgBotEnable" form:"tgBotEnable"`
 	TgBotToken      string `json:"tgBotToken" form:"tgBotToken"`

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

@@ -66,6 +66,7 @@ var defaultValueMap = map[string]string{
 	"expireDiff":                  "0",
 	"trafficDiff":                 "0",
 	"remarkTemplate":              DefaultRemarkTemplate,
+	"subShowIdentityOnAllLinks":   "false",
 	"timeLocation":                "Local",
 	"tgBotEnable":                 "false",
 	"tgBotToken":                  "",
@@ -649,6 +650,10 @@ func (s *SettingService) GetRemarkTemplate() (string, error) {
 	return s.getString("remarkTemplate")
 }
 
+func (s *SettingService) GetSubShowIdentityOnAllLinks() (bool, error) {
+	return s.getBool("subShowIdentityOnAllLinks")
+}
+
 func (s *SettingService) GetSecret() ([]byte, error) {
 	secret, err := s.getString("secret")
 	if secret == defaultValueMap["secret"] {

+ 39 - 0
internal/web/service/setting_sub_identity_test.go

@@ -0,0 +1,39 @@
+package service
+
+import "testing"
+
+func TestSubShowIdentityOnAllLinksDefaultsAndPersists(t *testing.T) {
+	setupSettingTestDB(t)
+	s := &SettingService{}
+
+	if got, err := s.GetSubShowIdentityOnAllLinks(); err != nil || got {
+		t.Fatalf("missing setting = %t, %v; want false, nil", got, err)
+	}
+	settings, err := s.GetAllSetting()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if settings.SubShowIdentityOnAllLinks {
+		t.Fatal("GetAllSetting returned true for a missing setting")
+	}
+
+	settings.SubShowIdentityOnAllLinks = true
+	if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
+		t.Fatal(err)
+	}
+	if got, err := s.GetSubShowIdentityOnAllLinks(); err != nil || !got {
+		t.Fatalf("persisted setting = %t, %v; want true, nil", got, err)
+	}
+
+	settings, err = s.GetAllSetting()
+	if err != nil {
+		t.Fatal(err)
+	}
+	settings.SubShowIdentityOnAllLinks = false
+	if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
+		t.Fatal(err)
+	}
+	if got, err := s.GetSubShowIdentityOnAllLinks(); err != nil || got {
+		t.Fatalf("persisted setting = %t, %v; want false, nil", got, err)
+	}
+}

+ 2 - 0
internal/web/translation/ar-EG.json

@@ -1435,6 +1435,8 @@
       "eventMemoryHigh": "ارتفاع استخدام الذاكرة (%)",
       "remarkTemplate": "قالب الملاحظة",
       "remarkTemplateDesc": "عند تعيينه، يحل هذا محل نموذج الملاحظة لكل رابط اشتراك — اكتب صيغتك الخاصة باستخدام رموز المتغيرات (استخدم الزر لإدراجها). اتركه فارغاً لاستخدام النموذج أعلاه.",
+      "subShowIdentityOnAllLinks": "إظهار الهوية في كل رابط",
+      "subShowIdentityOnAllLinksDesc": "عند التفعيل، يبقى {{EMAIL}} و{{USERNAME}} في ملاحظة كل رابط في محتوى الاشتراك. تظل رموز الاستخدام في الرابط الأول فقط.",
       "validation": {
         "pathLeadingSlash": "يجب أن يبدأ المسار بالرمز /"
       },

+ 2 - 0
internal/web/translation/en-US.json

@@ -1261,6 +1261,8 @@
       "panelOutboundPh": "Direct connection",
       "remarkTemplate": "Remark Template",
       "remarkTemplateDesc": "When set, this replaces the remark model for every subscription link — write your own format with the variable tokens (use the button to insert them). Leave empty to use the model above.",
+      "subShowIdentityOnAllLinks": "Show identity on every link",
+      "subShowIdentityOnAllLinksDesc": "When enabled, {{EMAIL}} and {{USERNAME}} stay on every subscription-body remark. Usage tokens still appear on the first link only.",
       "datepicker": "Calendar Type",
       "datepickerPlaceholder": "Select date",
       "datepickerDescription": "Scheduled tasks will run based on this calendar.",

+ 2 - 0
internal/web/translation/es-ES.json

@@ -1435,6 +1435,8 @@
       "eventMemoryHigh": "Uso de memoria alto (%)",
       "remarkTemplate": "Plantilla de notas",
       "remarkTemplateDesc": "Cuando se define, esto reemplaza el modelo de notas para cada enlace de suscripción — escribe tu propio formato con los tokens de variable (usa el botón para insertarlos). Déjalo vacío para usar el modelo anterior.",
+      "subShowIdentityOnAllLinks": "Mostrar identidad en cada enlace",
+      "subShowIdentityOnAllLinksDesc": "Si está activado, {{EMAIL}} y {{USERNAME}} permanecen en la nota de cada enlace del cuerpo de la suscripción. Los tokens de uso siguen solo en el primer enlace.",
       "validation": {
         "pathLeadingSlash": "La ruta debe comenzar con /"
       },

+ 2 - 0
internal/web/translation/fa-IR.json

@@ -1144,6 +1144,8 @@
       "panelOutboundPh": "اتصال مستقیم",
       "remarkTemplate": "قالب ریمارک",
       "remarkTemplateDesc": "اگر پر شود، جای مدلِ ریمارک را برای همه‌ی لینک‌های اشتراک می‌گیرد — فرمت دلخواهت را با توکن‌های متغیر بنویس (از دکمه برای درج استفاده کن). خالی = استفاده از مدلِ بالا.",
+      "subShowIdentityOnAllLinks": "نمایش هویت در همه لینک‌ها",
+      "subShowIdentityOnAllLinksDesc": "در صورت فعال بودن، {{EMAIL}} و {{USERNAME}} در یادداشت هر لینک بدنه اشتراک باقی می‌مانند. توکن‌های مصرف همچنان فقط در لینک اول نمایش داده می‌شوند.",
       "datepicker": "نوع تقویم",
       "datepickerPlaceholder": "انتخاب تاریخ",
       "datepickerDescription": "وظایف برنامه ریزی شده بر اساس این تقویم اجرا می‌شود",

+ 2 - 0
internal/web/translation/id-ID.json

@@ -1435,6 +1435,8 @@
       "eventMemoryHigh": "Penggunaan memori tinggi (%)",
       "remarkTemplate": "Templat Catatan",
       "remarkTemplateDesc": "Jika diatur, ini menggantikan model catatan untuk setiap tautan langganan — tulis format Anda sendiri dengan token variabel (gunakan tombol untuk menyisipkannya). Biarkan kosong untuk memakai model di atas.",
+      "subShowIdentityOnAllLinks": "Tampilkan identitas di setiap tautan",
+      "subShowIdentityOnAllLinksDesc": "Jika diaktifkan, {{EMAIL}} dan {{USERNAME}} tetap ada di catatan setiap tautan isi langganan. Token penggunaan tetap hanya di tautan pertama.",
       "validation": {
         "pathLeadingSlash": "Path harus diawali dengan /"
       },

+ 2 - 0
internal/web/translation/ja-JP.json

@@ -1435,6 +1435,8 @@
       "eventMemoryHigh": "メモリ使用率が高い (%)",
       "remarkTemplate": "備考テンプレート",
       "remarkTemplateDesc": "設定すると、すべてのサブスクリプションリンクの備考モデルを置き換えます — 変数トークンを使って独自の形式を記述してください(ボタンで挿入できます)。空欄にすると上記のモデルが使用されます。",
+      "subShowIdentityOnAllLinks": "すべてのリンクに識別情報を表示",
+      "subShowIdentityOnAllLinksDesc": "有効にすると、{{EMAIL}} と {{USERNAME}} がサブスクリプション本文のすべてのリンク備考に残ります。使用量トークンは引き続き最初のリンクのみです。",
       "validation": {
         "pathLeadingSlash": "パスは / で始まる必要があります"
       },

+ 2 - 0
internal/web/translation/pt-BR.json

@@ -1435,6 +1435,8 @@
       "eventMemoryHigh": "Uso de memória alto (%)",
       "remarkTemplate": "Modelo de Observação",
       "remarkTemplateDesc": "Quando definido, isto substitui o modelo de observação de cada link de assinatura — escreva seu próprio formato com os tokens de variáveis (use o botão para inseri-los). Deixe vazio para usar o modelo acima.",
+      "subShowIdentityOnAllLinks": "Mostrar identidade em todos os links",
+      "subShowIdentityOnAllLinksDesc": "Quando ativado, {{EMAIL}} e {{USERNAME}} permanecem na observação de cada link do corpo da assinatura. Tokens de uso continuam só no primeiro link.",
       "validation": {
         "pathLeadingSlash": "O caminho deve começar com /"
       },

+ 2 - 0
internal/web/translation/ru-RU.json

@@ -1435,6 +1435,8 @@
       "eventMemoryHigh": "Превышение порога памяти (%)",
       "remarkTemplate": "Шаблон примечания",
       "remarkTemplateDesc": "Если задан, заменяет модель примечания для каждой ссылки подписки — задайте собственный формат с помощью токенов переменных (используйте кнопку для их вставки). Оставьте пустым, чтобы использовать модель выше.",
+      "subShowIdentityOnAllLinks": "Показывать идентификатор на каждой ссылке",
+      "subShowIdentityOnAllLinksDesc": "Если включено, {{EMAIL}} и {{USERNAME}} остаются в примечании каждой ссылки тела подписки. Токены использования по-прежнему только на первой ссылке.",
       "validation": {
         "pathLeadingSlash": "Путь должен начинаться с /"
       },

+ 2 - 0
internal/web/translation/tr-TR.json

@@ -1435,6 +1435,8 @@
       "eventMemoryHigh": "Bellek kullanımı yüksek (%)",
       "remarkTemplate": "Açıklama Şablonu",
       "remarkTemplateDesc": "Ayarlandığında, her abonelik bağlantısının açıklama modelinin yerini alır — değişken belirteçleriyle kendi formatınızı yazın (eklemek için düğmeyi kullanın). Yukarıdaki modeli kullanmak için boş bırakın.",
+      "subShowIdentityOnAllLinks": "Kimliği her bağlantıda göster",
+      "subShowIdentityOnAllLinksDesc": "Etkinleştirildiğinde {{EMAIL}} ve {{USERNAME}} abonelik gövdesindeki her bağlantı notunda kalır. Kullanım jetonları yine yalnızca ilk bağlantıda görünür.",
       "validation": {
         "pathLeadingSlash": "Yol / ile başlamalıdır"
       },

+ 2 - 0
internal/web/translation/uk-UA.json

@@ -1435,6 +1435,8 @@
       "eventMemoryHigh": "Високе використання пам'яті (%)",
       "remarkTemplate": "Шаблон примітки",
       "remarkTemplateDesc": "Якщо задано, це замінює модель примітки для кожного посилання підписки — напишіть власний формат із токенами змінних (використовуйте кнопку для їх вставлення). Залиште порожнім, щоб використовувати модель вище.",
+      "subShowIdentityOnAllLinks": "Показувати ідентичність на кожному посиланні",
+      "subShowIdentityOnAllLinksDesc": "Якщо увімкнено, {{EMAIL}} і {{USERNAME}} залишаються в примітці кожного посилання тіла підписки. Токени використання й надалі лише на першому посиланні.",
       "validation": {
         "pathLeadingSlash": "Шлях має починатися з /"
       },

+ 2 - 0
internal/web/translation/vi-VN.json

@@ -1435,6 +1435,8 @@
       "eventMemoryHigh": "Sử dụng bộ nhớ cao (%)",
       "remarkTemplate": "Mẫu ghi chú",
       "remarkTemplateDesc": "Khi được đặt, mục này thay thế mô hình ghi chú cho mọi liên kết đăng ký — hãy viết định dạng riêng của bạn bằng các token biến (dùng nút để chèn chúng). Để trống để dùng mô hình ở trên.",
+      "subShowIdentityOnAllLinks": "Hiện danh tính trên mọi liên kết",
+      "subShowIdentityOnAllLinksDesc": "Khi bật, {{EMAIL}} và {{USERNAME}} vẫn có trong ghi chú mọi liên kết phần thân đăng ký. Token dung lượng vẫn chỉ ở liên kết đầu tiên.",
       "validation": {
         "pathLeadingSlash": "Đường dẫn phải bắt đầu bằng /"
       },

+ 2 - 0
internal/web/translation/zh-CN.json

@@ -1435,6 +1435,8 @@
       "eventMemoryHigh": "内存使用率高 (%)",
       "remarkTemplate": "备注模板",
       "remarkTemplateDesc": "设置后,将替换每个订阅链接的备注模型 — 使用变量标记编写您自己的格式(用按钮插入它们)。留空则使用上方的模型。",
+      "subShowIdentityOnAllLinks": "在每个链接上显示身份",
+      "subShowIdentityOnAllLinksDesc": "启用后,{{EMAIL}} 和 {{USERNAME}} 会保留在每条订阅正文备注中。用量相关变量仍仅出现在第一条链接。",
       "validation": {
         "pathLeadingSlash": "路径必须以 / 开头"
       },

+ 2 - 0
internal/web/translation/zh-TW.json

@@ -1435,6 +1435,8 @@
       "eventMemoryHigh": "記憶體使用率高 (%)",
       "remarkTemplate": "備註範本",
       "remarkTemplateDesc": "設定後,這將取代每個訂閱連結的備註模型——使用變數標記撰寫您自己的格式(使用按鈕來插入)。留空則使用上方的模型。",
+      "subShowIdentityOnAllLinks": "在每個連結上顯示身分",
+      "subShowIdentityOnAllLinksDesc": "啟用後,{{EMAIL}} 與 {{USERNAME}} 會保留在每條訂閱正文備註中。用量相關變數仍僅出現在第一條連結。",
       "validation": {
         "pathLeadingSlash": "路徑必須以 / 開頭"
       },