1
0

client_paging.go 22 KB

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