client_paging.go 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735
  1. package service
  2. import (
  3. "slices"
  4. "sort"
  5. "strconv"
  6. "strings"
  7. "time"
  8. "github.com/mhsanaei/3x-ui/v3/internal/database"
  9. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  10. "github.com/mhsanaei/3x-ui/v3/internal/xray"
  11. "gorm.io/gorm"
  12. )
  13. // ClientSlim is the row-shape used by the clients page. It drops fields the
  14. // table never reads (UUID, password, auth, flow, security, reverse, tgId)
  15. // so the list payload stays compact even when the panel manages thousands
  16. // of clients. Modals that need the full record still call /get/:email.
  17. type ClientSlim struct {
  18. Email string `json:"email" example:"[email protected]"`
  19. SubID string `json:"subId" example:"abcd1234"`
  20. Enable bool `json:"enable" example:"true"`
  21. TotalGB int64 `json:"totalGB" example:"53687091200"`
  22. ExpiryTime int64 `json:"expiryTime" example:"1735689600000"`
  23. LimitIP int `json:"limitIp" example:"0"`
  24. LimitHwid int `json:"limitHwid" example:"0"`
  25. Reset int `json:"reset" example:"0"`
  26. ResetDay int `json:"resetDay" example:"0"`
  27. ResetWeekday int `json:"resetWeekday" example:"0"`
  28. ResetMax int `json:"resetMax" example:"0"`
  29. Group string `json:"group,omitempty" example:"staff"`
  30. Comment string `json:"comment,omitempty" example:"Primary device"`
  31. InboundIds []int `json:"inboundIds" example:"[3,5]"`
  32. Traffic *xray.ClientTraffic `json:"traffic,omitempty"`
  33. CreatedAt int64 `json:"createdAt" example:"1735000000000"`
  34. UpdatedAt int64 `json:"updatedAt" example:"1735100000000"`
  35. }
  36. // ClientPageParams are the query params accepted by /panel/api/clients/list/paged.
  37. // All fields are optional — the empty value means "no filter" / defaults.
  38. //
  39. // Filter / Protocol / Inbound accept either a single value or a comma-separated
  40. // list; matching is OR within a field and AND across fields. The numeric range
  41. // fields treat 0 as "unset" on the lower bound and 0 (or negative) as
  42. // "unbounded" on the upper bound.
  43. type ClientPageParams struct {
  44. Page int `form:"page"`
  45. PageSize int `form:"pageSize"`
  46. Search string `form:"search"`
  47. Filter string `form:"filter"`
  48. Protocol string `form:"protocol"`
  49. Inbound string `form:"inbound"`
  50. Sort string `form:"sort"`
  51. Order string `form:"order"`
  52. ExpiryFrom int64 `form:"expiryFrom"`
  53. ExpiryTo int64 `form:"expiryTo"`
  54. UsageFrom int64 `form:"usageFrom"`
  55. UsageTo int64 `form:"usageTo"`
  56. AutoRenew string `form:"autoRenew"`
  57. HasTgID string `form:"hasTgId"`
  58. HasComment string `form:"hasComment"`
  59. Group string `form:"group"`
  60. }
  61. // ClientPageResponse is the shape returned by ListPaged. `Total` is the
  62. // row count in the DB; `Filtered` is the count after Search/Filter/Protocol
  63. // were applied, before pagination. The page contains at most PageSize items.
  64. // Summary is computed across the full DB row set so dashboard counters
  65. // on the clients page stay stable as the user paginates/filters.
  66. type ClientPageResponse struct {
  67. Items []ClientSlim `json:"items"`
  68. Total int `json:"total" example:"2000"`
  69. Filtered int `json:"filtered" example:"47"`
  70. Page int `json:"page" example:"1"`
  71. PageSize int `json:"pageSize" example:"25"`
  72. Summary ClientsSummary `json:"summary"`
  73. Groups []string `json:"groups" example:"[\"staff\",\"trial\"]"`
  74. }
  75. // ClientsSummary collects per-bucket counts plus the matching email lists so
  76. // the clients page can render the dashboard stat cards and their hover
  77. // popovers without shipping the full client array. The counters are exact;
  78. // the lists stop at clientSummaryEmailCap entries and only back the popovers.
  79. type ClientsSummary struct {
  80. Total int `json:"total" example:"2000"`
  81. Active int `json:"active" example:"1850"`
  82. OnlineCount int `json:"onlineCount" example:"1"`
  83. DepletedCount int `json:"depletedCount" example:"0"`
  84. ExpiringCount int `json:"expiringCount" example:"0"`
  85. DeactiveCount int `json:"deactiveCount" example:"150"`
  86. Online []string `json:"online" example:"[\"[email protected]\"]"`
  87. Depleted []string `json:"depleted" example:"[]"`
  88. Expiring []string `json:"expiring" example:"[]"`
  89. Deactive []string `json:"deactive" example:"[\"[email protected]\"]"`
  90. }
  91. const (
  92. clientPageDefaultSize = 25
  93. clientPageMaxSize = 200
  94. // clientSummaryEmailCap bounds each bucket's email list. Shipping every
  95. // matching email made the response — and the Zod validation the page runs
  96. // over it — grow with the client count on a request that repeats every 5s,
  97. // and left the hover popover rendering thousands of rows.
  98. clientSummaryEmailCap = 200
  99. // sqlNeverSentinel sorts "never expires" / "unlimited quota" clients last,
  100. // matching the sentinel the in-memory comparator used.
  101. sqlNeverSentinel = "4611686018427387903"
  102. // sqlClientEnabled tolerates a NULL enable column, which GORM scans as
  103. // false: without the COALESCE such a row would match neither the enabled
  104. // nor the disabled branch of any predicate.
  105. sqlClientEnabled = "COALESCE(c.enable, FALSE)"
  106. )
  107. // clientSearchCols are the text columns the search box matches.
  108. var clientSearchCols = []string{"c.email", "COALESCE(c.sub_id, '')", "COALESCE(c.comment, '')",
  109. "COALESCE(c.uuid, '')", "COALESCE(c.password, '')", "COALESCE(c.auth, '')"}
  110. // caseVariants returns s lower-cased, as typed, upper-cased and title-cased.
  111. // SQLite's LOWER() and LIKE fold ASCII only, so non-ASCII text is matched
  112. // against these spellings instead of relying on the database to fold it.
  113. func caseVariants(s string) []string {
  114. title := s
  115. if r := []rune(strings.ToLower(s)); len(r) > 0 {
  116. title = strings.ToUpper(string(r[:1])) + string(r[1:])
  117. }
  118. out := make([]string, 0, 4)
  119. for _, v := range []string{strings.ToLower(s), s, strings.ToUpper(s), title} {
  120. if !slices.Contains(out, v) {
  121. out = append(out, v)
  122. }
  123. }
  124. return out
  125. }
  126. // clientSearchCond builds the search predicate for the given needle variants
  127. // (lowered first) and returns its arguments.
  128. func clientSearchCond(variants []string) (string, []any) {
  129. var parts []string
  130. var args []any
  131. like := func(v string) string { return "%" + escapeLikeLiteral(v) + "%" }
  132. for _, col := range clientSearchCols {
  133. parts = append(parts, "LOWER("+col+") LIKE ? ESCAPE '\\'")
  134. args = append(args, like(variants[0]))
  135. for _, v := range variants {
  136. parts = append(parts, col+" LIKE ? ESCAPE '\\'")
  137. args = append(args, like(v))
  138. }
  139. }
  140. parts = append(parts, "(COALESCE(c.tg_id, 0) <> 0 AND CAST(c.tg_id AS TEXT) LIKE ? ESCAPE '\\')")
  141. args = append(args, like(variants[0]))
  142. return "(" + strings.Join(parts, " OR ") + ")", args
  143. }
  144. // clientQuery builds the statements behind the clients page: a clients row
  145. // joined to its traffic counters, plus the expressions every bucket predicate
  146. // shares. Filtering, sorting, paging and the summary all run in the database.
  147. // Loading every client (with attachments and traffic) into Go and doing it in
  148. // memory cost ~200ms per request at 20k clients on a page that polls every
  149. // 5 seconds, which is what made the table feel stuck on large panels.
  150. type clientQuery struct {
  151. db *gorm.DB
  152. joins []clientQueryJoin
  153. usedExpr string
  154. nowMs int64
  155. expireDiffMs int64
  156. trafficDiffBytes int64
  157. }
  158. type clientQueryJoin struct {
  159. sql string
  160. args []any
  161. }
  162. func newClientQuery(db *gorm.DB, nowMs, expireDiffMs, trafficDiffBytes int64) clientQuery {
  163. q := clientQuery{
  164. db: db,
  165. nowMs: nowMs,
  166. expireDiffMs: expireDiffMs,
  167. trafficDiffBytes: trafficDiffBytes,
  168. joins: []clientQueryJoin{{sql: "LEFT JOIN client_traffics ct ON ct.email = c.email"}},
  169. usedExpr: "(COALESCE(ct.up, 0) + COALESCE(ct.down, 0))",
  170. }
  171. freshSince := globalTrafficFreshSince()
  172. var probe int64
  173. err := db.Model(&model.ClientGlobalTraffic{}).
  174. Where("updated_at >= ?", freshSince).
  175. Limit(1).Count(&probe).Error
  176. if err != nil || probe == 0 {
  177. return q
  178. }
  179. // A master still pushes cross-panel usage here, so the predicates have to
  180. // see the same raised counters overlayGlobalTraffic applies on read.
  181. q.joins = append(q.joins, clientQueryJoin{
  182. sql: "LEFT JOIN (SELECT email, MAX(up) AS up, MAX(down) AS down FROM client_global_traffics" +
  183. " WHERE updated_at >= ? GROUP BY email) g ON g.email = c.email",
  184. args: []any{freshSince},
  185. })
  186. q.usedExpr = "(CASE WHEN COALESCE(g.up, 0) > COALESCE(ct.up, 0) THEN COALESCE(g.up, 0) ELSE COALESCE(ct.up, 0) END" +
  187. " + CASE WHEN COALESCE(g.down, 0) > COALESCE(ct.down, 0) THEN COALESCE(g.down, 0) ELSE COALESCE(ct.down, 0) END)"
  188. return q
  189. }
  190. func (q clientQuery) from() *gorm.DB {
  191. tx := q.db.Table("clients AS c")
  192. for _, j := range q.joins {
  193. tx = tx.Joins(j.sql, j.args...)
  194. }
  195. return tx
  196. }
  197. func (q clientQuery) depletedExpr() string {
  198. return "((c.total_gb > 0 AND " + q.usedExpr + " >= c.total_gb)" +
  199. " OR (c.expiry_time > 0 AND c.expiry_time <= " + sqlInt(q.nowMs) + "))"
  200. }
  201. func (q clientQuery) nearDepletionExpr() string {
  202. return "((c.expiry_time > 0 AND c.expiry_time - " + sqlInt(q.nowMs) + " < " + sqlInt(q.expireDiffMs) + ")" +
  203. " OR (c.total_gb > 0 AND c.total_gb - " + q.usedExpr + " < " + sqlInt(q.trafficDiffBytes) + "))"
  204. }
  205. func (q clientQuery) expiringExpr() string {
  206. return "(" + sqlClientEnabled + " AND NOT " + q.depletedExpr() + " AND " + q.nearDepletionExpr() + ")"
  207. }
  208. func (q clientQuery) activeExpr() string {
  209. return "(" + sqlClientEnabled + " AND NOT " + q.depletedExpr() + " AND NOT " + q.nearDepletionExpr() + ")"
  210. }
  211. // deactiveExpr leaves a disabled client that also ran out to depleted, so the
  212. // stat cards add up to the total and each card's filter lists what it counts.
  213. func (q clientQuery) deactiveExpr() string {
  214. return "(NOT " + sqlClientEnabled + " AND NOT " + q.depletedExpr() + ")"
  215. }
  216. // applyParams narrows tx by every predicate the clients page sends. Matching is
  217. // OR within a field and AND across fields, mirroring the query-param contract.
  218. // The second return says whether anything narrowed the set, so an unfiltered
  219. // request can reuse the total count instead of scanning for it again.
  220. func (q clientQuery) applyParams(tx *gorm.DB, params ClientPageParams, onlines []string) (*gorm.DB, bool) {
  221. narrowed := false
  222. where := func(cond string, args ...any) {
  223. narrowed = true
  224. tx = tx.Where(cond, args...)
  225. }
  226. if needle := strings.TrimSpace(params.Search); needle != "" {
  227. cond, args := clientSearchCond(caseVariants(needle))
  228. where(cond, args...)
  229. }
  230. if protocols := parseCSVStrings(params.Protocol); len(protocols) > 0 {
  231. where("EXISTS (SELECT 1 FROM client_inbounds ci JOIN inbounds ib ON ib.id = ci.inbound_id"+
  232. " WHERE ci.client_id = c.id AND LOWER(ib.protocol) IN ?)", protocols)
  233. }
  234. if inboundIds := parseCSVInts(params.Inbound); len(inboundIds) > 0 {
  235. where("EXISTS (SELECT 1 FROM client_inbounds ci WHERE ci.client_id = c.id AND ci.inbound_id IN ?)", inboundIds)
  236. }
  237. if buckets := parseCSVStrings(params.Filter); len(buckets) > 0 {
  238. cond, args := q.bucketCond(buckets, onlines)
  239. where(cond, args...)
  240. }
  241. if params.ExpiryFrom > 0 || params.ExpiryTo > 0 {
  242. // 0 means "never expires" and a negative value is the delayed-start
  243. // sentinel; both sit outside any bounded range.
  244. where("c.expiry_time > 0")
  245. if params.ExpiryFrom > 0 {
  246. where("c.expiry_time >= ?", params.ExpiryFrom)
  247. }
  248. if params.ExpiryTo > 0 {
  249. where("c.expiry_time <= ?", params.ExpiryTo)
  250. }
  251. }
  252. if params.UsageFrom > 0 {
  253. where(q.usedExpr+" >= ?", params.UsageFrom)
  254. }
  255. if params.UsageTo > 0 {
  256. where(q.usedExpr+" <= ?", params.UsageTo)
  257. }
  258. switch strings.ToLower(strings.TrimSpace(params.AutoRenew)) {
  259. case "on":
  260. where("(COALESCE(c.reset, 0) > 0 OR COALESCE(c.reset_day, 0) > 0 OR COALESCE(c.reset_weekday, 0) > 0)")
  261. case "off":
  262. where("(COALESCE(c.reset, 0) <= 0 AND COALESCE(c.reset_day, 0) <= 0 AND COALESCE(c.reset_weekday, 0) <= 0)")
  263. }
  264. switch strings.ToLower(strings.TrimSpace(params.HasTgID)) {
  265. case "yes":
  266. where("COALESCE(c.tg_id, 0) <> 0")
  267. case "no":
  268. where("COALESCE(c.tg_id, 0) = 0")
  269. }
  270. switch strings.ToLower(strings.TrimSpace(params.HasComment)) {
  271. case "yes":
  272. where("TRIM(COALESCE(c.comment, '')) <> ''")
  273. case "no":
  274. where("TRIM(COALESCE(c.comment, '')) = ''")
  275. }
  276. if groups := parseCSVStrings(params.Group); len(groups) > 0 {
  277. // The raw names cover non-ASCII capitals, which SQLite's LOWER() leaves alone.
  278. where("(LOWER(TRIM(COALESCE(c.group_name, ''))) IN ? OR TRIM(COALESCE(c.group_name, '')) IN ?)", groups, groupVariants(params.Group))
  279. }
  280. return tx, narrowed
  281. }
  282. func (q clientQuery) bucketCond(buckets, onlines []string) (string, []any) {
  283. conds := make([]string, 0, len(buckets))
  284. args := make([]any, 0, len(buckets))
  285. for _, b := range buckets {
  286. switch b {
  287. case "active":
  288. conds = append(conds, q.activeExpr())
  289. case "deactive":
  290. conds = append(conds, q.deactiveExpr())
  291. case "depleted":
  292. conds = append(conds, q.depletedExpr())
  293. case "expiring":
  294. conds = append(conds, q.expiringExpr())
  295. case "online":
  296. cond, inArgs := emailInCond("c.email", onlines)
  297. conds = append(conds, "("+sqlClientEnabled+" AND "+cond+")")
  298. args = append(args, inArgs...)
  299. default:
  300. // An unrecognised bucket name matched every client before the
  301. // predicates moved into SQL; keep that so a stale saved filter
  302. // cannot silently empty the table.
  303. conds = append(conds, "(1 = 1)")
  304. }
  305. }
  306. return "(" + strings.Join(conds, " OR ") + ")", args
  307. }
  308. func (q clientQuery) applyOrder(tx *gorm.DB, sortKey, order string) *gorm.DB {
  309. dir := " ASC"
  310. if order == "descend" {
  311. dir = " DESC"
  312. }
  313. // createdAt / updatedAt / lastOnline broke ties on the client id inside the
  314. // comparator, so reversing the sort reversed the tiebreak with it. The
  315. // other keys leaned on a stable sort over an id-ordered slice instead.
  316. tieDir := " ASC"
  317. var expr string
  318. switch sortKey {
  319. case "enable":
  320. expr = sqlClientEnabled
  321. case "email":
  322. expr = "LOWER(c.email)"
  323. case "inboundIds":
  324. expr = "(SELECT COUNT(*) FROM client_inbounds ci WHERE ci.client_id = c.id)"
  325. case "traffic":
  326. expr = q.usedExpr
  327. case "remaining":
  328. expr = "CASE WHEN c.total_gb > 0 THEN c.total_gb - " + q.usedExpr + " ELSE " + sqlNeverSentinel + " END"
  329. case "expiryTime":
  330. expr = "CASE WHEN c.expiry_time > 0 THEN c.expiry_time ELSE " + sqlNeverSentinel + " END"
  331. case "createdAt":
  332. expr, tieDir = "c.created_at", dir
  333. case "updatedAt":
  334. expr, tieDir = "c.updated_at", dir
  335. case "lastOnline":
  336. expr, tieDir = "COALESCE(ct.last_online, 0)", dir
  337. default:
  338. return tx.Order("c.id ASC")
  339. }
  340. return tx.Order(expr + dir + ", c.id" + tieDir)
  341. }
  342. // ListPaged returns one page of clients together with the counts the clients
  343. // page header needs. Every predicate runs in SQL, so the cost tracks the page
  344. // size rather than the number of clients on the panel.
  345. func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *SettingService, params ClientPageParams) (*ClientPageResponse, error) {
  346. db := database.GetDB()
  347. pageSize := params.PageSize
  348. if pageSize <= 0 {
  349. pageSize = clientPageDefaultSize
  350. }
  351. if pageSize > clientPageMaxSize {
  352. pageSize = clientPageMaxSize
  353. }
  354. page := params.Page
  355. if page <= 0 {
  356. page = 1
  357. }
  358. var expireDiffMs, trafficDiffBytes int64
  359. if settingSvc != nil {
  360. if v, err := settingSvc.GetExpireDiff(); err == nil {
  361. expireDiffMs = int64(v) * 86400000
  362. }
  363. if v, err := settingSvc.GetTrafficDiff(); err == nil {
  364. trafficDiffBytes = int64(v) * 1073741824
  365. }
  366. }
  367. onlines := inboundSvc.GetOnlineClients()
  368. q := newClientQuery(db, time.Now().UnixMilli(), expireDiffMs, trafficDiffBytes)
  369. var total int64
  370. if err := db.Model(&model.ClientRecord{}).Count(&total).Error; err != nil {
  371. return nil, err
  372. }
  373. summary, err := q.summary(onlines, int(total))
  374. if err != nil {
  375. return nil, err
  376. }
  377. filtered := total
  378. if scoped, narrowed := q.applyParams(q.from(), params, onlines); narrowed {
  379. if err := scoped.Count(&filtered).Error; err != nil {
  380. return nil, err
  381. }
  382. }
  383. items := []ClientSlim{}
  384. offset := (page - 1) * pageSize
  385. if int64(offset) < filtered {
  386. items, err = q.pageRows(params, onlines, offset, pageSize)
  387. if err != nil {
  388. return nil, err
  389. }
  390. }
  391. groups, err := s.listGroupNames()
  392. if err != nil {
  393. return nil, err
  394. }
  395. return &ClientPageResponse{
  396. Items: items,
  397. Total: int(total),
  398. Filtered: int(filtered),
  399. Page: page,
  400. PageSize: pageSize,
  401. Summary: summary,
  402. Groups: groups,
  403. }, nil
  404. }
  405. // pageRows resolves the requested page to client ids, then loads the records,
  406. // attachments and traffic for those ids only. A page never exceeds
  407. // clientPageMaxSize rows, which stays under sqlInChunk, so the follow-up IN
  408. // lists need no chunking.
  409. func (q clientQuery) pageRows(params ClientPageParams, onlines []string, offset, limit int) ([]ClientSlim, error) {
  410. tx, _ := q.applyParams(q.from(), params, onlines)
  411. var ids []int
  412. if err := q.applyOrder(tx, params.Sort, params.Order).
  413. Offset(offset).Limit(limit).
  414. Pluck("c.id", &ids).Error; err != nil {
  415. return nil, err
  416. }
  417. if len(ids) == 0 {
  418. return []ClientSlim{}, nil
  419. }
  420. var records []model.ClientRecord
  421. if err := q.db.Where("id IN ?", ids).Find(&records).Error; err != nil {
  422. return nil, err
  423. }
  424. byId := make(map[int]*model.ClientRecord, len(records))
  425. emails := make([]string, 0, len(records))
  426. for i := range records {
  427. byId[records[i].Id] = &records[i]
  428. if records[i].Email != "" {
  429. emails = append(emails, records[i].Email)
  430. }
  431. }
  432. var links []model.ClientInbound
  433. if err := q.db.Where("client_id IN ?", ids).Order("inbound_id ASC").Find(&links).Error; err != nil {
  434. return nil, err
  435. }
  436. attachments := make(map[int][]int, len(ids))
  437. for _, l := range links {
  438. attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId)
  439. }
  440. trafficByEmail := make(map[string]*xray.ClientTraffic, len(emails))
  441. if len(emails) > 0 {
  442. var stats []xray.ClientTraffic
  443. if err := q.db.Where("email IN ?", emails).Find(&stats).Error; err != nil {
  444. return nil, err
  445. }
  446. overlayGlobalTrafficValues(q.db, stats)
  447. for i := range stats {
  448. trafficByEmail[stats[i].Email] = &stats[i]
  449. }
  450. }
  451. items := make([]ClientSlim, 0, len(ids))
  452. for _, id := range ids {
  453. rec := byId[id]
  454. if rec == nil {
  455. continue
  456. }
  457. items = append(items, toClientSlim(ClientWithAttachments{
  458. ClientRecord: *rec,
  459. InboundIds: attachments[rec.Id],
  460. Traffic: trafficByEmail[rec.Email],
  461. }))
  462. }
  463. return items, nil
  464. }
  465. func (q clientQuery) summary(onlines []string, total int) (ClientsSummary, error) {
  466. s := ClientsSummary{
  467. Total: total,
  468. Online: []string{},
  469. Depleted: []string{},
  470. Expiring: []string{},
  471. Deactive: []string{},
  472. }
  473. var counts struct {
  474. Active int64
  475. Depleted int64
  476. Expiring int64
  477. Deactive int64
  478. }
  479. // SUM over an empty table yields NULL, which not every driver scans into an
  480. // int; COALESCE keeps a panel with no clients from erroring out.
  481. if err := q.from().Select(
  482. "COALESCE(SUM(CASE WHEN " + q.activeExpr() + " THEN 1 ELSE 0 END), 0) AS active," +
  483. " COALESCE(SUM(CASE WHEN " + q.depletedExpr() + " THEN 1 ELSE 0 END), 0) AS depleted," +
  484. " COALESCE(SUM(CASE WHEN " + q.expiringExpr() + " THEN 1 ELSE 0 END), 0) AS expiring," +
  485. " COALESCE(SUM(CASE WHEN " + q.deactiveExpr() + " THEN 1 ELSE 0 END), 0) AS deactive",
  486. ).Scan(&counts).Error; err != nil {
  487. return s, err
  488. }
  489. s.Active = int(counts.Active)
  490. s.DepletedCount = int(counts.Depleted)
  491. s.ExpiringCount = int(counts.Expiring)
  492. s.DeactiveCount = int(counts.Deactive)
  493. buckets := []struct {
  494. cond string
  495. count int
  496. out *[]string
  497. }{
  498. {q.depletedExpr(), s.DepletedCount, &s.Depleted},
  499. {q.expiringExpr(), s.ExpiringCount, &s.Expiring},
  500. {q.deactiveExpr(), s.DeactiveCount, &s.Deactive},
  501. }
  502. for _, b := range buckets {
  503. // The counter already says the bucket is empty, so skip the scan that
  504. // would look for emails it cannot find.
  505. if b.count == 0 {
  506. continue
  507. }
  508. var emails []string
  509. if err := q.from().Where(b.cond).
  510. Order("c.id ASC").Limit(clientSummaryEmailCap).
  511. Pluck("c.email", &emails).Error; err != nil {
  512. return s, err
  513. }
  514. if len(emails) > 0 {
  515. *b.out = emails
  516. }
  517. }
  518. online, onlineCount, err := q.onlineEmails(onlines)
  519. if err != nil {
  520. return s, err
  521. }
  522. s.Online = online
  523. s.OnlineCount = onlineCount
  524. return s, nil
  525. }
  526. // onlineEmails intersects the emails xray reports as connected with the enabled
  527. // clients this panel stores. The online set lives in memory and is bounded by
  528. // live connections, so it drives the query rather than a scan of every client.
  529. func (q clientQuery) onlineEmails(onlines []string) ([]string, int, error) {
  530. matched := []string{}
  531. count := 0
  532. for _, batch := range chunkStrings(onlines, sqlInChunk) {
  533. var page []string
  534. if err := q.db.Model(&model.ClientRecord{}).
  535. Where("COALESCE(enable, FALSE) = TRUE AND email IN ?", batch).
  536. Order("id ASC").
  537. Pluck("email", &page).Error; err != nil {
  538. return nil, 0, err
  539. }
  540. count += len(page)
  541. if room := clientSummaryEmailCap - len(matched); room > 0 {
  542. matched = append(matched, page[:min(room, len(page))]...)
  543. }
  544. }
  545. return matched, count, nil
  546. }
  547. // listGroupNames returns the group names the clients page offers as filters:
  548. // the stored groups plus any name a client still carries. ListGroups also sums
  549. // per-client traffic per group, which this page never reads and which costs a
  550. // full join over client_traffics on every poll.
  551. func (s *ClientService) listGroupNames() ([]string, error) {
  552. db := database.GetDB()
  553. var stored []string
  554. if err := db.Model(&model.ClientGroup{}).Pluck("name", &stored).Error; err != nil {
  555. return nil, err
  556. }
  557. var used []string
  558. if err := db.Model(&model.ClientRecord{}).
  559. Where("group_name <> ''").
  560. Distinct().
  561. Pluck("group_name", &used).Error; err != nil {
  562. return nil, err
  563. }
  564. seen := make(map[string]struct{}, len(stored)+len(used))
  565. out := make([]string, 0, len(stored)+len(used))
  566. for _, list := range [][]string{stored, used} {
  567. for _, name := range list {
  568. if name == "" {
  569. continue
  570. }
  571. if _, dup := seen[name]; dup {
  572. continue
  573. }
  574. seen[name] = struct{}{}
  575. out = append(out, name)
  576. }
  577. }
  578. sort.Slice(out, func(i, j int) bool {
  579. return strings.ToLower(out[i]) < strings.ToLower(out[j])
  580. })
  581. return out, nil
  582. }
  583. func sqlInt(v int64) string {
  584. return strconv.FormatInt(v, 10)
  585. }
  586. func toClientSlim(c ClientWithAttachments) ClientSlim {
  587. return ClientSlim{
  588. Email: c.Email,
  589. SubID: c.SubID,
  590. Enable: c.Enable,
  591. TotalGB: c.TotalGB,
  592. ExpiryTime: c.ExpiryTime,
  593. LimitIP: c.LimitIP,
  594. LimitHwid: c.LimitHwid,
  595. Reset: c.Reset,
  596. ResetDay: c.ResetDay,
  597. ResetWeekday: c.ResetWeekday,
  598. ResetMax: c.ResetMax,
  599. Group: c.Group,
  600. Comment: c.Comment,
  601. InboundIds: c.InboundIds,
  602. Traffic: c.Traffic,
  603. CreatedAt: c.CreatedAt,
  604. UpdatedAt: c.UpdatedAt,
  605. }
  606. }
  607. // escapeLikeLiteral neutralises LIKE wildcards so searching for "a_b" keeps
  608. // matching literally, the way strings.Contains did.
  609. func escapeLikeLiteral(s string) string {
  610. return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
  611. }
  612. // emailInCond renders an IN over a possibly large email set, split so no single
  613. // IN list outgrows the drivers' bind-parameter ceiling.
  614. func emailInCond(column string, emails []string) (string, []any) {
  615. if len(emails) == 0 {
  616. return "1 = 0", nil
  617. }
  618. chunks := chunkStrings(emails, sqlInChunk)
  619. parts := make([]string, 0, len(chunks))
  620. args := make([]any, 0, len(chunks))
  621. for _, chunk := range chunks {
  622. parts = append(parts, column+" IN ?")
  623. args = append(args, chunk)
  624. }
  625. return "(" + strings.Join(parts, " OR ") + ")", args
  626. }
  627. // parseCSVStrings splits a comma-separated list, trims/lower-cases each item,
  628. // and drops blanks. Returns nil when the input has no usable entries — the
  629. // caller can then skip the predicate entirely.
  630. func parseCSVStrings(raw string) []string {
  631. if raw == "" {
  632. return nil
  633. }
  634. parts := strings.Split(raw, ",")
  635. out := make([]string, 0, len(parts))
  636. for _, p := range parts {
  637. s := strings.ToLower(strings.TrimSpace(p))
  638. if s != "" {
  639. out = append(out, s)
  640. }
  641. }
  642. if len(out) == 0 {
  643. return nil
  644. }
  645. return out
  646. }
  647. // groupVariants is every case spelling of each requested group name.
  648. func groupVariants(raw string) []string {
  649. var out []string
  650. for _, p := range strings.Split(raw, ",") {
  651. if p = strings.TrimSpace(p); p != "" {
  652. for _, v := range caseVariants(p) {
  653. if !slices.Contains(out, v) {
  654. out = append(out, v)
  655. }
  656. }
  657. }
  658. }
  659. return out
  660. }
  661. // parseCSVInts is parseCSVStrings for positive integer IDs; non-numeric or
  662. // non-positive entries are silently dropped.
  663. func parseCSVInts(raw string) []int {
  664. if raw == "" {
  665. return nil
  666. }
  667. parts := strings.Split(raw, ",")
  668. out := make([]int, 0, len(parts))
  669. for _, p := range parts {
  670. s := strings.TrimSpace(p)
  671. if s == "" {
  672. continue
  673. }
  674. if n, err := strconv.Atoi(s); err == nil && n > 0 {
  675. out = append(out, n)
  676. }
  677. }
  678. if len(out) == 0 {
  679. return nil
  680. }
  681. return out
  682. }