Files
nginx-logtail/cmd/frontend/filter.go

176 lines
4.4 KiB
Go

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
}