Merge pull request #7 from Sora39831/codex

Delete user inbounds and sync-remove inbound clients
This commit is contained in:
Sora39 2026-04-05 03:59:25 +08:00 committed by GitHub
commit ba06dcbfb8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 228 additions and 22 deletions

View file

@ -105,19 +105,7 @@ func (a *UserController) deleteUser(c *gin.Context) {
}
currentUser := session.GetLoginUser(c)
inbounds, err := a.inboundService.GetInbounds(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.users.toasts.delete"), err)
return
}
for _, inbound := range inbounds {
if _, err := a.inboundService.DelInbound(inbound.Id); err != nil {
jsonMsg(c, I18nWeb(c, "pages.users.toasts.delete"), err)
return
}
}
err = a.userService.DeleteUser(id, currentUser.Id)
err = a.userService.DeleteUser(id, currentUser.Id, &a.inboundService)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.users.toasts.delete"), a.localizeUserError(c, err))
return

View file

@ -151,6 +151,68 @@ func (s *UserService) addUserClientsToAllInbounds(tx *gorm.DB, username string,
return nil
}
func (s *UserService) removeUserClientsFromAllInbounds(tx *gorm.DB, username string, inboundService *InboundService) error {
inbounds, err := inboundService.GetAllInbounds()
if err != nil {
return err
}
for _, inbound := range inbounds {
var settings map[string]any
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
return err
}
clientsRaw, ok := settings["clients"].([]any)
if !ok {
continue
}
newClients := make([]any, 0, len(clientsRaw))
removedEmails := make(map[string]struct{})
for _, clientRaw := range clientsRaw {
clientMap, ok := clientRaw.(map[string]any)
if !ok {
newClients = append(newClients, clientRaw)
continue
}
email, _ := clientMap["email"].(string)
if strings.EqualFold(email, username) {
if email != "" {
removedEmails[email] = struct{}{}
}
continue
}
newClients = append(newClients, clientRaw)
}
if len(removedEmails) == 0 {
continue
}
settings["clients"] = newClients
newSettings, err := json.Marshal(settings)
if err != nil {
return err
}
if err := tx.Model(&model.Inbound{}).Where("id = ?", inbound.Id).Update("settings", string(newSettings)).Error; err != nil {
return err
}
for email := range removedEmails {
if err := inboundService.DelClientStat(tx, inbound.Id, email); err != nil {
return err
}
if err := inboundService.DelClientIPs(tx, email); err != nil {
return err
}
}
}
return nil
}
// GetFirstUser retrieves the first user from the database.
// This is typically used for initial setup or when there's only one admin user.
func (s *UserService) GetFirstUser() (*model.User, error) {
@ -368,7 +430,7 @@ func (s *UserService) UpdateManagedUser(id int, username string, password string
}
// DeleteUser deletes a managed user.
func (s *UserService) DeleteUser(id int, currentUserId int) error {
func (s *UserService) DeleteUser(id int, currentUserId int, inboundService *InboundService) error {
if id == currentUserId {
return ErrCannotDeleteSelf
}
@ -382,15 +444,31 @@ func (s *UserService) DeleteUser(id int, currentUserId int) error {
return err
}
if user.Role == "admin" {
adminCount, err := s.countAdmins(db)
if err != nil {
return err
}
if adminCount <= 1 {
return ErrLastAdminRequired
}
}
if inboundService == nil {
inboundService = &InboundService{}
}
inbounds, err := inboundService.GetInbounds(id)
if err != nil {
return err
}
for _, inbound := range inbounds {
if _, err := inboundService.DelInbound(inbound.Id); err != nil {
return err
}
}
return db.Transaction(func(tx *gorm.DB) error {
if user.Role == "admin" {
adminCount, err := s.countAdmins(tx)
if err != nil {
return err
}
if adminCount <= 1 {
return ErrLastAdminRequired
}
if err := s.removeUserClientsFromAllInbounds(tx, user.Username, inboundService); err != nil {
return err
}
return tx.Delete(&model.User{}, id).Error
})

View file

@ -1,6 +1,7 @@
package service
import (
"encoding/json"
"os"
"path/filepath"
"testing"
@ -8,6 +9,7 @@ import (
"github.com/mhsanaei/3x-ui/v2/database"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/util/crypto"
"github.com/mhsanaei/3x-ui/v2/xray"
)
func setupTestDB(t *testing.T) {
@ -145,3 +147,141 @@ func TestUpdateFirstUser_CreateWhenNone(t *testing.T) {
t.Error("password hash should match 'firstpass'")
}
}
func TestDeleteUser_RemovesClientsFromAllInbounds(t *testing.T) {
setupTestDB(t)
db := database.GetDB()
userSvc := &UserService{}
inboundSvc := &InboundService{}
hashedPassword, err := crypto.HashPasswordAsBcrypt("password123")
if err != nil {
t.Fatalf("hash password failed: %v", err)
}
managedUser := &model.User{
Username: "managed_user",
Password: hashedPassword,
Role: "user",
}
if err := db.Create(managedUser).Error; err != nil {
t.Fatalf("create managed user failed: %v", err)
}
settings1Bytes, err := json.Marshal(map[string]any{
"clients": []map[string]any{
{"id": "client-1", "email": "managed_user", "enable": false},
{"id": "client-2", "email": "keep_user", "enable": false},
},
})
if err != nil {
t.Fatalf("marshal inbound settings 1 failed: %v", err)
}
settings2Bytes, err := json.Marshal(map[string]any{
"clients": []map[string]any{
{"id": "client-3", "email": "managed_user", "enable": false},
{"id": "client-4", "email": "another_user", "enable": false},
},
})
if err != nil {
t.Fatalf("marshal inbound settings 2 failed: %v", err)
}
inbound1 := &model.Inbound{
UserId: 1,
Port: 21001,
Protocol: model.VLESS,
Tag: "user-delete-sync-1",
Settings: string(settings1Bytes),
}
inbound2 := &model.Inbound{
UserId: 1,
Port: 21002,
Protocol: model.VLESS,
Tag: "user-delete-sync-2",
Settings: string(settings2Bytes),
}
if err := db.Create(inbound1).Error; err != nil {
t.Fatalf("create inbound1 failed: %v", err)
}
if err := db.Create(inbound2).Error; err != nil {
t.Fatalf("create inbound2 failed: %v", err)
}
if err := db.Create(&xray.ClientTraffic{InboundId: inbound1.Id, Email: "managed_user"}).Error; err != nil {
t.Fatalf("create traffic for inbound1 failed: %v", err)
}
if err := db.Create(&xray.ClientTraffic{InboundId: inbound2.Id, Email: "managed_user"}).Error; err != nil {
t.Fatalf("create traffic for inbound2 failed: %v", err)
}
if err := db.Create(&xray.ClientTraffic{InboundId: inbound1.Id, Email: "keep_user"}).Error; err != nil {
t.Fatalf("create keep_user traffic failed: %v", err)
}
if err := db.Create(&model.InboundClientIps{ClientEmail: "managed_user", Ips: "[\"1.1.1.1\"]"}).Error; err != nil {
t.Fatalf("create inbound client ips failed: %v", err)
}
if err := userSvc.DeleteUser(managedUser.Id, 1, inboundSvc); err != nil {
t.Fatalf("DeleteUser failed: %v", err)
}
var usersCount int64
if err := db.Model(&model.User{}).Where("id = ?", managedUser.Id).Count(&usersCount).Error; err != nil {
t.Fatalf("count users failed: %v", err)
}
if usersCount != 0 {
t.Fatalf("expected managed user to be deleted, remaining=%d", usersCount)
}
checkInboundHasNoManagedUser := func(inboundID int) {
t.Helper()
var inbound model.Inbound
if err := db.First(&inbound, inboundID).Error; err != nil {
t.Fatalf("load inbound %d failed: %v", inboundID, err)
}
var settings map[string]any
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
t.Fatalf("unmarshal inbound settings failed: %v", err)
}
clients, ok := settings["clients"].([]any)
if !ok {
t.Fatalf("invalid clients format in inbound %d", inboundID)
}
for _, clientRaw := range clients {
clientMap, ok := clientRaw.(map[string]any)
if !ok {
continue
}
email, _ := clientMap["email"].(string)
if email == "managed_user" {
t.Fatalf("managed_user still exists in inbound %d clients", inboundID)
}
}
}
checkInboundHasNoManagedUser(inbound1.Id)
checkInboundHasNoManagedUser(inbound2.Id)
var managedTrafficCount int64
if err := db.Model(&xray.ClientTraffic{}).Where("email = ?", "managed_user").Count(&managedTrafficCount).Error; err != nil {
t.Fatalf("count managed user traffic failed: %v", err)
}
if managedTrafficCount != 0 {
t.Fatalf("expected managed_user traffic to be deleted, remaining=%d", managedTrafficCount)
}
var keepTrafficCount int64
if err := db.Model(&xray.ClientTraffic{}).Where("email = ?", "keep_user").Count(&keepTrafficCount).Error; err != nil {
t.Fatalf("count keep_user traffic failed: %v", err)
}
if keepTrafficCount != 1 {
t.Fatalf("expected keep_user traffic to remain, got=%d", keepTrafficCount)
}
var ipsCount int64
if err := db.Model(&model.InboundClientIps{}).Where("client_email = ?", "managed_user").Count(&ipsCount).Error; err != nil {
t.Fatalf("count inbound client ips failed: %v", err)
}
if ipsCount != 0 {
t.Fatalf("expected managed_user inbound_client_ips to be deleted, remaining=%d", ipsCount)
}
}