github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/model/notification/center/notification_center.go (about)

     1  package center
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"strings"
     7  	"time"
     8  
     9  	"github.com/cozy/cozy-stack/model/app"
    10  	"github.com/cozy/cozy-stack/model/instance"
    11  	"github.com/cozy/cozy-stack/model/instance/lifecycle"
    12  	"github.com/cozy/cozy-stack/model/job"
    13  	"github.com/cozy/cozy-stack/model/notification"
    14  	"github.com/cozy/cozy-stack/model/oauth"
    15  	"github.com/cozy/cozy-stack/model/permission"
    16  	"github.com/cozy/cozy-stack/model/vfs"
    17  	"github.com/cozy/cozy-stack/pkg/consts"
    18  	"github.com/cozy/cozy-stack/pkg/couchdb"
    19  	"github.com/cozy/cozy-stack/pkg/couchdb/mango"
    20  	"github.com/cozy/cozy-stack/pkg/mail"
    21  	multierror "github.com/hashicorp/go-multierror"
    22  )
    23  
    24  const (
    25  	// NotificationDiskQuota category for sending alert when reaching 90% of disk
    26  	// usage quota.
    27  	NotificationDiskQuota = "disk-quota"
    28  	// NotificationOAuthClients category for sending alert when exceeding the
    29  	// connected OAuth clients limit.
    30  	NotificationOAuthClients = "oauth-clients"
    31  )
    32  
    33  var (
    34  	stackNotifications = map[string]*notification.Properties{
    35  		NotificationDiskQuota: {
    36  			Description:  "Warn about the diskquota reaching a high level",
    37  			Collapsible:  true,
    38  			Stateful:     true,
    39  			MailTemplate: "notifications_diskquota",
    40  			MinInterval:  7 * 24 * time.Hour,
    41  		},
    42  		NotificationOAuthClients: {
    43  			Description:  "Warn about the connected OAuth clients count exceeding the offer limit",
    44  			Collapsible:  false,
    45  			Stateful:     false,
    46  			MailTemplate: "notifications_oauthclients",
    47  		},
    48  	}
    49  )
    50  
    51  func init() {
    52  	vfs.RegisterDiskQuotaAlertCallback(func(domain string, capsizeExceeded bool) {
    53  		i, err := lifecycle.GetInstance(domain)
    54  		if err != nil {
    55  			return
    56  		}
    57  
    58  		title := i.Translate("Notifications Disk Quota Close Title")
    59  		message := i.Translate("Notifications Disk Quota Close Message")
    60  		offersLink, err := i.ManagerURL(instance.ManagerPremiumURL)
    61  		if err != nil {
    62  			return
    63  		}
    64  		cozyDriveLink := i.SubDomain(consts.DriveSlug)
    65  		redirectLink := consts.SettingsSlug + "/#/storage"
    66  
    67  		n := &notification.Notification{
    68  			Title:   title,
    69  			Message: message,
    70  			Slug:    consts.SettingsSlug,
    71  			State:   capsizeExceeded,
    72  			Data: map[string]interface{}{
    73  				// For email notification
    74  				"OffersLink":    offersLink,
    75  				"CozyDriveLink": cozyDriveLink.String(),
    76  
    77  				// For mobile push notification
    78  				"appName":      "",
    79  				"redirectLink": redirectLink,
    80  			},
    81  			PreferredChannels: []string{"mobile"},
    82  		}
    83  		_ = PushStack(domain, NotificationDiskQuota, n)
    84  	})
    85  
    86  	oauth.RegisterClientsLimitAlertCallback(func(i *instance.Instance, clientName string, clientsLimit int) {
    87  		devicesLink := i.SubDomain(consts.SettingsSlug)
    88  		devicesLink.Fragment = "/connectedDevices"
    89  
    90  		var offersLink string
    91  		if i.HasPremiumLinksEnabled() {
    92  			var err error
    93  			offersLink, err = i.ManagerURL(instance.ManagerPremiumURL)
    94  			if err != nil {
    95  				i.Logger().Errorf("Could not get instance Premium Manager URL: %s", err.Error())
    96  			}
    97  		}
    98  
    99  		n := &notification.Notification{
   100  			Title: i.Translate("Notifications OAuth Clients Subject"),
   101  			Slug:  consts.SettingsSlug,
   102  			Data: map[string]interface{}{
   103  				"ClientName":   clientName,
   104  				"ClientsLimit": clientsLimit,
   105  				"OffersLink":   offersLink,
   106  				"DevicesLink":  devicesLink.String(),
   107  			},
   108  			PreferredChannels: []string{"mail"},
   109  		}
   110  		PushStack(i.DomainName(), NotificationOAuthClients, n)
   111  	})
   112  }
   113  
   114  // PushStack creates and sends a new notification where the source is the stack.
   115  func PushStack(domain string, category string, n *notification.Notification) error {
   116  	inst, err := lifecycle.GetInstance(domain)
   117  	if err != nil {
   118  		return err
   119  	}
   120  	n.Originator = "stack"
   121  	n.Category = category
   122  	p := stackNotifications[category]
   123  	if p == nil {
   124  		return ErrCategoryNotFound
   125  	}
   126  	return makePush(inst, p, n)
   127  }
   128  
   129  // PushCLI creates and sends a new notification where the source is a CLI
   130  // client which provides both the notification content and its properties.
   131  func PushCLI(domain string, p *notification.Properties, n *notification.Notification) error {
   132  	inst, err := lifecycle.GetInstance(domain)
   133  	if err != nil {
   134  		return err
   135  	}
   136  	n.Originator = "cli"
   137  	return makePush(inst, p, n)
   138  }
   139  
   140  // Push creates and sends a new notification in database. This method verifies
   141  // the permissions associated with this creation in order to check that it is
   142  // granted to create a notification and to extract its source.
   143  func Push(inst *instance.Instance, perm *permission.Permission, n *notification.Notification) error {
   144  	if n.Title == "" {
   145  		return ErrBadNotification
   146  	}
   147  
   148  	var p notification.Properties
   149  	switch perm.Type {
   150  	case permission.TypeOauth:
   151  		c, ok := perm.Client.(*oauth.Client)
   152  		if !ok {
   153  			return ErrUnauthorized
   154  		}
   155  		n.Slug = ""
   156  		if slug := oauth.GetLinkedAppSlug(c.SoftwareID); slug != "" {
   157  			n.Slug = slug
   158  			m, err := app.GetWebappBySlug(inst, slug)
   159  			if err != nil {
   160  				return err
   161  			}
   162  			notifications := m.Notifications()
   163  			if notifications == nil {
   164  				return ErrNoCategory
   165  			}
   166  			p, ok = notifications[n.Category]
   167  		} else if c.Notifications != nil {
   168  			p, ok = c.Notifications[n.Category]
   169  		}
   170  		if !ok {
   171  			return ErrCategoryNotFound
   172  		}
   173  		n.Originator = "oauth"
   174  	case permission.TypeWebapp:
   175  		slug := strings.TrimPrefix(perm.SourceID, consts.Apps+"/")
   176  		m, err := app.GetWebappBySlug(inst, slug)
   177  		if err != nil {
   178  			return err
   179  		}
   180  		notifications := m.Notifications()
   181  		if notifications == nil {
   182  			return ErrNoCategory
   183  		}
   184  		var ok bool
   185  		p, ok = notifications[n.Category]
   186  		if !ok {
   187  			return ErrCategoryNotFound
   188  		}
   189  		n.Slug = m.Slug()
   190  		n.Originator = "app"
   191  	case permission.TypeKonnector:
   192  		slug := strings.TrimPrefix(perm.SourceID, consts.Apps+"/")
   193  		m, err := app.GetKonnectorBySlug(inst, slug)
   194  		if err != nil {
   195  			return err
   196  		}
   197  		notifications := m.Notifications()
   198  		if notifications == nil {
   199  			return ErrNoCategory
   200  		}
   201  		var ok bool
   202  		p, ok = notifications[n.Category]
   203  		if !ok {
   204  			return ErrCategoryNotFound
   205  		}
   206  		n.Slug = m.Slug()
   207  		n.Originator = "konnector"
   208  	default:
   209  		return ErrUnauthorized
   210  	}
   211  
   212  	return makePush(inst, &p, n)
   213  }
   214  
   215  func makePush(inst *instance.Instance, p *notification.Properties, n *notification.Notification) error {
   216  	lastSent := time.Now()
   217  	skipNotification := false
   218  
   219  	// XXX: for retro-compatibility, we do not yet block applications from
   220  	// sending notification from unknown category.
   221  	if p != nil && p.Stateful {
   222  		last, err := findLastNotification(inst, n.Source())
   223  		if err != nil {
   224  			return err
   225  		}
   226  		// when the state is the same for the last notification from this source,
   227  		// we do not bother sending or creating a new notification.
   228  		if last != nil {
   229  			if last.State == n.State {
   230  				inst.Logger().WithNamespace("notifications").
   231  					Debugf("Notification %v was not sent (collapsed by same state %s)", p, n.State)
   232  				return nil
   233  			}
   234  			if p.MinInterval > 0 && time.Until(last.LastSent) <= p.MinInterval {
   235  				skipNotification = true
   236  			}
   237  		}
   238  
   239  		if p.Stateful && !skipNotification {
   240  			if b, ok := n.State.(bool); ok && !b {
   241  				skipNotification = true
   242  			} else if i, ok := n.State.(int); ok && i == 0 {
   243  				skipNotification = true
   244  			}
   245  		}
   246  
   247  		if skipNotification && last != nil {
   248  			lastSent = last.LastSent
   249  		}
   250  	}
   251  
   252  	preferredChannels := ensureMailFallback(n.PreferredChannels)
   253  	at := n.At
   254  
   255  	n.NID = ""
   256  	n.NRev = ""
   257  	n.SourceID = n.Source()
   258  	n.CreatedAt = time.Now()
   259  	n.LastSent = lastSent
   260  	n.PreferredChannels = nil
   261  	n.At = ""
   262  
   263  	if err := couchdb.CreateDoc(inst, n); err != nil {
   264  		return err
   265  	}
   266  	if skipNotification {
   267  		return nil
   268  	}
   269  
   270  	var errm error
   271  	log := inst.Logger().WithNamespace("notifications")
   272  	for _, channel := range preferredChannels {
   273  		switch channel {
   274  		case "mobile":
   275  			if p != nil {
   276  				log.Infof("Sending push %#v: %v", p, n.State)
   277  				err := sendPush(inst, p, n, at)
   278  				if err == nil {
   279  					return nil
   280  				}
   281  				log.Errorf("Error while sending push %#v: %v. Error: %v", p, n.State, err)
   282  				errm = multierror.Append(errm, err)
   283  			}
   284  		case "mail":
   285  			err := sendMail(inst, p, n, at)
   286  			if err == nil {
   287  				return nil
   288  			}
   289  			errm = multierror.Append(errm, err)
   290  		case "sms":
   291  			log.Infof("Sending SMS: %v", n.State)
   292  			err := sendSMS(inst, p, n, at)
   293  			if err == nil {
   294  				return nil
   295  			}
   296  			log.Errorf("Error while sending sms: %s", err)
   297  			errm = multierror.Append(errm, err)
   298  		default:
   299  			err := fmt.Errorf("Unknown channel for notification: %s", channel)
   300  			errm = multierror.Append(errm, err)
   301  		}
   302  	}
   303  	return errm
   304  }
   305  
   306  func findLastNotification(inst *instance.Instance, source string) (*notification.Notification, error) {
   307  	var notifs []*notification.Notification
   308  	req := &couchdb.FindRequest{
   309  		UseIndex: "by-source-id",
   310  		Selector: mango.Equal("source_id", source),
   311  		Sort: mango.SortBy{
   312  			{Field: "source_id", Direction: mango.Desc},
   313  			{Field: "created_at", Direction: mango.Desc},
   314  		},
   315  		Limit: 1,
   316  	}
   317  	err := couchdb.FindDocs(inst, consts.Notifications, req, &notifs)
   318  	if err != nil {
   319  		return nil, err
   320  	}
   321  	if len(notifs) == 0 {
   322  		return nil, nil
   323  	}
   324  	return notifs[0], nil
   325  }
   326  
   327  func sendPush(inst *instance.Instance,
   328  	p *notification.Properties,
   329  	n *notification.Notification,
   330  	at string,
   331  ) error {
   332  	if !hasNotifiableDevice(inst) {
   333  		return errors.New("No device with push notification")
   334  	}
   335  	email := buildMailMessage(p, n)
   336  	push := PushMessage{
   337  		NotificationID: n.ID(),
   338  		Source:         n.Source(),
   339  		Title:          n.Title,
   340  		Message:        n.Message,
   341  		Priority:       n.Priority,
   342  		Sound:          n.Sound,
   343  		Data:           n.Data,
   344  		Collapsible:    p.Collapsible,
   345  		MailFallback:   email,
   346  	}
   347  	msg, err := job.NewMessage(&push)
   348  	if err != nil {
   349  		return err
   350  	}
   351  	return pushJobOrTrigger(inst, msg, "push", at)
   352  }
   353  
   354  func sendMail(inst *instance.Instance,
   355  	p *notification.Properties,
   356  	n *notification.Notification,
   357  	at string,
   358  ) error {
   359  	email := buildMailMessage(p, n)
   360  	if email == nil {
   361  		return nil
   362  	}
   363  	msg, err := job.NewMessage(&email)
   364  	if err != nil {
   365  		return err
   366  	}
   367  	return pushJobOrTrigger(inst, msg, "sendmail", at)
   368  }
   369  
   370  func sendSMS(inst *instance.Instance,
   371  	p *notification.Properties,
   372  	n *notification.Notification,
   373  	at string,
   374  ) error {
   375  	email := buildMailMessage(p, n)
   376  	msg, err := job.NewMessage(&SMS{
   377  		NotificationID: n.ID(),
   378  		Message:        n.Message,
   379  		MailFallback:   email,
   380  	})
   381  	if err != nil {
   382  		return err
   383  	}
   384  	return pushJobOrTrigger(inst, msg, "sms", at)
   385  }
   386  
   387  func buildMailMessage(p *notification.Properties, n *notification.Notification) *mail.Options {
   388  	email := mail.Options{Mode: mail.ModeFromStack}
   389  
   390  	// Notifications from the stack have their own mail templates defined
   391  	if p != nil && p.MailTemplate != "" {
   392  		email.TemplateName = p.MailTemplate
   393  		email.TemplateValues = n.Data
   394  	} else if n.ContentHTML != "" {
   395  		email.Subject = n.Title
   396  		email.Parts = make([]*mail.Part, 0, 2)
   397  		if n.Content != "" {
   398  			email.Parts = append(email.Parts,
   399  				&mail.Part{Body: n.Content, Type: "text/plain"})
   400  		}
   401  		if n.ContentHTML != "" {
   402  			email.Parts = append(email.Parts,
   403  				&mail.Part{Body: n.ContentHTML, Type: "text/html"})
   404  		}
   405  	} else {
   406  		return nil
   407  	}
   408  
   409  	return &email
   410  }
   411  
   412  func pushJobOrTrigger(inst *instance.Instance, msg job.Message, worker, at string) error {
   413  	if at == "" {
   414  		_, err := job.System().PushJob(inst, &job.JobRequest{
   415  			WorkerType: worker,
   416  			Message:    msg,
   417  		})
   418  		return err
   419  	}
   420  	t, err := job.NewTrigger(inst, job.TriggerInfos{
   421  		Type:       "@at",
   422  		WorkerType: worker,
   423  		Arguments:  at,
   424  	}, msg)
   425  	if err != nil {
   426  		return err
   427  	}
   428  	return job.System().AddTrigger(t)
   429  }
   430  
   431  func ensureMailFallback(channels []string) []string {
   432  	for _, c := range channels {
   433  		if c == "mail" {
   434  			return channels
   435  		}
   436  	}
   437  	return append(channels, "mail")
   438  }
   439  
   440  func hasNotifiableDevice(inst *instance.Instance) bool {
   441  	cs, err := oauth.GetNotifiables(inst)
   442  	return err == nil && len(cs) > 0
   443  }