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