mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-30 19:22:10 +03:00
1110caaa65
Invariant: saving an inbound's configuration never changes a client's enable, expiry, quota or renewal state, nor the inbound's own traffic counters. The inbound modal posts back the whole settings.clients list it loaded when it opened, and UpdateInbound stored it, synced it into the client records and wrote it into client_traffics. Any client renewed, depleted or reset while the modal was open was reverted - a renewed client came back disabled with its old expiry. The row itself was read before the serialized tx and saved whole, so traffic the poll added in between was rolled back and a depleted inbound could be re-enabled. UpdateInbound now re-reads the row inside the writer and, for clients the inbound already holds, keeps enable, expiryTime, totalGB, reset, resetDay, resetWeekday and resetMax from it; those change through the client endpoints. A master's node-sync push stays authoritative. Existing clients' lifecycle fields are no longer editable through the inbound JSON editor or /inbounds/update, which the API docs now state.
187 lines
5.0 KiB
Go
187 lines
5.0 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"reflect"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// commitInboundClientSettings writes a client op's edit (base → ib.Settings) onto
|
|
// the settings committed now: a traffic tick after the op's read must survive.
|
|
func commitInboundClientSettings(tx *gorm.DB, ib *model.Inbound, base string) error {
|
|
var current []string
|
|
if err := tx.Model(&model.Inbound{}).Where("id = ?", ib.Id).Pluck("settings", ¤t).Error; err != nil {
|
|
return err
|
|
}
|
|
if len(current) == 1 && current[0] != base {
|
|
merged, err := rebaseClientSettings(base, ib.Settings, current[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ib.Settings = merged
|
|
}
|
|
return tx.Model(&model.Inbound{}).Where("id = ?", ib.Id).Update("settings", ib.Settings).Error
|
|
}
|
|
|
|
// rebaseClientSettings three-way merges settings JSON: every key and client field
|
|
// ours left as base had it takes current's value; clients are matched by email.
|
|
func rebaseClientSettings(base, ours, current string) (string, error) {
|
|
var baseM, oursM, curM map[string]any
|
|
for _, p := range []struct {
|
|
raw string
|
|
dst *map[string]any
|
|
}{{base, &baseM}, {ours, &oursM}, {current, &curM}} {
|
|
if err := json.Unmarshal([]byte(p.raw), p.dst); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
out := mergeFields(baseM, oursM, curM)
|
|
baseClients, _ := baseM["clients"].([]any)
|
|
oursClients, _ := oursM["clients"].([]any)
|
|
curClients, _ := curM["clients"].([]any)
|
|
if _, has := oursM["clients"]; has {
|
|
out["clients"] = mergeClientLists(baseClients, oursClients, curClients)
|
|
}
|
|
b, err := json.MarshalIndent(out, "", " ")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(b), nil
|
|
}
|
|
|
|
func mergeFields(base, ours, current map[string]any) map[string]any {
|
|
out := make(map[string]any, len(current)+len(ours))
|
|
for k, v := range current {
|
|
out[k] = v
|
|
}
|
|
keys := make(map[string]struct{}, len(base)+len(ours))
|
|
for k := range base {
|
|
keys[k] = struct{}{}
|
|
}
|
|
for k := range ours {
|
|
keys[k] = struct{}{}
|
|
}
|
|
for k := range keys {
|
|
bv, inBase := base[k]
|
|
ov, inOurs := ours[k]
|
|
if inBase == inOurs && reflect.DeepEqual(bv, ov) {
|
|
continue
|
|
}
|
|
if inOurs {
|
|
out[k] = ov
|
|
} else {
|
|
delete(out, k)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func clientEntryEmail(entry any) (map[string]any, string) {
|
|
m, ok := entry.(map[string]any)
|
|
if !ok {
|
|
return nil, ""
|
|
}
|
|
email, _ := m["email"].(string)
|
|
return m, email
|
|
}
|
|
|
|
func indexClientsByEmail(list []any) map[string]map[string]any {
|
|
out := make(map[string]map[string]any, len(list))
|
|
for _, entry := range list {
|
|
if m, email := clientEntryEmail(entry); email != "" {
|
|
out[email] = m
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// mergeClientLists keeps ours' order. A client ours removed stays removed; one a
|
|
// concurrent writer added or removed keeps that change unless ours edited it.
|
|
func mergeClientLists(base, ours, current []any) []any {
|
|
baseBy := indexClientsByEmail(base)
|
|
curBy := indexClientsByEmail(current)
|
|
out := make([]any, 0, len(ours)+len(current))
|
|
placed := make(map[string]struct{}, len(ours))
|
|
for _, entry := range ours {
|
|
o, email := clientEntryEmail(entry)
|
|
if email == "" {
|
|
out = append(out, entry)
|
|
continue
|
|
}
|
|
placed[email] = struct{}{}
|
|
b, inBase := baseBy[email]
|
|
c, inCur := curBy[email]
|
|
switch {
|
|
case !inBase:
|
|
out = append(out, o)
|
|
case !inCur:
|
|
if !reflect.DeepEqual(b, o) {
|
|
out = append(out, o)
|
|
}
|
|
default:
|
|
out = append(out, mergeFields(b, o, c))
|
|
}
|
|
}
|
|
for _, entry := range current {
|
|
c, email := clientEntryEmail(entry)
|
|
if email == "" {
|
|
continue
|
|
}
|
|
if _, done := placed[email]; done {
|
|
continue
|
|
}
|
|
if _, inBase := baseBy[email]; !inBase {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Client limits and state the inbound form never owns: the client endpoints and
|
|
// traffic jobs change them, so a form opened earlier must not write them back.
|
|
var storedClientLifecycleKeys = []string{"enable", "expiryTime", "totalGB", "reset", "resetDay", "resetWeekday", "resetMax"}
|
|
|
|
// keepStoredClientLifecycle copies those keys from the stored settings onto every
|
|
// payload client the inbound already holds; new clients keep what they carry.
|
|
func keepStoredClientLifecycle(payload, stored string) string {
|
|
var payloadM, storedM map[string]any
|
|
if json.Unmarshal([]byte(payload), &payloadM) != nil || json.Unmarshal([]byte(stored), &storedM) != nil {
|
|
return payload
|
|
}
|
|
storedClients, _ := storedM["clients"].([]any)
|
|
storedBy := indexClientsByEmail(storedClients)
|
|
payloadClients, _ := payloadM["clients"].([]any)
|
|
changed := false
|
|
for _, entry := range payloadClients {
|
|
p, email := clientEntryEmail(entry)
|
|
s, known := storedBy[email]
|
|
if !known {
|
|
continue
|
|
}
|
|
for _, key := range storedClientLifecycleKeys {
|
|
sv, inStored := s[key]
|
|
pv, inPayload := p[key]
|
|
if inStored == inPayload && reflect.DeepEqual(sv, pv) {
|
|
continue
|
|
}
|
|
changed = true
|
|
if inStored {
|
|
p[key] = sv
|
|
} else {
|
|
delete(p, key)
|
|
}
|
|
}
|
|
}
|
|
if !changed {
|
|
return payload
|
|
}
|
|
b, err := json.MarshalIndent(payloadM, "", " ")
|
|
if err != nil {
|
|
return payload
|
|
}
|
|
return string(b)
|
|
}
|