3x-ui/web/service/client.go

616 lines
15 KiB
Go
Raw Normal View History

package service
import (
"encoding/json"
"errors"
"strings"
"time"
"github.com/google/uuid"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/util/common"
"github.com/mhsanaei/3x-ui/v3/xray"
"gorm.io/gorm"
)
type ClientWithAttachments struct {
model.ClientRecord
InboundIds []int `json:"inboundIds"`
Traffic *xray.ClientTraffic `json:"traffic,omitempty"`
}
func clientKeyForProtocol(p model.Protocol, rec *model.ClientRecord) string {
if rec == nil {
return ""
}
switch p {
case model.Trojan:
return rec.Password
case model.Shadowsocks:
return rec.Email
case model.Hysteria, model.Hysteria2:
return rec.Auth
default:
return rec.UUID
}
}
type ClientService struct{}
func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.Client) error {
if tx == nil {
tx = database.GetDB()
}
if err := tx.Where("inbound_id = ?", inboundId).Delete(&model.ClientInbound{}).Error; err != nil {
return err
}
for i := range clients {
c := clients[i]
email := strings.TrimSpace(c.Email)
if email == "" {
continue
}
incoming := c.ToRecord()
row := &model.ClientRecord{}
err := tx.Where("email = ?", email).First(row).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if errors.Is(err, gorm.ErrRecordNotFound) {
if err := tx.Create(incoming).Error; err != nil {
return err
}
row = incoming
} else {
row.UUID = incoming.UUID
row.Password = incoming.Password
row.Auth = incoming.Auth
row.Flow = incoming.Flow
row.Security = incoming.Security
row.Reverse = incoming.Reverse
row.SubID = incoming.SubID
row.LimitIP = incoming.LimitIP
row.TotalGB = incoming.TotalGB
row.ExpiryTime = incoming.ExpiryTime
row.Enable = incoming.Enable
row.TgID = incoming.TgID
row.Comment = incoming.Comment
row.Reset = incoming.Reset
if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) {
row.CreatedAt = incoming.CreatedAt
}
if incoming.UpdatedAt > row.UpdatedAt {
row.UpdatedAt = incoming.UpdatedAt
}
if err := tx.Save(row).Error; err != nil {
return err
}
}
link := model.ClientInbound{
ClientId: row.Id,
InboundId: inboundId,
FlowOverride: c.Flow,
}
if err := tx.Create(&link).Error; err != nil {
return err
}
}
return nil
}
func (s *ClientService) DetachInbound(tx *gorm.DB, inboundId int) error {
if tx == nil {
tx = database.GetDB()
}
return tx.Where("inbound_id = ?", inboundId).Delete(&model.ClientInbound{}).Error
}
func (s *ClientService) ListForInbound(tx *gorm.DB, inboundId int) ([]model.Client, error) {
if tx == nil {
tx = database.GetDB()
}
type joinedRow struct {
model.ClientRecord
FlowOverride string
}
var rows []joinedRow
err := tx.Table("clients").
Select("clients.*, client_inbounds.flow_override AS flow_override").
Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
Where("client_inbounds.inbound_id = ?", inboundId).
Order("clients.id ASC").
Find(&rows).Error
if err != nil {
return nil, err
}
out := make([]model.Client, 0, len(rows))
for i := range rows {
c := rows[i].ToClient()
if rows[i].FlowOverride != "" {
c.Flow = rows[i].FlowOverride
}
out = append(out, *c)
}
return out, nil
}
func (s *ClientService) GetRecordByEmail(tx *gorm.DB, email string) (*model.ClientRecord, error) {
if tx == nil {
tx = database.GetDB()
}
row := &model.ClientRecord{}
err := tx.Where("email = ?", email).First(row).Error
if err != nil {
return nil, err
}
return row, nil
}
func (s *ClientService) GetInboundIdsForEmail(tx *gorm.DB, email string) ([]int, error) {
if tx == nil {
tx = database.GetDB()
}
var ids []int
err := tx.Table("client_inbounds").
Select("client_inbounds.inbound_id").
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
Where("clients.email = ?", email).
Scan(&ids).Error
if err != nil {
return nil, err
}
return ids, nil
}
func (s *ClientService) GetByID(id int) (*model.ClientRecord, error) {
row := &model.ClientRecord{}
if err := database.GetDB().Where("id = ?", id).First(row).Error; err != nil {
return nil, err
}
return row, nil
}
func (s *ClientService) GetInboundIdsForRecord(id int) ([]int, error) {
var ids []int
err := database.GetDB().Table("client_inbounds").
Where("client_id = ?", id).
Order("inbound_id ASC").
Pluck("inbound_id", &ids).Error
if err != nil {
return nil, err
}
return ids, nil
}
func (s *ClientService) List() ([]ClientWithAttachments, error) {
db := database.GetDB()
var rows []model.ClientRecord
if err := db.Order("id ASC").Find(&rows).Error; err != nil {
return nil, err
}
if len(rows) == 0 {
return []ClientWithAttachments{}, nil
}
clientIds := make([]int, 0, len(rows))
emails := make([]string, 0, len(rows))
for i := range rows {
clientIds = append(clientIds, rows[i].Id)
if rows[i].Email != "" {
emails = append(emails, rows[i].Email)
}
}
var links []model.ClientInbound
if err := db.Where("client_id IN ?", clientIds).Find(&links).Error; err != nil {
return nil, err
}
attachments := make(map[int][]int, len(rows))
for _, l := range links {
attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId)
}
trafficByEmail := make(map[string]*xray.ClientTraffic, len(emails))
if len(emails) > 0 {
var stats []xray.ClientTraffic
if err := db.Where("email IN ?", emails).Find(&stats).Error; err != nil {
return nil, err
}
for i := range stats {
trafficByEmail[stats[i].Email] = &stats[i]
}
}
out := make([]ClientWithAttachments, 0, len(rows))
for i := range rows {
out = append(out, ClientWithAttachments{
ClientRecord: rows[i],
InboundIds: attachments[rows[i].Id],
Traffic: trafficByEmail[rows[i].Email],
})
}
return out, nil
}
type ClientCreatePayload struct {
Client model.Client `json:"client"`
InboundIds []int `json:"inboundIds"`
}
func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreatePayload) (bool, error) {
if payload == nil {
return false, common.NewError("empty payload")
}
client := payload.Client
if strings.TrimSpace(client.Email) == "" {
return false, common.NewError("client email is required")
}
if len(payload.InboundIds) == 0 {
return false, common.NewError("at least one inbound is required")
}
if client.SubID == "" {
client.SubID = uuid.NewString()
}
if !client.Enable {
client.Enable = true
}
now := time.Now().UnixMilli()
if client.CreatedAt == 0 {
client.CreatedAt = now
}
client.UpdatedAt = now
existing := &model.ClientRecord{}
err := database.GetDB().Where("email = ?", client.Email).First(existing).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return false, err
}
emailTaken := !errors.Is(err, gorm.ErrRecordNotFound)
if emailTaken {
if existing.SubID == "" || existing.SubID != client.SubID {
return false, common.NewError("email already in use:", client.Email)
}
}
needRestart := false
for _, ibId := range payload.InboundIds {
inbound, getErr := inboundSvc.GetInbound(ibId)
if getErr != nil {
return needRestart, getErr
}
if err := s.fillProtocolDefaults(&client, inbound.Protocol); err != nil {
return needRestart, err
}
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {client}})
if mErr != nil {
return needRestart, mErr
}
nr, addErr := inboundSvc.AddInboundClient(&model.Inbound{
Id: ibId,
Settings: string(settingsPayload),
})
if addErr != nil {
return needRestart, addErr
}
if nr {
needRestart = true
}
}
return needRestart, nil
}
func (s *ClientService) fillProtocolDefaults(c *model.Client, p model.Protocol) error {
switch p {
case model.VMESS, model.VLESS:
if c.ID == "" {
c.ID = uuid.NewString()
}
case model.Trojan, model.Shadowsocks:
if c.Password == "" {
c.Password = strings.ReplaceAll(uuid.NewString(), "-", "")
}
case model.Hysteria, model.Hysteria2:
if c.Auth == "" {
c.Auth = strings.ReplaceAll(uuid.NewString(), "-", "")
}
}
return nil
}
func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client) (bool, error) {
existing, err := s.GetByID(id)
if err != nil {
return false, err
}
inboundIds, err := s.GetInboundIdsForRecord(id)
if err != nil {
return false, err
}
if strings.TrimSpace(updated.Email) == "" {
return false, common.NewError("client email is required")
}
if updated.SubID == "" {
updated.SubID = existing.SubID
}
if updated.SubID == "" {
updated.SubID = uuid.NewString()
}
updated.UpdatedAt = time.Now().UnixMilli()
if updated.CreatedAt == 0 {
updated.CreatedAt = existing.CreatedAt
}
needRestart := false
for _, ibId := range inboundIds {
inbound, getErr := inboundSvc.GetInbound(ibId)
if getErr != nil {
return needRestart, getErr
}
oldKey := clientKeyForProtocol(inbound.Protocol, existing)
if oldKey == "" {
continue
}
if err := s.fillProtocolDefaults(&updated, inbound.Protocol); err != nil {
return needRestart, err
}
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {updated}})
if mErr != nil {
return needRestart, mErr
}
nr, upErr := inboundSvc.UpdateInboundClient(&model.Inbound{
Id: ibId,
Settings: string(settingsPayload),
}, oldKey)
if upErr != nil {
return needRestart, upErr
}
if nr {
needRestart = true
}
}
return needRestart, nil
}
func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic bool) (bool, error) {
existing, err := s.GetByID(id)
if err != nil {
return false, err
}
inboundIds, err := s.GetInboundIdsForRecord(id)
if err != nil {
return false, err
}
needRestart := false
for _, ibId := range inboundIds {
inbound, getErr := inboundSvc.GetInbound(ibId)
if getErr != nil {
return needRestart, getErr
}
key := clientKeyForProtocol(inbound.Protocol, existing)
if key == "" {
continue
}
nr, delErr := inboundSvc.DelInboundClient(ibId, key)
if delErr != nil {
return needRestart, delErr
}
if nr {
needRestart = true
}
}
db := database.GetDB()
if err := db.Where("client_id = ?", id).Delete(&model.ClientInbound{}).Error; err != nil {
return needRestart, err
}
if !keepTraffic && existing.Email != "" {
if err := db.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil {
return needRestart, err
}
if err := db.Where("client_email = ?", existing.Email).Delete(&model.InboundClientIps{}).Error; err != nil {
return needRestart, err
}
}
if err := db.Delete(&model.ClientRecord{}, id).Error; err != nil {
return needRestart, err
}
return needRestart, nil
}
func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) {
existing, err := s.GetByID(id)
if err != nil {
return false, err
}
currentIds, err := s.GetInboundIdsForRecord(id)
if err != nil {
return false, err
}
have := make(map[int]struct{}, len(currentIds))
for _, x := range currentIds {
have[x] = struct{}{}
}
clientWire := existing.ToClient()
clientWire.UpdatedAt = time.Now().UnixMilli()
needRestart := false
for _, ibId := range inboundIds {
if _, attached := have[ibId]; attached {
continue
}
inbound, getErr := inboundSvc.GetInbound(ibId)
if getErr != nil {
return needRestart, getErr
}
copyClient := *clientWire
if err := s.fillProtocolDefaults(&copyClient, inbound.Protocol); err != nil {
return needRestart, err
}
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {copyClient}})
if mErr != nil {
return needRestart, mErr
}
nr, addErr := inboundSvc.AddInboundClient(&model.Inbound{
Id: ibId,
Settings: string(settingsPayload),
})
if addErr != nil {
return needRestart, addErr
}
if nr {
needRestart = true
}
}
return needRestart, nil
}
func (s *ClientService) CreateOne(inboundSvc *InboundService, inboundId int, client model.Client) (bool, error) {
return s.Create(inboundSvc, &ClientCreatePayload{
Client: client,
InboundIds: []int{inboundId},
})
}
func (s *ClientService) DetachByEmail(inboundSvc *InboundService, inboundId int, email string) (bool, error) {
if email == "" {
return false, common.NewError("client email is required")
}
rec, err := s.GetRecordByEmail(nil, email)
if err != nil {
return false, err
}
return s.Detach(inboundSvc, rec.Id, []int{inboundId})
}
refactor(api): move every client-shaped endpoint off /inbounds onto /clients After the multi-inbound client migration, client state belongs to the client API surface, not the inbound one. Twelve routes that were crammed under /panel/api/inbounds/* now live where they belong, under /panel/api/clients/*. Moved (route, handler, doc): POST /clientIps/:email POST /clearClientIps/:email POST /onlines POST /lastOnline POST /updateClientTraffic/:email POST /resetAllClientTraffics/:id POST /delDepletedClients/:id POST /:id/resetClientTraffic/:email GET /getClientTraffics/:email GET /getClientTrafficsById/:id GET /getSubLinks/:subId GET /getClientLinks/:id/:email Their /clients/* counterparts are: POST /clients/clientIps/:email POST /clients/clearClientIps/:email POST /clients/onlines POST /clients/lastOnline POST /clients/updateTraffic/:email POST /clients/resetTraffic/:email (email-only, fans out) GET /clients/traffic/:email GET /clients/traffic/byId/:id GET /clients/subLinks/:subId GET /clients/links/:id/:email per-inbound resetAllClientTraffics and delDepletedClients are dropped entirely — the Clients page already exposes global Reset All Traffic and Delete depleted actions, and per-inbound resets are meaningless once a client can be attached to many inbounds. ClientService.ResetTrafficByEmail is the new email-only reset path: it looks up every inbound the client is attached to and pushes the counter reset + Xray re-add through inboundService.ResetClientTraffic for each one, so depleted users come back online instantly. Frontend callers (ClientsPage, useClients, ClientQrModal, ClientInfoModal, InboundInfoModal, InboundsPage, useInbounds) all switched to the new paths. The Inbounds page drops its per-inbound "Reset client traffic" and "Delete depleted clients" dropdown items — users do those at the client level now. api-docs is rebuilt to match. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 08:15:01 +00:00
func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email string) (bool, error) {
if email == "" {
return false, common.NewError("client email is required")
}
rec, err := s.GetRecordByEmail(nil, email)
if err != nil {
return false, err
}
inboundIds, err := s.GetInboundIdsForRecord(rec.Id)
if err != nil {
return false, err
}
if len(inboundIds) == 0 {
if rErr := inboundSvc.ResetClientTrafficByEmail(email); rErr != nil {
return false, rErr
}
return false, nil
}
needRestart := false
for _, ibId := range inboundIds {
nr, rErr := inboundSvc.ResetClientTraffic(ibId, email)
if rErr != nil {
return needRestart, rErr
}
if nr {
needRestart = true
}
}
return needRestart, nil
}
func (s *ClientService) DelDepleted(inboundSvc *InboundService) (int, bool, error) {
db := database.GetDB()
now := time.Now().UnixMilli()
depletedClause := "reset = 0 and ((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?))"
var rows []xray.ClientTraffic
if err := db.Where(depletedClause, now).Find(&rows).Error; err != nil {
return 0, false, err
}
if len(rows) == 0 {
return 0, false, nil
}
emails := make(map[string]struct{}, len(rows))
for _, r := range rows {
if r.Email != "" {
emails[r.Email] = struct{}{}
}
}
needRestart := false
deleted := 0
for email := range emails {
var rec model.ClientRecord
if err := db.Where("email = ?", email).First(&rec).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
continue
}
return deleted, needRestart, err
}
nr, err := s.Delete(inboundSvc, rec.Id, false)
if err != nil {
return deleted, needRestart, err
}
if nr {
needRestart = true
}
deleted++
}
return deleted, needRestart, nil
}
func (s *ClientService) ResetAllTraffics() (bool, error) {
res := database.GetDB().Model(&xray.ClientTraffic{}).
Where("1 = 1").
Updates(map[string]any{"up": 0, "down": 0})
if res.Error != nil {
return false, res.Error
}
return res.RowsAffected > 0, nil
}
func (s *ClientService) Detach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) {
existing, err := s.GetByID(id)
if err != nil {
return false, err
}
currentIds, err := s.GetInboundIdsForRecord(id)
if err != nil {
return false, err
}
have := make(map[int]struct{}, len(currentIds))
for _, x := range currentIds {
have[x] = struct{}{}
}
needRestart := false
for _, ibId := range inboundIds {
if _, attached := have[ibId]; !attached {
continue
}
inbound, getErr := inboundSvc.GetInbound(ibId)
if getErr != nil {
return needRestart, getErr
}
key := clientKeyForProtocol(inbound.Protocol, existing)
if key == "" {
continue
}
nr, delErr := inboundSvc.DelInboundClient(ibId, key)
if delErr != nil {
return needRestart, delErr
}
if nr {
needRestart = true
}
}
return needRestart, nil
}