|
@@ -1,6 +1,7 @@
|
|
|
package service
|
|
package service
|
|
|
|
|
|
|
|
import (
|
|
import (
|
|
|
|
|
+ "slices"
|
|
|
"sort"
|
|
"sort"
|
|
|
"strconv"
|
|
"strconv"
|
|
|
"strings"
|
|
"strings"
|
|
@@ -113,13 +114,45 @@ const (
|
|
|
sqlClientEnabled = "COALESCE(c.enable, FALSE)"
|
|
sqlClientEnabled = "COALESCE(c.enable, FALSE)"
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
-const clientSearchCond = `(LOWER(c.email) LIKE ? ESCAPE '\'
|
|
|
|
|
- OR LOWER(COALESCE(c.sub_id, '')) LIKE ? ESCAPE '\'
|
|
|
|
|
- OR LOWER(COALESCE(c.comment, '')) LIKE ? ESCAPE '\'
|
|
|
|
|
- OR LOWER(COALESCE(c.uuid, '')) LIKE ? ESCAPE '\'
|
|
|
|
|
- OR LOWER(COALESCE(c.password, '')) LIKE ? ESCAPE '\'
|
|
|
|
|
- OR LOWER(COALESCE(c.auth, '')) LIKE ? ESCAPE '\'
|
|
|
|
|
- OR (COALESCE(c.tg_id, 0) <> 0 AND CAST(c.tg_id AS TEXT) LIKE ? ESCAPE '\'))`
|
|
|
|
|
|
|
+// clientSearchCols are the text columns the search box matches.
|
|
|
|
|
+var clientSearchCols = []string{"c.email", "COALESCE(c.sub_id, '')", "COALESCE(c.comment, '')",
|
|
|
|
|
+ "COALESCE(c.uuid, '')", "COALESCE(c.password, '')", "COALESCE(c.auth, '')"}
|
|
|
|
|
+
|
|
|
|
|
+// caseVariants returns s lower-cased, as typed, upper-cased and title-cased.
|
|
|
|
|
+// SQLite's LOWER() and LIKE fold ASCII only, so non-ASCII text is matched
|
|
|
|
|
+// against these spellings instead of relying on the database to fold it.
|
|
|
|
|
+func caseVariants(s string) []string {
|
|
|
|
|
+ title := s
|
|
|
|
|
+ if r := []rune(strings.ToLower(s)); len(r) > 0 {
|
|
|
|
|
+ title = strings.ToUpper(string(r[:1])) + string(r[1:])
|
|
|
|
|
+ }
|
|
|
|
|
+ out := make([]string, 0, 4)
|
|
|
|
|
+ for _, v := range []string{strings.ToLower(s), s, strings.ToUpper(s), title} {
|
|
|
|
|
+ if !slices.Contains(out, v) {
|
|
|
|
|
+ out = append(out, v)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return out
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// clientSearchCond builds the search predicate for the given needle variants
|
|
|
|
|
+// (lowered first) and returns its arguments.
|
|
|
|
|
+func clientSearchCond(variants []string) (string, []any) {
|
|
|
|
|
+ var parts []string
|
|
|
|
|
+ var args []any
|
|
|
|
|
+ like := func(v string) string { return "%" + escapeLikeLiteral(v) + "%" }
|
|
|
|
|
+ for _, col := range clientSearchCols {
|
|
|
|
|
+ parts = append(parts, "LOWER("+col+") LIKE ? ESCAPE '\\'")
|
|
|
|
|
+ args = append(args, like(variants[0]))
|
|
|
|
|
+ for _, v := range variants {
|
|
|
|
|
+ parts = append(parts, col+" LIKE ? ESCAPE '\\'")
|
|
|
|
|
+ args = append(args, like(v))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ parts = append(parts, "(COALESCE(c.tg_id, 0) <> 0 AND CAST(c.tg_id AS TEXT) LIKE ? ESCAPE '\\')")
|
|
|
|
|
+ args = append(args, like(variants[0]))
|
|
|
|
|
+ return "(" + strings.Join(parts, " OR ") + ")", args
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
// clientQuery builds the statements behind the clients page: a clients row
|
|
// clientQuery builds the statements behind the clients page: a clients row
|
|
|
// joined to its traffic counters, plus the expressions every bucket predicate
|
|
// joined to its traffic counters, plus the expressions every bucket predicate
|
|
@@ -213,9 +246,9 @@ func (q clientQuery) applyParams(tx *gorm.DB, params ClientPageParams, onlines [
|
|
|
tx = tx.Where(cond, args...)
|
|
tx = tx.Where(cond, args...)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- if needle := strings.ToLower(strings.TrimSpace(params.Search)); needle != "" {
|
|
|
|
|
- pattern := "%" + escapeLikeLiteral(needle) + "%"
|
|
|
|
|
- where(clientSearchCond, pattern, pattern, pattern, pattern, pattern, pattern, pattern)
|
|
|
|
|
|
|
+ if needle := strings.TrimSpace(params.Search); needle != "" {
|
|
|
|
|
+ cond, args := clientSearchCond(caseVariants(needle))
|
|
|
|
|
+ where(cond, args...)
|
|
|
}
|
|
}
|
|
|
if protocols := parseCSVStrings(params.Protocol); len(protocols) > 0 {
|
|
if protocols := parseCSVStrings(params.Protocol); len(protocols) > 0 {
|
|
|
where("EXISTS (SELECT 1 FROM client_inbounds ci JOIN inbounds ib ON ib.id = ci.inbound_id"+
|
|
where("EXISTS (SELECT 1 FROM client_inbounds ci JOIN inbounds ib ON ib.id = ci.inbound_id"+
|
|
@@ -264,7 +297,8 @@ func (q clientQuery) applyParams(tx *gorm.DB, params ClientPageParams, onlines [
|
|
|
where("TRIM(COALESCE(c.comment, '')) = ''")
|
|
where("TRIM(COALESCE(c.comment, '')) = ''")
|
|
|
}
|
|
}
|
|
|
if groups := parseCSVStrings(params.Group); len(groups) > 0 {
|
|
if groups := parseCSVStrings(params.Group); len(groups) > 0 {
|
|
|
- where("LOWER(TRIM(COALESCE(c.group_name, ''))) IN ?", groups)
|
|
|
|
|
|
|
+ // The raw names cover non-ASCII capitals, which SQLite's LOWER() leaves alone.
|
|
|
|
|
+ where("(LOWER(TRIM(COALESCE(c.group_name, ''))) IN ? OR TRIM(COALESCE(c.group_name, '')) IN ?)", groups, groupVariants(params.Group))
|
|
|
}
|
|
}
|
|
|
return tx, narrowed
|
|
return tx, narrowed
|
|
|
}
|
|
}
|
|
@@ -662,6 +696,21 @@ func parseCSVStrings(raw string) []string {
|
|
|
return out
|
|
return out
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// groupVariants is every case spelling of each requested group name.
|
|
|
|
|
+func groupVariants(raw string) []string {
|
|
|
|
|
+ var out []string
|
|
|
|
|
+ for _, p := range strings.Split(raw, ",") {
|
|
|
|
|
+ if p = strings.TrimSpace(p); p != "" {
|
|
|
|
|
+ for _, v := range caseVariants(p) {
|
|
|
|
|
+ if !slices.Contains(out, v) {
|
|
|
|
|
+ out = append(out, v)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return out
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// parseCSVInts is parseCSVStrings for positive integer IDs; non-numeric or
|
|
// parseCSVInts is parseCSVStrings for positive integer IDs; non-numeric or
|
|
|
// non-positive entries are silently dropped.
|
|
// non-positive entries are silently dropped.
|
|
|
func parseCSVInts(raw string) []int {
|
|
func parseCSVInts(raw string) []int {
|