mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 23:35:00 +03:00
feat(hosts): add drag-and-drop and global subscription ordering (#6895)
Hosts already had one saved order on the Hosts page, but subscriptions honoured it only inside each inbound, so a host of a later inbound could never come before one of an earlier inbound (#6894). A new opt-in setting makes raw/base64, Clash/Mihomo and JSON subscriptions follow the Hosts page order across inbounds; the default keeps today's inbound-first output unchanged. The Hosts table gains drag-and-drop reordering next to the Up/Down buttons, on a pointer-reorder hook the Routing Rules table now shares.
This commit is contained in:
@@ -56,6 +56,7 @@ func (s *SubClashService) getClash(subId string, host string, legacy bool) (stri
|
||||
}
|
||||
|
||||
var proxies []map[string]any
|
||||
var ordered []orderedSubEntry[func() []map[string]any]
|
||||
var hasInactiveExternal bool
|
||||
var hasEnabledClient bool
|
||||
|
||||
@@ -72,15 +73,29 @@ func (s *SubClashService) getClash(subId string, host string, legacy bool) (stri
|
||||
continue
|
||||
}
|
||||
subReq.projectThroughFallbackMaster(inbound)
|
||||
if hostEps := subReq.hostEndpoints(inbound, "clash"); len(hostEps) > 0 {
|
||||
injectExternalProxy(inbound, hostEps)
|
||||
if !subReq.hostsFirst {
|
||||
if hostEps := subReq.hostEndpoints(inbound, "clash"); len(hostEps) > 0 {
|
||||
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)...)
|
||||
if subReq.hostsFirst {
|
||||
for _, variant := range subReq.subscriptionHostVariants(inbound, "clash") {
|
||||
render := func() []map[string]any { return s.getProxies(subReq, variant.inbound(subReq, inbound), client, host) }
|
||||
ordered = append(ordered, orderedSubEntry[func() []map[string]any]{variant.order, render})
|
||||
}
|
||||
} else {
|
||||
proxies = append(proxies, s.getProxies(subReq, inbound, client, host)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
if subReq.hostsFirst {
|
||||
for _, render := range orderedSubValues(ordered) {
|
||||
proxies = append(proxies, render()...)
|
||||
}
|
||||
}
|
||||
for _, ext := range externalLinks {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
type hostEntryOrder struct {
|
||||
sortOrder, inboundSort, inboundID, hostID int
|
||||
remark, groupID string
|
||||
}
|
||||
|
||||
type orderedSubEntry[T any] struct {
|
||||
order *hostEntryOrder
|
||||
value T
|
||||
}
|
||||
|
||||
// Group ties match the Hosts page, then inbound order and host row ID.
|
||||
// Stable sorting preserves emission order inside each host.
|
||||
func hostOrderLess(a, b *hostEntryOrder) bool {
|
||||
if a == nil || b == nil {
|
||||
return a != nil && b == nil
|
||||
}
|
||||
for _, c := range []int{
|
||||
cmp.Compare(a.sortOrder, b.sortOrder), cmp.Compare(a.remark, b.remark), cmp.Compare(a.groupID, b.groupID),
|
||||
cmp.Compare(a.inboundSort, b.inboundSort), cmp.Compare(a.inboundID, b.inboundID), cmp.Compare(a.hostID, b.hostID),
|
||||
} {
|
||||
if c != 0 {
|
||||
return c < 0
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func orderedSubValues[T any](entries []orderedSubEntry[T]) []T {
|
||||
sort.SliceStable(entries, func(i, j int) bool { return hostOrderLess(entries[i].order, entries[j].order) })
|
||||
var out []T
|
||||
for _, entry := range entries {
|
||||
out = append(out, entry.value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *SubService) loadSubscriptionHosts(inbounds []*model.Inbound) error {
|
||||
s.hostsByInbound = make(map[int][]*model.Host, len(inbounds))
|
||||
s.hostGroupOrders = make(map[string]hostEntryOrder)
|
||||
ids := make([]int, len(inbounds))
|
||||
for i, inbound := range inbounds {
|
||||
ids[i] = inbound.Id
|
||||
}
|
||||
for lo := 0; lo < len(ids); lo += 400 {
|
||||
var rows []*model.Host
|
||||
if err := database.GetDB().Where("inbound_id IN ?", ids[lo:min(lo+400, len(ids))]).Order("inbound_id ASC, sort_order ASC, id ASC").Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range rows {
|
||||
s.hostsByInbound[row.InboundId] = append(s.hostsByInbound[row.InboundId], row)
|
||||
key := hostOrderGroupID(row)
|
||||
if order, exists := s.hostGroupOrders[key]; exists {
|
||||
order.sortOrder = min(order.sortOrder, row.SortOrder)
|
||||
s.hostGroupOrders[key] = order
|
||||
} else {
|
||||
s.hostGroupOrders[key] = hostEntryOrder{sortOrder: row.SortOrder, remark: row.Remark, groupID: key}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type subscriptionHostVariant struct {
|
||||
host *model.Host
|
||||
order *hostEntryOrder
|
||||
}
|
||||
|
||||
func hostOrderGroupID(h *model.Host) string {
|
||||
if h.GroupId != "" {
|
||||
return h.GroupId
|
||||
}
|
||||
return fmt.Sprintf("fallback_%d", h.Id)
|
||||
}
|
||||
|
||||
func (s *SubService) subscriptionHostVariants(inbound *model.Inbound, format string) []subscriptionHostVariant {
|
||||
var variants []subscriptionHostVariant
|
||||
for _, h := range s.hostsByInbound[inbound.Id] {
|
||||
if h.IsDisabled || slices.Contains(h.ExcludeFromSubTypes, format) {
|
||||
continue
|
||||
}
|
||||
order := s.hostGroupOrders[hostOrderGroupID(h)]
|
||||
order.inboundSort, order.inboundID, order.hostID = inbound.SubSortIndex, inbound.Id, h.Id
|
||||
variants = append(variants, subscriptionHostVariant{h, &order})
|
||||
}
|
||||
if len(variants) == 0 {
|
||||
return []subscriptionHostVariant{{}}
|
||||
}
|
||||
return variants
|
||||
}
|
||||
|
||||
func (v subscriptionHostVariant) endpoint(s *SubService, inbound *model.Inbound) []map[string]any {
|
||||
if v.host == nil {
|
||||
return nil
|
||||
}
|
||||
return []map[string]any{hostToExternalProxyMap(v.host, s.resolveInboundAddress(inbound), inbound.Port)}
|
||||
}
|
||||
|
||||
func (v subscriptionHostVariant) inbound(s *SubService, inbound *model.Inbound) *model.Inbound {
|
||||
clone := *inbound
|
||||
if ep := v.endpoint(s, inbound); len(ep) > 0 {
|
||||
injectExternalProxy(&clone, ep)
|
||||
}
|
||||
return &clone
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/testpg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
func TestSubHostOrder_Postgres(t *testing.T) {
|
||||
if os.Getenv("XUI_DB_TYPE") != "postgres" || os.Getenv("XUI_DB_DSN") == "" {
|
||||
t.Skip("requires a disposable PostgreSQL database")
|
||||
}
|
||||
for _, scenario := range []struct {
|
||||
name string
|
||||
run func(*testing.T)
|
||||
}{
|
||||
{"global", TestSub_GlobalHostOrder},
|
||||
{"filters", TestSub_HostOrderFiltersAndFallbacks},
|
||||
{"base64", TestSub_HostOrderBase64AndInfoNode},
|
||||
{"balancer", TestSub_HostOrderBalancerSlots},
|
||||
} {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
cleanup, err := testpg.IsolatePackage("host_order")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
scenario.run(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSub_HostOrderBalancerSlots(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
setHostSortMode(t, "hosts-first")
|
||||
a := seedSubInbound(t, "order-sub", "a", 46910, 0, wsTLSStream)
|
||||
b := seedSubInbound(t, "order-sub", "b", 46911, 10, wsTLSStream)
|
||||
seedHost(t, &model.Host{InboundId: a.Id, GroupId: "a", SortOrder: 1, Remark: "A", Address: "a.example.com"})
|
||||
seedHost(t, &model.Host{InboundId: a.Id, GroupId: "c", SortOrder: 2, Remark: "C", Address: "c.example.com"})
|
||||
seedHost(t, &model.Host{InboundId: b.Id, GroupId: "b", SortOrder: 0, Remark: "B", Address: "b.example.com"})
|
||||
bal := seedSubBalancer(t, &model.SubBalancer{Remark: "auto", Strategy: "random", InboundIds: []int{a.Id, b.Id}, SortOrder: 5, Enabled: true})
|
||||
body, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("order-sub", "sub.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
docs := parseSubJsonDocs(t, body)
|
||||
var remarks []string
|
||||
for _, doc := range docs {
|
||||
if doc["remarks"] == "auto" {
|
||||
remarks = append(remarks, "balancer")
|
||||
continue
|
||||
}
|
||||
outbounds := doc["outbounds"].([]any)
|
||||
settings := outbounds[0].(map[string]any)["settings"].(map[string]any)
|
||||
remarks = append(remarks, settings["address"].(string))
|
||||
}
|
||||
if !reflect.DeepEqual(remarks, []string{"b.example.com", "a.example.com", "balancer", "c.example.com"}) {
|
||||
t.Fatalf("endpoint and balancer slots: %v", remarks)
|
||||
}
|
||||
prefix := fmt.Sprintf("bal-%d-vless", bal.Id)
|
||||
wantTags := []string{prefix, prefix + "-2", prefix + "-3", "direct", "block"}
|
||||
if got := docOutboundTags(docs[2]); !reflect.DeepEqual(got, wantTags) {
|
||||
t.Fatalf("balancer tags: %v, want %v", got, wantTags)
|
||||
}
|
||||
}
|
||||
|
||||
func setHostSortMode(t *testing.T, mode string) {
|
||||
t.Helper()
|
||||
row := &model.Setting{Key: "subHostSortMode"}
|
||||
if err := database.GetDB().Where("key = ?", row.Key).Assign(model.Setting{Value: mode}).FirstOrCreate(row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSub_HostOrderFiltersAndFallbacks(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
setHostSortMode(t, "hosts-first")
|
||||
ib := seedSubInbound(t, "order-sub", "eligible", 46900, 10, wsTLSStream)
|
||||
seedSubInbound(t, "order-sub", "legacy", 46901, -10, `{"network":"ws","security":"tls","wsSettings":{"path":"/"},"tlsSettings":{"serverName":"legacy.sni"},"externalProxy":[{"forceTls":"tls","dest":"legacy.example.com","port":443}]}`)
|
||||
noHosts := seedSubInbound(t, "order-sub", "no-hosts", 46902, -20, wsTLSStream)
|
||||
if err := database.GetDB().Model(noHosts).Update("listen", "198.51.100.20").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
foreign := seedSubInbound(t, "other-sub", "foreign", 46903, -40, wsTLSStream)
|
||||
excluded := seedSubInbound(t, "order-sub", "excluded", 46904, -30, wsTLSStream)
|
||||
if err := database.GetDB().Model(excluded).Update("exclude_from_sub", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, h := range []model.Host{
|
||||
{InboundId: ib.Id, GroupId: "main", SortOrder: 0, Address: "a.example.com"},
|
||||
{InboundId: ib.Id, GroupId: "off", SortOrder: -5, Address: "off.example.com", IsDisabled: true},
|
||||
{InboundId: ib.Id, GroupId: "format", SortOrder: -1, Address: "format.example.com", ExcludeFromSubTypes: []string{"clash"}},
|
||||
{InboundId: ib.Id, GroupId: "hidden", SortOrder: 1, Address: "hidden.example.com", IsHidden: true},
|
||||
{InboundId: foreign.Id, GroupId: "foreign", SortOrder: -10, Address: "foreign.example.com"},
|
||||
{InboundId: excluded.Id, GroupId: "excluded", SortOrder: -20, Address: "excluded.example.com"},
|
||||
} {
|
||||
row := h
|
||||
seedHost(t, &row)
|
||||
}
|
||||
db := database.GetDB()
|
||||
queries := 0
|
||||
const callback = "test:6894_hosts"
|
||||
if err := db.Callback().Query().Before("gorm:query").Register(callback, func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == "hosts" {
|
||||
queries++
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Callback().Query().Remove(callback) })
|
||||
for _, format := range []string{"raw", "clash", "json"} {
|
||||
queries = 0
|
||||
want := []string{"format.example.com", "a.example.com", "hidden.example.com", "198.51.100.20", "legacy.example.com"}
|
||||
if format == "clash" {
|
||||
want = want[1:]
|
||||
}
|
||||
if got := renderedHostAddresses(t, format, NewSubService("")); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("%s: %v, want %v", format, got, want)
|
||||
}
|
||||
if queries != 1 {
|
||||
t.Fatalf("%s queried hosts %d times, want 1", format, queries)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSub_HostOrderBase64AndInfoNode(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
setHostSortMode(t, "hosts-first")
|
||||
a := seedSubInbound(t, "order-sub", "a", 46905, 0, wsTLSStream)
|
||||
b := seedSubInbound(t, "order-sub", "b", 46906, 10, wsTLSStream)
|
||||
seedHost(t, &model.Host{InboundId: a.Id, GroupId: "a", SortOrder: 1, Remark: "A", Address: "a.example.com"})
|
||||
seedHost(t, &model.Host{InboundId: b.Id, GroupId: "b", SortOrder: 0, Remark: "B", Address: "b.example.com"})
|
||||
row := &model.Setting{Key: "subInfoNodeEnable"}
|
||||
if err := database.GetDB().Where("key = ?", row.Key).Assign(model.Setting{Value: "true"}).FirstOrCreate(row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router := newSubscriptionTestRouter(subscriptionTestRouterConfig{})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/order-sub", nil))
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("HTTP status %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(response.Body.String())
|
||||
if err != nil {
|
||||
t.Fatalf("base64 subscription: %v: %s", err, response.Body.String())
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(decoded)), "\n")
|
||||
if len(lines) != 3 || !strings.HasPrefix(lines[0], "socks://127.0.0.1") || !strings.Contains(lines[1], "b.example.com") || !strings.Contains(lines[2], "a.example.com") {
|
||||
t.Fatalf("encoded order/info node: %q", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func renderedHostAddresses(t *testing.T, format string, base *SubService) []string {
|
||||
t.Helper()
|
||||
var addresses []string
|
||||
switch format {
|
||||
case "raw":
|
||||
links, _, _, _, err := base.GetSubs("order-sub", "sub.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, line := range splitLinkLines(strings.Join(links, "\n")) {
|
||||
u, err := url.Parse(line)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addresses = append(addresses, u.Hostname())
|
||||
}
|
||||
case "clash":
|
||||
body, _, err := NewSubClashService(false, "", false, base).GetClash("order-sub", "sub.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var doc struct {
|
||||
Proxies []struct{ Server string } `yaml:"proxies"`
|
||||
}
|
||||
if err := yaml.Unmarshal([]byte(body), &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, proxy := range doc.Proxies {
|
||||
addresses = append(addresses, proxy.Server)
|
||||
}
|
||||
case "json":
|
||||
body, _, err := NewSubJsonService("", "", "", "", base).GetJson("order-sub", "sub.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var docs []struct {
|
||||
Outbounds []struct {
|
||||
Settings struct {
|
||||
Address string `json:"address"`
|
||||
} `json:"settings"`
|
||||
} `json:"outbounds"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &docs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, doc := range docs {
|
||||
if len(doc.Outbounds) == 0 || doc.Outbounds[0].Settings.Address == "" {
|
||||
t.Fatalf("JSON proxy shape: %s", body)
|
||||
}
|
||||
addresses = append(addresses, doc.Outbounds[0].Settings.Address)
|
||||
}
|
||||
}
|
||||
return addresses
|
||||
}
|
||||
|
||||
func TestSub_GlobalHostOrder(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
first := seedSubInbound(t, "order-sub", "first", 46894, -10, wsTLSStream)
|
||||
second := seedSubInbound(t, "order-sub", "second", 46895, 20, wsTLSStream)
|
||||
seedSubInbound(t, "order-sub", "fallback", 46896, -20, wsTLSStream)
|
||||
for _, host := range []model.Host{
|
||||
{GroupId: "a", InboundId: first.Id, SortOrder: 1, Remark: "A", Address: "a.example.com"},
|
||||
{GroupId: "b", InboundId: second.Id, SortOrder: 0, Remark: "B", Address: "b.example.com"},
|
||||
{GroupId: "c", InboundId: second.Id, SortOrder: 2, Remark: "C", Address: "c.example.com"},
|
||||
{GroupId: "shared", InboundId: first.Id, SortOrder: 1, Remark: "Z", Address: "shared1.example.com"},
|
||||
{GroupId: "shared", InboundId: first.Id, SortOrder: 1, Remark: "Z", Address: "shared2.example.com"},
|
||||
{GroupId: "shared", InboundId: second.Id, SortOrder: 1, Remark: "Z", Address: "shared1.example.com"},
|
||||
} {
|
||||
h := host
|
||||
seedHost(t, &h)
|
||||
}
|
||||
base := NewSubService("")
|
||||
assertAll := func(want []string) {
|
||||
t.Helper()
|
||||
for _, format := range []string{"raw", "clash", "json"} {
|
||||
if got := renderedHostAddresses(t, format, base); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("%s addresses = %v, want %v", format, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
assertAll([]string{"203.0.113.5", "a.example.com", "shared1.example.com", "shared2.example.com", "b.example.com", "shared1.example.com", "c.example.com"})
|
||||
setHostSortMode(t, "hosts-first")
|
||||
assertAll([]string{"b.example.com", "a.example.com", "shared1.example.com", "shared2.example.com", "shared1.example.com", "c.example.com", "203.0.113.5"})
|
||||
if err := (&service.HostService{}).ReorderHostGroups([]string{"c", "b", "shared", "a"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertAll([]string{"c.example.com", "b.example.com", "shared1.example.com", "shared2.example.com", "shared1.example.com", "a.example.com", "203.0.113.5"})
|
||||
setHostSortMode(t, "inbound-first")
|
||||
assertAll([]string{"203.0.113.5", "shared1.example.com", "shared2.example.com", "a.example.com", "c.example.com", "b.example.com", "shared1.example.com"})
|
||||
}
|
||||
@@ -19,7 +19,9 @@ import (
|
||||
// inbounds.
|
||||
func (s *SubService) hostEndpoints(inbound *model.Inbound, format string) []map[string]any {
|
||||
var hosts []*model.Host
|
||||
if err := database.GetDB().
|
||||
if s.hostsByInbound != nil {
|
||||
hosts = s.hostsByInbound[inbound.Id]
|
||||
} else if err := database.GetDB().
|
||||
Where("inbound_id = ? AND is_disabled = ?", inbound.Id, false).
|
||||
Order("sort_order asc, id asc").
|
||||
Find(&hosts).Error; err != nil {
|
||||
@@ -32,7 +34,7 @@ func (s *SubService) hostEndpoints(inbound *model.Inbound, format string) []map[
|
||||
defaultDest := s.resolveInboundAddress(inbound)
|
||||
eps := make([]map[string]any, 0, len(hosts))
|
||||
for _, h := range hosts {
|
||||
if slices.Contains(h.ExcludeFromSubTypes, format) {
|
||||
if h.IsDisabled || slices.Contains(h.ExcludeFromSubTypes, format) {
|
||||
continue
|
||||
}
|
||||
if IsCIDR(h.Address) {
|
||||
|
||||
@@ -136,6 +136,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
|
||||
|
||||
seenEmails := make(map[string]struct{})
|
||||
entries := make([]subConfigEntry, 0, len(inbounds))
|
||||
var ordered []orderedSubEntry[func() subConfigEntry]
|
||||
// Prepare Inbounds
|
||||
for _, inbound := range inbounds {
|
||||
clients := subReq.matchingClients(inbound, subId)
|
||||
@@ -149,8 +150,10 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
|
||||
continue
|
||||
}
|
||||
subReq.projectThroughFallbackMaster(inbound)
|
||||
if hostEps := subReq.hostEndpoints(inbound, "json"); len(hostEps) > 0 {
|
||||
injectExternalProxy(inbound, hostEps)
|
||||
if !subReq.hostsFirst {
|
||||
if hostEps := subReq.hostEndpoints(inbound, "json"); len(hostEps) > 0 {
|
||||
injectExternalProxy(inbound, hostEps)
|
||||
}
|
||||
}
|
||||
|
||||
var inboundConfigs []json_util.RawMessage
|
||||
@@ -159,7 +162,17 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
|
||||
hasEnabledClient = true
|
||||
}
|
||||
seenEmails[client.Email] = struct{}{}
|
||||
inboundConfigs = append(inboundConfigs, s.getConfig(subReq, inbound, client, host)...)
|
||||
if subReq.hostsFirst {
|
||||
for _, variant := range subReq.subscriptionHostVariants(inbound, "json") {
|
||||
render := func() subConfigEntry {
|
||||
configs := s.getConfig(subReq, variant.inbound(subReq, inbound), client, host)
|
||||
return subConfigEntry{sortIndex: inbound.SubSortIndex, id: inbound.Id, configs: configs, hostOrder: variant.order}
|
||||
}
|
||||
ordered = append(ordered, orderedSubEntry[func() subConfigEntry]{variant.order, render})
|
||||
}
|
||||
} else {
|
||||
inboundConfigs = append(inboundConfigs, s.getConfig(subReq, inbound, client, host)...)
|
||||
}
|
||||
}
|
||||
if len(inboundConfigs) > 0 {
|
||||
entries = append(entries, subConfigEntry{
|
||||
@@ -169,6 +182,14 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
|
||||
})
|
||||
}
|
||||
}
|
||||
if subReq.hostsFirst {
|
||||
// Render in final endpoint order so first-link usage remarks stay first.
|
||||
for _, render := range orderedSubValues(ordered) {
|
||||
if entry := render(); len(entry.configs) > 0 {
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
entries = s.appendBalancerEntries(entries)
|
||||
|
||||
// Inbounds arrive sorted by (sub_sort_index, id); balancers interleave by
|
||||
@@ -183,6 +204,23 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
|
||||
return entries[i].id < entries[j].id
|
||||
})
|
||||
var configArray []json_util.RawMessage
|
||||
if subReq.hostsFirst {
|
||||
// Preserve balancer slots from the legacy sort; reorder endpoint slots only.
|
||||
var endpoints []subConfigEntry
|
||||
for _, entry := range entries {
|
||||
if entry.kind == 0 {
|
||||
endpoints = append(endpoints, entry)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(endpoints, func(i, j int) bool { return hostOrderLess(endpoints[i].hostOrder, endpoints[j].hostOrder) })
|
||||
next := 0
|
||||
for i := range entries {
|
||||
if entries[i].kind == 0 {
|
||||
entries[i] = endpoints[next]
|
||||
next++
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, entry := range entries {
|
||||
configArray = append(configArray, entry.configs...)
|
||||
}
|
||||
@@ -255,6 +293,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
|
||||
// subConfigEntry is one ordered block of the JSON subscription: an inbound's
|
||||
// configs (kind 0) or a balancer config (kind 1).
|
||||
type subConfigEntry struct {
|
||||
hostOrder *hostEntryOrder
|
||||
sortIndex int
|
||||
kind int
|
||||
id int
|
||||
|
||||
+34
-3
@@ -77,6 +77,9 @@ type SubService struct {
|
||||
// with the clients array left out; generators read only inbound-level
|
||||
// fields (encryption, method, version, …) from it.
|
||||
settingsByInbound map[int]map[string]any
|
||||
hostsByInbound map[int][]*model.Host
|
||||
hostGroupOrders map[string]hostEntryOrder
|
||||
hostsFirst bool
|
||||
}
|
||||
|
||||
// NewSubService creates a new subscription service with the given configuration.
|
||||
@@ -112,6 +115,10 @@ func (s *SubService) PrepareForRequest(host string) {
|
||||
s.clientsByInbound = map[int]map[string]model.Client{}
|
||||
s.fullyPrimedInbounds = map[int]bool{}
|
||||
s.settingsByInbound = map[int]map[string]any{}
|
||||
s.hostsByInbound = nil
|
||||
s.hostGroupOrders = nil
|
||||
mode, _ := s.settingService.GetSubHostSortMode()
|
||||
s.hostsFirst = mode == "hosts-first"
|
||||
s.loadNodes()
|
||||
s.loadRemarkSettings()
|
||||
s.subCalendarExpireInclusive, _ = s.settingService.GetSubCalendarExpireInclusive()
|
||||
@@ -464,6 +471,7 @@ func (s *SubService) resolveInfoNodeRemark(subId string, uniqueEmails []string,
|
||||
|
||||
func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.ClientTraffic, error) {
|
||||
var result []string
|
||||
var ordered []orderedSubEntry[func() string]
|
||||
var emails []string
|
||||
var traffic xray.ClientTraffic
|
||||
var hasEnabledClient bool
|
||||
@@ -495,22 +503,42 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie
|
||||
s.projectThroughFallbackMaster(inbound)
|
||||
// Host overrides apply AFTER fallback projection so a host's
|
||||
// address/TLS wins over the projected master stream.
|
||||
hostEps := s.hostEndpoints(inbound, "raw")
|
||||
var hostEps []map[string]any
|
||||
if !s.hostsFirst {
|
||||
hostEps = s.hostEndpoints(inbound, "raw")
|
||||
}
|
||||
for _, client := range clients {
|
||||
if client.Enable {
|
||||
hasEnabledClient = true
|
||||
}
|
||||
var link string
|
||||
if len(hostEps) > 0 {
|
||||
if s.hostsFirst {
|
||||
for _, variant := range s.subscriptionHostVariants(inbound, "raw") {
|
||||
render := func() string {
|
||||
if variant.host != nil {
|
||||
return s.linkFromHosts(inbound, client, variant.endpoint(s, inbound))
|
||||
}
|
||||
return s.GetLink(inbound, client.Email)
|
||||
}
|
||||
ordered = append(ordered, orderedSubEntry[func() string]{variant.order, render})
|
||||
}
|
||||
} else if len(hostEps) > 0 {
|
||||
link = s.linkFromHosts(inbound, client, hostEps)
|
||||
} else {
|
||||
link = s.GetLink(inbound, client.Email)
|
||||
}
|
||||
result = append(result, link)
|
||||
if !s.hostsFirst {
|
||||
result = append(result, link)
|
||||
}
|
||||
emails = append(emails, client.Email)
|
||||
seenEmails[client.Email] = struct{}{}
|
||||
}
|
||||
}
|
||||
if s.hostsFirst {
|
||||
for _, render := range orderedSubValues(ordered) {
|
||||
result = append(result, render())
|
||||
}
|
||||
}
|
||||
for _, ext := range externalLinks {
|
||||
if ext.Enable {
|
||||
hasEnabledClient = true
|
||||
@@ -693,6 +721,9 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
|
||||
return nil, err
|
||||
}
|
||||
s.indexStatsBySubId(subId)
|
||||
if err := s.loadSubscriptionHosts(inbounds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return inbounds, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ type AllSetting struct {
|
||||
RemarkTemplate string `json:"remarkTemplate" form:"remarkTemplate"`
|
||||
SubShowIdentityOnAllLinks bool `json:"subShowIdentityOnAllLinks" form:"subShowIdentityOnAllLinks"`
|
||||
SubInfoNodeEnable bool `json:"subInfoNodeEnable" form:"subInfoNodeEnable"`
|
||||
SubHostSortMode string `json:"subHostSortMode" form:"subHostSortMode" example:"inbound-first"`
|
||||
SubCalendarExpireInclusive bool `json:"subCalendarExpireInclusive" form:"subCalendarExpireInclusive"`
|
||||
SubExpiredTemplate string `json:"subExpiredTemplate" form:"subExpiredTemplate"`
|
||||
SubTrafficDepletedTemplate string `json:"subTrafficDepletedTemplate" form:"subTrafficDepletedTemplate"`
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type HostService struct{}
|
||||
@@ -106,7 +107,10 @@ func groupHosts(hosts []*model.Host) []*entity.HostGroup {
|
||||
if res[i].SortOrder != res[j].SortOrder {
|
||||
return res[i].SortOrder < res[j].SortOrder
|
||||
}
|
||||
return res[i].Remark < res[j].Remark
|
||||
if res[i].Remark != res[j].Remark {
|
||||
return res[i].Remark < res[j].Remark
|
||||
}
|
||||
return res[i].GroupId < res[j].GroupId
|
||||
})
|
||||
|
||||
return res
|
||||
@@ -311,8 +315,33 @@ func (s *HostService) ReorderHostGroups(groupIds []string) error {
|
||||
if len(groupIds) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]bool, len(groupIds))
|
||||
for _, id := range groupIds {
|
||||
if id == "" || seen[id] {
|
||||
return common.NewError("host group IDs must be nonempty and unique")
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
return database.GetDB().Transaction(func(tx *gorm.DB) error {
|
||||
for i, groupId := range groupIds {
|
||||
var rows []*model.Host
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Order("id ASC").Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
groups := groupHosts(rows)
|
||||
known := make(map[string]bool, len(groups))
|
||||
ordered := append([]string(nil), groupIds...)
|
||||
for _, group := range groups {
|
||||
known[group.GroupId] = true
|
||||
if !seen[group.GroupId] {
|
||||
ordered = append(ordered, group.GroupId)
|
||||
}
|
||||
}
|
||||
for _, id := range groupIds {
|
||||
if !known[id] {
|
||||
return common.NewError("host group not found")
|
||||
}
|
||||
}
|
||||
for i, groupId := range ordered {
|
||||
if err := tx.Model(&model.Host{}).Where("group_id = ?", groupId).Update("sort_order", i).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
@@ -8,6 +9,43 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
||||
)
|
||||
|
||||
func TestReorderHostsRejectsInvalidGroupsAtomically(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &HostService{}
|
||||
ib := mkInbound(t, 46890, model.VLESS, `{"clients":[]}`)
|
||||
a := mkHost(t, svc, ib.Id, "a", 0)
|
||||
b := mkHost(t, svc, ib.Id, "b", 1)
|
||||
for _, invalid := range []struct {
|
||||
ids []string
|
||||
err string
|
||||
}{
|
||||
{[]string{b.GroupId, a.GroupId, b.GroupId}, "host group IDs must be nonempty and unique"},
|
||||
{[]string{b.GroupId, ""}, "host group IDs must be nonempty and unique"},
|
||||
{[]string{b.GroupId, "unknown"}, "host group not found"},
|
||||
} {
|
||||
if err := svc.ReorderHostGroups(invalid.ids); err == nil || err.Error() != invalid.err+"\n" {
|
||||
t.Fatalf("invalid group IDs %v: error = %v, want %q", invalid.ids, err, invalid.err)
|
||||
}
|
||||
groups, err := svc.GetHosts()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if groups[0].GroupId != a.GroupId || groups[0].SortOrder != 0 || groups[1].GroupId != b.GroupId || groups[1].SortOrder != 1 {
|
||||
t.Fatalf("invalid request changed ordering: %+v", groups)
|
||||
}
|
||||
}
|
||||
if err := svc.ReorderHostGroups([]string{b.GroupId}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
groups, err := svc.GetHosts()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := []string{groups[0].GroupId, groups[1].GroupId}; !reflect.DeepEqual(got, []string{b.GroupId, a.GroupId}) || groups[1].SortOrder != 1 {
|
||||
t.Fatalf("partial reorder lost omitted groups: %+v", groups)
|
||||
}
|
||||
}
|
||||
|
||||
func mkHost(t *testing.T, svc *HostService, inboundId int, remark string, order int) *entity.HostGroup {
|
||||
t.Helper()
|
||||
created, err := svc.AddHostGroup(&entity.HostGroup{
|
||||
|
||||
@@ -83,6 +83,7 @@ var defaultValueMap = map[string]string{
|
||||
"remarkTemplate": DefaultRemarkTemplate,
|
||||
"subShowIdentityOnAllLinks": "false",
|
||||
"subInfoNodeEnable": "false",
|
||||
"subHostSortMode": "inbound-first",
|
||||
"subCalendarExpireInclusive": "false",
|
||||
"subExpiredTemplate": DefaultSubExpiredTemplate,
|
||||
"subTrafficDepletedTemplate": DefaultSubTrafficDepletedTemplate,
|
||||
@@ -770,6 +771,10 @@ func (s *SettingService) GetSubInfoNodeEnable() (bool, error) {
|
||||
return s.getBool("subInfoNodeEnable")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubHostSortMode() (string, error) {
|
||||
return s.getString("subHostSortMode")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubCalendarExpireInclusive() (bool, error) {
|
||||
return s.getBool("subCalendarExpireInclusive")
|
||||
}
|
||||
@@ -1662,6 +1667,13 @@ type SecretClears struct {
|
||||
}
|
||||
|
||||
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting, clears SecretClears) error {
|
||||
switch allSetting.SubHostSortMode {
|
||||
case "":
|
||||
allSetting.SubHostSortMode = "inbound-first"
|
||||
case "inbound-first", "hosts-first":
|
||||
default:
|
||||
return errors.New("subscription host sort mode must be inbound-first or hosts-first")
|
||||
}
|
||||
switch allSetting.SubProfileMode {
|
||||
case "", SubProfileModeNone, SubProfileModeBuiltin, SubProfileModeCustom:
|
||||
allSetting.SubProfileMode = effectiveSubProfileMode(allSetting.SubProfileMode, allSetting.SubProfileUrl)
|
||||
|
||||
@@ -4,6 +4,34 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSubHostSortModePersistsAndRejectsInvalidValues(t *testing.T) {
|
||||
setupSettingTestDB(t)
|
||||
s := &SettingService{}
|
||||
settings, err := s.GetAllSetting()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.SubHostSortMode != "inbound-first" {
|
||||
t.Fatalf("default = %q", settings.SubHostSortMode)
|
||||
}
|
||||
settings.SubHostSortMode = "hosts-first"
|
||||
if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reloaded, err := s.GetAllSetting()
|
||||
if err != nil || reloaded.SubHostSortMode != "hosts-first" {
|
||||
t.Fatalf("reloaded mode = %+v, err=%v", reloaded, err)
|
||||
}
|
||||
settings.SubHostSortMode = "invalid"
|
||||
if err := s.UpdateAllSetting(settings, SecretClears{}); err == nil || err.Error() != "subscription host sort mode must be inbound-first or hosts-first" {
|
||||
t.Fatalf("invalid mode error = %v", err)
|
||||
}
|
||||
mode, err := s.GetSubHostSortMode()
|
||||
if err != nil || mode != "hosts-first" {
|
||||
t.Fatalf("invalid save changed mode: %q, %v", mode, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubInfoNodeSettingsDefaultsAndPersists(t *testing.T) {
|
||||
setupSettingTestDB(t)
|
||||
s := &SettingService{}
|
||||
|
||||
@@ -1826,7 +1826,11 @@
|
||||
"subIncyResolveDnsDomainDesc": "رابط سيرفر DoH، غالبًا ينتهي بـ /dns-query.",
|
||||
"subIncyResolveDnsIp": "IP سيرفر DoH",
|
||||
"subIncyResolveDnsIpDesc": "عنوان IP لسيرفر DoH للتهيئة.",
|
||||
"subIncyGroupRouting": "التوجيه"
|
||||
"subIncyGroupRouting": "التوجيه",
|
||||
"subHostSortMode": "ترتيب عقد الاشتراك",
|
||||
"subHostSortInbound": "ترتيب الاتصالات الواردة (الافتراضي)",
|
||||
"subHostSortHosts": "ترتيب المضيفين",
|
||||
"subHostSortModeDesc": "يتبع ترتيب المضيفين صفحة المضيفين عبر جميع الاتصالات الواردة. يحدد هذا ترتيب مخرجات الخادم؛ وقد تطبق تطبيقات العميل ترتيبًا آخر."
|
||||
},
|
||||
"xray": {
|
||||
"save": "احفظ",
|
||||
@@ -2465,7 +2469,8 @@
|
||||
"delete": "حذف المضيف",
|
||||
"badTag": "وسم غير صالح",
|
||||
"badVlessRoute": "أدخل رقماً واحداً بين 0 و65535"
|
||||
}
|
||||
},
|
||||
"dragToReorder": "اسحب لتغيير ترتيب {name}"
|
||||
},
|
||||
"sponsors": {
|
||||
"title": "الرعاة",
|
||||
|
||||
@@ -1202,7 +1202,8 @@
|
||||
"delete": "Host deleted successfully",
|
||||
"badTag": "Invalid tag",
|
||||
"badVlessRoute": "Enter a single number between 0 and 65535"
|
||||
}
|
||||
},
|
||||
"dragToReorder": "Drag to reorder {name}"
|
||||
},
|
||||
"nodes": {
|
||||
"addNode": "Add Node",
|
||||
@@ -1945,7 +1946,11 @@
|
||||
"subIncyResolveDnsDomainDesc": "DoH server URL, usually ending in /dns-query.",
|
||||
"subIncyResolveDnsIp": "DoH server IP",
|
||||
"subIncyResolveDnsIpDesc": "IP of the DoH server, used for bootstrap.",
|
||||
"subIncyGroupRouting": "Routing"
|
||||
"subIncyGroupRouting": "Routing",
|
||||
"subHostSortMode": "Subscription Node Ordering",
|
||||
"subHostSortInbound": "Inbound order (default)",
|
||||
"subHostSortHosts": "Hosts order",
|
||||
"subHostSortModeDesc": "Hosts order follows the Hosts page across inbounds. This controls the order emitted by the server; client apps may apply their own sorting."
|
||||
},
|
||||
"xray": {
|
||||
"save": "Save",
|
||||
|
||||
@@ -1826,7 +1826,11 @@
|
||||
"subIncyResolveDnsDomainDesc": "URL del servidor DoH, normalmente acaba en /dns-query.",
|
||||
"subIncyResolveDnsIp": "IP del servidor DoH",
|
||||
"subIncyResolveDnsIpDesc": "IP del servidor DoH para el arranque.",
|
||||
"subIncyGroupRouting": "Enrutado"
|
||||
"subIncyGroupRouting": "Enrutado",
|
||||
"subHostSortMode": "Orden de nodos de suscripción",
|
||||
"subHostSortInbound": "Orden de entradas (predeterminado)",
|
||||
"subHostSortHosts": "Orden de hosts",
|
||||
"subHostSortModeDesc": "Sigue el orden de la página de hosts entre distintas entradas. Determina el orden emitido por el servidor; las aplicaciones cliente pueden aplicar su propio orden."
|
||||
},
|
||||
"xray": {
|
||||
"save": "Guardar configuración",
|
||||
@@ -2465,7 +2469,8 @@
|
||||
"delete": "Eliminar host",
|
||||
"badTag": "Etiqueta no válida",
|
||||
"badVlessRoute": "Introduce un único número entre 0 y 65535"
|
||||
}
|
||||
},
|
||||
"dragToReorder": "Arrastra para reordenar {name}"
|
||||
},
|
||||
"sponsors": {
|
||||
"title": "Patrocinadores",
|
||||
|
||||
@@ -1826,7 +1826,11 @@
|
||||
"subIncyResolveDnsDomainDesc": "آدرس سرور DoH، معمولاً به /dns-query ختم میشود.",
|
||||
"subIncyResolveDnsIp": "آیپی سرور DoH",
|
||||
"subIncyResolveDnsIpDesc": "آیپی سرور DoH برای بوتاسترپ.",
|
||||
"subIncyGroupRouting": "مسیریابی"
|
||||
"subIncyGroupRouting": "مسیریابی",
|
||||
"subHostSortMode": "ترتیب گرههای اشتراک",
|
||||
"subHostSortInbound": "ترتیب ورودیها (پیشفرض)",
|
||||
"subHostSortHosts": "ترتیب میزبانها",
|
||||
"subHostSortModeDesc": "ترتیب میزبانها در صفحهٔ میزبانها، میان همهٔ ورودیها اعمال میشود. این تنظیم ترتیب خروجی سرور را تعیین میکند؛ برنامهٔ کلاینت ممکن است ترتیب دیگری اعمال کند."
|
||||
},
|
||||
"xray": {
|
||||
"save": "ذخیره",
|
||||
@@ -2465,7 +2469,8 @@
|
||||
"delete": "حذف میزبان",
|
||||
"badTag": "برچسب نامعتبر",
|
||||
"badVlessRoute": "یک عدد بین 0 تا 65535 وارد کنید"
|
||||
}
|
||||
},
|
||||
"dragToReorder": "برای تغییر ترتیب {name} بکشید"
|
||||
},
|
||||
"sponsors": {
|
||||
"title": "اسپانسرها",
|
||||
|
||||
@@ -1826,7 +1826,11 @@
|
||||
"subIncyResolveDnsDomainDesc": "URL server DoH, biasanya berakhiran /dns-query.",
|
||||
"subIncyResolveDnsIp": "IP server DoH",
|
||||
"subIncyResolveDnsIpDesc": "IP server DoH untuk bootstrap.",
|
||||
"subIncyGroupRouting": "Perutean"
|
||||
"subIncyGroupRouting": "Perutean",
|
||||
"subHostSortMode": "Urutan node langganan",
|
||||
"subHostSortInbound": "Urutan inbound (bawaan)",
|
||||
"subHostSortHosts": "Urutan host",
|
||||
"subHostSortModeDesc": "Mengikuti urutan di halaman Host untuk semua inbound. Ini menentukan urutan keluaran server; aplikasi klien dapat menerapkan urutan sendiri."
|
||||
},
|
||||
"xray": {
|
||||
"save": "Simpan",
|
||||
@@ -2465,7 +2469,8 @@
|
||||
"delete": "Hapus host",
|
||||
"badTag": "Tag tidak valid",
|
||||
"badVlessRoute": "Masukkan satu angka antara 0 dan 65535"
|
||||
}
|
||||
},
|
||||
"dragToReorder": "Seret untuk mengurutkan ulang {name}"
|
||||
},
|
||||
"sponsors": {
|
||||
"title": "Sponsor",
|
||||
|
||||
@@ -1826,7 +1826,11 @@
|
||||
"subIncyResolveDnsDomainDesc": "DoH サーバー URL。通常は /dns-query で終わります。",
|
||||
"subIncyResolveDnsIp": "DoH サーバー IP",
|
||||
"subIncyResolveDnsIpDesc": "ブートストラップ用の DoH サーバー IP。",
|
||||
"subIncyGroupRouting": "ルーティング"
|
||||
"subIncyGroupRouting": "ルーティング",
|
||||
"subHostSortMode": "サブスクリプションのノード順序",
|
||||
"subHostSortInbound": "インバウンド順(既定)",
|
||||
"subHostSortHosts": "ホスト順",
|
||||
"subHostSortModeDesc": "異なるインバウンド間でもホストページの順序に従います。サーバーが出力する順序を指定しますが、クライアントアプリが独自に並べ替える場合があります。"
|
||||
},
|
||||
"xray": {
|
||||
"importRules": "ルールをインポート",
|
||||
@@ -2465,7 +2469,8 @@
|
||||
"delete": "ホストを削除",
|
||||
"badTag": "無効なタグ",
|
||||
"badVlessRoute": "0〜65535 の単一の数値を入力してください"
|
||||
}
|
||||
},
|
||||
"dragToReorder": "ドラッグして {name} を並べ替え"
|
||||
},
|
||||
"sponsors": {
|
||||
"title": "スポンサー",
|
||||
|
||||
@@ -1826,7 +1826,11 @@
|
||||
"subIncyResolveDnsDomainDesc": "URL do servidor DoH, geralmente terminando em /dns-query.",
|
||||
"subIncyResolveDnsIp": "IP do servidor DoH",
|
||||
"subIncyResolveDnsIpDesc": "IP do servidor DoH para bootstrap.",
|
||||
"subIncyGroupRouting": "Roteamento"
|
||||
"subIncyGroupRouting": "Roteamento",
|
||||
"subHostSortMode": "Ordem dos nós da assinatura",
|
||||
"subHostSortInbound": "Ordem das entradas (padrão)",
|
||||
"subHostSortHosts": "Ordem dos hosts",
|
||||
"subHostSortModeDesc": "Segue a ordem da página de hosts entre diferentes entradas. Define a ordem emitida pelo servidor; os aplicativos clientes podem aplicar sua própria ordenação."
|
||||
},
|
||||
"xray": {
|
||||
"importRules": "Importar regras",
|
||||
@@ -2465,7 +2469,8 @@
|
||||
"delete": "Excluir host",
|
||||
"badTag": "Tag inválida",
|
||||
"badVlessRoute": "Insira um único número entre 0 e 65535"
|
||||
}
|
||||
},
|
||||
"dragToReorder": "Arraste para reordenar {name}"
|
||||
},
|
||||
"sponsors": {
|
||||
"title": "Patrocinadores",
|
||||
|
||||
@@ -1826,7 +1826,11 @@
|
||||
"subIncyResolveDnsDomainDesc": "URL DoH-сервера, обычно заканчивается на /dns-query.",
|
||||
"subIncyResolveDnsIp": "IP DoH-сервера",
|
||||
"subIncyResolveDnsIpDesc": "IP DoH-сервера, нужен для bootstrap.",
|
||||
"subIncyGroupRouting": "Маршрутизация"
|
||||
"subIncyGroupRouting": "Маршрутизация",
|
||||
"subHostSortMode": "Порядок узлов подписки",
|
||||
"subHostSortInbound": "Порядок входящих подключений (по умолчанию)",
|
||||
"subHostSortHosts": "Порядок хостов",
|
||||
"subHostSortModeDesc": "Используется порядок со страницы хостов для всех входящих подключений. Настройка задаёт порядок выдачи сервером; клиентские приложения могут сортировать узлы самостоятельно."
|
||||
},
|
||||
"xray": {
|
||||
"importRules": "Импорт правил",
|
||||
@@ -2465,7 +2469,8 @@
|
||||
"delete": "Удалить хост",
|
||||
"badTag": "Недопустимый тег",
|
||||
"badVlessRoute": "Введите одно число от 0 до 65535"
|
||||
}
|
||||
},
|
||||
"dragToReorder": "Перетащите, чтобы изменить порядок {name}"
|
||||
},
|
||||
"sponsors": {
|
||||
"title": "Спонсоры",
|
||||
|
||||
@@ -1826,7 +1826,11 @@
|
||||
"subIncyResolveDnsDomainDesc": "DoH sunucu URL'si, genelde /dns-query ile biter.",
|
||||
"subIncyResolveDnsIp": "DoH sunucu IP'si",
|
||||
"subIncyResolveDnsIpDesc": "Bootstrap için DoH sunucu IP'si.",
|
||||
"subIncyGroupRouting": "Yönlendirme"
|
||||
"subIncyGroupRouting": "Yönlendirme",
|
||||
"subHostSortMode": "Abonelik düğümü sıralaması",
|
||||
"subHostSortInbound": "Gelen bağlantı sırası (varsayılan)",
|
||||
"subHostSortHosts": "Ana bilgisayar sırası",
|
||||
"subHostSortModeDesc": "Farklı gelen bağlantılarda Ana Bilgisayarlar sayfasının sırasını izler. Sunucunun çıktı sırasını belirler; istemci uygulamaları kendi sıralamalarını uygulayabilir."
|
||||
},
|
||||
"xray": {
|
||||
"save": "Kaydet",
|
||||
@@ -2465,7 +2469,8 @@
|
||||
"delete": "Host'u sil",
|
||||
"badTag": "Geçersiz etiket",
|
||||
"badVlessRoute": "0 ile 65535 arasında tek bir sayı girin"
|
||||
}
|
||||
},
|
||||
"dragToReorder": "{name} öğesini yeniden sıralamak için sürükleyin"
|
||||
},
|
||||
"sponsors": {
|
||||
"title": "Sponsorlar",
|
||||
|
||||
@@ -1826,7 +1826,11 @@
|
||||
"subIncyResolveDnsDomainDesc": "URL DoH-сервера, зазвичай закінчується на /dns-query.",
|
||||
"subIncyResolveDnsIp": "IP DoH-сервера",
|
||||
"subIncyResolveDnsIpDesc": "IP DoH-сервера для bootstrap.",
|
||||
"subIncyGroupRouting": "Маршрутизація"
|
||||
"subIncyGroupRouting": "Маршрутизація",
|
||||
"subHostSortMode": "Порядок вузлів підписки",
|
||||
"subHostSortInbound": "Порядок вхідних підключень (типово)",
|
||||
"subHostSortHosts": "Порядок хостів",
|
||||
"subHostSortModeDesc": "Використовується порядок зі сторінки хостів для всіх вхідних підключень. Це визначає порядок видачі сервером; клієнтські застосунки можуть сортувати вузли самостійно."
|
||||
},
|
||||
"xray": {
|
||||
"save": "Зберегти",
|
||||
@@ -2465,7 +2469,8 @@
|
||||
"delete": "Видалити хост",
|
||||
"badTag": "Недійсний тег",
|
||||
"badVlessRoute": "Введіть одне число від 0 до 65535"
|
||||
}
|
||||
},
|
||||
"dragToReorder": "Перетягніть, щоб змінити порядок {name}"
|
||||
},
|
||||
"sponsors": {
|
||||
"title": "Спонсори",
|
||||
|
||||
@@ -1826,7 +1826,11 @@
|
||||
"subIncyResolveDnsDomainDesc": "URL máy chủ DoH, thường kết thúc bằng /dns-query.",
|
||||
"subIncyResolveDnsIp": "IP máy chủ DoH",
|
||||
"subIncyResolveDnsIpDesc": "IP máy chủ DoH để khởi tạo.",
|
||||
"subIncyGroupRouting": "Định tuyến"
|
||||
"subIncyGroupRouting": "Định tuyến",
|
||||
"subHostSortMode": "Thứ tự nút đăng ký",
|
||||
"subHostSortInbound": "Thứ tự kết nối vào (mặc định)",
|
||||
"subHostSortHosts": "Thứ tự máy chủ",
|
||||
"subHostSortModeDesc": "Theo thứ tự trên trang Máy chủ giữa các kết nối vào. Thiết lập này quyết định thứ tự do máy chủ xuất ra; ứng dụng khách có thể tự sắp xếp lại."
|
||||
},
|
||||
"xray": {
|
||||
"importRules": "Nhập quy tắc",
|
||||
@@ -2465,7 +2469,8 @@
|
||||
"delete": "Xóa host",
|
||||
"badTag": "Tag không hợp lệ",
|
||||
"badVlessRoute": "Nhập một số duy nhất từ 0 đến 65535"
|
||||
}
|
||||
},
|
||||
"dragToReorder": "Kéo để sắp xếp lại {name}"
|
||||
},
|
||||
"sponsors": {
|
||||
"title": "Nhà tài trợ",
|
||||
|
||||
@@ -1826,7 +1826,11 @@
|
||||
"subIncyResolveDnsDomainDesc": "DoH 服务器 URL,通常以 /dns-query 结尾。",
|
||||
"subIncyResolveDnsIp": "DoH 服务器 IP",
|
||||
"subIncyResolveDnsIpDesc": "用于引导的 DoH 服务器 IP。",
|
||||
"subIncyGroupRouting": "路由"
|
||||
"subIncyGroupRouting": "路由",
|
||||
"subHostSortMode": "订阅节点排序",
|
||||
"subHostSortInbound": "入站顺序(默认)",
|
||||
"subHostSortHosts": "主机顺序",
|
||||
"subHostSortModeDesc": "跨入站使用主机页面中的排序。此设置决定服务器输出顺序;客户端应用仍可能自行排序。"
|
||||
},
|
||||
"xray": {
|
||||
"importRules": "导入规则",
|
||||
@@ -2465,7 +2469,8 @@
|
||||
"delete": "删除主机",
|
||||
"badTag": "无效的标签",
|
||||
"badVlessRoute": "请输入 0 到 65535 之间的单个数字"
|
||||
}
|
||||
},
|
||||
"dragToReorder": "拖动以重新排列 {name}"
|
||||
},
|
||||
"sponsors": {
|
||||
"title": "赞助商",
|
||||
|
||||
@@ -1826,7 +1826,11 @@
|
||||
"subIncyResolveDnsDomainDesc": "DoH 伺服器 URL,通常以 /dns-query 結尾。",
|
||||
"subIncyResolveDnsIp": "DoH 伺服器 IP",
|
||||
"subIncyResolveDnsIpDesc": "用於引導的 DoH 伺服器 IP。",
|
||||
"subIncyGroupRouting": "路由"
|
||||
"subIncyGroupRouting": "路由",
|
||||
"subHostSortMode": "訂閱節點排序",
|
||||
"subHostSortInbound": "入站順序(預設)",
|
||||
"subHostSortHosts": "主機順序",
|
||||
"subHostSortModeDesc": "跨入站使用主機頁面中的排序。此設定決定伺服器輸出順序;用戶端應用程式仍可能自行排序。"
|
||||
},
|
||||
"xray": {
|
||||
"save": "儲存",
|
||||
@@ -2465,7 +2469,8 @@
|
||||
"delete": "刪除 Host",
|
||||
"badTag": "無效的標籤",
|
||||
"badVlessRoute": "請輸入 0 到 65535 之間的單一數字"
|
||||
}
|
||||
},
|
||||
"dragToReorder": "拖曳以重新排列 {name}"
|
||||
},
|
||||
"sponsors": {
|
||||
"title": "贊助商",
|
||||
|
||||
Reference in New Issue
Block a user