|
|
@@ -1265,6 +1265,379 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
|
|
|
return result, needRestart, nil
|
|
|
}
|
|
|
|
|
|
+// BulkDeleteResult mirrors BulkAdjustResult: total deleted plus per-email
|
|
|
+// skip reasons when an email could not be processed.
|
|
|
+type BulkDeleteResult struct {
|
|
|
+ Deleted int `json:"deleted"`
|
|
|
+ Skipped []BulkDeleteReport `json:"skipped,omitempty"`
|
|
|
+}
|
|
|
+
|
|
|
+type BulkDeleteReport struct {
|
|
|
+ Email string `json:"email"`
|
|
|
+ Reason string `json:"reason"`
|
|
|
+}
|
|
|
+
|
|
|
+// BulkDelete removes every client in the list in one optimized pass.
|
|
|
+// Instead of running the full single-delete pipeline N times (which would
|
|
|
+// re-read, re-parse, and re-write each inbound's settings JSON for every
|
|
|
+// email), it groups emails by inbound and performs a single
|
|
|
+// read-modify-write per inbound. Per-row DB cleanups are also batched with
|
|
|
+// IN-clause queries at the end. Errors on a particular email are recorded
|
|
|
+// in the Skipped list and processing continues for the rest.
|
|
|
+func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string, keepTraffic bool) (BulkDeleteResult, bool, error) {
|
|
|
+ result := BulkDeleteResult{}
|
|
|
+
|
|
|
+ seen := map[string]struct{}{}
|
|
|
+ cleanEmails := make([]string, 0, len(emails))
|
|
|
+ for _, e := range emails {
|
|
|
+ e = strings.TrimSpace(e)
|
|
|
+ if e == "" {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if _, ok := seen[e]; ok {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ seen[e] = struct{}{}
|
|
|
+ cleanEmails = append(cleanEmails, e)
|
|
|
+ }
|
|
|
+ if len(cleanEmails) == 0 {
|
|
|
+ return result, false, nil
|
|
|
+ }
|
|
|
+
|
|
|
+ db := database.GetDB()
|
|
|
+
|
|
|
+ var records []model.ClientRecord
|
|
|
+ if err := db.Where("email IN ?", cleanEmails).Find(&records).Error; err != nil {
|
|
|
+ return result, false, err
|
|
|
+ }
|
|
|
+ recordsByEmail := make(map[string]*model.ClientRecord, len(records))
|
|
|
+ for i := range records {
|
|
|
+ recordsByEmail[records[i].Email] = &records[i]
|
|
|
+ tombstoneClientEmail(records[i].Email)
|
|
|
+ }
|
|
|
+
|
|
|
+ skippedReasons := map[string]string{}
|
|
|
+ for _, email := range cleanEmails {
|
|
|
+ if _, ok := recordsByEmail[email]; !ok {
|
|
|
+ skippedReasons[email] = "client not found"
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ clientIds := make([]int, 0, len(recordsByEmail))
|
|
|
+ recordIdToEmail := make(map[int]string, len(recordsByEmail))
|
|
|
+ for _, r := range recordsByEmail {
|
|
|
+ clientIds = append(clientIds, r.Id)
|
|
|
+ recordIdToEmail[r.Id] = r.Email
|
|
|
+ }
|
|
|
+
|
|
|
+ emailsByInbound := map[int][]string{}
|
|
|
+ if len(clientIds) > 0 {
|
|
|
+ var mappings []model.ClientInbound
|
|
|
+ if err := db.Where("client_id IN ?", clientIds).Find(&mappings).Error; err != nil {
|
|
|
+ return result, false, err
|
|
|
+ }
|
|
|
+ for _, m := range mappings {
|
|
|
+ email, ok := recordIdToEmail[m.ClientId]
|
|
|
+ if !ok {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ emailsByInbound[m.InboundId] = append(emailsByInbound[m.InboundId], email)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ needRestart := false
|
|
|
+ for inboundId, ibEmails := range emailsByInbound {
|
|
|
+ ibResult := s.bulkDelInboundClients(inboundSvc, inboundId, ibEmails, recordsByEmail)
|
|
|
+ if ibResult.needRestart {
|
|
|
+ needRestart = true
|
|
|
+ }
|
|
|
+ for email, reason := range ibResult.perEmailSkipped {
|
|
|
+ if _, already := skippedReasons[email]; !already {
|
|
|
+ skippedReasons[email] = reason
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ successEmails := make([]string, 0, len(recordsByEmail))
|
|
|
+ successIds := make([]int, 0, len(recordsByEmail))
|
|
|
+ for email, rec := range recordsByEmail {
|
|
|
+ if _, skipped := skippedReasons[email]; skipped {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ successEmails = append(successEmails, email)
|
|
|
+ successIds = append(successIds, rec.Id)
|
|
|
+ }
|
|
|
+
|
|
|
+ if len(successIds) > 0 {
|
|
|
+ if err := db.Where("client_id IN ?", successIds).Delete(&model.ClientInbound{}).Error; err != nil {
|
|
|
+ return result, needRestart, err
|
|
|
+ }
|
|
|
+ if !keepTraffic && len(successEmails) > 0 {
|
|
|
+ if err := db.Where("email IN ?", successEmails).Delete(&xray.ClientTraffic{}).Error; err != nil {
|
|
|
+ return result, needRestart, err
|
|
|
+ }
|
|
|
+ if err := db.Where("client_email IN ?", successEmails).Delete(&model.InboundClientIps{}).Error; err != nil {
|
|
|
+ return result, needRestart, err
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if err := db.Where("id IN ?", successIds).Delete(&model.ClientRecord{}).Error; err != nil {
|
|
|
+ return result, needRestart, err
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ result.Deleted = len(successEmails)
|
|
|
+ for email, reason := range skippedReasons {
|
|
|
+ result.Skipped = append(result.Skipped, BulkDeleteReport{Email: email, Reason: reason})
|
|
|
+ }
|
|
|
+ return result, needRestart, nil
|
|
|
+}
|
|
|
+
|
|
|
+type bulkInboundDeleteResult struct {
|
|
|
+ perEmailSkipped map[string]string
|
|
|
+ needRestart bool
|
|
|
+}
|
|
|
+
|
|
|
+// bulkDelInboundClients removes multiple clients from a single inbound's
|
|
|
+// settings JSON in one read-modify-write cycle, runs the xray runtime
|
|
|
+// RemoveUser/DeleteUser calls, and persists the inbound. The returned map
|
|
|
+// holds per-email failure reasons; emails not present in the map are
|
|
|
+// considered successful for this inbound.
|
|
|
+func (s *ClientService) bulkDelInboundClients(
|
|
|
+ inboundSvc *InboundService,
|
|
|
+ inboundId int,
|
|
|
+ emails []string,
|
|
|
+ records map[string]*model.ClientRecord,
|
|
|
+) bulkInboundDeleteResult {
|
|
|
+ res := bulkInboundDeleteResult{perEmailSkipped: map[string]string{}}
|
|
|
+
|
|
|
+ defer lockInbound(inboundId).Unlock()
|
|
|
+
|
|
|
+ oldInbound, err := inboundSvc.GetInbound(inboundId)
|
|
|
+ if err != nil {
|
|
|
+ logger.Error("Load Old Data Error")
|
|
|
+ for _, e := range emails {
|
|
|
+ res.perEmailSkipped[e] = err.Error()
|
|
|
+ }
|
|
|
+ return res
|
|
|
+ }
|
|
|
+
|
|
|
+ var settings map[string]any
|
|
|
+ if err := json.Unmarshal([]byte(oldInbound.Settings), &settings); err != nil {
|
|
|
+ for _, e := range emails {
|
|
|
+ res.perEmailSkipped[e] = err.Error()
|
|
|
+ }
|
|
|
+ return res
|
|
|
+ }
|
|
|
+
|
|
|
+ clientKey := "id"
|
|
|
+ switch oldInbound.Protocol {
|
|
|
+ case model.Trojan:
|
|
|
+ clientKey = "password"
|
|
|
+ case model.Shadowsocks:
|
|
|
+ clientKey = "email"
|
|
|
+ case model.Hysteria, model.Hysteria2:
|
|
|
+ clientKey = "auth"
|
|
|
+ }
|
|
|
+
|
|
|
+ keyToEmail := make(map[string]string, len(emails))
|
|
|
+ for _, email := range emails {
|
|
|
+ rec := records[email]
|
|
|
+ if rec == nil {
|
|
|
+ res.perEmailSkipped[email] = "client not found"
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ key := clientKeyForProtocol(oldInbound.Protocol, rec)
|
|
|
+ if key == "" {
|
|
|
+ res.perEmailSkipped[email] = "missing client key for protocol"
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ keyToEmail[key] = email
|
|
|
+ }
|
|
|
+
|
|
|
+ interfaceClients, _ := settings["clients"].([]any)
|
|
|
+ newClients := make([]any, 0, len(interfaceClients))
|
|
|
+ foundEmails := map[string]bool{}
|
|
|
+ enableByEmail := map[string]bool{}
|
|
|
+ for _, client := range interfaceClients {
|
|
|
+ c, ok := client.(map[string]any)
|
|
|
+ if !ok {
|
|
|
+ newClients = append(newClients, client)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ cKey, _ := c[clientKey].(string)
|
|
|
+ if targetEmail, found := keyToEmail[cKey]; found {
|
|
|
+ foundEmails[targetEmail] = true
|
|
|
+ if em, _ := c["email"].(string); em != "" {
|
|
|
+ en, _ := c["enable"].(bool)
|
|
|
+ enableByEmail[em] = en
|
|
|
+ }
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ newClients = append(newClients, client)
|
|
|
+ }
|
|
|
+
|
|
|
+ for _, email := range keyToEmail {
|
|
|
+ if !foundEmails[email] {
|
|
|
+ res.perEmailSkipped[email] = "Client Not Found In Inbound"
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ db := database.GetDB()
|
|
|
+ newClients = compactOrphans(db, newClients)
|
|
|
+ if newClients == nil {
|
|
|
+ newClients = []any{}
|
|
|
+ }
|
|
|
+ settings["clients"] = newClients
|
|
|
+ newSettings, err := json.MarshalIndent(settings, "", " ")
|
|
|
+ if err != nil {
|
|
|
+ for email := range foundEmails {
|
|
|
+ if _, skip := res.perEmailSkipped[email]; !skip {
|
|
|
+ res.perEmailSkipped[email] = err.Error()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return res
|
|
|
+ }
|
|
|
+ oldInbound.Settings = string(newSettings)
|
|
|
+
|
|
|
+ foundList := make([]string, 0, len(foundEmails))
|
|
|
+ for email := range foundEmails {
|
|
|
+ foundList = append(foundList, email)
|
|
|
+ }
|
|
|
+
|
|
|
+ notDepletedByEmail := map[string]bool{}
|
|
|
+ if len(foundList) > 0 {
|
|
|
+ type trafficRow struct {
|
|
|
+ Email string
|
|
|
+ Enable bool
|
|
|
+ }
|
|
|
+ var rows []trafficRow
|
|
|
+ if err := db.Model(xray.ClientTraffic{}).
|
|
|
+ Where("email IN ?", foundList).
|
|
|
+ Select("email, enable").
|
|
|
+ Scan(&rows).Error; err == nil {
|
|
|
+ for _, r := range rows {
|
|
|
+ notDepletedByEmail[r.Email] = r.Enable
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ for email := range foundEmails {
|
|
|
+ shared, sharedErr := inboundSvc.emailUsedByOtherInbounds(email, inboundId)
|
|
|
+ if sharedErr != nil {
|
|
|
+ res.perEmailSkipped[email] = sharedErr.Error()
|
|
|
+ delete(foundEmails, email)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if shared {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if delErr := inboundSvc.DelClientIPs(db, email); delErr != nil {
|
|
|
+ logger.Error("Error in delete client IPs")
|
|
|
+ res.perEmailSkipped[email] = delErr.Error()
|
|
|
+ delete(foundEmails, email)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if delErr := inboundSvc.DelClientStat(db, email); delErr != nil {
|
|
|
+ logger.Error("Delete stats Data Error")
|
|
|
+ res.perEmailSkipped[email] = delErr.Error()
|
|
|
+ delete(foundEmails, email)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if oldInbound.NodeID == nil {
|
|
|
+ rt, rterr := inboundSvc.runtimeFor(oldInbound)
|
|
|
+ if rterr != nil {
|
|
|
+ res.needRestart = true
|
|
|
+ } else {
|
|
|
+ for email := range foundEmails {
|
|
|
+ if !enableByEmail[email] || !notDepletedByEmail[email] {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ err1 := rt.RemoveUser(context.Background(), oldInbound, email)
|
|
|
+ if err1 == nil {
|
|
|
+ logger.Debug("Client deleted on", rt.Name(), ":", email)
|
|
|
+ } else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) {
|
|
|
+ logger.Debug("User is already deleted. Nothing to do more...")
|
|
|
+ } else {
|
|
|
+ logger.Debug("Error in deleting client on", rt.Name(), ":", err1)
|
|
|
+ res.needRestart = true
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ rt, rterr := inboundSvc.runtimeFor(oldInbound)
|
|
|
+ if rterr != nil {
|
|
|
+ for email := range foundEmails {
|
|
|
+ res.perEmailSkipped[email] = rterr.Error()
|
|
|
+ delete(foundEmails, email)
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ for email := range foundEmails {
|
|
|
+ if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
|
|
|
+ res.perEmailSkipped[email] = err1.Error()
|
|
|
+ delete(foundEmails, email)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if err := db.Save(oldInbound).Error; err != nil {
|
|
|
+ for email := range foundEmails {
|
|
|
+ if _, skip := res.perEmailSkipped[email]; !skip {
|
|
|
+ res.perEmailSkipped[email] = err.Error()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return res
|
|
|
+ }
|
|
|
+
|
|
|
+ finalClients, err := inboundSvc.GetClients(oldInbound)
|
|
|
+ if err != nil {
|
|
|
+ return res
|
|
|
+ }
|
|
|
+ if err := s.SyncInbound(db, inboundId, finalClients); err != nil {
|
|
|
+ return res
|
|
|
+ }
|
|
|
+
|
|
|
+ return res
|
|
|
+}
|
|
|
+
|
|
|
+// BulkCreateResult mirrors BulkAdjustResult for the create flow.
|
|
|
+type BulkCreateResult struct {
|
|
|
+ Created int `json:"created"`
|
|
|
+ Skipped []BulkCreateReport `json:"skipped,omitempty"`
|
|
|
+}
|
|
|
+
|
|
|
+type BulkCreateReport struct {
|
|
|
+ Email string `json:"email"`
|
|
|
+ Reason string `json:"reason"`
|
|
|
+}
|
|
|
+
|
|
|
+// BulkCreate iterates payloads sequentially. Each item is the same shape
|
|
|
+// the single-create endpoint accepts, so callers can submit a heterogeneous
|
|
|
+// list (different inboundIds, plans, etc.) in one round-trip.
|
|
|
+func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []ClientCreatePayload) (BulkCreateResult, bool, error) {
|
|
|
+ result := BulkCreateResult{}
|
|
|
+ needRestart := false
|
|
|
+ for i := range payloads {
|
|
|
+ p := payloads[i]
|
|
|
+ email := strings.TrimSpace(p.Client.Email)
|
|
|
+ nr, err := s.Create(inboundSvc, &p)
|
|
|
+ if err != nil {
|
|
|
+ if email == "" {
|
|
|
+ email = "(missing email)"
|
|
|
+ }
|
|
|
+ result.Skipped = append(result.Skipped, BulkCreateReport{Email: email, Reason: err.Error()})
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if nr {
|
|
|
+ needRestart = true
|
|
|
+ }
|
|
|
+ result.Created++
|
|
|
+ }
|
|
|
+ return result, needRestart, nil
|
|
|
+}
|
|
|
+
|
|
|
func (s *ClientService) DelDepleted(inboundSvc *InboundService) (int, bool, error) {
|
|
|
db := database.GetDB()
|
|
|
now := time.Now().UnixMilli()
|