Skip to content

Commit

Permalink
MM-53458: Migrate to the v1 API (#108)
Browse files Browse the repository at this point in the history
We use the official firebase client and move off from the legacy API.

The new API uses a service account file and generates a short lived
oauth2 token that gets refreshed periodically.

https://mattermost.atlassian.net/browse/MM-53458
  • Loading branch information
agnivade authored Sep 25, 2023
1 parent ec68e7e commit d41f502
Show file tree
Hide file tree
Showing 9 changed files with 422 additions and 77 deletions.
5 changes: 3 additions & 2 deletions config/mattermost-push-proxy.sample.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"ThrottleMemoryStoreSize":50000,
"ThrottleVaryByHeader":"X-Forwarded-For",
"EnableMetrics": false,
"SendTimeoutSec": 30,
"ApplePushSettings":[
{
"Type":"apple",
Expand All @@ -29,11 +30,11 @@
"AndroidPushSettings": [
{
"Type":"android",
"AndroidApiKey":""
"ServiceFileLocation":""
},
{
"Type":"android_rn",
"AndroidApiKey":""
"ServiceFileLocation":""
}
],
"EnableConsoleLog": true,
Expand Down
29 changes: 27 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,30 @@ module github.com/mattermost/mattermost-push-proxy
go 1.20

require (
github.com/appleboy/go-fcm v0.1.5
firebase.google.com/go/v4 v4.12.0
github.com/gorilla/handlers v1.5.1
github.com/gorilla/mux v1.8.0
github.com/kyokomi/emoji v2.2.4+incompatible
github.com/prometheus/client_golang v1.16.0
github.com/prometheus/common v0.44.0
github.com/sideshow/apns2 v0.23.0
github.com/stretchr/testify v1.8.0
github.com/stretchr/testify v1.8.1
golang.org/x/net v0.15.0
golang.org/x/oauth2 v0.11.0
google.golang.org/api v0.138.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/throttled/throttled.v1 v1.0.0
)

require (
cloud.google.com/go v0.110.6 // indirect
cloud.google.com/go/compute v1.23.0 // indirect
cloud.google.com/go/compute/metadata v0.2.3 // indirect
cloud.google.com/go/firestore v1.11.0 // indirect
cloud.google.com/go/iam v1.1.1 // indirect
cloud.google.com/go/longrunning v0.5.1 // indirect
cloud.google.com/go/storage v1.30.1 // indirect
github.com/MicahParks/keyfunc v1.9.0 // indirect
github.com/PuerkitoBio/boom v0.0.0-20140219125548-fecdef1c97ca // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
Expand All @@ -26,6 +36,11 @@ require (
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/google/s2a-go v0.1.5 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.2.5 // indirect
github.com/googleapis/gax-go/v2 v2.12.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/olekukonko/ts v0.0.0-20171002115256-78ecb04241c0 // indirect
Expand All @@ -34,9 +49,19 @@ require (
github.com/prometheus/procfs v0.11.1 // indirect
github.com/rakyll/pb v0.0.0-20160123035540-8d46b8b097ef // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/crypto v0.13.0 // indirect
golang.org/x/sync v0.3.0 // indirect
golang.org/x/sys v0.12.0 // indirect
golang.org/x/text v0.13.0 // indirect
golang.org/x/time v0.3.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/appengine/v2 v2.0.2 // indirect
google.golang.org/genproto v0.0.0-20230803162519-f966b187b2e5 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20230803162519-f966b187b2e5 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20230807174057-1744710a1577 // indirect
google.golang.org/grpc v1.57.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
185 changes: 182 additions & 3 deletions go.sum

Large diffs are not rendered by default.

173 changes: 125 additions & 48 deletions server/android_notification_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,51 +4,125 @@
package server

import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
"time"

fcm "github.com/appleboy/go-fcm"
firebase "firebase.google.com/go/v4"
"firebase.google.com/go/v4/messaging"
"github.com/kyokomi/emoji"
"golang.org/x/oauth2/google"
"google.golang.org/api/option"
)

const (
apnsAuthError = "APNS_AUTH_ERROR"
internalError = "INTERNAL"
thirdPartyAuthError = "THIRD_PARTY_AUTH_ERROR"
invalidArgument = "INVALID_ARGUMENT"
quotaExceeded = "QUOTA_EXCEEDED"
senderIDMismatch = "SENDER_ID_MISMATCH"
unregistered = "UNREGISTERED"
unavailable = "UNAVAILABLE"
tokenSourceError = "TOKEN_SOURCE_ERROR"
)

const (
scope = "https://www.googleapis.com/auth/firebase.messaging"
)

type AndroidNotificationServer struct {
metrics *metrics
logger *Logger
AndroidPushSettings AndroidPushSettings
client *messaging.Client
sendTimeout time.Duration
}

func NewAndroidNotificationServer(settings AndroidPushSettings, logger *Logger, metrics *metrics) *AndroidNotificationServer {
// serviceAccount contains a subset of the fields in service-account.json.
// It is mainly used to extract the projectID and client email for authentication.
type serviceAccount struct {
Type string `json:"type"`
ProjectID string `json:"project_id"`
ClientEmail string `json:"client_email"`
ClientID string `json:"client_id"`
AuthURI string `json:"auth_uri"`
TokenURI string `json:"token_uri"`
}

func NewAndroidNotificationServer(settings AndroidPushSettings, logger *Logger, metrics *metrics, sendTimeoutSecs int) *AndroidNotificationServer {
return &AndroidNotificationServer{
AndroidPushSettings: settings,
metrics: metrics,
logger: logger,
sendTimeout: time.Duration(sendTimeoutSecs) * time.Second,
}
}

func (me *AndroidNotificationServer) Initialize() bool {
func (me *AndroidNotificationServer) Initialize() error {
me.logger.Infof("Initializing Android notification server for type=%v", me.AndroidPushSettings.Type)

if me.AndroidPushSettings.AndroidAPIKey == "" {
me.logger.Error("Android push notifications not configured. Missing AndroidAPIKey.")
return false
if me.AndroidPushSettings.AndroidAPIKey != "" {
me.logger.Infof("AndroidPushSettings.AndroidAPIKey is no longer used. Please remove this config value.")
}

if me.AndroidPushSettings.ServiceFileLocation == "" {
return errors.New("Android push notifications not configured. Missing ServiceFileLocation.")
}

jsonKey, err := os.ReadFile(me.AndroidPushSettings.ServiceFileLocation)
if err != nil {
return fmt.Errorf("error reading service file: %v", err)
}

cfg, err := google.JWTConfigFromJSON(jsonKey, scope)
if err != nil {
return fmt.Errorf("error getting JWT config: %v", err)
}

var serviceAcc serviceAccount
err = json.Unmarshal(jsonKey, &serviceAcc)
if err != nil {
return fmt.Errorf("error parsing service account JSON: %v", err)
}

opt := option.WithTokenSource(cfg.TokenSource(context.Background()))
conf := &firebase.Config{
ProjectID: serviceAcc.ProjectID,
ServiceAccountID: serviceAcc.ClientEmail,
}
app, err := firebase.NewApp(context.Background(), conf, opt)
if err != nil {
return fmt.Errorf("error initializing app: %v", err)
}

client, err := app.Messaging(context.Background())
if err != nil {
return fmt.Errorf("error initializing client: %v", err)
}
me.client = client

return true
return nil
}

func (me *AndroidNotificationServer) SendNotification(msg *PushNotification) PushResponse {
pushType := msg.Type
data := map[string]any{
data := map[string]string{
"ack_id": msg.AckID,
"type": pushType,
"version": msg.Version,
"channel_id": msg.ChannelID,
"is_crt_enabled": msg.IsCRTEnabled,
"is_crt_enabled": strconv.FormatBool(msg.IsCRTEnabled),
"server_id": msg.ServerID,
"category": msg.Category,
}

if msg.Badge != -1 {
data["badge"] = msg.Badge
data["badge"] = strconv.Itoa(msg.Badge)
}

if msg.RootID != "" {
Expand All @@ -58,7 +132,7 @@ func (me *AndroidNotificationServer) SendNotification(msg *PushNotification) Pus
if msg.IsIDLoaded {
data["post_id"] = msg.PostID
data["message"] = msg.Message
data["id_loaded"] = true
data["id_loaded"] = "true"
data["sender_id"] = msg.SenderID
data["sender_name"] = "Someone"
data["team_id"] = msg.TeamID
Expand All @@ -77,54 +151,57 @@ func (me *AndroidNotificationServer) SendNotification(msg *PushNotification) Pus
if me.metrics != nil {
me.metrics.incrementNotificationTotal(PushNotifyAndroid, pushType)
}
fcmMsg := &fcm.Message{
To: msg.DeviceID,
Data: data,
Priority: "high",
fcmMsg := &messaging.Message{
Token: msg.DeviceID,
Data: data,
Android: &messaging.AndroidConfig{
Priority: "high",
},
}

if me.AndroidPushSettings.AndroidAPIKey != "" {
sender, err := fcm.NewClient(me.AndroidPushSettings.AndroidAPIKey)
if err != nil {
if me.metrics != nil {
me.metrics.incrementFailure(PushNotifyAndroid, pushType, "invalid ApiKey")
}
return NewErrorPushResponse(err.Error())
}
ctx, cancel := context.WithTimeout(context.Background(), me.sendTimeout)
defer cancel()

me.logger.Infof("Sending android push notification for device=%v type=%v ackId=%v", me.AndroidPushSettings.Type, msg.Type, msg.AckID)
me.logger.Infof("Sending android push notification for device=%v type=%v ackId=%v", me.AndroidPushSettings.Type, msg.Type, msg.AckID)

start := time.Now()
resp, err := sender.SendWithRetry(fcmMsg, 2)
if me.metrics != nil {
me.metrics.observerNotificationResponse(PushNotifyAndroid, time.Since(start).Seconds())
}
start := time.Now()
_, err := me.client.Send(ctx, fcmMsg)
if me.metrics != nil {
me.metrics.observerNotificationResponse(PushNotifyAndroid, time.Since(start).Seconds())
}

if err != nil {
me.logger.Errorf("Failed to send FCM push sid=%v did=%v err=%v type=%v", msg.ServerID, msg.DeviceID, err, me.AndroidPushSettings.Type)

if err != nil {
me.logger.Errorf("Failed to send FCM push sid=%v did=%v err=%v type=%v", msg.ServerID, msg.DeviceID, err, me.AndroidPushSettings.Type)
if messaging.IsUnregistered(err) {
me.logger.Infof("Android response failure sending remove code: type=%v", me.AndroidPushSettings.Type)
if me.metrics != nil {
me.metrics.incrementFailure(PushNotifyAndroid, pushType, "unknown transport error")
me.metrics.incrementRemoval(PushNotifyAndroid, pushType, unregistered)
}
return NewErrorPushResponse("unknown transport error")
return NewRemovePushResponse()
}

if resp.Failure > 0 {
fcmError := resp.Results[0].Error
var reason string
switch {
case messaging.IsInternal(err):
reason = internalError
case messaging.IsInvalidArgument(err):
reason = invalidArgument
case messaging.IsQuotaExceeded(err):
reason = quotaExceeded
case messaging.IsSenderIDMismatch(err):
reason = senderIDMismatch
case messaging.IsThirdPartyAuthError(err):
reason = thirdPartyAuthError
default:
reason = "unknown transport error"

if fcmError == fcm.ErrInvalidRegistration || fcmError == fcm.ErrNotRegistered || fcmError == fcm.ErrMissingRegistration {
me.logger.Infof("Android response failure sending remove code: %v type=%v", resp, me.AndroidPushSettings.Type)
if me.metrics != nil {
me.metrics.incrementRemoval(PushNotifyAndroid, pushType, fcmError.Error())
}
return NewRemovePushResponse()
}

me.logger.Errorf("Android response failure: %v type=%v", resp, me.AndroidPushSettings.Type)
if me.metrics != nil {
me.metrics.incrementFailure(PushNotifyAndroid, pushType, fcmError.Error())
}
return NewErrorPushResponse(fcmError.Error())
}
if me.metrics != nil {
me.metrics.incrementFailure(PushNotifyAndroid, pushType, reason)
}

return NewErrorPushResponse(err.Error())
}

if me.metrics != nil {
Expand Down
50 changes: 50 additions & 0 deletions server/android_notification_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.

package server

import (
"encoding/json"
"os"
"testing"

"github.com/stretchr/testify/require"
)

func TestAndroidInitialize(t *testing.T) {
fileName := FindConfigFile("mattermost-push-proxy.sample.json")
cfg, err := LoadConfig(fileName)
require.NoError(t, err)

logger := NewLogger(cfg)

// Verify error for no service file
pushSettings := AndroidPushSettings{}
cfg.AndroidPushSettings[0] = pushSettings
require.Error(t, NewAndroidNotificationServer(cfg.AndroidPushSettings[0], logger, nil, cfg.SendTimeoutSec).Initialize())

f, err := os.CreateTemp("", "example")
require.NoError(t, err)
defer os.Remove(f.Name()) // clean up

cfg.AndroidPushSettings[0].ServiceFileLocation = f.Name()

// Verify error for bad JSON
_, err = f.Write([]byte("badJSON"))
require.NoError(t, err)
require.Error(t, NewAndroidNotificationServer(cfg.AndroidPushSettings[0], logger, nil, cfg.SendTimeoutSec).Initialize())

require.NoError(t, f.Truncate(0))
_, err = f.Seek(0, 0)
require.NoError(t, err)

// Verify no error for dummy JSON
require.NoError(t, json.NewEncoder(f).Encode(serviceAccount{
Type: "service_account",
ProjectID: "sample",
}))
require.NoError(t, f.Sync())
require.NoError(t, NewAndroidNotificationServer(cfg.AndroidPushSettings[0], logger, nil, cfg.SendTimeoutSec).Initialize())

require.NoError(t, f.Close())
}
Loading

0 comments on commit d41f502

Please sign in to comment.