diff --git a/protogen/gen/opencloud/messages/search/v0/search.pb.go b/protogen/gen/opencloud/messages/search/v0/search.pb.go index 0a8bf9c1c2..2e8b6b53ab 100644 --- a/protogen/gen/opencloud/messages/search/v0/search.pb.go +++ b/protogen/gen/opencloud/messages/search/v0/search.pb.go @@ -824,6 +824,10 @@ type Entity struct { MotionPhoto *MotionPhoto `protobuf:"bytes,21,opt,name=motionPhoto,proto3" json:"motionPhoto,omitempty"` Video *Video `protobuf:"bytes,22,opt,name=video,proto3" json:"video,omitempty"` LivePhoto *LivePhoto `protobuf:"bytes,23,opt,name=livePhoto,proto3" json:"livePhoto,omitempty"` + // The effective permission actions of the caller, projected from the space + // root permission set at query time (the same source as `permissions`), for + // the driveItem `@libre.graph.permissions.actions.allowedValues` facet. + PermissionsActionsAllowedValues []string `protobuf:"bytes,24,rep,name=permissionsActionsAllowedValues,proto3" json:"permissionsActionsAllowedValues,omitempty"` } func (x *Entity) Reset() { @@ -1019,6 +1023,13 @@ func (x *Entity) GetLivePhoto() *LivePhoto { return nil } +func (x *Entity) GetPermissionsActionsAllowedValues() []string { + if x != nil { + return x.PermissionsActionsAllowedValues + } + return nil +} + type Match struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1259,7 +1270,7 @@ var file_opencloud_messages_search_v0_search_proto_rawDesc = []byte{ 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x65, 0x42, 0x19, 0x0a, 0x17, 0x5f, 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x65, 0x72, 0x73, 0x69, - 0x6f, 0x6e, 0x22, 0xc9, 0x08, 0x0a, 0x06, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x39, 0x0a, + 0x6f, 0x6e, 0x22, 0x93, 0x09, 0x0a, 0x06, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x39, 0x0a, 0x03, 0x72, 0x65, 0x66, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, @@ -1327,19 +1338,23 @@ var file_opencloud_messages_search_v0_search_proto_rawDesc = []byte{ 0x68, 0x6f, 0x74, 0x6f, 0x18, 0x17, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x4c, 0x69, 0x76, 0x65, 0x50, 0x68, - 0x6f, 0x74, 0x6f, 0x52, 0x09, 0x6c, 0x69, 0x76, 0x65, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x22, 0x5b, - 0x0a, 0x05, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x12, 0x3c, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, - 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, - 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, - 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, 0x65, - 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x02, 0x52, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x42, 0x4d, 0x5a, 0x4b, 0x67, - 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, - 0x6f, 0x75, 0x64, 0x2d, 0x65, 0x75, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, - 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x67, 0x65, 0x6e, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x6f, 0x70, - 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, - 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x76, 0x30, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x33, + 0x6f, 0x74, 0x6f, 0x52, 0x09, 0x6c, 0x69, 0x76, 0x65, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x48, + 0x0a, 0x1f, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x41, 0x63, 0x74, + 0x69, 0x6f, 0x6e, 0x73, 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, + 0x73, 0x18, 0x18, 0x20, 0x03, 0x28, 0x09, 0x52, 0x1f, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x73, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x41, 0x6c, 0x6c, 0x6f, 0x77, + 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x22, 0x5b, 0x0a, 0x05, 0x4d, 0x61, 0x74, 0x63, + 0x68, 0x12, 0x3c, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x24, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, + 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, + 0x14, 0x0a, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x02, 0x52, 0x05, + 0x73, 0x63, 0x6f, 0x72, 0x65, 0x42, 0x4d, 0x5a, 0x4b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, + 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x65, 0x75, + 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x67, 0x65, 0x6e, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, + 0x64, 0x2f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, + 0x68, 0x2f, 0x76, 0x30, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/protogen/gen/opencloud/services/search/v0/search.swagger.json b/protogen/gen/opencloud/services/search/v0/search.swagger.json index 3e29fc7cb8..11ebb9da16 100644 --- a/protogen/gen/opencloud/services/search/v0/search.swagger.json +++ b/protogen/gen/opencloud/services/search/v0/search.swagger.json @@ -307,6 +307,13 @@ }, "livePhoto": { "$ref": "#/definitions/v0LivePhoto" + }, + "permissionsActionsAllowedValues": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The effective permission actions of the caller, projected from the space\nroot permission set at query time (the same source as `permissions`), for\nthe driveItem `@libre.graph.permissions.actions.allowedValues` facet." } } }, diff --git a/protogen/proto/opencloud/messages/search/v0/search.proto b/protogen/proto/opencloud/messages/search/v0/search.proto index a2a25a8d5e..8814fb1e07 100644 --- a/protogen/proto/opencloud/messages/search/v0/search.proto +++ b/protogen/proto/opencloud/messages/search/v0/search.proto @@ -110,6 +110,10 @@ message Entity { MotionPhoto motionPhoto = 21; Video video = 22; LivePhoto livePhoto = 23; + // The effective permission actions of the caller, projected from the space + // root permission set at query time (the same source as `permissions`), for + // the driveItem `@libre.graph.permissions.actions.allowedValues` facet. + repeated string permissionsActionsAllowedValues = 24; } message Match { diff --git a/services/graph/pkg/service/v0/driveitems.go b/services/graph/pkg/service/v0/driveitems.go index 167f8b1b62..60735a832f 100644 --- a/services/graph/pkg/service/v0/driveitems.go +++ b/services/graph/pkg/service/v0/driveitems.go @@ -500,6 +500,15 @@ func cs3TimestampToTime(t *types.Timestamp) time.Time { return time.Unix(int64(t.GetSeconds()), int64(t.GetNanos())) } +func webURLForID(publicBaseURL *url.URL, id string) *string { + if publicBaseURL == nil { + return nil + } + u := *publicBaseURL + u.Path = path.Join(u.Path, "f", id) + return libregraph.PtrString(u.String()) +} + func cs3ResourceToDriveItem(logger *log.Logger, publicBaseURL *url.URL, res *storageprovider.ResourceInfo) (*libregraph.DriveItem, error) { size := new(int64) *size = int64(res.GetSize()) // TODO lurking overflow: make size of libregraph drive item use uint64 @@ -509,9 +518,7 @@ func cs3ResourceToDriveItem(logger *log.Logger, publicBaseURL *url.URL, res *sto Size: size, } - webURL := *publicBaseURL - webURL.Path = path.Join(webURL.Path, "f", storagespace.FormatResourceID(res.GetId())) - driveItem.WebUrl = libregraph.PtrString(webURL.String()) + driveItem.WebUrl = webURLForID(publicBaseURL, storagespace.FormatResourceID(res.GetId())) if name := path.Base(res.GetPath()); name != "" { driveItem.Name = &name diff --git a/services/graph/pkg/service/v0/searchquery.go b/services/graph/pkg/service/v0/searchquery.go new file mode 100644 index 0000000000..38f31972f9 --- /dev/null +++ b/services/graph/pkg/service/v0/searchquery.go @@ -0,0 +1,212 @@ +package svc + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/go-chi/render" + libregraph "github.com/opencloud-eu/libre-graph-api-go" + revaCtx "github.com/opencloud-eu/reva/v2/pkg/ctx" + merrors "go-micro.dev/v4/errors" + "go-micro.dev/v4/metadata" + + searchsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0" + "github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode" + "github.com/opencloud-eu/opencloud/services/search/pkg/search" +) + +// SearchQuery handles POST /v1beta1/search/query (MS Graph searchQuery). +func (g Graph) SearchQuery(w http.ResponseWriter, r *http.Request) { + var req libregraph.SearchQueryRequest + if err := StrictJSONUnmarshal(r.Body, &req); err != nil { + g.logger.Debug().Err(err).Msg("could not decode search query request") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid body schema definition") + return + } + + if len(req.Requests) == 0 { + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "requests array must not be empty") + return + } + + if err := validateSearchExpand(r); err != nil { + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error()) + return + } + + // every request is validated before the first one runs + searches := make([]*searchsvc.SearchRequest, 0, len(req.Requests)) + for _, sr := range req.Requests { + if property := unsupportedProperty(sr); property != "" { + errorcode.NotSupported.Render(w, r, http.StatusNotImplemented, property+" is not supported yet") + return + } + prepared, err := searchRequestOf(sr) + if err != nil { + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error()) + return + } + searches = append(searches, prepared) + } + + th := r.Header.Get(revaCtx.TokenHeader) + ctx := revaCtx.ContextSetToken(r.Context(), th) + ctx = metadata.Set(ctx, revaCtx.TokenHeader, th) + + expandThumbnails := driveItemRelationExpanded(r, _expandThumbnails) + + responses := make([]libregraph.SearchResponse, 0, len(req.Requests)) + for i, sr := range req.Requests { + sresp, err := g.runSingleSearch(ctx, sr, searches[i], expandThumbnails) + if err != nil { + g.renderSearchError(w, r, err) + return + } + responses = append(responses, sresp) + } + + render.Status(r, http.StatusOK) + render.JSON(w, r, libregraph.SearchQuery200Response{Value: responses}) +} + +func validateSearchExpand(r *http.Request) error { + for _, values := range r.URL.Query()["$expand"] { + for _, relation := range strings.Split(values, ",") { + if relation != _expandThumbnails { + return fmt.Errorf("unsupported $expand %q; only %s is supported", relation, _expandThumbnails) + } + } + } + return nil +} + +// unsupportedProperty names a property of the spec this endpoint does not +// evaluate yet. +func unsupportedProperty(sr libregraph.SearchRequest) string { + if len(sr.SortProperties) > 0 { + return "sortProperties" + } + if len(sr.Aggregations) > 0 { + return "aggregations" + } + if len(sr.AggregationFilters) > 0 { + return "aggregationFilters" + } + return "" +} + +// searchRequestOf validates one request against the spec and translates it. +func searchRequestOf(sr libregraph.SearchRequest) (*searchsvc.SearchRequest, error) { + if err := validateEntityTypes(sr.EntityTypes); err != nil { + return nil, err + } + if err := validatePagination(sr.From, sr.Size); err != nil { + return nil, err + } + from, size := pagination(sr.From, sr.Size) + return &searchsvc.SearchRequest{ + Query: sr.Query.QueryString, + From: from, + PageSize: &size, + }, nil +} + +func validateEntityTypes(entityTypes []string) error { + if len(entityTypes) == 0 { + return fmt.Errorf("entityTypes must contain at least one entry") + } + for _, t := range entityTypes { + if t != "driveItem" { + return fmt.Errorf("unsupported entity type %q; only driveItem is supported", t) + } + } + return nil +} + +func (g Graph) runSingleSearch(ctx context.Context, sr libregraph.SearchRequest, prepared *searchsvc.SearchRequest, expandThumbnails bool) (libregraph.SearchResponse, error) { + from, size := prepared.GetFrom(), prepared.GetPageSize() + + rsp, err := g.searchService.Search(ctx, prepared) + if err != nil { + return libregraph.SearchResponse{}, err + } + + // the caller's id decides @libre.graph.me.following (the WebDAV report's oc:favorite) + uid := "" + if u, ok := revaCtx.ContextGetUser(ctx); ok { + uid = u.GetId().GetOpaqueId() + } + + hits := make([]libregraph.SearchHit, 0, len(rsp.Matches)) + for i, match := range rsp.Matches { + item := searchEntityToDriveItem(match.GetEntity(), uid) + item.WebUrl = webURLForID(g.publicBaseURL, item.GetId()) + if expandThumbnails { + setDriveItemThumbnailsByID(item, item.GetId(), g.config.Commons.OpenCloudURL) + } + hit := libregraph.SearchHit{HitId: item.Id, Rank: libregraph.PtrInt32(from + int32(i) + 1), Resource: item} + if h := match.GetEntity().GetHighlights(); h != "" { + hit.Summary = libregraph.PtrString(h) + } + hits = append(hits, hit) + } + + total := int64(rsp.TotalMatches) + // a next page has to exist and to be within reach + next := int64(from) + int64(size) + more := next < total && next < search.MaxResultWindow + return libregraph.SearchResponse{ + SearchTerms: []string{sr.Query.QueryString}, + HitsContainers: []libregraph.SearchHitsContainer{{ + Hits: hits, + Total: &total, + MoreResultsAvailable: &more, + }}, + }, nil +} + +// the spec's default and upper bound of SearchRequest.size +const ( + defaultPageSize = 25 + maxPageSize = 500 +) + +func pagination(fromP, sizeP *int32) (from, size int32) { + from, size = 0, defaultPageSize + if fromP != nil { + from = *fromP + } + if sizeP != nil { + size = *sizeP + } + return from, size +} + +// openapi-generator does not enforce the spec's [0,inf)/[0,500] bounds +func validatePagination(fromP, sizeP *int32) error { + from, size := pagination(fromP, sizeP) + if from < 0 { + return fmt.Errorf("from must not be negative") + } + if size < 0 || size > maxPageSize { + return fmt.Errorf("size must be between 0 and %d", maxPageSize) + } + return search.CheckResultWindow(from, size) +} + +// renderSearchError answers with the status the search service failed with. +func (g Graph) renderSearchError(w http.ResponseWriter, r *http.Request, err error) { + e := merrors.Parse(err.Error()) + switch e.Code { + case http.StatusBadRequest: + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, e.Detail) + case http.StatusRequestTimeout, http.StatusServiceUnavailable, http.StatusGatewayTimeout: + g.logger.Error().Err(err).Msg("search service did not answer") + errorcode.ServiceNotAvailable.Render(w, r, http.StatusServiceUnavailable, e.Detail) + default: + g.logger.Error().Err(err).Msg("search service call failed") + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, e.Detail) + } +} diff --git a/services/graph/pkg/service/v0/searchquery_mapping.go b/services/graph/pkg/service/v0/searchquery_mapping.go new file mode 100644 index 0000000000..6f536db692 --- /dev/null +++ b/services/graph/pkg/service/v0/searchquery_mapping.go @@ -0,0 +1,97 @@ +package svc + +import ( + "path" + "slices" + "time" + + storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + libregraph "github.com/opencloud-eu/libre-graph-api-go" + "github.com/opencloud-eu/reva/v2/pkg/storagespace" + + searchmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0" + "github.com/opencloud-eu/opencloud/services/search/pkg/mapping" +) + +func searchResourceID(id *searchmsg.ResourceID) string { + return storagespace.FormatResourceID(&storageprovider.ResourceId{ + StorageId: id.GetStorageId(), + SpaceId: id.GetSpaceId(), + OpaqueId: id.GetOpaqueId(), + }) +} + +func searchEntityToDriveItem(e *searchmsg.Entity, uid string) *libregraph.DriveItem { + size := int64(e.GetSize()) + di := &libregraph.DriveItem{ + Id: libregraph.PtrString(searchResourceID(e.GetId())), + Name: libregraph.PtrString(e.GetName()), + Size: &size, + } + if etag := e.GetEtag(); etag != "" { + di.ETag = &etag + } + if mt := e.GetLastModifiedTime(); mt != nil { + lm := time.Unix(mt.GetSeconds(), int64(mt.GetNanos())).UTC() + di.LastModifiedDateTime = &lm + } + if e.GetType() == uint64(storageprovider.ResourceType_RESOURCE_TYPE_FILE) && e.GetMimeType() != "" { + mt := e.GetMimeType() + di.File = &libregraph.OpenGraphFile{MimeType: &mt} + } + if e.GetType() == uint64(storageprovider.ResourceType_RESOURCE_TYPE_CONTAINER) { + di.Folder = &libregraph.Folder{} + } + if p := e.GetParentId(); p != nil { + ref := libregraph.NewItemReference() + ref.SetDriveId(storagespace.FormatStorageID(p.GetStorageId(), p.GetSpaceId())) + ref.SetId(searchResourceID(p)) + if refPath := e.GetRef().GetPath(); refPath != "" { + // the index keeps paths relative to the space root (./dir/file) + parent := path.Dir(path.Join("/", refPath)) + ref.SetPath(parent) + if parent != "/" { + ref.SetName(path.Base(parent)) + } + } + di.ParentReference = ref + } + di.RemoteItem = searchEntityToRemoteItem(e) + di.Audio = mapping.FromProto[libregraph.Audio](e.GetAudio()) + di.Image = mapping.FromProto[libregraph.Image](e.GetImage()) + di.Photo = mapping.FromProto[libregraph.Photo](e.GetPhoto()) + di.Location = mapping.FromProto[libregraph.GeoCoordinates](e.GetLocation()) + di.Video = mapping.FromProto[libregraph.Video](e.GetVideo()) + di.LibreGraphMotionPhoto = mapping.FromProto[libregraph.MotionPhoto](e.GetMotionPhoto()) + di.LibreGraphLivePhoto = mapping.FromProto[libregraph.LivePhoto](e.GetLivePhoto()) + if tags := e.GetTags(); len(tags) > 0 { + di.LibreGraphTags = tags + } + if av := e.GetPermissionsActionsAllowedValues(); len(av) > 0 { + di.LibreGraphPermissionsActionsAllowedValues = av + } + // the WebDAV report emits oc:favorite only when the caller favorited the item; mirror that + if uid != "" && slices.Contains(e.GetFavorites(), uid) { + di.LibreGraphMeFollowing = libregraph.PtrBool(true) + } + return di +} + +// remoteItem carries the id in the owner's drive and the mountpoint it is +// reached through; absent for hits from the caller's own spaces +func searchEntityToRemoteItem(e *searchmsg.Entity) *libregraph.RemoteItem { + id := e.GetRemoteItemId() + if id == nil { + return nil + } + + item := libregraph.NewRemoteItem() + item.SetId(searchResourceID(id)) + + if root := e.GetShareRootName(); root != "" { + item.SetPath(root) + item.SetName(path.Base(root)) + } + + return item +} diff --git a/services/graph/pkg/service/v0/searchquery_test.go b/services/graph/pkg/service/v0/searchquery_test.go new file mode 100644 index 0000000000..67a49f6470 --- /dev/null +++ b/services/graph/pkg/service/v0/searchquery_test.go @@ -0,0 +1,327 @@ +package svc + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + + // ginkgo qualified: the svc package declares Context (option.go), which + // would collide with a dot-import. + ginkgo "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + libregraph "github.com/opencloud-eu/libre-graph-api-go" + "go-micro.dev/v4/client" + merrors "go-micro.dev/v4/errors" + + "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" +) + +type stubSearchService struct { + search func(*searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) +} + +func (s stubSearchService) Search(_ context.Context, req *searchsvc.SearchRequest, _ ...client.CallOption) (*searchsvc.SearchResponse, error) { + return s.search(req) +} + +func (s stubSearchService) IndexSpace(_ context.Context, _ *searchsvc.IndexSpaceRequest, _ ...client.CallOption) (searchsvc.SearchProvider_IndexSpaceService, error) { + return nil, nil +} + +func graphWithSearch(stub stubSearchService) Graph { + logger := log.NewLogger() + return Graph{ + BaseGraphService: BaseGraphService{logger: &logger}, + searchService: stub, + } +} + +// graphWithSearchAnswer answers every search with resp and hands out the last +// request it saw. +func graphWithSearchAnswer(resp *searchsvc.SearchResponse) (Graph, func() *searchsvc.SearchRequest) { + var captured *searchsvc.SearchRequest + g := graphWithSearch(stubSearchService{ + search: func(req *searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) { + captured = req + return resp, nil + }, + }) + return g, func() *searchsvc.SearchRequest { return captured } +} + +func graphWithoutSearch() Graph { + return graphWithSearch(stubSearchService{ + search: func(*searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) { + ginkgo.Fail("search service must not be called when validation fails") + return nil, nil + }, + }) +} + +func postSearchQuery(g Graph, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/search/query", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + g.SearchQuery(rr, req) + return rr +} + +func searchQueryBody(fragment string) string { + return `{"requests": [{"entityTypes": ["driveItem"], "query": {"queryString": "mediatype:audio"}, "size": 0, ` + fragment + `}]}` +} + +func oneMatch(entity *searchmsg.Entity) *searchsvc.SearchResponse { + return &searchsvc.SearchResponse{TotalMatches: 1, Matches: []*searchmsg.Match{{Entity: entity}}} +} + +type searchHitsContainerJSON struct { + Hits []struct { + Rank int32 `json:"rank"` + Resource struct { + RemoteItem *struct { + Id string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + } `json:"remoteItem"` + ParentReference struct { + Id string `json:"id"` + Path string `json:"path"` + Name *string `json:"name"` + } `json:"parentReference"` + } `json:"resource"` + } `json:"hits"` + Total int64 `json:"total"` + MoreResultsAvailable bool `json:"moreResultsAvailable"` +} + +// hitsContainers decodes the one hits container of every request. +func hitsContainers(rr *httptest.ResponseRecorder) []searchHitsContainerJSON { + var decoded struct { + Value []struct { + HitsContainers []searchHitsContainerJSON `json:"hitsContainers"` + } `json:"value"` + } + Expect(json.Unmarshal(rr.Body.Bytes(), &decoded)).To(Succeed()) + out := make([]searchHitsContainerJSON, 0, len(decoded.Value)) + for _, v := range decoded.Value { + Expect(v.HitsContainers).To(HaveLen(1)) + out = append(out, v.HitsContainers[0]) + } + return out +} + +func hitsContainer(rr *httptest.ResponseRecorder) searchHitsContainerJSON { + containers := hitsContainers(rr) + Expect(containers).To(HaveLen(1)) + return containers[0] +} + +var _ = ginkgo.Describe("SearchQuery", func() { + ginkgo.DescribeTable("rejects a malformed request with 400 before any search runs", + func(body, message string) { + rr := postSearchQuery(graphWithoutSearch(), body) + Expect(rr.Code).To(Equal(http.StatusBadRequest), rr.Body.String()) + Expect(rr.Body.String()).To(ContainSubstring(message)) + }, + ginkgo.Entry("entityTypes absent, the generated model requires it", `{"requests": [{"query": {"queryString": "fox"}}]}`, "invalid body"), + ginkgo.Entry("entityTypes empty", `{"requests": [{"entityTypes": [], "query": {"queryString": "fox"}}]}`, "entityTypes"), + ginkgo.Entry("another entity", `{"requests": [{"entityTypes": ["message"], "query": {"queryString": "fox"}}]}`, "message"), + ginkgo.Entry("a page beyond the first 10000 matches, whatever the engine", + `{"requests": [{"entityTypes": ["driveItem"], "query": {"queryString": "notes"}, "from": 10000, "size": 25}]}`, + `"message":"from and size reach beyond`), + ginkgo.Entry("a bad second request, every request is validated before the first one runs", `{"requests": [ + {"entityTypes": ["driveItem"], "query": {"queryString": "notes"}}, + {"entityTypes": ["driveItem"], "query": {"queryString": "notes"}, "size": 1000} + ]}`, "size must be"), + ) + + ginkgo.It("answers one hits container per request, in request order", func() { + g := graphWithSearch(stubSearchService{ + search: func(req *searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) { + return &searchsvc.SearchResponse{TotalMatches: int32(len(req.GetQuery()))}, nil + }, + }) + rr := postSearchQuery(g, `{"requests": [ + {"entityTypes": ["driveItem"], "query": {"queryString": "a"}, "size": 0}, + {"entityTypes": ["driveItem"], "query": {"queryString": "abc"}, "size": 0} + ]}`) + Expect(rr.Code).To(Equal(http.StatusOK), rr.Body.String()) + + containers := hitsContainers(rr) + Expect(containers).To(HaveLen(2)) + Expect(containers[0].Total).To(Equal(int64(1))) + Expect(containers[1].Total).To(Equal(int64(3))) + }) + + ginkgo.DescribeTable("describes a hit from a shared space as a remote item", + func(entity *searchmsg.Entity, wantID, wantPath, wantName string) { + g, _ := graphWithSearchAnswer(oneMatch(entity)) + rr := postSearchQuery(g, searchQueryBody(`"from": 0`)) + Expect(rr.Code).To(Equal(http.StatusOK), rr.Body.String()) + + remote := hitsContainer(rr).Hits[0].Resource.RemoteItem + if wantID == "" { + Expect(remote).To(BeNil()) + return + } + Expect(remote.Id).To(Equal(wantID)) + Expect(remote.Path).To(Equal(wantPath)) + Expect(remote.Name).To(Equal(wantName), "the mountpoint name the caller sees") + }, + ginkgo.Entry("a hit from a shared space", &searchmsg.Entity{ + Id: &searchmsg.ResourceID{StorageId: "1", SpaceId: "2", OpaqueId: "3"}, + Name: "contract.pdf", + ShareRootName: "/Project X", + RemoteItemId: &searchmsg.ResourceID{StorageId: "4", SpaceId: "5", OpaqueId: "6"}, + }, "4$5!6", "/Project X", "Project X"), + ginkgo.Entry("a hit from the caller's own space has none", &searchmsg.Entity{ + Id: &searchmsg.ResourceID{StorageId: "1", SpaceId: "2", OpaqueId: "3"}, + Name: "notes.txt", + }, "", "", ""), + ) + + ginkgo.DescribeTable("describes the parent of a hit by its path from the space root", + func(refPath, wantPath string, wantName *string) { + g, _ := graphWithSearchAnswer(oneMatch(&searchmsg.Entity{ + Id: &searchmsg.ResourceID{StorageId: "1", SpaceId: "2", OpaqueId: "3"}, + ParentId: &searchmsg.ResourceID{StorageId: "1", SpaceId: "2", OpaqueId: "4"}, + Ref: &searchmsg.Reference{Path: refPath}, + Name: "notes.txt", + })) + rr := postSearchQuery(g, searchQueryBody(`"from": 0`)) + Expect(rr.Code).To(Equal(http.StatusOK), rr.Body.String()) + + parent := hitsContainer(rr).Hits[0].Resource.ParentReference + Expect(parent.Id).To(Equal("1$2!4")) + Expect(parent.Path).To(Equal(wantPath)) + if wantName == nil { + Expect(parent.Name).To(BeNil()) + } else { + Expect(parent.Name).To(HaveValue(Equal(*wantName))) + } + }, + ginkgo.Entry("a hit in a folder", "./projects/2026/notes.txt", "/projects/2026", libregraph.PtrString("2026")), + ginkgo.Entry("a hit in the space root, the root has no name", "./notes.txt", "/", nil), + ) + + ginkgo.DescribeTable("validatePagination enforces the spec's from/size bounds", + func(from, size *int32, wantErr bool) { + err := validatePagination(from, size) + if wantErr { + Expect(err).To(HaveOccurred()) + } else { + Expect(err).ToNot(HaveOccurred()) + } + }, + ginkgo.Entry("absent values are valid", nil, nil, false), + ginkgo.Entry("zero size is valid", libregraph.PtrInt32(5), libregraph.PtrInt32(0), false), + ginkgo.Entry("maximum size is valid", libregraph.PtrInt32(0), libregraph.PtrInt32(500), false), + ginkgo.Entry("negative from is rejected", libregraph.PtrInt32(-10), libregraph.PtrInt32(5), true), + ginkgo.Entry("negative size is rejected", libregraph.PtrInt32(10), libregraph.PtrInt32(-1), true), + ginkgo.Entry("oversized size is rejected", libregraph.PtrInt32(0), libregraph.PtrInt32(1000), true), + ginkgo.Entry("the last page within the first 10000 matches is valid", libregraph.PtrInt32(9975), libregraph.PtrInt32(25), false), + ginkgo.Entry("a page reaching beyond the first 10000 matches is rejected", libregraph.PtrInt32(9990), libregraph.PtrInt32(25), true), + ginkgo.Entry("the default size counts", libregraph.PtrInt32(10000), nil, true), + ginkgo.Entry("a from that overflows with the size is rejected", libregraph.PtrInt32(2147483647), libregraph.PtrInt32(500), true), + ) + + ginkgo.It("pushes from/size to the search service and offsets the ranks", func() { + g, captured := graphWithSearchAnswer(&searchsvc.SearchResponse{ + TotalMatches: 42, + Matches: []*searchmsg.Match{ + {Entity: &searchmsg.Entity{Id: &searchmsg.ResourceID{StorageId: "1", SpaceId: "2", OpaqueId: "a"}, Name: "a.txt"}}, + {Entity: &searchmsg.Entity{Id: &searchmsg.ResourceID{StorageId: "1", SpaceId: "2", OpaqueId: "b"}, Name: "b.txt"}}, + }, + }) + rr := postSearchQuery(g, `{"requests": [{"entityTypes": ["driveItem"], "query": {"queryString": "notes"}, "from": 10, "size": 2}]}`) + Expect(rr.Code).To(Equal(http.StatusOK), rr.Body.String()) + Expect(captured().GetFrom()).To(Equal(int32(10))) + Expect(captured().GetPageSize()).To(Equal(int32(2))) + + hc := hitsContainer(rr) + Expect(hc.Total).To(Equal(int64(42))) + Expect(hc.MoreResultsAvailable).To(BeTrue()) + Expect(hc.Hits).To(HaveLen(2)) + Expect(hc.Hits[0].Rank).To(Equal(int32(11))) + Expect(hc.Hits[1].Rank).To(Equal(int32(12))) + }) + + ginkgo.DescribeTable("promises more results only within reach of from and size", + func(from, size, total int, want bool) { + g, _ := graphWithSearchAnswer(&searchsvc.SearchResponse{TotalMatches: int32(total)}) + rr := postSearchQuery(g, fmt.Sprintf(`{"requests": [{"entityTypes": ["driveItem"], "query": {"queryString": "notes"}, "from": %d, "size": %d}]}`, from, size)) + Expect(rr.Code).To(Equal(http.StatusOK), rr.Body.String()) + Expect(hitsContainer(rr).MoreResultsAvailable).To(Equal(want)) + }, + ginkgo.Entry("more matches than the page", 0, 25, 42, true), + ginkgo.Entry("the last page", 25, 25, 42, false), + ginkgo.Entry("a next page within the first 10000 matches", 9950, 25, 20000, true), + ginkgo.Entry("a next page the search would refuse", 9975, 25, 20000, false), + ) + + ginkgo.It("sends an explicit zero page size for facet-only requests", func() { + g, captured := graphWithSearchAnswer(&searchsvc.SearchResponse{TotalMatches: 7}) + rr := postSearchQuery(g, searchQueryBody(`"from": 0`)) + Expect(rr.Code).To(Equal(http.StatusOK), rr.Body.String()) + Expect(captured().PageSize).To(HaveValue(Equal(int32(0)))) + + hc := hitsContainer(rr) + Expect(hc.Total).To(Equal(int64(7))) + Expect(hc.Hits).To(BeEmpty()) + }) + + ginkgo.DescribeTable("answers a property it does not evaluate with 501 instead of ignoring it", + func(fragment, property string) { + rr := postSearchQuery(graphWithoutSearch(), searchQueryBody(fragment)) + Expect(rr.Code).To(Equal(http.StatusNotImplemented), rr.Body.String()) + Expect(rr.Body.String()).To(ContainSubstring("notSupported")) + Expect(rr.Body.String()).To(ContainSubstring(property)) + }, + ginkgo.Entry("sortProperties", `"sortProperties": [{"name": "name"}]`, "sortProperties"), + ginkgo.Entry("aggregations", `"aggregations": [{"field": "audio.artist"}]`, "aggregations"), + ginkgo.Entry("aggregationFilters", `"aggregationFilters": ["audio.artist:\"ǂǂ5361786f6e\""]`, "aggregationFilters"), + ) + + ginkgo.It("rejects an $expand it does not know with 400", func() { + req := httptest.NewRequest(http.MethodPost, "/search/query?$expand=thumbnails,permissions", bytes.NewBufferString(searchQueryBody(`"from": 0`))) + rr := httptest.NewRecorder() + graphWithoutSearch().SearchQuery(rr, req) + Expect(rr.Code).To(Equal(http.StatusBadRequest), rr.Body.String()) + Expect(rr.Body.String()).To(ContainSubstring("permissions")) + }) + + ginkgo.DescribeTable("answers with the status the search service failed with", + func(serviceErr error, status int, code, message string) { + g := graphWithSearch(stubSearchService{ + search: func(*searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) { return nil, serviceErr }, + }) + rr := postSearchQuery(g, searchQueryBody(`"from": 0`)) + Expect(rr.Code).To(Equal(status), rr.Body.String()) + + var decoded struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + Expect(json.Unmarshal(rr.Body.Bytes(), &decoded)).To(Succeed()) + Expect(decoded.Error.Code).To(Equal(code)) + Expect(decoded.Error.Message).To(Equal(message), "the detail, not the error envelope of the service") + }, + ginkgo.Entry("a request the service refuses", + merrors.BadRequest("search", "empty query provided"), + http.StatusBadRequest, "invalidRequest", "empty query provided"), + ginkgo.Entry("a timeout", merrors.Timeout("search", "deadline exceeded"), + http.StatusServiceUnavailable, "serviceNotAvailable", "deadline exceeded"), + ginkgo.Entry("a failing engine", merrors.InternalServerError("search", "engine down"), + http.StatusInternalServerError, "generalException", "engine down"), + ginkgo.Entry("an error that is no service error", errors.New("connection refused"), + http.StatusInternalServerError, "generalException", "connection refused"), + ) +}) diff --git a/services/graph/pkg/service/v0/service.go b/services/graph/pkg/service/v0/service.go index 347fe8ce17..46edb977b0 100644 --- a/services/graph/pkg/service/v0/service.go +++ b/services/graph/pkg/service/v0/service.go @@ -111,6 +111,8 @@ type Service interface { //nolint:interfacebloat GetTags(w http.ResponseWriter, r *http.Request) AssignTags(w http.ResponseWriter, r *http.Request) UnassignTags(w http.ResponseWriter, r *http.Request) + + SearchQuery(w http.ResponseWriter, r *http.Request) } // NewService returns a service implementation for Service. @@ -289,6 +291,7 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx r.Get("/", svc.GetRoleDefinitions) r.Get("/{roleID}", svc.GetRoleDefinition) }) + r.Post("/search/query", svc.SearchQuery) }) r.Route("/v1.0", func(r chi.Router) { r.Route("/extensions/org.libregraph", func(r chi.Router) { diff --git a/services/graph/pkg/service/v0/sharedbyme.go b/services/graph/pkg/service/v0/sharedbyme.go index cc61df8b75..a3c385096c 100644 --- a/services/graph/pkg/service/v0/sharedbyme.go +++ b/services/graph/pkg/service/v0/sharedbyme.go @@ -42,7 +42,7 @@ func (g Graph) GetSharedByMe(w http.ResponseWriter, r *http.Request) { expandThumbnails := strings.Contains(expand, "thumbnails") if expandThumbnails { for k, item := range driveItems { - setShareThumbnails(&item, item.GetId(), g.config.Commons.OpenCloudURL) + setDriveItemThumbnailsByID(&item, item.GetId(), g.config.Commons.OpenCloudURL) driveItems[k] = item } } diff --git a/services/graph/pkg/service/v0/sharedwithme.go b/services/graph/pkg/service/v0/sharedwithme.go index 87aa96a887..c4c28a9d50 100644 --- a/services/graph/pkg/service/v0/sharedwithme.go +++ b/services/graph/pkg/service/v0/sharedwithme.go @@ -71,7 +71,7 @@ func (g Graph) listSharedWithMe(ctx context.Context, expandThumbnails bool) ([]l if expandThumbnails { for k, item := range driveItems { - setShareThumbnails(&item, item.RemoteItem.GetId(), g.config.Commons.OpenCloudURL) + setDriveItemThumbnailsByID(&item, item.RemoteItem.GetId(), g.config.Commons.OpenCloudURL) driveItems[k] = item } } diff --git a/services/graph/pkg/service/v0/thumbnails.go b/services/graph/pkg/service/v0/thumbnails.go index 75d28bd3a7..118f838c37 100644 --- a/services/graph/pkg/service/v0/thumbnails.go +++ b/services/graph/pkg/service/v0/thumbnails.go @@ -57,9 +57,8 @@ func previewThumbnail(base string, box int32) *libregraph.Thumbnail { return &libregraph.Thumbnail{Url: &url} } -// setShareThumbnails works off the driveItem, the share listings have no resource -// info. The id comes separately, a received share carries it on its remote item. -func setShareThumbnails(item *libregraph.DriveItem, itemID, baseURL string) { +// for callers that have no CS3 resource info: the share listings and the search results +func setDriveItemThumbnailsByID(item *libregraph.DriveItem, itemID, baseURL string) { mimeType := item.GetFile().MimeType if itemID == "" || mimeType == nil || !thumbnail.IsMimeTypeSupported(*mimeType) { return diff --git a/services/search/pkg/mapping/proto.go b/services/search/pkg/mapping/proto.go new file mode 100644 index 0000000000..64b7262044 --- /dev/null +++ b/services/search/pkg/mapping/proto.go @@ -0,0 +1,37 @@ +package mapping + +import ( + "reflect" + + "github.com/opencloud-eu/opencloud/pkg/conversions" +) + +// FromProto builds a *T (a libregraph facet) from the equally shaped proto +// facet message. The bridge runs over proto3 JSON, which renders int64 as +// strings, so leaves that fail the typed set are re-parsed from the string. +// Fail-soft like the deserializers: nil for nil input or an empty message. +func FromProto[T any, M any](m *M) *T { + if m == nil { + return nil + } + t := reflect.TypeFor[T]() + fields, err := conversions.To[map[string]any](m) + if err != nil || len(fields) == 0 { + return nil + } + out := reflect.New(t) + if !fillStruct(out.Elem(), fields, "", setValueLenient) { + return nil + } + return out.Interface().(*T) +} + +func setValueLenient(v reflect.Value, raw any) error { + err := setValue(v, raw) + if err != nil { + if s, ok := raw.(string); ok { + return setValueFromString(v, s) + } + } + return err +} diff --git a/services/search/pkg/mapping/proto_test.go b/services/search/pkg/mapping/proto_test.go new file mode 100644 index 0000000000..5e904b4040 --- /dev/null +++ b/services/search/pkg/mapping/proto_test.go @@ -0,0 +1,55 @@ +package mapping + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "google.golang.org/protobuf/types/known/timestamppb" + + libregraph "github.com/opencloud-eu/libre-graph-api-go" + "github.com/opencloud-eu/opencloud/pkg/conversions" + searchmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0" +) + +var _ = Describe("FromProto", func() { + It("maps an audio facet including proto3-JSON int64 strings", func() { + in := &searchmsg.Audio{ + Artist: conversions.ToPointer("Saxon"), + Album: conversions.ToPointer("Wheels of Steel"), + Bitrate: conversions.ToPointer(int64(320)), + Year: conversions.ToPointer(int32(1980)), + } + out := FromProto[libregraph.Audio](in) + Expect(out).ToNot(BeNil()) + Expect(out.GetArtist()).To(Equal("Saxon")) + Expect(out.GetAlbum()).To(Equal("Wheels of Steel")) + Expect(out.GetBitrate()).To(Equal(int64(320))) + Expect(out.GetYear()).To(Equal(int32(1980))) + }) + + It("maps a timestamp to time.Time and float32 to float64", func() { + taken := timestamppb.New(mustTime("2023-07-21T10:11:12Z")) + in := &searchmsg.Photo{ + CameraMake: conversions.ToPointer("Nikon"), + FNumber: conversions.ToPointer(float32(2.8)), + TakenDateTime: taken, + } + out := FromProto[libregraph.Photo](in) + Expect(out).ToNot(BeNil()) + Expect(out.GetCameraMake()).To(Equal("Nikon")) + Expect(out.GetFNumber()).To(BeNumerically("~", 2.8, 0.0001)) + Expect(out.GetTakenDateTime()).To(Equal(mustTime("2023-07-21T10:11:12Z"))) + }) + + It("returns nil for nil input and for an empty message", func() { + Expect(FromProto[libregraph.Audio]((*searchmsg.Audio)(nil))).To(BeNil()) + Expect(FromProto[libregraph.Audio](&searchmsg.Audio{})).To(BeNil()) + }) +}) + +func mustTime(s string) time.Time { + t, err := time.Parse(time.RFC3339, s) + Expect(err).ToNot(HaveOccurred()) + return t +} diff --git a/services/search/pkg/search/service.go b/services/search/pkg/search/service.go index 7bb3a9ce33..482f21ee3d 100644 --- a/services/search/pkg/search/service.go +++ b/services/search/pkg/search/service.go @@ -35,6 +35,7 @@ import ( "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" + "github.com/opencloud-eu/opencloud/services/graph/pkg/unifiedrole" "github.com/opencloud-eu/opencloud/services/search/pkg/config" "github.com/opencloud-eu/opencloud/services/search/pkg/content" "github.com/opencloud-eu/opencloud/services/search/pkg/metrics" @@ -491,6 +492,9 @@ func (s *Service) searchIndex(ctx context.Context, req *searchsvc.SearchRequest, isMountpoint := isShared && match.GetEntity().GetRef().GetPath() == "." isDir := match.GetEntity().GetMimeType() == "httpd/unix-directory" match.Entity.Permissions = convertToWebDAVPermissions(isShared, isMountpoint, isDir, permissions) + // allowedValues is the same effective permission set the WebDAV report's + // oc:permissions string projects, in libregraph action notation. + match.Entity.PermissionsActionsAllowedValues = unifiedrole.CS3ResourcePermissionsToLibregraphActions(permissions) if req.Ref != nil && searchPathPrefix == "/"+match.Entity.Name { continue