diff --git a/services/webdav/pkg/service/v0/filter.go b/services/webdav/pkg/service/v0/filter.go new file mode 100644 index 00000000000..17540de4bfa --- /dev/null +++ b/services/webdav/pkg/service/v0/filter.go @@ -0,0 +1,288 @@ +package svc + +import ( + "context" + "encoding/xml" + "net/http" + "path" + "strconv" + "strings" + "time" + + gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + grpcmetadata "google.golang.org/grpc/metadata" + + "github.com/owncloud/ocis/v2/services/webdav/pkg/constants" + "github.com/owncloud/ocis/v2/services/webdav/pkg/net" + "github.com/owncloud/ocis/v2/services/webdav/pkg/prop" + "github.com/owncloud/ocis/v2/services/webdav/pkg/propfind" + revactx "github.com/owncloud/reva/v2/pkg/ctx" + "github.com/owncloud/reva/v2/pkg/conversions" + "github.com/owncloud/reva/v2/pkg/permission" + "github.com/owncloud/reva/v2/pkg/storagespace" + "github.com/owncloud/reva/v2/pkg/utils" +) + +const propOcFavorite = "http://owncloud.org/ns/favorite" + +// favoriteInfo holds a ResourceInfo and the resolved path for href construction. +type favoriteInfo struct { + info *provider.ResourceInfo + // href-ready path relative to the space root, e.g. "Documents/notes.md" + relativePath string + // hrefPrefix is the DAV prefix for constructing hrefs, e.g. + // "/dav/files/admin" for personal/share spaces or + // "/dav/spaces/$" for project spaces. + hrefPrefix string +} + +// handleFilterFiles handles REPORT requests with oc:filter-files / oc:filter-rules. +func (g Webdav) handleFilterFiles(w http.ResponseWriter, r *http.Request, ff *reportFilterFiles) { + logger := g.log.SubloggerWithRequestID(r.Context()) + + if !ff.Rules.Favorite { + // Only favorites filtering is supported; return empty 207. + g.sendFavoritesResponse(nil, w, r) + return + } + + t := r.Header.Get(revactx.TokenHeader) + ctx := revactx.ContextSetToken(r.Context(), t) + ctx = grpcmetadata.AppendToOutgoingContext(ctx, revactx.TokenHeader, t) + + gwClient, err := g.gatewaySelector.Next() + if err != nil { + logger.Error().Err(err).Msg("error selecting gateway client") + renderError(w, r, errInternalError("could not get gateway client")) + return + } + + // Get current user — needed both for CheckPermission (which reads the + // user from the context) and for href construction later. + whoAmI, err := gwClient.WhoAmI(ctx, &gatewayv1beta1.WhoAmIRequest{Token: t}) + if err != nil { + logger.Error().Err(err).Msg("error getting current user") + renderError(w, r, errInternalError("could not get current user")) + return + } + if whoAmI.Status.Code != rpcv1beta1.Code_CODE_OK { + logger.Error().Str("status", whoAmI.Status.Message).Msg("could not get current user") + renderError(w, r, errInternalError("could not get current user")) + return + } + ctx = revactx.ContextSetUser(ctx, whoAmI.User) + username := whoAmI.User.Username + + // Check permission + ok, err := utils.CheckPermission(ctx, permission.ListFavorites, gwClient) + if err != nil { + logger.Error().Err(err).Msg("error checking list favorites permission") + renderError(w, r, errInternalError("error checking permission")) + return + } + if !ok { + logger.Debug().Msg("user not allowed to list favorites") + renderError(w, r, errPermissionDenied("permission denied")) + return + } + + // List user's storage spaces + spacesResp, err := gwClient.ListStorageSpaces(ctx, &provider.ListStorageSpacesRequest{ + Filters: []*provider.ListStorageSpacesRequest_Filter{ + { + Type: provider.ListStorageSpacesRequest_Filter_TYPE_USER, + Term: &provider.ListStorageSpacesRequest_Filter_Owner{ + Owner: whoAmI.User.Id, + }, + }, + }, + }) + if err != nil { + logger.Error().Err(err).Msg("error listing storage spaces") + renderError(w, r, errInternalError("could not list storage spaces")) + return + } + if spacesResp.Status.Code != rpcv1beta1.Code_CODE_OK { + logger.Error().Str("status", spacesResp.Status.Message).Msg("could not list storage spaces") + renderError(w, r, errInternalError("could not list storage spaces")) + return + } + + // Build the /dav/files/ href prefix. + // The frontend expects all hrefs under this prefix when it sends + // REPORT to /dav/files/. Project spaces are not addressable + // under this path and are skipped for now. + filesPrefix := path.Join("/dav/files", username) + if strings.HasPrefix(r.URL.Path, "/remote.php/") { + filesPrefix = path.Join("/remote.php/dav/files", username) + } + + // Collect favorites across personal and share spaces + var favorites []favoriteInfo + for _, space := range spacesResp.StorageSpaces { + if space.Root == nil { + continue + } + + var pathPrefix string + switch space.SpaceType { + case "personal": + pathPrefix = "" + case "mountpoint", "grant": + // Mounted shares appear under "Shares/" + name := space.Name + if name == "" { + name = space.Id.OpaqueId + } + pathPrefix = path.Join("Shares", name) + default: + // Project spaces and other types don't appear under + // /dav/files// — skip for now. + continue + } + + g.collectFavorites(ctx, gwClient, &provider.Reference{ResourceId: space.Root}, pathPrefix, filesPrefix, &favorites) + } + + g.sendFavoritesResponse(favorites, w, r) +} + +// collectFavorites recursively walks a storage space, collecting resources +// that have the oc:favorite metadata set. +func (g Webdav) collectFavorites( + ctx context.Context, + client gatewayv1beta1.GatewayAPIClient, + ref *provider.Reference, + pathPrefix string, + hrefPrefix string, + results *[]favoriteInfo, +) { + resp, err := client.ListContainer(ctx, &provider.ListContainerRequest{ + Ref: ref, + ArbitraryMetadataKeys: []string{propOcFavorite}, + }) + if err != nil { + g.log.Error().Err(err).Msg("error listing container for favorites") + return + } + if resp.Status.Code != rpcv1beta1.Code_CODE_OK { + // Skip spaces/directories we cannot access + return + } + + for _, info := range resp.Infos { + childPath := path.Join(pathPrefix, info.GetName()) + + // Check if this resource is favorited + if md := info.GetArbitraryMetadata().GetMetadata(); md != nil { + if fav, ok := md[propOcFavorite]; ok && fav != "" && fav != "0" { + *results = append(*results, favoriteInfo{ + info: info, + relativePath: childPath, + hrefPrefix: hrefPrefix, + }) + } + } + + // Recurse into directories + if info.Type == provider.ResourceType_RESOURCE_TYPE_CONTAINER { + g.collectFavorites(ctx, client, &provider.Reference{ResourceId: info.Id}, childPath, hrefPrefix, results) + } + } +} + +// sendFavoritesResponse writes a 207 Multi-Status response for the collected favorites. +func (g Webdav) sendFavoritesResponse(favorites []favoriteInfo, w http.ResponseWriter, r *http.Request) { + logger := g.log.SubloggerWithRequestID(r.Context()) + + responses := make([]*propfind.ResponseXML, 0, len(favorites)) + for i := range favorites { + resp := favoriteInfoToPropResponse(&favorites[i]) + responses = append(responses, resp) + } + + msr := propfind.NewMultiStatusResponseXML() + msr.Responses = responses + + msg, err := xml.Marshal(msr) + if err != nil { + logger.Error().Err(err).Msg("error marshaling favorites response") + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.Header().Set(net.HeaderDav, "1, 3, extended-mkcol") + w.Header().Set(net.HeaderContentType, "application/xml; charset=utf-8") + w.WriteHeader(http.StatusMultiStatus) + if _, err := w.Write(msg); err != nil { + logger.Err(err).Msg("error writing favorites response") + } +} + +// favoriteInfoToPropResponse converts a favoriteInfo into a ResponseXML. +func favoriteInfoToPropResponse(fav *favoriteInfo) *propfind.ResponseXML { + info := fav.info + + response := &propfind.ResponseXML{ + Href: net.EncodePath(path.Join(fav.hrefPrefix, fav.relativePath)), + Propstat: []propfind.PropstatXML{}, + } + + propstatOK := propfind.PropstatXML{ + Status: "HTTP/1.1 200 OK", + Prop: []prop.PropertyXML{}, + } + + // oc:fileid + if info.Id != nil { + propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:fileid", storagespace.FormatResourceID(info.Id))) + } + + // oc:name + propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:name", info.GetName())) + + // d:getlastmodified + if info.Mtime != nil { + t := time.Unix(int64(info.Mtime.Seconds), int64(info.Mtime.Nanos)) + propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("d:getlastmodified", t.UTC().Format(constants.RFC1123))) + } + + // d:getcontenttype + propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("d:getcontenttype", info.GetMimeType())) + + // d:getetag + if info.Etag != "" { + propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("d:getetag", "\""+info.Etag+"\"")) + } + + // oc:permissions + if info.PermissionSet != nil { + role := conversions.RoleFromResourcePermissions(info.PermissionSet, false) + isDir := info.Type == provider.ResourceType_RESOURCE_TYPE_CONTAINER + wdp := role.WebDAVPermissions(isDir, false, false, false) + propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:permissions", wdp)) + } + + // oc:favorite (always "1" since we only return favorites) + propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:favorite", "1")) + + // d:resourcetype + size + size := strconv.FormatUint(info.Size, 10) + if info.Type == provider.ResourceType_RESOURCE_TYPE_CONTAINER { + propstatOK.Prop = append(propstatOK.Prop, prop.Raw("d:resourcetype", "")) + propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:size", size)) + } else { + propstatOK.Prop = append(propstatOK.Prop, + prop.Escaped("d:resourcetype", ""), + prop.Escaped("d:getcontentlength", size), + ) + } + + if len(propstatOK.Prop) > 0 { + response.Propstat = append(response.Propstat, propstatOK) + } + + return response +} diff --git a/services/webdav/pkg/service/v0/filter_test.go b/services/webdav/pkg/service/v0/filter_test.go new file mode 100644 index 00000000000..f46ecf2b5f8 --- /dev/null +++ b/services/webdav/pkg/service/v0/filter_test.go @@ -0,0 +1,460 @@ +package svc + +import ( + "context" + "encoding/xml" + "net/http" + "net/http/httptest" + "strings" + "testing" + + gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + permissions "github.com/cs3org/go-cs3apis/cs3/permissions/v1beta1" + rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" + revactx "github.com/owncloud/reva/v2/pkg/ctx" + "github.com/owncloud/reva/v2/pkg/rgrpc/todo/pool" + cs3mocks "github.com/owncloud/reva/v2/tests/cs3mocks/mocks" + "github.com/stretchr/testify/mock" + "google.golang.org/grpc" + + "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/ocis/v2/services/webdav/pkg/constants" + "github.com/owncloud/ocis/v2/services/webdav/pkg/propfind" +) + +const filterFilesBody = ` + + + + + + + + + + + + + + 1 + +` + +func setupTestWebdav(t *testing.T, gwClient *cs3mocks.GatewayAPIClient) Webdav { + t.Helper() + + pool.RemoveSelector("GatewaySelector" + "com.owncloud.api.gateway") + selector := pool.GetSelector[gateway.GatewayAPIClient]( + "GatewaySelector", + "com.owncloud.api.gateway", + func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient { + return gwClient + }, + ) + + return Webdav{ + log: log.NopLogger(), + gatewaySelector: selector, + } +} + +func newFilterFilesRequest(username string) *http.Request { + r := httptest.NewRequest("REPORT", "/dav/files/"+username, strings.NewReader(filterFilesBody)) + ctx := context.WithValue(r.Context(), constants.ContextKeyID, username) + ctx = revactx.ContextSetToken(ctx, "test-token") + ctx = revactx.ContextSetUser(ctx, &userpb.User{ + Id: &userpb.UserId{OpaqueId: username}, + Username: username, + }) + r.Header.Set(revactx.TokenHeader, "test-token") + return r.WithContext(ctx) +} + +func okStatus() *rpcv1beta1.Status { + return &rpcv1beta1.Status{Code: rpcv1beta1.Code_CODE_OK} +} + +func mockCheckPermission(gwClient *cs3mocks.GatewayAPIClient, allowed bool) { + code := rpcv1beta1.Code_CODE_OK + if !allowed { + code = rpcv1beta1.Code_CODE_PERMISSION_DENIED + } + gwClient.On("CheckPermission", mock.Anything, mock.Anything).Return( + &permissions.CheckPermissionResponse{ + Status: &rpcv1beta1.Status{Code: code}, + }, nil, + ) +} + +func mockWhoAmI(gwClient *cs3mocks.GatewayAPIClient, username string) { + gwClient.On("WhoAmI", mock.Anything, mock.Anything).Return( + &gateway.WhoAmIResponse{ + Status: okStatus(), + User: &userpb.User{ + Id: &userpb.UserId{OpaqueId: username}, + Username: username, + }, + }, nil, + ) +} + +func mockListStorageSpaces(gwClient *cs3mocks.GatewayAPIClient, spaces []*provider.StorageSpace) { + gwClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return( + &provider.ListStorageSpacesResponse{ + Status: okStatus(), + StorageSpaces: spaces, + }, nil, + ) +} + +func personalSpace(spaceID string) *provider.StorageSpace { + return &provider.StorageSpace{ + Id: &provider.StorageSpaceId{OpaqueId: spaceID}, + SpaceType: "personal", + Name: "alice", + Root: &provider.ResourceId{ + StorageId: "storage1", + SpaceId: spaceID, + OpaqueId: spaceID, + }, + } +} + +func TestFilterFilesReturns207WithFavorites(t *testing.T) { + gwClient := cs3mocks.NewGatewayAPIClient(t) + svc := setupTestWebdav(t, gwClient) + + mockCheckPermission(gwClient, true) + mockWhoAmI(gwClient, "alice") + mockListStorageSpaces(gwClient, []*provider.StorageSpace{personalSpace("space1")}) + + // Root listing returns a favorited file and a non-favorited directory + gwClient.On("ListContainer", mock.Anything, mock.MatchedBy(func(req *provider.ListContainerRequest) bool { + return req.Ref.ResourceId.OpaqueId == "space1" + })).Return(&provider.ListContainerResponse{ + Status: okStatus(), + Infos: []*provider.ResourceInfo{ + { + Id: &provider.ResourceId{StorageId: "storage1", SpaceId: "space1", OpaqueId: "file1"}, + Name: "favorite-doc.txt", + Type: provider.ResourceType_RESOURCE_TYPE_FILE, + MimeType: "text/plain", + Size: 1024, + Etag: "abc123", + Mtime: &typesv1beta1.Timestamp{Seconds: 1700000000}, + PermissionSet: &provider.ResourcePermissions{ + GetPath: true, + Stat: true, + InitiateFileDownload: true, + }, + ArbitraryMetadata: &provider.ArbitraryMetadata{ + Metadata: map[string]string{ + "http://owncloud.org/ns/favorite": "1", + }, + }, + }, + { + Id: &provider.ResourceId{StorageId: "storage1", SpaceId: "space1", OpaqueId: "dir1"}, + Name: "not-favorite-dir", + Type: provider.ResourceType_RESOURCE_TYPE_CONTAINER, + Size: 0, + Mtime: &typesv1beta1.Timestamp{Seconds: 1700000000}, + PermissionSet: &provider.ResourcePermissions{ + GetPath: true, + Stat: true, + }, + }, + }, + }, nil) + + // Recursive listing of the non-favorite directory: empty + gwClient.On("ListContainer", mock.Anything, mock.MatchedBy(func(req *provider.ListContainerRequest) bool { + return req.Ref.ResourceId.OpaqueId == "dir1" + })).Return(&provider.ListContainerResponse{ + Status: okStatus(), + Infos: []*provider.ResourceInfo{}, + }, nil) + + r := newFilterFilesRequest("alice") + rep, err := readReport(r.Body) + if err != nil { + t.Fatalf("readReport: %v", err) + } + if rep.FilterFiles == nil { + t.Fatal("expected FilterFiles to be parsed") + } + + // Re-create request since body was consumed + r = newFilterFilesRequest("alice") + rr := httptest.NewRecorder() + svc.handleFilterFiles(rr, r, rep.FilterFiles) + + if rr.Code != http.StatusMultiStatus { + t.Errorf("expected 207, got %d: %s", rr.Code, rr.Body.String()) + } + + // Verify XML response + var ms propfind.MultiStatusResponseUnmarshalXML + if err := xml.Unmarshal(rr.Body.Bytes(), &ms); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if len(ms.Responses) != 1 { + t.Fatalf("expected 1 favorite response, got %d", len(ms.Responses)) + } + + // Check href — personal space favorites use /dav/files// format + expectedHref := "/dav/files/alice/favorite-doc.txt" + if ms.Responses[0].Href != expectedHref { + t.Errorf("expected href %q, got %q", expectedHref, ms.Responses[0].Href) + } +} + +func TestFilterFilesEmptyFavoritesReturns207(t *testing.T) { + gwClient := cs3mocks.NewGatewayAPIClient(t) + svc := setupTestWebdav(t, gwClient) + + mockCheckPermission(gwClient, true) + mockWhoAmI(gwClient, "alice") + mockListStorageSpaces(gwClient, []*provider.StorageSpace{personalSpace("space1")}) + + // Root listing: no files at all + gwClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{ + Status: okStatus(), + Infos: []*provider.ResourceInfo{}, + }, nil) + + r := newFilterFilesRequest("alice") + rep, _ := readReport(r.Body) + + r = newFilterFilesRequest("alice") + rr := httptest.NewRecorder() + svc.handleFilterFiles(rr, r, rep.FilterFiles) + + if rr.Code != http.StatusMultiStatus { + t.Errorf("expected 207, got %d: %s", rr.Code, rr.Body.String()) + } + + var ms propfind.MultiStatusResponseUnmarshalXML + if err := xml.Unmarshal(rr.Body.Bytes(), &ms); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if len(ms.Responses) != 0 { + t.Errorf("expected 0 responses, got %d", len(ms.Responses)) + } +} + +func TestFilterFilesReturnsBothFileAndFolderFavorites(t *testing.T) { + gwClient := cs3mocks.NewGatewayAPIClient(t) + svc := setupTestWebdav(t, gwClient) + + mockCheckPermission(gwClient, true) + mockWhoAmI(gwClient, "alice") + mockListStorageSpaces(gwClient, []*provider.StorageSpace{personalSpace("space1")}) + + favMeta := &provider.ArbitraryMetadata{ + Metadata: map[string]string{propOcFavorite: "1"}, + } + + gwClient.On("ListContainer", mock.Anything, mock.MatchedBy(func(req *provider.ListContainerRequest) bool { + return req.Ref.ResourceId.OpaqueId == "space1" + })).Return(&provider.ListContainerResponse{ + Status: okStatus(), + Infos: []*provider.ResourceInfo{ + { + Id: &provider.ResourceId{StorageId: "s1", SpaceId: "space1", OpaqueId: "file1"}, + Name: "my-file.pdf", + Type: provider.ResourceType_RESOURCE_TYPE_FILE, + Size: 2048, + MimeType: "application/pdf", + Mtime: &typesv1beta1.Timestamp{Seconds: 1700000000}, + ArbitraryMetadata: favMeta, + }, + { + Id: &provider.ResourceId{StorageId: "s1", SpaceId: "space1", OpaqueId: "dir1"}, + Name: "my-folder", + Type: provider.ResourceType_RESOURCE_TYPE_CONTAINER, + Size: 4096, + Mtime: &typesv1beta1.Timestamp{Seconds: 1700000000}, + ArbitraryMetadata: favMeta, + }, + }, + }, nil) + + // Recursive listing of the favorite folder: empty + gwClient.On("ListContainer", mock.Anything, mock.MatchedBy(func(req *provider.ListContainerRequest) bool { + return req.Ref.ResourceId.OpaqueId == "dir1" + })).Return(&provider.ListContainerResponse{ + Status: okStatus(), + Infos: []*provider.ResourceInfo{}, + }, nil) + + r := newFilterFilesRequest("alice") + rep, _ := readReport(r.Body) + r = newFilterFilesRequest("alice") + rr := httptest.NewRecorder() + svc.handleFilterFiles(rr, r, rep.FilterFiles) + + if rr.Code != http.StatusMultiStatus { + t.Fatalf("expected 207, got %d", rr.Code) + } + + var ms propfind.MultiStatusResponseUnmarshalXML + if err := xml.Unmarshal(rr.Body.Bytes(), &ms); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if len(ms.Responses) != 2 { + t.Fatalf("expected 2 favorites (file + folder), got %d", len(ms.Responses)) + } + + // Verify one is a file (no collection) and one is a folder (collection) + var hasFile, hasFolder bool + for _, resp := range ms.Responses { + if strings.Contains(resp.Href, "my-file.pdf") { + hasFile = true + } + if strings.Contains(resp.Href, "my-folder") { + hasFolder = true + } + } + if !hasFile { + t.Error("expected file favorite in response") + } + if !hasFolder { + t.Error("expected folder favorite in response") + } +} + +func TestFilterFilesHrefsUseFilesPrefix(t *testing.T) { + gwClient := cs3mocks.NewGatewayAPIClient(t) + svc := setupTestWebdav(t, gwClient) + + mockCheckPermission(gwClient, true) + mockWhoAmI(gwClient, "bob") + mockListStorageSpaces(gwClient, []*provider.StorageSpace{personalSpace("space1")}) + + gwClient.On("ListContainer", mock.Anything, mock.MatchedBy(func(req *provider.ListContainerRequest) bool { + return req.Ref.ResourceId.OpaqueId == "space1" + })).Return(&provider.ListContainerResponse{ + Status: okStatus(), + Infos: []*provider.ResourceInfo{ + { + Id: &provider.ResourceId{StorageId: "s1", SpaceId: "space1", OpaqueId: "nested"}, + Name: "Documents", + Type: provider.ResourceType_RESOURCE_TYPE_CONTAINER, + Mtime: &typesv1beta1.Timestamp{Seconds: 1700000000}, + }, + }, + }, nil) + + // Nested file that is a favorite + gwClient.On("ListContainer", mock.Anything, mock.MatchedBy(func(req *provider.ListContainerRequest) bool { + return req.Ref.ResourceId.OpaqueId == "nested" + })).Return(&provider.ListContainerResponse{ + Status: okStatus(), + Infos: []*provider.ResourceInfo{ + { + Id: &provider.ResourceId{StorageId: "s1", SpaceId: "space1", OpaqueId: "deepfile"}, + Name: "notes.md", + Type: provider.ResourceType_RESOURCE_TYPE_FILE, + Size: 512, + MimeType: "text/markdown", + Mtime: &typesv1beta1.Timestamp{Seconds: 1700000000}, + ArbitraryMetadata: &provider.ArbitraryMetadata{ + Metadata: map[string]string{propOcFavorite: "1"}, + }, + }, + }, + }, nil) + + r := newFilterFilesRequest("bob") + rep, _ := readReport(r.Body) + r = newFilterFilesRequest("bob") + rr := httptest.NewRecorder() + svc.handleFilterFiles(rr, r, rep.FilterFiles) + + if rr.Code != http.StatusMultiStatus { + t.Fatalf("expected 207, got %d", rr.Code) + } + + var ms propfind.MultiStatusResponseUnmarshalXML + if err := xml.Unmarshal(rr.Body.Bytes(), &ms); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if len(ms.Responses) != 1 { + t.Fatalf("expected 1 response, got %d", len(ms.Responses)) + } + + // Personal space favorites use /dav/files// format + expectedHref := "/dav/files/bob/Documents/notes.md" + if ms.Responses[0].Href != expectedHref { + t.Errorf("expected href %q, got %q", expectedHref, ms.Responses[0].Href) + } +} + +func TestFilterFilesSkipsProjectSpaces(t *testing.T) { + gwClient := cs3mocks.NewGatewayAPIClient(t) + svc := setupTestWebdav(t, gwClient) + + mockCheckPermission(gwClient, true) + mockWhoAmI(gwClient, "alice") + + projectSpace := &provider.StorageSpace{ + Id: &provider.StorageSpaceId{OpaqueId: "proj-space"}, + SpaceType: "project", + Name: "Engineering", + Root: &provider.ResourceId{ + StorageId: "storage1", + SpaceId: "proj-space", + OpaqueId: "proj-space", + }, + } + mockListStorageSpaces(gwClient, []*provider.StorageSpace{projectSpace}) + + // No ListContainer mock needed — project spaces should be skipped entirely + + r := newFilterFilesRequest("alice") + rep, _ := readReport(r.Body) + r = newFilterFilesRequest("alice") + rr := httptest.NewRecorder() + svc.handleFilterFiles(rr, r, rep.FilterFiles) + + if rr.Code != http.StatusMultiStatus { + t.Fatalf("expected 207, got %d: %s", rr.Code, rr.Body.String()) + } + + var ms propfind.MultiStatusResponseUnmarshalXML + if err := xml.Unmarshal(rr.Body.Bytes(), &ms); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + // Project spaces are not addressable under /dav/files//, + // so they should be skipped — no favorites returned. + if len(ms.Responses) != 0 { + t.Fatalf("expected 0 responses (project spaces skipped), got %d", len(ms.Responses)) + } +} + +func TestFilterFilesPermissionDenied(t *testing.T) { + gwClient := cs3mocks.NewGatewayAPIClient(t) + svc := setupTestWebdav(t, gwClient) + + mockWhoAmI(gwClient, "alice") + mockCheckPermission(gwClient, false) + + r := newFilterFilesRequest("alice") + rep, _ := readReport(r.Body) + r = newFilterFilesRequest("alice") + rr := httptest.NewRecorder() + svc.handleFilterFiles(rr, r, rep.FilterFiles) + + if rr.Code != http.StatusForbidden { + t.Errorf("expected 403, got %d: %s", rr.Code, rr.Body.String()) + } +} diff --git a/services/webdav/pkg/service/v0/search.go b/services/webdav/pkg/service/v0/search.go index 8eb32a73897..4ca502a848f 100644 --- a/services/webdav/pkg/service/v0/search.go +++ b/services/webdav/pkg/service/v0/search.go @@ -27,7 +27,7 @@ import ( const ( elementNameSearchFiles = "search-files" - // TODO elementNameFilterFiles = "filter-files" + elementNameFilterFiles = "filter-files" ) // Search is the endpoint for retrieving search results for REPORT requests @@ -40,35 +40,37 @@ func (g Webdav) Search(w http.ResponseWriter, r *http.Request) { return } - if rep.SearchFiles == nil { - renderError(w, r, errBadRequest("missing search-files tag")) - logger.Debug().Err(err).Msg("error reading report") - return - } - - t := r.Header.Get(revactx.TokenHeader) - ctx := revactx.ContextSetToken(r.Context(), t) - ctx = metadata.Set(ctx, revactx.TokenHeader, t) + switch { + case rep.SearchFiles != nil: + t := r.Header.Get(revactx.TokenHeader) + ctx := revactx.ContextSetToken(r.Context(), t) + ctx = metadata.Set(ctx, revactx.TokenHeader, t) - req := &searchsvc.SearchRequest{ - Query: rep.SearchFiles.Search.Pattern, - PageSize: int32(rep.SearchFiles.Search.Limit), - } + req := &searchsvc.SearchRequest{ + Query: rep.SearchFiles.Search.Pattern, + PageSize: int32(rep.SearchFiles.Search.Limit), + } - rsp, err := g.searchClient.Search(ctx, req) - if err != nil { - e := merrors.Parse(err.Error()) - switch e.Code { - case http.StatusBadRequest: - renderError(w, r, errBadRequest(e.Detail)) - default: - renderError(w, r, errInternalError(err.Error())) + rsp, err := g.searchClient.Search(ctx, req) + if err != nil { + e := merrors.Parse(err.Error()) + switch e.Code { + case http.StatusBadRequest: + renderError(w, r, errBadRequest(e.Detail)) + default: + renderError(w, r, errInternalError(err.Error())) + } + logger.Error().Err(err).Msg("could not get search results") + return } - logger.Error().Err(err).Msg("could not get search results") - return - } - g.sendSearchResponse(rsp, w, r) + g.sendSearchResponse(rsp, w, r) + case rep.FilterFiles != nil: + g.handleFilterFiles(w, r, rep.FilterFiles) + default: + renderError(w, r, errBadRequest("missing search-files or filter-files element")) + logger.Debug().Msg("error reading report: no recognized element") + } } func (g Webdav) sendSearchResponse(rsp *searchsvc.SearchResponse, w http.ResponseWriter, r *http.Request) { @@ -354,15 +356,13 @@ func readReport(r io.Reader) (rep *report, err error) { return nil, err } rep.SearchFiles = &repSF - /* - } else if v.Name.Local == elementNameFilterFiles { - var repFF reportFilterFiles - err = decoder.DecodeElement(&repFF, &v) - if err != nil { - return nil, http.StatusBadRequest, err - } - rep.FilterFiles = &repFF - */ + } else if v.Name.Local == elementNameFilterFiles { + var repFF reportFilterFiles + err = decoder.DecodeElement(&repFF, &v) + if err != nil { + return nil, err + } + rep.FilterFiles = &repFF } } }