diff --git a/cmd/instances.go b/cmd/instances.go
index 0dd3af6ce63..abf01f18c99 100644
--- a/cmd/instances.go
+++ b/cmd/instances.go
@@ -152,7 +152,7 @@ cozy-stack instances add allows to create an instance on the cozy for a
given domain.
If the COZY_DISABLE_INSTANCES_ADD_RM env variable is set, creating and
-destroying instances will be desactivated and the content of this variable will
+destroying instances will be disabled and the content of this variable will
be used as the error message.
`,
Example: "$ cozy-stack instances add --passphrase cozy --apps drive,photos,settings,home,store cozy.localhost:8080",
diff --git a/cozy.example.yaml b/cozy.example.yaml
index 8649aa74863..b9d18157ec5 100644
--- a/cozy.example.yaml
+++ b/cozy.example.yaml
@@ -146,7 +146,8 @@ jobs:
# - "push": sending push notifications
# - "sms": sending SMS notifications
# - "sendmail": sending mails
- # - "share-replicate": for cozy to cozy sharing
+ # - "share-group": for cozy to cozy sharing
+ # - "share-replicate": idem
# - "share-track": idem
# - "share-upload": idem
# - "thumbnail": creatings and deleting thumbnails for images
diff --git a/docs/cli/cozy-stack_instances_add.md b/docs/cli/cozy-stack_instances_add.md
index 490d8acd669..119e09a4be7 100644
--- a/docs/cli/cozy-stack_instances_add.md
+++ b/docs/cli/cozy-stack_instances_add.md
@@ -9,7 +9,7 @@ cozy-stack instances add allows to create an instance on the cozy for a
given domain.
If the COZY_DISABLE_INSTANCES_ADD_RM env variable is set, creating and
-destroying instances will be desactivated and the content of this variable will
+destroying instances will be disabled and the content of this variable will
be used as the error message.
diff --git a/docs/sharing-design.md b/docs/sharing-design.md
index 1d6327cc9e6..e82c463e882 100644
--- a/docs/sharing-design.md
+++ b/docs/sharing-design.md
@@ -473,20 +473,35 @@ care of it later.
### Description of a sharing
- An identifier (the same for all members of the sharing)
-- A list of `members`. The first one is the owner. For each member, we have
- the URL of the cozy, a contact name, a public name, an email, a status, a
- read-only flag, and some credentials to authorize the transfer of data
- between the owner and the recipients. The status can be:
- - `owner` for the member that has created the sharing
- - `mail-not-sent` for a member that has been added, but its invitation
- has not yet been sent (often, this status is used only for a few
- seconds)
- - `pending` for a member with an invitation sent, but who has not clicked
- on the link
- - `seen` for a member that has clicked on the invitation link, but has not
- setup the Cozy to Cozy replication for the sharing
- - `ready` for a member where the Cozy to Cozy replication has been set up
- - `revoked` for a member who is on longer in the sharing
+- A list of `members`. The first one is the owner. For each member, we have:
+ - `status`, a status that can be:
+ - `owner` for the member that has created the sharing
+ - `mail-not-sent` for a member that has been added, but its
+ invitation has not yet been sent (often, this status is used only
+ for a few seconds)
+ - `pending` for a member with an invitation sent, but who has not
+ clicked on the link
+ - `seen` for a member that has clicked on the invitation link, but
+ has not setup the Cozy to Cozy replication for the sharing
+ - `ready` for a member where the Cozy to Cozy replication has been
+ set up
+ - `revoked` for a member who is on longer in the sharing
+ - `name`, a contact name
+ - `public_name`, a public name
+ - `email`, the email address
+ - `instance`, the URL of the Cozy
+ - `read_only`, a flag to tell if the contact is restricted to read-only mode
+ - `only_in_groups`, a flag that will be false if the member has been added
+ as a single contact
+ - `groups`, a list of indexes of the groups
+- A list of `groups`, with for each one:
+ - `id`, the identifier of the io.cozy.contacts.groups
+ - `name`, the name of the group
+ - `addedBy`, the index of the member that has added the group
+ - `read_only`, a flag to tell if the group is restricted to read-only mode
+ - `revoked`, a flag set to true when the group is revoked from the sharing
+- Some `credentials` to authorize the transfer of data between the owner and
+ the recipients
- A `description` (one sentence that will help people understand what is
shared and why)
- A flag `active` that says if the sharing is currently active for at least
diff --git a/docs/sharing.md b/docs/sharing.md
index 578dabb75ee..c393a4e39bf 100644
--- a/docs/sharing.md
+++ b/docs/sharing.md
@@ -95,6 +95,10 @@ Content-Type: application/vnd.api+json
{
"id": "2a31ce0128b5f89e40fd90da3f014087",
"type": "io.cozy.contacts"
+ },
+ {
+ "id": "51bbc980acb0013cb5f618c04daba326",
+ "type": "io.cozy.contacts.groups"
}
]
}
@@ -136,6 +140,20 @@ Content-Type: application/vnd.api+json
"status": "mail-not-sent",
"name": "Bob",
"email": "bob@example.net"
+ },
+ {
+ "status": "mail-not-sent",
+ "name": "Gaby",
+ "email": "gaby@example.net",
+ "only_in_groups": true,
+ "groups": [0]
+ }
+ ],
+ "groups": [
+ {
+ "id": "51bbc980acb0013cb5f618c04daba326",
+ "name": "G. people",
+ "addedBy": 0
}
],
"rules": [
@@ -747,9 +765,9 @@ HTTP/1.1 204 No Content
### POST /sharings/:sharing-id/recipients
-This route allows the sharer to add new recipients to a sharing. It can also be
-used by a recipient when the sharing has `open_sharing` set to true if the
-recipient doesn't have the `read_only` flag
+This route allows the sharer to add new recipients (and groups of recipients)
+to a sharing. It can also be used by a recipient when the sharing has
+`open_sharing` set to true if the recipient doesn't have the `read_only` flag.
#### Request
@@ -868,9 +886,9 @@ Content-Type: application/vnd.api+json
### POST /sharings/:sharing-id/recipients/delegated
This is an internal route for the stack. It is called by the recipient cozy on
-the owner cozy to add recipients to the sharing (`open_sharing: true` only). It
-should send an `email` address, but if the email address is not known, an
-`instance` URL can also be used.
+the owner cozy to add recipients and groups to the sharing (`open_sharing:
+true` only). Data for direct recipients should contain an email address but if
+it is not known, an instance URL can also be provided.
#### Request
@@ -892,6 +910,15 @@ Content-Type: application/vnd.api+json
"email": "dave@example.net"
}
]
+ },
+ "groups": {
+ "data": [
+ {
+ "id": "b57cd790b2f4013c3ced18c04daba326",
+ "name": "Dance",
+ "addedBy": 1
+ }
+ ]
}
}
}
@@ -911,12 +938,68 @@ Content-Type: application/json
}
```
+### POST /sharings/:sharing-id/members/:index/invitation
+
+This is an internal route for the stack. It is called by the recipient cozy on
+the owner cozy to send an invitation.
+
+#### Request
+
+```http
+POST /sharings/ce8835a061d0ef68947afe69a0046722/members/4/invitation HTTP/1.1
+Host: alice.example.net
+Content-Type: application/vnd.api+json
+```
+
+```json
+{
+ "data": {
+ "type": "io.cozy.sharings.members",
+ "attributes": {
+ "email": "diana@example.net"
+ }
+ }
+}
+```
+
+#### Response
+
+```http
+HTTP/1.1 200 OK
+Content-Type: application/json
+```
+
+```json
+{
+ "diana@example.net": "uS6wN7fTYaLZ-GdC_P6UWA"
+}
+```
+
+### DELETE /sharings/:sharing-id/groups/:group-index/:member-index
+
+This is an internal route for the stack. It is called by the recipient cozy on
+the owner cozy to remove a member of a sharing from a group.
+
+#### Request
+
+```http
+DELETE /sharings/ce8835a061d0ef68947afe69a0046722/groups/0/1 HTTP/1.1
+Host: alice.example.net
+```
+
+#### Response
+
+```http
+HTTP/1.1 204 No Content
+```
+
### PUT /sharings/:sharing-id/recipients
-This internal route is used to update the list of members, their states, emails
-and names, on the recipients cozy. The token used for this route can be the
-access token for a sharing where synchronization is active, or the sharecode
-for a member who has only a shortcut to the sharing on their Cozy instance.
+This internal route is used to update the list of members (their states, emails
+and names) and the list of groups on the recipients cozy. The token used for
+this route can be the access token for a sharing where synchronization is
+active, or the sharecode for a member who has only a shortcut to the sharing on
+their Cozy instance.
#### Request
@@ -953,6 +1036,13 @@ Content-Type: application/vnd.api+json
"email": "dave@example.net",
"read_only": true
}
+ ],
+ "included": [
+ {
+ "id": "51bbc980acb0013cb5f618c04daba326",
+ "name": "G. people",
+ "addedBy": 0
+ }
]
}
```
@@ -1137,6 +1227,27 @@ Host: alice.example.net
HTTP/1.1 204 No Content
```
+### DELETE /sharings/:sharing-id/groups/:index
+
+This route can be only be called on the cozy instance of the sharer to revoke a
+group of the sharing. The parameter is the index of this recipient in the
+`groups` array of the sharing. The `removed` property for this group will be
+set to `true`, and it will revoke the members of this group unless they are
+still part of the sharing via another group or as direct recipients.
+
+#### Request
+
+```http
+DELETE /sharings/ce8835a061d0ef68947afe69a0046722/groups/0 HTTP/1.1
+Host: alice.example.net
+```
+
+#### Response
+
+```http
+HTTP/1.1 204 No Content
+```
+
### DELETE /sharings/:sharing-id/recipients/self
This route can be used by an application in the cozy of a recipient to remove it
diff --git a/docs/workers.md b/docs/workers.md
index aca6676b7b2..91e3e4a0171 100644
--- a/docs/workers.md
+++ b/docs/workers.md
@@ -343,11 +343,18 @@ in the config file, via the `fs.auto_clean_trashed_after` parameter.
## share workers
-The stack have 3 workers to power the sharings (internal usage only):
+The stack have 4 workers to power the sharings (internal usage only):
-1. `share-track`, to update the `io.cozy.shared` database
-2. `share-replicate`, to start a replicator for most documents
-3. `share-upload`, to upload files
+1. `share-group`, to add/remove members to a sharing
+2. `share-track`, to update the `io.cozy.shared` database
+3. `share-replicate`, to start a replicator for most documents
+4. `share-upload`, to upload files
+
+### Share-group
+
+When a contact is added to or removed from a group, the change should be
+reflected in the group's sharings' recipients. The message is composed of the
+contact ID, the list of groups added and the list of groups removed.
### Share-track
diff --git a/model/contact/contact_test.go b/model/contact/contact_test.go
new file mode 100644
index 00000000000..b1238779ec7
--- /dev/null
+++ b/model/contact/contact_test.go
@@ -0,0 +1,83 @@
+package contact
+
+import (
+ "encoding/json"
+ "fmt"
+ "testing"
+
+ "github.com/cozy/cozy-stack/pkg/config/config"
+ "github.com/cozy/cozy-stack/pkg/consts"
+ "github.com/cozy/cozy-stack/pkg/couchdb"
+ "github.com/cozy/cozy-stack/pkg/prefixer"
+ "github.com/gofrs/uuid/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetAllContacts(t *testing.T) {
+ config.UseTestFile(t)
+ instPrefix := prefixer.NewPrefixer(0, "cozy-test", "cozy-test")
+ t.Cleanup(func() { _ = couchdb.DeleteDB(instPrefix, consts.Contacts) })
+
+ g := NewGroup()
+ g.SetID(uuid.Must(uuid.NewV7()).String())
+
+ gaby := fmt.Sprintf(`{
+ "address": [],
+ "birthday": "",
+ "birthplace": "",
+ "company": "",
+ "cozy": [],
+ "cozyMetadata": {
+ "createdAt": "2024-02-13T15:05:58.917Z",
+ "createdByApp": "Contacts",
+ "createdByAppVersion": "1.7.0",
+ "doctypeVersion": 3,
+ "metadataVersion": 1,
+ "updatedAt": "2024-02-13T15:06:21.046Z",
+ "updatedByApps": [
+ {
+ "date": "2024-02-13T15:06:21.046Z",
+ "slug": "Contacts",
+ "version": "1.7.0"
+ }
+ ]
+ },
+ "displayName": "Gaby",
+ "email": [],
+ "fullname": "Gaby",
+ "gender": "female",
+ "indexes": {
+ "byFamilyNameGivenNameEmailCozyUrl": "gaby"
+ },
+ "jobTitle": "",
+ "metadata": {
+ "cozy": true,
+ "version": 1
+ },
+ "name": {
+ "givenName": "Gaby"
+ },
+ "note": "",
+ "phone": [],
+ "relationships": {
+ "groups": {
+ "data": [
+ {
+ "_id": "%s",
+ "_type": "io.cozy.contacts.groups"
+ }
+ ]
+ }
+ }
+}`, g.ID())
+
+ doc := couchdb.JSONDoc{Type: consts.Contacts, M: make(map[string]interface{})}
+ require.NoError(t, json.Unmarshal([]byte(gaby), &doc.M))
+ require.NoError(t, couchdb.CreateDoc(instPrefix, &doc))
+
+ contacts, err := g.GetAllContacts(instPrefix)
+ require.NoError(t, err)
+ require.Len(t, contacts, 1)
+ assert.Equal(t, contacts[0].PrimaryName(), "Gaby")
+}
diff --git a/model/contact/contacts.go b/model/contact/contacts.go
index def3c6c9c18..9c27a92d358 100644
--- a/model/contact/contacts.go
+++ b/model/contact/contacts.go
@@ -1,3 +1,5 @@
+// Package contact is for managing the io.cozy.contacts documents and their
+// groups.
package contact
import (
@@ -82,6 +84,20 @@ func (c *Contact) PrimaryName() string {
return primary
}
+// SortingKey returns a string that can be used for sorting the contacts like
+// in the contacts app.
+func (c *Contact) SortingKey() string {
+ indexes, ok := c.Get("indexes").(map[string]interface{})
+ if !ok {
+ return c.PrimaryName()
+ }
+ str, ok := indexes["byFamilyNameGivenNameEmailCozyUrl"].(string)
+ if !ok {
+ return c.PrimaryName()
+ }
+ return str
+}
+
// PrimaryPhoneNumber returns the preferred phone number,
// or a blank string if the contact has no known phone number.
func (c *Contact) PrimaryPhoneNumber() string {
@@ -139,6 +155,34 @@ func (c *Contact) PrimaryCozyURL() string {
return url
}
+// GroupIDs returns the list of the group identifiers that this contact belongs to.
+func (c *Contact) GroupIDs() []string {
+ rels, ok := c.Get("relationships").(map[string]interface{})
+ if !ok {
+ return nil
+ }
+
+ var groupIDs []string
+
+ for _, groups := range rels {
+ if groups, ok := groups.(map[string]interface{}); ok {
+ if data, ok := groups["data"].([]interface{}); ok {
+ for _, item := range data {
+ if item, ok := item.(map[string]interface{}); ok {
+ if item["_type"] == consts.Groups {
+ if id, ok := item["_id"].(string); ok {
+ groupIDs = append(groupIDs, id)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return groupIDs
+}
+
// AddNameIfMissing can be used to add a name if there was none. We need the
// email address to ignore it if the displayName was updated with it by a
// service of the contacts application.
diff --git a/model/contact/group.go b/model/contact/group.go
new file mode 100644
index 00000000000..ee2153d9142
--- /dev/null
+++ b/model/contact/group.go
@@ -0,0 +1,74 @@
+package contact
+
+import (
+ "sort"
+
+ "github.com/cozy/cozy-stack/pkg/consts"
+ "github.com/cozy/cozy-stack/pkg/couchdb"
+ "github.com/cozy/cozy-stack/pkg/couchdb/mango"
+ "github.com/cozy/cozy-stack/pkg/prefixer"
+)
+
+// Group is a struct for a group of contacts.
+type Group struct {
+ couchdb.JSONDoc
+}
+
+// NewGroup returns a new blank group.
+func NewGroup() *Group {
+ return &Group{
+ JSONDoc: couchdb.JSONDoc{
+ M: make(map[string]interface{}),
+ },
+ }
+}
+
+// DocType returns the contact document type
+func (g *Group) DocType() string { return consts.Groups }
+
+// Name returns the name of the group
+func (g *Group) Name() string {
+ name, _ := g.Get("name").(string)
+ return name
+}
+
+// FindGroup returns the group of contacts stored in database from a given ID
+func FindGroup(db prefixer.Prefixer, groupID string) (*Group, error) {
+ doc := &Group{}
+ err := couchdb.GetDoc(db, consts.Groups, groupID, doc)
+ return doc, err
+}
+
+// GetAllContacts returns the list of contacts inside this group.
+func (g *Group) GetAllContacts(db prefixer.Prefixer) ([]*Contact, error) {
+ var docs []*Contact
+ req := &couchdb.FindRequest{
+ UseIndex: "by-groups",
+ Selector: mango.Map{
+ "relationships": map[string]interface{}{
+ "groups": map[string]interface{}{
+ "data": map[string]interface{}{
+ "$elemMatch": map[string]interface{}{
+ "_id": g.ID(),
+ "_type": consts.Groups,
+ },
+ },
+ },
+ },
+ },
+ Limit: 1000,
+ }
+ err := couchdb.FindDocs(db, consts.Contacts, req, &docs)
+ if err != nil {
+ return nil, err
+ }
+
+ // XXX I didn't find a way to make a mango request with the correct sort
+ less := func(i, j int) bool {
+ a := docs[i].SortingKey()
+ b := docs[j].SortingKey()
+ return a < b
+ }
+ sort.Slice(docs, less)
+ return docs, nil
+}
diff --git a/model/job/mem_scheduler.go b/model/job/mem_scheduler.go
index 5e73ff384fe..d6daeeec254 100644
--- a/model/job/mem_scheduler.go
+++ b/model/job/mem_scheduler.go
@@ -24,6 +24,7 @@ type memScheduler struct {
ts map[string]Trigger
thumb *ThumbnailTrigger
+ share *ShareGroupTrigger
mu sync.RWMutex
log *logger.Entry
}
@@ -51,6 +52,8 @@ func (s *memScheduler) StartScheduler(b Broker) error {
s.thumb = NewThumbnailTrigger(s.broker)
go s.thumb.Schedule()
+ s.share = NewShareGroupTrigger(s.broker)
+ go s.share.Schedule()
// XXX The memory scheduler loads the triggers from CouchDB when the stack
// is started. This can cause some stability issues when running system
@@ -117,6 +120,7 @@ func (s *memScheduler) ShutdownScheduler(ctx context.Context) error {
t.Unschedule()
}
s.thumb.Unschedule()
+ s.share.Unschedule()
fmt.Println("ok.")
return nil
}
diff --git a/model/job/redis_scheduler.go b/model/job/redis_scheduler.go
index 9d66c632883..f8b2ce9213a 100644
--- a/model/job/redis_scheduler.go
+++ b/model/job/redis_scheduler.go
@@ -59,6 +59,7 @@ type redisScheduler struct {
client redis.UniversalClient
ctx context.Context
thumb *ThumbnailTrigger
+ share *ShareGroupTrigger
closed chan struct{}
stopped chan struct{}
log *logger.Entry
@@ -99,6 +100,8 @@ func (s *redisScheduler) StartScheduler(b Broker) error {
s.startEventDispatcher()
s.thumb = NewThumbnailTrigger(s.broker)
go s.thumb.Schedule()
+ s.share = NewShareGroupTrigger(s.broker)
+ go s.share.Schedule()
go s.pollLoop()
return nil
}
@@ -252,6 +255,7 @@ func (s *redisScheduler) ShutdownScheduler(ctx context.Context) error {
fmt.Print(" shutting down redis scheduler...")
close(s.closed)
s.thumb.Unschedule()
+ s.share.Unschedule()
select {
case <-ctx.Done():
fmt.Println("failed: ", ctx.Err())
diff --git a/model/job/trigger_share_group.go b/model/job/trigger_share_group.go
new file mode 100644
index 00000000000..26ac949e7ea
--- /dev/null
+++ b/model/job/trigger_share_group.go
@@ -0,0 +1,146 @@
+package job
+
+import (
+ "github.com/cozy/cozy-stack/model/contact"
+ "github.com/cozy/cozy-stack/pkg/consts"
+ "github.com/cozy/cozy-stack/pkg/couchdb"
+ "github.com/cozy/cozy-stack/pkg/logger"
+ "github.com/cozy/cozy-stack/pkg/realtime"
+)
+
+type ShareGroupTrigger struct {
+ broker Broker
+ log *logger.Entry
+ unscheduled chan struct{}
+}
+
+// ShareGroupMessage is used for jobs on the share-group worker.
+type ShareGroupMessage struct {
+ ContactID string `json:"contact_id"`
+ GroupsAdded []string `json:"added"`
+ GroupsRemoved []string `json:"removed"`
+ BecomeInvitable bool `json:"invitable"`
+ DeletedDoc *couchdb.JSONDoc `json:"deleted_doc,omitempty"`
+}
+
+func NewShareGroupTrigger(broker Broker) *ShareGroupTrigger {
+ return &ShareGroupTrigger{
+ broker: broker,
+ log: logger.WithNamespace("scheduler"),
+ unscheduled: make(chan struct{}),
+ }
+}
+
+func (t *ShareGroupTrigger) Schedule() {
+ sub := realtime.GetHub().SubscribeFirehose()
+ defer sub.Close()
+ for {
+ select {
+ case e := <-sub.Channel:
+ if msg := t.match(e); msg != nil {
+ t.pushJob(e, msg)
+ }
+ case <-t.unscheduled:
+ return
+ }
+ }
+}
+
+func (t *ShareGroupTrigger) match(e *realtime.Event) *ShareGroupMessage {
+ if e.Doc.DocType() != consts.Contacts {
+ return nil
+ }
+ if e.Verb == realtime.EventNotify {
+ return nil
+ }
+
+ newdoc, ok := e.Doc.(*couchdb.JSONDoc)
+ if !ok {
+ return nil
+ }
+ newContact := &contact.Contact{JSONDoc: *newdoc}
+ var newgroups []string
+ if e.Verb != realtime.EventDelete {
+ newgroups = newContact.GroupIDs()
+ }
+
+ var oldgroups []string
+ invitable := false
+ olddoc, ok := e.OldDoc.(*couchdb.JSONDoc)
+ if ok {
+ oldContact := &contact.Contact{JSONDoc: *olddoc}
+ oldgroups = oldContact.GroupIDs()
+ invitable = contactIsNowInvitable(oldContact, newContact)
+ }
+
+ added := diffGroupIDs(newgroups, oldgroups)
+ removed := diffGroupIDs(oldgroups, newgroups)
+
+ if len(added) == 0 && len(removed) == 0 && !invitable {
+ return nil
+ }
+
+ msg := &ShareGroupMessage{
+ ContactID: e.Doc.ID(),
+ GroupsAdded: added,
+ GroupsRemoved: removed,
+ BecomeInvitable: invitable,
+ }
+ if e.Verb == realtime.EventDelete {
+ msg.DeletedDoc = olddoc
+ }
+ return msg
+}
+
+func diffGroupIDs(as, bs []string) []string {
+ var diff []string
+ for _, a := range as {
+ found := false
+ for _, b := range bs {
+ if a == b {
+ found = true
+ }
+ }
+ if !found {
+ diff = append(diff, a)
+ }
+ }
+ return diff
+}
+
+func contactIsNowInvitable(oldContact, newContact *contact.Contact) bool {
+ if oldURL := oldContact.PrimaryCozyURL(); oldURL != "" {
+ return false
+ }
+ if oldAddr, err := oldContact.ToMailAddress(); err == nil && oldAddr.Email != "" {
+ return false
+ }
+ if newURL := newContact.PrimaryCozyURL(); newURL != "" {
+ return true
+ }
+ if newAddr, err := newContact.ToMailAddress(); err == nil && newAddr.Email != "" {
+ return true
+ }
+ return false
+}
+
+func (t *ShareGroupTrigger) pushJob(e *realtime.Event, msg *ShareGroupMessage) {
+ log := t.log.WithField("domain", e.Domain)
+ m, err := NewMessage(msg)
+ if err != nil {
+ log.Infof("trigger share-group: cannot serialize message: %s", err)
+ return
+ }
+ req := &JobRequest{
+ WorkerType: "share-group",
+ Message: m,
+ }
+ log.Infof("trigger share-group: Pushing new job for contact %s", msg.ContactID)
+ if _, err := t.broker.PushJob(e, req); err != nil {
+ log.Errorf("trigger share-group: Could not schedule a new job: %s", err.Error())
+ }
+}
+
+func (t *ShareGroupTrigger) Unschedule() {
+ close(t.unscheduled)
+}
diff --git a/model/job/trigger_share_group_test.go b/model/job/trigger_share_group_test.go
new file mode 100644
index 00000000000..05f7997dd2a
--- /dev/null
+++ b/model/job/trigger_share_group_test.go
@@ -0,0 +1,183 @@
+package job
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/cozy/cozy-stack/pkg/consts"
+ "github.com/cozy/cozy-stack/pkg/couchdb"
+ "github.com/cozy/cozy-stack/pkg/realtime"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestShareGroupTrigger(t *testing.T) {
+ trigger := &ShareGroupTrigger{}
+
+ t.Run("The contact becomes invitable", func(t *testing.T) {
+ justName := &couchdb.JSONDoc{
+ Type: consts.Contacts,
+ M: map[string]interface{}{
+ "_id": "85507320-b157-013c-12d8-18c04daba325",
+ "_rev": "1-abcdef",
+ "fullname": "Bob",
+ },
+ }
+ msg := trigger.match(&realtime.Event{
+ Doc: justName,
+ OldDoc: nil,
+ Verb: realtime.EventCreate,
+ })
+ require.Nil(t, msg)
+
+ withAnEmail := justName.Clone().(*couchdb.JSONDoc)
+ withAnEmail.M["email"] = []interface{}{
+ map[string]interface{}{
+ "address": "bob@example.net",
+ },
+ }
+ withAnEmail.M["_rev"] = "2-abcdef"
+ msg = trigger.match(&realtime.Event{
+ Doc: withAnEmail,
+ OldDoc: justName,
+ Verb: realtime.EventUpdate,
+ })
+ require.NotNil(t, msg)
+ assert.Equal(t, msg.ContactID, "85507320-b157-013c-12d8-18c04daba325")
+ assert.Len(t, msg.GroupsAdded, 0)
+ assert.Len(t, msg.GroupsRemoved, 0)
+ assert.True(t, msg.BecomeInvitable)
+
+ withCozyURL := justName.Clone().(*couchdb.JSONDoc)
+ withCozyURL.M["cozy"] = []interface{}{
+ map[string]interface{}{
+ "url": "bob.mycozy.cloud",
+ },
+ }
+ withCozyURL.M["_rev"] = "2-abcdef"
+ msg = trigger.match(&realtime.Event{
+ Doc: withCozyURL,
+ OldDoc: justName,
+ Verb: realtime.EventUpdate,
+ })
+ require.NotNil(t, msg)
+ assert.Equal(t, msg.ContactID, "85507320-b157-013c-12d8-18c04daba325")
+ assert.Len(t, msg.GroupsAdded, 0)
+ assert.Len(t, msg.GroupsRemoved, 0)
+ assert.True(t, msg.BecomeInvitable)
+
+ both := withAnEmail.Clone().(*couchdb.JSONDoc)
+ both.M["cozy"] = []interface{}{
+ map[string]interface{}{
+ "url": "bob.mycozy.cloud",
+ },
+ }
+ both.M["_rev"] = "3-abcdef"
+ msg = trigger.match(&realtime.Event{
+ Doc: both,
+ OldDoc: withAnEmail,
+ Verb: realtime.EventUpdate,
+ })
+ assert.Nil(t, msg)
+ })
+
+ t.Run("Groups are added/removed to a contact", func(t *testing.T) {
+ noGroup := &couchdb.JSONDoc{
+ Type: consts.Contacts,
+ M: map[string]interface{}{
+ "_id": "85507320-b157-013c-12d8-18c04daba326",
+ "_rev": "1-abcdef",
+ "fullname": "Bob",
+ },
+ }
+ msg := trigger.match(&realtime.Event{
+ Doc: noGroup,
+ OldDoc: nil,
+ Verb: realtime.EventCreate,
+ })
+ require.Nil(t, msg)
+
+ updatedName := noGroup.Clone().(*couchdb.JSONDoc)
+ updatedName.M["fullname"] = "Bobby"
+ updatedName.M["_rev"] = "2-abcdef"
+ msg = trigger.match(&realtime.Event{
+ Doc: updatedName,
+ OldDoc: noGroup,
+ Verb: realtime.EventUpdate,
+ })
+ require.Nil(t, msg)
+
+ var groups map[string]interface{}
+ require.NoError(t, json.Unmarshal([]byte(`{
+ "groups": {
+ "data": [
+ {
+ "_id": "id-friends",
+ "_type": "io.cozy.contacts.groups"
+ },
+ {
+ "_id": "id-football",
+ "_type": "io.cozy.contacts.groups"
+ }
+ ]
+ }
+}`), &groups))
+ addedInGroups := updatedName.Clone().(*couchdb.JSONDoc)
+ addedInGroups.M["relationships"] = groups
+ addedInGroups.M["_rev"] = "3-abcdef"
+ msg = trigger.match(&realtime.Event{
+ Doc: addedInGroups,
+ OldDoc: updatedName,
+ Verb: realtime.EventUpdate,
+ })
+ require.NotNil(t, msg)
+ assert.Equal(t, msg.ContactID, "85507320-b157-013c-12d8-18c04daba326")
+ assert.EqualValues(t, msg.GroupsAdded, []string{"id-friends", "id-football"})
+ assert.Len(t, msg.GroupsRemoved, 0)
+ assert.False(t, msg.BecomeInvitable)
+
+ var groups2 map[string]interface{}
+ require.NoError(t, json.Unmarshal([]byte(`{
+ "groups": {
+ "data": [
+ {
+ "_id": "id-friends",
+ "_type": "io.cozy.contacts.groups"
+ }
+ ]
+ }
+}`), &groups2))
+ removedFromFootball := addedInGroups.Clone().(*couchdb.JSONDoc)
+ removedFromFootball.M["relationships"] = groups2
+ removedFromFootball.M["_rev"] = "4-abcdef"
+ msg = trigger.match(&realtime.Event{
+ Doc: removedFromFootball,
+ OldDoc: addedInGroups,
+ Verb: realtime.EventUpdate,
+ })
+ require.NotNil(t, msg)
+ assert.Equal(t, msg.ContactID, "85507320-b157-013c-12d8-18c04daba326")
+ assert.Len(t, msg.GroupsAdded, 0)
+ assert.EqualValues(t, msg.GroupsRemoved, []string{"id-football"})
+ assert.False(t, msg.BecomeInvitable)
+
+ deleted := &couchdb.JSONDoc{
+ Type: consts.Contacts,
+ M: map[string]interface{}{
+ "_id": removedFromFootball.ID(),
+ "_rev": "5-abcdef",
+ "_deleted": true,
+ },
+ }
+ msg = trigger.match(&realtime.Event{
+ Doc: deleted,
+ OldDoc: removedFromFootball,
+ Verb: realtime.EventDelete,
+ })
+ require.NotNil(t, msg)
+ assert.Equal(t, msg.ContactID, "85507320-b157-013c-12d8-18c04daba326")
+ assert.Len(t, msg.GroupsAdded, 0)
+ assert.EqualValues(t, msg.GroupsRemoved, []string{"id-friends"})
+ assert.False(t, msg.BecomeInvitable)
+ })
+}
diff --git a/model/sharing/group.go b/model/sharing/group.go
new file mode 100644
index 00000000000..a0c34b97956
--- /dev/null
+++ b/model/sharing/group.go
@@ -0,0 +1,448 @@
+package sharing
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "sort"
+ "time"
+
+ "github.com/cozy/cozy-stack/client/request"
+ "github.com/cozy/cozy-stack/model/contact"
+ "github.com/cozy/cozy-stack/model/instance"
+ "github.com/cozy/cozy-stack/model/job"
+ "github.com/cozy/cozy-stack/model/permission"
+ "github.com/cozy/cozy-stack/pkg/consts"
+ "github.com/cozy/cozy-stack/pkg/couchdb"
+ "github.com/cozy/cozy-stack/pkg/jsonapi"
+ multierror "github.com/hashicorp/go-multierror"
+ "github.com/labstack/echo/v4"
+)
+
+// Group contains the information about a group of members of the sharing.
+type Group struct {
+ ID string `json:"id,omitempty"`
+ Name string `json:"name"`
+ AddedBy int `json:"addedBy"` // The index of the member who added the group
+ ReadOnly bool `json:"read_only"`
+ Revoked bool `json:"revoked,omitempty"`
+}
+
+// AddGroup adds a group of contacts identified by its ID to the members of the
+// sharing.
+func (s *Sharing) AddGroup(inst *instance.Instance, groupID string, readOnly bool) error {
+ group, err := contact.FindGroup(inst, groupID)
+ if err != nil {
+ return err
+ }
+ contacts, err := group.GetAllContacts(inst)
+ if err != nil {
+ return err
+ }
+
+ groupIndex := len(s.Groups)
+ for _, contact := range contacts {
+ m := buildMemberFromContact(contact, readOnly)
+ m.OnlyInGroups = true
+ _, idx, err := s.addMember(inst, m)
+ if err != nil {
+ return err
+ }
+ pos := sort.SearchInts(s.Members[idx].Groups, groupIndex)
+ if pos == len(s.Members[idx].Groups) || s.Members[idx].Groups[pos] != groupIndex {
+ s.Members[idx].Groups = append(s.Members[idx].Groups, groupIndex)
+ sort.Ints(s.Members[idx].Groups)
+ }
+ }
+
+ g := Group{ID: groupID, Name: group.Name(), AddedBy: 0, ReadOnly: readOnly}
+ s.Groups = append(s.Groups, g)
+ return nil
+}
+
+// RevokeGroup revokes a group of members on the sharer Cozy. After that, the
+// sharing is disabled if there are no longer any active recipient.
+func (s *Sharing) RevokeGroup(inst *instance.Instance, index int) error {
+ if !s.Owner {
+ return ErrInvalidSharing
+ }
+
+ var errm error
+ for i, m := range s.Members {
+ inGroup := false
+ for _, idx := range m.Groups {
+ if idx == index {
+ inGroup = true
+ }
+ }
+ if !inGroup {
+ continue
+ }
+ if len(m.Groups) == 1 {
+ s.Members[i].Groups = nil
+ } else {
+ var groups []int
+ for _, idx := range m.Groups {
+ if idx != index {
+ groups = append(groups, idx)
+ }
+ }
+ s.Members[i].Groups = groups
+ }
+ if m.OnlyInGroups && len(s.Members[i].Groups) == 0 {
+ if err := s.RevokeRecipient(inst, i); err != nil {
+ errm = multierror.Append(errm, err)
+ }
+ }
+ }
+
+ s.Groups[index].Revoked = true
+ if err := couchdb.UpdateDoc(inst, s); err != nil {
+ errm = multierror.Append(errm, err)
+ }
+ return errm
+}
+
+// UpdateGroups is called when a contact is added or removed to a group. It
+// finds the sharings for this group, and adds or removes the member to those
+// sharings.
+func UpdateGroups(inst *instance.Instance, msg job.ShareGroupMessage) error {
+ var c *contact.Contact
+ if msg.DeletedDoc != nil {
+ c = &contact.Contact{JSONDoc: *msg.DeletedDoc}
+ } else {
+ doc, err := contact.Find(inst, msg.ContactID)
+ if err != nil {
+ return err
+ }
+ c = doc
+ }
+
+ sharings, err := FindActive(inst)
+ if err != nil {
+ return err
+ }
+
+ var errm error
+ for _, s := range sharings {
+ for _, added := range msg.GroupsAdded {
+ for idx, group := range s.Groups {
+ if group.ID == added {
+ if s.Owner {
+ if err := s.AddMemberToGroup(inst, idx, c); err != nil {
+ errm = multierror.Append(errm, err)
+ }
+ } else {
+ if err := s.DelegateAddMemberToGroup(inst, idx, c); err != nil {
+ errm = multierror.Append(errm, err)
+ }
+ }
+ }
+ }
+ }
+ for _, removed := range msg.GroupsRemoved {
+ for idx, group := range s.Groups {
+ if group.ID == removed {
+ if s.Owner {
+ if err := s.RemoveMemberFromGroup(inst, idx, c); err != nil {
+ errm = multierror.Append(errm, err)
+ }
+ } else {
+ if err := s.DelegateRemoveMemberFromGroup(inst, idx, c); err != nil {
+ errm = multierror.Append(errm, err)
+ }
+ }
+ }
+ }
+ }
+
+ if msg.BecomeInvitable {
+ if err := s.AddInvitationForContact(inst, c); err != nil {
+ errm = multierror.Append(errm, err)
+ }
+ }
+ }
+
+ return errm
+}
+
+// AddMemberToGroup adds a contact to a sharing via a group (on the owner).
+func (s *Sharing) AddMemberToGroup(inst *instance.Instance, groupIndex int, contact *contact.Contact) error {
+ readOnly := s.Groups[groupIndex].ReadOnly
+ m := buildMemberFromContact(contact, readOnly)
+ m.OnlyInGroups = true
+ _, idx, err := s.addMember(inst, m)
+ if err != nil {
+ return err
+ }
+ s.Members[idx].Groups = append(s.Members[idx].Groups, groupIndex)
+ sort.Ints(s.Members[idx].Groups)
+
+ // We can ignore the error as we will try again to save the sharing
+ // after sending the invitation.
+ _ = couchdb.UpdateDoc(inst, s)
+ var perms *permission.Permission
+ if s.PreviewPath != "" {
+ if perms, err = s.CreatePreviewPermissions(inst); err != nil {
+ return err
+ }
+ }
+ if err = s.SendInvitations(inst, perms); err != nil {
+ return err
+ }
+ cloned := s.Clone().(*Sharing)
+ go cloned.NotifyRecipients(inst, nil)
+ return nil
+}
+
+// DelegateAddMemberToGroup adds a contact to a sharing via a group (on a recipient).
+func (s *Sharing) DelegateAddMemberToGroup(inst *instance.Instance, groupIndex int, contact *contact.Contact) error {
+ readOnly := s.Groups[groupIndex].ReadOnly
+ m := buildMemberFromContact(contact, readOnly)
+ m.OnlyInGroups = true
+ m.Groups = []int{groupIndex}
+ api := &APIDelegateAddContacts{
+ sid: s.ID(),
+ members: []Member{m},
+ }
+ return s.SendDelegated(inst, api)
+}
+
+// RemoveMemberFromGroup removes a member of a group.
+func (s *Sharing) RemoveMemberFromGroup(inst *instance.Instance, groupIndex int, contact *contact.Contact) error {
+ var email string
+ if addr, err := contact.ToMailAddress(); err == nil {
+ email = addr.Email
+ }
+ cozyURL := contact.PrimaryCozyURL()
+
+ matchMember := func(m Member) bool {
+ if m.Email != "" && m.Email == email {
+ return true
+ }
+ if m.Instance != "" && m.Instance == cozyURL {
+ return true
+ }
+ return false
+ }
+
+ for i, m := range s.Members {
+ if !matchMember(m) {
+ continue
+ }
+
+ var groups []int
+ for _, idx := range m.Groups {
+ if idx != groupIndex {
+ groups = append(groups, idx)
+ }
+ }
+ s.Members[i].Groups = groups
+
+ if m.OnlyInGroups && len(s.Members[i].Groups) == 0 {
+ return s.RevokeRecipient(inst, i)
+ } else {
+ return couchdb.UpdateDoc(inst, s)
+ }
+ }
+
+ return nil
+}
+
+// DelegateRemoveMemberFromGroup removes a member from a sharing group (on a recipient).
+func (s *Sharing) DelegateRemoveMemberFromGroup(inst *instance.Instance, groupIndex int, contact *contact.Contact) error {
+ var email string
+ if addr, err := contact.ToMailAddress(); err == nil {
+ email = addr.Email
+ }
+ cozyURL := contact.PrimaryCozyURL()
+
+ for i, m := range s.Members {
+ if m.Email != "" && m.Email == email {
+ return s.SendRemoveMemberFromGroup(inst, groupIndex, i)
+ }
+ if m.Instance != "" && m.Instance == cozyURL {
+ return s.SendRemoveMemberFromGroup(inst, groupIndex, i)
+ }
+ }
+ return ErrMemberNotFound
+}
+
+func (s *Sharing) SendRemoveMemberFromGroup(inst *instance.Instance, groupIndex, memberIndex int) error {
+ u, err := url.Parse(s.Members[0].Instance)
+ if err != nil {
+ return err
+ }
+ c := &s.Credentials[0]
+ if c.AccessToken == nil {
+ return ErrInvalidSharing
+ }
+ opts := &request.Options{
+ Method: http.MethodDelete,
+ Scheme: u.Scheme,
+ Domain: u.Host,
+ Path: fmt.Sprintf("/sharings/%s/groups/%d/%d", s.SID, groupIndex, memberIndex),
+ Headers: request.Headers{
+ echo.HeaderAuthorization: "Bearer " + c.AccessToken.AccessToken,
+ },
+ ParseError: ParseRequestError,
+ }
+ res, err := request.Req(opts)
+ if res != nil && res.StatusCode/100 == 4 {
+ res, err = RefreshToken(inst, err, s, &s.Members[0], c, opts, nil)
+ }
+ if err != nil {
+ return err
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusNoContent {
+ return ErrInternalServerError
+ }
+ return nil
+}
+
+func (s *Sharing) DelegatedRemoveMemberFromGroup(inst *instance.Instance, groupIndex, memberIndex int) error {
+ var groups []int
+ for _, idx := range s.Members[memberIndex].Groups {
+ if idx != groupIndex {
+ groups = append(groups, idx)
+ }
+ }
+ s.Members[memberIndex].Groups = groups
+
+ if s.Members[memberIndex].OnlyInGroups && len(s.Members[memberIndex].Groups) == 0 {
+ return s.RevokeRecipient(inst, memberIndex)
+ } else {
+ return couchdb.UpdateDoc(inst, s)
+ }
+}
+
+func (s *Sharing) AddInvitationForContact(inst *instance.Instance, contact *contact.Contact) error {
+ var email string
+ if addr, err := contact.ToMailAddress(); err == nil {
+ email = addr.Email
+ }
+ cozyURL := contact.PrimaryCozyURL()
+ name := contact.PrimaryName()
+ groupIDs := contact.GroupIDs()
+
+ matchMember := func(m Member) bool {
+ if m.Name != name {
+ return false
+ }
+ for _, gid := range groupIDs {
+ for _, g := range m.Groups {
+ if s.Groups[g].ID == gid {
+ return true
+ }
+ }
+ }
+ return false
+ }
+
+ for i, m := range s.Members {
+ if i == 0 || m.Status != MemberStatusMailNotSent {
+ continue
+ }
+ if !matchMember(m) {
+ continue
+ }
+ m.Email = email
+ m.Instance = cozyURL
+ s.Members[i] = m
+
+ if !s.Owner {
+ return s.DelegateAddInvitation(inst, i)
+ }
+
+ // We can ignore the error as we will try again to save the sharing
+ // after sending the invitation.
+ _ = couchdb.UpdateDoc(inst, s)
+ var perms *permission.Permission
+ var err error
+ if s.PreviewPath != "" {
+ if perms, err = s.CreatePreviewPermissions(inst); err != nil {
+ return err
+ }
+ }
+ if err = s.SendInvitations(inst, perms); err != nil {
+ return err
+ }
+ cloned := s.Clone().(*Sharing)
+ go cloned.NotifyRecipients(inst, nil)
+ return nil
+ }
+
+ return nil
+}
+
+func (s *Sharing) DelegateAddInvitation(inst *instance.Instance, memberIndex int) error {
+ body, err := json.Marshal(map[string]interface{}{
+ "data": map[string]interface{}{
+ "type": consts.SharingsMembers,
+ "attributes": s.Members[memberIndex],
+ },
+ })
+ if err != nil {
+ return err
+ }
+ u, err := url.Parse(s.Members[0].Instance)
+ if err != nil {
+ return err
+ }
+ c := &s.Credentials[0]
+ if c.AccessToken == nil {
+ return ErrInvalidSharing
+ }
+ opts := &request.Options{
+ Method: http.MethodPost,
+ Scheme: u.Scheme,
+ Domain: u.Host,
+ Path: fmt.Sprintf("/sharings/%s/members/%d/invitation", s.ID(), memberIndex),
+ Headers: request.Headers{
+ echo.HeaderAccept: echo.MIMEApplicationJSON,
+ echo.HeaderContentType: jsonapi.ContentType,
+ echo.HeaderAuthorization: "Bearer " + c.AccessToken.AccessToken,
+ },
+ Body: bytes.NewReader(body),
+ ParseError: ParseRequestError,
+ }
+ res, err := request.Req(opts)
+ if res != nil && res.StatusCode/100 == 4 {
+ res, err = RefreshToken(inst, err, s, &s.Members[0], c, opts, body)
+ }
+ if err != nil {
+ return err
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusOK {
+ return ErrInternalServerError
+ }
+ var states map[string]string
+ if err = json.NewDecoder(res.Body).Decode(&states); err != nil {
+ return err
+ }
+
+ // We can have conflicts when updating the sharing document, so we are
+ // retrying when it is the case.
+ maxRetries := 3
+ i := 0
+ for {
+ s.Members[i].Status = MemberStatusReady
+ if err := couchdb.UpdateDoc(inst, s); err == nil {
+ break
+ }
+ i++
+ if i > maxRetries {
+ return err
+ }
+ time.Sleep(1 * time.Second)
+ s, err = FindSharing(inst, s.SID)
+ if err != nil {
+ return err
+ }
+ }
+ return s.SendInvitationsToMembers(inst, []Member{s.Members[memberIndex]}, states)
+}
diff --git a/model/sharing/group_test.go b/model/sharing/group_test.go
new file mode 100644
index 00000000000..90d1e966aa5
--- /dev/null
+++ b/model/sharing/group_test.go
@@ -0,0 +1,295 @@
+package sharing
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/cozy/cozy-stack/model/contact"
+ "github.com/cozy/cozy-stack/model/instance"
+ "github.com/cozy/cozy-stack/model/instance/lifecycle"
+ "github.com/cozy/cozy-stack/model/job"
+ "github.com/cozy/cozy-stack/pkg/config/config"
+ "github.com/cozy/cozy-stack/pkg/consts"
+ "github.com/cozy/cozy-stack/pkg/couchdb"
+ "github.com/cozy/cozy-stack/tests/testutils"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ _ "github.com/cozy/cozy-stack/worker/mails"
+)
+
+func TestGroups(t *testing.T) {
+ if testing.Short() {
+ t.Skip("an instance is required for this test: test skipped due to the use of --short flag")
+ }
+
+ config.UseTestFile(t)
+ testutils.NeedCouchdb(t)
+ setup := testutils.NewSetup(t, t.Name())
+ inst := setup.GetTestInstance(&lifecycle.Options{
+ Email: "alice@example.net",
+ PublicName: "Alice",
+ })
+
+ t.Run("RevokeGroup", func(t *testing.T) {
+ now := time.Now()
+ friends := createGroup(t, inst, "Friends")
+ football := createGroup(t, inst, "Football")
+ bob := createContactInGroups(t, inst, "Bob", []string{friends.ID()})
+ _ = createContactInGroups(t, inst, "Charlie", []string{friends.ID(), football.ID()})
+ _ = createContactInGroups(t, inst, "Dave", []string{football.ID()})
+
+ s := &Sharing{
+ Active: true,
+ Owner: true,
+ Description: "Just testing groups",
+ Members: []Member{
+ {Status: MemberStatusOwner, Name: "Alice", Email: "alice@cozy.tools"},
+ },
+ Rules: []Rule{
+ {
+ Title: "Just testing groups",
+ DocType: "io.cozy.tests",
+ Values: []string{uuidv7()},
+ },
+ },
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ require.NoError(t, couchdb.CreateDoc(inst, s))
+ require.NoError(t, s.AddContact(inst, bob.ID(), false))
+ require.NoError(t, s.AddGroup(inst, friends.ID(), false))
+ require.NoError(t, s.AddGroup(inst, football.ID(), false))
+
+ require.Len(t, s.Members, 4)
+ require.Equal(t, s.Members[0].Name, "Alice")
+ require.Equal(t, s.Members[1].Name, "Bob")
+ assert.False(t, s.Members[1].OnlyInGroups)
+ assert.Equal(t, s.Members[1].Groups, []int{0})
+ require.Equal(t, s.Members[2].Name, "Charlie")
+ assert.True(t, s.Members[2].OnlyInGroups)
+ assert.Equal(t, s.Members[2].Groups, []int{0, 1})
+ require.Equal(t, s.Members[3].Name, "Dave")
+ assert.True(t, s.Members[3].OnlyInGroups)
+ assert.Equal(t, s.Members[3].Groups, []int{1})
+
+ require.Len(t, s.Groups, 2)
+ require.Equal(t, s.Groups[0].Name, "Friends")
+ assert.False(t, s.Groups[0].Revoked)
+ require.Equal(t, s.Groups[1].Name, "Football")
+ assert.False(t, s.Groups[1].Revoked)
+
+ require.NoError(t, s.RevokeGroup(inst, 1)) // Revoke the football group
+
+ require.Len(t, s.Members, 4)
+ assert.NotEqual(t, s.Members[1].Status, MemberStatusRevoked)
+ assert.Equal(t, s.Members[1].Groups, []int{0})
+ assert.NotEqual(t, s.Members[2].Status, MemberStatusRevoked)
+ assert.Equal(t, s.Members[2].Groups, []int{0})
+ assert.Equal(t, s.Members[3].Status, MemberStatusRevoked)
+ assert.Empty(t, s.Members[3].Groups)
+
+ require.Len(t, s.Groups, 2)
+ assert.False(t, s.Groups[0].Revoked)
+ assert.True(t, s.Groups[1].Revoked)
+
+ require.NoError(t, s.RevokeGroup(inst, 0)) // Revoke the fiends group
+
+ require.Len(t, s.Members, 4)
+ assert.NotEqual(t, s.Members[1].Status, MemberStatusRevoked)
+ assert.Empty(t, s.Members[1].Groups)
+ assert.Equal(t, s.Members[2].Status, MemberStatusRevoked)
+ assert.Empty(t, s.Members[2].Groups)
+ assert.Equal(t, s.Members[3].Status, MemberStatusRevoked)
+ assert.Empty(t, s.Members[3].Groups)
+
+ require.Len(t, s.Groups, 2)
+ assert.True(t, s.Groups[0].Revoked)
+ assert.True(t, s.Groups[1].Revoked)
+ })
+
+ t.Run("UpdateGroups", func(t *testing.T) {
+ now := time.Now()
+ friends := createGroup(t, inst, "Friends")
+ football := createGroup(t, inst, "Football")
+ _ = createContactInGroups(t, inst, "Bob", []string{friends.ID()})
+ charlie := createContactInGroups(t, inst, "Charlie", []string{football.ID()})
+ dave := createContactWithoutEmail(t, inst, "Dave", []string{football.ID()})
+
+ s := &Sharing{
+ Active: true,
+ Owner: true,
+ Description: "Just testing groups",
+ Members: []Member{
+ {Status: MemberStatusOwner, Name: "Alice", Email: "alice@cozy.tools"},
+ },
+ Rules: []Rule{
+ {
+ Title: "Just testing groups",
+ DocType: "io.cozy.tests",
+ Values: []string{uuidv7()},
+ },
+ },
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ require.NoError(t, s.AddGroup(inst, friends.ID(), false))
+ require.NoError(t, s.AddGroup(inst, football.ID(), false))
+ perms, err := s.Create(inst)
+ require.NoError(t, err)
+ require.NoError(t, s.SendInvitations(inst, perms))
+ sid := s.ID()
+
+ require.Len(t, s.Members, 4)
+ require.Equal(t, s.Members[0].Name, "Alice")
+ require.Equal(t, s.Members[1].Name, "Bob")
+ assert.True(t, s.Members[1].OnlyInGroups)
+ assert.Equal(t, s.Members[1].Groups, []int{0})
+ assert.Equal(t, s.Members[1].Status, MemberStatusPendingInvitation)
+ require.Equal(t, s.Members[2].Name, "Charlie")
+ assert.True(t, s.Members[2].OnlyInGroups)
+ assert.Equal(t, s.Members[2].Groups, []int{1})
+ assert.Equal(t, s.Members[2].Status, MemberStatusPendingInvitation)
+ require.Equal(t, s.Members[3].Name, "Dave")
+ assert.True(t, s.Members[3].OnlyInGroups)
+ assert.Equal(t, s.Members[3].Groups, []int{1})
+ assert.Equal(t, s.Members[3].Status, MemberStatusMailNotSent)
+
+ require.Len(t, s.Groups, 2)
+ require.Equal(t, s.Groups[0].Name, "Friends")
+ assert.False(t, s.Groups[0].Revoked)
+ require.Equal(t, s.Groups[1].Name, "Football")
+ assert.False(t, s.Groups[1].Revoked)
+
+ // Charlie is added to the friends group
+ msg1 := job.ShareGroupMessage{
+ ContactID: charlie.ID(),
+ GroupsAdded: []string{friends.ID()},
+ }
+ require.NoError(t, UpdateGroups(inst, msg1))
+
+ s = &Sharing{}
+ require.NoError(t, couchdb.GetDoc(inst, consts.Sharings, sid, s))
+
+ require.Len(t, s.Members, 4)
+ require.Equal(t, s.Members[0].Name, "Alice")
+ require.Equal(t, s.Members[1].Name, "Bob")
+ assert.True(t, s.Members[1].OnlyInGroups)
+ assert.Equal(t, s.Members[1].Groups, []int{0})
+ require.Equal(t, s.Members[2].Name, "Charlie")
+ assert.True(t, s.Members[2].OnlyInGroups)
+ assert.Equal(t, s.Members[2].Groups, []int{0, 1})
+ require.Equal(t, s.Members[3].Name, "Dave")
+ assert.True(t, s.Members[3].OnlyInGroups)
+ assert.Equal(t, s.Members[3].Groups, []int{1})
+
+ // Charlie is removed of the football group
+ msg2 := job.ShareGroupMessage{
+ ContactID: charlie.ID(),
+ GroupsRemoved: []string{football.ID()},
+ }
+ require.NoError(t, UpdateGroups(inst, msg2))
+
+ s = &Sharing{}
+ require.NoError(t, couchdb.GetDoc(inst, consts.Sharings, sid, s))
+
+ require.Len(t, s.Members, 4)
+ require.Equal(t, s.Members[0].Name, "Alice")
+ require.Equal(t, s.Members[1].Name, "Bob")
+ assert.True(t, s.Members[1].OnlyInGroups)
+ assert.Equal(t, s.Members[1].Groups, []int{0})
+ require.Equal(t, s.Members[2].Name, "Charlie")
+ assert.True(t, s.Members[2].OnlyInGroups)
+ assert.Equal(t, s.Members[2].Groups, []int{0})
+ require.Equal(t, s.Members[3].Name, "Dave")
+ assert.True(t, s.Members[3].OnlyInGroups)
+ assert.Equal(t, s.Members[3].Groups, []int{1})
+
+ // Email address is added for Dave, and an invitation can now be sent
+ addEmailToContact(t, inst, dave)
+ msg3 := job.ShareGroupMessage{
+ ContactID: dave.ID(),
+ BecomeInvitable: true,
+ }
+ require.NoError(t, UpdateGroups(inst, msg3))
+
+ s = &Sharing{}
+ require.NoError(t, couchdb.GetDoc(inst, consts.Sharings, sid, s))
+
+ require.Len(t, s.Members, 4)
+ require.Equal(t, s.Members[0].Name, "Alice")
+ require.Equal(t, s.Members[1].Name, "Bob")
+ assert.True(t, s.Members[1].OnlyInGroups)
+ assert.Equal(t, s.Members[1].Groups, []int{0})
+ assert.Equal(t, s.Members[1].Status, MemberStatusPendingInvitation)
+ require.Equal(t, s.Members[2].Name, "Charlie")
+ assert.True(t, s.Members[2].OnlyInGroups)
+ assert.Equal(t, s.Members[2].Groups, []int{0})
+ assert.Equal(t, s.Members[1].Status, MemberStatusPendingInvitation)
+ require.Equal(t, s.Members[3].Name, "Dave")
+ assert.True(t, s.Members[3].OnlyInGroups)
+ assert.Equal(t, s.Members[3].Groups, []int{1})
+ assert.Equal(t, s.Members[3].Status, MemberStatusPendingInvitation)
+ })
+}
+
+func createGroup(t *testing.T, inst *instance.Instance, name string) *contact.Group {
+ t.Helper()
+ g := contact.NewGroup()
+ g.M["name"] = name
+ require.NoError(t, couchdb.CreateDoc(inst, g))
+ return g
+}
+
+func createContactInGroups(t *testing.T, inst *instance.Instance, contactName string, groupIDs []string) *contact.Contact {
+ t.Helper()
+ email := strings.ToLower(contactName) + "@cozy.tools"
+ mail := map[string]interface{}{"address": email}
+
+ var groups []interface{}
+ for _, id := range groupIDs {
+ groups = append(groups, map[string]interface{}{
+ "_id": id,
+ "_type": consts.Groups,
+ })
+ }
+
+ c := contact.New()
+ c.M["fullname"] = contactName
+ c.M["email"] = []interface{}{mail}
+ c.M["relationships"] = map[string]interface{}{
+ "groups": map[string]interface{}{"data": groups},
+ }
+ require.NoError(t, couchdb.CreateDoc(inst, c))
+ return c
+}
+
+func createContactWithoutEmail(t *testing.T, inst *instance.Instance, contactName string, groupIDs []string) *contact.Contact {
+ t.Helper()
+
+ var groups []interface{}
+ for _, id := range groupIDs {
+ groups = append(groups, map[string]interface{}{
+ "_id": id,
+ "_type": consts.Groups,
+ })
+ }
+
+ c := contact.New()
+ c.M["fullname"] = contactName
+ c.M["relationships"] = map[string]interface{}{
+ "groups": map[string]interface{}{"data": groups},
+ }
+ require.NoError(t, couchdb.CreateDoc(inst, c))
+ return c
+}
+
+func addEmailToContact(t *testing.T, inst *instance.Instance, c *contact.Contact) {
+ t.Helper()
+
+ email := strings.ToLower(c.PrimaryName()) + "@cozy.tools"
+ mail := map[string]interface{}{"address": email}
+ c.M["email"] = []interface{}{mail}
+ require.NoError(t, couchdb.UpdateDoc(inst, c))
+}
diff --git a/model/sharing/invitation.go b/model/sharing/invitation.go
index a95abebcdb3..7e657bdcc3b 100644
--- a/model/sharing/invitation.go
+++ b/model/sharing/invitation.go
@@ -46,6 +46,9 @@ func (s *Sharing) SendInvitations(inst *instance.Instance, perms *permission.Per
}
}
if m.Email == "" {
+ if len(m.Groups) > 0 {
+ return nil
+ }
return ErrInvitationNotSent
}
if err := m.SendMail(inst, s, sharer, desc, link); err != nil {
diff --git a/model/sharing/member.go b/model/sharing/member.go
index c8403fe7c46..0fdb0104b84 100644
--- a/model/sharing/member.go
+++ b/model/sharing/member.go
@@ -60,12 +60,14 @@ func maxNumberOfMembers(inst *instance.Instance) int {
// Member contains the information about a recipient (or the sharer) for a sharing
type Member struct {
- Status string `json:"status"`
- Name string `json:"name,omitempty"`
- PublicName string `json:"public_name,omitempty"`
- Email string `json:"email,omitempty"`
- Instance string `json:"instance,omitempty"`
- ReadOnly bool `json:"read_only,omitempty"`
+ Status string `json:"status"`
+ Name string `json:"name,omitempty"`
+ PublicName string `json:"public_name,omitempty"`
+ Email string `json:"email,omitempty"`
+ Instance string `json:"instance,omitempty"`
+ ReadOnly bool `json:"read_only,omitempty"`
+ OnlyInGroups bool `json:"only_in_groups,omitempty"` // False if the member has been added as an io.cozy.contacts
+ Groups []int `json:"groups,omitempty"` // The indexes of the groups a member is part of
}
// PrimaryName returns the main name of this member
@@ -89,6 +91,14 @@ func (m *Member) InstanceHost() string {
return u.Host
}
+// Same returns true if the two members are the same.
+func (m *Member) Same(other Member) bool {
+ return m.Name == other.Name &&
+ m.PublicName == other.PublicName &&
+ m.Email == other.Email &&
+ m.Instance == other.Instance
+}
+
// Credentials is the struct with the secret stuff used for authentication &
// authorization.
type Credentials struct {
@@ -107,10 +117,15 @@ type Credentials struct {
InboundClientID string `json:"inbound_client_id,omitempty"`
}
-// AddContacts adds a list of contacts on the sharer cozy
-func (s *Sharing) AddContacts(inst *instance.Instance, contactIDs map[string]bool) error {
- for id, ro := range contactIDs {
- if err := s.AddContact(inst, id, ro); err != nil {
+// AddGroupsAndContacts adds a list of contacts on the sharer cozy
+func (s *Sharing) AddGroupsAndContacts(inst *instance.Instance, groupIDs, contactIDs []string, readOnly bool) error {
+ for _, id := range contactIDs {
+ if err := s.AddContact(inst, id, readOnly); err != nil {
+ return err
+ }
+ }
+ for _, id := range groupIDs {
+ if err := s.AddGroup(inst, id, readOnly); err != nil {
return err
}
}
@@ -136,6 +151,15 @@ func (s *Sharing) AddContact(inst *instance.Instance, contactID string, readOnly
if err != nil {
return err
}
+ m := buildMemberFromContact(c, readOnly)
+ if m.Email == "" && m.Instance == "" {
+ return contact.ErrNoMailAddress
+ }
+ _, _, err = s.addMember(inst, m)
+ return err
+}
+
+func buildMemberFromContact(c *contact.Contact, readOnly bool) Member {
var name, email string
cozyURL := c.PrimaryCozyURL()
addr, err := c.ToMailAddress()
@@ -143,39 +167,37 @@ func (s *Sharing) AddContact(inst *instance.Instance, contactID string, readOnly
name = addr.Name
email = addr.Email
} else {
- if cozyURL == "" {
- return err
- }
name = c.PrimaryName()
}
- m := Member{
+ return Member{
Status: MemberStatusMailNotSent,
Name: name,
Email: email,
Instance: cozyURL,
ReadOnly: readOnly,
}
- _, err = s.addMember(inst, m)
- return err
}
-func (s *Sharing) addMember(inst *instance.Instance, m Member) (string, error) {
+// addMember adds a member to the members of the sharing if they are not yet in
+// this list, and also adds credentials for them. It returns the state
+// parameter of the credentials if added, and the index of the member.
+func (s *Sharing) addMember(inst *instance.Instance, m Member) (string, int, error) {
idx := -1
for i, member := range s.Members {
if i == 0 {
continue // Skip the owner
}
var found bool
- if m.Email == "" {
- found = m.Instance == member.Instance
- } else {
+ if m.Email != "" {
found = m.Email == member.Email
+ } else if m.Instance != "" {
+ found = m.Instance == member.Instance
}
if !found {
continue
}
if member.Status == MemberStatusReady {
- return "", nil
+ return "", i, nil
}
idx = i
s.Members[i].Status = m.Status
@@ -186,7 +208,7 @@ func (s *Sharing) addMember(inst *instance.Instance, m Member) (string, error) {
}
if idx < 1 {
if len(s.Members) >= maxNumberOfMembers(inst) {
- return "", ErrTooManyMembers
+ return "", -1, ErrTooManyMembers
}
s.Members = append(s.Members, m)
}
@@ -197,10 +219,11 @@ func (s *Sharing) addMember(inst *instance.Instance, m Member) (string, error) {
}
if idx < 1 {
s.Credentials = append(s.Credentials, creds)
+ idx = len(s.Credentials)
} else {
s.Credentials[idx-1] = creds
}
- return creds.State, nil
+ return creds.State, idx, nil
}
// APIDelegateAddContacts is used to serialize a request to add contacts to
@@ -208,6 +231,7 @@ func (s *Sharing) addMember(inst *instance.Instance, m Member) (string, error) {
type APIDelegateAddContacts struct {
sid string
members []Member
+ groups []Group
}
// ID returns the sharing qualified identifier
@@ -242,18 +266,22 @@ func (a *APIDelegateAddContacts) Relationships() jsonapi.RelationshipMap {
"recipients": jsonapi.Relationship{
Data: a.members,
},
+ "groups": jsonapi.Relationship{
+ Data: a.groups,
+ },
}
}
var _ jsonapi.Object = (*APIDelegateAddContacts)(nil)
-// DelegateAddContacts adds a list of contacts on a recipient cozy. Part of
-// the work is delegated to owner cozy, but the invitation mail is still sent
-// from the recipient cozy.
-func (s *Sharing) DelegateAddContacts(inst *instance.Instance, contactIDs map[string]bool) error {
+// DelegateAddContactsAndGroups adds a list of contacts and groups on a
+// recipient cozy. Part of the work is delegated to owner cozy, but the
+// invitation mail is still sent from the recipient cozy.
+func (s *Sharing) DelegateAddContactsAndGroups(inst *instance.Instance, groupIDs, contactIDs []string, readOnly bool) error {
api := &APIDelegateAddContacts{}
api.sid = s.SID
- for id, ro := range contactIDs {
+
+ for _, id := range contactIDs {
c, err := contact.Find(inst, id)
if err != nil {
return err
@@ -275,10 +303,38 @@ func (s *Sharing) DelegateAddContacts(inst *instance.Instance, contactIDs map[st
Name: name,
Email: email,
Instance: cozyURL,
- ReadOnly: ro,
+ ReadOnly: readOnly,
}
api.members = append(api.members, m)
}
+
+ for _, groupID := range groupIDs {
+ group, err := contact.FindGroup(inst, groupID)
+ if err != nil {
+ return err
+ }
+ g := Group{ID: groupID, Name: group.Name(), ReadOnly: readOnly}
+ api.groups = append(api.groups, g)
+
+ contacts, err := group.GetAllContacts(inst)
+ if err != nil {
+ return err
+ }
+ groupIndex := len(s.Groups)
+ for _, contact := range contacts {
+ m := buildMemberFromContact(contact, readOnly)
+ m.Groups = []int{groupIndex}
+ m.OnlyInGroups = true
+ api.members = append(api.members, m)
+ }
+ }
+
+ return s.SendDelegated(inst, api)
+}
+
+// SendDelegated calls the delegated endpoint on the sharer to adds
+// contacts/groups.
+func (s *Sharing) SendDelegated(inst *instance.Instance, api *APIDelegateAddContacts) error {
data, err := jsonapi.MarshalObject(api)
if err != nil {
return err
@@ -370,18 +426,12 @@ func (s *Sharing) DelegateAddContacts(inst *instance.Instance, contactIDs map[st
// AddDelegatedContact adds a contact on the owner cozy, but for a contact from
// a recipient (open_sharing: true only)
-func (s *Sharing) AddDelegatedContact(inst *instance.Instance, email, instanceURL string, readOnly bool) (string, error) {
- status := MemberStatusPendingInvitation
- if instanceURL != "" {
- status = MemberStatusMailNotSent
+func (s *Sharing) AddDelegatedContact(inst *instance.Instance, m Member) (string, error) {
+ m.Status = MemberStatusPendingInvitation
+ if m.Instance != "" || m.Email == "" {
+ m.Status = MemberStatusMailNotSent
}
- m := Member{
- Status: status,
- Email: email,
- Instance: instanceURL,
- ReadOnly: readOnly,
- }
- state, err := s.addMember(inst, m)
+ state, _, err := s.addMember(inst, m)
if err != nil {
return "", err
}
@@ -436,9 +486,9 @@ func (s *Sharing) DelegateDiscovery(inst *instance.Instance, state, cozyURL stri
return success["redirect"], nil
}
-// UpdateRecipients updates the list of recipients
-func (s *Sharing) UpdateRecipients(inst *instance.Instance, members []Member) error {
- for i, m := range members {
+// UpdateRecipients updates the lists of members and groups.
+func (s *Sharing) UpdateRecipients(inst *instance.Instance, params PutRecipientsParams) error {
+ for i, m := range params.Members {
if i >= len(s.Members) {
s.Members = append(s.Members, Member{})
}
@@ -452,6 +502,7 @@ func (s *Sharing) UpdateRecipients(inst *instance.Instance, members []Member) er
s.Members[i].Status = m.Status
s.Members[i].ReadOnly = m.ReadOnly
}
+ s.Groups = params.Groups
return couchdb.UpdateDoc(inst, s)
}
@@ -571,14 +622,14 @@ func (s *Sharing) FindMemberByInboundClientID(clientID string) (*Member, error)
func (s *Sharing) FindCredentials(m *Member) *Credentials {
if s.Owner {
for i, member := range s.Members {
- if i > 0 && *m == member {
+ if i > 0 && m.Same(member) {
return &s.Credentials[i-1]
}
}
return nil
}
- if *m == s.Members[0] {
+ if m.Same(s.Members[0]) {
return &s.Credentials[0]
}
return nil
@@ -990,6 +1041,13 @@ func (s *Sharing) NotifyMemberRevocation(inst *instance.Instance, m *Member, c *
return nil
}
+// PutRecipientsParams is the body of the request for updating the list of
+// members and groups on the active recipients of a sharing.
+type PutRecipientsParams struct {
+ Members []Member `json:"data"`
+ Groups []Group `json:"included"`
+}
+
// NotifyRecipients will push the updated list of members of the sharing to the
// active recipients. It is meant to be used in a goroutine, errors are just
// logged (nothing critical here).
@@ -1031,12 +1089,10 @@ func (s *Sharing) NotifyRecipients(inst *instance.Instance, except *Member) {
return
}
- var members struct {
- Members []Member `json:"data"`
- }
- members.Members = make([]Member, len(s.Members))
+ var params PutRecipientsParams
+ params.Members = make([]Member, len(s.Members))
for i, m := range s.Members {
- members.Members[i] = Member{
+ params.Members[i] = Member{
Status: m.Status,
PublicName: m.PublicName,
Email: m.Email,
@@ -1044,7 +1100,9 @@ func (s *Sharing) NotifyRecipients(inst *instance.Instance, except *Member) {
// Instance and name are private
}
}
- body, err := json.Marshal(members)
+ params.Groups = make([]Group, len(s.Groups))
+ copy(params.Groups, s.Groups)
+ body, err := json.Marshal(params)
if err != nil {
inst.Logger().WithNamespace("sharing").
Warnf("Can't serialize the updated members list for %s: %s", s.SID, err)
diff --git a/model/sharing/oauth.go b/model/sharing/oauth.go
index ee4541a6342..1544b32b022 100644
--- a/model/sharing/oauth.go
+++ b/model/sharing/oauth.go
@@ -544,7 +544,7 @@ func (s *Sharing) ChangeMemberAddress(inst *instance.Instance, m *Member, params
if i == 0 {
continue
}
- if s.Members[i] == *m {
+ if m.Same(s.Members[i]) {
s.Credentials[i-1].AccessToken.AccessToken = params.AccessToken
s.Credentials[i-1].AccessToken.RefreshToken = params.RefreshToken
}
diff --git a/model/sharing/sharing.go b/model/sharing/sharing.go
index a628691e41d..fee6de6fef1 100644
--- a/model/sharing/sharing.go
+++ b/model/sharing/sharing.go
@@ -69,6 +69,7 @@ type Sharing struct {
// Members[0] is the owner, Members[1...] are the recipients
Members []Member `json:"members"`
+ Groups []Group `json:"groups,omitempty"`
// On the owner, credentials[i] is associated to members[i+1]
// On a recipient, there is only credentials[0] (for the owner)
@@ -484,7 +485,7 @@ func (s *Sharing) RevokePreviewPermissions(inst *instance.Instance) error {
// RevokeRecipient revoke only one recipient on the sharer. After that, if the
// sharing has still at least one active member, we keep it as is. Else, we
-// desactive the sharing.
+// disable the sharing.
func (s *Sharing) RevokeRecipient(inst *instance.Instance, index int) error {
if !s.Owner {
return ErrInvalidSharing
@@ -719,6 +720,21 @@ func FindSharings(db prefixer.Prefixer, sharingIDs []string) ([]*Sharing, error)
return res, nil
}
+// FindActive returns the list of active sharings.
+func FindActive(db prefixer.Prefixer) ([]*Sharing, error) {
+ req := &couchdb.FindRequest{
+ UseIndex: "active",
+ Selector: mango.Equal("active", true),
+ Limit: 1000,
+ }
+ var res []*Sharing
+ err := couchdb.FindDocs(db, consts.Sharings, req, &res)
+ if err != nil {
+ return nil, err
+ }
+ return res, nil
+}
+
// GetSharingsByDocType returns all the sharings for the given doctype
func GetSharingsByDocType(inst *instance.Instance, docType string) (map[string]*Sharing, error) {
req := &couchdb.ViewRequest{
@@ -1378,11 +1394,13 @@ func (s *Sharing) checkSharingMembers() (checks []map[string]interface{}, validM
for _, m := range s.Members {
if m.Status == MemberStatusMailNotSent {
- checks = append(checks, map[string]interface{}{
- "id": s.SID,
- "type": "mail_not_sent",
- "member": m.Instance,
- })
+ if len(m.Groups) == 0 {
+ checks = append(checks, map[string]interface{}{
+ "id": s.SID,
+ "type": "mail_not_sent",
+ "member": m.Instance,
+ })
+ }
continue
}
diff --git a/pkg/consts/doctype.go b/pkg/consts/doctype.go
index fa235c07c8b..04ca406c9e2 100644
--- a/pkg/consts/doctype.go
+++ b/pkg/consts/doctype.go
@@ -70,6 +70,8 @@ const (
Permissions = "io.cozy.permissions"
// Contacts doc type for sharing
Contacts = "io.cozy.contacts"
+ // Groups of contacts doc type for sharing
+ Groups = "io.cozy.contacts.groups"
// RemoteRequests doc type for logging requests to remote websites
RemoteRequests = "io.cozy.remote.requests"
// RemoteSecrets doc type for secrets used by remote doctypes
diff --git a/pkg/couchdb/index.go b/pkg/couchdb/index.go
index 2b044e66882..9c79b96d85e 100644
--- a/pkg/couchdb/index.go
+++ b/pkg/couchdb/index.go
@@ -14,7 +14,7 @@ import (
// IndexViewsVersion is the version of current definition of views & indexes.
// This number should be incremented when this file changes.
-const IndexViewsVersion int = 36
+const IndexViewsVersion int = 37
// Indexes is the index list required by an instance to run properly.
var Indexes = []*mango.Index{
@@ -66,6 +66,12 @@ var Indexes = []*mango.Index{
// Used to lookup the bitwarden ciphers
mango.MakeIndex(consts.BitwardenCiphers, "by-folder-id", mango.IndexDef{Fields: []string{"folder_id"}}),
mango.MakeIndex(consts.BitwardenCiphers, "by-organization-id", mango.IndexDef{Fields: []string{"organization_id"}}),
+
+ // Used to find the contacts in a group
+ mango.MakeIndex(consts.Contacts, "by-groups", mango.IndexDef{Fields: []string{"relationships.groups.data"}}),
+
+ // Used to find the active sharings
+ mango.MakeIndex(consts.Sharings, "active", mango.IndexDef{Fields: []string{"active"}}),
}
// DiskUsageView is the view used for computing the disk usage for files
diff --git a/tests/system/lib/contact.rb b/tests/system/lib/contact.rb
index 19732fc95c9..003185bc8df 100644
--- a/tests/system/lib/contact.rb
+++ b/tests/system/lib/contact.rb
@@ -1,7 +1,7 @@
class Contact
include Model
- attr_reader :name, :fullname, :emails, :addresses, :phones, :cozy, :me
+ attr_reader :name, :fullname, :emails, :addresses, :phones, :cozy, :me, :group_ids
def self.doctype
"io.cozy.contacts"
@@ -26,6 +26,7 @@ def initialize(opts = {})
@phones = [{ number: phone }]
@cozy = opts[:cozy]
@me = opts[:me] || false
+ @group_ids = opts[:groups] || []
end
def self.from_json(j)
@@ -54,7 +55,14 @@ def as_json
email: @emails,
cozy: @cozy,
address: @addresses,
- phone: @phones
+ phone: @phones,
+ relationships: {
+ groups: {
+ data: @group_ids.map do |id|
+ { "_id": id, "_type": Group.doctype }
+ end
+ }
+ }
}
end
end
diff --git a/tests/system/lib/group.rb b/tests/system/lib/group.rb
new file mode 100644
index 00000000000..f803d06ea58
--- /dev/null
+++ b/tests/system/lib/group.rb
@@ -0,0 +1,26 @@
+class Group
+ include Model
+
+ attr_reader :name
+
+ def self.doctype
+ "io.cozy.contacts.groups"
+ end
+
+ def initialize(opts = {})
+ @name = opts[:name] || Faker::Educator.subject
+ end
+
+ def self.from_json(j)
+ group = Group.new(name: j["name"])
+ group.couch_id = j["_id"]
+ group.couch_rev = j["_rev"]
+ group
+ end
+
+ def as_json
+ {
+ name: @name
+ }
+ end
+end
diff --git a/tests/system/lib/model.rb b/tests/system/lib/model.rb
index f387405aeaf..4dffaf2b5a8 100644
--- a/tests/system/lib/model.rb
+++ b/tests/system/lib/model.rb
@@ -44,7 +44,7 @@ def to_json(*_args)
def as_reference
{
- doctype: doctype,
+ type: doctype,
id: @couch_id
}
end
diff --git a/tests/system/lib/sharing.rb b/tests/system/lib/sharing.rb
index 0ccf4543c1e..1266d416d1d 100644
--- a/tests/system/lib/sharing.rb
+++ b/tests/system/lib/sharing.rb
@@ -49,7 +49,8 @@ def self.get_shared_docs(inst, sharing_id, doctype)
j.dig "relationships", "shared_docs", "data"
end
- def add_members(inst, contacts, doctype)
+ def add_members(inst, contacts)
+ doctype = @rules[0].doctype
opts = {
accept: "application/vnd.api+json",
content_type: "application/vnd.api+json",
@@ -69,6 +70,36 @@ def add_members(inst, contacts, doctype)
res.code
end
+ def add_group(inst, group)
+ doctype = @rules[0].doctype
+ opts = {
+ accept: "application/vnd.api+json",
+ content_type: "application/vnd.api+json",
+ authorization: "Bearer #{inst.token_for doctype}"
+ }
+ data = {
+ data: {
+ relationships: {
+ recipients: {
+ data: [group.as_reference]
+ }
+ }
+ }
+ }
+ body = JSON.generate data
+ res = inst.client["/sharings/#{@couch_id}/recipients"].post body, opts
+ res.code
+ end
+
+ def remove_group(inst, index)
+ doctype = @rules[0].doctype
+ opts = {
+ authorization: "Bearer #{inst.token_for doctype}"
+ }
+ res = inst.client["/sharings/#{@couch_id}/groups/#{index}"].delete opts
+ res.code
+ end
+
def read_only(inst, index)
opts = {
authorization: "Bearer #{inst.token_for Folder.doctype}"
diff --git a/tests/system/tests/revoke_sharing.rb b/tests/system/tests/revoke_sharing.rb
index e729c37eb67..fb69b5afd73 100644
--- a/tests/system/tests/revoke_sharing.rb
+++ b/tests/system/tests/revoke_sharing.rb
@@ -179,14 +179,14 @@ def assert_recipient_revoked(inst, sharing_id, index)
sleep 3
# Add Charlie, Dave, and Emily to the sharing
- code = sharing.add_members inst_alice, [contact_charlie], Folder.doctype
+ code = sharing.add_members inst_alice, [contact_charlie]
assert_equal 200, code
sleep 1
inst_charlie.accept sharing
sleep 6
- code = sharing.add_members inst_bob, [contact_dave], Folder.doctype
+ code = sharing.add_members inst_bob, [contact_dave]
assert_equal 200, code
- code = sharing.add_members inst_bob, [contact_emily], Folder.doctype
+ code = sharing.add_members inst_bob, [contact_emily]
assert_equal 200, code
sleep 1
inst_dave.accept sharing, inst_bob
diff --git a/tests/system/tests/sharing_several_members.rb b/tests/system/tests/sharing_several_members.rb
index f4116a41f90..113a80a1e53 100644
--- a/tests/system/tests/sharing_several_members.rb
+++ b/tests/system/tests/sharing_several_members.rb
@@ -62,7 +62,46 @@
sleep 6
f2_bob.remove inst_bob
- sleep 21
+ sleep 6
+
+ # Check that we can add a group from the owner
+ g1 = Group.create inst, name: Faker::Kpop.girl_groups
+ contact_gaby = Contact.create inst, given_name: "Gaby", groups: [g1.couch_id]
+ sleep 1
+ sharing.add_group inst, g1
+ sleep 3
+ info = Sharing.get_sharing_info inst, sharing.couch_id, Folder.doctype
+ members = [contact_bob, contact_charlie, contact_dave, contact_emily, contact_gaby]
+ revoked = []
+ check_sharing_has_groups_and_members info, [g1], members, revoked
+
+ # Check that we can add a group from a recipient
+ g2 = Group.create inst_bob, name: Faker::Kpop.boy_bands
+ contact_hugo = Contact.create inst_bob, given_name: "Hugo", groups: [g2.couch_id]
+ sleep 1
+ sharing.add_group inst_bob, g2
+ sleep 3
+ info = Sharing.get_sharing_info inst, sharing.couch_id, Folder.doctype
+ members = [contact_bob, contact_charlie, contact_dave, contact_emily, contact_gaby, contact_hugo]
+ revoked = []
+ check_sharing_has_groups_and_members info, [g1, g2], members, revoked
+
+ # Check that we can remove a member of a group
+ contact_hugo.delete inst_bob
+ sleep 4
+ info = Sharing.get_sharing_info inst, sharing.couch_id, Folder.doctype
+ members = [contact_bob, contact_charlie, contact_dave, contact_emily, contact_gaby, contact_hugo]
+ revoked = [6]
+ check_sharing_has_groups_and_members info, [g1, g2], members, revoked
+
+ # Check that we can remove a group
+ sharing.remove_group inst, 0
+ sleep 4
+ info = Sharing.get_sharing_info inst, sharing.couch_id, Folder.doctype
+ members = [contact_bob, contact_charlie, contact_dave, contact_emily, contact_gaby, contact_hugo]
+ revoked = [5]
+ assert info.dig("attributes", "groups", 0, "revoked")
+ check_sharing_has_groups_and_members info, [g1, g2], members, revoked
# Check that the files are the same on disk
da = File.join Helpers.current_dir, inst.domain, folder.name
@@ -139,3 +178,22 @@
inst_fred.remove
end
end
+
+def check_sharing_has_groups_and_members(info, groups, contacts, revoked)
+ grps = info.dig("attributes", "groups") || []
+ assert_equal grps.length, groups.length
+ groups.each_with_index do |g, i|
+ assert_equal grps[i]["name"], g.name
+ end
+
+ members = info.dig "attributes", "members"
+ # We have the owner in members but not in contacts
+ assert_equal members.length, contacts.length + 1
+ contacts.each_with_index do |contact, i|
+ assert_equal members[i+1]["name"], contact.fullname
+ end
+
+ revoked.each do |i|
+ assert_equal members[i]["status"], "revoked"
+ end
+end
diff --git a/web/sharings/replicator_test.go b/web/sharings/replicator_test.go
index 84957716ffd..973c141fc9c 100644
--- a/web/sharings/replicator_test.go
+++ b/web/sharings/replicator_test.go
@@ -181,16 +181,16 @@ func TestReplicator(t *testing.T) {
obj.NotContainsKey(sid2)
// sid3 was updated on the source
- obj.Value(sid3).Object().Value("missing").Array().Equal([]string{"5-3b"})
+ obj.Value(sid3).Object().Value("missing").Array().IsEqual([]string{"5-3b"})
// sid4 is a conflict
- obj.Value(sid4).Object().Value("missing").Array().Equal([]string{"2-4b", "2-4c", "4-4d"})
+ obj.Value(sid4).Object().Value("missing").Array().IsEqual([]string{"2-4b", "2-4c", "4-4d"})
// sid5 has been created on the target
obj.NotContainsKey(sid5)
// sid6 has been created on the source
- obj.Value(sid6).Object().Value("missing").Array().Equal([]string{"1-6b"})
+ obj.Value(sid6).Object().Value("missing").Array().IsEqual([]string{"1-6b"})
})
t.Run("BulkDocs", func(t *testing.T) {
@@ -368,13 +368,13 @@ func TestReplicator(t *testing.T) {
Expect().Status(200).
JSON().Object()
- obj.ValueEqual("_id", xoredID)
- obj.ValueEqual("_rev", folder.DocRev)
- obj.ValueEqual("type", "directory")
- obj.ValueEqual("name", "zorglub")
+ obj.HasValue("_id", xoredID)
+ obj.HasValue("_rev", folder.DocRev)
+ obj.HasValue("type", "directory")
+ obj.HasValue("name", "zorglub")
obj.NotContainsKey("dir_id")
- obj.Value("created_at").String().DateTime(time.RFC3339)
- obj.Value("updated_at").String().DateTime(time.RFC3339)
+ obj.Value("created_at").String().AsDateTime(time.RFC3339)
+ obj.Value("updated_at").String().AsDateTime(time.RFC3339)
})
}
diff --git a/web/sharings/revoke.go b/web/sharings/revoke.go
index c8b6310f827..e6d8917b3a9 100644
--- a/web/sharings/revoke.go
+++ b/web/sharings/revoke.go
@@ -55,6 +55,32 @@ func RevokeRecipient(c echo.Context) error {
return c.NoContent(http.StatusNoContent)
}
+// RevokeGroup is used by the owner to revoke a group
+func RevokeGroup(c echo.Context) error {
+ inst := middlewares.GetInstance(c)
+ sharingID := c.Param("sharing-id")
+ s, err := sharing.FindSharing(inst, sharingID)
+ if err != nil {
+ return wrapErrors(err)
+ }
+ _, err = checkCreatePermissions(c, s)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusForbidden)
+ }
+ index, err := strconv.Atoi(c.Param("index"))
+ if err != nil {
+ return jsonapi.InvalidParameter("index", err)
+ }
+ if index >= len(s.Groups) {
+ return jsonapi.InvalidParameter("index", errors.New("Invalid index"))
+ }
+ if err = s.RevokeGroup(inst, index); err != nil {
+ return wrapErrors(err)
+ }
+ go s.NotifyRecipients(inst, nil)
+ return c.NoContent(http.StatusNoContent)
+}
+
// RevocationRecipientNotif is used to inform a recipient that the sharing is revoked
func RevocationRecipientNotif(c echo.Context) error {
inst := middlewares.GetInstance(c)
diff --git a/web/sharings/sharings.go b/web/sharings/sharings.go
index 66c6fa4706e..cf544824e33 100644
--- a/web/sharings/sharings.go
+++ b/web/sharings/sharings.go
@@ -1,3 +1,8 @@
+// Package sharings is the HTTP routes for the sharing. We have two types of
+// routes, some routes are used by the clients to create, list, revoke sharings
+// and add/remove recipients, and other routes are reserved for an internal
+// usage, mostly to synchronize the documents between the Cozys of the members
+// of the sharings.
package sharings
import (
@@ -19,6 +24,7 @@ import (
"github.com/cozy/cozy-stack/pkg/config/config"
"github.com/cozy/cozy-stack/pkg/consts"
"github.com/cozy/cozy-stack/pkg/couchdb"
+ "github.com/cozy/cozy-stack/pkg/crypto"
"github.com/cozy/cozy-stack/pkg/jsonapi"
"github.com/cozy/cozy-stack/pkg/logger"
"github.com/cozy/cozy-stack/pkg/safehttp"
@@ -49,9 +55,17 @@ func CreateSharing(c echo.Context) error {
if rel, ok := obj.GetRelationship("recipients"); ok {
if data, ok := rel.Data.([]interface{}); ok {
for _, ref := range data {
- if id, ok := ref.(map[string]interface{})["id"].(string); ok {
- if err = s.AddContact(inst, id, false); err != nil {
- return err
+ if t, _ := ref.(map[string]interface{})["type"].(string); t == consts.Groups {
+ if id, ok := ref.(map[string]interface{})["id"].(string); ok {
+ if err = s.AddGroup(inst, id, false); err != nil {
+ return err
+ }
+ }
+ } else {
+ if id, ok := ref.(map[string]interface{})["id"].(string); ok {
+ if err = s.AddContact(inst, id, false); err != nil {
+ return err
+ }
}
}
}
@@ -61,9 +75,17 @@ func CreateSharing(c echo.Context) error {
if rel, ok := obj.GetRelationship("read_only_recipients"); ok {
if data, ok := rel.Data.([]interface{}); ok {
for _, ref := range data {
- if id, ok := ref.(map[string]interface{})["id"].(string); ok {
- if err = s.AddContact(inst, id, true); err != nil {
- return err
+ if t, _ := ref.(map[string]interface{})["type"].(string); t == consts.Groups {
+ if id, ok := ref.(map[string]interface{})["id"].(string); ok {
+ if err = s.AddGroup(inst, id, true); err != nil {
+ return err
+ }
+ }
+ } else {
+ if id, ok := ref.(map[string]interface{})["id"].(string); ok {
+ if err = s.AddContact(inst, id, true); err != nil {
+ return err
+ }
}
}
}
@@ -286,16 +308,20 @@ func ChangeCozyAddress(c echo.Context) error {
func addRecipientsToSharing(inst *instance.Instance, s *sharing.Sharing, rel *jsonapi.Relationship, readOnly bool) error {
var err error
if data, ok := rel.Data.([]interface{}); ok {
- ids := make(map[string]bool)
+ var contactIDs, groupIDs []string
for _, ref := range data {
if id, ok := ref.(map[string]interface{})["id"].(string); ok {
- ids[id] = readOnly
+ if t, _ := ref.(map[string]interface{})["type"].(string); t == consts.Groups {
+ groupIDs = append(groupIDs, id)
+ } else {
+ contactIDs = append(contactIDs, id)
+ }
}
}
if s.Owner {
- err = s.AddContacts(inst, ids)
+ err = s.AddGroupsAndContacts(inst, groupIDs, contactIDs, readOnly)
} else {
- err = s.DelegateAddContacts(inst, ids)
+ err = s.DelegateAddContactsAndGroups(inst, groupIDs, contactIDs, readOnly)
}
}
return err
@@ -330,8 +356,8 @@ func AddRecipients(c echo.Context) error {
return jsonapiSharingWithDocs(c, s)
}
-// AddRecipientsDelegated is used to add a member to a sharing on the owner's cozy
-// when it's the recipient's cozy that sends the mail invitation.
+// AddRecipientsDelegated is used to add members and groups to a sharing on the
+// owner's cozy when it's the recipient's cozy that sends the mail invitation.
func AddRecipientsDelegated(c echo.Context) error {
inst := middlewares.GetInstance(c)
sharingID := c.Param("sharing-id")
@@ -342,53 +368,190 @@ func AddRecipientsDelegated(c echo.Context) error {
if !s.Owner || !s.Open {
return echo.NewHTTPError(http.StatusForbidden)
}
- var body sharing.Sharing
- obj, err := jsonapi.Bind(c.Request().Body, &body)
+ member, err := requestMember(c, s)
if err != nil {
+ return wrapErrors(err)
+ }
+ memberIndex := -1
+ for i, m := range s.Members {
+ if m.Instance == member.Instance {
+ memberIndex = i
+ }
+ }
+ if memberIndex == -1 {
+ return jsonapi.InternalServerError(sharing.ErrInvalidSharing)
+ }
+
+ var body struct {
+ Data struct {
+ Type string `json:"type"`
+ ID string `json:"id"`
+ Relationships struct {
+ Groups struct {
+ Data []sharing.Group `json:"data"`
+ } `json:"groups"`
+ Recipients struct {
+ Data []sharing.Member `json:"data"`
+ } `json:"recipients"`
+ } `json:"relationships"`
+ } `json:"data"`
+ }
+ if err = json.NewDecoder(c.Request().Body).Decode(&body); err != nil {
return jsonapi.BadJSON()
}
+
+ for _, g := range body.Data.Relationships.Groups.Data {
+ g.AddedBy = memberIndex
+ s.Groups = append(s.Groups, g)
+ }
+
states := make(map[string]string)
- if rel, ok := obj.GetRelationship("recipients"); ok {
- if data, ok := rel.Data.([]interface{}); ok {
- for _, ref := range data {
- contact, _ := ref.(map[string]interface{})
- email, _ := contact["email"].(string)
- cozy, _ := contact["instance"].(string)
- ro, _ := contact["read_only"].(bool)
- state, err := s.AddDelegatedContact(inst, email, cozy, ro)
- if err != nil {
+ for _, m := range body.Data.Relationships.Recipients.Data {
+ state, err := s.AddDelegatedContact(inst, m)
+ if err != nil {
+ if len(m.Groups) > 0 {
+ continue
+ }
+ return wrapErrors(err)
+ }
+ // If we have an URL for the Cozy, we can create a shortcut as an invitation
+ if m.Instance != "" {
+ states[m.Instance] = state
+ var perms *permission.Permission
+ if s.PreviewPath != "" {
+ if perms, err = s.CreatePreviewPermissions(inst); err != nil {
return wrapErrors(err)
}
- if email == "" {
- states[cozy] = state
- } else {
- states[email] = state
- }
+ }
+ if err = s.SendInvitations(inst, perms); err != nil {
+ return wrapErrors(err)
+ }
+ } else if m.Email != "" {
+ states[m.Email] = state
+ }
+ }
- // If we have an URL for the Cozy, we can create a shortcut as an invitation
- if cozy != "" {
- var perms *permission.Permission
- if s.PreviewPath != "" {
- if perms, err = s.CreatePreviewPermissions(inst); err != nil {
- return wrapErrors(err)
- }
- }
- if err = s.SendInvitations(inst, perms); err != nil {
- return wrapErrors(err)
- }
+ if err := couchdb.UpdateDoc(inst, s); err != nil {
+ return wrapErrors(err)
+ }
+ cloned := s.Clone().(*sharing.Sharing)
+ go cloned.NotifyRecipients(inst, nil)
+ return c.JSON(http.StatusOK, states)
+}
+
+// AddInvitationDelegated is when a member has been added to a sharing via a
+// group, but is invited only later (no email or Cozy instance known when they
+// was added).
+func AddInvitationDelegated(c echo.Context) error {
+ inst := middlewares.GetInstance(c)
+ sharingID := c.Param("sharing-id")
+ s, err := sharing.FindSharing(inst, sharingID)
+ if err != nil {
+ return wrapErrors(err)
+ }
+ if !s.Owner || !s.Open {
+ return echo.NewHTTPError(http.StatusForbidden)
+ }
+
+ memberIndex, err := strconv.Atoi(c.Param("member-index"))
+ if err != nil || memberIndex <= 0 || memberIndex >= len(s.Members) {
+ return jsonapi.InvalidParameter("member-index", errors.New("invalid member-index parameter"))
+ }
+
+ var body struct {
+ Data struct {
+ Type string `json:"type"`
+ Member sharing.Member `json:"attributes"`
+ }
+ }
+ if err = json.NewDecoder(c.Request().Body).Decode(&body); err != nil {
+ return jsonapi.BadJSON()
+ }
+
+ states := make(map[string]string)
+ m := s.Members[memberIndex]
+ if m.Status == sharing.MemberStatusMailNotSent {
+ m.Instance = body.Data.Member.Instance
+ m.Email = body.Data.Member.Email
+ state64 := crypto.Base64Encode(crypto.GenerateRandomBytes(sharing.StateLen))
+ state := string(state64)
+ creds := sharing.Credentials{
+ State: state,
+ XorKey: sharing.MakeXorKey(),
+ }
+ s.Credentials[memberIndex-1] = creds
+ s.Members[memberIndex] = m
+ // If we have an URL for the Cozy, we can create a shortcut as an invitation
+ if m.Instance != "" {
+ states[m.Instance] = state
+ var perms *permission.Permission
+ if s.PreviewPath != "" {
+ if perms, err = s.CreatePreviewPermissions(inst); err != nil {
+ return wrapErrors(err)
}
}
-
- if err := couchdb.UpdateDoc(inst, s); err != nil {
+ if err = s.SendInvitations(inst, perms); err != nil {
return wrapErrors(err)
}
- cloned := s.Clone().(*sharing.Sharing)
- go cloned.NotifyRecipients(inst, nil)
+ } else if m.Email != "" {
+ states[m.Email] = state
+ s.Members[memberIndex].Status = sharing.MemberStatusReady
}
}
+
+ if err := couchdb.UpdateDoc(inst, s); err != nil {
+ return wrapErrors(err)
+ }
+ cloned := s.Clone().(*sharing.Sharing)
+ go cloned.NotifyRecipients(inst, nil)
return c.JSON(http.StatusOK, states)
}
+// RemoveMemberFromGroup is used to remove a member from a group (delegated).
+func RemoveMemberFromGroup(c echo.Context) error {
+ inst := middlewares.GetInstance(c)
+ sharingID := c.Param("sharing-id")
+ s, err := sharing.FindSharing(inst, sharingID)
+ if err != nil {
+ return wrapErrors(err)
+ }
+ if !s.Owner || !s.Open {
+ return echo.NewHTTPError(http.StatusForbidden)
+ }
+
+ member, err := requestMember(c, s)
+ if err != nil {
+ return wrapErrors(err)
+ }
+ addedBy := -1
+ for i, m := range s.Members {
+ if m.Instance == member.Instance {
+ addedBy = i
+ }
+ }
+ if addedBy == -1 {
+ return jsonapi.InternalServerError(sharing.ErrInvalidSharing)
+ }
+
+ groupIndex, err := strconv.Atoi(c.Param("group-index"))
+ if err != nil || groupIndex < 0 || groupIndex >= len(s.Groups) {
+ return jsonapi.InvalidParameter("group-index", errors.New("invalid group-index parameter"))
+ }
+ if s.Groups[groupIndex].AddedBy != addedBy {
+ return echo.NewHTTPError(http.StatusForbidden)
+ }
+
+ memberIndex, err := strconv.Atoi(c.Param("member-index"))
+ if err != nil || memberIndex <= 0 || memberIndex >= len(s.Members) {
+ return jsonapi.InvalidParameter("member-index", errors.New("invalid member-index parameter"))
+ }
+
+ if err := s.DelegatedRemoveMemberFromGroup(inst, groupIndex, memberIndex); err != nil {
+ return wrapErrors(err)
+ }
+ return c.NoContent(http.StatusNoContent)
+}
+
// PutRecipients is used to update the members list on the recipients cozy
func PutRecipients(c echo.Context) error {
inst := middlewares.GetInstance(c)
@@ -414,13 +577,11 @@ func PutRecipients(c echo.Context) error {
}
}
- var body struct {
- Members []sharing.Member `json:"data"`
- }
- if err = json.NewDecoder(c.Request().Body).Decode(&body); err != nil {
+ var params sharing.PutRecipientsParams
+ if err = json.NewDecoder(c.Request().Body).Decode(¶ms); err != nil {
return wrapErrors(err)
}
- if err = s.UpdateRecipients(inst, body.Members); err != nil {
+ if err = s.UpdateRecipients(inst, params); err != nil {
return wrapErrors(err)
}
return c.NoContent(http.StatusNoContent)
@@ -727,6 +888,7 @@ func Routes(router *echo.Group) {
router.PUT("/:sharing-id/recipients", PutRecipients)
router.DELETE("/:sharing-id/recipients", RevokeSharing) // On the sharer
router.DELETE("/:sharing-id/recipients/:index", RevokeRecipient) // On the sharer
+ router.DELETE("/:sharing-id/groups/:index", RevokeGroup) // On the sharer
router.POST("/:sharing-id/recipients/self/moved", ChangeCozyAddress)
router.POST("/:sharing-id/recipients/:index/readonly", AddReadOnly) // On the sharer
router.POST("/:sharing-id/recipients/self/readonly", DowngradeToReadOnly, checkSharingWritePermissions) // On the recipient
@@ -739,6 +901,8 @@ func Routes(router *echo.Group) {
// Delegated routes for open sharing
router.POST("/:sharing-id/recipients/delegated", AddRecipientsDelegated, checkSharingWritePermissions)
+ router.POST("/:sharing-id/members/:index/invitation", AddInvitationDelegated, checkSharingWritePermissions)
+ router.DELETE("/:sharing-id/groups/:group-index/:member-index", RemoveMemberFromGroup, checkSharingWritePermissions)
// Misc
router.GET("/news", CountNewShortcuts)
diff --git a/web/sharings/sharings_test.go b/web/sharings/sharings_test.go
index 41294495820..ba81949c4bb 100644
--- a/web/sharings/sharings_test.go
+++ b/web/sharings/sharings_test.go
@@ -122,10 +122,10 @@ func TestSharings(t *testing.T) {
},
"relationships": {
"recipients": {
- "data": [{"id": "` + bobContact.ID() + `", "doctype": "` + bobContact.DocType() + `"}]
+ "data": [{"id": "` + bobContact.ID() + `", "type": "` + bobContact.DocType() + `"}]
},
"read_only_recipients": {
- "data": [{"id": "` + daveContact.ID() + `", "doctype": "` + daveContact.DocType() + `"}]
+ "data": [{"id": "` + daveContact.ID() + `", "type": "` + daveContact.DocType() + `"}]
}
}
}
@@ -167,7 +167,7 @@ func TestSharings(t *testing.T) {
eA.GET(u.Path).
WithQuery("state", state).
Expect().Status(200).
- ContentType("text/html", "utf-8").
+ HasContentType("text/html", "utf-8").
Body().
Contains("Connect to your Cozy").
Contains(``)
matches := body.Match(`