mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-10-31 18:48:37 -04:00
feat(search): configurable query filter
This commit is contained in:
1 parent
38262408d6
commit
3fe11ea60a
22 files changed
+851
-473
No files matched your search
@@ -7,10 +7,13 @@
|
||||
////////////////////////////////////////////////////////
|
||||
|
||||
AST <-
|
||||
n:Nodes {
|
||||
_ n:Nodes _ EOF {
|
||||
return buildAST(n, c.text, c.pos)
|
||||
}
|
||||
|
||||
EOF <-
|
||||
!.
|
||||
|
||||
////////////////////////////////////////////////////////
|
||||
// nodes
|
||||
////////////////////////////////////////////////////////
|
||||
|
||||
+482
-421
File diff suppressed because it is too large.
Load diff
@@ -33,13 +33,13 @@ func TestParse_Spec(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: `AND`,
|
||||
error: kql.StartsWithBinaryOperatorError{
|
||||
error: kql.DanglingOperatorError{
|
||||
Node: &ast.OperatorNode{Value: kql.BoolAND},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: `AND cat AND dog`,
|
||||
error: kql.StartsWithBinaryOperatorError{
|
||||
error: kql.DanglingOperatorError{
|
||||
Node: &ast.OperatorNode{Value: kql.BoolAND},
|
||||
},
|
||||
},
|
||||
@@ -79,13 +79,13 @@ func TestParse_Spec(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: `OR`,
|
||||
error: kql.StartsWithBinaryOperatorError{
|
||||
error: kql.DanglingOperatorError{
|
||||
Node: &ast.OperatorNode{Value: kql.BoolOR},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: `OR cat AND dog`,
|
||||
error: kql.StartsWithBinaryOperatorError{
|
||||
error: kql.DanglingOperatorError{
|
||||
Node: &ast.OperatorNode{Value: kql.BoolOR},
|
||||
},
|
||||
},
|
||||
@@ -941,28 +941,58 @@ func TestParse_Errors(t *testing.T) {
|
||||
},
|
||||
{
|
||||
query: "animal:(AND cat)",
|
||||
error: kql.StartsWithBinaryOperatorError{
|
||||
error: kql.DanglingOperatorError{
|
||||
Node: &ast.OperatorNode{Value: kql.BoolAND},
|
||||
},
|
||||
},
|
||||
{
|
||||
query: "animal:(OR cat)",
|
||||
error: kql.StartsWithBinaryOperatorError{
|
||||
error: kql.DanglingOperatorError{
|
||||
Node: &ast.OperatorNode{Value: kql.BoolOR},
|
||||
},
|
||||
},
|
||||
{
|
||||
query: "(AND cat)",
|
||||
error: kql.StartsWithBinaryOperatorError{
|
||||
error: kql.DanglingOperatorError{
|
||||
Node: &ast.OperatorNode{Value: kql.BoolAND},
|
||||
},
|
||||
},
|
||||
{
|
||||
query: "(OR cat)",
|
||||
error: kql.StartsWithBinaryOperatorError{
|
||||
error: kql.DanglingOperatorError{
|
||||
Node: &ast.OperatorNode{Value: kql.BoolOR},
|
||||
},
|
||||
},
|
||||
{
|
||||
query: `cat AND`,
|
||||
error: kql.DanglingOperatorError{
|
||||
Node: &ast.OperatorNode{Value: kql.BoolAND},
|
||||
},
|
||||
},
|
||||
{
|
||||
query: `cat NOT`,
|
||||
error: kql.DanglingOperatorError{
|
||||
Node: &ast.OperatorNode{Value: kql.BoolNOT},
|
||||
},
|
||||
},
|
||||
{
|
||||
query: `cat AND OR dog`,
|
||||
error: kql.DanglingOperatorError{
|
||||
Node: &ast.OperatorNode{Value: kql.BoolOR},
|
||||
},
|
||||
},
|
||||
{
|
||||
query: `cat NOT AND dog`,
|
||||
error: kql.DanglingOperatorError{
|
||||
Node: &ast.OperatorNode{Value: kql.BoolAND},
|
||||
},
|
||||
},
|
||||
{
|
||||
query: `cat AND (dog NOT)`,
|
||||
error: kql.DanglingOperatorError{
|
||||
Node: &ast.OperatorNode{Value: kql.BoolNOT},
|
||||
},
|
||||
},
|
||||
{
|
||||
query: `cat dog`,
|
||||
ast: &ast.Ast{
|
||||
|
||||
+35
-8
@@ -7,13 +7,26 @@ import (
|
||||
"github.com/opencloud-eu/opencloud/pkg/ast"
|
||||
)
|
||||
|
||||
// StartsWithBinaryOperatorError records an error and the operation that caused it.
|
||||
type StartsWithBinaryOperatorError struct {
|
||||
// DanglingOperatorError records an error and the operator that caused it.
|
||||
type DanglingOperatorError struct {
|
||||
Node *ast.OperatorNode
|
||||
}
|
||||
|
||||
func (e StartsWithBinaryOperatorError) Error() string {
|
||||
return "the expression can't begin from a binary operator: '" + e.Node.Value + "'"
|
||||
func (e DanglingOperatorError) Error() string {
|
||||
return "the operator '" + e.Node.Value + "' is missing an operand"
|
||||
}
|
||||
|
||||
// SyntaxError records an error and the query that caused it.
|
||||
type SyntaxError struct {
|
||||
Inner error
|
||||
}
|
||||
|
||||
func (e SyntaxError) Error() string {
|
||||
return "invalid query syntax: " + e.Inner.Error()
|
||||
}
|
||||
|
||||
func (e SyntaxError) Unwrap() error {
|
||||
return e.Inner
|
||||
}
|
||||
|
||||
// NamedGroupInvalidNodesError records an error and the operation that caused it.
|
||||
@@ -39,17 +52,31 @@ func (e UnsupportedTimeRangeError) Error() string {
|
||||
return fmt.Sprintf("unable to convert '%v' to a time range", e.Value)
|
||||
}
|
||||
|
||||
func isGrammarActionError(err error) bool {
|
||||
var (
|
||||
danglingOperator *DanglingOperatorError
|
||||
namedGroupInvalidNodes *NamedGroupInvalidNodesError
|
||||
unsupportedTimeRange *UnsupportedTimeRangeError
|
||||
)
|
||||
|
||||
return errors.As(err, &danglingOperator) ||
|
||||
errors.As(err, &namedGroupInvalidNodes) ||
|
||||
errors.As(err, &unsupportedTimeRange)
|
||||
}
|
||||
|
||||
// IsValidationError reports whether err is one of the KQL parse/validation
|
||||
// errors produced by this package, i.e. the query itself is at fault and the
|
||||
// caller should treat it as a bad request.
|
||||
func IsValidationError(err error) bool {
|
||||
var (
|
||||
startsWithBinaryOperator *StartsWithBinaryOperatorError
|
||||
namedGroupInvalidNodes *NamedGroupInvalidNodesError
|
||||
unsupportedTimeRange *UnsupportedTimeRangeError
|
||||
danglingOperator *DanglingOperatorError
|
||||
syntax *SyntaxError
|
||||
namedGroupInvalidNodes *NamedGroupInvalidNodesError
|
||||
unsupportedTimeRange *UnsupportedTimeRangeError
|
||||
)
|
||||
|
||||
return errors.As(err, &startsWithBinaryOperator) ||
|
||||
return errors.As(err, &danglingOperator) ||
|
||||
errors.As(err, &syntax) ||
|
||||
errors.As(err, &namedGroupInvalidNodes) ||
|
||||
errors.As(err, &unsupportedTimeRange)
|
||||
}
|
||||
+6
-9
@@ -26,19 +26,16 @@ func (b Builder) Build(q string) (*ast.Ast, error) {
|
||||
f, err := Parse("", []byte(q))
|
||||
if err != nil {
|
||||
var list errList
|
||||
errors.As(err, &list)
|
||||
|
||||
for _, listError := range list {
|
||||
var parserError *parserError
|
||||
switch {
|
||||
case errors.As(listError, &parserError):
|
||||
if parserError.Inner != nil {
|
||||
if errors.As(err, &list) {
|
||||
for _, listError := range list {
|
||||
var parserError *parserError
|
||||
if errors.As(listError, &parserError) && isGrammarActionError(parserError.Inner) {
|
||||
return nil, parserError.Inner
|
||||
}
|
||||
|
||||
return nil, listError
|
||||
}
|
||||
}
|
||||
|
||||
return nil, &SyntaxError{Inner: err}
|
||||
}
|
||||
|
||||
return f.(*ast.Ast), nil
|
||||
|
||||
+43
-1
@@ -21,7 +21,7 @@ func TestNewAST(t *testing.T) {
|
||||
{
|
||||
name: "error",
|
||||
givenQuery: kql.BoolAND,
|
||||
expectedError: kql.StartsWithBinaryOperatorError{
|
||||
expectedError: kql.DanglingOperatorError{
|
||||
Node: &ast.OperatorNode{Value: kql.BoolAND},
|
||||
},
|
||||
},
|
||||
@@ -50,3 +50,45 @@ func TestNewAST(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_ValidationErrors(t *testing.T) {
|
||||
assert := tAssert.New(t)
|
||||
|
||||
for _, q := range []string{
|
||||
"NOT",
|
||||
"cat NOT",
|
||||
"cat AND",
|
||||
"AND cat",
|
||||
"cat AND OR dog",
|
||||
"cat NOT AND dog",
|
||||
"(cat",
|
||||
"cat)",
|
||||
"()",
|
||||
"cat) OR (dog",
|
||||
} {
|
||||
got, err := kql.Builder{}.Build(q)
|
||||
assert.Nil(got, q)
|
||||
assert.Error(err, q)
|
||||
assert.True(kql.IsValidationError(err), "%q: %v", q, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_ValidQueries(t *testing.T) {
|
||||
assert := tAssert.New(t)
|
||||
|
||||
for _, q := range []string{
|
||||
"cat",
|
||||
"cat dog",
|
||||
"NOT cat",
|
||||
"NOT NOT cat",
|
||||
"cat NOT dog",
|
||||
"cat AND NOT dog",
|
||||
"(cat OR dog)",
|
||||
`name:"cat dog"`,
|
||||
"cat AND (dog OR fish)",
|
||||
} {
|
||||
got, err := kql.Builder{}.Build(q)
|
||||
assert.NoError(err, q)
|
||||
assert.NotNil(got, q)
|
||||
}
|
||||
}
|
||||
+28
-14
@@ -5,23 +5,12 @@ import (
|
||||
)
|
||||
|
||||
func validateAst(a *ast.Ast) error {
|
||||
switch node := a.Nodes[0].(type) {
|
||||
case *ast.OperatorNode:
|
||||
switch node.Value {
|
||||
case BoolAND, BoolOR:
|
||||
return &StartsWithBinaryOperatorError{Node: node}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return validateOperators(a.Nodes)
|
||||
}
|
||||
|
||||
func validateGroupNode(n *ast.GroupNode) error {
|
||||
switch node := n.Nodes[0].(type) {
|
||||
case *ast.OperatorNode:
|
||||
switch node.Value {
|
||||
case BoolAND, BoolOR:
|
||||
return &StartsWithBinaryOperatorError{Node: node}
|
||||
}
|
||||
if err := validateOperators(n.Nodes); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if n.Key != "" {
|
||||
@@ -34,3 +23,28 @@ func validateGroupNode(n *ast.GroupNode) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOperators(nodes []ast.Node) error {
|
||||
for i, node := range nodes {
|
||||
op, ok := node.(*ast.OperatorNode)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
switch op.Value {
|
||||
case BoolAND, BoolOR:
|
||||
if i == 0 {
|
||||
return &DanglingOperatorError{Node: op}
|
||||
}
|
||||
if _, prevIsOperator := nodes[i-1].(*ast.OperatorNode); prevIsOperator {
|
||||
return &DanglingOperatorError{Node: op}
|
||||
}
|
||||
}
|
||||
|
||||
if i == len(nodes)-1 {
|
||||
return &DanglingOperatorError{Node: op}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -80,6 +80,25 @@ Not all parts are supported, the following list gives an overview of parts that
|
||||
|
||||
In [this ADR](https://github.com/owncloud/ocis/blob/docs/ocis/adr/0020-file-search-query-language.md) you can read why KQL was chosen.
|
||||
|
||||
## Query filter
|
||||
|
||||
`SEARCH_QUERY_FILTER` is a KQL expression ANDed to every search. It applies on every engine, works on existing
|
||||
indexes without reindexing, and keeps paging and totals right. Default:
|
||||
|
||||
```
|
||||
NOT path:"./.space" AND NOT id:{{.SPACE_ROOT_PERSONAL}}
|
||||
```
|
||||
|
||||
This leaves out `./.space` (the space image and readme) and the personal space root (named after its owner). Two
|
||||
placeholders become the searched space's root id, or an id that matches nothing in other space types:
|
||||
|
||||
* `{{.SPACE_ROOT_PERSONAL}}` for personal spaces.
|
||||
* `{{.SPACE_ROOT_PROJECT}}` for project spaces. Not in the default, so a project space stays findable by name; add
|
||||
`AND NOT id:{{.SPACE_ROOT_PROJECT}}` to drop it too.
|
||||
|
||||
Quote the value in a shell or compose env, the braces are a template token. An empty value turns the filter off. The
|
||||
service validates the filter at startup and does not start if it cannot parse it.
|
||||
|
||||
## Content analysis / Extraction
|
||||
|
||||
The search service supports the following content extraction methods:
|
||||
|
||||
@@ -3,8 +3,9 @@ package config
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/shared"
|
||||
"go-micro.dev/v4/client"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/shared"
|
||||
)
|
||||
|
||||
// Config combines all available configuration parts.
|
||||
@@ -29,6 +30,7 @@ type Config struct {
|
||||
ContentExtractionSizeLimit uint64 `yaml:"content_extraction_size_limit" env:"SEARCH_CONTENT_EXTRACTION_SIZE_LIMIT" desc:"Maximum file size in bytes that is allowed for content extraction." introductionVersion:"1.0.0"`
|
||||
BatchSize int `yaml:"batch_size" env:"SEARCH_BATCH_SIZE" desc:"The number of documents to process in a single batch. Defaults to 500." introductionVersion:"1.0.0"`
|
||||
ReindexMaxConcurrency int `yaml:"reindex_concurrency" env:"SEARCH_REINDEX_MAX_CONCURRENCY" desc:"The maximum number of spaces that are reindexed concurrently when reindexing all spaces." introductionVersion:"7.4.0"`
|
||||
QueryFilter string `yaml:"query_filter" env:"SEARCH_QUERY_FILTER" desc:"A KQL expression added to every search query with AND, e.g. to leave results out." introductionVersion:"%%NEXT%%"`
|
||||
|
||||
ServiceAccount ServiceAccount `yaml:"service_account"`
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ func DefaultConfig() *config.Config {
|
||||
ContentExtractionSizeLimit: 20 * 1024 * 1024, // Limit content extraction to <20MB files by default
|
||||
BatchSize: 50,
|
||||
ReindexMaxConcurrency: 3,
|
||||
QueryFilter: `NOT path:"./.space" AND NOT id:` + config.PersonalSpaceRootPlaceholder,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,11 @@ package parser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
occfg "github.com/opencloud-eu/opencloud/pkg/config"
|
||||
"github.com/opencloud-eu/opencloud/pkg/kql"
|
||||
"github.com/opencloud-eu/opencloud/pkg/shared"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/config"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/config/defaults"
|
||||
@@ -49,5 +52,26 @@ func Validate(cfg *config.Config) error {
|
||||
return shared.MissingServiceAccountSecret(cfg.Service.Name)
|
||||
}
|
||||
|
||||
return validateQueryFilter(cfg.QueryFilter)
|
||||
}
|
||||
|
||||
func validateQueryFilter(query string) error {
|
||||
if query == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
expanded := strings.NewReplacer(
|
||||
config.PersonalSpaceRootPlaceholder, `"`+config.NoResourceID+`"`,
|
||||
config.ProjectSpaceRootPlaceholder, `"`+config.NoResourceID+`"`,
|
||||
).Replace(query)
|
||||
|
||||
if strings.Contains(expanded, "{{") || strings.Contains(expanded, "}}") {
|
||||
return errors.New("search query filter: unknown placeholder")
|
||||
}
|
||||
|
||||
if _, err := (kql.Builder{}).Build(expanded); err != nil {
|
||||
return fmt.Errorf("search query filter: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package parser
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateQueryFilter(t *testing.T) {
|
||||
for query, valid := range map[string]bool{
|
||||
"": true,
|
||||
`NOT path:"./.space" AND NOT id:{{.SPACE_ROOT_PERSONAL}}`: true,
|
||||
`NOT id:{{.SPACE_ROOT_PERSONAL}} AND NOT id:{{.SPACE_ROOT_PROJECT}}`: true,
|
||||
`NOT mediatype:folder`: true,
|
||||
`NOT name:"a(b"`: true,
|
||||
`NOT id:{{.SPACE_ROOT_GRANT}}`: false,
|
||||
`NOT id:{{.SPACE_ROOT_PERSONAL}`: false,
|
||||
`(NOT path:x`: false,
|
||||
`NOT path:x) OR (name:y`: false,
|
||||
`AND AND`: false,
|
||||
`NOT`: false,
|
||||
} {
|
||||
if err := validateQueryFilter(query); (err == nil) != valid {
|
||||
t.Errorf("validateQueryFilter(%q) = %v, want valid %v", query, err, valid)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,12 @@ package config
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
PersonalSpaceRootPlaceholder = "{{.SPACE_ROOT_PERSONAL}}"
|
||||
ProjectSpaceRootPlaceholder = "{{.SPACE_ROOT_PROJECT}}"
|
||||
NoResourceID = "none$none!none"
|
||||
)
|
||||
|
||||
// Events combines the configuration options for the event bus.
|
||||
type Events struct {
|
||||
Disabled bool `yaml:"disabled" env:"SEARCH_EVENTS_DISABLED" desc:"Disables listening for events. Set this to true if the service should only handle GRPC requests." introductionVersion:"4.0.0"`
|
||||
|
||||
@@ -321,6 +321,23 @@ Fixtures:
|
||||
| VISIBILITY-06 | `hidden:banana` | no match | no match | no match | ✅ |
|
||||
| VISIBILITY-07 | `hidden:"true"` | .private, dotfile.txt, secret.txt | .private, dotfile.txt, secret.txt | .private, dotfile.txt, secret.txt | ✅ |
|
||||
|
||||
### filter
|
||||
|
||||
Fixtures:
|
||||
|
||||
- `alice`, ID = 1$1!1, folder, Path = .
|
||||
- `.space`, ID = 1$1!.space, folder
|
||||
- `readme.md`, ID = 1$1!readme.md, Path = ./.space/readme.md
|
||||
- `.spaces`, ID = 1$1!.spaces, folder
|
||||
- `notes.md`, ID = 1$1!notes.md
|
||||
|
||||
| Case | Query | expected | bleve | OpenSearch | same? |
|
||||
|---|---|---|---|---|---|
|
||||
| FILTER-01 | `path:"./.space"` | .space, readme.md | .space, readme.md | .space, readme.md | ✅ |
|
||||
| FILTER-02 | `(name:"*.md") AND (NOT path:"./....NOT id:"none$none!none")` | notes.md | notes.md | notes.md | ✅ |
|
||||
| FILTER-03 | `(name:"alice" OR name:".spaces")...ace" AND NOT id:"1$1!1")` | .spaces | .spaces | .spaces | ✅ |
|
||||
| FILTER-04 | `(name:"alice") AND (NOT path:"./...NOT id:"none$none!none")` | alice | alice | alice | ✅ |
|
||||
|
||||
### boolean
|
||||
|
||||
Fixtures:
|
||||
@@ -448,7 +465,11 @@ Fixtures:
|
||||
| Case | Query | expected | bleve | OpenSearch | same? |
|
||||
|---|---|---|---|---|---|
|
||||
| INVALID-01 | `AND mediatype:document` | bad request | bad request | bad request | ✅ |
|
||||
| INVALID-02 | `mediatype:document AND` | alpha.txt | alpha.txt | alpha.txt | ✅ |
|
||||
| INVALID-02 | `mediatype:document AND` | bad request | bad request | bad request | ✅ |
|
||||
| INVALID-03 | `mediatype:document NOT` | bad request | bad request | bad request | ✅ |
|
||||
| INVALID-04 | `mediatype:document AND OR name:alpha` | bad request | bad request | bad request | ✅ |
|
||||
| INVALID-05 | `(mediatype:document` | bad request | bad request | bad request | ✅ |
|
||||
| INVALID-06 | `mediatype:document) OR (name:alpha` | bad request | bad request | bad request | ✅ |
|
||||
|
||||
## Operations
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@ func queryGroups() []queryGroup {
|
||||
fieldsGroup(),
|
||||
deletedGroup(),
|
||||
visibilityGroup(),
|
||||
filterGroup(),
|
||||
booleanGroup(),
|
||||
samenameGroup(),
|
||||
stressGroup(),
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package parity
|
||||
|
||||
import (
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
|
||||
)
|
||||
|
||||
func filterGroup() queryGroup {
|
||||
return queryGroup{
|
||||
name: "filter",
|
||||
fixtures: []search.Resource{
|
||||
fixtureFolder("alice", withID("1$1!1"), withPath(".")),
|
||||
fixtureFolder(".space", withPath("./.space")),
|
||||
fixtureDoc("readme.md", withPath("./.space/readme.md")),
|
||||
fixtureFolder(".spaces", withPath("./.spaces")),
|
||||
fixtureDoc("notes.md"),
|
||||
},
|
||||
cases: []queryCase{
|
||||
{id: 1, query: `path:"./.space"`, want: []string{".space", "readme.md"}},
|
||||
{id: 2, query: `(name:"*.md") AND (NOT path:"./.space" AND NOT id:"none$none!none")`, want: []string{"notes.md"}},
|
||||
{id: 3, query: `(name:"alice" OR name:".spaces") AND (NOT path:"./.space" AND NOT id:"1$1!1")`, want: []string{".spaces"}},
|
||||
{id: 4, query: `(name:"alice") AND (NOT path:"./.space" AND NOT id:"none$none!none")`, want: []string{"alice"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,11 @@ func invalidGroup() queryGroup {
|
||||
},
|
||||
cases: []queryCase{
|
||||
{id: 1, query: `AND mediatype:document`, wantBadRequest: true},
|
||||
{id: 2, query: `mediatype:document AND`, want: []string{"alpha.txt"}},
|
||||
{id: 2, query: `mediatype:document AND`, wantBadRequest: true},
|
||||
{id: 3, query: `mediatype:document NOT`, wantBadRequest: true},
|
||||
{id: 4, query: `mediatype:document AND OR name:alpha`, wantBadRequest: true},
|
||||
{id: 5, query: `(mediatype:document`, wantBadRequest: true},
|
||||
{id: 6, query: `mediatype:document) OR (name:alpha`, wantBadRequest: true},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/protobuf/types/known/fieldmaskpb"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/kql"
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
searchmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
|
||||
searchsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0"
|
||||
@@ -75,6 +76,8 @@ type Service struct {
|
||||
serviceAccountSecret string
|
||||
|
||||
batchSize int
|
||||
|
||||
queryFilter string
|
||||
}
|
||||
|
||||
var errSkipSpace error
|
||||
@@ -92,11 +95,37 @@ func NewService(gatewaySelector pool.Selectable[gateway.GatewayAPIClient], eng E
|
||||
serviceAccountSecret: cfg.ServiceAccount.ServiceAccountSecret,
|
||||
|
||||
batchSize: cfg.BatchSize,
|
||||
|
||||
queryFilter: cfg.QueryFilter,
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// withQueryFilter adds the query filter to the engine query, so paging and totals stay right
|
||||
func (s *Service) withQueryFilter(query string, space *provider.StorageSpace) string {
|
||||
if s.queryFilter == "" {
|
||||
return query
|
||||
}
|
||||
|
||||
return "(" + query + ") AND (" + ExpandQueryFilter(s.queryFilter, space) + ")"
|
||||
}
|
||||
|
||||
// ExpandQueryFilter fills the query filter placeholders for the given space.
|
||||
func ExpandQueryFilter(query string, space *provider.StorageSpace) string {
|
||||
rootID := func(spaceType string) string {
|
||||
if space.GetSpaceType() == spaceType {
|
||||
return `"` + storagespace.FormatResourceID(space.GetRoot()) + `"`
|
||||
}
|
||||
return `"` + config.NoResourceID + `"`
|
||||
}
|
||||
|
||||
return strings.NewReplacer(
|
||||
config.PersonalSpaceRootPlaceholder, rootID(_spaceTypePersonal),
|
||||
config.ProjectSpaceRootPlaceholder, rootID(_spaceTypeProject),
|
||||
).Replace(query)
|
||||
}
|
||||
|
||||
// Search processes a search request and passes it down to the engine.
|
||||
func (s *Service) Search(ctx context.Context, req *searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) {
|
||||
s.logger.Debug().Str("query", req.Query).Msg("performing a search")
|
||||
@@ -136,6 +165,12 @@ func (s *Service) Search(ctx context.Context, req *searchsvc.SearchRequest) (*se
|
||||
if query == "" {
|
||||
return nil, errtypes.BadRequest("empty query provided")
|
||||
}
|
||||
if _, err := (kql.Builder{}).Build(query); err != nil {
|
||||
if kql.IsValidationError(err) {
|
||||
return nil, errtypes.BadRequest(err.Error())
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
req.Query = query
|
||||
if len(scope) > 0 {
|
||||
scopedID, err := storagespace.ParseID(scope)
|
||||
@@ -413,7 +448,7 @@ func (s *Service) searchIndex(ctx context.Context, req *searchsvc.SearchRequest,
|
||||
}
|
||||
|
||||
searchRequest := &searchsvc.SearchIndexRequest{
|
||||
Query: req.Query,
|
||||
Query: s.withQueryFilter(req.Query, space),
|
||||
Ref: &searchmsg.Reference{
|
||||
ResourceId: searchRootID,
|
||||
Path: searchPathPrefix,
|
||||
|
||||
@@ -177,6 +177,19 @@ var _ = Describe("Searchprovider", func() {
|
||||
)
|
||||
})
|
||||
|
||||
DescribeTable("ExpandQueryFilter",
|
||||
func(spaceType, expected string) {
|
||||
space := &sprovider.StorageSpace{
|
||||
SpaceType: spaceType,
|
||||
Root: &sprovider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "spaceid"},
|
||||
}
|
||||
Expect(search.ExpandQueryFilter(`NOT id:{{.SPACE_ROOT_PERSONAL}} AND NOT id:{{.SPACE_ROOT_PROJECT}}`, space)).To(Equal(expected))
|
||||
},
|
||||
Entry("a personal space fills the personal placeholder", "personal", `NOT id:"storageid$spaceid!spaceid" AND NOT id:"none$none!none"`),
|
||||
Entry("a project space fills the project placeholder", "project", `NOT id:"none$none!none" AND NOT id:"storageid$spaceid!spaceid"`),
|
||||
Entry("a share fills neither", "grant", `NOT id:"none$none!none" AND NOT id:"none$none!none"`),
|
||||
)
|
||||
|
||||
DescribeTable("IsHidden",
|
||||
func(path string, hidden bool) {
|
||||
Expect(search.IsHidden(path)).To(Equal(hidden))
|
||||
@@ -244,6 +257,17 @@ var _ = Describe("Searchprovider", func() {
|
||||
}))
|
||||
})
|
||||
|
||||
It("adds the query filter with the personal space root filled in", func() {
|
||||
s := search.NewService(gatewaySelector, indexClient, extractor, nil, logger, &config.Config{
|
||||
QueryFilter: `NOT path:"./.space" AND NOT id:{{.SPACE_ROOT_PERSONAL}}`,
|
||||
})
|
||||
_, err := s.Search(ctx, &searchsvc.SearchRequest{Query: "foo"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
indexClient.AssertCalled(GinkgoT(), "Search", mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchIndexRequest) bool {
|
||||
return req.Query == `(foo) AND (NOT path:"./.space" AND NOT id:"storageid$personalspace!personalspace")`
|
||||
}))
|
||||
})
|
||||
|
||||
It("searches the personal user space", func() {
|
||||
res, err := s.Search(ctx, &searchsvc.SearchRequest{
|
||||
Query: "foo",
|
||||
|
||||
@@ -100,12 +100,12 @@ Feature: Search
|
||||
@issue-10329
|
||||
Scenario Outline: user can search hidden files
|
||||
Given using <dav-path-version> DAV path
|
||||
And user "Alice" has created a folder ".space" in space "project101"
|
||||
When user "Alice" searches for "*.sp*" using the WebDAV API
|
||||
And user "Alice" has created a folder ".hidden" in space "project101"
|
||||
When user "Alice" searches for "*.hid*" using the WebDAV API
|
||||
Then the HTTP status code should be "207"
|
||||
And the search result should contain "1" entries
|
||||
And the search result of user "Alice" should contain these entries:
|
||||
| /.space |
|
||||
| /.hidden |
|
||||
Examples:
|
||||
| dav-path-version |
|
||||
| old |
|
||||
@@ -113,6 +113,28 @@ Feature: Search
|
||||
| spaces |
|
||||
|
||||
|
||||
Scenario Outline: user cannot search the .space folder of a space
|
||||
Given using <dav-path-version> DAV path
|
||||
And user "Alice" has created a folder ".space" in space "project101"
|
||||
When user "Alice" searches for "*.sp*" using the WebDAV API
|
||||
Then the HTTP status code should be "207"
|
||||
And the search result should contain "0" entries
|
||||
Examples:
|
||||
| dav-path-version |
|
||||
| old |
|
||||
| new |
|
||||
| spaces |
|
||||
|
||||
|
||||
Scenario: user cannot search files inside the .space folder of a space
|
||||
Given using spaces DAV path
|
||||
And user "Alice" has created a folder ".space" in space "project101"
|
||||
And user "Alice" has uploaded a file inside space "project101" with content "hidden content" to ".space/spaceInternal.txt"
|
||||
When user "Alice" searches for "*spaceInternal*" using the WebDAV API
|
||||
Then the HTTP status code should be "207"
|
||||
And the search result should contain "0" entries
|
||||
|
||||
|
||||
Scenario Outline: user cannot search pending share
|
||||
Given user "Brian" has disabled auto-accepting
|
||||
And using <dav-path-version> DAV path
|
||||
|
||||
@@ -184,9 +184,8 @@ Feature: media type search
|
||||
And user "Alice" has uploaded file "filesForUpload/data.tar.bz2" to "data.tar.bz2"
|
||||
When user "Alice" searches for "mediatype:folder" using the WebDAV API
|
||||
Then the HTTP status code should be "207"
|
||||
And the search result should contain "2" entries
|
||||
And the search result should contain "1" entries
|
||||
And the search result of user "Alice" should contain these entries:
|
||||
| %spaceid% |
|
||||
| testFolder |
|
||||
When user "Alice" searches for "mediatype:document" using the WebDAV API
|
||||
Then the HTTP status code should be "207"
|
||||
|
||||
@@ -261,5 +261,4 @@ Feature: tag search
|
||||
| (tag:mathe OR tag:klass10) NOT tag:physik | 2 | answers | verification work |
|
||||
| tag:mathe NOT name:exercises | 1 | answers | |
|
||||
| tag:mathe AND NOT name:exercises | 1 | answers | |
|
||||
# The third finding is the personal space itself
|
||||
| NOT tag:mathe | 3 | verification work | withoutTagFolder |
|
||||
| NOT tag:mathe | 2 | verification work | withoutTagFolder |
|
||||
Reference in new issue
Block a user