github.com/cozy/cozy-stack@v0.0.0-20240327093429-939e4a21320e/model/sharing/group.go (about)

     1  package sharing
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/json"
     6  	"fmt"
     7  	"net/http"
     8  	"net/url"
     9  	"sort"
    10  	"time"
    11  
    12  	"github.com/cozy/cozy-stack/client/request"
    13  	"github.com/cozy/cozy-stack/model/contact"
    14  	"github.com/cozy/cozy-stack/model/instance"
    15  	"github.com/cozy/cozy-stack/model/job"
    16  	"github.com/cozy/cozy-stack/model/permission"
    17  	"github.com/cozy/cozy-stack/pkg/consts"
    18  	"github.com/cozy/cozy-stack/pkg/couchdb"
    19  	"github.com/cozy/cozy-stack/pkg/jsonapi"
    20  	multierror "github.com/hashicorp/go-multierror"
    21  	"github.com/labstack/echo/v4"
    22  )
    23  
    24  // Group contains the information about a group of members of the sharing.
    25  type Group struct {
    26  	ID       string `json:"id,omitempty"`
    27  	Name     string `json:"name"`
    28  	AddedBy  int    `json:"addedBy"` // The index of the member who added the group
    29  	ReadOnly bool   `json:"read_only"`
    30  	Revoked  bool   `json:"revoked,omitempty"`
    31  }
    32  
    33  // AddGroup adds a group of contacts identified by its ID to the members of the
    34  // sharing.
    35  func (s *Sharing) AddGroup(inst *instance.Instance, groupID string, readOnly bool) error {
    36  	group, err := contact.FindGroup(inst, groupID)
    37  	if err != nil {
    38  		return err
    39  	}
    40  	contacts, err := group.GetAllContacts(inst)
    41  	if err != nil {
    42  		return err
    43  	}
    44  
    45  	groupIndex := len(s.Groups)
    46  	for _, contact := range contacts {
    47  		m := buildMemberFromContact(contact, readOnly)
    48  		m.OnlyInGroups = true
    49  		_, idx, err := s.addMember(inst, m)
    50  		if err != nil {
    51  			return err
    52  		}
    53  		pos := sort.SearchInts(s.Members[idx].Groups, groupIndex)
    54  		if pos == len(s.Members[idx].Groups) || s.Members[idx].Groups[pos] != groupIndex {
    55  			s.Members[idx].Groups = append(s.Members[idx].Groups, groupIndex)
    56  			sort.Ints(s.Members[idx].Groups)
    57  		}
    58  	}
    59  
    60  	g := Group{ID: groupID, Name: group.Name(), AddedBy: 0, ReadOnly: readOnly}
    61  	s.Groups = append(s.Groups, g)
    62  	return nil
    63  }
    64  
    65  // RevokeGroup revokes a group of members on the sharer Cozy. After that, the
    66  // sharing is disabled if there are no longer any active recipient.
    67  func (s *Sharing) RevokeGroup(inst *instance.Instance, index int) error {
    68  	if !s.Owner {
    69  		return ErrInvalidSharing
    70  	}
    71  
    72  	var errm error
    73  	for i, m := range s.Members {
    74  		inGroup := false
    75  		for _, idx := range m.Groups {
    76  			if idx == index {
    77  				inGroup = true
    78  			}
    79  		}
    80  		if !inGroup {
    81  			continue
    82  		}
    83  		if len(m.Groups) == 1 {
    84  			s.Members[i].Groups = nil
    85  		} else {
    86  			var groups []int
    87  			for _, idx := range m.Groups {
    88  				if idx != index {
    89  					groups = append(groups, idx)
    90  				}
    91  			}
    92  			s.Members[i].Groups = groups
    93  		}
    94  		if m.OnlyInGroups && len(s.Members[i].Groups) == 0 {
    95  			if err := s.RevokeRecipient(inst, i); err != nil {
    96  				errm = multierror.Append(errm, err)
    97  			}
    98  		}
    99  	}
   100  
   101  	s.Groups[index].Revoked = true
   102  	if err := couchdb.UpdateDoc(inst, s); err != nil {
   103  		errm = multierror.Append(errm, err)
   104  	}
   105  	return errm
   106  }
   107  
   108  // UpdateGroups is called when a contact is added or removed to a group. It
   109  // finds the sharings for this group, and adds or removes the member to those
   110  // sharings.
   111  func UpdateGroups(inst *instance.Instance, msg job.ShareGroupMessage) error {
   112  	if msg.RenamedGroup != nil {
   113  		return updateRenamedGroup(inst, msg.RenamedGroup)
   114  	}
   115  
   116  	var c *contact.Contact
   117  	if msg.DeletedDoc != nil {
   118  		c = &contact.Contact{JSONDoc: *msg.DeletedDoc}
   119  	} else {
   120  		doc, err := contact.Find(inst, msg.ContactID)
   121  		if err != nil {
   122  			return err
   123  		}
   124  		c = doc
   125  	}
   126  
   127  	sharings, err := FindActive(inst)
   128  	if err != nil {
   129  		return err
   130  	}
   131  
   132  	var errm error
   133  	for _, s := range sharings {
   134  		for _, added := range msg.GroupsAdded {
   135  			for idx, group := range s.Groups {
   136  				if group.ID == added {
   137  					if s.Owner {
   138  						if err := s.AddMemberToGroup(inst, idx, c); err != nil {
   139  							errm = multierror.Append(errm, err)
   140  						}
   141  					} else {
   142  						if err := s.DelegateAddMemberToGroup(inst, idx, c); err != nil {
   143  							errm = multierror.Append(errm, err)
   144  						}
   145  					}
   146  				}
   147  			}
   148  		}
   149  		for _, removed := range msg.GroupsRemoved {
   150  			for idx, group := range s.Groups {
   151  				if group.ID == removed {
   152  					if s.Owner {
   153  						if err := s.RemoveMemberFromGroup(inst, idx, c); err != nil {
   154  							errm = multierror.Append(errm, err)
   155  						}
   156  					} else {
   157  						if err := s.DelegateRemoveMemberFromGroup(inst, idx, c); err != nil {
   158  							errm = multierror.Append(errm, err)
   159  						}
   160  					}
   161  				}
   162  			}
   163  		}
   164  
   165  		if msg.BecomeInvitable {
   166  			if err := s.AddInvitationForContact(inst, c); err != nil {
   167  				errm = multierror.Append(errm, err)
   168  			}
   169  		}
   170  	}
   171  
   172  	return errm
   173  }
   174  
   175  func updateRenamedGroup(inst *instance.Instance, doc *couchdb.JSONDoc) error {
   176  	sharings, err := FindActive(inst)
   177  	if err != nil {
   178  		return err
   179  	}
   180  
   181  	var errm error
   182  	for _, s := range sharings {
   183  		for idx, group := range s.Groups {
   184  			if group.ID == doc.ID() {
   185  				if name, ok := doc.M["name"].(string); ok {
   186  					group.Name = name
   187  					s.Groups[idx] = group
   188  					if err := couchdb.UpdateDoc(inst, s); err != nil {
   189  						errm = multierror.Append(errm, err)
   190  					}
   191  					cloned := s.Clone().(*Sharing)
   192  					go cloned.NotifyRecipients(inst, nil)
   193  				}
   194  			}
   195  		}
   196  	}
   197  
   198  	return errm
   199  }
   200  
   201  // AddMemberToGroup adds a contact to a sharing via a group (on the owner).
   202  func (s *Sharing) AddMemberToGroup(inst *instance.Instance, groupIndex int, contact *contact.Contact) error {
   203  	readOnly := s.Groups[groupIndex].ReadOnly
   204  	m := buildMemberFromContact(contact, readOnly)
   205  	m.OnlyInGroups = true
   206  	_, idx, err := s.addMember(inst, m)
   207  	if err != nil {
   208  		return err
   209  	}
   210  	s.Members[idx].Groups = append(s.Members[idx].Groups, groupIndex)
   211  	sort.Ints(s.Members[idx].Groups)
   212  
   213  	// We can ignore the error as we will try again to save the sharing
   214  	// after sending the invitation.
   215  	_ = couchdb.UpdateDoc(inst, s)
   216  	var perms *permission.Permission
   217  	if s.PreviewPath != "" {
   218  		if perms, err = s.CreatePreviewPermissions(inst); err != nil {
   219  			return err
   220  		}
   221  	}
   222  	if err = s.SendInvitations(inst, perms); err != nil {
   223  		return err
   224  	}
   225  	cloned := s.Clone().(*Sharing)
   226  	go cloned.NotifyRecipients(inst, nil)
   227  	return nil
   228  }
   229  
   230  // DelegateAddMemberToGroup adds a contact to a sharing via a group (on a recipient).
   231  func (s *Sharing) DelegateAddMemberToGroup(inst *instance.Instance, groupIndex int, contact *contact.Contact) error {
   232  	readOnly := s.Groups[groupIndex].ReadOnly
   233  	m := buildMemberFromContact(contact, readOnly)
   234  	m.OnlyInGroups = true
   235  	m.Groups = []int{groupIndex}
   236  	api := &APIDelegateAddContacts{
   237  		sid:     s.ID(),
   238  		members: []Member{m},
   239  	}
   240  	return s.SendDelegated(inst, api)
   241  }
   242  
   243  // RemoveMemberFromGroup removes a member of a group.
   244  func (s *Sharing) RemoveMemberFromGroup(inst *instance.Instance, groupIndex int, contact *contact.Contact) error {
   245  	var email string
   246  	if addr, err := contact.ToMailAddress(); err == nil {
   247  		email = addr.Email
   248  	}
   249  	cozyURL := contact.PrimaryCozyURL()
   250  
   251  	matchMember := func(m Member) bool {
   252  		if m.Email != "" && m.Email == email {
   253  			return true
   254  		}
   255  		if m.Instance != "" && m.Instance == cozyURL {
   256  			return true
   257  		}
   258  		return false
   259  	}
   260  
   261  	for i, m := range s.Members {
   262  		if !matchMember(m) {
   263  			continue
   264  		}
   265  
   266  		var groups []int
   267  		for _, idx := range m.Groups {
   268  			if idx != groupIndex {
   269  				groups = append(groups, idx)
   270  			}
   271  		}
   272  		s.Members[i].Groups = groups
   273  
   274  		if m.OnlyInGroups && len(s.Members[i].Groups) == 0 {
   275  			return s.RevokeRecipient(inst, i)
   276  		} else {
   277  			return couchdb.UpdateDoc(inst, s)
   278  		}
   279  	}
   280  
   281  	return nil
   282  }
   283  
   284  // DelegateRemoveMemberFromGroup removes a member from a sharing group (on a recipient).
   285  func (s *Sharing) DelegateRemoveMemberFromGroup(inst *instance.Instance, groupIndex int, contact *contact.Contact) error {
   286  	var email string
   287  	if addr, err := contact.ToMailAddress(); err == nil {
   288  		email = addr.Email
   289  	}
   290  	cozyURL := contact.PrimaryCozyURL()
   291  
   292  	for i, m := range s.Members {
   293  		if m.Email != "" && m.Email == email {
   294  			return s.SendRemoveMemberFromGroup(inst, groupIndex, i)
   295  		}
   296  		if m.Instance != "" && m.Instance == cozyURL {
   297  			return s.SendRemoveMemberFromGroup(inst, groupIndex, i)
   298  		}
   299  	}
   300  	return ErrMemberNotFound
   301  }
   302  
   303  func (s *Sharing) SendRemoveMemberFromGroup(inst *instance.Instance, groupIndex, memberIndex int) error {
   304  	u, err := url.Parse(s.Members[0].Instance)
   305  	if err != nil {
   306  		return err
   307  	}
   308  	c := &s.Credentials[0]
   309  	if c.AccessToken == nil {
   310  		return ErrInvalidSharing
   311  	}
   312  	opts := &request.Options{
   313  		Method: http.MethodDelete,
   314  		Scheme: u.Scheme,
   315  		Domain: u.Host,
   316  		Path:   fmt.Sprintf("/sharings/%s/groups/%d/%d", s.SID, groupIndex, memberIndex),
   317  		Headers: request.Headers{
   318  			echo.HeaderAuthorization: "Bearer " + c.AccessToken.AccessToken,
   319  		},
   320  		ParseError: ParseRequestError,
   321  	}
   322  	res, err := request.Req(opts)
   323  	if res != nil && res.StatusCode/100 == 4 {
   324  		res, err = RefreshToken(inst, err, s, &s.Members[0], c, opts, nil)
   325  	}
   326  	if err != nil {
   327  		return err
   328  	}
   329  	defer res.Body.Close()
   330  	if res.StatusCode != http.StatusNoContent {
   331  		return ErrInternalServerError
   332  	}
   333  	return nil
   334  }
   335  
   336  func (s *Sharing) DelegatedRemoveMemberFromGroup(inst *instance.Instance, groupIndex, memberIndex int) error {
   337  	var groups []int
   338  	for _, idx := range s.Members[memberIndex].Groups {
   339  		if idx != groupIndex {
   340  			groups = append(groups, idx)
   341  		}
   342  	}
   343  	s.Members[memberIndex].Groups = groups
   344  
   345  	if s.Members[memberIndex].OnlyInGroups && len(s.Members[memberIndex].Groups) == 0 {
   346  		return s.RevokeRecipient(inst, memberIndex)
   347  	} else {
   348  		return couchdb.UpdateDoc(inst, s)
   349  	}
   350  }
   351  
   352  func (s *Sharing) AddInvitationForContact(inst *instance.Instance, contact *contact.Contact) error {
   353  	var email string
   354  	if addr, err := contact.ToMailAddress(); err == nil {
   355  		email = addr.Email
   356  	}
   357  	cozyURL := contact.PrimaryCozyURL()
   358  	name := contact.PrimaryName()
   359  	groupIDs := contact.GroupIDs()
   360  
   361  	matchMember := func(m Member) bool {
   362  		if m.Name != name {
   363  			return false
   364  		}
   365  		for _, gid := range groupIDs {
   366  			for _, g := range m.Groups {
   367  				if s.Groups[g].ID == gid {
   368  					return true
   369  				}
   370  			}
   371  		}
   372  		return false
   373  	}
   374  
   375  	for i, m := range s.Members {
   376  		if i == 0 || m.Status != MemberStatusMailNotSent {
   377  			continue
   378  		}
   379  		if !matchMember(m) {
   380  			continue
   381  		}
   382  		m.Email = email
   383  		m.Instance = cozyURL
   384  		s.Members[i] = m
   385  
   386  		if !s.Owner {
   387  			return s.DelegateAddInvitation(inst, i)
   388  		}
   389  
   390  		// We can ignore the error as we will try again to save the sharing
   391  		// after sending the invitation.
   392  		_ = couchdb.UpdateDoc(inst, s)
   393  		var perms *permission.Permission
   394  		var err error
   395  		if s.PreviewPath != "" {
   396  			if perms, err = s.CreatePreviewPermissions(inst); err != nil {
   397  				return err
   398  			}
   399  		}
   400  		if err = s.SendInvitations(inst, perms); err != nil {
   401  			return err
   402  		}
   403  		cloned := s.Clone().(*Sharing)
   404  		go cloned.NotifyRecipients(inst, nil)
   405  		return nil
   406  	}
   407  
   408  	return nil
   409  }
   410  
   411  func (s *Sharing) DelegateAddInvitation(inst *instance.Instance, memberIndex int) error {
   412  	body, err := json.Marshal(map[string]interface{}{
   413  		"data": map[string]interface{}{
   414  			"type":       consts.SharingsMembers,
   415  			"attributes": s.Members[memberIndex],
   416  		},
   417  	})
   418  	if err != nil {
   419  		return err
   420  	}
   421  	u, err := url.Parse(s.Members[0].Instance)
   422  	if err != nil {
   423  		return err
   424  	}
   425  	c := &s.Credentials[0]
   426  	if c.AccessToken == nil {
   427  		return ErrInvalidSharing
   428  	}
   429  	opts := &request.Options{
   430  		Method: http.MethodPost,
   431  		Scheme: u.Scheme,
   432  		Domain: u.Host,
   433  		Path:   fmt.Sprintf("/sharings/%s/members/%d/invitation", s.ID(), memberIndex),
   434  		Headers: request.Headers{
   435  			echo.HeaderAccept:        echo.MIMEApplicationJSON,
   436  			echo.HeaderContentType:   jsonapi.ContentType,
   437  			echo.HeaderAuthorization: "Bearer " + c.AccessToken.AccessToken,
   438  		},
   439  		Body:       bytes.NewReader(body),
   440  		ParseError: ParseRequestError,
   441  	}
   442  	res, err := request.Req(opts)
   443  	if res != nil && res.StatusCode/100 == 4 {
   444  		res, err = RefreshToken(inst, err, s, &s.Members[0], c, opts, body)
   445  	}
   446  	if err != nil {
   447  		return err
   448  	}
   449  	defer res.Body.Close()
   450  	if res.StatusCode != http.StatusOK {
   451  		return ErrInternalServerError
   452  	}
   453  	var states map[string]string
   454  	if err = json.NewDecoder(res.Body).Decode(&states); err != nil {
   455  		return err
   456  	}
   457  
   458  	// We can have conflicts when updating the sharing document, so we are
   459  	// retrying when it is the case.
   460  	maxRetries := 3
   461  	i := 0
   462  	for {
   463  		s.Members[i].Status = MemberStatusReady
   464  		if err := couchdb.UpdateDoc(inst, s); err == nil {
   465  			break
   466  		}
   467  		i++
   468  		if i > maxRetries {
   469  			return err
   470  		}
   471  		time.Sleep(1 * time.Second)
   472  		s, err = FindSharing(inst, s.SID)
   473  		if err != nil {
   474  			return err
   475  		}
   476  	}
   477  	return s.SendInvitationsToMembers(inst, []Member{s.Members[memberIndex]}, states)
   478  }