diff --git a/services/graph/pkg/service/v0/driveitems.go b/services/graph/pkg/service/v0/driveitems.go index 167f8b1b62..5b426d0694 100644 --- a/services/graph/pkg/service/v0/driveitems.go +++ b/services/graph/pkg/service/v0/driveitems.go @@ -355,6 +355,15 @@ func (g Graph) GetDriveItem(w http.ResponseWriter, r *http.Request) { } render.Status(r, http.StatusOK) + if driveItemRelationExpanded(r, _expandOpenExtensions) { + expanded, err := driveItemWithOpenExtensions(driveItem, res.GetInfo()) + if err != nil { + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + return + } + render.JSON(w, r, expanded) + return + } render.JSON(w, r, &driveItem) } diff --git a/services/graph/pkg/service/v0/openextension_codec_test.go b/services/graph/pkg/service/v0/openextension_codec_test.go new file mode 100644 index 0000000000..3e36c960d4 --- /dev/null +++ b/services/graph/pkg/service/v0/openextension_codec_test.go @@ -0,0 +1,314 @@ +package svc_test + +import ( + "encoding/json" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/opencloud-eu/reva/v2/pkg/openextension" +) + +// The codec lives in reva (WebDAV and graph share it); its behavior is pinned +// here because the graph endpoints and the search index build on it. +var _ = Describe("open extension codec", func() { + const project = "com.example.project" + key := func(name, property string) string { + return "http://opencloud.eu/ns/extensions/" + name + "/" + property + } + + parse := func(body string) openextension.Patch { + GinkgoHelper() + p, err := openextension.Parse([]byte(body)) + Expect(err).NotTo(HaveOccurred()) + return p + } + + rejects := func(body, reason string) { + GinkgoHelper() + _, err := openextension.Parse([]byte(body)) + Expect(err).To(MatchError(openextension.ErrInvalid)) + Expect(err.Error()).To(ContainSubstring(reason)) + } + + Describe("Parse", func() { + It("infers the kind from the JSON type", func() { + p := parse(`{"extensionName":"com.example.project","status":"reviewed","priority":3,"done":false,"tags":["a","b"]}`) + Expect(p.Set).To(HaveLen(4)) + Expect(p.Set["status"].Kind).To(Equal(openextension.KindString)) + Expect(p.Set["priority"].Kind).To(Equal(openextension.KindNumber)) + Expect(p.Set["priority"].IsInteger()).To(BeTrue()) + Expect(p.Set["done"].Kind).To(Equal(openextension.KindBool)) + Expect(p.Set["tags"].Kind).To(Equal(openextension.KindString)) + Expect(p.Set["tags"].Array).To(BeTrue()) + Expect(p.Set["tags"].Strings()).To(Equal([]string{"a", "b"})) + }) + + It("keeps the JSON as written", func() { + p := parse(`{"n":"34","m":34,"f":1.50}`) + Expect(string(p.Set["n"].Raw)).To(Equal(`"34"`)) + Expect(p.Set["n"].Kind).To(Equal(openextension.KindString)) + Expect(string(p.Set["m"].Raw)).To(Equal(`34`)) + Expect(string(p.Set["f"].Raw)).To(Equal(`1.50`)) + Expect(p.Set["f"].IsInteger()).To(BeFalse()) + }) + + It("takes dates and geo points from their annotations", func() { + p := parse(`{"due":"2026-10-01T00:00:00Z","due@odata.type":"#DateTimeOffset",` + + `"site":{"latitude":52.5,"longitude":13.4},"site@odata.type":"#microsoft.graph.geoCoordinates",` + + `"dates":["2026-10-01T00:00:00Z"],"dates@odata.type":"#Collection(DateTimeOffset)"}`) + Expect(p.Set["due"].Kind).To(Equal(openextension.KindDate)) + Expect(p.Set["due"].Times()).To(HaveLen(1)) + Expect(p.Set["site"].Kind).To(Equal(openextension.KindGeo)) + geo, ok := p.Set["site"].Geo() + Expect(ok).To(BeTrue()) + Expect(geo.Latitude).To(Equal(52.5)) + Expect(geo.Longitude).To(Equal(13.4)) + Expect(geo.Altitude).To(BeNil()) + Expect(p.Set["dates"].Kind).To(Equal(openextension.KindDate)) + Expect(p.Set["dates"].Array).To(BeTrue()) + }) + + It("treats a date-time string without annotation as a string", func() { + p := parse(`{"due":"2026-10-01T00:00:00Z"}`) + Expect(p.Set["due"].Kind).To(Equal(openextension.KindString)) + }) + + It("removes properties sent as null", func() { + p := parse(`{"status":"approved","priority":null}`) + Expect(p.Set).To(HaveKey("status")) + Expect(p.Remove).To(Equal([]string{"priority"})) + }) + + It("accepts the optional inferable annotations", func() { + p := parse(`{"a":"x","a@odata.type":"#String","b":3,"b@odata.type":"#Int64","c":2.5,"c@odata.type":"#Double","d":true,"d@odata.type":"#Boolean","e":[1,2],"e@odata.type":"#Collection(Int64)"}`) + Expect(p.Set).To(HaveLen(5)) + }) + + It("rejects a value that does not match its annotation", func() { + rejects(`{"due":"next week","due@odata.type":"#DateTimeOffset"}`, "not an RFC 3339 date-time") + rejects(`{"n":"34","n@odata.type":"#Int64"}`, "is a string but annotated as #Int64") + rejects(`{"n":1.5,"n@odata.type":"#Int64"}`, "is not an integer") + rejects(`{"n":1,"n@odata.type":"#Boolean"}`, "annotated as #Boolean") + rejects(`{"n":1,"n@odata.type":"#Whatever"}`, "annotated as #Whatever") + }) + + It("rejects objects unless they are annotated geo coordinates", func() { + rejects(`{"assignee":{"name":"alice"}}`, "objects are only allowed as geoCoordinates") + rejects(`{"site":{"latitude":52.5},"site@odata.type":"#microsoft.graph.geoCoordinates"}`, "geoCoordinates need numeric latitude and longitude") + rejects(`{"site":{"latitude":91,"longitude":0},"site@odata.type":"#microsoft.graph.geoCoordinates"}`, "geoCoordinates need numeric latitude and longitude") + rejects(`{"site":{"latitude":1,"longitude":2,"name":"x"},"site@odata.type":"#microsoft.graph.geoCoordinates"}`, "geoCoordinates need numeric latitude and longitude") + }) + + It("rejects nested and mixed arrays", func() { + rejects(`{"a":[[1]]}`, "arrays hold scalars only") + rejects(`{"a":[1,"x"]}`, "array elements must all be number") + rejects(`{"a":[{"latitude":1,"longitude":2}],"a@odata.type":"#Collection(microsoft.graph.geoCoordinates)"}`, "arrays hold scalars only") + }) + + It("rejects bad property names, dangling annotations and oversized values", func() { + rejects(`{"a.b":1}`, "must be a simple identifier") + rejects(`{"1a":1}`, "must be a simple identifier") + rejects(`{"a@odata.type":"#String"}`, "annotates a property that is not in the body") + rejects(`[1]`, "body must be a JSON object") + rejects(`{"a":"`+strings.Repeat("a", openextension.MaxValueSize)+`"}`, "at most") + }) + }) + + Describe("stored form", func() { + It("prefixes every value with the code of its kind", func() { + p := parse(`{"s":"open","n":3,"f":1.50,"b":true,"t":"2026-10-01T00:00:00Z","t@odata.type":"#DateTimeOffset",` + + `"g":{"latitude":52.5,"longitude":13.4,"altitude":34},"g@odata.type":"#microsoft.graph.geoCoordinates",` + + `"l":["a","b"],"m":[1,2.5],"c":[true],"d":["2026-10-01T00:00:00Z"],"d@odata.type":"#Collection(DateTimeOffset)"}`) + encoded := map[string]string{} + for k, v := range p.Set { + encoded[k] = openextension.EncodeValue(v) + } + Expect(encoded).To(Equal(map[string]string{ + "s": "s:open", + "n": "n:3", + "f": "n:1.50", + "b": "b:true", + "t": "d:2026-10-01T00:00:00Z", + "g": "g:52.5,13.4,34", + "l": `S:["a","b"]`, + "m": `N:[1,2.5]`, + "c": `B:[true]`, + "d": `D:["2026-10-01T00:00:00Z"]`, + })) + }) + + It("round-trips every kind", func() { + p := parse(`{"s":"a:b, c","n":3,"f":1.50,"b":false,"t":"2026-10-01T00:00:00Z","t@odata.type":"#DateTimeOffset",` + + `"g":{"latitude":52.5,"longitude":13.4},"g@odata.type":"#microsoft.graph.geoCoordinates",` + + `"l":["a","b"],"m":[1,2.5],"d":["2026-10-01T00:00:00Z"],"d@odata.type":"#Collection(DateTimeOffset)"}`) + for k, v := range p.Set { + back, err := openextension.DecodeValue(openextension.EncodeValue(v)) + Expect(err).NotTo(HaveOccurred(), k) + Expect(back).To(Equal(v), k) + } + }) + + It("rejects a value without a known code or with a payload that does not fit it", func() { + for _, raw := range []string{"open", "", "x:1", "34", "http://example.org", "s", "n:abc", "n:[1]", "b:maybe", "d:tomorrow", "g:1", "g:91,0", "S:1", "N:[1,\"x\"]", "D:[\"x\"]"} { + _, err := openextension.DecodeValue(raw) + Expect(err).To(MatchError(openextension.ErrInvalid), raw) + } + }) + + It("turns a patch into metadata keys", func() { + set, unset := parse(`{"status":"open","priority":3,"old":null,"due":"2026-10-01T00:00:00Z","due@odata.type":"#DateTimeOffset"}`).Metadata(project) + Expect(set).To(Equal(map[string]string{ + key(project, "status"): "s:open", + key(project, "priority"): "n:3", + key(project, "due"): "d:2026-10-01T00:00:00Z", + })) + Expect(unset).To(Equal([]string{key(project, "old")})) + }) + + It("applies removals together with sets", func() { + ext := openextension.OpenExtension{Name: "x.y"} + ext.Apply(parse(`{"a":1,"b":2}`)) + ext.Apply(parse(`{"a":null,"c":3}`)) + Expect(ext.Keys()).To(Equal([]string{"b", "c"})) + }) + }) + + Describe("MarshalJSON", func() { + It("annotates only what JSON cannot express", func() { + ext := openextension.OpenExtension{Name: "com.example.project"} + ext.Apply(parse(`{"status":"open","priority":3,"due":"2026-10-01T00:00:00Z","due@odata.type":"#DateTimeOffset","tags":["a"]}`)) + out, err := json.Marshal(ext) + Expect(err).NotTo(HaveOccurred()) + Expect(string(out)).To(Equal(`{"extensionName":"com.example.project","due":"2026-10-01T00:00:00Z","due@odata.type":"#DateTimeOffset","priority":3,"status":"open","tags":["a"]}`)) + }) + + It("reports the OData type of every kind", func() { + p := parse(`{"s":"x","i":3,"d":2.5,"b":true,"l":["a"],"n":[1],"t":"2026-10-01T00:00:00Z","t@odata.type":"#DateTimeOffset","g":{"latitude":1,"longitude":2},"g@odata.type":"#microsoft.graph.geoCoordinates"}`) + Expect(p.Set["s"].ODataType()).To(Equal("#String")) + Expect(p.Set["i"].ODataType()).To(Equal("#Int64")) + Expect(p.Set["d"].ODataType()).To(Equal("#Double")) + Expect(p.Set["b"].ODataType()).To(Equal("#Boolean")) + Expect(p.Set["l"].ODataType()).To(Equal("#Collection(String)")) + Expect(p.Set["n"].ODataType()).To(Equal("#Collection(Double)")) + Expect(p.Set["t"].ODataType()).To(Equal("#DateTimeOffset")) + Expect(p.Set["g"].ODataType()).To(Equal("#microsoft.graph.geoCoordinates")) + }) + }) + + Describe("names and keys", func() { + It("requires reverse DNS extension names", func() { + Expect(openextension.ValidateName("com.example.project")).To(Succeed()) + Expect(openextension.ValidateName("eu.opencloud.work-flow_2")).To(Succeed()) + Expect(openextension.ValidateName("project")).To(MatchError(openextension.ErrInvalid)) + Expect(openextension.ValidateName("com..example")).To(MatchError(openextension.ErrInvalid)) + Expect(openextension.ValidateName("com/example")).To(MatchError(openextension.ErrInvalid)) + }) + + It("maps names and properties to namespaces and metadata keys", func() { + Expect(openextension.Namespace(project)).To(Equal("http://opencloud.eu/ns/extensions/com.example.project")) + Expect(openextension.Key(project, "status")).To(Equal(key(project, "status"))) + + name, ok := openextension.NameFromNamespace("http://opencloud.eu/ns/extensions/com.example.project") + Expect(ok).To(BeTrue()) + Expect(name).To(Equal(project)) + for _, ns := range []string{"http://opencloud.eu/ns/extensions/", "http://opencloud.eu/ns/extensions/a/b", "http://owncloud.org/ns", "DAV:"} { + _, ok := openextension.NameFromNamespace(ns) + Expect(ok).To(BeFalse(), ns) + } + + name, property, ok := openextension.SplitKey(key(project, "status")) + Expect(ok).To(BeTrue()) + Expect(name).To(Equal(project)) + Expect(property).To(Equal("status")) + for _, k := range []string{"tags", "http://owncloud.org/ns/favorite", "http://opencloud.eu/ns/extensions/com.example.project", "http://opencloud.eu/ns/extensions/com.example.project/", "http://opencloud.eu/ns/extensions//status"} { + _, _, ok := openextension.SplitKey(k) + Expect(ok).To(BeFalse(), k) + } + }) + + It("collects the extensions out of arbitrary metadata", func() { + metadata := map[string]string{ + "tags": "a,b", + key("com.example.b", "x"): "n:1", + key("com.example.a", "y"): "s:z", + key("com.example.a", "plain"): "written without a type code", + key("com.example.a", "bad"): "n:abc", + } + exts := openextension.FromMetadata(metadata) + Expect(exts).To(HaveLen(2)) + Expect(exts[0].Name).To(Equal("com.example.a")) + Expect(exts[0].Keys()).To(Equal([]string{"y"}), "unreadable values are skipped") + Expect(exts[1].Name).To(Equal("com.example.b")) + + ext, found := openextension.Lookup(metadata, "com.example.b") + Expect(found).To(BeTrue()) + Expect(ext.Values["x"].Numbers()).To(Equal([]float64{1})) + _, found = openextension.Lookup(metadata, "com.example") + Expect(found).To(BeFalse(), "a name that is a prefix of another does not match") + + Expect(openextension.MetadataKeys(metadata, "com.example.a")).To(Equal([]string{key("com.example.a", "bad"), key("com.example.a", "plain"), key("com.example.a", "y")})) + }) + }) + + Describe("WebDAV", func() { + It("renders every kind with its xsi:type", func() { + p := parse(`{"s":"a52.513.434"})) + Expect(render("l")).To(Equal([2]string{"oc:list", "xy"})) + Expect(render("n")).To(Equal([2]string{"oc:list", `12`})) + }) + + It("parses PROPPATCH values, a property without xsi:type is a string", func() { + from := func(inner, typ string) openextension.Value { + GinkgoHelper() + v, err := openextension.FromDAV("p", []byte(inner), typ) + Expect(err).NotTo(HaveOccurred()) + return v + } + Expect(from("34", "")).To(Equal(openextension.Value{Kind: openextension.KindString, Raw: json.RawMessage(`"34"`)})) + Expect(from("a & b", "")).To(Equal(openextension.Value{Kind: openextension.KindString, Raw: json.RawMessage(`"a & b"`)})) + Expect(from("34", "xs:integer")).To(Equal(openextension.Value{Kind: openextension.KindNumber, Raw: json.RawMessage(`34`)})) + Expect(from(" 2.5 ", "xsd:decimal")).To(Equal(openextension.Value{Kind: openextension.KindNumber, Raw: json.RawMessage(`2.5`)})) + Expect(from("1", "xs:boolean")).To(Equal(openextension.Value{Kind: openextension.KindBool, Raw: json.RawMessage(`true`)})) + Expect(from("2026-10-01T00:00:00Z", "xs:dateTime")).To(Equal(openextension.Value{Kind: openextension.KindDate, Raw: json.RawMessage(`"2026-10-01T00:00:00Z"`)})) + Expect(from("52.513.4", "oc:geoCoordinates")). + To(Equal(openextension.Value{Kind: openextension.KindGeo, Raw: json.RawMessage(`{"latitude":52.5,"longitude":13.4}`)})) + Expect(from(`12`, "oc:list")). + To(Equal(openextension.Value{Kind: openextension.KindNumber, Array: true, Raw: json.RawMessage(`[1,2]`)})) + Expect(from(`ab`, "oc:list")). + To(Equal(openextension.Value{Kind: openextension.KindString, Array: true, Raw: json.RawMessage(`["a","b"]`)})) + }) + + It("rejects PROPPATCH values that do not fit their type", func() { + for _, c := range [][2]string{{"abc", "xs:integer"}, {"maybe", "xs:boolean"}, {"tomorrow", "xs:dateTime"}, {"x", "oc:geoCoordinates"}, {"12", "oc:list"}} { + _, err := openextension.FromDAV("p", []byte(c[0]), c[1]) + Expect(err).To(MatchError(openextension.ErrInvalid), "%s as %s", c[0], c[1]) + } + }) + + It("round-trips a value through WebDAV and the stored form", func() { + p := parse(`{"n":[1,2.5],"g":{"latitude":52.5,"longitude":13.4},"g@odata.type":"#microsoft.graph.geoCoordinates","t":"2026-10-01T00:00:00Z","t@odata.type":"#DateTimeOffset"}`) + for key, v := range p.Set { + d := openextension.ToDAV(v) + back, err := openextension.FromDAV(key, d.InnerXML, d.Type) + Expect(err).NotTo(HaveOccurred()) + Expect(back).To(Equal(v)) + stored, err := openextension.DecodeValue(openextension.EncodeValue(back)) + Expect(err).NotTo(HaveOccurred()) + Expect(stored).To(Equal(v)) + } + }) + }) +}) diff --git a/services/graph/pkg/service/v0/openextensions.go b/services/graph/pkg/service/v0/openextensions.go new file mode 100644 index 0000000000..6f1795e268 --- /dev/null +++ b/services/graph/pkg/service/v0/openextensions.go @@ -0,0 +1,274 @@ +package svc + +import ( + "fmt" + "io" + "net/http" + "net/url" + + rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + libregraph "github.com/opencloud-eu/libre-graph-api-go" + + "github.com/opencloud-eu/reva/v2/pkg/openextension" + + "github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode" +) + +const _expandOpenExtensions = "extensions" + +const maxOpenExtensionBody = 2 * openextension.MaxProperties * openextension.MaxValueSize + +type openExtensionCollection struct { + Value []openextension.OpenExtension `json:"value"` +} + +// ListOpenExtensions lists the open extensions of a driveItem. +func (g Graph) ListOpenExtensions(w http.ResponseWriter, r *http.Request) { + info, ok := g.statOpenExtensionItem(w, r) + if !ok { + return + } + exts := openextension.FromMetadata(info.GetArbitraryMetadata().GetMetadata()) + if exts == nil { + exts = []openextension.OpenExtension{} + } + render.Status(r, http.StatusOK) + render.JSON(w, r, openExtensionCollection{Value: exts}) +} + +// GetOpenExtension returns one open extension of a driveItem. +func (g Graph) GetOpenExtension(w http.ResponseWriter, r *http.Request) { + name, ok := openExtensionNameParam(w, r) + if !ok { + return + } + info, ok := g.statOpenExtensionItem(w, r) + if !ok { + return + } + ext, found := openextension.Lookup(info.GetArbitraryMetadata().GetMetadata(), name) + if !found { + errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "extension not found") + return + } + render.Status(r, http.StatusOK) + render.JSON(w, r, ext) +} + +// UpsertOpenExtension creates an open extension or merges into an existing one: +// members set, null removes, omitted stay. +func (g Graph) UpsertOpenExtension(w http.ResponseWriter, r *http.Request) { + name, ok := openExtensionNameParam(w, r) + if !ok { + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, maxOpenExtensionBody+1)) + if err != nil { + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "could not read the request body") + return + } + if len(body) > maxOpenExtensionBody { + errorcode.InvalidRequest.Render(w, r, http.StatusRequestEntityTooLarge, "the request body is too large") + return + } + patch, err := openextension.Parse(body) + if err != nil { + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error()) + return + } + + info, ok := g.statOpenExtensionItem(w, r) + if !ok { + return + } + if !canWriteOpenExtensions(info) { + errorcode.AccessDenied.Render(w, r, http.StatusForbidden, "no permission to write extensions") + return + } + + metadata := info.GetArbitraryMetadata().GetMetadata() + ext, existed := openextension.Lookup(metadata, name) + ext.Apply(patch) + if len(ext.Values) > openextension.MaxProperties { + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, + fmt.Sprintf("extension %s would have %d properties, at most %d are allowed", name, len(ext.Values), openextension.MaxProperties)) + return + } + set, unset := patch.Metadata(name) + unset = storedOnly(unset, metadata) + + client, err := g.gatewaySelector.Next() + if err != nil { + g.logger.Error().Err(err).Msg("error selecting next gateway client") + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + return + } + ref := &provider.Reference{ResourceId: info.GetId()} + if len(set) > 0 { + res, err := client.SetArbitraryMetadata(r.Context(), &provider.SetArbitraryMetadataRequest{ + Ref: ref, + ArbitraryMetadata: &provider.ArbitraryMetadata{Metadata: set}, + }) + if !g.renderMetadataStatus(w, r, res.GetStatus(), err) { + return + } + } + if len(unset) > 0 { + res, err := client.UnsetArbitraryMetadata(r.Context(), &provider.UnsetArbitraryMetadataRequest{Ref: ref, ArbitraryMetadataKeys: unset}) + if !g.renderMetadataStatus(w, r, res.GetStatus(), err) { + return + } + } + + if len(ext.Values) == 0 { + w.WriteHeader(http.StatusNoContent) + return + } + status := http.StatusOK + if !existed { + status = http.StatusCreated + } + render.Status(r, status) + render.JSON(w, r, ext) +} + +// DeleteOpenExtension removes an open extension from a driveItem. +func (g Graph) DeleteOpenExtension(w http.ResponseWriter, r *http.Request) { + name, ok := openExtensionNameParam(w, r) + if !ok { + return + } + info, ok := g.statOpenExtensionItem(w, r) + if !ok { + return + } + if !canWriteOpenExtensions(info) { + errorcode.AccessDenied.Render(w, r, http.StatusForbidden, "no permission to write extensions") + return + } + keys := openextension.MetadataKeys(info.GetArbitraryMetadata().GetMetadata(), name) + if len(keys) == 0 { + errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "extension not found") + return + } + + client, err := g.gatewaySelector.Next() + if err != nil { + g.logger.Error().Err(err).Msg("error selecting next gateway client") + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + return + } + res, err := client.UnsetArbitraryMetadata(r.Context(), &provider.UnsetArbitraryMetadataRequest{ + Ref: &provider.Reference{ResourceId: info.GetId()}, + ArbitraryMetadataKeys: keys, + }) + if !g.renderMetadataStatus(w, r, res.GetStatus(), err) { + return + } + w.WriteHeader(http.StatusNoContent) +} + +func openExtensionNameParam(w http.ResponseWriter, r *http.Request) (string, bool) { + name, err := url.PathUnescape(chi.URLParam(r, "extensionName")) + if err != nil { + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid extension name") + return "", false + } + if err := openextension.ValidateName(name); err != nil { + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error()) + return "", false + } + return name, true +} + +// statOpenExtensionItem stats the item of the request; false means the error was rendered. +func (g Graph) statOpenExtensionItem(w http.ResponseWriter, r *http.Request) (*provider.ResourceInfo, bool) { + _, itemID, err := GetDriveAndItemIDParam(r, g.logger) + if err != nil { + errorcode.RenderError(w, r, err) + return nil, false + } + client, err := g.gatewaySelector.Next() + if err != nil { + g.logger.Error().Err(err).Msg("error selecting next gateway client") + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + return nil, false + } + res, err := client.Stat(r.Context(), &provider.StatRequest{Ref: &provider.Reference{ResourceId: itemID}}) + switch { + case err != nil: + g.logger.Error().Err(err).Msg("error statting the item") + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + return nil, false + case res.GetStatus().GetCode() == rpc.Code_CODE_OK: + return res.GetInfo(), true + case res.GetStatus().GetCode() == rpc.Code_CODE_NOT_FOUND, res.GetStatus().GetCode() == rpc.Code_CODE_PERMISSION_DENIED: + errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.GetStatus().GetMessage()) + return nil, false + case res.GetStatus().GetCode() == rpc.Code_CODE_UNAUTHENTICATED: + errorcode.Unauthenticated.Render(w, r, http.StatusUnauthorized, res.GetStatus().GetMessage()) + return nil, false + default: + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.GetStatus().GetMessage()) + return nil, false + } +} + +// renderMetadataStatus renders a failed write and reports whether it succeeded. +func (g Graph) renderMetadataStatus(w http.ResponseWriter, r *http.Request, status *rpc.Status, err error) bool { + switch { + case err != nil: + g.logger.Error().Err(err).Msg("error writing arbitrary metadata") + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + return false + case status.GetCode() == rpc.Code_CODE_OK: + return true + case status.GetCode() == rpc.Code_CODE_LOCKED: + errorcode.ItemIsLocked.Render(w, r, http.StatusLocked, "the item is locked") + return false + case status.GetCode() == rpc.Code_CODE_PERMISSION_DENIED: + errorcode.AccessDenied.Render(w, r, http.StatusForbidden, status.GetMessage()) + return false + case status.GetCode() == rpc.Code_CODE_NOT_FOUND: + errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, status.GetMessage()) + return false + default: + g.logger.Error().Interface("status", status).Msg("error writing arbitrary metadata") + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, status.GetMessage()) + return false + } +} + +// canWriteOpenExtensions is the tag rule: writing metadata needs write access. +func canWriteOpenExtensions(info *provider.ResourceInfo) bool { + pm := info.GetPermissionSet() + return pm != nil && (pm.GetInitiateFileUpload() || pm.GetCreateContainer()) +} + +func storedOnly(keys []string, metadata map[string]string) []string { + out := keys[:0] + for _, key := range keys { + if _, ok := metadata[key]; ok { + out = append(out, key) + } + } + return out +} + +// driveItemWithOpenExtensions adds the extensions relation; the generated model +// has no field for it yet. +func driveItemWithOpenExtensions(item *libregraph.DriveItem, info *provider.ResourceInfo) (map[string]any, error) { + m, err := item.ToMap() + if err != nil { + return nil, err + } + exts := openextension.FromMetadata(info.GetArbitraryMetadata().GetMetadata()) + if exts == nil { + exts = []openextension.OpenExtension{} + } + m[_expandOpenExtensions] = exts + return m, nil +} diff --git a/services/graph/pkg/service/v0/openextensions_test.go b/services/graph/pkg/service/v0/openextensions_test.go new file mode 100644 index 0000000000..c3b4692197 --- /dev/null +++ b/services/graph/pkg/service/v0/openextensions_test.go @@ -0,0 +1,319 @@ +package svc_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + + gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/go-chi/chi/v5" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/mock" + "github.com/tidwall/gjson" + "google.golang.org/grpc" + + revactx "github.com/opencloud-eu/reva/v2/pkg/ctx" + "github.com/opencloud-eu/reva/v2/pkg/rgrpc/status" + "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" + cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks" + + "github.com/opencloud-eu/opencloud/pkg/log" + "github.com/opencloud-eu/opencloud/pkg/shared" + "github.com/opencloud-eu/opencloud/services/graph/mocks" + "github.com/opencloud-eu/opencloud/services/graph/pkg/config" + "github.com/opencloud-eu/opencloud/services/graph/pkg/config/defaults" + "github.com/opencloud-eu/opencloud/services/graph/pkg/metrics" + service "github.com/opencloud-eu/opencloud/services/graph/pkg/service/v0" +) + +var _ = Describe("OpenExtensions", func() { + var ( + svc service.Service + ctx context.Context + cfg *config.Config + gatewayClient *cs3mocks.GatewayAPIClient + gatewaySelector pool.Selectable[gateway.GatewayAPIClient] + eventsPublisher mocks.Publisher + rr *httptest.ResponseRecorder + + itemID = &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "nodeid"} + info *provider.ResourceInfo + writable = &provider.ResourcePermissions{InitiateFileUpload: true} + readonly = &provider.ResourcePermissions{Stat: true} + + currentUser = &userpb.User{Id: &userpb.UserId{OpaqueId: "user"}} + ) + + key := func(name, property string) string { + return "http://opencloud.eu/ns/extensions/" + name + "/" + property + } + + // request builds a request against the extension routes with the chi + // parameters set the way the router would. + request := func(method, name, body string, query ...string) *http.Request { + var r *http.Request + if body != "" { + r = httptest.NewRequest(method, "/graph/v1beta1/drives/storageid$spaceid/items/storageid$spaceid!nodeid/extensions/"+name, strings.NewReader(body)) + } else { + r = httptest.NewRequest(method, "/graph/v1beta1/drives/storageid$spaceid/items/storageid$spaceid!nodeid/extensions/"+name, nil) + } + if len(query) > 0 { + r.URL.RawQuery = query[0] + } + rctx := chi.NewRouteContext() + rctx.URLParams.Add("driveID", "storageid$spaceid") + rctx.URLParams.Add("itemID", "storageid$spaceid!nodeid") + rctx.URLParams.Add("driveItemID", "storageid$spaceid!nodeid") + if name != "" { + rctx.URLParams.Add("extensionName", name) + } + return r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx)) + } + + statReturns := func(info *provider.ResourceInfo) { + gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(&provider.StatResponse{Status: status.NewOK(ctx), Info: info}, nil) + } + + BeforeEach(func() { + eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil) + + pool.RemoveSelector("GatewaySelector" + "eu.opencloud.api.gateway") + gatewayClient = &cs3mocks.GatewayAPIClient{} + gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient]( + "GatewaySelector", + "eu.opencloud.api.gateway", + func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient { + return gatewayClient + }, + ) + + logger := log.NewLogger() + metrics, _ := metrics.New(prometheus.NewRegistry(), &logger, func([]string) (string, string) { return "", "" }) + + rr = httptest.NewRecorder() + ctx = context.Background() + + cfg = defaults.FullDefaultConfig() + cfg.Identity.LDAP.CACert = "" + cfg.TokenManager.JWTSecret = "loremipsum" + cfg.Commons = &shared.Commons{} + cfg.GRPCClientTLS = &shared.GRPCClientTLS{} + + var err error + svc, err = service.NewService( + service.Config(cfg), + service.Metrics(metrics), + service.WithGatewaySelector(gatewaySelector), + service.EventsPublisher(&eventsPublisher), + ) + Expect(err).ToNot(HaveOccurred()) + + info = &provider.ResourceInfo{ + Type: provider.ResourceType_RESOURCE_TYPE_FILE, + Id: itemID, + Etag: "etag", + PermissionSet: writable, + ArbitraryMetadata: &provider.ArbitraryMetadata{Metadata: map[string]string{ + "tags": "a,b", + key("com.example.project", "due"): "d:2026-10-01T00:00:00Z", + key("com.example.project", "priority"): "n:3", + key("com.example.project", "status"): "s:open", + key("com.example.audit", "reviewed"): "b:true", + }}, + } + }) + + Describe("ListOpenExtensions", func() { + It("lists every extension with its annotations, sorted by name", func() { + statReturns(info) + svc.ListOpenExtensions(rr, request(http.MethodGet, "", "")) + Expect(rr.Code).To(Equal(http.StatusOK)) + body := rr.Body.String() + Expect(gjson.Get(body, "value.#").Int()).To(Equal(int64(2))) + Expect(gjson.Get(body, "value.0.extensionName").String()).To(Equal("com.example.audit")) + Expect(gjson.Get(body, "value.0.reviewed").Bool()).To(BeTrue()) + Expect(gjson.Get(body, "value.1.extensionName").String()).To(Equal("com.example.project")) + Expect(gjson.Get(body, "value.1.priority").Int()).To(Equal(int64(3))) + Expect(gjson.Get(body, "value.1.due@odata\\.type").String()).To(Equal("#DateTimeOffset")) + Expect(gjson.Get(body, "value.1.status").String()).To(Equal("open"), "the stored type code stays internal") + Expect(gjson.Get(body, "value.1.status@odata\\.type").Exists()).To(BeFalse(), "inferable types are not annotated") + }) + + It("answers an item without extensions with an empty collection", func() { + info.ArbitraryMetadata = nil + statReturns(info) + svc.ListOpenExtensions(rr, request(http.MethodGet, "", "")) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect(rr.Body.String()).To(MatchJSON(`{"value":[]}`)) + }) + + It("hides items the caller cannot see", func() { + gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(&provider.StatResponse{Status: status.NewPermissionDenied(ctx, nil, "denied")}, nil) + svc.ListOpenExtensions(rr, request(http.MethodGet, "", "")) + Expect(rr.Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("GetOpenExtension", func() { + It("returns the extension", func() { + statReturns(info) + svc.GetOpenExtension(rr, request(http.MethodGet, "com.example.project", "")) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect(rr.Body.String()).To(MatchJSON(`{"extensionName":"com.example.project","due":"2026-10-01T00:00:00Z","due@odata.type":"#DateTimeOffset","priority":3,"status":"open"}`)) + }) + + It("answers 404 for an extension the item does not have", func() { + statReturns(info) + svc.GetOpenExtension(rr, request(http.MethodGet, "com.example.other", "")) + Expect(rr.Code).To(Equal(http.StatusNotFound)) + }) + + It("rejects a name that is not reverse DNS", func() { + svc.GetOpenExtension(rr, request(http.MethodGet, "project", "")) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + gatewayClient.AssertNotCalled(GinkgoT(), "Stat", mock.Anything, mock.Anything) + }) + }) + + Describe("UpsertOpenExtension", func() { + var written map[string]string + + BeforeEach(func() { + written = nil + gatewayClient.On("SetArbitraryMetadata", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + req := args.Get(1).(*provider.SetArbitraryMetadataRequest) + Expect(req.GetRef().GetResourceId()).To(Equal(itemID)) + written = req.GetArbitraryMetadata().GetMetadata() + }).Return(&provider.SetArbitraryMetadataResponse{Status: status.NewOK(ctx)}, nil) + }) + + It("creates an extension, one typed value per property", func() { + statReturns(info) + svc.UpsertOpenExtension(rr, request(http.MethodPut, "com.example.new", + `{"extensionName":"com.example.new","state":"open","site":{"latitude":52.5,"longitude":13.4},"site@odata.type":"#microsoft.graph.geoCoordinates"}`)) + Expect(rr.Code).To(Equal(http.StatusCreated)) + Expect(written).To(Equal(map[string]string{ + key("com.example.new", "site"): "g:52.5,13.4", + key("com.example.new", "state"): "s:open", + })) + Expect(rr.Body.String()).To(MatchJSON(`{"extensionName":"com.example.new","site":{"latitude":52.5,"longitude":13.4},"site@odata.type":"#microsoft.graph.geoCoordinates","state":"open"}`)) + }) + + It("merges into an existing extension, null removes", func() { + statReturns(info) + var removed []string + gatewayClient.On("UnsetArbitraryMetadata", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + removed = args.Get(1).(*provider.UnsetArbitraryMetadataRequest).GetArbitraryMetadataKeys() + }).Return(&provider.UnsetArbitraryMetadataResponse{Status: status.NewOK(ctx)}, nil) + svc.UpsertOpenExtension(rr, request(http.MethodPut, "com.example.project", `{"status":"approved","priority":null,"effort":2.5}`)) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect(written).To(Equal(map[string]string{ + key("com.example.project", "status"): "s:approved", + key("com.example.project", "effort"): "n:2.5", + }), "only the properties of the request are written") + Expect(removed).To(Equal([]string{key("com.example.project", "priority")})) + Expect(rr.Body.String()).To(MatchJSON(`{"extensionName":"com.example.project","due":"2026-10-01T00:00:00Z","due@odata.type":"#DateTimeOffset","effort":2.5,"status":"approved"}`)) + }) + + It("re-types a property written without its old annotation", func() { + statReturns(info) + svc.UpsertOpenExtension(rr, request(http.MethodPut, "com.example.project", `{"due":"later"}`)) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect(written).To(Equal(map[string]string{key("com.example.project", "due"): "s:later"})) + }) + + It("skips the removal of a property that is not stored", func() { + statReturns(info) + svc.UpsertOpenExtension(rr, request(http.MethodPut, "com.example.project", `{"nope":null}`)) + Expect(rr.Code).To(Equal(http.StatusOK)) + gatewayClient.AssertNotCalled(GinkgoT(), "SetArbitraryMetadata", mock.Anything, mock.Anything) + gatewayClient.AssertNotCalled(GinkgoT(), "UnsetArbitraryMetadata", mock.Anything, mock.Anything) + }) + + It("answers 204 once every property is removed", func() { + statReturns(info) + gatewayClient.On("UnsetArbitraryMetadata", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + req := args.Get(1).(*provider.UnsetArbitraryMetadataRequest) + Expect(req.GetArbitraryMetadataKeys()).To(Equal([]string{key("com.example.audit", "reviewed")})) + }).Return(&provider.UnsetArbitraryMetadataResponse{Status: status.NewOK(ctx)}, nil) + svc.UpsertOpenExtension(rr, request(http.MethodPut, "com.example.audit", `{"reviewed":null}`)) + Expect(rr.Code).To(Equal(http.StatusNoContent)) + gatewayClient.AssertNotCalled(GinkgoT(), "SetArbitraryMetadata", mock.Anything, mock.Anything) + }) + + It("rejects an invalid body before touching the storage", func() { + svc.UpsertOpenExtension(rr, request(http.MethodPut, "com.example.project", `{"assignee":{"name":"alice"}}`)) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + Expect(gjson.Get(rr.Body.String(), "error.message").String()).To(ContainSubstring("objects are only allowed as geoCoordinates")) + gatewayClient.AssertNotCalled(GinkgoT(), "Stat", mock.Anything, mock.Anything) + }) + + It("needs write access", func() { + info.PermissionSet = readonly + statReturns(info) + svc.UpsertOpenExtension(rr, request(http.MethodPut, "com.example.project", `{"status":"x"}`)) + Expect(rr.Code).To(Equal(http.StatusForbidden)) + gatewayClient.AssertNotCalled(GinkgoT(), "SetArbitraryMetadata", mock.Anything, mock.Anything) + }) + + It("reports a locked item", func() { + statReturns(info) + gatewayClient.ExpectedCalls = nil + statReturns(info) + gatewayClient.On("SetArbitraryMetadata", mock.Anything, mock.Anything).Return(&provider.SetArbitraryMetadataResponse{Status: status.NewLocked(ctx, "locked")}, nil) + svc.UpsertOpenExtension(rr, request(http.MethodPut, "com.example.project", `{"status":"x"}`)) + Expect(rr.Code).To(Equal(http.StatusLocked)) + }) + }) + + Describe("DeleteOpenExtension", func() { + It("unsets every stored property", func() { + statReturns(info) + gatewayClient.On("UnsetArbitraryMetadata", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + req := args.Get(1).(*provider.UnsetArbitraryMetadataRequest) + Expect(req.GetArbitraryMetadataKeys()).To(Equal([]string{key("com.example.project", "due"), key("com.example.project", "priority"), key("com.example.project", "status")})) + }).Return(&provider.UnsetArbitraryMetadataResponse{Status: status.NewOK(ctx)}, nil) + svc.DeleteOpenExtension(rr, request(http.MethodDelete, "com.example.project", "")) + Expect(rr.Code).To(Equal(http.StatusNoContent)) + }) + + It("answers 404 for an extension the item does not have", func() { + statReturns(info) + svc.DeleteOpenExtension(rr, request(http.MethodDelete, "com.example.other", "")) + Expect(rr.Code).To(Equal(http.StatusNotFound)) + gatewayClient.AssertNotCalled(GinkgoT(), "UnsetArbitraryMetadata", mock.Anything, mock.Anything) + }) + + It("needs write access", func() { + info.PermissionSet = readonly + statReturns(info) + svc.DeleteOpenExtension(rr, request(http.MethodDelete, "com.example.project", "")) + Expect(rr.Code).To(Equal(http.StatusForbidden)) + }) + }) + + Describe("GetDriveItem", func() { + It("expands the extensions on request", func() { + statReturns(info) + svc.GetDriveItem(rr, request(http.MethodGet, "", "", "$expand=extensions")) + Expect(rr.Code).To(Equal(http.StatusOK)) + body := rr.Body.String() + Expect(gjson.Get(body, "id").String()).To(Equal("storageid$spaceid!nodeid")) + Expect(gjson.Get(body, "extensions.#").Int()).To(Equal(int64(2))) + Expect(gjson.Get(body, "extensions.1.extensionName").String()).To(Equal("com.example.project")) + Expect(gjson.Get(body, "extensions.1.due@odata\\.type").String()).To(Equal("#DateTimeOffset")) + }) + + It("leaves the extensions out without $expand", func() { + statReturns(info) + svc.GetDriveItem(rr, request(http.MethodGet, "", "")) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect(gjson.Get(rr.Body.String(), "extensions").Exists()).To(BeFalse()) + }) + }) +}) diff --git a/services/graph/pkg/service/v0/service.go b/services/graph/pkg/service/v0/service.go index 9b9636e370..0cac51a08f 100644 --- a/services/graph/pkg/service/v0/service.go +++ b/services/graph/pkg/service/v0/service.go @@ -103,6 +103,10 @@ type Service interface { //nolint:interfacebloat GetRootDriveChildren(w http.ResponseWriter, r *http.Request) GetDriveItem(w http.ResponseWriter, r *http.Request) + ListOpenExtensions(w http.ResponseWriter, r *http.Request) + GetOpenExtension(w http.ResponseWriter, r *http.Request) + UpsertOpenExtension(w http.ResponseWriter, r *http.Request) + DeleteOpenExtension(w http.ResponseWriter, r *http.Request) GetDriveItemChildren(w http.ResponseWriter, r *http.Request) CreateUploadSession(w http.ResponseWriter, r *http.Request) @@ -273,6 +277,14 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx r.Delete("/", drivesDriveItemApi.DeleteDriveItem) r.Post("/invite", driveItemPermissionsApi.Invite) r.Post("/createLink", driveItemPermissionsApi.CreateLink) + r.Route("/extensions", func(r chi.Router) { + r.Get("/", svc.ListOpenExtensions) + r.Route("/{extensionName}", func(r chi.Router) { + r.Get("/", svc.GetOpenExtension) + r.Put("/", svc.UpsertOpenExtension) + r.Delete("/", svc.DeleteOpenExtension) + }) + }) r.Route("/permissions", func(r chi.Router) { r.Get("/", driveItemPermissionsApi.ListPermissions) r.Route("/{permissionID}", func(r chi.Router) {