github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/worker/push/push.go (about) 1 // Package push is the worker that sends push notifications to mobile apps. 2 package push 3 4 import ( 5 "context" 6 "crypto/ecdsa" 7 "crypto/md5" 8 "crypto/tls" 9 "encoding/binary" 10 "encoding/hex" 11 "errors" 12 "fmt" 13 "net/http" 14 "path/filepath" 15 "runtime" 16 "time" 17 18 firebase "firebase.google.com/go/v4" 19 "firebase.google.com/go/v4/messaging" 20 "github.com/cozy/cozy-stack/model/account" 21 "github.com/cozy/cozy-stack/model/instance" 22 "github.com/cozy/cozy-stack/model/job" 23 "github.com/cozy/cozy-stack/model/notification/center" 24 "github.com/cozy/cozy-stack/model/notification/huawei" 25 "github.com/cozy/cozy-stack/model/oauth" 26 "github.com/cozy/cozy-stack/pkg/config/config" 27 "github.com/cozy/cozy-stack/pkg/logger" 28 "github.com/cozy/cozy-stack/pkg/mail" 29 "google.golang.org/api/option" 30 31 fcm "github.com/appleboy/go-fcm" 32 33 apns "github.com/sideshow/apns2" 34 apns_cert "github.com/sideshow/apns2/certificate" 35 apns_payload "github.com/sideshow/apns2/payload" 36 apns_token "github.com/sideshow/apns2/token" 37 ) 38 39 var ( 40 fcmClient *messaging.Client 41 legacyFCMClient *fcm.Client 42 iosClient *apns.Client 43 huaweiClient *huawei.Client 44 ) 45 46 func init() { 47 job.AddWorker(&job.WorkerConfig{ 48 WorkerType: "push", 49 Concurrency: runtime.NumCPU(), 50 MaxExecCount: 1, 51 Timeout: 10 * time.Second, 52 WorkerInit: Init, 53 WorkerFunc: Worker, 54 }) 55 } 56 57 // Init initializes the necessary global clients 58 func Init() (err error) { 59 conf := config.GetConfig().Notifications 60 61 if conf.FCMCredentialsFile != "" { 62 ctx := context.Background() 63 creds := option.WithCredentialsFile(conf.FCMCredentialsFile) 64 app, err := firebase.NewApp(ctx, nil, creds) 65 if err != nil { 66 logger.WithNamespace("push").Warnf("%s", err) 67 return err 68 } 69 fcmClient, err = app.Messaging(ctx) 70 if err != nil { 71 logger.WithNamespace("push").Warnf("%s", err) 72 return err 73 } 74 logger.WithNamespace("push"). 75 Infof("Initialized FCM client with credentials file") 76 } 77 78 if conf.AndroidAPIKey != "" { 79 if conf.FCMServer != "" { 80 legacyFCMClient, err = fcm.NewClient(conf.AndroidAPIKey, fcm.WithEndpoint(conf.FCMServer)) 81 } else { 82 legacyFCMClient, err = fcm.NewClient(conf.AndroidAPIKey) 83 } 84 logger.WithNamespace("push").Infof("Initialized FCM client with Android API Key") 85 if err != nil { 86 logger.WithNamespace("push").Warnf("%s", err) 87 return 88 } 89 } 90 91 if conf.IOSCertificateKeyPath != "" { 92 var authKey *ecdsa.PrivateKey 93 var certificateKey tls.Certificate 94 95 switch filepath.Ext(conf.IOSCertificateKeyPath) { 96 case ".p12": 97 certificateKey, err = apns_cert.FromP12File( 98 conf.IOSCertificateKeyPath, conf.IOSCertificatePassword) 99 case ".pem": 100 certificateKey, err = apns_cert.FromPemFile( 101 conf.IOSCertificateKeyPath, conf.IOSCertificatePassword) 102 case ".p8": 103 authKey, err = apns_token.AuthKeyFromFile(conf.IOSCertificateKeyPath) 104 default: 105 err = errors.New("wrong certificate key extension") 106 } 107 if err != nil { 108 return err 109 } 110 111 if authKey != nil { 112 t := &apns_token.Token{ 113 AuthKey: authKey, 114 KeyID: conf.IOSKeyID, 115 TeamID: conf.IOSTeamID, 116 } 117 iosClient = apns.NewTokenClient(t) 118 } else { 119 iosClient = apns.NewClient(certificateKey) 120 } 121 if conf.Development { 122 iosClient = iosClient.Development() 123 } else { 124 iosClient = iosClient.Production() 125 } 126 } 127 128 if conf.HuaweiSendMessagesURL != "" { 129 huaweiClient, err = huawei.NewClient(conf) 130 if err != nil { 131 return err 132 } 133 } 134 135 return 136 } 137 138 // Worker is the worker that send push messages. 139 func Worker(ctx *job.TaskContext) error { 140 var msg center.PushMessage 141 if err := ctx.UnmarshalMessage(&msg); err != nil { 142 return err 143 } 144 cs, err := oauth.GetNotifiables(ctx.Instance) 145 if err != nil { 146 return err 147 } 148 slug := msg.Slug() 149 seen := make(map[string]struct{}) 150 nbSent := 0 151 152 // First, try to send the notification to the dedicated app 153 for _, c := range cs { 154 if _, ok := seen[c.NotificationDeviceToken]; ok { 155 continue 156 } 157 if c.Flagship { 158 continue 159 } 160 seen[c.NotificationDeviceToken] = struct{}{} 161 if err := push(ctx, c, &msg); err == nil { 162 nbSent++ 163 if nbSent >= 10 { 164 ctx.Logger().Warnf("too many notifiable devices for %s", slug) 165 return nil 166 } 167 } else { 168 ctx.Logger(). 169 WithFields(logger.Fields{ 170 "device_id": c.ID(), 171 "device_platform": c.NotificationPlatform, 172 }). 173 Warnf("could not send notification on device: %s", err) 174 } 175 } 176 if nbSent > 0 { 177 return nil 178 } 179 180 // If no dedicated app, try to send the notification to the flagship app 181 name := slug 182 if str, ok := msg.Data["appName"].(string); ok { 183 name = str 184 } 185 if name != "" { 186 msg.Title = fmt.Sprintf("%s - %s", name, msg.Title) 187 } 188 for _, c := range cs { 189 if _, ok := seen[c.NotificationDeviceToken]; ok { 190 continue 191 } 192 if !c.Flagship { 193 continue 194 } 195 seen[c.NotificationDeviceToken] = struct{}{} 196 if err := push(ctx, c, &msg); err == nil { 197 nbSent++ 198 if nbSent >= 10 { 199 ctx.Logger().Warnf("too many notifiable flagship apps") 200 return nil 201 } 202 } else { 203 ctx.Logger(). 204 WithFields(logger.Fields{ 205 "device_id": c.ID(), 206 "device_platform": c.NotificationPlatform, 207 }). 208 Warnf("could not send notification on device: %s", err) 209 } 210 } 211 if nbSent > 0 { 212 return nil 213 } 214 215 // Else, we fallback to send the notifiation by email 216 sendFallbackMail(ctx.Instance, msg.MailFallback) 217 return nil 218 } 219 220 func push(ctx *job.TaskContext, c *oauth.Client, msg *center.PushMessage) error { 221 switch c.NotificationPlatform { 222 case oauth.PlatformFirebase, "android", "ios": 223 return pushToFirebase(ctx, c, msg) 224 case oauth.PlatformAPNS: 225 return pushToAPNS(ctx, c, msg) 226 case oauth.PlatformHuawei: 227 return pushToHuawei(ctx, c, msg) 228 default: 229 return fmt.Errorf("notifications: unknown platform %q", c.NotificationPlatform) 230 } 231 } 232 233 // Firebase Cloud Messaging HTTP Protocol 234 // https://firebase.google.com/docs/cloud-messaging 235 func pushToFirebase(ctx *job.TaskContext, c *oauth.Client, msg *center.PushMessage) error { 236 slug := msg.Slug() 237 if c.Flagship { 238 slug = "" 239 } 240 241 client := getFirebaseClient(slug, ctx.Instance.ContextName) 242 243 if client == nil { 244 return pushToLegacyFirebase(ctx, c, msg) 245 } 246 247 var priority string 248 if msg.Priority == "high" { 249 priority = "high" 250 } 251 252 var hashedSource []byte 253 if msg.Collapsible { 254 hashedSource = hashSource(msg.Source) 255 } else { 256 hashedSource = hashSource(msg.Source + msg.NotificationID) 257 } 258 259 notification := &messaging.Message{ 260 Token: c.NotificationDeviceToken, 261 Data: prepareAndroidData(msg, hashedSource), 262 Notification: &messaging.Notification{ 263 Title: msg.Title, 264 Body: msg.Message, 265 }, 266 Android: &messaging.AndroidConfig{ 267 Priority: priority, 268 Notification: &messaging.AndroidNotification{ 269 Sound: msg.Sound, 270 }, 271 }, 272 } 273 274 if msg.Collapsible { 275 notification.Android.CollapseKey = hex.EncodeToString(hashedSource) 276 } 277 278 ctx.Logger().Infof("Firebase send: %#v", notification) 279 messageID, err := client.Send(ctx.Context, notification) 280 if err != nil { 281 ctx.Logger().Warnf("Error during firebase send: %s", err) 282 if messaging.IsUnregistered(err) || messaging.IsSenderIDMismatch(err) { 283 _ = c.Delete(ctx.Instance) 284 } 285 return err 286 } 287 288 ctx.Logger().Debugf("Successfully sent message: %s", messageID) 289 return nil 290 } 291 292 func pushToLegacyFirebase(ctx *job.TaskContext, c *oauth.Client, msg *center.PushMessage) error { 293 slug := msg.Slug() 294 if c.Flagship { 295 slug = "" 296 } 297 298 client := getLegacyFirebaseClient(slug, ctx.Instance.ContextName) 299 300 if client == nil { 301 ctx.Logger().Warn("Could not send android notification: not configured") 302 return nil 303 } 304 305 var priority string 306 if msg.Priority == "high" { 307 priority = "high" 308 } 309 310 var hashedSource []byte 311 if msg.Collapsible { 312 hashedSource = hashSource(msg.Source) 313 } else { 314 hashedSource = hashSource(msg.Source + msg.NotificationID) 315 } 316 317 notification := &fcm.Message{ 318 To: c.NotificationDeviceToken, 319 Priority: priority, 320 ContentAvailable: true, 321 Notification: &fcm.Notification{ 322 Sound: msg.Sound, 323 Title: msg.Title, 324 Body: msg.Message, 325 }, 326 Data: prepareLegacyAndroidData(msg, hashedSource), 327 } 328 329 if msg.Collapsible { 330 notification.CollapseKey = hex.EncodeToString(hashedSource) 331 } 332 333 res, err := client.Send(notification) 334 if err != nil { 335 ctx.Logger().Warnf("Error during fcm send: %s", err) 336 return err 337 } 338 if res.Failure == 0 { 339 return nil 340 } 341 342 for _, result := range res.Results { 343 if result.Unregistered() { 344 _ = c.Delete(ctx.Instance) 345 } 346 if err = result.Error; err != nil { 347 return err 348 } 349 } 350 return nil 351 } 352 353 func prepareAndroidData(msg *center.PushMessage, hashedSource []byte) map[string]string { 354 // notID should be an integer, we take the first 32bits of the hashed source 355 // value. 356 notID := int32(binary.BigEndian.Uint32(hashedSource[:4])) 357 if notID < 0 { 358 notID = -notID 359 } 360 361 data := map[string]string{ 362 // Fields required by phonegap-plugin-push 363 // see: https://github.com/phonegap/phonegap-plugin-push/blob/master/docs/PAYLOAD.md#android-behaviour 364 "notId": fmt.Sprintf("%v", notID), 365 "title": msg.Title, 366 "body": msg.Message, 367 } 368 for k, v := range msg.Data { 369 data[k] = fmt.Sprintf("%v", v) 370 } 371 return data 372 } 373 374 func prepareLegacyAndroidData(msg *center.PushMessage, hashedSource []byte) map[string]interface{} { 375 // notID should be an integer, we take the first 32bits of the hashed source 376 // value. 377 notID := int32(binary.BigEndian.Uint32(hashedSource[:4])) 378 if notID < 0 { 379 notID = -notID 380 } 381 382 data := map[string]interface{}{ 383 // Fields required by phonegap-plugin-push 384 // see: https://github.com/phonegap/phonegap-plugin-push/blob/master/docs/PAYLOAD.md#android-behaviour 385 "notId": notID, 386 "title": msg.Title, 387 "body": msg.Message, 388 } 389 for k, v := range msg.Data { 390 data[k] = v 391 } 392 return data 393 } 394 395 func getFirebaseClient(slug, contextName string) *messaging.Client { 396 if slug == "" { 397 return fcmClient 398 } 399 typ, err := account.TypeInfo(slug, contextName) 400 if err == nil && len(typ.FCMCredentials) > 0 { 401 ctx := context.Background() 402 creds := option.WithCredentialsJSON(typ.FCMCredentials) 403 app, err := firebase.NewApp(ctx, nil, creds) 404 if err != nil { 405 return fcmClient 406 } 407 client, err := app.Messaging(ctx) 408 if err != nil { 409 return fcmClient 410 } 411 return client 412 } 413 return fcmClient 414 } 415 416 func getLegacyFirebaseClient(slug, contextName string) *fcm.Client { 417 if slug == "" { 418 return legacyFCMClient 419 } 420 typ, err := account.TypeInfo(slug, contextName) 421 if err == nil && typ.AndroidAPIKey != "" { 422 client, err := fcm.NewClient(typ.AndroidAPIKey) 423 if err != nil { 424 return nil 425 } 426 return client 427 } 428 return legacyFCMClient 429 } 430 431 func pushToAPNS(ctx *job.TaskContext, c *oauth.Client, msg *center.PushMessage) error { 432 if iosClient == nil { 433 ctx.Logger().Warn("Could not send iOS notification: not configured") 434 return nil 435 } 436 437 var priority int 438 if msg.Priority == "normal" { 439 priority = apns.PriorityLow 440 } else { 441 priority = apns.PriorityHigh 442 } 443 444 payload := apns_payload.NewPayload(). 445 AlertTitle(msg.Title). 446 Alert(msg.Message). 447 Sound(msg.Sound) 448 449 for k, v := range msg.Data { 450 payload.Custom(k, v) 451 } 452 453 notification := &apns.Notification{ 454 DeviceToken: c.NotificationDeviceToken, 455 Payload: payload, 456 Priority: priority, 457 CollapseID: hex.EncodeToString(hashSource(msg.Source)), // CollapseID should not exceed 64 bytes 458 } 459 460 res, err := iosClient.PushWithContext(ctx, notification) 461 if err != nil { 462 return err 463 } 464 if res.StatusCode == http.StatusGone { 465 _ = c.Delete(ctx.Instance) 466 } 467 if res.StatusCode != http.StatusOK { 468 return fmt.Errorf("failed to push apns notification: %d %s", res.StatusCode, res.Reason) 469 } 470 return nil 471 } 472 473 func pushToHuawei(ctx *job.TaskContext, c *oauth.Client, msg *center.PushMessage) error { 474 if huaweiClient == nil { 475 ctx.Logger().Warn("Could not send Huawei notification: not configured") 476 return nil 477 } 478 479 var hashedSource []byte 480 if msg.Collapsible { 481 hashedSource = hashSource(msg.Source) 482 } else { 483 hashedSource = hashSource(msg.Source + msg.NotificationID) 484 } 485 data := prepareLegacyAndroidData(msg, hashedSource) 486 487 notification := huawei.NewNotification(msg.Title, msg.Message, c.NotificationDeviceToken, data) 488 ctx.Logger().Infof("Huawei Push Kit send: %#v", notification) 489 unregistered, err := huaweiClient.PushWithContext(ctx, notification) 490 if unregistered { 491 _ = c.Delete(ctx.Instance) 492 } 493 if err != nil { 494 ctx.Logger().Warnf("Error during huawei send: %s", err) 495 } 496 return err 497 } 498 499 func hashSource(source string) []byte { 500 h := md5.New() 501 _, _ = h.Write([]byte(source)) 502 return h.Sum(nil) 503 } 504 505 func sendFallbackMail(inst *instance.Instance, email *mail.Options) { 506 if inst == nil || email == nil { 507 return 508 } 509 msg, err := job.NewMessage(&email) 510 if err != nil { 511 return 512 } 513 _, _ = job.System().PushJob(inst, &job.JobRequest{ 514 WorkerType: "sendmail", 515 Message: msg, 516 }) 517 }