mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 15:17:14 +00:00
fix(inbounds): allow negative subSortIndex for subscription order (#6465)
* 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>
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -2604,9 +2604,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"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": {
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -697,7 +697,7 @@ export default function InboundFormModal({
|
||||
t('pages.inbounds.form.subSortIndexHelp'),
|
||||
)}
|
||||
>
|
||||
<InputNumber min={1} />
|
||||
<InputNumber />
|
||||
</FormField>
|
||||
|
||||
{protocol === Protocols.VLESS && (
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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<typeof InboundDbFieldsSchema>;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user