github.com/cozy/cozy-stack@v0.0.0-20240327093429-939e4a21320e/worker/push/push.go (about)

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