package main import ( "fmt" "regexp" "strings" st "git.ipng.ch/ipng/nginx-logtail/internal/store" ) // andRe splits a filter expression on AND (case-insensitive, surrounded by whitespace). var andRe = regexp.MustCompile(`(?i)\s+and\s+`) // ParseFilterExpr parses a mini filter expression into a filterState. // // Syntax: TERM [AND TERM ...] // // Terms: // // status=200 status!=200 status>=400 status>400 status<=500 status<500 // website=example.com — exact match // website~=gouda.* — RE2 regex // uri=/api/v1/ — exact match // uri~=^/api/.* — RE2 regex // prefix=1.2.3.0/24 — exact match // // Values may be enclosed in double or single quotes. func ParseFilterExpr(s string) (filterState, error) { s = strings.TrimSpace(s) if s == "" { return filterState{}, nil } terms := andRe.Split(s, -1) var fs filterState for _, term := range terms { term = strings.TrimSpace(term) if term == "" { continue } if err := applyTerm(term, &fs); err != nil { return filterState{}, err } } return fs, nil } // applyTerm parses a single "field op value" term into fs. func applyTerm(term string, fs *filterState) error { // Find the first operator character: ~, !, >, <, = opIdx := strings.IndexAny(term, "~!><=") if opIdx <= 0 { return fmt.Errorf("invalid term %q: expected field=value, field>=value, field~=regex, etc.", term) } field := strings.ToLower(strings.TrimSpace(term[:opIdx])) rest := term[opIdx:] var op, value string switch { case strings.HasPrefix(rest, "~="): op, value = "~=", rest[2:] case strings.HasPrefix(rest, "!="): op, value = "!=", rest[2:] case strings.HasPrefix(rest, ">="): op, value = ">=", rest[2:] case strings.HasPrefix(rest, "<="): op, value = "<=", rest[2:] case strings.HasPrefix(rest, ">"): op, value = ">", rest[1:] case strings.HasPrefix(rest, "<"): op, value = "<", rest[1:] case strings.HasPrefix(rest, "="): op, value = "=", rest[1:] default: return fmt.Errorf("unrecognised operator in %q", term) } value = unquote(strings.TrimSpace(value)) switch field { case "status": if op == "~=" { return fmt.Errorf("status does not support ~=; use =, !=, >=, >, <=, <") } expr := op + value if op == "=" { expr = value // ParseStatusExpr accepts bare "200" } if _, _, ok := st.ParseStatusExpr(expr); !ok { return fmt.Errorf("invalid status expression %q", expr) } fs.Status = expr case "website": switch op { case "=": fs.Website = value case "~=": fs.WebsiteRe = value default: return fmt.Errorf("website only supports = and ~=, not %q", op) } case "uri": switch op { case "=": fs.URI = value case "~=": fs.URIRe = value default: return fmt.Errorf("uri only supports = and ~=, not %q", op) } case "prefix": if op != "=" { return fmt.Errorf("prefix only supports =, not %q", op) } fs.Prefix = value default: return fmt.Errorf("unknown field %q; valid: status, website, uri, prefix", field) } return nil } // unquote strips surrounding double or single quotes. func unquote(s string) string { if len(s) >= 2 { if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { return s[1 : len(s)-1] } } return s } // FilterExprString serialises a filterState back into the mini filter expression language. // Returns "" when no filters are active. func FilterExprString(f filterState) string { var parts []string if f.Website != "" { parts = append(parts, "website="+quoteMaybe(f.Website)) } if f.WebsiteRe != "" { parts = append(parts, "website~="+quoteMaybe(f.WebsiteRe)) } if f.Prefix != "" { parts = append(parts, "prefix="+quoteMaybe(f.Prefix)) } if f.URI != "" { parts = append(parts, "uri="+quoteMaybe(f.URI)) } if f.URIRe != "" { parts = append(parts, "uri~="+quoteMaybe(f.URIRe)) } if f.Status != "" { parts = append(parts, statusTermStr(f.Status)) } return strings.Join(parts, " AND ") } // statusTermStr converts a stored status expression (">=400", "200") to a // full filter term ("status>=400", "status=200"). func statusTermStr(expr string) string { if expr == "" { return "" } if len(expr) > 0 && (expr[0] == '!' || expr[0] == '>' || expr[0] == '<') { return "status" + expr } return "status=" + expr } // quoteMaybe wraps s in double quotes when it contains spaces or quote characters. func quoteMaybe(s string) string { if strings.ContainsAny(s, " \t\"'") { return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"` } return s }