worker/push/push.go
Rama master · réplica de lectura del repositorio original.
// 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,
})
}