feat(sub): per-inbound sort order for subscription links

Add a subSortIndex field to inbounds that controls the order of links
in subscription output only: the raw sub body, the HTML sub page, and
the JSON/Clash formats (all served from the same query). Lower values
come first; ties keep id order. The panel inbound list is unaffected.

The value is editable in the inbound form next to the share-address
fields, propagates to nodes via wireInbound, and follows the usual
node-sync rules (copied on import, mirrored while not dirty, never a
structural change).

Rescoped from #5214 by @Ponywka.
This commit is contained in:
MHSanaei
2026-06-12 12:03:22 +02:00
parent 7ae3ea66d1
commit f1a4286e2f
36 changed files with 367 additions and 4 deletions
+8
View File
@@ -1345,6 +1345,12 @@
},
"sniffing": {},
"streamSettings": {},
"subSortIndex": {
"description": "1-based sort order of this inbound's links in subscription output only (lower first; ties by id)",
"example": 1,
"minimum": 1,
"type": "integer"
},
"tag": {
"example": "in-443-tcp",
"type": "string"
@@ -1385,6 +1391,7 @@
"shareAddrStrategy",
"sniffing",
"streamSettings",
"subSortIndex",
"tag",
"total",
"trafficReset",
@@ -2153,6 +2160,7 @@
"shareAddrStrategy": "node",
"sniffing": null,
"streamSettings": null,
"subSortIndex": 1,
"tag": "in-443-tcp",
"total": 0,
"trafficReset": "never",
+1
View File
@@ -292,6 +292,7 @@ export const EXAMPLES: Record<string, unknown> = {
"shareAddrStrategy": "node",
"sniffing": null,
"streamSettings": null,
"subSortIndex": 1,
"tag": "in-443-tcp",
"total": 0,
"trafficReset": "never",
+7
View File
@@ -1319,6 +1319,12 @@ 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)",
"example": 1,
"minimum": 1,
"type": "integer"
},
"tag": {
"example": "in-443-tcp",
"type": "string"
@@ -1359,6 +1365,7 @@ export const SCHEMAS: Record<string, unknown> = {
"shareAddrStrategy",
"sniffing",
"streamSettings",
"subSortIndex",
"tag",
"total",
"trafficReset",
+1
View File
@@ -293,6 +293,7 @@ export interface Inbound {
shareAddrStrategy: string;
sniffing: unknown;
streamSettings: unknown;
subSortIndex: number;
tag: string;
total: number;
trafficReset: string;
+1
View File
@@ -314,6 +314,7 @@ export const InboundSchema = z.object({
shareAddrStrategy: z.enum(['node', 'listen', 'custom']),
sniffing: z.unknown(),
streamSettings: z.unknown(),
subSortIndex: z.number().int().min(1),
tag: z.string(),
total: z.number().int(),
trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']),
@@ -39,6 +39,7 @@ export interface RawInboundRow {
nodeId?: number | null;
shareAddrStrategy?: string;
shareAddr?: string;
subSortIndex?: number;
clientStats?: unknown;
}
@@ -65,6 +66,7 @@ export interface WireInboundPayload {
nodeId?: number;
shareAddrStrategy: ShareAddrStrategy;
shareAddr: string;
subSortIndex: number;
}
function coerceJsonObject(value: unknown): Record<string, unknown> {
@@ -175,6 +177,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),
protocol,
settings,
} as InboundFormValues;
@@ -322,6 +325,7 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
tag: values.tag,
shareAddrStrategy: values.shareAddrStrategy,
shareAddr: values.shareAddr,
subSortIndex: values.subSortIndex,
};
if (values.nodeId != null) payload.nodeId = values.nodeId;
return payload;
+3
View File
@@ -42,6 +42,7 @@ export type DBInboundInit = Partial<{
nodeId: number | null;
shareAddrStrategy: string;
shareAddr: string;
subSortIndex: number;
originNodeGuid: string;
fallbackParent: FallbackParentRef | null;
}>;
@@ -88,6 +89,7 @@ export class DBInbound {
nodeId: number | null;
shareAddrStrategy: string;
shareAddr: string;
subSortIndex: number;
originNodeGuid: string;
fallbackParent: FallbackParentRef | null;
@@ -116,6 +118,7 @@ export class DBInbound {
this.nodeId = null;
this.shareAddrStrategy = "node";
this.shareAddr = "";
this.subSortIndex = 1;
this.originNodeGuid = "";
this.fallbackParent = null;
if (data == null) {
@@ -575,6 +575,14 @@ export default function InboundFormModal({
</Form.Item>
)}
<Form.Item
name="subSortIndex"
label={t('pages.inbounds.form.subSortIndex')}
extra={t('pages.inbounds.form.subSortIndexHelp')}
>
<InputNumber min={1} />
</Form.Item>
<Form.Item
name="port"
label={t('pages.inbounds.port')}
@@ -93,6 +93,11 @@ export default function InboundList({
[dbInbounds],
);
const hasAnySubSortIndex = useMemo(
() => dbInbounds.some((i) => (i.subSortIndex ?? 1) > 1),
[dbInbounds],
);
const toggleSelect = useCallback((id: number, checked: boolean) => {
setSelectedRowKeys((prev) => {
const next = new Set(prev);
@@ -115,6 +120,7 @@ export default function InboundList({
const columns = useInboundColumns({
hasAnyRemark,
hasAnySubSortIndex,
hasActiveNode,
nodesById,
clientCount,
@@ -22,6 +22,7 @@ export interface DBInboundRecord extends ProtocolFlags {
id: number;
enable: boolean;
remark: string;
subSortIndex: number;
port: number;
protocol: string;
up: number;
@@ -1,6 +1,6 @@
import { useMemo, type ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import { Popover, Switch, Tag, type TableColumnType } from 'antd';
import { Popover, Switch, Tag, Tooltip, type TableColumnType } from 'antd';
import { TeamOutlined } from '@ant-design/icons';
import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
@@ -21,6 +21,7 @@ import type { ClientCountEntry, DBInboundRecord, RowAction } from './types';
interface UseInboundColumnsParams {
hasAnyRemark: boolean;
hasAnySubSortIndex: boolean;
hasActiveNode: boolean;
nodesById: Map<number, NodeRecord>;
clientCount: Record<number, ClientCountEntry>;
@@ -33,6 +34,7 @@ interface UseInboundColumnsParams {
export function useInboundColumns({
hasAnyRemark,
hasAnySubSortIndex,
hasActiveNode,
nodesById,
clientCount,
@@ -113,6 +115,20 @@ export function useInboundColumns({
});
}
if (hasAnySubSortIndex) {
cols.push({
title: (
<Tooltip title={t('pages.inbounds.form.subSortIndex')}>
{t('pages.inbounds.subSortIndex')}
</Tooltip>
),
dataIndex: 'subSortIndex',
key: 'subSortIndex',
align: 'right',
width: 70,
});
}
cols.push(
{
title: t('pages.inbounds.port'),
@@ -267,5 +283,5 @@ export function useInboundColumns({
);
return cols;
}, [t, hasAnyRemark, hasActiveNode, nodesById, clientCount, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable]);
}, [t, hasAnyRemark, hasAnySubSortIndex, hasActiveNode, nodesById, clientCount, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable]);
}
@@ -39,6 +39,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),
});
export type InboundDbFields = z.infer<typeof InboundDbFieldsSchema>;
@@ -7,6 +7,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > http
"Protocol",
"Address",
"Share address strategy",
"Subscription sort order",
"Port",
"Total Flow",
"Traffic Reset",
@@ -22,6 +23,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > hyste
"Protocol",
"Address",
"Share address strategy",
"Subscription sort order",
"Port",
"Total Flow",
"Traffic Reset",
@@ -37,6 +39,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > mixed
"Protocol",
"Address",
"Share address strategy",
"Subscription sort order",
"Port",
"Total Flow",
"Traffic Reset",
@@ -52,6 +55,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > shado
"Protocol",
"Address",
"Share address strategy",
"Subscription sort order",
"Port",
"Total Flow",
"Traffic Reset",
@@ -67,6 +71,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > troja
"Protocol",
"Address",
"Share address strategy",
"Subscription sort order",
"Port",
"Total Flow",
"Traffic Reset",
@@ -82,6 +87,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > tun 1
"Protocol",
"Address",
"Share address strategy",
"Subscription sort order",
"Port",
"Total Flow",
"Traffic Reset",
@@ -97,6 +103,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > tunne
"Protocol",
"Address",
"Share address strategy",
"Subscription sort order",
"Port",
"Total Flow",
"Traffic Reset",
@@ -112,6 +119,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > vless
"Protocol",
"Address",
"Share address strategy",
"Subscription sort order",
"Port",
"Total Flow",
"Traffic Reset",
@@ -127,6 +135,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > vmess
"Protocol",
"Address",
"Share address strategy",
"Subscription sort order",
"Port",
"Total Flow",
"Traffic Reset",
@@ -142,6 +151,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > wireg
"Protocol",
"Address",
"Share address strategy",
"Subscription sort order",
"Port",
"Total Flow",
"Traffic Reset",
+33 -1
View File
@@ -6,7 +6,7 @@ import {
formValuesToWirePayload,
type RawInboundRow,
} from '@/lib/xray/inbound-form-adapter';
import { InboundFormSchema } from '@/schemas/forms/inbound-form';
import { InboundDbFieldsSchema, InboundFormSchema } from '@/schemas/forms/inbound-form';
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
// Round-trip: raw DB row → InboundFormValues → wire payload, asserting
@@ -262,3 +262,35 @@ describe('formValuesToWirePayload', () => {
expect(replay.streamSettings).toEqual(original.streamSettings);
});
});
describe('subSortIndex', () => {
it('rawInboundToFormValues defaults to 1 when field is absent', () => {
const values = rawInboundToFormValues({ ...vlessRow, subSortIndex: undefined });
expect(values.subSortIndex).toBe(1);
});
it('rawInboundToFormValues preserves valid values and clamps below-minimum ones 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);
});
it('formValuesToWirePayload includes subSortIndex in the payload', () => {
const values = rawInboundToFormValues({ ...vlessRow, subSortIndex: 3 });
const payload = formValuesToWirePayload(values);
expect(payload.subSortIndex).toBe(3);
});
it('subSortIndex round-trips through raw → values → payload', () => {
const values = rawInboundToFormValues({ ...vlessRow, subSortIndex: 42 });
const payload = formValuesToWirePayload(values);
const replay = rawInboundToFormValues({ ...vlessRow, subSortIndex: payload.subSortIndex });
expect(replay.subSortIndex).toBe(42);
});
it('InboundDbFieldsSchema enforces an integer minimum of 1 and defaults to 1', () => {
expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 1.5 }).success).toBe(false);
expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 0 }).success).toBe(false);
expect(InboundDbFieldsSchema.parse({}).subSortIndex).toBe(1);
});
});
+18
View File
@@ -91,6 +91,9 @@ func initModels() error {
if err := pruneOrphanedClientInbounds(); err != nil {
return err
}
if err := normalizeInboundSubSortIndex(); err != nil {
return err
}
if IsPostgres() {
if err := resyncPostgresSequences(db, models); err != nil {
log.Printf("Error resyncing postgres sequences: %v", err)
@@ -123,6 +126,21 @@ func pruneOrphanedClientInbounds() error {
return nil
}
// 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.
func normalizeInboundSubSortIndex() error {
res := db.Exec("UPDATE inbounds SET sub_sort_index = 1 WHERE sub_sort_index < 1")
if res.Error != nil {
log.Printf("Error normalizing inbound sub_sort_index: %v", res.Error)
return res.Error
}
if res.RowsAffected > 0 {
log.Printf("Normalized sub_sort_index on %d inbound(s)", res.RowsAffected)
}
return nil
}
func isIgnorableDuplicateColumnErr(err error, mdl any) bool {
if err == nil {
return false
+1
View File
@@ -50,6 +50,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)
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
+1 -1
View File
@@ -284,7 +284,7 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
WHERE
inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria')
AND clients.sub_id = ? AND inbounds.enable = ?
)`, subId, true).Find(&inbounds).Error
)`, subId, true).Order("sub_sort_index ASC").Order("id ASC").Find(&inbounds).Error
if err != nil {
return nil, err
}
+79
View File
@@ -0,0 +1,79 @@
package sub
import (
"fmt"
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// TestGetSubs_OrdersBySubSortIndexThenId verifies that subscription output
// lists inbound links ordered by sub_sort_index ASC, breaking ties by id ASC.
// The same query feeds the raw body, the HTML sub page, and the JSON/Clash
// formats, so asserting on GetSubs covers all of them.
func TestGetSubs_OrdersBySubSortIndexThenId(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
const subId = "sub-sort"
db := database.GetDB()
seed := []struct {
tag string
port int
subSortIndex int
email string
uuid string
}{
// Created in this order on purpose: without the ORDER BY the links
// would come out s3, s1, s2a, s2b (creation order).
{"sort-3", 42101, 3, "s3@example.com", "0d68a695-4be1-4d92-a9c3-8c0f1c2cf001"},
{"sort-1", 42102, 1, "s1@example.com", "0d68a695-4be1-4d92-a9c3-8c0f1c2cf002"},
{"sort-2a", 42103, 2, "s2a@example.com", "0d68a695-4be1-4d92-a9c3-8c0f1c2cf003"},
{"sort-2b", 42104, 2, "s2b@example.com", "0d68a695-4be1-4d92-a9c3-8c0f1c2cf004"},
}
for _, s := range seed {
settings := fmt.Sprintf(`{"clients": [{"id": %q, "email": %q, "subId": %q, "enable": true}]}`, s.uuid, s.email, subId)
ib := &model.Inbound{
UserId: 1,
Tag: s.tag,
Enable: true,
Port: s.port,
Protocol: model.VLESS,
Settings: settings,
StreamSettings: `{"network": "tcp", "security": "none"}`,
SubSortIndex: s.subSortIndex,
}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("seed inbound %s: %v", s.tag, err)
}
client := &model.ClientRecord{Email: s.email, SubID: subId, UUID: s.uuid, Enable: true}
if err := db.Create(client).Error; err != nil {
t.Fatalf("seed client %s: %v", s.email, err)
}
if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
t.Fatalf("seed client_inbound %s: %v", s.email, err)
}
}
s := NewSubService(false, "-ieo")
links, emails, _, _, err := s.GetSubs(subId, "sub.example.com")
if err != nil {
t.Fatalf("GetSubs: %v", err)
}
if len(links) != len(seed) {
t.Fatalf("links = %d, want %d", len(links), len(seed))
}
want := []string{"s1@example.com", "s2a@example.com", "s2b@example.com", "s3@example.com"}
for i, email := range want {
if emails[i] != email {
t.Fatalf("emails order = %v, want %v (sub_sort_index ASC, id ASC)", emails, want)
}
}
}
+1
View File
@@ -490,6 +490,7 @@ func wireInbound(ib *model.Inbound) url.Values {
v := url.Values{}
v.Set("total", strconv.FormatInt(ib.Total, 10))
v.Set("remark", ib.Remark)
v.Set("subSortIndex", strconv.Itoa(ib.SubSortIndex))
v.Set("enable", strconv.FormatBool(ib.Enable))
v.Set("expiryTime", strconv.FormatInt(ib.ExpiryTime, 10))
v.Set("listen", ib.Listen)
+3
View File
@@ -458,6 +458,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
// Normalize streamSettings based on protocol
s.normalizeStreamSettings(inbound)
s.normalizeMtprotoSecret(inbound)
inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex)
if err := normalizeInboundShareAddressStrict(inbound); err != nil {
return inbound, false, err
}
@@ -786,6 +787,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
// Normalize streamSettings based on protocol
s.normalizeStreamSettings(inbound)
s.normalizeMtprotoSecret(inbound)
inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex)
conflict, err := s.checkPortConflict(inbound, inbound.Id)
if err != nil {
@@ -888,6 +890,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
oldInbound.Total = inbound.Total
oldInbound.Remark = inbound.Remark
oldInbound.SubSortIndex = inbound.SubSortIndex
oldInbound.Enable = inbound.Enable
oldInbound.ExpiryTime = inbound.ExpiryTime
oldInbound.TrafficReset = inbound.TrafficReset
+2
View File
@@ -358,6 +358,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
LastTrafficResetTime: snapIb.LastTrafficResetTime,
Enable: snapIb.Enable,
Remark: snapIb.Remark,
SubSortIndex: normalizeSubSortIndex(snapIb.SubSortIndex),
Total: snapIb.Total,
ExpiryTime: snapIb.ExpiryTime,
Up: snapIb.Up,
@@ -382,6 +383,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
if !dirty {
updates["enable"] = snapIb.Enable
updates["remark"] = snapIb.Remark
updates["sub_sort_index"] = normalizeSubSortIndex(snapIb.SubSortIndex)
updates["listen"] = snapIb.Listen
updates["port"] = snapIb.Port
updates["protocol"] = snapIb.Protocol
@@ -0,0 +1,111 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func makeInboundWithSubSortIndex(tag string, port int, subSortIndex int) *model.Inbound {
return &model.Inbound{
UserId: 1,
Tag: tag,
Enable: true,
Listen: "0.0.0.0",
Port: port,
Protocol: model.VLESS,
StreamSettings: `{"network":"tcp"}`,
Settings: `{"clients":[]}`,
SubSortIndex: subSortIndex,
}
}
// TestUpdateInbound_PersistsSubSortIndex verifies that UpdateInbound copies
// SubSortIndex from the incoming update payload to the persisted row.
func TestUpdateInbound_PersistsSubSortIndex(t *testing.T) {
setupConflictDB(t)
ib := makeInboundWithSubSortIndex("in-7001-tcp", 7001, 1)
if err := database.GetDB().Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
update := *ib
update.SubSortIndex = 7
svc := &InboundService{}
got, _, err := svc.UpdateInbound(&update)
if err != nil {
t.Fatalf("UpdateInbound: %v", err)
}
if got.SubSortIndex != 7 {
t.Fatalf("returned SubSortIndex = %d, want 7", 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 != 7 {
t.Fatalf("persisted SubSortIndex = %d, want 7", reloaded.SubSortIndex)
}
}
// 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) {
setupConflictDB(t)
ib := makeInboundWithSubSortIndex("in-7002-tcp", 7002, 5)
if err := database.GetDB().Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
svc := &InboundService{}
for _, below := range []int{0, -3} {
update := *ib
update.SubSortIndex = below
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)
}
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)
}
}
}
// TestAddInbound_SubSortIndexClampedToMinimum verifies the same clamping on
// the create path (an omitted form field binds to 0).
func TestAddInbound_SubSortIndexClampedToMinimum(t *testing.T) {
setupConflictDB(t)
svc := &InboundService{}
ib := makeInboundWithSubSortIndex("in-7003-tcp", 7003, 0)
got, _, err := svc.AddInbound(ib)
if err != nil {
t.Fatalf("AddInbound: %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, got.Id).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.SubSortIndex != 1 {
t.Fatalf("persisted SubSortIndex = %d, want 1", reloaded.SubSortIndex)
}
}
+10
View File
@@ -7,6 +7,16 @@ package service
// 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.
func normalizeSubSortIndex(v int) int {
if v < 1 {
return 1
}
return v
}
// uniqueNonEmptyStrings returns a deduplicated copy of in with empty strings
// removed, preserving the order of first occurrence.
func uniqueNonEmptyStrings(in []string) []string {
+3
View File
@@ -416,6 +416,7 @@
},
"telegramDesc": "ادخل ID شات Telegram. (استخدم '/id' في البوت) أو ({'@'}userinfobot)",
"subscriptionDesc": "عشان تلاقي رابط الاشتراك، ادخل على 'التفاصيل'. وكمان ممكن تستخدم نفس الاسم لعدة عملاء.",
"subSortIndex": "ترتيب الاشتراك",
"same": "نفسه",
"inboundInfo": "معلومات الإدخال",
"exportInbound": "تصدير الإدخال",
@@ -595,6 +596,8 @@
"shareAddrStrategyHelp": "تحدد العنوان الذي يُكتب في روابط المشاركة المصدّرة ورموز QR ومخرجات الاشتراك.",
"shareAddr": "عنوان مشاركة مخصص",
"shareAddrHelp": "يُستخدم فقط عندما تكون استراتيجية عنوان المشاركة مخصصة. أدخل اسم مضيف أو عنوان IP بدون بروتوكول أو منفذ.",
"subSortIndex": "ترتيب الروابط في الاشتراك",
"subSortIndexHelp": "موضع روابط هذا الوارد في مخرجات الاشتراك (صفحة الاشتراك وتطبيقات العملاء). القيم الأقل تظهر أولاً، والقيم المتساوية تحافظ على ترتيب الإنشاء. لا يؤثر على قائمة الواردات في اللوحة.",
"shareAddrStrategyOptions": {
"node": "عنوان العقدة",
"listen": "عنوان استماع الوارد",
+3
View File
@@ -416,6 +416,7 @@
},
"telegramDesc": "Please provide Telegram Chat ID. (use '/id' command in the bot) or ({'@'}userinfobot)",
"subscriptionDesc": "To find your subscription URL, navigate to the 'Details'. Additionally, you can use the same name for several clients.",
"subSortIndex": "Sub order",
"same": "Same",
"inboundInfo": "Inbound Information",
"exportInbound": "Export Inbound",
@@ -596,6 +597,8 @@
"shareAddrStrategyHelp": "Controls which address is written into exported share links, QR codes, and subscription output.",
"shareAddr": "Custom share address",
"shareAddrHelp": "Used only when the share address strategy is Custom. Enter a host or IP without a scheme or port.",
"subSortIndex": "Subscription sort order",
"subSortIndexHelp": "Position of this inbound's links in subscription output (sub page and client apps). Lower values come first; equal values keep creation order. Does not affect the panel inbound list.",
"shareAddrStrategyOptions": {
"node": "Node address",
"listen": "Inbound listen",
+3
View File
@@ -416,6 +416,7 @@
},
"telegramDesc": "Por favor, proporciona el ID de Chat de Telegram. (usa el comando '/id' en el bot) o ({'@'}userinfobot)",
"subscriptionDesc": "Puedes encontrar tu enlace de suscripción en Detalles, también puedes usar el mismo nombre para varias configuraciones.",
"subSortIndex": "Orden sub",
"same": "misma",
"inboundInfo": "Información de entrada",
"exportInbound": "Exportación entrante",
@@ -595,6 +596,8 @@
"shareAddrStrategyHelp": "Controla qué dirección se escribe en los enlaces compartidos exportados, códigos QR y la salida de suscripción.",
"shareAddr": "Dirección compartida personalizada",
"shareAddrHelp": "Solo se usa cuando la estrategia de dirección para compartir es Personalizada. Introduce un host o IP sin esquema ni puerto.",
"subSortIndex": "Orden en la suscripción",
"subSortIndexHelp": "Posición de los enlaces de esta entrada en la salida de la suscripción (página de suscripción y apps cliente). Los valores más bajos van primero; con valores iguales se mantiene el orden de creación. No afecta a la lista de entradas del panel.",
"shareAddrStrategyOptions": {
"node": "Dirección del nodo",
"listen": "Dirección de escucha del inbound",
+3
View File
@@ -416,6 +416,7 @@
},
"telegramDesc": "لطفا شناسه گفتگوی تلگرام را وارد کنید. (از دستور '/id' در ربات استفاده کنید) یا ({'@'}userinfobot)",
"subscriptionDesc": "شما می‌توانید لینک سابسکربپشن خودرا در 'جزئیات' پیدا کنید، همچنین می‌توانید از همین نام برای چندین کاربر استفاده‌کنید",
"subSortIndex": "ترتیب اشتراک",
"same": "همسان",
"inboundInfo": "اطلاعات ورودی",
"exportInbound": "استخراج ورودی",
@@ -595,6 +596,8 @@
"shareAddrStrategyHelp": "مشخص می‌کند کدام آدرس در لینک‌های اشتراک‌گذاری خروجی، کدهای QR و خروجی اشتراک نوشته شود.",
"shareAddr": "آدرس اشتراک‌گذاری سفارشی",
"shareAddrHelp": "فقط زمانی استفاده می‌شود که راهبرد آدرس اشتراک‌گذاری روی سفارشی باشد. میزبان یا IP را بدون طرح و پورت وارد کنید.",
"subSortIndex": "ترتیب در اشتراک",
"subSortIndexHelp": "جایگاه لینک‌های این ورودی در خروجی اشتراک (صفحه اشتراک و برنامه‌های کلاینت). مقدار کمتر اول می‌آید و مقدارهای برابر ترتیب ایجاد را حفظ می‌کنند. روی فهرست ورودی‌های پنل تأثیری ندارد.",
"shareAddrStrategyOptions": {
"node": "آدرس نود",
"listen": "آدرس شنود ورودی",
+3
View File
@@ -416,6 +416,7 @@
},
"telegramDesc": "Harap berikan ID Obrolan Telegram. (gunakan perintah '/id' di bot) atau ({'@'}userinfobot)",
"subscriptionDesc": "Untuk menemukan URL langganan Anda, buka 'Rincian'. Selain itu, Anda dapat menggunakan nama yang sama untuk beberapa klien.",
"subSortIndex": "Urutan sub",
"same": "Sama",
"inboundInfo": "Informasi Inbound",
"exportInbound": "Ekspor Masuk",
@@ -595,6 +596,8 @@
"shareAddrStrategyHelp": "Menentukan alamat yang ditulis ke tautan berbagi yang diekspor, kode QR, dan keluaran langganan.",
"shareAddr": "Alamat berbagi kustom",
"shareAddrHelp": "Hanya digunakan saat strategi alamat berbagi adalah Kustom. Masukkan host atau IP tanpa skema atau port.",
"subSortIndex": "Urutan dalam langganan",
"subSortIndexHelp": "Posisi tautan inbound ini dalam keluaran langganan (halaman langganan dan aplikasi klien). Nilai lebih kecil tampil lebih dulu; nilai sama mempertahankan urutan pembuatan. Tidak memengaruhi daftar inbound di panel.",
"shareAddrStrategyOptions": {
"node": "Alamat node",
"listen": "Alamat listen inbound",
+3
View File
@@ -416,6 +416,7 @@
},
"telegramDesc": "TelegramチャットIDを提供してください。(ボットで'/id'コマンドを使用)または({'@'}userinfobot",
"subscriptionDesc": "サブスクリプションURLを見つけるには、“詳細情報”に移動してください。また、複数のクライアントに同じ名前を使用することができます。",
"subSortIndex": "サブ並び順",
"same": "同じ",
"inboundInfo": "インバウンド情報",
"exportInbound": "インバウンドルールをエクスポート",
@@ -595,6 +596,8 @@
"shareAddrStrategyHelp": "エクスポートされる共有リンク、QRコード、サブスクリプション出力に書き込むアドレスを制御します。",
"shareAddr": "カスタム共有アドレス",
"shareAddrHelp": "共有アドレス戦略がカスタムの場合のみ使用されます。スキームやポートを含めずにホスト名またはIPを入力してください。",
"subSortIndex": "サブスクリプションでの並び順",
"subSortIndexHelp": "サブスクリプション出力(サブスクリプションページおよびクライアントアプリ)におけるこのインバウンドのリンクの位置。値が小さいほど先頭に表示され、同じ値の場合は作成順が維持されます。パネルのインバウンド一覧には影響しません。",
"shareAddrStrategyOptions": {
"node": "ノードアドレス",
"listen": "インバウンドのリッスンアドレス",
+3
View File
@@ -416,6 +416,7 @@
},
"telegramDesc": "Por favor, forneça o ID do Chat do Telegram. (use o comando '/id' no bot) ou ({'@'}userinfobot)",
"subscriptionDesc": "Para encontrar seu URL de assinatura, navegue até 'Detalhes'. Além disso, você pode usar o mesmo nome para vários clientes.",
"subSortIndex": "Ordem sub",
"same": "Igual",
"inboundInfo": "Informações do Inbound",
"exportInbound": "Exportar Inbound",
@@ -595,6 +596,8 @@
"shareAddrStrategyHelp": "Controla qual endereço é gravado nos links de compartilhamento exportados, códigos QR e na saída de assinatura.",
"shareAddr": "Endereço de compartilhamento personalizado",
"shareAddrHelp": "Usado apenas quando a estratégia de endereço de compartilhamento é Personalizada. Informe um host ou IP sem esquema nem porta.",
"subSortIndex": "Ordem na assinatura",
"subSortIndexHelp": "Posição dos links desta entrada na saída da assinatura (página de assinatura e aplicativos cliente). Valores menores vêm primeiro; valores iguais mantêm a ordem de criação. Não afeta a lista de entradas do painel.",
"shareAddrStrategyOptions": {
"node": "Endereço do nó",
"listen": "Endereço de escuta do inbound",
+3
View File
@@ -416,6 +416,7 @@
},
"telegramDesc": "Пожалуйста, укажите Chat ID Telegram. (используйте команду '/id' в боте) или ({'@'}userinfobot)",
"subscriptionDesc": "Вы можете найти свою ссылку подписки в разделе 'Подробнее'",
"subSortIndex": "Порядок",
"same": "Тот же",
"inboundInfo": "Информация о подключении",
"exportInbound": "Экспорт подключений",
@@ -595,6 +596,8 @@
"shareAddrStrategyHelp": "Определяет, какой адрес записывать в экспортируемые ссылки, QR-коды и выдачу подписки.",
"shareAddr": "Пользовательский адрес для ссылок",
"shareAddrHelp": "Используется только когда стратегия адреса для ссылок — пользовательская. Укажите хост или IP без схемы и порта.",
"subSortIndex": "Порядок в подписке",
"subSortIndexHelp": "Позиция ссылок этого входящего в выдаче подписки (страница подписки и клиентские приложения). Меньшие значения идут первыми; при равных значениях сохраняется порядок создания. Не влияет на список входящих в панели.",
"shareAddrStrategyOptions": {
"node": "Адрес узла",
"listen": "Адрес прослушивания inbound",
+3
View File
@@ -416,6 +416,7 @@
},
"telegramDesc": "Lütfen Telegram Sohbet Kimliği (Chat ID) sağlayın. ({'@'}userinfobot'tan öğrenebilir veya botta '/id' komutunu kullanabilirsiniz.)",
"subscriptionDesc": "Abonelik URL'nizi bulmak için 'Detaylar'a gidin. Aynı adı birden fazla kullanıcı için kullanabilirsiniz.",
"subSortIndex": "Sıralama",
"same": "Aynı",
"inboundInfo": "Gelen Bağlantı Bilgileri",
"exportInbound": "Gelen Bağlantını Dışa Aktar",
@@ -596,6 +597,8 @@
"shareAddrStrategyHelp": "Dışa aktarılan paylaşım bağlantılarına, QR kodlarına ve abonelik çıktısına hangi adresin yazılacağını belirler.",
"shareAddr": "Özel paylaşım adresi",
"shareAddrHelp": "Yalnızca paylaşım adresi stratejisi Özel olduğunda kullanılır. Şema veya port olmadan bir ana makine ya da IP girin.",
"subSortIndex": "Abonelikte sıralama",
"subSortIndexHelp": "Bu gelen bağlantının linklerinin abonelik çıktısındaki (abonelik sayfası ve istemci uygulamaları) konumu. Küçük değerler önce gelir; eşit değerlerde oluşturulma sırası korunur. Paneldeki gelen bağlantı listesini etkilemez.",
"shareAddrStrategyOptions": {
"node": "Düğüm adresi",
"listen": "Inbound dinleme adresi",
+3
View File
@@ -416,6 +416,7 @@
},
"telegramDesc": "Будь ласка, вкажіть ID чату Telegram. (використовуйте команду '/id' у боті) або ({'@'}userinfobot)",
"subscriptionDesc": "Щоб знайти URL-адресу вашої підписки, перейдіть до «Деталі». Крім того, ви можете використовувати одне ім'я для кількох клієнтів.",
"subSortIndex": "Порядок",
"same": "Те саме",
"inboundInfo": "Інформація про підключення",
"exportInbound": "Експортувати вхідні",
@@ -595,6 +596,8 @@
"shareAddrStrategyHelp": "Визначає, яку адресу записувати в експортовані посилання поширення, QR-коди та вивід підписки.",
"shareAddr": "Користувацька адреса поширення",
"shareAddrHelp": "Використовується лише коли стратегія адреси поширення — користувацька. Введіть хост або IP без схеми та порту.",
"subSortIndex": "Порядок у підписці",
"subSortIndexHelp": "Позиція посилань цього вхідного у виводі підписки (сторінка підписки та клієнтські застосунки). Менші значення йдуть першими; за однакових значень зберігається порядок створення. Не впливає на список вхідних у панелі.",
"shareAddrStrategyOptions": {
"node": "Адреса вузла",
"listen": "Адреса прослуховування inbound",
+3
View File
@@ -416,6 +416,7 @@
},
"telegramDesc": "Vui lòng cung cấp ID Trò chuyện Telegram. (sử dụng lệnh '/id' trong bot) hoặc ({'@'}userinfobot)",
"subscriptionDesc": "Bạn có thể tìm liên kết gói đăng ký của mình trong Chi tiết, cũng như bạn có thể sử dụng cùng tên cho nhiều cấu hình khác nhau",
"subSortIndex": "Thứ tự sub",
"same": "Giống nhau",
"inboundInfo": "Thông tin Inbound",
"exportInbound": "Xuất nhập khẩu",
@@ -595,6 +596,8 @@
"shareAddrStrategyHelp": "Kiểm soát địa chỉ được ghi vào liên kết chia sẻ đã xuất, mã QR và nội dung đăng ký.",
"shareAddr": "Địa chỉ chia sẻ tùy chỉnh",
"shareAddrHelp": "Chỉ dùng khi chiến lược địa chỉ chia sẻ là Tùy chỉnh. Nhập host hoặc IP không kèm giao thức hoặc cổng.",
"subSortIndex": "Thứ tự trong gói đăng ký",
"subSortIndexHelp": "Vị trí liên kết của inbound này trong nội dung gói đăng ký (trang đăng ký và ứng dụng khách). Giá trị nhỏ hơn xếp trước; giá trị bằng nhau giữ thứ tự tạo. Không ảnh hưởng đến danh sách inbound trong bảng điều khiển.",
"shareAddrStrategyOptions": {
"node": "Địa chỉ node",
"listen": "Địa chỉ listen inbound",
+3
View File
@@ -416,6 +416,7 @@
},
"telegramDesc": "请提供Telegram聊天ID。(在机器人中使用'/id'命令)或({'@'}userinfobot",
"subscriptionDesc": "要找到你的订阅 URL,请导航到“详细信息”。此外,你可以为多个客户端使用相同的名称。",
"subSortIndex": "订阅排序",
"same": "相同",
"inboundInfo": "入站信息",
"exportInbound": "导出入站规则",
@@ -595,6 +596,8 @@
"shareAddrStrategyHelp": "控制导出分享链接、二维码和订阅输出时写入哪个地址。",
"shareAddr": "自定义分享地址",
"shareAddrHelp": "仅在分享地址策略为自定义时使用。填写不带协议和端口的域名或 IP。",
"subSortIndex": "订阅排序",
"subSortIndexHelp": "此入站的链接在订阅输出(订阅页面和客户端应用)中的位置。数值越小越靠前;数值相同时保持创建顺序。不影响面板中的入站列表。",
"shareAddrStrategyOptions": {
"node": "节点地址",
"listen": "入站监听地址",
+3
View File
@@ -416,6 +416,7 @@
},
"telegramDesc": "請提供Telegram聊天ID。(在機器人中使用'/id'命令)或({'@'}userinfobot",
"subscriptionDesc": "要找到你的訂閱 URL,請導航到“詳細資訊”。此外,你可以為多個客戶端使用相同的名稱。",
"subSortIndex": "訂閱排序",
"same": "相同",
"inboundInfo": "入站資訊",
"exportInbound": "匯出入站規則",
@@ -595,6 +596,8 @@
"shareAddrStrategyHelp": "控制匯出分享連結、QR Code 和訂閱輸出時寫入哪個地址。",
"shareAddr": "自訂分享地址",
"shareAddrHelp": "僅在分享地址策略為自訂時使用。填寫不帶協定和連接埠的網域或 IP。",
"subSortIndex": "訂閱排序",
"subSortIndexHelp": "此入站的連結在訂閱輸出(訂閱頁面和客戶端應用)中的位置。數值越小越靠前;數值相同時保持建立順序。不影響面板中的入站清單。",
"shareAddrStrategyOptions": {
"node": "節點地址",
"listen": "入站監聽地址",