mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-13 06:40:59 +00:00
feat(clients): orphan cleanup + export/import via CodeMirror modals
Add three client-management actions to the Clients page More menu:
- Delete unattached clients: removes every client with no inbound
attachment, cascading its traffic rows, IP log, and external links
(POST /clients/delOrphans).
- Export clients: shows the {client, inboundIds} list in a read-only
CodeMirror viewer with copy/download (GET /clients/export returns the
array in the standard envelope).
- Import clients: pastes that JSON into an editable CodeMirror editor,
mirroring Import an Inbound (POST /clients/import takes a { data }
body). Attached clients go through the create-and-attach path; items
with no inboundIds are restored as bare records; existing emails are
never overwritten and are reported as skipped.
Document the new endpoints in api-docs and translate the new strings
into all supported languages.
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ExportAll returns every client in the same {client, inboundIds} shape that
|
||||
// /add and /bulkCreate accept, so an exported file round-trips straight back
|
||||
// through Import. Clients with no inbound attachment are included with an empty
|
||||
// inboundIds list so an export taken before DeleteOrphans can restore them.
|
||||
func (s *ClientService) ExportAll() ([]ClientCreatePayload, error) {
|
||||
db := database.GetDB()
|
||||
var rows []model.ClientRecord
|
||||
if err := db.Order("id ASC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ClientCreatePayload, 0, len(rows))
|
||||
if len(rows) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
ids := make([]int, 0, len(rows))
|
||||
for i := range rows {
|
||||
ids = append(ids, rows[i].Id)
|
||||
}
|
||||
|
||||
attachments := make(map[int][]int, len(rows))
|
||||
for _, batch := range chunkInts(ids, sqlInChunk) {
|
||||
var links []model.ClientInbound
|
||||
if err := db.Where("client_id IN ?", batch).Order("inbound_id ASC").Find(&links).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, l := range links {
|
||||
attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId)
|
||||
}
|
||||
}
|
||||
|
||||
for i := range rows {
|
||||
client := rows[i].ToClient()
|
||||
// The per-inbound flow_override is the reliable flow for multi-inbound
|
||||
// clients; the canonical column can be left stale by SyncInbound (#4792).
|
||||
if flow, err := s.EffectiveFlow(db, rows[i].Id); err == nil && flow != "" {
|
||||
client.Flow = flow
|
||||
}
|
||||
out = append(out, ClientCreatePayload{
|
||||
Client: *client,
|
||||
InboundIds: attachments[rows[i].Id],
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ImportClients recreates clients from an exported list. Items that carry
|
||||
// inboundIds go through the normal BulkCreate path (added to every inbound and
|
||||
// pushed to xray); items with no inboundIds are restored as bare records so an
|
||||
// orphan-inclusive export round-trips. Existing emails are never overwritten —
|
||||
// they are reported in Skipped. The boolean reports whether xray needs a restart.
|
||||
func (s *ClientService) ImportClients(inboundSvc *InboundService, items []ClientCreatePayload) (BulkCreateResult, bool, error) {
|
||||
result := BulkCreateResult{}
|
||||
if len(items) == 0 {
|
||||
return result, false, nil
|
||||
}
|
||||
|
||||
attached := make([]ClientCreatePayload, 0, len(items))
|
||||
orphans := make([]ClientCreatePayload, 0)
|
||||
for i := range items {
|
||||
if len(items[i].InboundIds) > 0 {
|
||||
attached = append(attached, items[i])
|
||||
} else {
|
||||
orphans = append(orphans, items[i])
|
||||
}
|
||||
}
|
||||
|
||||
skip := func(email, reason string) {
|
||||
if strings.TrimSpace(email) == "" {
|
||||
email = "(missing email)"
|
||||
}
|
||||
result.Skipped = append(result.Skipped, BulkCreateReport{Email: email, Reason: reason})
|
||||
}
|
||||
|
||||
needRestart := false
|
||||
if len(attached) > 0 {
|
||||
sub, nr, err := s.BulkCreate(inboundSvc, attached)
|
||||
if err != nil {
|
||||
return result, needRestart, err
|
||||
}
|
||||
needRestart = needRestart || nr
|
||||
result.Created += sub.Created
|
||||
result.Skipped = append(result.Skipped, sub.Skipped...)
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
for i := range orphans {
|
||||
client := orphans[i].Client
|
||||
email := strings.TrimSpace(client.Email)
|
||||
if email == "" {
|
||||
skip("", "client email is required")
|
||||
continue
|
||||
}
|
||||
if verr := validateClientEmail(email); verr != nil {
|
||||
skip(email, verr.Error())
|
||||
continue
|
||||
}
|
||||
if verr := validateClientSubID(client.SubID); verr != nil {
|
||||
skip(email, verr.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
// An existing record (in the DB or just created from the attached set
|
||||
// above) always wins — import never clobbers a live client.
|
||||
var taken int64
|
||||
if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&taken).Error; err != nil {
|
||||
return result, needRestart, err
|
||||
}
|
||||
if taken > 0 {
|
||||
skip(email, "email already in use: "+email)
|
||||
continue
|
||||
}
|
||||
|
||||
client.Email = email
|
||||
if client.SubID == "" {
|
||||
client.SubID = uuid.NewString()
|
||||
}
|
||||
if client.SubID != "" {
|
||||
var subTaken int64
|
||||
if err := db.Model(&model.ClientRecord{}).
|
||||
Where("sub_id = ? AND email <> ?", client.SubID, email).
|
||||
Count(&subTaken).Error; err != nil {
|
||||
return result, needRestart, err
|
||||
}
|
||||
if subTaken > 0 {
|
||||
skip(email, "subId already in use: "+client.SubID)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if !client.Enable {
|
||||
client.Enable = true
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
if client.CreatedAt == 0 {
|
||||
client.CreatedAt = now
|
||||
}
|
||||
client.UpdatedAt = now
|
||||
|
||||
if err := db.Create(client.ToRecord()).Error; err != nil {
|
||||
skip(email, err.Error())
|
||||
continue
|
||||
}
|
||||
result.Created++
|
||||
}
|
||||
|
||||
return result, needRestart, nil
|
||||
}
|
||||
|
||||
// DeleteOrphans removes every client that is not attached to any inbound,
|
||||
// together with its traffic rows, IP log, and external links. It mirrors the
|
||||
// cleanup the single-client Delete performs, batched into one transaction.
|
||||
// Returns the number of clients deleted.
|
||||
func (s *ClientService) DeleteOrphans() (int, error) {
|
||||
db := database.GetDB()
|
||||
sub := database.GetDB().Table("client_inbounds").Select("client_id")
|
||||
var rows []model.ClientRecord
|
||||
if err := db.Where("id NOT IN (?)", sub).Order("id ASC").Find(&rows).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
ids := make([]int, 0, len(rows))
|
||||
emails := make([]string, 0, len(rows))
|
||||
for i := range rows {
|
||||
ids = append(ids, rows[i].Id)
|
||||
if rows[i].Email != "" {
|
||||
emails = append(emails, rows[i].Email)
|
||||
}
|
||||
}
|
||||
tombstoneClientEmails(emails)
|
||||
|
||||
if err := runSerializedTx(func(tx *gorm.DB) error {
|
||||
for _, batch := range chunkInts(ids, sqlInChunk) {
|
||||
if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientExternalLink{}).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
if len(emails) > 0 {
|
||||
for _, batch := range chunkStrings(emails, sqlInChunk) {
|
||||
if e := tx.Where("email IN ?", batch).Delete(&xray.ClientTraffic{}).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if e := tx.Where("client_email IN ?", batch).Delete(&model.InboundClientIps{}).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
if e := clearGlobalTraffic(tx, emails...); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
for _, batch := range chunkInts(ids, sqlInChunk) {
|
||||
if e := tx.Where("id IN ?", batch).Delete(&model.ClientRecord{}).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(ids), nil
|
||||
}
|
||||
Reference in New Issue
Block a user