server_firebase.go 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. package server
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "log"
  7. "strings"
  8. firebase "firebase.google.com/go/v4"
  9. "firebase.google.com/go/v4/messaging"
  10. "google.golang.org/api/option"
  11. "heckel.io/ntfy/auth"
  12. )
  13. const (
  14. fcmMessageLimit = 4000
  15. fcmApnsBodyMessageLimit = 100
  16. )
  17. func createFirebaseSubscriber(credentialsFile string, auther auth.Auther) (subscriber, error) {
  18. fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(credentialsFile))
  19. if err != nil {
  20. return nil, err
  21. }
  22. msg, err := fb.Messaging(context.Background())
  23. if err != nil {
  24. return nil, err
  25. }
  26. return func(v *visitor, m *message) error {
  27. if err := v.FirebaseAllowed(); err != nil {
  28. return errHTTPTooManyRequestsFirebaseQuotaReached
  29. }
  30. fbm, err := toFirebaseMessage(m, auther)
  31. if err != nil {
  32. return err
  33. }
  34. _, err = msg.Send(context.Background(), fbm)
  35. if err != nil && messaging.IsQuotaExceeded(err) {
  36. log.Printf("[%s] FB quota exceeded when trying to publish to topic %s, temporarily denying FB access", v.ip, m.Topic)
  37. v.FirebaseTemporarilyDeny()
  38. return errHTTPTooManyRequestsFirebaseQuotaReached
  39. }
  40. return err
  41. }, nil
  42. }
  43. // toFirebaseMessage converts a message to a Firebase message.
  44. //
  45. // Normal messages ("message"):
  46. // - For Android, we can receive data messages from Firebase and process them as code, so we just send all fields
  47. // in the "data" attribute. In the Android app, we then turn those into a notification and display it.
  48. // - On iOS, we are not allowed to receive data-only messages, so we build messages with an "alert" (with title and
  49. // message), and still send the rest of the data along in the "aps" attribute. We can then locally modify the
  50. // message in the Notification Service Extension.
  51. //
  52. // Keepalive messages ("keepalive"):
  53. // - On Android, we subscribe to the "~control" topic, which is used to restart the foreground service (if it died,
  54. // e.g. after an app update). We send these keepalive messages regularly (see Config.FirebaseKeepaliveInterval).
  55. // - On iOS, we subscribe to the "~poll" topic, which is used to poll all topics regularly. This is because iOS
  56. // does not allow any background or scheduled activity at all.
  57. //
  58. // Poll request messages ("poll_request"):
  59. // - Normal messages are turned into poll request messages if anonymous users are not allowed to read the message.
  60. // On Android, this will trigger the app to poll the topic and thereby displaying new messages.
  61. // - If UpstreamBaseURL is set, messages are forwarded as poll requests to an upstream server and then forwarded
  62. // to Firebase here. This is mainly for iOS to support self-hosted servers.
  63. func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, error) {
  64. var data map[string]string // Mostly matches https://ntfy.sh/docs/subscribe/api/#json-message-format
  65. var apnsConfig *messaging.APNSConfig
  66. switch m.Event {
  67. case keepaliveEvent, openEvent:
  68. data = map[string]string{
  69. "id": m.ID,
  70. "time": fmt.Sprintf("%d", m.Time),
  71. "event": m.Event,
  72. "topic": m.Topic,
  73. }
  74. apnsConfig = createAPNSBackgroundConfig(data)
  75. case pollRequestEvent:
  76. data = map[string]string{
  77. "id": m.ID,
  78. "time": fmt.Sprintf("%d", m.Time),
  79. "event": m.Event,
  80. "topic": m.Topic,
  81. "message": m.Message,
  82. "poll_id": m.PollID,
  83. }
  84. apnsConfig = createAPNSAlertConfig(m, data)
  85. case messageEvent:
  86. allowForward := true
  87. if auther != nil {
  88. allowForward = auther.Authorize(nil, m.Topic, auth.PermissionRead) == nil
  89. }
  90. if allowForward {
  91. data = map[string]string{
  92. "id": m.ID,
  93. "time": fmt.Sprintf("%d", m.Time),
  94. "event": m.Event,
  95. "topic": m.Topic,
  96. "priority": fmt.Sprintf("%d", m.Priority),
  97. "tags": strings.Join(m.Tags, ","),
  98. "click": m.Click,
  99. "title": m.Title,
  100. "message": m.Message,
  101. "encoding": m.Encoding,
  102. }
  103. if len(m.Actions) > 0 {
  104. actions, err := json.Marshal(m.Actions)
  105. if err != nil {
  106. return nil, err
  107. }
  108. data["actions"] = string(actions)
  109. }
  110. if m.Attachment != nil {
  111. data["attachment_name"] = m.Attachment.Name
  112. data["attachment_type"] = m.Attachment.Type
  113. data["attachment_size"] = fmt.Sprintf("%d", m.Attachment.Size)
  114. data["attachment_expires"] = fmt.Sprintf("%d", m.Attachment.Expires)
  115. data["attachment_url"] = m.Attachment.URL
  116. }
  117. apnsConfig = createAPNSAlertConfig(m, data)
  118. } else {
  119. // If anonymous read for a topic is not allowed, we cannot send the message along
  120. // via Firebase. Instead, we send a "poll_request" message, asking the client to poll.
  121. data = map[string]string{
  122. "id": m.ID,
  123. "time": fmt.Sprintf("%d", m.Time),
  124. "event": pollRequestEvent,
  125. "topic": m.Topic,
  126. }
  127. // TODO Handle APNS?
  128. }
  129. }
  130. var androidConfig *messaging.AndroidConfig
  131. if m.Priority >= 4 {
  132. androidConfig = &messaging.AndroidConfig{
  133. Priority: "high",
  134. }
  135. }
  136. return maybeTruncateFCMMessage(&messaging.Message{
  137. Topic: m.Topic,
  138. Data: data,
  139. Android: androidConfig,
  140. APNS: apnsConfig,
  141. }), nil
  142. }
  143. // maybeTruncateFCMMessage performs best-effort truncation of FCM messages.
  144. // The docs say the limit is 4000 characters, but during testing it wasn't quite clear
  145. // what fields matter; so we're just capping the serialized JSON to 4000 bytes.
  146. func maybeTruncateFCMMessage(m *messaging.Message) *messaging.Message {
  147. s, err := json.Marshal(m)
  148. if err != nil {
  149. return m
  150. }
  151. if len(s) > fcmMessageLimit {
  152. over := len(s) - fcmMessageLimit + 16 // = len("truncated":"1",), sigh ...
  153. message, ok := m.Data["message"]
  154. if ok && len(message) > over {
  155. m.Data["truncated"] = "1"
  156. m.Data["message"] = message[:len(message)-over]
  157. }
  158. }
  159. return m
  160. }
  161. // createAPNSAlertConfig creates an APNS config for iOS notifications that show up as an alert (only relevant for iOS).
  162. // We must set the Alert struct ("alert"), and we need to set MutableContent ("mutable-content"), so the Notification Service
  163. // Extension in iOS can modify the message.
  164. func createAPNSAlertConfig(m *message, data map[string]string) *messaging.APNSConfig {
  165. apnsData := make(map[string]interface{})
  166. for k, v := range data {
  167. apnsData[k] = v
  168. }
  169. return &messaging.APNSConfig{
  170. Payload: &messaging.APNSPayload{
  171. CustomData: apnsData,
  172. Aps: &messaging.Aps{
  173. MutableContent: true,
  174. Alert: &messaging.ApsAlert{
  175. Title: m.Title,
  176. Body: maybeTruncateAPNSBodyMessage(m.Message),
  177. },
  178. },
  179. },
  180. }
  181. }
  182. // createAPNSBackgroundConfig creates an APNS config for a silent background message (only relevant for iOS). Apple only
  183. // allows us to send 2-3 of these notifications per hour, and delivery not guaranteed. We use this only for the ~poll
  184. // topic, which triggers the iOS app to poll all topics for changes.
  185. //
  186. // See https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/pushing_background_updates_to_your_app
  187. func createAPNSBackgroundConfig(data map[string]string) *messaging.APNSConfig {
  188. apnsData := make(map[string]interface{})
  189. for k, v := range data {
  190. apnsData[k] = v
  191. }
  192. return &messaging.APNSConfig{
  193. Headers: map[string]string{
  194. "apns-push-type": "background",
  195. "apns-priority": "5",
  196. },
  197. Payload: &messaging.APNSPayload{
  198. Aps: &messaging.Aps{
  199. ContentAvailable: true,
  200. },
  201. CustomData: apnsData,
  202. },
  203. }
  204. }
  205. // maybeTruncateAPNSBodyMessage truncates the body for APNS.
  206. //
  207. // The "body" of the push notification can contain the entire message, which would count doubly for the overall length
  208. // of the APNS payload. I set a limit of 100 characters before truncating the notification "body" with ellipsis.
  209. // The message would not be changed (unless truncated for being too long). Note: if the payload is too large (>4KB),
  210. // APNS will simply reject / discard the notification, meaning it will never arrive on the iOS device.
  211. func maybeTruncateAPNSBodyMessage(s string) string {
  212. if len(s) >= fcmApnsBodyMessageLimit {
  213. over := len(s) - fcmApnsBodyMessageLimit + 3 // len("...")
  214. return s[:len(s)-over] + "..."
  215. }
  216. return s
  217. }