From 51e0afdd908df7325c118f06c057f629317be48d Mon Sep 17 00:00:00 2001 From: mrchatam Date: Sat, 12 Sep 2026 12:44:53 +0330 Subject: [PATCH] fix(inbounds): allow negative subSortIndex for subscription order (#6465) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(inbounds): allow negative subSortIndex for subscription order Preserve explicitly set negative indices so primary inbounds can sort ahead of the default without renumbering peers; keep 0/omitted → 1. * fix(inbounds): gofumpt model.go and trim subSortIndex comments --------- Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com> --- docs/public/openapi.json | 3 +- frontend/public/openapi.json | 3 +- frontend/src/generated/schemas.ts | 3 +- frontend/src/generated/zod.ts | 2 +- frontend/src/lib/xray/inbound-form-adapter.ts | 2 +- .../pages/inbounds/form/InboundFormModal.tsx | 2 +- .../src/pages/inbounds/list/InboundList.tsx | 2 +- frontend/src/schemas/forms/inbound-form.ts | 2 +- .../src/test/inbound-form-adapter.test.ts | 13 ++-- internal/database/db.go | 7 +- internal/database/model/model.go | 2 +- internal/web/controller/inbound.go | 2 +- internal/web/service/inbound_sub_sort_test.go | 70 +++++++++++++------ internal/web/service/inbound_subsort_test.go | 24 +++++++ internal/web/service/inbound_util.go | 7 +- internal/web/service/inbound_util_test.go | 20 ++++++ 16 files changed, 114 insertions(+), 50 deletions(-) create mode 100644 internal/web/service/inbound_util_test.go diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 1295a6c3b..e10d71550 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -2630,9 +2630,8 @@ "sniffing": {}, "streamSettings": {}, "subSortIndex": { - "description": "1-based sort order of this inbound's links in subscription output only (lower first; ties by id)", + "description": "Sort order of this inbound's links in subscription output only (lower first; negatives allowed; 0/omitted → 1; ties by id)", "example": 1, - "minimum": 1, "type": "integer" }, "tag": { diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 1295a6c3b..e10d71550 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -2630,9 +2630,8 @@ "sniffing": {}, "streamSettings": {}, "subSortIndex": { - "description": "1-based sort order of this inbound's links in subscription output only (lower first; ties by id)", + "description": "Sort order of this inbound's links in subscription output only (lower first; negatives allowed; 0/omitted → 1; ties by id)", "example": 1, - "minimum": 1, "type": "integer" }, "tag": { diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index e8248d938..daae0e8f4 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -2604,9 +2604,8 @@ export const SCHEMAS: Record = { "sniffing": {}, "streamSettings": {}, "subSortIndex": { - "description": "1-based sort order of this inbound's links in subscription output only (lower first; ties by id)", + "description": "Sort order of this inbound's links in subscription output only (lower first; negatives allowed; 0/omitted → 1; ties by id)", "example": 1, - "minimum": 1, "type": "integer" }, "tag": { diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 46fdd7620..634aa319d 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -638,7 +638,7 @@ export const InboundSchema = z.object({ shareAddrStrategy: z.enum(['node', 'listen', 'custom']), sniffing: z.unknown(), streamSettings: z.unknown(), - subSortIndex: z.number().int().min(1), + subSortIndex: z.number().int(), tag: z.string(), total: z.number().int(), trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']), diff --git a/frontend/src/lib/xray/inbound-form-adapter.ts b/frontend/src/lib/xray/inbound-form-adapter.ts index aaefb0742..59605c70e 100644 --- a/frontend/src/lib/xray/inbound-form-adapter.ts +++ b/frontend/src/lib/xray/inbound-form-adapter.ts @@ -210,7 +210,7 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues { nodeId: row.nodeId ?? null, shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy), shareAddr: row.shareAddr ?? '', - subSortIndex: Math.max(1, row.subSortIndex ?? 1), + subSortIndex: row.subSortIndex == null || row.subSortIndex === 0 ? 1 : row.subSortIndex, disableFlow: row.disableFlow ?? false, protocol, settings, diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx index 141e431bd..d690cac7a 100644 --- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/form/InboundFormModal.tsx @@ -697,7 +697,7 @@ export default function InboundFormModal({ t('pages.inbounds.form.subSortIndexHelp'), )} > - + {protocol === Protocols.VLESS && ( diff --git a/frontend/src/pages/inbounds/list/InboundList.tsx b/frontend/src/pages/inbounds/list/InboundList.tsx index f9468ce52..1b3931585 100644 --- a/frontend/src/pages/inbounds/list/InboundList.tsx +++ b/frontend/src/pages/inbounds/list/InboundList.tsx @@ -119,7 +119,7 @@ export default function InboundList({ ); const hasAnySubSortIndex = useMemo( - () => dbInbounds.some((i) => (i.subSortIndex ?? 1) > 1), + () => dbInbounds.some((i) => (i.subSortIndex ?? 1) !== 1), [dbInbounds], ); diff --git a/frontend/src/schemas/forms/inbound-form.ts b/frontend/src/schemas/forms/inbound-form.ts index 4a886dc91..6740c6d08 100644 --- a/frontend/src/schemas/forms/inbound-form.ts +++ b/frontend/src/schemas/forms/inbound-form.ts @@ -80,7 +80,7 @@ export const InboundDbFieldsSchema = z.object({ nodeId: z.number().int().nullable().optional(), shareAddrStrategy: ShareAddrStrategySchema.default('node'), shareAddr: z.string().default(''), - subSortIndex: z.number().int().min(1).default(1), + subSortIndex: z.number().int().default(1), disableFlow: z.boolean().default(false), }); export type InboundDbFields = z.infer; diff --git a/frontend/src/test/inbound-form-adapter.test.ts b/frontend/src/test/inbound-form-adapter.test.ts index 26526b66e..04cca2d12 100644 --- a/frontend/src/test/inbound-form-adapter.test.ts +++ b/frontend/src/test/inbound-form-adapter.test.ts @@ -329,10 +329,10 @@ describe('subSortIndex', () => { expect(values.subSortIndex).toBe(1); }); - it('rawInboundToFormValues preserves valid values and clamps below-minimum ones to 1', () => { + it('rawInboundToFormValues preserves positives and negatives; maps 0/absent to 1', () => { expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: 5 }).subSortIndex).toBe(5); expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: 0 }).subSortIndex).toBe(1); - expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: -10 }).subSortIndex).toBe(1); + expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: -10 }).subSortIndex).toBe(-10); }); it('formValuesToWirePayload includes subSortIndex in the payload', () => { @@ -348,18 +348,15 @@ describe('subSortIndex', () => { expect(replay.subSortIndex).toBe(42); }); - it('InboundDbFieldsSchema enforces an integer minimum of 1 and defaults to 1', () => { + it('InboundDbFieldsSchema accepts integers including negatives and defaults to 1', () => { // Reject for the RIGHT reason: the issue must be about subSortIndex, not some // unrelated field — otherwise a schema that rejects everything would pass. const nonInt = InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 1.5 }); expect(nonInt.success).toBe(false); if (!nonInt.success) expect(nonInt.error.issues[0]?.path).toContain('subSortIndex'); - const belowMin = InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 0 }); - expect(belowMin.success).toBe(false); - if (!belowMin.success) expect(belowMin.error.issues[0]?.path).toContain('subSortIndex'); - - // A valid integer >= 1 must pass (guards against a mutant rejecting all values). + expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 0 }).success).toBe(true); + expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: -1 }).success).toBe(true); expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 5 }).success).toBe(true); expect(InboundDbFieldsSchema.parse({}).subSortIndex).toBe(1); }); diff --git a/internal/database/db.go b/internal/database/db.go index d4e0c2647..f71865e30 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -1031,11 +1031,10 @@ func migrateTgIDIndex() error { return db.Migrator().CreateIndex(&model.ClientRecord{}, "TgID") } -// normalizeInboundSubSortIndex lifts sub_sort_index values below the 1-based -// minimum (rows written by builds that defaulted the column to 0, or by nodes -// predating the field) so they cannot sort ahead of explicitly ranked inbounds. +// normalizeInboundSubSortIndex lifts legacy zero defaults to 1. +// Explicit negatives are left alone so primary inbounds can sort first. func normalizeInboundSubSortIndex() error { - res := db.Exec("UPDATE inbounds SET sub_sort_index = 1 WHERE sub_sort_index < 1") + res := db.Exec("UPDATE inbounds SET sub_sort_index = 1 WHERE sub_sort_index = 0") if res.Error != nil { log.Printf("Error normalizing inbound sub_sort_index: %v", res.Error) return res.Error diff --git a/internal/database/model/model.go b/internal/database/model/model.go index e002d75e3..25cc620dc 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -52,7 +52,7 @@ type Inbound struct { Down int64 `json:"down" form:"down"` // Download traffic in bytes Total int64 `json:"total" form:"total"` // Total traffic limit in bytes Remark string `json:"remark" form:"remark" example:"VLESS-443"` // Human-readable remark - SubSortIndex int `json:"subSortIndex" form:"subSortIndex" gorm:"default:1" validate:"omitempty,gte=1" example:"1"` // 1-based sort order of this inbound's links in subscription output only (lower first; ties by id) + SubSortIndex int `json:"subSortIndex" form:"subSortIndex" gorm:"default:1" validate:"omitempty" example:"1"` // Sort order of this inbound's links in subscription output only (lower first; negatives allowed; 0/omitted → 1; ties by id) Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1" example:"true"` // Whether the inbound is enabled ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule diff --git a/internal/web/controller/inbound.go b/internal/web/controller/inbound.go index 956a18aaa..313aa391c 100644 --- a/internal/web/controller/inbound.go +++ b/internal/web/controller/inbound.go @@ -274,7 +274,7 @@ func (a *InboundController) setInboundSubSortIndex(c *gin.Context) { return } type form struct { - SubSortIndex int `json:"subSortIndex" form:"subSortIndex" binding:"required,min=1"` + SubSortIndex int `json:"subSortIndex" form:"subSortIndex" binding:"required"` } var f form if err := c.ShouldBind(&f); err != nil { diff --git a/internal/web/service/inbound_sub_sort_test.go b/internal/web/service/inbound_sub_sort_test.go index c52d084b0..15bcb4d05 100644 --- a/internal/web/service/inbound_sub_sort_test.go +++ b/internal/web/service/inbound_sub_sort_test.go @@ -52,10 +52,9 @@ func TestUpdateInbound_PersistsSubSortIndex(t *testing.T) { } } -// TestUpdateInbound_SubSortIndexClampedToMinimum verifies that values below -// the 1-based minimum (0 from clients that predate the field, or negatives) -// are clamped to 1 instead of being stored. -func TestUpdateInbound_SubSortIndexClampedToMinimum(t *testing.T) { +// TestUpdateInbound_SubSortIndexZeroMapsToDefault verifies that 0 from clients +// that predate the field is normalized to 1 instead of being stored. +func TestUpdateInbound_SubSortIndexZeroMapsToDefault(t *testing.T) { setupConflictDB(t) ib := makeInboundWithSubSortIndex("in-7002-tcp", 7002, 5) @@ -64,25 +63,54 @@ func TestUpdateInbound_SubSortIndexClampedToMinimum(t *testing.T) { } svc := &InboundService{} - for _, below := range []int{0, -3} { - update := *ib - update.SubSortIndex = below + update := *ib + update.SubSortIndex = 0 - got, _, err := svc.UpdateInbound(&update) - if err != nil { - t.Fatalf("UpdateInbound(%d): %v", below, err) - } - if got.SubSortIndex != 1 { - t.Fatalf("returned SubSortIndex = %d for input %d, want 1", got.SubSortIndex, below) - } + got, _, err := svc.UpdateInbound(&update) + if err != nil { + t.Fatalf("UpdateInbound: %v", err) + } + if got.SubSortIndex != 1 { + t.Fatalf("returned SubSortIndex = %d, want 1", got.SubSortIndex) + } - var reloaded model.Inbound - if err := database.GetDB().First(&reloaded, ib.Id).Error; err != nil { - t.Fatalf("reload: %v", err) - } - if reloaded.SubSortIndex != 1 { - t.Fatalf("persisted SubSortIndex = %d for input %d, want 1", reloaded.SubSortIndex, below) - } + var reloaded model.Inbound + if err := database.GetDB().First(&reloaded, ib.Id).Error; err != nil { + t.Fatalf("reload: %v", err) + } + if reloaded.SubSortIndex != 1 { + t.Fatalf("persisted SubSortIndex = %d, want 1", reloaded.SubSortIndex) + } +} + +// TestUpdateInbound_PreservesNegativeSubSortIndex verifies that an explicitly +// set negative index is stored (so it can sort ahead of the default of 1). +func TestUpdateInbound_PreservesNegativeSubSortIndex(t *testing.T) { + setupConflictDB(t) + + ib := makeInboundWithSubSortIndex("in-7004-tcp", 7004, 5) + if err := database.GetDB().Create(ib).Error; err != nil { + t.Fatalf("create inbound: %v", err) + } + + svc := &InboundService{} + update := *ib + update.SubSortIndex = -1 + + got, _, err := svc.UpdateInbound(&update) + if err != nil { + t.Fatalf("UpdateInbound: %v", err) + } + if got.SubSortIndex != -1 { + t.Fatalf("returned SubSortIndex = %d, want -1", got.SubSortIndex) + } + + var reloaded model.Inbound + if err := database.GetDB().First(&reloaded, ib.Id).Error; err != nil { + t.Fatalf("reload: %v", err) + } + if reloaded.SubSortIndex != -1 { + t.Fatalf("persisted SubSortIndex = %d, want -1", reloaded.SubSortIndex) } } diff --git a/internal/web/service/inbound_subsort_test.go b/internal/web/service/inbound_subsort_test.go index 0bee27193..6adbff822 100644 --- a/internal/web/service/inbound_subsort_test.go +++ b/internal/web/service/inbound_subsort_test.go @@ -55,3 +55,27 @@ func TestSetInboundSubSortIndexUsesNarrowNodeUpdate(t *testing.T) { t.Fatalf("full snapshot node updates = %d, want 0", got) } } + +func TestSetInboundSubSortIndexPreservesNegative(t *testing.T) { + if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil { + t.Fatalf("init db: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) + + ib := &model.Inbound{UserId: 1, Remark: "r", Port: 21003, Protocol: model.VLESS, Settings: `{"clients":[]}`, SubSortIndex: 1, Enable: true} + if err := database.GetDB().Create(ib).Error; err != nil { + t.Fatalf("seed: %v", err) + } + + if err := (&InboundService{}).SetInboundSubSortIndex(ib.Id, -2); err != nil { + t.Fatalf("set: %v", err) + } + + var got model.Inbound + if err := database.GetDB().First(&got, ib.Id).Error; err != nil { + t.Fatalf("reload: %v", err) + } + if got.SubSortIndex != -2 { + t.Fatalf("subSortIndex = %d, want -2", got.SubSortIndex) + } +} diff --git a/internal/web/service/inbound_util.go b/internal/web/service/inbound_util.go index 641e23f06..d3ac8098a 100644 --- a/internal/web/service/inbound_util.go +++ b/internal/web/service/inbound_util.go @@ -9,11 +9,10 @@ import "strings" // installs (>32k clients) where even modern SQLite would refuse a single IN. const sqliteMaxVars = 900 -// normalizeSubSortIndex clamps the 1-based subscription sort order. Values -// below 1 arrive from clients that predate the field (omitted form key binds -// to 0) and must not sort ahead of explicitly ranked inbounds. +// normalizeSubSortIndex maps omitted/zero to 1; explicit negatives are kept +// so a primary inbound can sort ahead of the default. func normalizeSubSortIndex(v int) int { - if v < 1 { + if v == 0 { return 1 } return v diff --git a/internal/web/service/inbound_util_test.go b/internal/web/service/inbound_util_test.go new file mode 100644 index 000000000..c4a5f46ba --- /dev/null +++ b/internal/web/service/inbound_util_test.go @@ -0,0 +1,20 @@ +package service + +import "testing" + +func TestNormalizeSubSortIndex(t *testing.T) { + cases := []struct { + in, want int + }{ + {0, 1}, + {1, 1}, + {7, 7}, + {-1, -1}, + {-10, -10}, + } + for _, tc := range cases { + if got := normalizeSubSortIndex(tc.in); got != tc.want { + t.Fatalf("normalizeSubSortIndex(%d) = %d, want %d", tc.in, got, tc.want) + } + } +}