From 0e8f8831e488f529c2e7652aadeb3f31a963523a Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Sat, 3 Oct 2026 12:32:14 +0200 Subject: [PATCH] feat(webdav): carry the facets in the search REPORT hits The search service returns audio, location, image, photo, video, motion photo and live photo with every hit, the REPORT response dropped them and clients had to PROPFIND each hit. They are now rendered in the xml shape PROPFIND uses, so a client reads both the same way. --- go.mod | 2 +- services/webdav/pkg/service/v0/search.go | 52 +++++++++++++++++++ services/webdav/pkg/service/v0/search_test.go | 25 +++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 271b78b785..271c64bbf2 100644 --- a/go.mod +++ b/go.mod @@ -43,6 +43,7 @@ require ( github.com/gookit/config/v2 v2.2.9 github.com/gorilla/mux v1.8.1 github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 + github.com/iancoleman/strcase v0.3.0 github.com/invopop/validation v0.8.0 github.com/jellydator/ttlcache/v2 v2.11.1 github.com/jellydator/ttlcache/v3 v3.4.1 @@ -245,7 +246,6 @@ require ( github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/yamux v0.1.2 // indirect github.com/huandu/xstrings v1.5.0 // indirect - github.com/iancoleman/strcase v0.3.0 // indirect github.com/imdario/mergo v0.3.16 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect diff --git a/services/webdav/pkg/service/v0/search.go b/services/webdav/pkg/service/v0/search.go index e2f6a3f4a4..c4b062b081 100644 --- a/services/webdav/pkg/service/v0/search.go +++ b/services/webdav/pkg/service/v0/search.go @@ -12,11 +12,16 @@ import ( "slices" "strconv" "strings" + "time" userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/iancoleman/strcase" merrors "go-micro.dev/v4/errors" "go-micro.dev/v4/metadata" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/known/timestamppb" revactx "github.com/opencloud-eu/reva/v2/pkg/ctx" "github.com/opencloud-eu/reva/v2/pkg/storagespace" @@ -262,6 +267,14 @@ func matchToPropResponse(ctx context.Context, davPrefix, publicURL string, match propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:favorite", "1")) } + propstatOK.Prop = appendFacet(propstatOK.Prop, "audio", match.Entity.Audio) + propstatOK.Prop = appendFacet(propstatOK.Prop, "location", match.Entity.Location) + propstatOK.Prop = appendFacet(propstatOK.Prop, "image", match.Entity.Image) + propstatOK.Prop = appendFacet(propstatOK.Prop, "photo", match.Entity.Photo) + propstatOK.Prop = appendFacet(propstatOK.Prop, "video", match.Entity.Video) + propstatOK.Prop = appendFacet(propstatOK.Prop, "motion-photo", match.Entity.MotionPhoto) + propstatOK.Prop = appendFacet(propstatOK.Prop, "live-photo", match.Entity.LivePhoto) + if len(propstatOK.Prop) > 0 { response.Propstat = append(response.Propstat, propstatOK) } @@ -269,6 +282,45 @@ func matchToPropResponse(ctx context.Context, davPrefix, publicURL string, match return &response, nil } +// appendFacet renders a facet in the xml shape PROPFIND uses for it. +func appendFacet(props []prop.PropertyXML, name string, facet proto.Message) []prop.PropertyXML { + m := facet.ProtoReflect() + if !m.IsValid() { + return props + } + + content := strings.Builder{} + fields := m.Descriptor().Fields() + for i := 0; i < fields.Len(); i++ { + fd := fields.Get(i) + if !m.Has(fd) { + continue + } + key := "oc:" + strcase.ToKebab(string(fd.Name())) + content.WriteString("<" + key + ">") + content.WriteString(prop.Escape(facetValue(fd, m.Get(fd)))) + content.WriteString("") + } + if content.Len() == 0 { + return props + } + return append(props, prop.Raw("oc:"+name, content.String())) +} + +func facetValue(fd protoreflect.FieldDescriptor, v protoreflect.Value) string { + switch fd.Kind() { + case protoreflect.FloatKind: + return strconv.FormatFloat(v.Float(), 'f', -1, 32) + case protoreflect.DoubleKind: + return strconv.FormatFloat(v.Float(), 'f', -1, 64) + case protoreflect.MessageKind: + if ts, ok := v.Message().Interface().(*timestamppb.Timestamp); ok { + return ts.AsTime().Format(time.RFC3339) + } + } + return v.String() +} + func hasPreview(md *provider.ResourceInfo, appendToOK func(p ...prop.PropertyXML)) { _, match := thumbnail.SupportedMimeTypes[md.MimeType] if match { diff --git a/services/webdav/pkg/service/v0/search_test.go b/services/webdav/pkg/service/v0/search_test.go index 5bfa2782d8..facfeeffb3 100644 --- a/services/webdav/pkg/service/v0/search_test.go +++ b/services/webdav/pkg/service/v0/search_test.go @@ -1,10 +1,17 @@ package svc import ( + "context" "testing" + "time" + userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" + + searchmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0" ) func TestSearch(t *testing.T) { @@ -33,3 +40,21 @@ var _ = Describe("SpacesSearchRegex", func() { Entry("unrelated path", "/dav/files/123", "", false), ) }) + +var _ = Describe("multistatusResponse", func() { + It("carries the facets of a hit in the PROPFIND shape", func() { + id := &searchmsg.ResourceID{StorageId: "storage", SpaceId: "space", OpaqueId: "file"} + res, err := multistatusResponse(context.Background(), "", "", []*searchmsg.Match{{Entity: &searchmsg.Entity{ + Ref: &searchmsg.Reference{ResourceId: id}, + Id: id, + Audio: &searchmsg.Audio{}, + Photo: &searchmsg.Photo{FNumber: proto.Float32(1.8), TakenDateTime: timestamppb.New(time.Date(2018, 1, 1, 12, 34, 56, 0, time.UTC))}, + LivePhoto: &searchmsg.LivePhoto{ContentId: proto.String("a&b"), StillImageTimeUs: proto.Int64(1233333)}, + }}}, &userv1beta1.User{Id: &userv1beta1.UserId{}}) + Expect(err).ToNot(HaveOccurred()) + + Expect(string(res)).To(ContainSubstring("1.82018-01-01T12:34:56Z")) + Expect(string(res)).To(ContainSubstring("a&b1233333")) + Expect(string(res)).ToNot(ContainSubstring("oc:audio")) + }) +})