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 }