// Package push is the worker that sends push notifications to mobile apps. package push import ( "context" "crypto/ecdsa" "crypto/md5" "crypto/tls" "encoding/binary" "encoding/hex" "errors" "fmt" "net/http" "path/filepath" "runtime" "time" firebase "firebase.google.com/go/v4" "firebase.google.com/go/v4/messaging" "github.com/cozy/cozy-stack/model/account" "github.com/cozy/cozy-stack/model/instance" "github.com/cozy/cozy-stack/model/job" "github.com/cozy/cozy-stack/model/notification/center" "github.com/cozy/cozy-stack/model/notification/huawei" "github.com/cozy/cozy-stack/model/oauth" "github.com/cozy/cozy-stack/pkg/config/config" "github.com/cozy/cozy-stack/pkg/logger" "github.com/cozy/cozy-stack/pkg/mail" "google.golang.org/api/option" apns "github.com/sideshow/apns2" apns_cert "github.com/sideshow/apns2/certificate" apns_payload "github.com/sideshow/apns2/payload" apns_token "github.com/sideshow/apns2/token" ) var ( fcmClient *messaging.Client iosClient *apns.Client huaweiClient *huawei.Client ) func init() { job.AddWorker(&job.WorkerConfig{ WorkerType: "push", Concurrency: runtime.NumCPU(), MaxExecCount: 1, Timeout: 10 * time.Second, WorkerInit: Init, WorkerFunc: Worker, }) } // Init initializes the necessary global clients func Init() (err error) { conf := config.GetConfig().Notifications if conf.FCMCredentialsFile != "" { ctx := context.Background() creds := option.WithCredentialsFile(conf.FCMCredentialsFile) app, err := firebase.NewApp(ctx, nil, creds) if err != nil { logger.WithNamespace("push").Warnf("%s", err) return err } fcmClient, err = app.Messaging(ctx) if err != nil { logger.WithNamespace("push").Warnf("%s", err) return err } logger.WithNamespace("push"). Infof("Initialized FCM client with credentials file") } if conf.IOSCertificateKeyPath != "" { var authKey *ecdsa.PrivateKey var certificateKey tls.Certificate switch filepath.Ext(conf.IOSCertificateKeyPath) { case ".p12": certificateKey, err = apns_cert.FromP12File( conf.IOSCertificateKeyPath, conf.IOSCertificatePassword) case ".pem": certificateKey, err = apns_cert.FromPemFile( conf.IOSCertificateKeyPath, conf.IOSCertificatePassword) case ".p8": authKey, err = apns_token.AuthKeyFromFile(conf.IOSCertificateKeyPath) default: err = errors.New("wrong certificate key extension") } if err != nil { return err } if authKey != nil { t := &apns_token.Token{ AuthKey: authKey, KeyID: conf.IOSKeyID, TeamID: conf.IOSTeamID, } iosClient = apns.NewTokenClient(t) } else { iosClient = apns.NewClient(certificateKey) } if conf.Development { iosClient = iosClient.Development() } else { iosClient = iosClient.Production() } } if conf.HuaweiSendMessagesURL != "" { huaweiClient, err = huawei.NewClient(conf) if err != nil { return err } } return } // Worker is the worker that send push messages. func Worker(ctx *job.TaskContext) error { var msg center.PushMessage if err := ctx.UnmarshalMessage(&msg); err != nil { return err } cs, err := oauth.GetNotifiables(ctx.Instance) if err != nil { return err } slug := msg.Slug() seen := make(map[string]struct{}) nbSent := 0 // First, try to send the notification to the dedicated app for _, c := range cs { if _, ok := seen[c.NotificationDeviceToken]; ok { continue } if c.Flagship { continue } seen[c.NotificationDeviceToken] = struct{}{} if err := push(ctx, c, &msg); err == nil { nbSent++ if nbSent >= 10 { ctx.Logger().Warnf("too many notifiable devices for %s", slug) return nil } } else { ctx.Logger(). WithFields(logger.Fields{ "device_id": c.ID(), "device_platform": c.NotificationPlatform, }). Warnf("could not send notification on device: %s", err) } } if nbSent > 0 { return nil } // If no dedicated app, try to send the notification to the flagship app name := slug if str, ok := msg.Data["appName"].(string); ok { name = str } if name != "" { msg.Title = fmt.Sprintf("%s - %s", name, msg.Title) } for _, c := range cs { if _, ok := seen[c.NotificationDeviceToken]; ok { continue } if !c.Flagship { continue } seen[c.NotificationDeviceToken] = struct{}{} if err := push(ctx, c, &msg); err == nil { nbSent++ if nbSent >= 10 { ctx.Logger().Warnf("too many notifiable flagship apps") return nil } } else { ctx.Logger(). WithFields(logger.Fields{ "device_id": c.ID(), "device_platform": c.NotificationPlatform, }). Warnf("could not send notification on device: %s", err) } } if nbSent > 0 { return nil } // Else, we fallback to send the notifiation by email sendFallbackMail(ctx.Instance, msg.MailFallback) return nil } func push(ctx *job.TaskContext, c *oauth.Client, msg *center.PushMessage) error { switch c.NotificationPlatform { case oauth.PlatformFirebase, "android", "ios": return pushToFirebase(ctx, c, msg) case oauth.PlatformAPNS: return pushToAPNS(ctx, c, msg) case oauth.PlatformHuawei: return pushToHuawei(ctx, c, msg) default: return fmt.Errorf("notifications: unknown platform %q", c.NotificationPlatform) } } // Firebase Cloud Messaging HTTP Protocol // https://firebase.google.com/docs/cloud-messaging func pushToFirebase(ctx *job.TaskContext, c *oauth.Client, msg *center.PushMessage) error { slug := msg.Slug() if c.Flagship { slug = "" } client := getFirebaseClient(slug, ctx.Instance.ContextName) if client == nil { ctx.Logger().Warn("Could not send android notification: not configured") return nil } var priority string if msg.Priority == "high" { priority = "high" } var hashedSource []byte if msg.Collapsible { hashedSource = hashSource(msg.Source) } else { hashedSource = hashSource(msg.Source + msg.NotificationID) } notification := &messaging.Message{ Token: c.NotificationDeviceToken, Data: prepareAndroidData(msg, hashedSource), Notification: &messaging.Notification{ Title: msg.Title, Body: msg.Message, }, Android: &messaging.AndroidConfig{ Priority: priority, Notification: &messaging.AndroidNotification{ Sound: msg.Sound, }, }, } if msg.Collapsible { notification.Android.CollapseKey = hex.EncodeToString(hashedSource) } ctx.Logger().Infof("Firebase send: %#v", notification) messageID, err := client.Send(ctx.Context, notification) if err != nil { ctx.Logger().Warnf("Error during firebase send: %s", err) if messaging.IsUnregistered(err) || messaging.IsSenderIDMismatch(err) { _ = c.Delete(ctx.Instance) } return err } ctx.Logger().Debugf("Successfully sent message: %s", messageID) return nil } func prepareAndroidData(msg *center.PushMessage, hashedSource []byte) map[string]string { // notID should be an integer, we take the first 32bits of the hashed source // value. notID := int32(binary.BigEndian.Uint32(hashedSource[:4])) if notID < 0 { notID = -notID } data := map[string]string{ // Fields required by phonegap-plugin-push // see: https://github.com/phonegap/phonegap-plugin-push/blob/master/docs/PAYLOAD.md#android-behaviour "notId": fmt.Sprintf("%v", notID), "title": msg.Title, "body": msg.Message, } for k, v := range msg.Data { data[k] = fmt.Sprintf("%v", v) } return data } func getFirebaseClient(slug, contextName string) *messaging.Client { if slug == "" { return fcmClient } typ, err := account.TypeInfo(slug, contextName) if err == nil && len(typ.FCMCredentials) > 0 { ctx := context.Background() creds := option.WithCredentialsJSON(typ.FCMCredentials) app, err := firebase.NewApp(ctx, nil, creds) if err != nil { return fcmClient } client, err := app.Messaging(ctx) if err != nil { return fcmClient } return client } return fcmClient } func pushToAPNS(ctx *job.TaskContext, c *oauth.Client, msg *center.PushMessage) error { if iosClient == nil { ctx.Logger().Warn("Could not send iOS notification: not configured") return nil } var priority int if msg.Priority == "normal" { priority = apns.PriorityLow } else { priority = apns.PriorityHigh } payload := apns_payload.NewPayload(). AlertTitle(msg.Title). Alert(msg.Message). Sound(msg.Sound) for k, v := range msg.Data { payload.Custom(k, v) } notification := &apns.Notification{ DeviceToken: c.NotificationDeviceToken, Payload: payload, Priority: priority, CollapseID: hex.EncodeToString(hashSource(msg.Source)), // CollapseID should not exceed 64 bytes } res, err := iosClient.PushWithContext(ctx, notification) if err != nil { return err } if res.StatusCode == http.StatusGone { _ = c.Delete(ctx.Instance) } if res.StatusCode != http.StatusOK { return fmt.Errorf("failed to push apns notification: %d %s", res.StatusCode, res.Reason) } return nil } func pushToHuawei(ctx *job.TaskContext, c *oauth.Client, msg *center.PushMessage) error { if huaweiClient == nil { ctx.Logger().Warn("Could not send Huawei notification: not configured") return nil } var hashedSource []byte if msg.Collapsible { hashedSource = hashSource(msg.Source) } else { hashedSource = hashSource(msg.Source + msg.NotificationID) } data := prepareHuaweiData(msg, hashedSource) notification := huawei.NewNotification(msg.Title, msg.Message, c.NotificationDeviceToken, data) ctx.Logger().Infof("Huawei Push Kit send: %#v", notification) unregistered, err := huaweiClient.PushWithContext(ctx, notification) if unregistered { _ = c.Delete(ctx.Instance) } if err != nil { ctx.Logger().Warnf("Error during huawei send: %s", err) } return err } func hashSource(source string) []byte { h := md5.New() _, _ = h.Write([]byte(source)) return h.Sum(nil) } func prepareHuaweiData(msg *center.PushMessage, hashedSource []byte) map[string]interface{} { // notID should be an integer, we take the first 32bits of the hashed source // value. notID := int32(binary.BigEndian.Uint32(hashedSource[:4])) if notID < 0 { notID = -notID } data := map[string]interface{}{ // Fields required by phonegap-plugin-push // see: https://github.com/phonegap/phonegap-plugin-push/blob/master/docs/PAYLOAD.md#android-behaviour "notId": notID, "title": msg.Title, "body": msg.Message, } for k, v := range msg.Data { data[k] = v } return data } func sendFallbackMail(inst *instance.Instance, email *mail.Options) { if inst == nil || email == nil { return } msg, err := job.NewMessage(&email) if err != nil { return } _, _ = job.System().PushJob(inst, &job.JobRequest{ WorkerType: "sendmail", Message: msg, }) }