mirror of
https://github.com/coder/coder.git
synced 2026-06-03 21:18:24 +00:00
b779c9ee33
## Problem The chat listing endpoint (`GetChatsByOwnerID`) was using `fetchWithPostFilter`, which fetches N rows from the database and then filters them in Go memory using RBAC checks. This causes a pagination bug: if the user requests `limit=25` but some rows fail the auth check, fewer than 25 rows are returned even though more authorized rows exist in the database. The client may incorrectly assume it has reached the end of the list. ## Solution Switch to the same pattern used by `GetWorkspaces`, `GetTemplates`, and `GetUsers`: `prepareSQLFilter` + `GetAuthorized*` variant. The RBAC filter is compiled to a SQL WHERE clause and injected into the query before `ORDER BY`/`LIMIT`, so the database returns exactly the requested number of authorized rows. Additionally, `GetChatsByOwnerID` is renamed to `GetChats` with `OwnerID` as an optional (nullable) filter parameter, matching the `GetWorkspaces` naming convention. ## Changes | File | Change | |------|--------| | `queries/chats.sql` | Renamed to `GetChats`, `owner_id` now optional via CASE/NULL, added `-- @authorize_filter` | | `queries.sql.go` | Renamed constant, params struct (`GetChatsParams`), and method | | `querier.go` | Interface method renamed | | `modelqueries.go` | Added `chatQuerier` interface + `GetAuthorizedChats` impl | | `dbauthz/dbauthz.go` | `GetChats` now uses `prepareSQLFilter` instead of `fetchWithPostFilter` | | `dbauthz/dbauthz_test.go` | Updated tests for SQL filter pattern | | `dbmock/dbmock.go` | Renamed + added mock for `GetAuthorizedChats` | | `dbmetrics/querymetrics.go` | Renamed + added metrics wrapper | | `rbac/regosql/configs.go` | Added `ChatConverter` (maps `org_owner` to empty string literal since `chats` has no `organization_id` column) | | `rbac/authz.go` | Added `ConfigChats()` | | `chats.go` | Handler uses renamed method with `uuid.NullUUID` | | `searchquery/search.go` | Updated return type | | `gitsync/worker.go` | Updated interface and call site | | Various test files | Updated for renamed types |
690 lines
25 KiB
Go
690 lines
25 KiB
Go
package searchquery
|
|
|
|
import (
|
|
"context"
|
|
"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"
|
|
)
|
|
|
|
// AuditLogs requires the database to fetch an organization by name
|
|
// to convert to organization uuid.
|
|
//
|
|
// Supported query parameters:
|
|
//
|
|
// - request_id: UUID (can be used to search for associated audits e.g. connect/disconnect or open/close)
|
|
// - resource_id: UUID
|
|
// - resource_target: string
|
|
// - username: string
|
|
// - email: string
|
|
// - date_from: string (date in format "2006-01-02")
|
|
// - date_to: string (date in format "2006-01-02")
|
|
// - organization: string (organization UUID or name)
|
|
// - resource_type: string (enum)
|
|
// - action: string (enum)
|
|
// - build_reason: string (enum)
|
|
func AuditLogs(ctx context.Context, db database.Store, query string) (database.GetAuditLogsOffsetParams,
|
|
database.CountAuditLogsParams, []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 {
|
|
// nolint:exhaustruct // We don't need to initialize these structs because we return an error.
|
|
return database.GetAuditLogsOffsetParams{}, database.CountAuditLogsParams{}, errors
|
|
}
|
|
|
|
const dateLayout = "2006-01-02"
|
|
parser := httpapi.NewQueryParamParser()
|
|
filter := database.GetAuditLogsOffsetParams{
|
|
RequestID: parser.UUID(values, uuid.Nil, "request_id"),
|
|
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),
|
|
OrganizationID: parseOrganization(ctx, db, parser, values, "organization"),
|
|
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)
|
|
}
|
|
|
|
// Prepare the count filter, which uses the same parameters as the GetAuditLogsOffsetParams.
|
|
// nolint:exhaustruct // UserID is not obtained from the query parameters.
|
|
countFilter := database.CountAuditLogsParams{
|
|
RequestID: filter.RequestID,
|
|
ResourceID: filter.ResourceID,
|
|
ResourceTarget: filter.ResourceTarget,
|
|
Username: filter.Username,
|
|
Email: filter.Email,
|
|
DateFrom: filter.DateFrom,
|
|
DateTo: filter.DateTo,
|
|
OrganizationID: filter.OrganizationID,
|
|
ResourceType: filter.ResourceType,
|
|
Action: filter.Action,
|
|
BuildReason: filter.BuildReason,
|
|
}
|
|
|
|
parser.ErrorExcessParams(values)
|
|
return filter, countFilter, parser.Errors
|
|
}
|
|
|
|
func ConnectionLogs(ctx context.Context, db database.Store, query string, apiKey database.APIKey) (database.GetConnectionLogsOffsetParams, database.CountConnectionLogsParams, []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 {
|
|
// nolint:exhaustruct // We don't need to initialize these structs because we return an error.
|
|
return database.GetConnectionLogsOffsetParams{}, database.CountConnectionLogsParams{}, errors
|
|
}
|
|
|
|
parser := httpapi.NewQueryParamParser()
|
|
filter := database.GetConnectionLogsOffsetParams{
|
|
OrganizationID: parseOrganization(ctx, db, parser, values, "organization"),
|
|
WorkspaceOwner: parser.String(values, "", "workspace_owner"),
|
|
WorkspaceOwnerEmail: parser.String(values, "", "workspace_owner_email"),
|
|
Type: string(httpapi.ParseCustom(parser, values, "", "type", httpapi.ParseEnum[database.ConnectionType])),
|
|
Username: parser.String(values, "", "username"),
|
|
UserEmail: parser.String(values, "", "user_email"),
|
|
ConnectedAfter: parser.Time3339Nano(values, time.Time{}, "connected_after"),
|
|
ConnectedBefore: parser.Time3339Nano(values, time.Time{}, "connected_before"),
|
|
WorkspaceID: parser.UUID(values, uuid.Nil, "workspace_id"),
|
|
ConnectionID: parser.UUID(values, uuid.Nil, "connection_id"),
|
|
Status: string(httpapi.ParseCustom(parser, values, "", "status", httpapi.ParseEnum[codersdk.ConnectionLogStatus])),
|
|
}
|
|
|
|
if filter.Username == "me" {
|
|
filter.UserID = apiKey.UserID
|
|
filter.Username = ""
|
|
}
|
|
|
|
if filter.WorkspaceOwner == "me" {
|
|
filter.WorkspaceOwnerID = apiKey.UserID
|
|
filter.WorkspaceOwner = ""
|
|
}
|
|
|
|
// This MUST be kept in sync with the above
|
|
countFilter := database.CountConnectionLogsParams{
|
|
OrganizationID: filter.OrganizationID,
|
|
WorkspaceOwner: filter.WorkspaceOwner,
|
|
WorkspaceOwnerID: filter.WorkspaceOwnerID,
|
|
WorkspaceOwnerEmail: filter.WorkspaceOwnerEmail,
|
|
Type: filter.Type,
|
|
UserID: filter.UserID,
|
|
Username: filter.Username,
|
|
UserEmail: filter.UserEmail,
|
|
ConnectedAfter: filter.ConnectedAfter,
|
|
ConnectedBefore: filter.ConnectedBefore,
|
|
WorkspaceID: filter.WorkspaceID,
|
|
ConnectionID: filter.ConnectionID,
|
|
Status: filter.Status,
|
|
}
|
|
parser.ErrorExcessParams(values)
|
|
return filter, countFilter, 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"),
|
|
Name: parser.String(values, "", "name"),
|
|
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"),
|
|
CreatedAfter: parser.Time3339Nano(values, time.Time{}, "created_after"),
|
|
CreatedBefore: parser.Time3339Nano(values, time.Time{}, "created_before"),
|
|
GithubComUserID: parser.Int64(values, 0, "github_com_user_id"),
|
|
LoginType: httpapi.ParseCustomList(parser, values, []database.LoginType{}, "login_type", httpapi.ParseEnum[database.LoginType]),
|
|
}
|
|
parser.ErrorExcessParams(values)
|
|
return filter, parser.Errors
|
|
}
|
|
|
|
func Members(query string, organizationID uuid.UUID) (database.OrganizationMembersParams, []codersdk.ValidationError) {
|
|
query = strings.TrimSpace(query)
|
|
if query == "" {
|
|
return database.OrganizationMembersParams{
|
|
OrganizationID: organizationID,
|
|
UserID: uuid.Nil,
|
|
IncludeSystem: false,
|
|
GithubUserID: 0,
|
|
}, nil
|
|
}
|
|
values, errors := searchTerms(query, func(term string, values url.Values) error {
|
|
switch term {
|
|
case "user_id":
|
|
values.Set("user_id", "")
|
|
case "github_user_id":
|
|
values.Set("github_user_id", "")
|
|
case "include_system":
|
|
values.Set("include_system", "")
|
|
default:
|
|
return xerrors.Errorf("invalid search term: %s", term)
|
|
}
|
|
return nil
|
|
})
|
|
if len(errors) > 0 {
|
|
return database.OrganizationMembersParams{
|
|
OrganizationID: organizationID,
|
|
UserID: uuid.Nil,
|
|
IncludeSystem: false,
|
|
GithubUserID: 0,
|
|
}, errors
|
|
}
|
|
|
|
parser := httpapi.NewQueryParamParser()
|
|
params := database.OrganizationMembersParams{
|
|
OrganizationID: organizationID,
|
|
UserID: parser.UUID(values, uuid.Nil, "user_id"),
|
|
IncludeSystem: parser.Boolean(values, false, "include_system"),
|
|
GithubUserID: parser.Int64(values, 0, "github_user_id"),
|
|
}
|
|
parser.ErrorExcessParams(values)
|
|
|
|
return params, parser.Errors
|
|
}
|
|
|
|
func Workspaces(ctx context.Context, db database.Store, query string, page codersdk.Pagination, agentInactiveDisconnectTimeout time.Duration) (database.GetWorkspacesParams, []codersdk.ValidationError) {
|
|
filter := database.GetWorkspacesParams{
|
|
AgentInactiveDisconnectTimeoutSeconds: int64(agentInactiveDisconnectTimeout.Seconds()),
|
|
|
|
// #nosec G115 - Safe conversion for pagination offset which is expected to be within int32 range
|
|
Offset: int32(page.Offset),
|
|
// #nosec G115 - Safe conversion for pagination limit which is expected to be within int32 range
|
|
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.HasAgentStatuses = parser.Strings(values, []string{}, "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"),
|
|
}
|
|
filter.HasAITask = parser.NullableBoolean(values, sql.NullBool{}, "has-ai-task")
|
|
filter.HasExternalAgent = parser.NullableBoolean(values, sql.NullBool{}, "has_external_agent")
|
|
filter.OrganizationID = parseOrganization(ctx, db, parser, values, "organization")
|
|
filter.Shared = parser.NullableBoolean(values, sql.NullBool{}, "shared")
|
|
// TODO: support "me" by passing in the actorID
|
|
filter.SharedWithUserID = parseUser(ctx, db, parser, values, "shared_with_user", uuid.Nil)
|
|
filter.SharedWithGroupID = parseGroup(ctx, db, parser, values, "shared_with_group")
|
|
// Translate healthy filter to has-agent statuses
|
|
// healthy:true = connected, healthy:false = disconnected or timeout
|
|
if healthy := parser.NullableBoolean(values, sql.NullBool{}, "healthy"); healthy.Valid {
|
|
if healthy.Bool {
|
|
filter.HasAgentStatuses = append(filter.HasAgentStatuses, "connected")
|
|
} else {
|
|
filter.HasAgentStatuses = append(filter.HasAgentStatuses, "disconnected", "timeout")
|
|
}
|
|
}
|
|
|
|
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 Templates(ctx context.Context, db database.Store, actorID uuid.UUID, query string) (database.GetTemplatesWithFilterParams, []codersdk.ValidationError) {
|
|
// Always lowercase for all searches.
|
|
query = strings.ToLower(query)
|
|
values, errors := searchTerms(query, func(term string, values url.Values) error {
|
|
// Default to the display name
|
|
values.Add("display_name", term)
|
|
return nil
|
|
})
|
|
if len(errors) > 0 {
|
|
return database.GetTemplatesWithFilterParams{}, errors
|
|
}
|
|
|
|
parser := httpapi.NewQueryParamParser()
|
|
filter := database.GetTemplatesWithFilterParams{
|
|
Deleted: parser.Boolean(values, false, "deleted"),
|
|
OrganizationID: parseOrganization(ctx, db, parser, values, "organization"),
|
|
ExactName: parser.String(values, "", "exact_name"),
|
|
ExactDisplayName: parser.String(values, "", "exact_display_name"),
|
|
FuzzyName: parser.String(values, "", "name"),
|
|
FuzzyDisplayName: parser.String(values, "", "display_name"),
|
|
IDs: parser.UUIDs(values, []uuid.UUID{}, "ids"),
|
|
Deprecated: parser.NullableBoolean(values, sql.NullBool{}, "deprecated"),
|
|
HasAITask: parser.NullableBoolean(values, sql.NullBool{}, "has-ai-task"),
|
|
AuthorID: parser.UUID(values, uuid.Nil, "author_id"),
|
|
AuthorUsername: parser.String(values, "", "author"),
|
|
HasExternalAgent: parser.NullableBoolean(values, sql.NullBool{}, "has_external_agent"),
|
|
}
|
|
|
|
if filter.AuthorUsername == codersdk.Me {
|
|
filter.AuthorID = actorID
|
|
filter.AuthorUsername = ""
|
|
}
|
|
|
|
parser.ErrorExcessParams(values)
|
|
return filter, parser.Errors
|
|
}
|
|
|
|
func AIBridgeInterceptions(ctx context.Context, db database.Store, query string, page codersdk.Pagination, actorID uuid.UUID) (database.ListAIBridgeInterceptionsParams, []codersdk.ValidationError) {
|
|
// nolint:exhaustruct // Empty values just means "don't filter by that field".
|
|
filter := database.ListAIBridgeInterceptionsParams{
|
|
AfterID: page.AfterID,
|
|
// #nosec G115 - Safe conversion for pagination limit which is expected to be within int32 range
|
|
Limit: int32(page.Limit),
|
|
// #nosec G115 - Safe conversion for pagination offset which is expected to be within int32 range
|
|
Offset: int32(page.Offset),
|
|
}
|
|
|
|
if query == "" {
|
|
return filter, nil
|
|
}
|
|
|
|
values, errors := searchTerms(query, func(term string, values url.Values) error {
|
|
// Default to the initiating user
|
|
values.Add("initiator", term)
|
|
return nil
|
|
})
|
|
if len(errors) > 0 {
|
|
return filter, errors
|
|
}
|
|
|
|
parser := httpapi.NewQueryParamParser()
|
|
filter.InitiatorID = parseUser(ctx, db, parser, values, "initiator", actorID)
|
|
filter.Provider = parser.String(values, "", "provider")
|
|
filter.Model = parser.String(values, "", "model")
|
|
filter.Client = parser.String(values, "", "client")
|
|
|
|
// Time must be between started_after and started_before.
|
|
filter.StartedAfter = parser.Time3339Nano(values, time.Time{}, "started_after")
|
|
filter.StartedBefore = parser.Time3339Nano(values, time.Time{}, "started_before")
|
|
if !filter.StartedBefore.IsZero() && !filter.StartedAfter.IsZero() && !filter.StartedBefore.After(filter.StartedAfter) {
|
|
parser.Errors = append(parser.Errors, codersdk.ValidationError{
|
|
Field: "started_before",
|
|
Detail: `Query param "started_before" has invalid value: "started_before" must be after "started_after" if set`,
|
|
})
|
|
}
|
|
|
|
parser.ErrorExcessParams(values)
|
|
return filter, parser.Errors
|
|
}
|
|
|
|
func AIBridgeModels(query string, page codersdk.Pagination) (database.ListAIBridgeModelsParams, []codersdk.ValidationError) {
|
|
// nolint:exhaustruct // Empty values just means "don't filter by that field".
|
|
filter := database.ListAIBridgeModelsParams{
|
|
// #nosec G115 - Safe conversion for pagination offset which is expected to be within int32 range
|
|
Offset: int32(page.Offset),
|
|
// #nosec G115 - Safe conversion for pagination limit which is expected to be within int32 range
|
|
Limit: int32(page.Limit),
|
|
}
|
|
|
|
if query == "" {
|
|
return filter, nil
|
|
}
|
|
|
|
values, errors := searchTerms(query, func(term string, values url.Values) error {
|
|
// Defaults to the `model` if no `key:value` pair is provided.
|
|
values.Add("model", term)
|
|
return nil
|
|
})
|
|
if len(errors) > 0 {
|
|
return filter, errors
|
|
}
|
|
|
|
parser := httpapi.NewQueryParamParser()
|
|
filter.Model = parser.String(values, "", "model")
|
|
|
|
parser.ErrorExcessParams(values)
|
|
return filter, parser.Errors
|
|
}
|
|
|
|
// Tasks parses a search query for tasks.
|
|
//
|
|
// Supported query parameters:
|
|
// - owner: string (username, UUID, or 'me' for current user)
|
|
// - organization: string (organization UUID or name)
|
|
// - status: string (pending, initializing, active, paused, error, unknown)
|
|
func Tasks(ctx context.Context, db database.Store, query string, actorID uuid.UUID) (database.ListTasksParams, []codersdk.ValidationError) {
|
|
filter := database.ListTasksParams{
|
|
OwnerID: uuid.Nil,
|
|
OrganizationID: uuid.Nil,
|
|
Status: "",
|
|
}
|
|
|
|
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 {
|
|
// Default unqualified terms to owner
|
|
values.Add("owner", term)
|
|
return nil
|
|
})
|
|
if len(errors) > 0 {
|
|
return filter, errors
|
|
}
|
|
|
|
parser := httpapi.NewQueryParamParser()
|
|
filter.OwnerID = parseUser(ctx, db, parser, values, "owner", actorID)
|
|
filter.OrganizationID = parseOrganization(ctx, db, parser, values, "organization")
|
|
filter.Status = parser.String(values, "", "status")
|
|
|
|
parser.ErrorExcessParams(values)
|
|
return filter, parser.Errors
|
|
}
|
|
|
|
// Chats parses a search query for chats.
|
|
//
|
|
// Supported query parameters:
|
|
// - archived: boolean (default: false, excludes archived chats unless explicitly set)
|
|
func Chats(query string) (database.GetChatsParams, []codersdk.ValidationError) {
|
|
filter := database.GetChatsParams{
|
|
// Default to hiding archived chats.
|
|
Archived: sql.NullBool{Bool: false, Valid: true},
|
|
}
|
|
|
|
if query == "" {
|
|
return filter, nil
|
|
}
|
|
|
|
// Always lowercase for all searches.
|
|
query = strings.ToLower(query)
|
|
values, errors := searchTerms(query, func(term string, _ url.Values) error {
|
|
return xerrors.Errorf("unsupported search term: %q", term)
|
|
})
|
|
if len(errors) > 0 {
|
|
return filter, errors
|
|
}
|
|
|
|
parser := httpapi.NewQueryParamParser()
|
|
filter.Archived = parser.NullableBoolean(values, filter.Archived, "archived")
|
|
|
|
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.
|
|
tokens := splitQueryParameterByDelimiter(query, ' ', true)
|
|
elements := processTokens(tokens)
|
|
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
|
|
}
|
|
|
|
func parseOrganization(ctx context.Context, db database.Store, parser *httpapi.QueryParamParser, vals url.Values, queryParam string) uuid.UUID {
|
|
return httpapi.ParseCustom(parser, vals, uuid.Nil, queryParam, func(v string) (uuid.UUID, error) {
|
|
if v == "" {
|
|
return uuid.Nil, nil
|
|
}
|
|
organizationID, err := uuid.Parse(v)
|
|
if err == nil {
|
|
return organizationID, nil
|
|
}
|
|
organization, err := db.GetOrganizationByName(ctx, database.GetOrganizationByNameParams{
|
|
Name: v, Deleted: false,
|
|
})
|
|
if err != nil {
|
|
return uuid.Nil, xerrors.Errorf("organization %q either does not exist, or you are unauthorized to view it", v)
|
|
}
|
|
return organization.ID, nil
|
|
})
|
|
}
|
|
|
|
func parseUser(ctx context.Context, db database.Store, parser *httpapi.QueryParamParser, vals url.Values, queryParam string, actorID uuid.UUID) uuid.UUID {
|
|
return httpapi.ParseCustom(parser, vals, uuid.Nil, queryParam, func(v string) (uuid.UUID, error) {
|
|
if v == "" {
|
|
return uuid.Nil, nil
|
|
}
|
|
if v == codersdk.Me && actorID != uuid.Nil {
|
|
return actorID, nil
|
|
}
|
|
userID, err := uuid.Parse(v)
|
|
if err == nil {
|
|
return userID, nil
|
|
}
|
|
user, err := db.GetUserByEmailOrUsername(ctx, database.GetUserByEmailOrUsernameParams{
|
|
Username: v,
|
|
})
|
|
if err != nil {
|
|
return uuid.Nil, xerrors.Errorf("user %q either does not exist, or you are unauthorized to view them", v)
|
|
}
|
|
return user.ID, nil
|
|
})
|
|
}
|
|
|
|
// Parse a group filter value into a group UUID.
|
|
// Supported formats:
|
|
// - <group-uuid>
|
|
// - <organization-name>/<group-name>
|
|
// - <group-name> (resolved in the default organization)
|
|
func parseGroup(ctx context.Context, db database.Store, parser *httpapi.QueryParamParser, vals url.Values, queryParam string) uuid.UUID {
|
|
return httpapi.ParseCustom(parser, vals, uuid.Nil, queryParam, func(v string) (uuid.UUID, error) {
|
|
if v == "" {
|
|
return uuid.Nil, nil
|
|
}
|
|
groupID, err := uuid.Parse(v)
|
|
if err == nil {
|
|
return groupID, nil
|
|
}
|
|
|
|
var groupName string
|
|
var org database.Organization
|
|
parts := strings.Split(v, "/")
|
|
switch len(parts) {
|
|
case 1:
|
|
dbOrg, err := db.GetDefaultOrganization(ctx)
|
|
if err != nil {
|
|
return uuid.Nil, xerrors.New("fetching default organization")
|
|
}
|
|
org = dbOrg
|
|
groupName = parts[0]
|
|
case 2:
|
|
orgName := parts[0]
|
|
if err := codersdk.NameValid(orgName); err != nil {
|
|
return uuid.Nil, xerrors.Errorf("invalid organization name %w", err)
|
|
}
|
|
dbOrg, err := db.GetOrganizationByName(ctx, database.GetOrganizationByNameParams{
|
|
Name: orgName,
|
|
})
|
|
if err != nil {
|
|
return uuid.Nil, xerrors.Errorf("organization %q either does not exist, or you are unauthorized to view it", orgName)
|
|
}
|
|
org = dbOrg
|
|
|
|
groupName = parts[1]
|
|
|
|
default:
|
|
return uuid.Nil, xerrors.New("invalid organization or group name, the filter must be in the pattern of <organization name>/<group name>")
|
|
}
|
|
|
|
if err := codersdk.GroupNameValid(groupName); err != nil {
|
|
return uuid.Nil, xerrors.Errorf("invalid group name %w", err)
|
|
}
|
|
|
|
group, err := db.GetGroupByOrgAndName(ctx, database.GetGroupByOrgAndNameParams{
|
|
OrganizationID: org.ID,
|
|
Name: groupName,
|
|
})
|
|
if err != nil {
|
|
return uuid.Nil, xerrors.Errorf("group %q either does not exist, does not belong to the organization %q, or you are unauthorized to view it", groupName, org.Name)
|
|
}
|
|
return group.ID, 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
|
|
}
|
|
|
|
// processTokens takes the split tokens and groups them based on a delimiter (':').
|
|
// Tokens without a delimiter present are joined to support searching with spaces.
|
|
//
|
|
// Example Input: ['deprecated:false', 'test', 'template']
|
|
// Example Output: ['deprecated:false', 'test template']
|
|
func processTokens(tokens []string) []string {
|
|
var results []string
|
|
var nonFieldTerms []string
|
|
for _, token := range tokens {
|
|
if strings.Contains(token, string(':')) {
|
|
results = append(results, token)
|
|
} else {
|
|
nonFieldTerms = append(nonFieldTerms, token)
|
|
}
|
|
}
|
|
if len(nonFieldTerms) > 0 {
|
|
results = append(results, strings.Join(nonFieldTerms, " "))
|
|
}
|
|
return results
|
|
}
|