diff --git a/changelog/unreleased/tags.md b/changelog/unreleased/tags.md new file mode 100644 index 0000000000..2853b685a4 --- /dev/null +++ b/changelog/unreleased/tags.md @@ -0,0 +1,5 @@ +Enhancement: Tags + +Base functionality for tagging files + +https://github.com/cs3org/reva/pull/3555 diff --git a/internal/http/services/owncloud/ocdav/propfind/propfind.go b/internal/http/services/owncloud/ocdav/propfind/propfind.go index 5c0ed7e190..5a65d697dc 100644 --- a/internal/http/services/owncloud/ocdav/propfind/propfind.go +++ b/internal/http/services/owncloud/ocdav/propfind/propfind.go @@ -695,6 +695,7 @@ func (p *Handler) getSpaceResourceInfos(ctx context.Context, w http.ResponseWrit if res.Status.Code != rpc.Code_CODE_OK { log.Debug().Interface("status", res.Status).Msg("List Container not ok, skipping") + w.WriteHeader(http.StatusInternalServerError) return nil, false } for _, info := range res.Infos { @@ -830,7 +831,7 @@ func requiresExplicitFetching(n *xml.Name) bool { } case net.NsOwncloud: switch n.Local { - case "favorite", "share-types", "checksums", "size": + case "favorite", "share-types", "checksums", "size", "tags": return true default: return false @@ -1087,6 +1088,10 @@ func mdToPropResponse(ctx context.Context, pf *XML, md *provider.ResourceInfo, p appendToOK(prop.Raw("oc:checksums", checksums.String())) } + if k := md.GetArbitraryMetadata().GetMetadata(); k != nil { + propstatOK.Prop = append(propstatOK.Prop, prop.Raw("oc:tags", k["tags"])) + } + // ls do not report any properties as missing by default if ls == nil { // favorites from arbitrary metadata @@ -1354,6 +1359,10 @@ func mdToPropResponse(ctx context.Context, pf *XML, md *provider.ResourceInfo, p appendToNotFound(prop.NotFound("oc:signature-auth")) } } + case "tags": + if k := md.GetArbitraryMetadata().GetMetadata(); k != nil { + propstatOK.Prop = append(propstatOK.Prop, prop.Raw("oc:tags", k["tags"])) + } case "name": appendToOK(prop.Escaped("oc:name", md.Name)) case "shareid": @@ -1582,6 +1591,10 @@ func metadataKeyOf(n *xml.Name) string { if n.Local == "share-types" { return "share-types" } + + if n.Local == "tags" { + return "tags" + } } return fmt.Sprintf("%s/%s", n.Space, n.Local) } diff --git a/internal/http/services/owncloud/ocs/data/capabilities.go b/internal/http/services/owncloud/ocs/data/capabilities.go index 820875a5ab..4d7a0d733c 100644 --- a/internal/http/services/owncloud/ocs/data/capabilities.go +++ b/internal/http/services/owncloud/ocs/data/capabilities.go @@ -132,6 +132,7 @@ type CapabilitiesFiles struct { Undelete ocsBool `json:"undelete" xml:"undelete"` Versioning ocsBool `json:"versioning" xml:"versioning"` Favorites ocsBool `json:"favorites" xml:"favorites"` + Tags ocsBool `json:"tags" xml:"tags"` BlacklistedFiles []string `json:"blacklisted_files" xml:"blacklisted_files>element" mapstructure:"blacklisted_files"` TusSupport *CapabilitiesFilesTusSupport `json:"tus_support" xml:"tus_support" mapstructure:"tus_support"` Archivers []*CapabilitiesArchiver `json:"archivers" xml:"archivers" mapstructure:"archivers"` diff --git a/pkg/events/tags.go b/pkg/events/tags.go new file mode 100644 index 0000000000..44b6adf05c --- /dev/null +++ b/pkg/events/tags.go @@ -0,0 +1,56 @@ +// Copyright 2018-2022 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package events + +import ( + "encoding/json" + + user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" +) + +// TagsAdded is emitted when a Tag has been added +type TagsAdded struct { + SpaceOwner *user.UserId + Tags string + Ref *provider.Reference + Executant *user.UserId +} + +// Unmarshal to fulfill umarshaller interface +func (TagsAdded) Unmarshal(v []byte) (interface{}, error) { + e := TagsAdded{} + err := json.Unmarshal(v, &e) + return e, err +} + +// TagsRemoved is emitted when a Tag has been added +type TagsRemoved struct { + SpaceOwner *user.UserId + Tags string + Ref *provider.Reference + Executant *user.UserId +} + +// Unmarshal to fulfill umarshaller interface +func (TagsRemoved) Unmarshal(v []byte) (interface{}, error) { + e := TagsRemoved{} + err := json.Unmarshal(v, &e) + return e, err +} diff --git a/pkg/tags/tags.go b/pkg/tags/tags.go new file mode 100644 index 0000000000..c6bb765d5c --- /dev/null +++ b/pkg/tags/tags.go @@ -0,0 +1,112 @@ +// Copyright 2018-2022 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package tags + +import ( + "strings" +) + +var ( + // character used to separate tags in lists + _tagsep = "," + // maximum number of tags + _maxtags = 100 +) + +// Tags is a helper struct for merging, deleting and deduplicating the tags while preserving the order +type Tags struct { + sep string + maxtags int + + t []string + exists map[string]bool + numtags int +} + +// FromList creates a Tags struct from a list of tags +func FromList(s string) *Tags { + t := &Tags{sep: _tagsep, maxtags: _maxtags, exists: make(map[string]bool)} + t.t = t.addTags(s) + return t +} + +// AddList appends a list of new tags and returns true if at least one was appended +func (t *Tags) AddList(s string) bool { + tags := t.addTags(s) + t.t = append(tags, t.t...) + return len(tags) > 0 +} + +// RemoveList removes a list of tags and returns true if at least one was removed +func (t *Tags) RemoveList(s string) bool { + var removed bool + for _, tag := range strings.Split(s, t.sep) { + if !t.exists[tag] { + continue + } + + for i, tt := range t.t { + if tt == tag { + t.t = append(t.t[:i], t.t[i+1:]...) + break + } + } + + delete(t.exists, tag) + removed = true + } + return removed +} + +// AsList returns the tags converted to a list +func (t *Tags) AsList() string { + return strings.Join(t.t, t.sep) +} + +// AsSlice returns the tags as slice of strings +func (t *Tags) AsSlice() []string { + return t.t +} + +// adds the tags and returns a list of added tags +func (t *Tags) addTags(s string) []string { + added := make([]string, 0) + for _, tag := range strings.Split(s, t.sep) { + if tag == "" { + // ignore empty tags + continue + } + + if t.exists[tag] { + // tag is already existing + continue + } + + if t.numtags >= t.maxtags { + // max number of tags reached. We return silently without warning anyone + break + } + + added = append(added, tag) + t.exists[tag] = true + t.numtags++ + } + + return added +} diff --git a/pkg/tags/tags_test.go b/pkg/tags/tags_test.go new file mode 100644 index 0000000000..0e91eef485 --- /dev/null +++ b/pkg/tags/tags_test.go @@ -0,0 +1,181 @@ +// Copyright 2018-2022 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package tags + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAddTags(t *testing.T) { + testCases := []struct { + Alias string + InitialTags string + TagsToAdd string + ExpectedTags string + ExpectNoTagsAdded bool + }{ + { + Alias: "add one tag", + InitialTags: "", + TagsToAdd: "tag1", + ExpectedTags: "tag1", + }, + { + Alias: "add multiple tags", + InitialTags: "a,b,c", + TagsToAdd: "c,d,e", + ExpectedTags: "d,e,a,b,c", + }, + { + Alias: "no new tags", + InitialTags: "a,b,c,d,e,f", + TagsToAdd: "c,d,e", + ExpectedTags: "a,b,c,d,e,f", + ExpectNoTagsAdded: true, + }, + { + Alias: "ignore duplicate tags", + InitialTags: "a", + TagsToAdd: "b,b,b,b,a,a,a,a,a", + ExpectedTags: "b,a", + }, + { + Alias: "stop adding when maximum is reached", + InitialTags: "a,b,c,d,e,f,g,h,i", + TagsToAdd: "j,k,l,m", + ExpectedTags: "j,a,b,c,d,e,f,g,h,i", + }, + { + Alias: "don't do anything when already maxed", + InitialTags: "a,b,c,d,e,f,g,h,i,j", + TagsToAdd: "k,l,m,n,o,p", + ExpectedTags: "a,b,c,d,e,f,g,h,i,j", + ExpectNoTagsAdded: true, + }, + { + Alias: "trailing seps are ignored", + InitialTags: "tag1", + TagsToAdd: "tag2,", + ExpectedTags: "tag2,tag1", + }, + { + Alias: "special characters are allowed", + InitialTags: "old tag", + TagsToAdd: "new tag,bettertag!", + ExpectedTags: "new tag,bettertag!,old tag", + }, + { + Alias: "empty tags are ignored", + InitialTags: "tag1", + TagsToAdd: "tag2,,tag3", + ExpectedTags: "tag2,tag3,tag1", + }, + { + Alias: "empty tags are not ignored if there are no new tags", + InitialTags: "tag1", + TagsToAdd: ",,,tag1,,", + ExpectedTags: "tag1", + ExpectNoTagsAdded: true, + }, + { + Alias: "condition hold for initial tags too", + InitialTags: "tag1,tag1,,tag3,", + TagsToAdd: "tag2", + ExpectedTags: "tag2,tag1,tag3", + }, + } + + for _, tc := range testCases { + ts := FromList(tc.InitialTags) + ts.maxtags = 10 + + added := ts.AddList(tc.TagsToAdd) + require.Equal(t, tc.ExpectNoTagsAdded, !added, tc.Alias) + require.Equal(t, tc.ExpectedTags, ts.AsList(), tc.Alias) + require.Equal(t, strings.Split(tc.ExpectedTags, ","), ts.AsSlice(), tc.Alias) + } + +} + +func TestRemoveTags(t *testing.T) { + testCases := []struct { + Alias string + InitialTags string + TagsToRemove string + ExpectedTags string + ExpectNoTagsRemoved bool + }{ + { + Alias: "simple", + InitialTags: "a,b,c", + TagsToRemove: "a,b", + ExpectedTags: "c", + }, + { + Alias: "remove all tags", + InitialTags: "a,b,c,d,e,f", + TagsToRemove: "f,c,a,d,e,b", + ExpectedTags: "", + }, + { + Alias: "ignore duplicate tags", + InitialTags: "a,b", + TagsToRemove: "b,b,b,b", + ExpectedTags: "a", + }, + { + Alias: "order of tags is preserved", + InitialTags: "a,b,c,d", + TagsToRemove: "a,c", + ExpectedTags: "b,d", + }, + { + Alias: "special characters are allowed", + InitialTags: "anothertag,btag!!,c#,distro 66", + TagsToRemove: "distro 66,btag!!", + ExpectedTags: "anothertag,c#", + }, + { + Alias: "empty list errors", + InitialTags: "tag1,tag2", + TagsToRemove: ",,,,,", + ExpectedTags: "tag1,tag2", + ExpectNoTagsRemoved: true, + }, + { + Alias: "unknown tag errors", + InitialTags: "tag1,tag2", + TagsToRemove: "tag3", + ExpectedTags: "tag1,tag2", + ExpectNoTagsRemoved: true, + }, + } + + for _, tc := range testCases { + ts := FromList(tc.InitialTags) + + removed := ts.RemoveList(tc.TagsToRemove) + require.Equal(t, tc.ExpectNoTagsRemoved, !removed, tc.Alias) + require.Equal(t, tc.ExpectedTags, ts.AsList(), tc.Alias) + } + +}