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 := ¬ification.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 := ¬ification.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, ¬ifs) 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 }