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 }