diff --git a/database/db.go b/database/db.go index 89d4106b..78446ca3 100644 --- a/database/db.go +++ b/database/db.go @@ -4,6 +4,7 @@ package database import ( "bytes" + "encoding/json" "errors" "io" "log" @@ -42,6 +43,9 @@ func initModels() error { &model.CustomGeoResource{}, &model.Node{}, &model.ApiToken{}, + &model.ClientRecord{}, + &model.ClientInbound{}, + &model.InboundFallbackChild{}, } for _, mdl := range models { if err := db.AutoMigrate(mdl); err != nil { @@ -157,9 +161,91 @@ func runSeeders(isUsersEmpty bool) error { return err } } + + if !slices.Contains(seedersHistory, "ClientsTable") { + if err := seedClientsFromInboundJSON(); err != nil { + return err + } + } return nil } +func seedClientsFromInboundJSON() error { + var inbounds []model.Inbound + if err := db.Find(&inbounds).Error; err != nil { + return err + } + + return db.Transaction(func(tx *gorm.DB) error { + byEmail := map[string]*model.ClientRecord{} + + for _, inbound := range inbounds { + if strings.TrimSpace(inbound.Settings) == "" { + continue + } + var settings map[string]any + if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil { + log.Printf("ClientsTable seed: skip inbound %d (invalid settings json): %v", inbound.Id, err) + continue + } + rawList, ok := settings["clients"].([]any) + if !ok { + continue + } + + for _, raw := range rawList { + obj, ok := raw.(map[string]any) + if !ok { + continue + } + blob, err := json.Marshal(obj) + if err != nil { + continue + } + var c model.Client + if err := json.Unmarshal(blob, &c); err != nil { + continue + } + email := strings.TrimSpace(c.Email) + if email == "" { + continue + } + incoming := c.ToRecord() + + row, dup := byEmail[email] + if !dup { + if err := tx.Create(incoming).Error; err != nil { + return err + } + byEmail[email] = incoming + row = incoming + } else { + conflicts := model.MergeClientRecord(row, incoming) + for _, x := range conflicts { + log.Printf("client merge: email=%s conflict on %s old=%v new=%v kept=%v", + email, x.Field, x.Old, x.New, x.Kept) + } + if err := tx.Save(row).Error; err != nil { + return err + } + } + + link := model.ClientInbound{ + ClientId: row.Id, + InboundId: inbound.Id, + FlowOverride: c.Flow, + } + if err := tx.Where("client_id = ? AND inbound_id = ?", row.Id, inbound.Id). + FirstOrCreate(&link).Error; err != nil { + return err + } + } + } + + return tx.Create(&model.HistoryOfSeeders{SeederName: "ClientsTable"}).Error + }) +} + // seedApiTokens copies the legacy `apiToken` setting into the new // api_tokens table as a row named "default" so existing central panels // keep working after the upgrade. Idempotent — records itself in diff --git a/database/model/model.go b/database/model/model.go index d71e0589..2a15e22d 100644 --- a/database/model/model.go +++ b/database/model/model.go @@ -2,6 +2,7 @@ package model import ( + "encoding/json" "fmt" "github.com/mhsanaei/3x-ui/v3/util/json_util" @@ -191,3 +192,228 @@ type Client struct { CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp } + +type ClientRecord struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` + Email string `json:"email" gorm:"uniqueIndex;not null"` + SubID string `json:"subId" gorm:"index;column:sub_id"` + UUID string `json:"uuid" gorm:"column:uuid"` + Password string `json:"password"` + Auth string `json:"auth"` + Flow string `json:"flow"` + Security string `json:"security"` + Reverse string `json:"reverse" gorm:"column:reverse"` + LimitIP int `json:"limitIp" gorm:"column:limit_ip"` + TotalGB int64 `json:"totalGB" gorm:"column:total_gb"` + ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"` + Enable bool `json:"enable" gorm:"default:true"` + TgID int64 `json:"tgId" gorm:"column:tg_id"` + Comment string `json:"comment"` + Reset int `json:"reset" gorm:"default:0"` + CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"` + UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"` +} + +func (ClientRecord) TableName() string { return "clients" } + +type ClientInbound struct { + ClientId int `json:"clientId" gorm:"primaryKey;column:client_id;index"` + InboundId int `json:"inboundId" gorm:"primaryKey;column:inbound_id;index"` + FlowOverride string `json:"flowOverride" gorm:"column:flow_override"` + CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"` +} + +func (ClientInbound) TableName() string { return "client_inbounds" } + +type InboundFallbackChild struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` + MasterId int `json:"masterId" gorm:"index;not null;column:master_id"` + ChildId int `json:"childId" gorm:"index;not null;column:child_id"` + Name string `json:"name"` + Alpn string `json:"alpn"` + Path string `json:"path"` + Xver int `json:"xver"` + SortOrder int `json:"sortOrder" gorm:"default:0;column:sort_order"` +} + +func (InboundFallbackChild) TableName() string { return "inbound_fallback_children" } + +func (c *Client) ToRecord() *ClientRecord { + rec := &ClientRecord{ + Email: c.Email, + SubID: c.SubID, + UUID: c.ID, + Password: c.Password, + Auth: c.Auth, + Flow: c.Flow, + Security: c.Security, + LimitIP: c.LimitIP, + TotalGB: c.TotalGB, + ExpiryTime: c.ExpiryTime, + Enable: c.Enable, + TgID: c.TgID, + Comment: c.Comment, + Reset: c.Reset, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + } + if c.Reverse != nil { + if b, err := json.Marshal(c.Reverse); err == nil { + rec.Reverse = string(b) + } + } + return rec +} + +func (r *ClientRecord) ToClient() *Client { + c := &Client{ + ID: r.UUID, + Email: r.Email, + SubID: r.SubID, + Password: r.Password, + Auth: r.Auth, + Flow: r.Flow, + Security: r.Security, + LimitIP: r.LimitIP, + TotalGB: r.TotalGB, + ExpiryTime: r.ExpiryTime, + Enable: r.Enable, + TgID: r.TgID, + Comment: r.Comment, + Reset: r.Reset, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } + if r.Reverse != "" { + var rev ClientReverse + if err := json.Unmarshal([]byte(r.Reverse), &rev); err == nil { + c.Reverse = &rev + } + } + return c +} + +type ClientMergeConflict struct { + Field string + Old any + New any + Kept any +} + +func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientMergeConflict { + var conflicts []ClientMergeConflict + keep := func(field string, oldV, newV, kept any) { + conflicts = append(conflicts, ClientMergeConflict{Field: field, Old: oldV, New: newV, Kept: kept}) + } + + incomingNewer := incoming.UpdatedAt > existing.UpdatedAt || + (incoming.UpdatedAt == existing.UpdatedAt && incoming.CreatedAt > existing.CreatedAt) + + if existing.UUID != incoming.UUID && incoming.UUID != "" { + if incomingNewer || existing.UUID == "" { + keep("uuid", existing.UUID, incoming.UUID, incoming.UUID) + existing.UUID = incoming.UUID + } else { + keep("uuid", existing.UUID, incoming.UUID, existing.UUID) + } + } + if existing.Password != incoming.Password && incoming.Password != "" { + if incomingNewer || existing.Password == "" { + keep("password", existing.Password, incoming.Password, incoming.Password) + existing.Password = incoming.Password + } + } + if existing.Auth != incoming.Auth && incoming.Auth != "" { + if incomingNewer || existing.Auth == "" { + keep("auth", existing.Auth, incoming.Auth, incoming.Auth) + existing.Auth = incoming.Auth + } + } + if existing.Flow != incoming.Flow && incoming.Flow != "" { + if incomingNewer || existing.Flow == "" { + keep("flow", existing.Flow, incoming.Flow, incoming.Flow) + existing.Flow = incoming.Flow + } + } + if existing.Security != incoming.Security && incoming.Security != "" { + if incomingNewer || existing.Security == "" { + keep("security", existing.Security, incoming.Security, incoming.Security) + existing.Security = incoming.Security + } + } + if existing.SubID != incoming.SubID && incoming.SubID != "" { + if incomingNewer || existing.SubID == "" { + keep("subId", existing.SubID, incoming.SubID, incoming.SubID) + existing.SubID = incoming.SubID + } + } + if existing.TotalGB != incoming.TotalGB { + picked := existing.TotalGB + if existing.TotalGB == 0 || (incoming.TotalGB != 0 && incoming.TotalGB > existing.TotalGB) { + picked = incoming.TotalGB + } + if picked != existing.TotalGB { + keep("totalGB", existing.TotalGB, incoming.TotalGB, picked) + existing.TotalGB = picked + } + } + if existing.ExpiryTime != incoming.ExpiryTime { + picked := existing.ExpiryTime + if existing.ExpiryTime == 0 || (incoming.ExpiryTime != 0 && incoming.ExpiryTime > existing.ExpiryTime) { + picked = incoming.ExpiryTime + } + if picked != existing.ExpiryTime { + keep("expiryTime", existing.ExpiryTime, incoming.ExpiryTime, picked) + existing.ExpiryTime = picked + } + } + if existing.LimitIP != incoming.LimitIP && incoming.LimitIP != 0 { + picked := existing.LimitIP + if existing.LimitIP == 0 || incoming.LimitIP > existing.LimitIP { + picked = incoming.LimitIP + } + if picked != existing.LimitIP { + keep("limitIp", existing.LimitIP, incoming.LimitIP, picked) + existing.LimitIP = picked + } + } + if existing.TgID != incoming.TgID && incoming.TgID != 0 { + if incomingNewer || existing.TgID == 0 { + keep("tgId", existing.TgID, incoming.TgID, incoming.TgID) + existing.TgID = incoming.TgID + } + } + if existing.Reset != incoming.Reset && incoming.Reset != 0 { + if incomingNewer || existing.Reset == 0 { + keep("reset", existing.Reset, incoming.Reset, incoming.Reset) + existing.Reset = incoming.Reset + } + } + if existing.Reverse != incoming.Reverse && incoming.Reverse != "" { + if incomingNewer || existing.Reverse == "" { + keep("reverse", existing.Reverse, incoming.Reverse, incoming.Reverse) + existing.Reverse = incoming.Reverse + } + } + if existing.Comment != incoming.Comment && incoming.Comment != "" { + if incomingNewer || existing.Comment == "" { + keep("comment", existing.Comment, incoming.Comment, incoming.Comment) + existing.Comment = incoming.Comment + } + } + if existing.Enable != incoming.Enable { + if incoming.Enable { + if !existing.Enable { + keep("enable", existing.Enable, incoming.Enable, true) + existing.Enable = true + } + } + } + if incoming.CreatedAt != 0 && (existing.CreatedAt == 0 || incoming.CreatedAt < existing.CreatedAt) { + existing.CreatedAt = incoming.CreatedAt + } + if incoming.UpdatedAt > existing.UpdatedAt { + existing.UpdatedAt = incoming.UpdatedAt + } + return conflicts +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7da007d9..af331712 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -334,9 +334,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", - "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -895,9 +895,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", - "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -944,13 +944,13 @@ "license": "MIT" }, "node_modules/@vitejs/plugin-vue": { - "version": "6.0.6", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz", - "integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==", + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz", + "integrity": "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-rc.13" + "@rolldown/pluginutils": "^1.0.1" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -1477,16 +1477,16 @@ } }, "node_modules/eslint": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz", - "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz", + "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.5.5", + "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", @@ -2694,9 +2694,9 @@ } }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -2748,13 +2748,6 @@ "@rolldown/binding-win32-x64-msvc": "1.0.1" } }, - "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, "node_modules/scroll-into-view-if-needed": { "version": "2.2.31", "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",