package httpapi import ( "errors" "fmt" "net/url" "strconv" "strings" "time" "github.com/google/uuid" "golang.org/x/xerrors" "github.com/coder/coder/v2/codersdk" ) // QueryParamParser is a helper for parsing all query params and gathering all // errors in 1 sweep. This means all invalid fields are returned at once, // rather than only returning the first error type QueryParamParser struct { // Errors is the set of errors to return via the API. If the length // of this set is 0, there are no errors!. Errors []codersdk.ValidationError // Parsed is a map of all query params that were parsed. This is useful // for checking if extra query params were passed in. Parsed map[string]bool // RequiredNotEmptyParams is a map of all query params that are required. This is useful // for forcing a value to be provided. RequiredNotEmptyParams map[string]bool } func NewQueryParamParser() *QueryParamParser { return &QueryParamParser{ Errors: []codersdk.ValidationError{}, Parsed: map[string]bool{}, RequiredNotEmptyParams: map[string]bool{}, } } // ErrorExcessParams checks if any query params were passed in that were not // parsed. If so, it adds an error to the parser as these values are not valid // query parameters. func (p *QueryParamParser) ErrorExcessParams(values url.Values) { for k := range values { if _, ok := p.Parsed[k]; !ok { p.Errors = append(p.Errors, codersdk.ValidationError{ Field: k, Detail: fmt.Sprintf("%q is not a valid query param", k), }) } } } func (p *QueryParamParser) addParsed(key string) { p.Parsed[key] = true } func (p *QueryParamParser) UInt(vals url.Values, def uint64, queryParam string) uint64 { v, err := parseQueryParam(p, vals, func(v string) (uint64, error) { return strconv.ParseUint(v, 10, 64) }, def, queryParam) if err != nil { p.Errors = append(p.Errors, codersdk.ValidationError{ Field: queryParam, Detail: fmt.Sprintf("Query param %q must be a valid positive integer: %s", queryParam, err.Error()), }) return 0 } return v } func (p *QueryParamParser) Int(vals url.Values, def int, queryParam string) int { v, err := parseQueryParam(p, vals, strconv.Atoi, def, queryParam) if err != nil { p.Errors = append(p.Errors, codersdk.ValidationError{ Field: queryParam, Detail: fmt.Sprintf("Query param %q must be a valid integer: %s", queryParam, err.Error()), }) } return v } // PositiveInt32 function checks if the given value is 32-bit and positive. // // We can't use `uint32` as the value must be within the range <0,2147483647> // as database expects it. Otherwise, the database query fails with `pq: OFFSET must not be negative`. func (p *QueryParamParser) PositiveInt32(vals url.Values, def int32, queryParam string) int32 { v, err := parseQueryParam(p, vals, func(v string) (int32, error) { intValue, err := strconv.ParseInt(v, 10, 32) if err != nil { return 0, err } if intValue < 0 { return 0, xerrors.Errorf("value is negative") } return int32(intValue), nil }, def, queryParam) if err != nil { p.Errors = append(p.Errors, codersdk.ValidationError{ Field: queryParam, Detail: fmt.Sprintf("Query param %q must be a valid 32-bit positive integer: %s", queryParam, err.Error()), }) } return v } func (p *QueryParamParser) Boolean(vals url.Values, def bool, queryParam string) bool { v, err := parseQueryParam(p, vals, strconv.ParseBool, def, queryParam) if err != nil { p.Errors = append(p.Errors, codersdk.ValidationError{ Field: queryParam, Detail: fmt.Sprintf("Query param %q must be a valid boolean: %s", queryParam, err.Error()), }) } return v } func (p *QueryParamParser) RequiredNotEmpty(queryParam ...string) *QueryParamParser { for _, q := range queryParam { p.RequiredNotEmptyParams[q] = true } return p } func (p *QueryParamParser) UUIDorMe(vals url.Values, def uuid.UUID, me uuid.UUID, queryParam string) uuid.UUID { return ParseCustom(p, vals, def, queryParam, func(v string) (uuid.UUID, error) { if v == "me" { return me, nil } return uuid.Parse(v) }) } func (p *QueryParamParser) UUID(vals url.Values, def uuid.UUID, queryParam string) uuid.UUID { v, err := parseQueryParam(p, vals, uuid.Parse, def, queryParam) if err != nil { p.Errors = append(p.Errors, codersdk.ValidationError{ Field: queryParam, Detail: fmt.Sprintf("Query param %q must be a valid uuid", queryParam), }) } return v } func (p *QueryParamParser) UUIDs(vals url.Values, def []uuid.UUID, queryParam string) []uuid.UUID { return ParseCustomList(p, vals, def, queryParam, func(v string) (uuid.UUID, error) { return uuid.Parse(strings.TrimSpace(v)) }) } func (p *QueryParamParser) RedirectURL(vals url.Values, base *url.URL, queryParam string) *url.URL { v, err := parseQueryParam(p, vals, url.Parse, base, queryParam) if err != nil { p.Errors = append(p.Errors, codersdk.ValidationError{ Field: queryParam, Detail: fmt.Sprintf("Query param %q must be a valid url: %s", queryParam, err.Error()), }) } // It can be a sub-directory but not a sub-domain, as we have apps on // sub-domains and that seems too dangerous. if v.Host != base.Host || !strings.HasPrefix(v.Path, base.Path) { p.Errors = append(p.Errors, codersdk.ValidationError{ Field: queryParam, Detail: fmt.Sprintf("Query param %q must be a subset of %s", queryParam, base), }) } return v } func (p *QueryParamParser) Time(vals url.Values, def time.Time, queryParam, layout string) time.Time { return p.timeWithMutate(vals, def, queryParam, layout, nil) } // Time uses the default time format of RFC3339Nano and always returns a UTC time. func (p *QueryParamParser) Time3339Nano(vals url.Values, def time.Time, queryParam string) time.Time { layout := time.RFC3339Nano return p.timeWithMutate(vals, def, queryParam, layout, func(term string) string { // All search queries are forced to lowercase. But the RFC format requires // upper case letters. So just uppercase the term. return strings.ToUpper(term) }) } func (p *QueryParamParser) timeWithMutate(vals url.Values, def time.Time, queryParam, layout string, mutate func(term string) string) time.Time { v, err := parseQueryParam(p, vals, func(term string) (time.Time, error) { if mutate != nil { term = mutate(term) } t, err := time.Parse(layout, term) if err != nil { return time.Time{}, err } return t.UTC(), nil }, def, queryParam) if err != nil { p.Errors = append(p.Errors, codersdk.ValidationError{ Field: queryParam, Detail: fmt.Sprintf("Query param %q must be a valid date format (%s): %s", queryParam, layout, err.Error()), }) } return v } func (p *QueryParamParser) String(vals url.Values, def string, queryParam string) string { v, err := parseQueryParam(p, vals, func(v string) (string, error) { return v, nil }, def, queryParam) if err != nil { p.Errors = append(p.Errors, codersdk.ValidationError{ Field: queryParam, Detail: fmt.Sprintf("Query param %q must be a valid string: %s", queryParam, err.Error()), }) } return v } func (p *QueryParamParser) Strings(vals url.Values, def []string, queryParam string) []string { return ParseCustomList(p, vals, def, queryParam, func(v string) (string, error) { return v, nil }) } // ValidEnum represents an enum that can be parsed and validated. type ValidEnum interface { // Add more types as needed (avoid importing large dependency trees). ~string // Valid is required on the enum type to be used with ParseEnum. Valid() bool } // ParseEnum is a function that can be passed into ParseCustom that handles enum // validation. func ParseEnum[T ValidEnum](term string) (T, error) { enum := T(term) if enum.Valid() { return enum, nil } var empty T return empty, xerrors.Errorf("%q is not a valid value", term) } // ParseCustom has to be a function, not a method on QueryParamParser because generics // cannot be used on struct methods. func ParseCustom[T any](parser *QueryParamParser, vals url.Values, def T, queryParam string, parseFunc func(v string) (T, error)) T { v, err := parseQueryParam(parser, vals, parseFunc, def, queryParam) if err != nil { parser.Errors = append(parser.Errors, codersdk.ValidationError{ Field: queryParam, Detail: fmt.Sprintf("Query param %q has invalid value: %s", queryParam, err.Error()), }) } return v } // ParseCustomList is a function that handles csv query params or multiple values // for a query param. // Csv is supported as it is a common way to pass multiple values in a query param. // Multiple values is supported (key=value&key=value2) for feature parity with GitHub issue search. func ParseCustomList[T any](parser *QueryParamParser, vals url.Values, def []T, queryParam string, parseFunc func(v string) (T, error)) []T { v, err := parseQueryParamSet(parser, vals, func(set []string) ([]T, error) { // Gather all terms. allTerms := make([]string, 0, len(set)) for _, s := range set { // If a term is a csv, break it out into individual terms. terms := strings.Split(s, ",") allTerms = append(allTerms, terms...) } var badErrors error var output []T for _, s := range allTerms { good, err := parseFunc(s) if err != nil { badErrors = errors.Join(badErrors, err) continue } output = append(output, good) } if badErrors != nil { return []T{}, badErrors } return output, nil }, def, queryParam) if err != nil { parser.Errors = append(parser.Errors, codersdk.ValidationError{ Field: queryParam, Detail: fmt.Sprintf("Query param %q has invalid values: %s", queryParam, err.Error()), }) } return v } // parseQueryParam expects just 1 value set for the given query param. func parseQueryParam[T any](parser *QueryParamParser, vals url.Values, parse func(v string) (T, error), def T, queryParam string) (T, error) { setParse := func(set []string) (T, error) { if len(set) > 1 { // Set as a parser.Error rather than return an error. // Returned errors are errors from the passed in `parse` function, and // imply the query param value had attempted to be parsed. // By raising the error this way, we can also more easily control how it // is presented to the user. A returned error is wrapped with more text. parser.Errors = append(parser.Errors, codersdk.ValidationError{ Field: queryParam, Detail: fmt.Sprintf("Query param %q provided more than once, found %d times. Only provide 1 instance of this query param.", queryParam, len(set)), }) return def, nil } return parse(set[0]) } return parseQueryParamSet(parser, vals, setParse, def, queryParam) } func parseQueryParamSet[T any](parser *QueryParamParser, vals url.Values, parse func(set []string) (T, error), def T, queryParam string) (T, error) { parser.addParsed(queryParam) // If the query param is required and not present, return an error. if parser.RequiredNotEmptyParams[queryParam] && (!vals.Has(queryParam) || vals.Get(queryParam) == "") { parser.Errors = append(parser.Errors, codersdk.ValidationError{ Field: queryParam, Detail: fmt.Sprintf("Query param %q is required and cannot be empty", queryParam), }) return def, nil } // If the query param is not present, return the default value. if !vals.Has(queryParam) || vals.Get(queryParam) == "" { return def, nil } return parse(vals[queryParam]) }