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  }