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 }