diff --git a/docs/public/openapi.json b/docs/public/openapi.json index c15e60add..d10f7a5e9 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -217,6 +217,9 @@ "subEncrypt": { "type": "boolean" }, + "subExpiredTemplate": { + "type": "string" + }, "subHideSettings": { "type": "boolean" }, @@ -226,6 +229,9 @@ "subIncyRoutingRules": { "type": "string" }, + "subInfoNodeEnable": { + "type": "boolean" + }, "subJsonAlwaysArray": { "type": "boolean" }, @@ -288,6 +294,9 @@ "subTitle": { "type": "string" }, + "subTrafficDepletedTemplate": { + "type": "string" + }, "subURI": { "type": "string" }, @@ -433,9 +442,11 @@ "subEnable", "subEnableRouting", "subEncrypt", + "subExpiredTemplate", "subHideSettings", "subIncyEnableRouting", "subIncyRoutingRules", + "subInfoNodeEnable", "subJsonAlwaysArray", "subJsonAutoDetect", "subJsonEnable", @@ -456,6 +467,7 @@ "subSupportUrl", "subThemeDir", "subTitle", + "subTrafficDepletedTemplate", "subURI", "subUpdates", "tgBotAPIServer", @@ -696,6 +708,9 @@ "subEncrypt": { "type": "boolean" }, + "subExpiredTemplate": { + "type": "string" + }, "subHideSettings": { "type": "boolean" }, @@ -705,6 +720,9 @@ "subIncyRoutingRules": { "type": "string" }, + "subInfoNodeEnable": { + "type": "boolean" + }, "subJsonAlwaysArray": { "type": "boolean" }, @@ -767,6 +785,9 @@ "subTitle": { "type": "string" }, + "subTrafficDepletedTemplate": { + "type": "string" + }, "subURI": { "type": "string" }, @@ -919,9 +940,11 @@ "subEnable", "subEnableRouting", "subEncrypt", + "subExpiredTemplate", "subHideSettings", "subIncyEnableRouting", "subIncyRoutingRules", + "subInfoNodeEnable", "subJsonAlwaysArray", "subJsonAutoDetect", "subJsonEnable", @@ -942,6 +965,7 @@ "subSupportUrl", "subThemeDir", "subTitle", + "subTrafficDepletedTemplate", "subURI", "subUpdates", "tgBotAPIServer", diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index c15e60add..d10f7a5e9 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -217,6 +217,9 @@ "subEncrypt": { "type": "boolean" }, + "subExpiredTemplate": { + "type": "string" + }, "subHideSettings": { "type": "boolean" }, @@ -226,6 +229,9 @@ "subIncyRoutingRules": { "type": "string" }, + "subInfoNodeEnable": { + "type": "boolean" + }, "subJsonAlwaysArray": { "type": "boolean" }, @@ -288,6 +294,9 @@ "subTitle": { "type": "string" }, + "subTrafficDepletedTemplate": { + "type": "string" + }, "subURI": { "type": "string" }, @@ -433,9 +442,11 @@ "subEnable", "subEnableRouting", "subEncrypt", + "subExpiredTemplate", "subHideSettings", "subIncyEnableRouting", "subIncyRoutingRules", + "subInfoNodeEnable", "subJsonAlwaysArray", "subJsonAutoDetect", "subJsonEnable", @@ -456,6 +467,7 @@ "subSupportUrl", "subThemeDir", "subTitle", + "subTrafficDepletedTemplate", "subURI", "subUpdates", "tgBotAPIServer", @@ -696,6 +708,9 @@ "subEncrypt": { "type": "boolean" }, + "subExpiredTemplate": { + "type": "string" + }, "subHideSettings": { "type": "boolean" }, @@ -705,6 +720,9 @@ "subIncyRoutingRules": { "type": "string" }, + "subInfoNodeEnable": { + "type": "boolean" + }, "subJsonAlwaysArray": { "type": "boolean" }, @@ -767,6 +785,9 @@ "subTitle": { "type": "string" }, + "subTrafficDepletedTemplate": { + "type": "string" + }, "subURI": { "type": "string" }, @@ -919,9 +940,11 @@ "subEnable", "subEnableRouting", "subEncrypt", + "subExpiredTemplate", "subHideSettings", "subIncyEnableRouting", "subIncyRoutingRules", + "subInfoNodeEnable", "subJsonAlwaysArray", "subJsonAutoDetect", "subJsonEnable", @@ -942,6 +965,7 @@ "subSupportUrl", "subThemeDir", "subTitle", + "subTrafficDepletedTemplate", "subURI", "subUpdates", "tgBotAPIServer", diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 7ef73b8e4..10cec0c6e 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -58,9 +58,11 @@ export const EXAMPLES: Record = { "subEnable": false, "subEnableRouting": false, "subEncrypt": false, + "subExpiredTemplate": "", "subHideSettings": false, "subIncyEnableRouting": false, "subIncyRoutingRules": "", + "subInfoNodeEnable": false, "subJsonAlwaysArray": false, "subJsonAutoDetect": false, "subJsonEnable": false, @@ -81,6 +83,7 @@ export const EXAMPLES: Record = { "subSupportUrl": "", "subThemeDir": "", "subTitle": "", + "subTrafficDepletedTemplate": "", "subURI": "", "subUpdates": 0, "tgBotAPIServer": "", @@ -172,9 +175,11 @@ export const EXAMPLES: Record = { "subEnable": false, "subEnableRouting": false, "subEncrypt": false, + "subExpiredTemplate": "", "subHideSettings": false, "subIncyEnableRouting": false, "subIncyRoutingRules": "", + "subInfoNodeEnable": false, "subJsonAlwaysArray": false, "subJsonAutoDetect": false, "subJsonEnable": false, @@ -195,6 +200,7 @@ export const EXAMPLES: Record = { "subSupportUrl": "", "subThemeDir": "", "subTitle": "", + "subTrafficDepletedTemplate": "", "subURI": "", "subUpdates": 0, "tgBotAPIServer": "", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 54d393714..233fe0d20 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -191,6 +191,9 @@ export const SCHEMAS: Record = { "subEncrypt": { "type": "boolean" }, + "subExpiredTemplate": { + "type": "string" + }, "subHideSettings": { "type": "boolean" }, @@ -200,6 +203,9 @@ export const SCHEMAS: Record = { "subIncyRoutingRules": { "type": "string" }, + "subInfoNodeEnable": { + "type": "boolean" + }, "subJsonAlwaysArray": { "type": "boolean" }, @@ -262,6 +268,9 @@ export const SCHEMAS: Record = { "subTitle": { "type": "string" }, + "subTrafficDepletedTemplate": { + "type": "string" + }, "subURI": { "type": "string" }, @@ -407,9 +416,11 @@ export const SCHEMAS: Record = { "subEnable", "subEnableRouting", "subEncrypt", + "subExpiredTemplate", "subHideSettings", "subIncyEnableRouting", "subIncyRoutingRules", + "subInfoNodeEnable", "subJsonAlwaysArray", "subJsonAutoDetect", "subJsonEnable", @@ -430,6 +441,7 @@ export const SCHEMAS: Record = { "subSupportUrl", "subThemeDir", "subTitle", + "subTrafficDepletedTemplate", "subURI", "subUpdates", "tgBotAPIServer", @@ -670,6 +682,9 @@ export const SCHEMAS: Record = { "subEncrypt": { "type": "boolean" }, + "subExpiredTemplate": { + "type": "string" + }, "subHideSettings": { "type": "boolean" }, @@ -679,6 +694,9 @@ export const SCHEMAS: Record = { "subIncyRoutingRules": { "type": "string" }, + "subInfoNodeEnable": { + "type": "boolean" + }, "subJsonAlwaysArray": { "type": "boolean" }, @@ -741,6 +759,9 @@ export const SCHEMAS: Record = { "subTitle": { "type": "string" }, + "subTrafficDepletedTemplate": { + "type": "string" + }, "subURI": { "type": "string" }, @@ -893,9 +914,11 @@ export const SCHEMAS: Record = { "subEnable", "subEnableRouting", "subEncrypt", + "subExpiredTemplate", "subHideSettings", "subIncyEnableRouting", "subIncyRoutingRules", + "subInfoNodeEnable", "subJsonAlwaysArray", "subJsonAutoDetect", "subJsonEnable", @@ -916,6 +939,7 @@ export const SCHEMAS: Record = { "subSupportUrl", "subThemeDir", "subTitle", + "subTrafficDepletedTemplate", "subURI", "subUpdates", "tgBotAPIServer", diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 966e46aba..85dc94dc8 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -66,9 +66,11 @@ export interface AllSetting { subEnable: boolean; subEnableRouting: boolean; subEncrypt: boolean; + subExpiredTemplate: string; subHideSettings: boolean; subIncyEnableRouting: boolean; subIncyRoutingRules: string; + subInfoNodeEnable: boolean; subJsonAlwaysArray: boolean; subJsonAutoDetect: boolean; subJsonEnable: boolean; @@ -89,6 +91,7 @@ export interface AllSetting { subSupportUrl: string; subThemeDir: string; subTitle: string; + subTrafficDepletedTemplate: string; subURI: string; subUpdates: number; tgBotAPIServer: string; @@ -181,9 +184,11 @@ export interface AllSettingView { subEnable: boolean; subEnableRouting: boolean; subEncrypt: boolean; + subExpiredTemplate: string; subHideSettings: boolean; subIncyEnableRouting: boolean; subIncyRoutingRules: string; + subInfoNodeEnable: boolean; subJsonAlwaysArray: boolean; subJsonAutoDetect: boolean; subJsonEnable: boolean; @@ -204,6 +209,7 @@ export interface AllSettingView { subSupportUrl: string; subThemeDir: string; subTitle: string; + subTrafficDepletedTemplate: string; subURI: string; subUpdates: number; tgBotAPIServer: string; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 2c56200ca..f1f002729 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -82,9 +82,11 @@ export const AllSettingSchema = z.object({ subEnable: z.boolean(), subEnableRouting: z.boolean(), subEncrypt: z.boolean(), + subExpiredTemplate: z.string(), subHideSettings: z.boolean(), subIncyEnableRouting: z.boolean(), subIncyRoutingRules: z.string(), + subInfoNodeEnable: z.boolean(), subJsonAlwaysArray: z.boolean(), subJsonAutoDetect: z.boolean(), subJsonEnable: z.boolean(), @@ -105,6 +107,7 @@ export const AllSettingSchema = z.object({ subSupportUrl: z.string(), subThemeDir: z.string(), subTitle: z.string(), + subTrafficDepletedTemplate: z.string(), subURI: z.string(), subUpdates: z.number().int().min(0).max(525600), tgBotAPIServer: z.string(), @@ -198,9 +201,11 @@ export const AllSettingViewSchema = z.object({ subEnable: z.boolean(), subEnableRouting: z.boolean(), subEncrypt: z.boolean(), + subExpiredTemplate: z.string(), subHideSettings: z.boolean(), subIncyEnableRouting: z.boolean(), subIncyRoutingRules: z.string(), + subInfoNodeEnable: z.boolean(), subJsonAlwaysArray: z.boolean(), subJsonAutoDetect: z.boolean(), subJsonEnable: z.boolean(), @@ -221,6 +226,7 @@ export const AllSettingViewSchema = z.object({ subSupportUrl: z.string(), subThemeDir: z.string(), subTitle: z.string(), + subTrafficDepletedTemplate: z.string(), subURI: z.string(), subUpdates: z.number().int().min(0).max(525600), tgBotAPIServer: z.string(), diff --git a/frontend/src/models/setting.ts b/frontend/src/models/setting.ts index b88a80b85..766cddbba 100644 --- a/frontend/src/models/setting.ts +++ b/frontend/src/models/setting.ts @@ -16,6 +16,10 @@ export class AllSetting { trafficDiff = 0; remarkTemplate = '{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D'; subShowIdentityOnAllLinks = false; + subInfoNodeEnable = false; + subExpiredTemplate = '⛔ {{EMAIL}} | Expired: {{EXPIRE_DATE}}'; + subTrafficDepletedTemplate = + '🚫 {{EMAIL}} | Traffic Depleted | {{TRAFFIC_USED}}/{{TRAFFIC_TOTAL}}'; datepicker: 'gregorian' | 'jalalian' = 'gregorian'; tgBotEnable = false; tgBotToken = ''; diff --git a/frontend/src/pages/settings/SubscriptionGeneralTab.tsx b/frontend/src/pages/settings/SubscriptionGeneralTab.tsx index b05ee3b55..ef5b2eda7 100644 --- a/frontend/src/pages/settings/SubscriptionGeneralTab.tsx +++ b/frontend/src/pages/settings/SubscriptionGeneralTab.tsx @@ -182,6 +182,41 @@ export default function SubscriptionGeneralTab({ /> + + updateSetting({ subInfoNodeEnable: v })} + /> + + + + updateSetting({ subExpiredTemplate: v })} + maxLength={256} + /> + + + + updateSetting({ subTrafficDepletedTemplate: v })} + maxLength={256} + /> + + { + it('defaults on AllSetting', () => { + const s = new AllSetting(); + expect(s.subInfoNodeEnable).toBe(false); + expect(s.subExpiredTemplate).toBe('⛔ {{EMAIL}} | Expired: {{EXPIRE_DATE}}'); + expect(s.subTrafficDepletedTemplate).toBe( + '🚫 {{EMAIL}} | Traffic Depleted | {{TRAFFIC_USED}}/{{TRAFFIC_TOTAL}}', + ); + }); + + it('accepts valid values in the settings schema', () => { + const r = AllSettingSchema.safeParse({ + subInfoNodeEnable: true, + subExpiredTemplate: 'custom expired', + subTrafficDepletedTemplate: 'custom depleted', + }); + expect(r.success).toBe(true); + }); + + it('rejects invalid types', () => { + expect(AllSettingSchema.safeParse({ subInfoNodeEnable: 'true' }).success).toBe(false); + }); +}); diff --git a/internal/sub/clash_info_node_test.go b/internal/sub/clash_info_node_test.go new file mode 100644 index 000000000..e27e4b392 --- /dev/null +++ b/internal/sub/clash_info_node_test.go @@ -0,0 +1,248 @@ +package sub + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +func TestSubClash_InfoNode_Active(t *testing.T) { + setupInfoNodeTestDB(t) + db := database.GetDB() + + ib := &model.Inbound{ + Id: 1, + UserId: 1, + Remark: "Germany-VLESS", + Enable: true, + Port: 443, + Protocol: model.VLESS, + Settings: `{"clients":[{"id":"c1-uuid","email":"user1@test.com","subId":"sub-clash","enable":true,"totalGB":10737418240}]}`, + StreamSettings: `{"network":"tcp","security":"none"}`, + } + if err := db.Create(ib).Error; err != nil { + t.Fatal(err) + } + rec := &model.ClientRecord{ + Id: 1, + Email: "user1@test.com", + SubID: "sub-clash", + UUID: "c1-uuid", + Enable: true, + TotalGB: 10737418240, + } + if err := db.Create(rec).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&xray.ClientTraffic{ + InboundId: 1, + Email: "user1@test.com", + Up: 1073741824, + Down: 1073741824, + Total: 10737418240, + Enable: true, + }).Error; err != nil { + t.Fatal(err) + } + + sub := NewSubService("{{EMAIL}}|📊{{TRAFFIC_LEFT}}") + sub.subInfoNodeEnable = true + clash := NewSubClashService(false, "", sub) + + out, _, err := clash.GetClash("sub-clash", "sub.example.com") + if err != nil { + t.Fatalf("GetClash: %v", err) + } + + if !strings.Contains(out, "type: socks5") || !strings.Contains(out, "server: 127.0.0.1") { + t.Fatalf("expected socks5 dummy node in clash YAML, got:\n%s", out) + } + if !strings.Contains(out, "user1@test.com|📊8.00GB") { + t.Fatalf("expected expanded remark on dummy proxy, got:\n%s", out) + } +} + +func TestSubClash_InfoNode_Expired(t *testing.T) { + setupInfoNodeTestDB(t) + db := database.GetDB() + + expiredTime := time.Now().Add(-24 * time.Hour).UnixMilli() + ib := &model.Inbound{ + Id: 1, + UserId: 1, + Remark: "Germany-VLESS", + Enable: true, + Port: 443, + Protocol: model.VLESS, + Settings: fmt.Sprintf(`{"clients":[{"id":"c1-uuid","email":"exp@test.com","subId":"sub-clash-exp","enable":true,"expiryTime":%d}]}`, expiredTime), + StreamSettings: `{"network":"tcp","security":"none"}`, + } + if err := db.Create(ib).Error; err != nil { + t.Fatal(err) + } + rec := &model.ClientRecord{ + Id: 1, + Email: "exp@test.com", + SubID: "sub-clash-exp", + UUID: "c1-uuid", + Enable: true, + ExpiryTime: expiredTime, + } + if err := db.Create(rec).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&xray.ClientTraffic{ + InboundId: 1, + Email: "exp@test.com", + ExpiryTime: expiredTime, + Enable: true, + }).Error; err != nil { + t.Fatal(err) + } + + sub := NewSubService("{{INBOUND}}") + sub.subInfoNodeEnable = true + sub.subExpiredTemplate = service.DefaultSubExpiredTemplate + clash := NewSubClashService(false, "", sub) + + out, _, err := clash.GetClash("sub-clash-exp", "sub.example.com") + if err != nil { + t.Fatalf("GetClash: %v", err) + } + + if !strings.Contains(out, "type: socks5") || !strings.Contains(out, "server: 127.0.0.1") { + t.Fatalf("expected socks5 dummy node in clash YAML, got:\n%s", out) + } + if !strings.Contains(out, "Expired") || !strings.Contains(out, "exp@test.com") { + t.Fatalf("expected expired remark in clash YAML, got:\n%s", out) + } + if strings.Contains(out, "Germany-VLESS") { + t.Fatalf("expired subscription must NOT contain working inbound, got:\n%s", out) + } +} + +func TestSubClash_InfoNode_Depleted(t *testing.T) { + setupInfoNodeTestDB(t) + db := database.GetDB() + + ib := &model.Inbound{ + Id: 1, + UserId: 1, + Remark: "Germany-VLESS", + Enable: true, + Port: 443, + Protocol: model.VLESS, + Settings: `{"clients":[{"id":"c1-uuid","email":"dep@test.com","subId":"sub-clash-dep","enable":true,"totalGB":5368709120}]}`, + StreamSettings: `{"network":"tcp","security":"none"}`, + } + if err := db.Create(ib).Error; err != nil { + t.Fatal(err) + } + rec := &model.ClientRecord{ + Id: 1, + Email: "dep@test.com", + SubID: "sub-clash-dep", + UUID: "c1-uuid", + Enable: true, + TotalGB: 5368709120, + } + if err := db.Create(rec).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&xray.ClientTraffic{ + InboundId: 1, + Email: "dep@test.com", + Up: 3221225472, + Down: 2147483648, + Total: 5368709120, + Enable: true, + }).Error; err != nil { + t.Fatal(err) + } + + sub := NewSubService("{{INBOUND}}") + sub.subInfoNodeEnable = true + sub.subTrafficDepletedTemplate = service.DefaultSubTrafficDepletedTemplate + clash := NewSubClashService(false, "", sub) + + out, _, err := clash.GetClash("sub-clash-dep", "sub.example.com") + if err != nil { + t.Fatalf("GetClash: %v", err) + } + + if !strings.Contains(out, "type: socks5") || !strings.Contains(out, "server: 127.0.0.1") { + t.Fatalf("expected socks5 dummy node in clash YAML, got:\n%s", out) + } + if !strings.Contains(out, "Traffic Depleted") || !strings.Contains(out, "dep@test.com") { + t.Fatalf("expected depleted remark in clash YAML, got:\n%s", out) + } + if strings.Contains(out, "Germany-VLESS") { + t.Fatalf("depleted subscription must NOT contain working inbound, got:\n%s", out) + } +} + +func TestSubClash_InfoNode_ProxyGroupOrder_DoesNotDefaultToDummy(t *testing.T) { + setupInfoNodeTestDB(t) + db := database.GetDB() + + ib := &model.Inbound{ + Id: 1, + UserId: 1, + Remark: "Germany-VLESS", + Enable: true, + Port: 443, + Protocol: model.VLESS, + Settings: `{"clients":[{"id":"c1-uuid","email":"user1@test.com","subId":"sub-clash","enable":true,"totalGB":10737418240}]}`, + StreamSettings: `{"network":"tcp","security":"none"}`, + } + if err := db.Create(ib).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.ClientRecord{Id: 1, Email: "user1@test.com", SubID: "sub-clash", UUID: "c1-uuid", Enable: true}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&xray.ClientTraffic{InboundId: 1, Email: "user1@test.com", Enable: true}).Error; err != nil { + t.Fatal(err) + } + + sub := NewSubService("{{EMAIL}}|📊{{TRAFFIC_LEFT}}|{{STATUS}}") + sub.subInfoNodeEnable = true + clash := NewSubClashService(false, "", sub) + + out, _, err := clash.GetClash("sub-clash", "sub.example.com") + if err != nil { + t.Fatalf("GetClash: %v", err) + } + + // Status token must be rendered as active + if !strings.Contains(out, "active") { + t.Fatalf("expected active status in clash dummy remark, got:\n%s", out) + } + + // In proxy-groups, PROXY select group must NOT have the dummy node as first member + // PROXY group proxies should begin with real proxy + if strings.Contains(out, "user1@test.com|active") && strings.Contains(out, "proxies:\n - user1@test.com|active") { + t.Fatalf("PROXY group must NOT contain dummy info node as member, got:\n%s", out) + } + if !strings.Contains(out, "proxies:\n - Germany-VLESS\n - DIRECT") { + t.Fatalf("expected real proxy Germany-VLESS in PROXY group, got:\n%s", out) + } +} diff --git a/internal/sub/clash_service.go b/internal/sub/clash_service.go index 63fb5b40d..410dd2691 100644 --- a/internal/sub/clash_service.go +++ b/internal/sub/clash_service.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "maps" + "slices" "strings" "github.com/goccy/go-json" @@ -40,6 +41,7 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e var proxies []map[string]any var hasInactiveExternal bool + var hasEnabledClient bool seenEmails := make(map[string]struct{}) for _, inbound := range inbounds { @@ -52,11 +54,17 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e injectExternalProxy(inbound, hostEps) } for _, client := range clients { + if client.Enable { + hasEnabledClient = true + } seenEmails[client.Email] = struct{}{} proxies = append(proxies, s.getProxies(subReq, inbound, client, host)...) } } for _, ext := range externalLinks { + if ext.Enable { + hasEnabledClient = true + } if !ext.Active { seenEmails[ext.Email] = struct{}{} hasInactiveExternal = true @@ -82,8 +90,25 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e for e := range seenEmails { emails = append(emails, e) } + slices.Sort(emails) traffic, _ := subReq.AggregateTrafficByEmails(emails) + traffic.Enable = hasEnabledClient header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000) + + if mode, remark := subReq.resolveInfoNodeRemark(subId, emails, traffic, len(proxies) > 0); mode != infoNodeNone { + dummyProxy := map[string]any{ + "name": remark, + "type": "socks5", + "server": "127.0.0.1", + "port": 1080, + } + if mode == infoNodeExpired || mode == infoNodeDepleted { + proxies = []map[string]any{dummyProxy} + } else { + proxies = append([]map[string]any{dummyProxy}, proxies...) + } + } + if len(proxies) == 0 { return "", header, nil } @@ -92,6 +117,9 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e proxyNames := make([]string, 0, len(proxies)+1) for _, proxy := range proxies { + if isDummyProxy(proxy) && len(proxies) > 1 { + continue + } if name, ok := proxy["name"].(string); ok && name != "" { proxyNames = append(proxyNames, name) } @@ -152,6 +180,19 @@ func ensureUniqueProxyNames(proxies []map[string]any) { } } +func isDummyProxy(proxy map[string]any) bool { + typ, _ := proxy["type"].(string) + server, _ := proxy["server"].(string) + var port int + switch p := proxy["port"].(type) { + case int: + port = p + case float64: + port = int(p) + } + return typ == "socks5" && server == "127.0.0.1" && port == 1080 +} + func fallbackProxyName(proxy map[string]any, idx int) string { typ, _ := proxy["type"].(string) server, _ := proxy["server"].(string) diff --git a/internal/sub/json_info_node_test.go b/internal/sub/json_info_node_test.go new file mode 100644 index 000000000..814124ec7 --- /dev/null +++ b/internal/sub/json_info_node_test.go @@ -0,0 +1,247 @@ +package sub + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +func TestSubJson_InfoNode_Active(t *testing.T) { + setupInfoNodeTestDB(t) + db := database.GetDB() + + ib := &model.Inbound{ + Id: 1, + UserId: 1, + Remark: "Germany-VLESS", + Enable: true, + Port: 443, + Protocol: model.VLESS, + Settings: `{"clients":[{"id":"c1-uuid","email":"user1@test.com","subId":"sub-json","enable":true,"totalGB":10737418240}]}`, + StreamSettings: `{"network":"tcp","security":"none"}`, + } + if err := db.Create(ib).Error; err != nil { + t.Fatal(err) + } + rec := &model.ClientRecord{ + Id: 1, + Email: "user1@test.com", + SubID: "sub-json", + UUID: "c1-uuid", + Enable: true, + TotalGB: 10737418240, + } + if err := db.Create(rec).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&xray.ClientTraffic{ + InboundId: 1, + Email: "user1@test.com", + Up: 1073741824, + Down: 1073741824, + Total: 10737418240, + Enable: true, + }).Error; err != nil { + t.Fatal(err) + } + + sub := NewSubService("{{EMAIL}}|📊{{TRAFFIC_LEFT}}") + sub.subInfoNodeEnable = true + jsonSvc := NewSubJsonService("", "", "", sub) + + out, _, err := jsonSvc.GetJson("sub-json", "sub.example.com", false) + if err != nil { + t.Fatalf("GetJson: %v", err) + } + + if !strings.Contains(out, "user1@test.com|📊8.00GB") { + t.Fatalf("expected dummy remark in JSON remarks, got:\n%s", out) + } + if !strings.Contains(out, `"protocol": "socks"`) && !strings.Contains(out, `"protocol":"socks"`) { + t.Fatalf("expected socks outbound in JSON, got:\n%s", out) + } +} + +func TestSubJson_InfoNode_Expired(t *testing.T) { + setupInfoNodeTestDB(t) + db := database.GetDB() + + expiredTime := time.Now().Add(-24 * time.Hour).UnixMilli() + ib := &model.Inbound{ + Id: 1, + UserId: 1, + Remark: "Germany-VLESS", + Enable: true, + Port: 443, + Protocol: model.VLESS, + Settings: fmt.Sprintf(`{"clients":[{"id":"c1-uuid","email":"exp@test.com","subId":"sub-json-exp","enable":true,"expiryTime":%d}]}`, expiredTime), + StreamSettings: `{"network":"tcp","security":"none"}`, + } + if err := db.Create(ib).Error; err != nil { + t.Fatal(err) + } + rec := &model.ClientRecord{ + Id: 1, + Email: "exp@test.com", + SubID: "sub-json-exp", + UUID: "c1-uuid", + Enable: true, + ExpiryTime: expiredTime, + } + if err := db.Create(rec).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&xray.ClientTraffic{ + InboundId: 1, + Email: "exp@test.com", + ExpiryTime: expiredTime, + Enable: true, + }).Error; err != nil { + t.Fatal(err) + } + + sub := NewSubService("{{INBOUND}}") + sub.subInfoNodeEnable = true + sub.subExpiredTemplate = service.DefaultSubExpiredTemplate + jsonSvc := NewSubJsonService("", "", "", sub) + + out, _, err := jsonSvc.GetJson("sub-json-exp", "sub.example.com", false) + if err != nil { + t.Fatalf("GetJson: %v", err) + } + + if !strings.Contains(out, "Expired") || !strings.Contains(out, "exp@test.com") { + t.Fatalf("expected expired remark in JSON remarks, got:\n%s", out) + } + if strings.Contains(out, "Germany-VLESS") { + t.Fatalf("expired subscription must NOT contain working inbound in JSON, got:\n%s", out) + } +} + +func TestSubJson_InfoNode_Depleted(t *testing.T) { + setupInfoNodeTestDB(t) + db := database.GetDB() + + ib := &model.Inbound{ + Id: 1, + UserId: 1, + Remark: "Germany-VLESS", + Enable: true, + Port: 443, + Protocol: model.VLESS, + Settings: `{"clients":[{"id":"c1-uuid","email":"dep@test.com","subId":"sub-json-dep","enable":true,"totalGB":5368709120}]}`, + StreamSettings: `{"network":"tcp","security":"none"}`, + } + if err := db.Create(ib).Error; err != nil { + t.Fatal(err) + } + rec := &model.ClientRecord{ + Id: 1, + Email: "dep@test.com", + SubID: "sub-json-dep", + UUID: "c1-uuid", + Enable: true, + TotalGB: 5368709120, + } + if err := db.Create(rec).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&xray.ClientTraffic{ + InboundId: 1, + Email: "dep@test.com", + Up: 3221225472, + Down: 2147483648, + Total: 5368709120, + Enable: true, + }).Error; err != nil { + t.Fatal(err) + } + + sub := NewSubService("{{INBOUND}}") + sub.subInfoNodeEnable = true + sub.subTrafficDepletedTemplate = service.DefaultSubTrafficDepletedTemplate + jsonSvc := NewSubJsonService("", "", "", sub) + + out, _, err := jsonSvc.GetJson("sub-json-dep", "sub.example.com", false) + if err != nil { + t.Fatalf("GetJson: %v", err) + } + + if !strings.Contains(out, "Traffic Depleted") || !strings.Contains(out, "dep@test.com") { + t.Fatalf("expected depleted remark in JSON remarks, got:\n%s", out) + } + if strings.Contains(out, "Germany-VLESS") { + t.Fatalf("depleted subscription must NOT contain working inbound in JSON, got:\n%s", out) + } +} + +func TestSubJson_InfoNode_StatusActive(t *testing.T) { + setupInfoNodeTestDB(t) + db := database.GetDB() + + ib := &model.Inbound{ + Id: 1, + UserId: 1, + Remark: "Germany-VLESS", + Enable: true, + Port: 443, + Protocol: model.VLESS, + Settings: `{"clients":[{"id":"c1-uuid","email":"user1@test.com","subId":"sub-json-status","enable":true,"totalGB":10737418240}]}`, + StreamSettings: `{"network":"tcp","security":"none"}`, + } + if err := db.Create(ib).Error; err != nil { + t.Fatal(err) + } + rec := &model.ClientRecord{ + Id: 1, + Email: "user1@test.com", + SubID: "sub-json-status", + UUID: "c1-uuid", + Enable: true, + TotalGB: 10737418240, + } + if err := db.Create(rec).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&xray.ClientTraffic{ + InboundId: 1, + Email: "user1@test.com", + Up: 100, + Down: 100, + Total: 10737418240, + Enable: true, + }).Error; err != nil { + t.Fatal(err) + } + + sub := NewSubService("{{EMAIL}}|{{STATUS_EMOJI}} {{STATUS}}") + sub.subInfoNodeEnable = true + jsonSvc := NewSubJsonService("", "", "", sub) + + out, _, err := jsonSvc.GetJson("sub-json-status", "sub.example.com", false) + if err != nil { + t.Fatalf("GetJson: %v", err) + } + + if !strings.Contains(out, "user1@test.com|✅ active") { + t.Fatalf("expected 'user1@test.com|✅ active' in JSON remarks, got:\n%s", out) + } +} diff --git a/internal/sub/json_service.go b/internal/sub/json_service.go index 07d7fc983..dac890f7d 100644 --- a/internal/sub/json_service.go +++ b/internal/sub/json_service.go @@ -83,6 +83,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo var header string var hasInactiveExternal bool + var hasEnabledClient bool seenEmails := make(map[string]struct{}) entries := make([]subConfigEntry, 0, len(inbounds)) @@ -99,6 +100,9 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo var inboundConfigs []json_util.RawMessage for _, client := range clients { + if client.Enable { + hasEnabledClient = true + } seenEmails[client.Email] = struct{}{} inboundConfigs = append(inboundConfigs, s.getConfig(subReq, inbound, client, host)...) } @@ -128,6 +132,9 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo configArray = append(configArray, entry.configs...) } for _, ext := range externalLinks { + if ext.Enable { + hasEnabledClient = true + } if !ext.Active { seenEmails[ext.Email] = struct{}{} hasInactiveExternal = true @@ -162,8 +169,20 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo for e := range seenEmails { emails = append(emails, e) } + slices.Sort(emails) traffic, _ := subReq.AggregateTrafficByEmails(emails) + traffic.Enable = hasEnabledClient header = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000) + + if mode, remark := subReq.resolveInfoNodeRemark(subId, emails, traffic, len(configArray) > 0); mode != infoNodeNone { + dummyConfig := s.genDummySocksConfig(remark) + if mode == infoNodeExpired || mode == infoNodeDepleted { + configArray = []json_util.RawMessage{dummyConfig} + } else { + configArray = append([]json_util.RawMessage{dummyConfig}, configArray...) + } + } + if len(configArray) == 0 { return "", header, nil } @@ -962,6 +981,32 @@ func (s *SubJsonService) genWireguard(inbound *model.Inbound, client model.Clien return result } +func (s *SubJsonService) genDummySocksConfig(remark string) json_util.RawMessage { + outbound := map[string]any{ + "protocol": "socks", + "tag": "proxy", + "settings": map[string]any{ + "servers": []any{ + map[string]any{ + "address": "127.0.0.1", + "port": 1080, + }, + }, + }, + } + rawOutbound, _ := json.Marshal(outbound) + newOutbounds := []json_util.RawMessage{rawOutbound} + newOutbounds = append(newOutbounds, s.defaultOutbounds...) + + newConfigJson := make(map[string]any) + maps.Copy(newConfigJson, s.configJson) + newConfigJson["outbounds"] = newOutbounds + newConfigJson["remarks"] = remark + + newConfig, _ := json.MarshalIndent(newConfigJson, "", " ") + return newConfig +} + func mergeFinalMask(base any, extra map[string]any) map[string]any { merged := map[string]any{} if baseMap, ok := base.(map[string]any); ok { diff --git a/internal/sub/remark_vars.go b/internal/sub/remark_vars.go index 2480db6c1..28dc5b617 100644 --- a/internal/sub/remark_vars.go +++ b/internal/sub/remark_vars.go @@ -625,7 +625,7 @@ func (s *SubService) effectiveTemplate(client model.Client) string { s.usageShown = map[string]bool{} } key := templateInfoKey(client) - if s.usageShown[key] { + if s.subInfoNodeEnable || s.usageShown[key] { remove := firstLinkOnlyBodyTokens if s.showIdentityOnAllLinks { remove = usageInfoTokens diff --git a/internal/sub/service.go b/internal/sub/service.go index c9e6cdaf1..9a6b698d4 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -44,10 +44,13 @@ type SubService struct { subscriptionBody bool // usageShown emits info once per subscription identity, including twins. // PrepareForRequest resets this per-request state. - usageShown map[string]bool - showIdentityOnAllLinks bool - inboundService service.InboundService - settingService service.SettingService + usageShown map[string]bool + showIdentityOnAllLinks bool + subInfoNodeEnable bool + subExpiredTemplate string + subTrafficDepletedTemplate string + 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,14 +200,29 @@ func (s *SubService) linkSettings(inbound *model.Inbound) map[string]any { // (the date formatter reads datepicker). Loading it only in getSubs left // JSON/Clash with the zero value. func (s *SubService) loadRemarkSettings() { - var err error - s.datepicker, err = s.settingService.GetDatepicker() - if err != nil { + if s.datepicker == "" { s.datepicker = "gregorian" } - s.showIdentityOnAllLinks, err = s.settingService.GetSubShowIdentityOnAllLinks() - if err != nil { - s.showIdentityOnAllLinks = false + if s.subExpiredTemplate == "" { + s.subExpiredTemplate = service.DefaultSubExpiredTemplate + } + if s.subTrafficDepletedTemplate == "" { + s.subTrafficDepletedTemplate = service.DefaultSubTrafficDepletedTemplate + } + if datepicker, err := s.settingService.GetDatepicker(); err == nil && datepicker != "" { + s.datepicker = datepicker + } + if enabled, err := s.settingService.GetSubShowIdentityOnAllLinks(); err == nil && enabled { + s.showIdentityOnAllLinks = enabled + } + if enabled, err := s.settingService.GetSubInfoNodeEnable(); err == nil && enabled { + s.subInfoNodeEnable = enabled + } + if tmpl, err := s.settingService.GetSubExpiredTemplate(); err == nil && tmpl != "" { + s.subExpiredTemplate = tmpl + } + if tmpl, err := s.settingService.GetSubTrafficDepletedTemplate(); err == nil && tmpl != "" { + s.subTrafficDepletedTemplate = tmpl } } @@ -293,6 +311,70 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int return s.ForRequest(host).getSubs(subId) } +type infoNodeMode int + +const ( + infoNodeNone infoNodeMode = iota + infoNodeActive + infoNodeExpired + infoNodeDepleted +) + +func (s *SubService) resolveInfoNodeRemark(subId string, uniqueEmails []string, traffic xray.ClientTraffic, hasEntries bool) (infoNodeMode, string) { + if !s.subInfoNodeEnable || !s.subscriptionBody { + return infoNodeNone, "" + } + nowSec := time.Now().Unix() + isExpired := traffic.ExpiryTime > 0 && traffic.ExpiryTime/1000 <= nowSec + isDepleted := traffic.Total > 0 && (traffic.Up+traffic.Down) >= traffic.Total + + primaryEmail := "" + if len(uniqueEmails) > 0 { + primaryEmail = uniqueEmails[0] + } + ctx := remarkContext{ + client: model.Client{Email: primaryEmail, SubID: subId}, + stats: traffic, + } + + if isExpired { + tmpl := s.subExpiredTemplate + if tmpl == "" { + tmpl = service.DefaultSubExpiredTemplate + } + remark := expandRemarkVars(tmpl, ctx) + if strings.TrimSpace(remark) == "" { + remark = "Expired" + } + return infoNodeExpired, remark + } + + if isDepleted { + tmpl := s.subTrafficDepletedTemplate + if tmpl == "" { + tmpl = service.DefaultSubTrafficDepletedTemplate + } + remark := expandRemarkVars(tmpl, ctx) + if strings.TrimSpace(remark) == "" { + remark = "Traffic Depleted" + } + return infoNodeDepleted, remark + } + + if hasEntries { + tmpl := s.remarkTemplate + if tmpl == "" { + tmpl = service.DefaultRemarkTemplate + } + remark := expandRemarkVars(tmpl, ctx) + if strings.TrimSpace(remark) != "" { + return infoNodeActive, remark + } + } + + return infoNodeNone, "" +} + func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.ClientTraffic, error) { var result []string var emails []string @@ -360,8 +442,18 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie for e := range seenEmails { uniqueEmails = append(uniqueEmails, e) } + slices.Sort(uniqueEmails) traffic, lastOnline := s.AggregateTrafficByEmails(uniqueEmails) traffic.Enable = hasEnabledClient + + if mode, remark := s.resolveInfoNodeRemark(subId, uniqueEmails, traffic, len(result) > 0); mode != infoNodeNone { + dummyLink := fmt.Sprintf("socks://127.0.0.1:1080#%s", strings.ReplaceAll(url.QueryEscape(remark), "+", "%20")) + if mode == infoNodeExpired || mode == infoNodeDepleted { + return []string{dummyLink}, emails, lastOnline, traffic, nil + } + result = append([]string{dummyLink}, result...) + } + return result, emails, lastOnline, traffic, nil } diff --git a/internal/sub/service_info_node_test.go b/internal/sub/service_info_node_test.go new file mode 100644 index 000000000..274f2881d --- /dev/null +++ b/internal/sub/service_info_node_test.go @@ -0,0 +1,365 @@ +package sub + +import ( + "fmt" + "net/url" + "strings" + "testing" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +func setupInfoNodeTestDB(t *testing.T) { + t.Helper() + if err := database.InitDB(t.TempDir() + "/test_infonode.db"); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { + _ = database.CloseDB() + }) + db := database.GetDB() + if err := db.AutoMigrate( + &model.Inbound{}, + &model.ClientRecord{}, + &model.ClientInbound{}, + &xray.ClientTraffic{}, + &model.Setting{}, + ); err != nil { + t.Fatalf("AutoMigrate: %v", err) + } +} + +func TestSubService_InfoNode_Active(t *testing.T) { + setupInfoNodeTestDB(t) + db := database.GetDB() + + ib := &model.Inbound{ + Id: 1, + UserId: 1, + Up: 0, + Down: 0, + Total: 0, + Remark: "Germany-VLESS", + Enable: true, + Port: 443, + Protocol: model.VLESS, + Settings: `{"clients":[{"id":"c1-uuid","email":"user1@test.com","subId":"sub-active","enable":true,"totalGB":10737418240,"expiryTime":0}]}`, + StreamSettings: `{"network":"tcp","security":"none"}`, + } + if err := db.Create(ib).Error; err != nil { + t.Fatal(err) + } + rec := &model.ClientRecord{ + Id: 1, + Email: "user1@test.com", + SubID: "sub-active", + UUID: "c1-uuid", + Enable: true, + TotalGB: 10737418240, // 10 GB + } + if err := db.Create(rec).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&xray.ClientTraffic{ + InboundId: 1, + Email: "user1@test.com", + Up: 1073741824, // 1 GB + Down: 1073741824, // 1 GB + Total: 10737418240, + Enable: true, + }).Error; err != nil { + t.Fatal(err) + } + + svc := NewSubService("{{EMAIL}}|📊{{TRAFFIC_LEFT}}") + svc.subInfoNodeEnable = true + svc.subscriptionBody = true + + links, emails, _, traffic, err := svc.GetSubs("sub-active", "sub.example.com") + if err != nil { + t.Fatalf("GetSubs error: %v", err) + } + if len(links) != 2 { + t.Fatalf("expected 2 links (dummy info node + 1 vless link), got %d: %v", len(links), links) + } + if len(emails) != 1 || emails[0] != "user1@test.com" { + t.Fatalf("emails = %v, want [user1@test.com]", emails) + } + if !traffic.Enable { + t.Fatalf("traffic should be enabled") + } + + // First link must be the dummy socks node + if !strings.HasPrefix(links[0], "socks://127.0.0.1:1080#") { + t.Fatalf("first link must be dummy socks node, got: %q", links[0]) + } + decodedRemark, _ := url.QueryUnescape(strings.TrimPrefix(links[0], "socks://127.0.0.1:1080#")) + if !strings.Contains(decodedRemark, "user1@test.com") || !strings.Contains(decodedRemark, "8.00GB") { + t.Fatalf("expected dummy remark to contain user1@test.com and 8.00GB, got: %q", decodedRemark) + } + + // Second link must be the clean vless link without traffic left tokens + if !strings.HasPrefix(links[1], "vless://") { + t.Fatalf("second link must be vless, got: %q", links[1]) + } + if strings.Contains(links[1], "8.00GB") { + t.Fatalf("server link must have clean remark without usage stats, got: %q", links[1]) + } +} + +func TestSubService_InfoNode_Expired(t *testing.T) { + setupInfoNodeTestDB(t) + db := database.GetDB() + + expiredTime := time.Now().Add(-24 * time.Hour).UnixMilli() + ib := &model.Inbound{ + Id: 1, + UserId: 1, + Remark: "Germany-VLESS", + Enable: true, + Port: 443, + Protocol: model.VLESS, + Settings: fmt.Sprintf(`{"clients":[{"id":"c1-uuid","email":"exp@test.com","subId":"sub-exp","enable":true,"expiryTime":%d}]}`, expiredTime), + StreamSettings: `{"network":"tcp","security":"none"}`, + } + if err := db.Create(ib).Error; err != nil { + t.Fatal(err) + } + rec := &model.ClientRecord{ + Id: 1, + Email: "exp@test.com", + SubID: "sub-exp", + UUID: "c1-uuid", + Enable: true, + ExpiryTime: expiredTime, + } + if err := db.Create(rec).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&xray.ClientTraffic{ + InboundId: 1, + Email: "exp@test.com", + ExpiryTime: expiredTime, + Enable: true, + }).Error; err != nil { + t.Fatal(err) + } + + svc := NewSubService("{{INBOUND}}|{{EMAIL}}") + svc.subInfoNodeEnable = true + svc.subExpiredTemplate = service.DefaultSubExpiredTemplate + svc.subscriptionBody = true + + links, _, _, _, err := svc.GetSubs("sub-exp", "sub.example.com") + if err != nil { + t.Fatalf("GetSubs error: %v", err) + } + if len(links) != 1 { + t.Fatalf("expected ONLY 1 link (dummy expired node), got %d: %v", len(links), links) + } + if !strings.HasPrefix(links[0], "socks://127.0.0.1:1080#") { + t.Fatalf("expired link must be dummy socks node, got: %q", links[0]) + } + decodedRemark, _ := url.QueryUnescape(strings.TrimPrefix(links[0], "socks://127.0.0.1:1080#")) + if !strings.Contains(decodedRemark, "Expired") || !strings.Contains(decodedRemark, "exp@test.com") { + t.Fatalf("expected expired remark, got: %q", decodedRemark) + } +} + +func TestSubService_InfoNode_TrafficDepleted(t *testing.T) { + setupInfoNodeTestDB(t) + db := database.GetDB() + + ib := &model.Inbound{ + Id: 1, + UserId: 1, + Remark: "Germany-VLESS", + Enable: true, + Port: 443, + Protocol: model.VLESS, + Settings: `{"clients":[{"id":"c1-uuid","email":"dep@test.com","subId":"sub-dep","enable":true,"totalGB":5368709120}]}`, + StreamSettings: `{"network":"tcp","security":"none"}`, + } + if err := db.Create(ib).Error; err != nil { + t.Fatal(err) + } + rec := &model.ClientRecord{ + Id: 1, + Email: "dep@test.com", + SubID: "sub-dep", + UUID: "c1-uuid", + Enable: true, + TotalGB: 5368709120, // 5 GB + } + if err := db.Create(rec).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&xray.ClientTraffic{ + InboundId: 1, + Email: "dep@test.com", + Up: 3221225472, // 3 GB + Down: 2147483648, // 2 GB (total 5 GB used = depleted) + Total: 5368709120, + Enable: true, + }).Error; err != nil { + t.Fatal(err) + } + + svc := NewSubService("{{INBOUND}}|{{EMAIL}}") + svc.subInfoNodeEnable = true + svc.subTrafficDepletedTemplate = service.DefaultSubTrafficDepletedTemplate + svc.subscriptionBody = true + + links, _, _, _, err := svc.GetSubs("sub-dep", "sub.example.com") + if err != nil { + t.Fatalf("GetSubs error: %v", err) + } + if len(links) != 1 { + t.Fatalf("expected ONLY 1 link (dummy depleted node), got %d: %v", len(links), links) + } + if !strings.HasPrefix(links[0], "socks://127.0.0.1:1080#") { + t.Fatalf("depleted link must be dummy socks node, got: %q", links[0]) + } + decodedRemark, _ := url.QueryUnescape(strings.TrimPrefix(links[0], "socks://127.0.0.1:1080#")) + if !strings.Contains(decodedRemark, "Traffic Depleted") || !strings.Contains(decodedRemark, "dep@test.com") { + t.Fatalf("expected depleted remark, got: %q", decodedRemark) + } +} + +func TestSubService_GetSubs_NonSubscriptionBody_NoInfoNode(t *testing.T) { + setupInfoNodeTestDB(t) + db := database.GetDB() + + ib := &model.Inbound{ + Id: 1, + UserId: 1, + Remark: "US-VLESS", + Enable: true, + Port: 443, + Protocol: model.VLESS, + Settings: `{"clients":[{"id":"c1-uuid","email":"user1@test.com","subId":"sub-panel","enable":true,"totalGB":10737418240}]}`, + StreamSettings: `{"network":"tcp","security":"none"}`, + } + if err := db.Create(ib).Error; err != nil { + t.Fatal(err) + } + rec := &model.ClientRecord{ + Id: 1, + Email: "user1@test.com", + SubID: "sub-panel", + UUID: "c1-uuid", + Enable: true, + TotalGB: 10737418240, + } + if err := db.Create(rec).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&xray.ClientTraffic{ + InboundId: 1, + Email: "user1@test.com", + Up: 1073741824, + Down: 1073741824, + Total: 10737418240, + Enable: true, + }).Error; err != nil { + t.Fatal(err) + } + + // When called via GetSubs (e.g. from LinkProvider for admin QR / copy modals), + // subscriptionBody is false, so it should render the clean link and not add dummy nodes. + svc := NewSubService("{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D") + svc.subInfoNodeEnable = true + + links, _, _, _, err := svc.GetSubs("sub-panel", "sub.example.com") + if err != nil { + t.Fatalf("GetSubs error: %v", err) + } + if len(links) != 1 { + t.Fatalf("expected 1 clean link, got %d: %v", len(links), links) + } + if strings.HasPrefix(links[0], "socks://127.0.0.1:1080#") { + t.Fatalf("GetSubs must NOT return dummy socks node when subscriptionBody is false: %v", links[0]) + } + if strings.Contains(links[0], "8.00GB") { + t.Fatalf("GetSubs must NOT contain snapshot usage stats in non-subscriptionBody context: %v", links[0]) + } +} + +func TestSubService_InfoNode_MultiClient_DeterministicPrimaryEmail(t *testing.T) { + setupInfoNodeTestDB(t) + db := database.GetDB() + + ib := &model.Inbound{ + Id: 1, + UserId: 1, + Remark: "Germany-VLESS", + Enable: true, + Port: 443, + Protocol: model.VLESS, + Settings: `{"clients":[{"id":"c1-uuid","email":"zeta@test.com","subId":"sub-multi","enable":true,"totalGB":10737418240},{"id":"c2-uuid","email":"alpha@test.com","subId":"sub-multi","enable":true,"totalGB":10737418240}]}`, + StreamSettings: `{"network":"tcp","security":"none"}`, + } + if err := db.Create(ib).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.ClientRecord{ + Id: 1, Email: "zeta@test.com", SubID: "sub-multi", UUID: "c1-uuid", Enable: true, TotalGB: 10737418240, + }).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.ClientRecord{ + Id: 2, Email: "alpha@test.com", SubID: "sub-multi", UUID: "c2-uuid", Enable: true, TotalGB: 10737418240, + }).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 2}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&xray.ClientTraffic{ + InboundId: 1, Email: "zeta@test.com", Up: 100, Down: 100, Total: 10737418240, Enable: true, + }).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&xray.ClientTraffic{ + InboundId: 1, Email: "alpha@test.com", Up: 100, Down: 100, Total: 10737418240, Enable: true, + }).Error; err != nil { + t.Fatal(err) + } + + svc := NewSubService("{{EMAIL}}") + svc.subInfoNodeEnable = true + svc.subscriptionBody = true + + for i := 0; i < 5; i++ { + links, _, _, _, err := svc.GetSubs("sub-multi", "sub.example.com") + if err != nil { + t.Fatalf("GetSubs error: %v", err) + } + if len(links) < 1 || !strings.HasPrefix(links[0], "socks://127.0.0.1:1080#") { + t.Fatalf("expected dummy socks node, got: %v", links) + } + decodedRemark, _ := url.QueryUnescape(strings.TrimPrefix(links[0], "socks://127.0.0.1:1080#")) + if decodedRemark != "alpha@test.com" { + t.Fatalf("expected primaryEmail 'alpha@test.com' (sorted alphabetically), got %q", decodedRemark) + } + } +} diff --git a/internal/web/entity/entity.go b/internal/web/entity/entity.go index bee5cea1f..9bd460380 100644 --- a/internal/web/entity/entity.go +++ b/internal/web/entity/entity.go @@ -30,12 +30,15 @@ type AllSetting struct { IpLimitAllowlist string `json:"ipLimitAllowlist" form:"ipLimitAllowlist"` 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"` - SubShowIdentityOnAllLinks bool `json:"subShowIdentityOnAllLinks" form:"subShowIdentityOnAllLinks"` - 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"` + SubInfoNodeEnable bool `json:"subInfoNodeEnable" form:"subInfoNodeEnable"` + SubExpiredTemplate string `json:"subExpiredTemplate" form:"subExpiredTemplate"` + SubTrafficDepletedTemplate string `json:"subTrafficDepletedTemplate" form:"subTrafficDepletedTemplate"` + Datepicker string `json:"datepicker" form:"datepicker"` TgBotEnable bool `json:"tgBotEnable" form:"tgBotEnable"` TgBotToken string `json:"tgBotToken" form:"tgBotToken"` diff --git a/internal/web/service/setting.go b/internal/web/service/setting.go index 1ad240a90..2cf5e8433 100644 --- a/internal/web/service/setting.go +++ b/internal/web/service/setting.go @@ -34,11 +34,13 @@ import ( var xrayTemplateConfig string const ( - DefaultSubClashUserAgentRegex = `(?i)(clash|mihomo)` - DefaultSubJsonUserAgentRegex = `` - DefaultRemarkTemplate = "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D" - DefaultTrustedProxyCIDRs = "127.0.0.1/32,::1/128" - maxRegexLength = 2048 + DefaultSubClashUserAgentRegex = `(?i)(clash|mihomo)` + DefaultSubJsonUserAgentRegex = `` + DefaultRemarkTemplate = "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D" + DefaultSubExpiredTemplate = "⛔ {{EMAIL}} | Expired: {{EXPIRE_DATE}}" + DefaultSubTrafficDepletedTemplate = "🚫 {{EMAIL}} | Traffic Depleted | {{TRAFFIC_USED}}/{{TRAFFIC_TOTAL}}" + DefaultTrustedProxyCIDRs = "127.0.0.1/32,::1/128" + maxRegexLength = 2048 ) var defaultValueMap = map[string]string{ @@ -70,6 +72,9 @@ var defaultValueMap = map[string]string{ "trafficDiff": "0", "remarkTemplate": DefaultRemarkTemplate, "subShowIdentityOnAllLinks": "false", + "subInfoNodeEnable": "false", + "subExpiredTemplate": DefaultSubExpiredTemplate, + "subTrafficDepletedTemplate": DefaultSubTrafficDepletedTemplate, "timeLocation": "Local", "tgBotEnable": "false", "tgBotToken": "", @@ -676,6 +681,18 @@ func (s *SettingService) GetSubShowIdentityOnAllLinks() (bool, error) { return s.getBool("subShowIdentityOnAllLinks") } +func (s *SettingService) GetSubInfoNodeEnable() (bool, error) { + return s.getBool("subInfoNodeEnable") +} + +func (s *SettingService) GetSubExpiredTemplate() (string, error) { + return s.getString("subExpiredTemplate") +} + +func (s *SettingService) GetSubTrafficDepletedTemplate() (string, error) { + return s.getString("subTrafficDepletedTemplate") +} + func (s *SettingService) GetSecret() ([]byte, error) { secret, err := s.getString("secret") if secret == "" || secret == defaultValueMap["secret"] { diff --git a/internal/web/service/setting_sub_info_node_test.go b/internal/web/service/setting_sub_info_node_test.go new file mode 100644 index 000000000..16f90e470 --- /dev/null +++ b/internal/web/service/setting_sub_info_node_test.go @@ -0,0 +1,44 @@ +package service + +import ( + "testing" +) + +func TestSubInfoNodeSettingsDefaultsAndPersists(t *testing.T) { + setupSettingTestDB(t) + s := &SettingService{} + + settings, err := s.GetAllSetting() + if err != nil { + t.Fatal(err) + } + if settings.SubInfoNodeEnable { + t.Fatal("expected default SubInfoNodeEnable false") + } + if settings.SubExpiredTemplate != DefaultSubExpiredTemplate { + t.Fatalf("expected default SubExpiredTemplate %q, got %q", DefaultSubExpiredTemplate, settings.SubExpiredTemplate) + } + if settings.SubTrafficDepletedTemplate != DefaultSubTrafficDepletedTemplate { + t.Fatalf("expected default SubTrafficDepletedTemplate %q, got %q", DefaultSubTrafficDepletedTemplate, settings.SubTrafficDepletedTemplate) + } + + settings.SubInfoNodeEnable = true + settings.SubExpiredTemplate = "custom expired" + settings.SubTrafficDepletedTemplate = "custom depleted" + if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil { + t.Fatal(err) + } + + gotEnabled, err := s.GetSubInfoNodeEnable() + if err != nil || !gotEnabled { + t.Fatalf("expected true, got %v, err %v", gotEnabled, err) + } + gotExp, err := s.GetSubExpiredTemplate() + if err != nil || gotExp != "custom expired" { + t.Fatalf("expected 'custom expired', got %q, err %v", gotExp, err) + } + gotDep, err := s.GetSubTrafficDepletedTemplate() + if err != nil || gotDep != "custom depleted" { + t.Fatalf("expected 'custom depleted', got %q, err %v", gotDep, err) + } +} diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 95e4f563c..85b196fee 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -1456,7 +1456,13 @@ "httpMethodDesc": "الأسلوب المستخدم في طلبات probe.", "note": "تحمل موزّعات leastPing/leastLoad دائمًا burstObservatory. يخصّص هذا المفتاح معاملات probe — أوقفه لاستخدام الإعدادات الافتراضية المدمجة. تُطبَّق التغييرات بعد إعادة تشغيل اللوحة." } - } + }, + "subInfoNodeEnable": "تكوين معلومات منفصل / عقدة وهمية", + "subInfoNodeEnableDesc": "عند التفعيل، يتم عرض الملاحظات وحركة المرور/الأيام المتبقية كتكوين SOCKS منفصل في الأعلى، وعند انتهاء الصلاحية أو نفاد البيانات يتم إرجاع تكوين الحالة فقط.", + "subExpiredTemplate": "قالب انتهاء الصلاحية", + "subExpiredTemplateDesc": "قالب التكوين الوهمي عند انتهاء صلاحية اشتراك المستخدم.", + "subTrafficDepletedTemplate": "قالب نفاد البيانات", + "subTrafficDepletedTemplateDesc": "قالب التكوين الوهمي عند استهلاك حصة بيانات المستخدم بالكامل." }, "xray": { "save": "احفظ", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index bc7f4bec9..c44a1ec3c 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -1574,7 +1574,13 @@ "httpMethodDesc": "Method used for probe requests.", "note": "leastPing/leastLoad balancers always carry a burst observatory. This switch customises its probe parameters — turn it off to use the built-in defaults. Changes apply after a panel restart." } - } + }, + "subInfoNodeEnable": "Separate Info Config / Dummy Node", + "subInfoNodeEnableDesc": "When enabled, remarks and remaining traffic/days are served as a separate dummy SOCKS config at the top, and expired/depleted subscriptions return only the status config.", + "subExpiredTemplate": "Expired Template", + "subExpiredTemplateDesc": "Template for the dummy config when the subscriber account has expired.", + "subTrafficDepletedTemplate": "Traffic Depleted Template", + "subTrafficDepletedTemplateDesc": "Template for the dummy config when subscriber traffic quota is exhausted." }, "xray": { "save": "Save", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 2b848a897..61f0418f8 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -1456,7 +1456,13 @@ "httpMethodDesc": "Método usado para las solicitudes de probe.", "note": "Los balanceadores leastPing/leastLoad siempre llevan un burstObservatory. Este interruptor personaliza sus parámetros de probe — apágalo para usar los valores predeterminados integrados. Los cambios se aplican tras reiniciar el panel." } - } + }, + "subInfoNodeEnable": "Configuración de información separada / Nodo ficticio", + "subInfoNodeEnableDesc": "Si está activado, la información de tráfico y días restantes se muestra en un nodo SOCKS ficticio arriba, y las suscripciones caducadas o agotadas devuelven solo el estado.", + "subExpiredTemplate": "Plantilla de expirado", + "subExpiredTemplateDesc": "Plantilla para el nodo ficticio cuando la suscripción ha expirado.", + "subTrafficDepletedTemplate": "Plantilla de tráfico agotado", + "subTrafficDepletedTemplateDesc": "Plantilla para el nodo ficticio cuando el límite de tráfico se ha agotado." }, "xray": { "save": "Guardar configuración", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index fdcd05b45..d527d72c9 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -1456,7 +1456,13 @@ "httpMethodDesc": "متد استفاده‌شده برای درخواست‌های probe.", "note": "موزان‌کننده‌های leastPing/leastLoad همیشه burstObservatory دارند. این کلید پارامترهای probe آن را سفارشی می‌کند — آن را خاموش کنید تا از پیش‌فرض‌های داخلی استفاده شود. تغییرات پس از راه‌اندازی مجدد پنل اعمال می‌شوند." } - } + }, + "subInfoNodeEnable": "کانفیگ جداگانه اطلاعات / نود نمایشی", + "subInfoNodeEnableDesc": "در صورت فعال بودن، مشخصات و حجم/روزهای باقیمانده به عنوان یک کانفیگ مجزای ساکس در بالای لیست نمایش داده می‌شود و در صورت انقضا یا اتمام حجم فقط پیام وضعیت ارسال می‌شود.", + "subExpiredTemplate": "قالب پیام انقضا", + "subExpiredTemplateDesc": "قالب کانفیگ نمایشی زمانی که اشتراک کاربر منقضی شده است.", + "subTrafficDepletedTemplate": "قالب پیام اتمام حجم", + "subTrafficDepletedTemplateDesc": "قالب کانفیگ نمایشی زمانی که حجم اشتراک کاربر به پایان رسیده است." }, "xray": { "save": "ذخیره", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 070d5d675..2adc9179c 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -1456,7 +1456,13 @@ "httpMethodDesc": "Metode yang dipakai untuk permintaan probe.", "note": "Penyeimbang leastPing/leastLoad selalu membawa burstObservatory. Sakelar ini menyesuaikan parameter probe-nya — matikan untuk memakai bawaan default. Perubahan berlaku setelah panel dimulai ulang." } - } + }, + "subInfoNodeEnable": "Konfigurasi Info Terpisah / Node Dummy", + "subInfoNodeEnableDesc": "Saat diaktifkan, catatan dan sisa kuota/hari disajikan sebagai konfigurasi SOCKS dummy terpisah di bagian atas, dan langganan kedaluwarsa/habis hanya mengembalikan status.", + "subExpiredTemplate": "Templat Kedaluwarsa", + "subExpiredTemplateDesc": "Templat untuk konfigurasi dummy saat akun langganan telah kedaluwarsa.", + "subTrafficDepletedTemplate": "Templat Kuota Habis", + "subTrafficDepletedTemplateDesc": "Templat untuk konfigurasi dummy saat kuota data langganan telah habis." }, "xray": { "save": "Simpan", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 804624f07..85e7515e8 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -1456,7 +1456,13 @@ "httpMethodDesc": "プローブ要求に使う HTTP メソッド。", "note": "leastPing/leastLoad バランサーは常に burstObservatory を持ちます。このスイッチはプローブパラメータをカスタマイズします — オフにすると組み込みのデフォルトを使います。変更はパネルの再起動後に反映されます。" } - } + }, + "subInfoNodeEnable": "個別情報設定 / ダミーノード", + "subInfoNodeEnableDesc": "有効にすると、備考と残りの通信量/日数が上部の独立したSOCKSダミー構成として表示され、期限切れや通信量超過時はステータスのみが返されます。", + "subExpiredTemplate": "期限切れテンプレート", + "subExpiredTemplateDesc": "サブスクリプションの有効期限が切れた際のダミー構成用テンプレート。", + "subTrafficDepletedTemplate": "通信量超過テンプレート", + "subTrafficDepletedTemplateDesc": "サブスクリプションの通信量が上限に達した際のダミー構成用テンプレート。" }, "xray": { "importRules": "ルールをインポート", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 07b27eb92..79ddaabd3 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -1456,7 +1456,13 @@ "httpMethodDesc": "Método usado nas requisições de probe.", "note": "Balanceadores leastPing/leastLoad sempre carregam um burstObservatory. Esta opção personaliza seus parâmetros de probe — desligue-a para usar os padrões integrados. As alterações se aplicam após reiniciar o painel." } - } + }, + "subInfoNodeEnable": "Configuração de informações separada / Nó fictício", + "subInfoNodeEnableDesc": "Quando ativado, observações e tráfego/dias restantes são exibidos em uma configuração SOCKS fictícia no topo, e assinaturas expiradas/esgotadas retornam apenas o status.", + "subExpiredTemplate": "Modelo expirado", + "subExpiredTemplateDesc": "Modelo para a configuração fictícia quando a assinatura expirou.", + "subTrafficDepletedTemplate": "Modelo de tráfego esgotado", + "subTrafficDepletedTemplateDesc": "Modelo para a configuração fictícia quando a cota de tráfego foi esgotada." }, "xray": { "importRules": "Importar regras", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 0d86b338b..06c452ab1 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -1456,7 +1456,13 @@ "httpMethodDesc": "Метод запросов при проверках.", "note": "Балансировщики leastPing/leastLoad всегда содержат burst-обсерваторию. Этот переключатель настраивает её параметры проб — выключите, чтобы использовать встроенные значения по умолчанию. Изменения применяются после перезапуска панели." } - } + }, + "subInfoNodeEnable": "Отдельный инфо-конфиг / фиктивный узел", + "subInfoNodeEnableDesc": "Если включено, примечание и остаток трафика/дней отображаются отдельным SOCKS-конфигом вверху, а при истечении срока/трафика возвращается только статус.", + "subExpiredTemplate": "Шаблон истекшей подписки", + "subExpiredTemplateDesc": "Шаблон для фиктивного узла, когда срок действия подписки истек.", + "subTrafficDepletedTemplate": "Шаблон исчерпания трафика", + "subTrafficDepletedTemplateDesc": "Шаблон для фиктивного узла, когда лимит трафика исчерпан." }, "xray": { "importRules": "Импорт правил", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 55596fb1a..638563309 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -1456,7 +1456,13 @@ "httpMethodDesc": "Probe isteklerinde kullanılan HTTP yöntemi.", "note": "leastPing/leastLoad dengeleyicileri her zaman bir burstObservatory taşır. Bu anahtar probe parametrelerini özelleştirir — yerleşik varsayılanları kullanmak için kapatın. Değişiklikler panel yeniden başlatıldıktan sonra uygulanır." } - } + }, + "subInfoNodeEnable": "Ayrı Bilgi Yapılandırması / Sahte Düğüm", + "subInfoNodeEnableDesc": "Etkinleştirildiğinde, notlar ve kalan trafik/günler en üstte ayrı bir sahte SOCKS yapılandırması olarak sunulur; süresi dolmuş veya kotası bitmiş abonelikler yalnızca durum yapılandırmasını alır.", + "subExpiredTemplate": "Süresi Dolmuş Şablonu", + "subExpiredTemplateDesc": "Abonelik süresi dolduğunda sahte yapılandırma için kullanılacak şablon.", + "subTrafficDepletedTemplate": "Trafik Tükendi Şablonu", + "subTrafficDepletedTemplateDesc": "Abonelik trafik kotası bittiğinde sahte yapılandırma için kullanılacak şablon." }, "xray": { "save": "Kaydet", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index f08d4e2ee..17c1f4b8c 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -1456,7 +1456,13 @@ "httpMethodDesc": "Метод запитів під час перевірок.", "note": "Балансувальники leastPing/leastLoad завжди мають burstObservatory. Цей перемикач налаштовує її параметри probe — вимкніть, щоб використовувати вбудовані значення за замовчуванням. Зміни застосовуються після перезапуску панелі." } - } + }, + "subInfoNodeEnable": "Окремий інфо-конфіг / фіктивний вузол", + "subInfoNodeEnableDesc": "Якщо увімкнено, примітка та залишок трафіку/днів відображаються окремим SOCKS-конфігом угорі, а після закінчення терміну/трафіку повертається лише статус.", + "subExpiredTemplate": "Шаблон закінчення терміну", + "subExpiredTemplateDesc": "Шаблон для фіктивного вузла, коли термін дії підписки закінчився.", + "subTrafficDepletedTemplate": "Шаблон вичерпання трафіку", + "subTrafficDepletedTemplateDesc": "Шаблон для фіктивного вузла, коли ліміт трафіку вичерпано." }, "xray": { "save": "Зберегти", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 8a590cb93..487a265f9 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -1456,7 +1456,13 @@ "httpMethodDesc": "Phương thức dùng cho yêu cầu probe.", "note": "Các bộ cân bằng leastPing/leastLoad luôn mang một burstObservatory. Công tắc này tùy chỉnh các tham số probe — tắt nó để dùng mặc định tích hợp. Các thay đổi áp dụng sau khi khởi động lại bảng điều khiển." } - } + }, + "subInfoNodeEnable": "Cấu hình thông tin riêng / Nút ảo", + "subInfoNodeEnableDesc": "Khi bật, ghi chú và lưu lượng/ngày còn lại sẽ hiển thị dưới dạng cấu hình SOCKS ảo riêng ở trên cùng, và đăng ký hết hạn/hết dung lượng chỉ trả về cấu hình trạng thái.", + "subExpiredTemplate": "Mẫu hết hạn", + "subExpiredTemplateDesc": "Mẫu cho cấu hình ảo khi tài khoản đăng ký đã hết hạn.", + "subTrafficDepletedTemplate": "Mẫu hết dung lượng", + "subTrafficDepletedTemplateDesc": "Mẫu cho cấu hình ảo khi tài khoản đăng ký đã dùng hết dung lượng." }, "xray": { "importRules": "Nhập quy tắc", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 34a65ef28..6d84d78a2 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -1456,7 +1456,13 @@ "httpMethodDesc": "探活请求使用的 HTTP 方法。", "note": "leastPing/leastLoad 均衡器始终带有 burstObservatory。此开关自定义其探活参数 — 关闭以使用内置默认值。更改在面板重启后生效。" } - } + }, + "subInfoNodeEnable": "独立信息配置 / 提示节点", + "subInfoNodeEnableDesc": "启用后,备注及剩余流量/天数将作为单独的 SOCKS 提示配置显示在顶部;过期或流量耗尽时仅返回状态提示。", + "subExpiredTemplate": "过期提示模板", + "subExpiredTemplateDesc": "用户订阅过期时提示配置的备注模板。", + "subTrafficDepletedTemplate": "流量耗尽模板", + "subTrafficDepletedTemplateDesc": "用户订阅流量用尽时提示配置的备注模板。" }, "xray": { "importRules": "导入规则", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 2763da1c1..f1eb582ca 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -1456,7 +1456,13 @@ "httpMethodDesc": "探活請求使用的 HTTP 方法。", "note": "leastPing/leastLoad 平衡器始終帶有 burstObservatory。此開關自訂其探活參數 — 關閉以使用內建預設值。變更在面板重啟後生效。" } - } + }, + "subInfoNodeEnable": "獨立資訊配置 / 提示節點", + "subInfoNodeEnableDesc": "啟用後,備註及剩餘流量/天數將作為單獨的 SOCKS 提示配置顯示在頂部;過期或流量耗盡時僅返回狀態提示。", + "subExpiredTemplate": "過期提示範本", + "subExpiredTemplateDesc": "使用者訂閱過期時提示配置的備註範本。", + "subTrafficDepletedTemplate": "流量耗盡範本", + "subTrafficDepletedTemplateDesc": "使用者訂閱流量用盡時提示配置的備註範本。" }, "xray": { "save": "儲存",