github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/model/sharing/invitation.go (about) 1 package sharing 2 3 import ( 4 "context" 5 "fmt" 6 "net/url" 7 "time" 8 9 "github.com/cozy/cozy-stack/model/instance" 10 "github.com/cozy/cozy-stack/model/job" 11 "github.com/cozy/cozy-stack/model/permission" 12 csettings "github.com/cozy/cozy-stack/model/settings" 13 "github.com/cozy/cozy-stack/model/vfs" 14 "github.com/cozy/cozy-stack/pkg/consts" 15 "github.com/cozy/cozy-stack/pkg/couchdb" 16 "github.com/cozy/cozy-stack/pkg/mail" 17 "github.com/cozy/cozy-stack/pkg/shortcut" 18 "golang.org/x/sync/errgroup" 19 ) 20 21 // SendInvitations sends invitation mails to the recipients that were in the 22 // mail-not-sent status (owner only) 23 func (s *Sharing) SendInvitations(inst *instance.Instance, perms *permission.Permission) error { 24 if !s.Owner { 25 return ErrInvalidSharing 26 } 27 if len(s.Members) != len(s.Credentials)+1 { 28 return ErrInvalidSharing 29 } 30 sharer, desc := s.getSharerAndDescription(inst) 31 canSendShortcut := s.Rules[0].DocType != consts.BitwardenOrganizations 32 33 g, _ := errgroup.WithContext(context.Background()) 34 for i := range s.Members { 35 m := &s.Members[i] 36 if i == 0 || m.Status != MemberStatusMailNotSent { // i == 0 is for the owner 37 continue 38 } 39 state := s.Credentials[i-1].State 40 g.Go(func() error { 41 link := m.InvitationLink(inst, s, state, perms) 42 if m.Instance != "" && canSendShortcut { 43 if err := m.SendShortcut(inst, s, link); err == nil { 44 m.Status = MemberStatusPendingInvitation 45 return nil 46 } 47 } 48 if m.Email == "" { 49 if len(m.Groups) > 0 { 50 return nil 51 } 52 return ErrInvitationNotSent 53 } 54 if err := m.SendMail(inst, s, sharer, desc, link); err != nil { 55 inst.Logger().WithNamespace("sharing"). 56 Errorf("Can't send email for %#v: %s", m.Email, err) 57 return ErrInvitationNotSent 58 } 59 m.Status = MemberStatusPendingInvitation 60 return nil 61 }) 62 } 63 errg := g.Wait() 64 if err := couchdb.UpdateDoc(inst, s); err != nil { 65 return err 66 } 67 return errg 68 } 69 70 // SendInvitationsToMembers sends mails from a recipient (open_sharing) to 71 // their contacts to invite them 72 func (s *Sharing) SendInvitationsToMembers(inst *instance.Instance, members []Member, states map[string]string) error { 73 sharer, desc := s.getSharerAndDescription(inst) 74 75 keys := make([]string, 0, len(members)) 76 for _, m := range members { 77 key := m.Email 78 if key == "" { 79 key = m.Instance 80 } 81 // If an instance URL is available, the owner's Cozy has already 82 // created a shortcut, so we don't need to send an invitation. 83 if m.Instance == "" { 84 if m.Email == "" { 85 return ErrInvitationNotSent 86 } 87 link := m.InvitationLink(inst, s, states[key], nil) 88 if err := m.SendMail(inst, s, sharer, desc, link); err != nil { 89 inst.Logger().WithNamespace("sharing"). 90 Errorf("Can't send email for %#v: %s", m.Email, err) 91 return ErrInvitationNotSent 92 } 93 } 94 keys = append(keys, key) 95 } 96 97 // We can have conflicts when updating the sharing document, so we are 98 // retrying when it is the case. 99 maxRetries := 3 100 i := 0 101 for { 102 for j, member := range s.Members { 103 if j == 0 { 104 continue // skip the owner 105 } 106 if member.Status != MemberStatusMailNotSent { 107 continue 108 } 109 for _, key := range keys { 110 if member.Email == key || member.Instance == key { 111 s.Members[j].Status = MemberStatusPendingInvitation 112 break 113 } 114 } 115 } 116 err := couchdb.UpdateDoc(inst, s) 117 if err == nil { 118 return nil 119 } 120 i++ 121 if i > maxRetries { 122 return err 123 } 124 time.Sleep(1 * time.Second) 125 s, err = FindSharing(inst, s.SID) 126 if err != nil { 127 return err 128 } 129 } 130 } 131 132 func (s *Sharing) getSharerAndDescription(inst *instance.Instance) (string, string) { 133 sharer, _ := csettings.PublicName(inst) 134 if sharer == "" { 135 sharer = inst.Translate("Sharing Empty name") 136 } 137 desc := s.Description 138 if desc == "" { 139 desc = inst.Translate("Sharing Empty description") 140 } 141 return sharer, desc 142 } 143 144 // InvitationLink generates an HTTP link where the recipient can start the 145 // process of accepting the sharing 146 func (m *Member) InvitationLink(inst *instance.Instance, s *Sharing, state string, perms *permission.Permission) string { 147 if s.Owner && s.PreviewPath != "" && perms != nil { 148 var code string 149 if perms.Codes != nil { 150 if c, ok := perms.Codes[m.Email]; ok { 151 code = c 152 } 153 } 154 if perms.ShortCodes != nil { 155 if c, ok := perms.ShortCodes[m.Email]; ok { 156 code = c 157 } 158 } 159 if code != "" { 160 u := inst.SubDomain(s.AppSlug) 161 u.Path = s.PreviewPath 162 u.RawQuery = url.Values{"sharecode": {code}}.Encode() 163 return u.String() 164 } 165 } 166 167 query := url.Values{"state": {state}} 168 path := fmt.Sprintf("/sharings/%s/discovery", s.SID) 169 return inst.PageURL(path, query) 170 } 171 172 // SendMail sends an invitation mail to a recipient 173 func (m *Member) SendMail(inst *instance.Instance, s *Sharing, sharer, description, link string) error { 174 addr := &mail.Address{ 175 Email: m.Email, 176 Name: m.PrimaryName(), 177 } 178 sharerMail, _ := inst.SettingsEMail() 179 var action string 180 if s.ReadOnlyRules() || m.ReadOnly { 181 action = inst.Translate("Mail Sharing Request Action Read") 182 } else { 183 action = inst.Translate("Mail Sharing Request Action Write") 184 } 185 docType := getDocumentType(inst, s) 186 mailValues := map[string]interface{}{ 187 "SharerPublicName": sharer, 188 "SharerEmail": sharerMail, 189 "Action": action, 190 "Description": description, 191 "DocType": docType, 192 "SharingLink": link, 193 } 194 msg, err := job.NewMessage(mail.Options{ 195 Mode: "from", 196 To: []*mail.Address{addr}, 197 TemplateName: "sharing_request", 198 TemplateValues: mailValues, 199 RecipientName: addr.Name, 200 Layout: mail.CozyCloudLayout, 201 }) 202 if err != nil { 203 return err 204 } 205 _, err = job.System().PushJob(inst, &job.JobRequest{ 206 WorkerType: "sendmail", 207 Message: msg, 208 }) 209 return err 210 } 211 212 func getDocumentType(inst *instance.Instance, s *Sharing) string { 213 rule := s.FirstFilesRule() 214 if rule == nil { 215 if len(s.Rules) > 0 && s.Rules[0].DocType == consts.BitwardenOrganizations { 216 return inst.Translate("Notification Sharing Type Organization") 217 } 218 return inst.Translate("Notification Sharing Type Document") 219 } 220 _, err := inst.VFS().FileByID(rule.Values[0]) 221 if err != nil { 222 return inst.Translate("Notification Sharing Type Directory") 223 } 224 return inst.Translate("Notification Sharing Type File") 225 } 226 227 // CreateShortcut is used to create a shortcut for a Cozy to Cozy sharing that 228 // has not yet been accepted. 229 func (s *Sharing) CreateShortcut(inst *instance.Instance, previewURL string, seen bool) error { 230 dir, err := EnsureSharedWithMeDir(inst) 231 if err != nil { 232 return err 233 } 234 235 body := shortcut.Generate(previewURL) 236 cm := vfs.NewCozyMetadata(s.Members[0].Instance) 237 fileDoc, err := vfs.NewFileDoc( 238 s.Description+".url", 239 dir.DocID, 240 int64(len(body)), 241 nil, // Let the VFS compute the md5sum 242 consts.ShortcutMimeType, 243 "shortcut", 244 cm.UpdatedAt, 245 false, // Not executable 246 false, // Not trashed 247 false, // Not encrypted 248 nil, // No tags 249 ) 250 if err != nil { 251 return err 252 } 253 fileDoc.CozyMetadata = cm 254 status := "new" 255 if seen { 256 status = "seen" 257 } 258 fileDoc.Metadata = vfs.Metadata{ 259 "sharing": map[string]interface{}{ 260 "status": status, 261 }, 262 "target": map[string]interface{}{ 263 "cozyMetadata": map[string]interface{}{ 264 "instance": s.Members[0].Instance, 265 }, 266 "_type": s.Rules[0].DocType, 267 "mime": s.Rules[0].Mime, 268 }, 269 } 270 fileDoc.AddReferencedBy(couchdb.DocReference{ 271 ID: s.SID, 272 Type: consts.Sharings, 273 }) 274 275 file, err := inst.VFS().CreateFile(fileDoc, nil) 276 if err != nil { 277 basename := fileDoc.DocName 278 for i := 2; i < 100; i++ { 279 fileDoc.DocName = fmt.Sprintf("%s (%d)", basename, i) 280 file, err = inst.VFS().CreateFile(fileDoc, nil) 281 if err == nil { 282 break 283 } 284 } 285 if err != nil { 286 return err 287 } 288 } 289 _, err = file.Write(body) 290 if cerr := file.Close(); cerr != nil && err == nil { 291 err = cerr 292 } 293 if err != nil { 294 return err 295 } 296 297 s.ShortcutID = fileDoc.DocID 298 if err := couchdb.UpdateDoc(inst, s); err != nil { 299 inst.Logger().Warnf("Cannot save shortcut id %s: %s", s.ShortcutID, err) 300 } 301 302 return s.SendShortcutMail(inst, fileDoc, previewURL) 303 } 304 305 // SendShortcut sends the HTTP request to the cozy of the recipient for adding 306 // a shortcut on the recipient's instance. 307 func (m *Member) SendShortcut(inst *instance.Instance, s *Sharing, link string) error { 308 u, err := url.Parse(m.Instance) 309 if err != nil || u.Host == "" { 310 return ErrInvalidURL 311 } 312 313 creds := s.FindCredentials(m) 314 if creds == nil { 315 return ErrInvalidSharing 316 } 317 318 v := url.Values{} 319 v.Add("shortcut", "true") 320 v.Add("url", link) 321 u.RawQuery = v.Encode() 322 return m.CreateSharingRequest(inst, s, creds, u) 323 } 324 325 // SendShortcutMail will send a notification mail after a shortcut for a 326 // sharing has been created. 327 func (s *Sharing) SendShortcutMail(inst *instance.Instance, fileDoc *vfs.FileDoc, previewURL string) error { 328 sharerName := s.Members[0].PublicName 329 if sharerName == "" { 330 sharerName = inst.Translate("Sharing Empty name") 331 } 332 var action string 333 if s.ReadOnlyRules() { 334 action = inst.Translate("Mail Sharing Request Action Read") 335 } else { 336 action = inst.Translate("Mail Sharing Request Action Write") 337 } 338 targetType := getTargetType(inst, fileDoc.Metadata) 339 mailValues := map[string]interface{}{ 340 "SharerPublicName": sharerName, 341 "Action": action, 342 "TargetType": targetType, 343 "TargetName": s.Description, 344 "SharingLink": previewURL, 345 } 346 msg, err := job.NewMessage(mail.Options{ 347 Mode: "noreply", 348 TemplateName: "notifications_sharing", 349 TemplateValues: mailValues, 350 Layout: mail.CozyCloudLayout, 351 }) 352 if err != nil { 353 return err 354 } 355 _, err = job.System().PushJob(inst, &job.JobRequest{ 356 WorkerType: "sendmail", 357 Message: msg, 358 }) 359 return err 360 } 361 362 func getTargetType(inst *instance.Instance, metadata map[string]interface{}) string { 363 target, _ := metadata["target"].(map[string]interface{}) 364 if target["_type"] != consts.Files { 365 return inst.Translate("Notification Sharing Type Document") 366 } 367 if target["mime"] == nil || target["mime"] == "" { 368 return inst.Translate("Notification Sharing Type Directory") 369 } 370 return inst.Translate("Notification Sharing Type File") 371 }