diff --git a/server/server_firebase.go b/server/server_firebase.go index c0e8813d..827fec45 100644 --- a/server/server_firebase.go +++ b/server/server_firebase.go @@ -2,6 +2,7 @@ package server import ( "context" + "encoding/json" firebase "firebase.google.com/go" "firebase.google.com/go/messaging" "fmt" @@ -10,6 +11,29 @@ import ( "strings" ) +const ( + fcmMessageLimit = 4000 +) + +// maybeTruncateFCMMessage performs best-effort truncation of FCM messages. +// The docs say the limit is 4000 characters, but during testing it wasn't quite clear +// what fields matter; so we're just capping the serialized JSON to 4000 bytes. +func maybeTruncateFCMMessage(m *messaging.Message) *messaging.Message { + s, err := json.Marshal(m) + if err != nil { + return m + } + if len(s) > fcmMessageLimit { + over := len(s) - fcmMessageLimit + 16 // = len("truncated":"1",), sigh ... + message, ok := m.Data["message"] + if ok && len(message) > over { + m.Data["truncated"] = "1" + m.Data["message"] = message[:len(message)-over] + } + } + return m +} + func createFirebaseSubscriber(credentialsFile string, auther auth.Auther) (subscriber, error) { fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(credentialsFile)) if err != nil { diff --git a/server/server_firebase_test.go b/server/server_firebase_test.go new file mode 100644 index 00000000..203960ea --- /dev/null +++ b/server/server_firebase_test.go @@ -0,0 +1,170 @@ +package server + +import ( + "encoding/json" + "errors" + "firebase.google.com/go/messaging" + "fmt" + "github.com/stretchr/testify/require" + "heckel.io/ntfy/auth" + "strings" + "testing" +) + +type testAuther struct { + Allow bool +} + +func (t testAuther) Authenticate(username, password string) (*auth.User, error) { + return nil, errors.New("not used") +} + +func (t testAuther) Authorize(user *auth.User, topic string, perm auth.Permission) error { + if t.Allow { + return nil + } + return errors.New("unauthorized") +} + +func TestToFirebaseMessage_Keepalive(t *testing.T) { + m := newKeepaliveMessage("mytopic") + fbm, err := toFirebaseMessage(m, nil) + require.Nil(t, err) + require.Equal(t, "mytopic", fbm.Topic) + require.Nil(t, fbm.Android) + require.Equal(t, map[string]string{ + "id": m.ID, + "time": fmt.Sprintf("%d", m.Time), + "event": m.Event, + "topic": m.Topic, + }, fbm.Data) +} + +func TestToFirebaseMessage_Open(t *testing.T) { + m := newOpenMessage("mytopic") + fbm, err := toFirebaseMessage(m, nil) + require.Nil(t, err) + require.Equal(t, "mytopic", fbm.Topic) + require.Nil(t, fbm.Android) + require.Equal(t, map[string]string{ + "id": m.ID, + "time": fmt.Sprintf("%d", m.Time), + "event": m.Event, + "topic": m.Topic, + }, fbm.Data) +} + +func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) { + m := newDefaultMessage("mytopic", "this is a message") + m.Priority = 4 + m.Tags = []string{"tag 1", "tag2"} + m.Click = "https://google.com" + m.Title = "some title" + m.Attachment = &attachment{ + Name: "some file.jpg", + Type: "image/jpeg", + Size: 12345, + Expires: 98765543, + URL: "https://example.com/file.jpg", + Owner: "some-owner", + } + fbm, err := toFirebaseMessage(m, &testAuther{Allow: true}) + require.Nil(t, err) + require.Equal(t, "mytopic", fbm.Topic) + require.Equal(t, &messaging.AndroidConfig{ + Priority: "high", + }, fbm.Android) + require.Equal(t, map[string]string{ + "id": m.ID, + "time": fmt.Sprintf("%d", m.Time), + "event": "message", + "topic": "mytopic", + "priority": "4", + "tags": strings.Join(m.Tags, ","), + "click": "https://google.com", + "title": "some title", + "message": "this is a message", + "encoding": "", + "attachment_name": "some file.jpg", + "attachment_type": "image/jpeg", + "attachment_size": "12345", + "attachment_expires": "98765543", + "attachment_url": "https://example.com/file.jpg", + }, fbm.Data) +} + +func TestToFirebaseMessage_Message_Normal_Not_Allowed(t *testing.T) { + m := newDefaultMessage("mytopic", "this is a message") + m.Priority = 5 + fbm, err := toFirebaseMessage(m, &testAuther{Allow: false}) // Not allowed! + require.Nil(t, err) + require.Equal(t, "mytopic", fbm.Topic) + require.Equal(t, &messaging.AndroidConfig{ + Priority: "high", + }, fbm.Android) + require.Equal(t, "", fbm.Data["message"]) + require.Equal(t, "", fbm.Data["priority"]) + require.Equal(t, map[string]string{ + "id": m.ID, + "time": fmt.Sprintf("%d", m.Time), + "event": "poll_request", + "topic": "mytopic", + }, fbm.Data) +} + +func TestMaybeTruncateFCMMessage(t *testing.T) { + origMessage := strings.Repeat("this is a long string", 300) + origFCMMessage := &messaging.Message{ + Topic: "mytopic", + Data: map[string]string{ + "id": "abcdefg", + "time": "1641324761", + "event": "message", + "topic": "mytopic", + "priority": "0", + "tags": "", + "title": "", + "message": origMessage, + }, + Android: &messaging.AndroidConfig{ + Priority: "high", + }, + } + origMessageLength := len(origFCMMessage.Data["message"]) + serializedOrigFCMMessage, _ := json.Marshal(origFCMMessage) + require.Greater(t, len(serializedOrigFCMMessage), fcmMessageLimit) // Pre-condition + + truncatedFCMMessage := maybeTruncateFCMMessage(origFCMMessage) + truncatedMessageLength := len(truncatedFCMMessage.Data["message"]) + serializedTruncatedFCMMessage, _ := json.Marshal(truncatedFCMMessage) + require.Equal(t, fcmMessageLimit, len(serializedTruncatedFCMMessage)) + require.Equal(t, "1", truncatedFCMMessage.Data["truncated"]) + require.NotEqual(t, origMessageLength, truncatedMessageLength) +} + +func TestMaybeTruncateFCMMessage_NotTooLong(t *testing.T) { + origMessage := "not really a long string" + origFCMMessage := &messaging.Message{ + Topic: "mytopic", + Data: map[string]string{ + "id": "abcdefg", + "time": "1641324761", + "event": "message", + "topic": "mytopic", + "priority": "0", + "tags": "", + "title": "", + "message": origMessage, + }, + } + origMessageLength := len(origFCMMessage.Data["message"]) + serializedOrigFCMMessage, _ := json.Marshal(origFCMMessage) + require.LessOrEqual(t, len(serializedOrigFCMMessage), fcmMessageLimit) // Pre-condition + + notTruncatedFCMMessage := maybeTruncateFCMMessage(origFCMMessage) + notTruncatedMessageLength := len(notTruncatedFCMMessage.Data["message"]) + serializedNotTruncatedFCMMessage, _ := json.Marshal(notTruncatedFCMMessage) + require.Equal(t, origMessageLength, notTruncatedMessageLength) + require.Equal(t, len(serializedOrigFCMMessage), len(serializedNotTruncatedFCMMessage)) + require.Equal(t, "", notTruncatedFCMMessage.Data["truncated"]) +} diff --git a/server/util.go b/server/util.go index 4966cb0f..08832dcf 100644 --- a/server/util.go +++ b/server/util.go @@ -1,35 +1,10 @@ package server import ( - "encoding/json" - "firebase.google.com/go/messaging" "net/http" "strings" ) -const ( - fcmMessageLimit = 4000 -) - -// maybeTruncateFCMMessage performs best-effort truncation of FCM messages. -// The docs say the limit is 4000 characters, but during testing it wasn't quite clear -// what fields matter; so we're just capping the serialized JSON to 4000 bytes. -func maybeTruncateFCMMessage(m *messaging.Message) *messaging.Message { - s, err := json.Marshal(m) - if err != nil { - return m - } - if len(s) > fcmMessageLimit { - over := len(s) - fcmMessageLimit + 16 // = len("truncated":"1",), sigh ... - message, ok := m.Data["message"] - if ok && len(message) > over { - m.Data["truncated"] = "1" - m.Data["message"] = message[:len(message)-over] - } - } - return m -} - func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool { value := strings.ToLower(readParam(r, names...)) if value == "" { diff --git a/server/util_test.go b/server/util_test.go index 75a76a24..63bc6b40 100644 --- a/server/util_test.go +++ b/server/util_test.go @@ -1,66 +1,29 @@ package server import ( - "encoding/json" - "firebase.google.com/go/messaging" "github.com/stretchr/testify/require" - "strings" + "net/http" "testing" ) -func TestMaybeTruncateFCMMessage(t *testing.T) { - origMessage := strings.Repeat("this is a long string", 300) - origFCMMessage := &messaging.Message{ - Topic: "mytopic", - Data: map[string]string{ - "id": "abcdefg", - "time": "1641324761", - "event": "message", - "topic": "mytopic", - "priority": "0", - "tags": "", - "title": "", - "message": origMessage, - }, - Android: &messaging.AndroidConfig{ - Priority: "high", - }, - } - origMessageLength := len(origFCMMessage.Data["message"]) - serializedOrigFCMMessage, _ := json.Marshal(origFCMMessage) - require.Greater(t, len(serializedOrigFCMMessage), fcmMessageLimit) // Pre-condition +func TestReadBoolParam(t *testing.T) { + r, _ := http.NewRequest("GET", "https://ntfy.sh/mytopic?up=1&firebase=no", nil) + up := readBoolParam(r, false, "x-up", "up") + firebase := readBoolParam(r, true, "x-firebase", "firebase") + require.Equal(t, true, up) + require.Equal(t, false, firebase) - truncatedFCMMessage := maybeTruncateFCMMessage(origFCMMessage) - truncatedMessageLength := len(truncatedFCMMessage.Data["message"]) - serializedTruncatedFCMMessage, _ := json.Marshal(truncatedFCMMessage) - require.Equal(t, fcmMessageLimit, len(serializedTruncatedFCMMessage)) - require.Equal(t, "1", truncatedFCMMessage.Data["truncated"]) - require.NotEqual(t, origMessageLength, truncatedMessageLength) -} - -func TestMaybeTruncateFCMMessage_NotTooLong(t *testing.T) { - origMessage := "not really a long string" - origFCMMessage := &messaging.Message{ - Topic: "mytopic", - Data: map[string]string{ - "id": "abcdefg", - "time": "1641324761", - "event": "message", - "topic": "mytopic", - "priority": "0", - "tags": "", - "title": "", - "message": origMessage, - }, - } - origMessageLength := len(origFCMMessage.Data["message"]) - serializedOrigFCMMessage, _ := json.Marshal(origFCMMessage) - require.LessOrEqual(t, len(serializedOrigFCMMessage), fcmMessageLimit) // Pre-condition - - notTruncatedFCMMessage := maybeTruncateFCMMessage(origFCMMessage) - notTruncatedMessageLength := len(notTruncatedFCMMessage.Data["message"]) - serializedNotTruncatedFCMMessage, _ := json.Marshal(notTruncatedFCMMessage) - require.Equal(t, origMessageLength, notTruncatedMessageLength) - require.Equal(t, len(serializedOrigFCMMessage), len(serializedNotTruncatedFCMMessage)) - require.Equal(t, "", notTruncatedFCMMessage.Data["truncated"]) + r, _ = http.NewRequest("GET", "https://ntfy.sh/mytopic", nil) + r.Header.Set("X-Up", "yes") + r.Header.Set("X-Firebase", "0") + up = readBoolParam(r, false, "x-up", "up") + firebase = readBoolParam(r, true, "x-firebase", "firebase") + require.Equal(t, true, up) + require.Equal(t, false, firebase) + + r, _ = http.NewRequest("GET", "https://ntfy.sh/mytopic", nil) + up = readBoolParam(r, false, "x-up", "up") + firebase = readBoolParam(r, true, "x-up", "up") + require.Equal(t, false, up) + require.Equal(t, true, firebase) }