mirror of https://github.com/coder/coder.git
229 lines
7.9 KiB
Go
229 lines
7.9 KiB
Go
package searchquery
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/httpapi"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
func AuditLogs(query string) (database.GetAuditLogsOffsetParams, []codersdk.ValidationError) {
|
|
// Always lowercase for all searches.
|
|
query = strings.ToLower(query)
|
|
values, errors := searchTerms(query, func(term string, values url.Values) error {
|
|
values.Add("resource_type", term)
|
|
return nil
|
|
})
|
|
if len(errors) > 0 {
|
|
return database.GetAuditLogsOffsetParams{}, errors
|
|
}
|
|
|
|
const dateLayout = "2006-01-02"
|
|
parser := httpapi.NewQueryParamParser()
|
|
filter := database.GetAuditLogsOffsetParams{
|
|
ResourceID: parser.UUID(values, uuid.Nil, "resource_id"),
|
|
ResourceTarget: parser.String(values, "", "resource_target"),
|
|
Username: parser.String(values, "", "username"),
|
|
Email: parser.String(values, "", "email"),
|
|
DateFrom: parser.Time(values, time.Time{}, "date_from", dateLayout),
|
|
DateTo: parser.Time(values, time.Time{}, "date_to", dateLayout),
|
|
ResourceType: string(httpapi.ParseCustom(parser, values, "", "resource_type", httpapi.ParseEnum[database.ResourceType])),
|
|
Action: string(httpapi.ParseCustom(parser, values, "", "action", httpapi.ParseEnum[database.AuditAction])),
|
|
BuildReason: string(httpapi.ParseCustom(parser, values, "", "build_reason", httpapi.ParseEnum[database.BuildReason])),
|
|
}
|
|
if !filter.DateTo.IsZero() {
|
|
filter.DateTo = filter.DateTo.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
|
|
}
|
|
parser.ErrorExcessParams(values)
|
|
return filter, parser.Errors
|
|
}
|
|
|
|
func Users(query string) (database.GetUsersParams, []codersdk.ValidationError) {
|
|
// Always lowercase for all searches.
|
|
query = strings.ToLower(query)
|
|
values, errors := searchTerms(query, func(term string, values url.Values) error {
|
|
values.Add("search", term)
|
|
return nil
|
|
})
|
|
if len(errors) > 0 {
|
|
return database.GetUsersParams{}, errors
|
|
}
|
|
|
|
parser := httpapi.NewQueryParamParser()
|
|
filter := database.GetUsersParams{
|
|
Search: parser.String(values, "", "search"),
|
|
Status: httpapi.ParseCustomList(parser, values, []database.UserStatus{}, "status", httpapi.ParseEnum[database.UserStatus]),
|
|
RbacRole: parser.Strings(values, []string{}, "role"),
|
|
LastSeenAfter: parser.Time3339Nano(values, time.Time{}, "last_seen_after"),
|
|
LastSeenBefore: parser.Time3339Nano(values, time.Time{}, "last_seen_before"),
|
|
}
|
|
parser.ErrorExcessParams(values)
|
|
return filter, parser.Errors
|
|
}
|
|
|
|
func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectTimeout time.Duration) (database.GetWorkspacesParams, []codersdk.ValidationError) {
|
|
filter := database.GetWorkspacesParams{
|
|
AgentInactiveDisconnectTimeoutSeconds: int64(agentInactiveDisconnectTimeout.Seconds()),
|
|
|
|
Offset: int32(page.Offset),
|
|
Limit: int32(page.Limit),
|
|
}
|
|
|
|
if query == "" {
|
|
return filter, nil
|
|
}
|
|
|
|
// Always lowercase for all searches.
|
|
query = strings.ToLower(query)
|
|
values, errors := searchTerms(query, func(term string, values url.Values) error {
|
|
// It is a workspace name, and maybe includes an owner
|
|
parts := splitQueryParameterByDelimiter(term, '/', false)
|
|
switch len(parts) {
|
|
case 1:
|
|
values.Add("name", parts[0])
|
|
case 2:
|
|
values.Add("owner", parts[0])
|
|
values.Add("name", parts[1])
|
|
default:
|
|
return xerrors.Errorf("Query element %q can only contain 1 '/'", term)
|
|
}
|
|
return nil
|
|
})
|
|
if len(errors) > 0 {
|
|
return filter, errors
|
|
}
|
|
|
|
parser := httpapi.NewQueryParamParser()
|
|
filter.WorkspaceIds = parser.UUIDs(values, []uuid.UUID{}, "id")
|
|
filter.OwnerUsername = parser.String(values, "", "owner")
|
|
filter.TemplateName = parser.String(values, "", "template")
|
|
filter.Name = parser.String(values, "", "name")
|
|
filter.Status = string(httpapi.ParseCustom(parser, values, "", "status", httpapi.ParseEnum[database.WorkspaceStatus]))
|
|
filter.HasAgent = parser.String(values, "", "has-agent")
|
|
filter.Dormant = parser.Boolean(values, false, "dormant")
|
|
filter.LastUsedAfter = parser.Time3339Nano(values, time.Time{}, "last_used_after")
|
|
filter.LastUsedBefore = parser.Time3339Nano(values, time.Time{}, "last_used_before")
|
|
filter.UsingActive = sql.NullBool{
|
|
// Invert the value of the query parameter to get the correct value.
|
|
// UsingActive returns if the workspace is on the latest template active version.
|
|
Bool: !parser.Boolean(values, true, "outdated"),
|
|
// Only include this search term if it was provided. Otherwise default to omitting it
|
|
// which will return all workspaces.
|
|
Valid: values.Has("outdated"),
|
|
}
|
|
|
|
type paramMatch struct {
|
|
name string
|
|
value *string
|
|
}
|
|
// parameter matching takes the form of:
|
|
// `param:<name>[=<value>]`
|
|
// If the value is omitted, then we match on the presence of the parameter.
|
|
// If the value is provided, then we match on the parameter and value.
|
|
params := httpapi.ParseCustomList(parser, values, []paramMatch{}, "param", func(v string) (paramMatch, error) {
|
|
// Ignore excess spaces
|
|
v = strings.TrimSpace(v)
|
|
parts := strings.Split(v, "=")
|
|
if len(parts) == 1 {
|
|
// Only match on the presence of the parameter
|
|
return paramMatch{name: parts[0], value: nil}, nil
|
|
}
|
|
if len(parts) == 2 {
|
|
if parts[1] == "" {
|
|
return paramMatch{}, xerrors.Errorf("query element %q has an empty value. omit the '=' to match just on the parameter name", v)
|
|
}
|
|
// Match on the parameter and value
|
|
return paramMatch{name: parts[0], value: &parts[1]}, nil
|
|
}
|
|
return paramMatch{}, xerrors.Errorf("query element %q can only contain 1 '='", v)
|
|
})
|
|
for _, p := range params {
|
|
if p.value == nil {
|
|
filter.HasParam = append(filter.HasParam, p.name)
|
|
continue
|
|
}
|
|
filter.ParamNames = append(filter.ParamNames, p.name)
|
|
filter.ParamValues = append(filter.ParamValues, *p.value)
|
|
}
|
|
|
|
parser.ErrorExcessParams(values)
|
|
return filter, parser.Errors
|
|
}
|
|
|
|
func searchTerms(query string, defaultKey func(term string, values url.Values) error) (url.Values, []codersdk.ValidationError) {
|
|
searchValues := make(url.Values)
|
|
|
|
// Because we do this in 2 passes, we want to maintain quotes on the first
|
|
// pass. Further splitting occurs on the second pass and quotes will be
|
|
// dropped.
|
|
elements := splitQueryParameterByDelimiter(query, ' ', true)
|
|
for _, element := range elements {
|
|
if strings.HasPrefix(element, ":") || strings.HasSuffix(element, ":") {
|
|
return nil, []codersdk.ValidationError{
|
|
{
|
|
Field: "q",
|
|
Detail: fmt.Sprintf("Query element %q cannot start or end with ':'", element),
|
|
},
|
|
}
|
|
}
|
|
parts := splitQueryParameterByDelimiter(element, ':', false)
|
|
switch len(parts) {
|
|
case 1:
|
|
// No key:value pair. Use default behavior.
|
|
err := defaultKey(element, searchValues)
|
|
if err != nil {
|
|
return nil, []codersdk.ValidationError{
|
|
{Field: "q", Detail: err.Error()},
|
|
}
|
|
}
|
|
case 2:
|
|
searchValues.Add(strings.ToLower(parts[0]), parts[1])
|
|
default:
|
|
return nil, []codersdk.ValidationError{
|
|
{
|
|
Field: "q",
|
|
Detail: fmt.Sprintf("Query element %q can only contain 1 ':'", element),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
return searchValues, nil
|
|
}
|
|
|
|
// splitQueryParameterByDelimiter takes a query string and splits it into the individual elements
|
|
// of the query. Each element is separated by a delimiter. All quoted strings are
|
|
// kept as a single element.
|
|
//
|
|
// Although all our names cannot have spaces, that is a validation error.
|
|
// We should still parse the quoted string as a single value so that validation
|
|
// can properly fail on the space. If we do not, a value of `template:"my name"`
|
|
// will search `template:"my name:name"`, which produces an empty list instead of
|
|
// an error.
|
|
// nolint:revive
|
|
func splitQueryParameterByDelimiter(query string, delimiter rune, maintainQuotes bool) []string {
|
|
quoted := false
|
|
parts := strings.FieldsFunc(query, func(r rune) bool {
|
|
if r == '"' {
|
|
quoted = !quoted
|
|
}
|
|
return !quoted && r == delimiter
|
|
})
|
|
if !maintainQuotes {
|
|
for i, part := range parts {
|
|
parts[i] = strings.Trim(part, "\"")
|
|
}
|
|
}
|
|
|
|
return parts
|
|
}
|