This repository has no description
1package notella
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "os"
9 "strings"
10 "time"
11
12 firebase "firebase.google.com/go/v4"
13 "firebase.google.com/go/v4/messaging"
14 ll "github.com/gwennlbh/label-logger-go"
15 "golang.org/x/oauth2"
16 "golang.org/x/oauth2/google"
17 "google.golang.org/api/option"
18)
19
20var firebaseClient *firebase.App
21var firebaseCtx = context.Background()
22
23const MaxTokensPerRequest = 490
24
25func (msg Message) SendToFirebase(groupId string, subs []Subscription) error {
26 if firebaseClient == nil || !config.HasValidFirebaseServiceAccount() {
27 return nil
28 }
29
30 fcm, err := firebaseClient.Messaging(firebaseCtx)
31 if err != nil {
32 return fmt.Errorf("while initializing FCM client: %w", err)
33 }
34
35 if config.DryRunMode && len(config.DryRunExceptions) == 0 {
36 ll.Warn("dry run mode enabled, not sending FCM message to %d tokens", len(subs))
37 return nil
38 }
39
40 message := msg.FirebaseMessage(groupId)
41 tokens := make([]string, 0, len(subs))
42 for _, sub := range subs {
43 if config.DryRunMode {
44 exempt := false
45 for _, username := range config.DryRunExceptions {
46 if username == sub.Owner.Uid {
47 exempt = true
48 }
49 }
50 if !exempt {
51 continue
52 }
53 }
54 tokens = append(tokens, sub.FirebaseToken())
55 }
56
57 if config.DryRunMode {
58 ll.Warn("dry run mode enabled, only sending FCM message to %d tokens (owned by %+v)", len(tokens), config.DryRunExceptions)
59 }
60
61 for _, tokensChunk := range chunkBy(tokens, MaxTokensPerRequest) {
62 go func(tokens []string) {
63 if len(tokens) == 0 {
64 return
65 }
66 message.Tokens = tokens
67 resp, err := fcm.SendEachForMulticast(firebaseCtx, &message)
68 if err != nil {
69 ll.ErrorDisplay("while sending FCM message", err)
70 } else if resp.FailureCount > 0 {
71 fcmErrors := make([]string, 0, resp.FailureCount)
72 for i, result := range resp.Responses {
73 if !result.Success {
74 if result.Error.Error() == "Requested entity was not found." {
75 if sub, found := FindSubscriptionByNativeToken(tokens[i], subs); found {
76 ll.Log("Deleting", "yellow", "invalid native subscription %s", tokens[i])
77 sub.Destroy()
78 }
79 } else {
80 fcmErrors = append(fcmErrors, fmt.Sprintf("%s: %s", tokens[i], result.Error))
81 }
82 }
83 }
84 if len(fcmErrors) > 0 {
85 ll.ErrorDisplay(
86 "some FCM messages failed for %d tokens",
87 fmt.Errorf("- %s", strings.Join(fcmErrors, "\n- ")),
88 resp.FailureCount,
89 )
90 }
91 }
92 }(tokensChunk)
93 }
94
95 return nil
96}
97
98func (msg Message) FirebaseMessage(groupId string) messaging.MulticastMessage {
99 clickAction := ""
100 if len(msg.Actions) > 0 {
101 clickAction = msg.Actions[0].Label
102 }
103 return messaging.MulticastMessage{
104 Data: map[string]string{
105 "original": msg.JSONString(),
106 },
107 Android: &messaging.AndroidConfig{
108 RestrictedPackageName: config.AppPackageId,
109 Notification: &messaging.AndroidNotification{
110 VibrateTimingMillis: []int64{}, // TODO
111 EventTimestamp: nil, // TODO
112 ClickAction: clickAction,
113 },
114 },
115 Notification: &messaging.Notification{
116 Title: msg.Title,
117 Body: msg.Body,
118 ImageURL: msg.Image,
119 },
120 }
121}
122
123type firebaseServiceAccount struct {
124 Type string `json:"type"`
125 ProjectId string `json:"project_id"`
126 PrivateKeyId string `json:"private_key_id"`
127 PrivateKey string `json:"private_key"`
128 ClientEmail string `json:"client_email"`
129 ClientId string `json:"client_id"`
130 AuthUri string `json:"auth_uri"`
131 TokenUri string `json:"token_uri"`
132 AuthProviderX509CertUrl string `json:"auth_provider_x509_cert_url"`
133 ClientX509CertUrl string `json:"client_x509_cert_url"`
134 UniverseDomain string `json:"universe_domain"`
135}
136
137func setupFirebaseClient() (err error) {
138 httpClient := http.DefaultClient
139 if os.Getenv("DEBUG") == "1" {
140 httpClient = &http.Client{
141 Transport: debugTransport{t: http.DefaultTransport},
142 }
143 }
144
145 ctxWithClient := context.WithValue(firebaseCtx, oauth2.HTTPClient, httpClient)
146 creds, err := google.CredentialsFromJSON(ctxWithClient, []byte(config.FirebaseServiceAccount), "https://www.googleapis.com/auth/firebase.messaging")
147 if err != nil {
148 return fmt.Errorf("while setting credentials: %w", err)
149 }
150
151 client := &http.Client{
152 Transport: &oauth2.Transport{
153 Source: creds.TokenSource,
154 Base: httpClient.Transport,
155 },
156 Timeout: 10 * time.Second,
157 }
158
159 firebaseClient, err = firebase.NewApp(firebaseCtx, nil,
160 option.WithCredentialsJSON([]byte(config.FirebaseServiceAccount)),
161 option.WithHTTPClient(client),
162 )
163 return
164}
165
166func (config Configuration) HasValidFirebaseServiceAccount() bool {
167 var serviceAccount firebaseServiceAccount
168 err := json.Unmarshal([]byte(config.FirebaseServiceAccount), &serviceAccount)
169 if err != nil {
170 return false
171 }
172 if serviceAccount.Type != "service_account" {
173 return false
174 }
175
176 if err = setupFirebaseClient(); err != nil {
177 return false
178 }
179
180 return true
181}
182
183func (sub Subscription) FirebaseToken() string {
184 return strings.TrimPrefix(strings.TrimPrefix(sub.Webpush.Endpoint, "apns://"), "firebase://")
185}
186
187func (sub Subscription) IsNative() bool {
188 return !sub.IsWebpush()
189}