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  }