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>
This commit is contained in:
H-TTTTT
2026-07-29 04:12:52 +08:00
committed by GitHub
parent 8bbca76bdd
commit 8f49327efb
29 changed files with 190 additions and 9 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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%' }}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"] {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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