Handle binary messages for UnifiedPush
parent
4710812c24
commit
4ceb058a40
|
@ -4,6 +4,7 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
firebase "firebase.google.com/go"
|
firebase "firebase.google.com/go"
|
||||||
|
@ -95,6 +96,7 @@ const (
|
||||||
firebaseControlTopic = "~control" // See Android if changed
|
firebaseControlTopic = "~control" // See Android if changed
|
||||||
emptyMessageBody = "triggered" // Used if message body is empty
|
emptyMessageBody = "triggered" // Used if message body is empty
|
||||||
defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
|
defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
|
||||||
|
encodingBase64 = "base64"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WebSocket constants
|
// WebSocket constants
|
||||||
|
@ -415,11 +417,11 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m := newDefaultMessage(t.ID, "")
|
m := newDefaultMessage(t.ID, "")
|
||||||
cache, firebase, email, err := s.parsePublishParams(r, v, m)
|
cache, firebase, email, unifiedpush, err := s.parsePublishParams(r, v, m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.handlePublishBody(r, v, m, body); err != nil {
|
if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if m.Message == "" {
|
if m.Message == "" {
|
||||||
|
@ -461,7 +463,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (cache bool, firebase bool, email string, err error) {
|
func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (cache bool, firebase bool, email string, unifiedpush bool, err error) {
|
||||||
cache = readBoolParam(r, true, "x-cache", "cache")
|
cache = readBoolParam(r, true, "x-cache", "cache")
|
||||||
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
||||||
m.Title = readParam(r, "x-title", "title", "t")
|
m.Title = readParam(r, "x-title", "title", "t")
|
||||||
|
@ -476,7 +478,7 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
|
||||||
}
|
}
|
||||||
if attach != "" {
|
if attach != "" {
|
||||||
if !attachURLRegex.MatchString(attach) {
|
if !attachURLRegex.MatchString(attach) {
|
||||||
return false, false, "", errHTTPBadRequestAttachmentURLInvalid
|
return false, false, "", false, errHTTPBadRequestAttachmentURLInvalid
|
||||||
}
|
}
|
||||||
m.Attachment.URL = attach
|
m.Attachment.URL = attach
|
||||||
if m.Attachment.Name == "" {
|
if m.Attachment.Name == "" {
|
||||||
|
@ -495,11 +497,11 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
|
||||||
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
|
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
|
||||||
if email != "" {
|
if email != "" {
|
||||||
if err := v.EmailAllowed(); err != nil {
|
if err := v.EmailAllowed(); err != nil {
|
||||||
return false, false, "", errHTTPTooManyRequestsLimitEmails
|
return false, false, "", false, errHTTPTooManyRequestsLimitEmails
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if s.mailer == nil && email != "" {
|
if s.mailer == nil && email != "" {
|
||||||
return false, false, "", errHTTPBadRequestEmailDisabled
|
return false, false, "", false, errHTTPBadRequestEmailDisabled
|
||||||
}
|
}
|
||||||
messageStr := readParam(r, "x-message", "message", "m")
|
messageStr := readParam(r, "x-message", "message", "m")
|
||||||
if messageStr != "" {
|
if messageStr != "" {
|
||||||
|
@ -507,7 +509,7 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
|
||||||
}
|
}
|
||||||
m.Priority, err = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
|
m.Priority, err = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, false, "", errHTTPBadRequestPriorityInvalid
|
return false, false, "", false, errHTTPBadRequestPriorityInvalid
|
||||||
}
|
}
|
||||||
tagsStr := readParam(r, "x-tags", "tags", "tag", "ta")
|
tagsStr := readParam(r, "x-tags", "tags", "tag", "ta")
|
||||||
if tagsStr != "" {
|
if tagsStr != "" {
|
||||||
|
@ -519,50 +521,65 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
|
||||||
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
|
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
|
||||||
if delayStr != "" {
|
if delayStr != "" {
|
||||||
if !cache {
|
if !cache {
|
||||||
return false, false, "", errHTTPBadRequestDelayNoCache
|
return false, false, "", false, errHTTPBadRequestDelayNoCache
|
||||||
}
|
}
|
||||||
if email != "" {
|
if email != "" {
|
||||||
return false, false, "", errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
|
return false, false, "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
|
||||||
}
|
}
|
||||||
delay, err := util.ParseFutureTime(delayStr, time.Now())
|
delay, err := util.ParseFutureTime(delayStr, time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, false, "", errHTTPBadRequestDelayCannotParse
|
return false, false, "", false, errHTTPBadRequestDelayCannotParse
|
||||||
} else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() {
|
} else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() {
|
||||||
return false, false, "", errHTTPBadRequestDelayTooSmall
|
return false, false, "", false, errHTTPBadRequestDelayTooSmall
|
||||||
} else if delay.Unix() > time.Now().Add(s.config.MaxDelay).Unix() {
|
} else if delay.Unix() > time.Now().Add(s.config.MaxDelay).Unix() {
|
||||||
return false, false, "", errHTTPBadRequestDelayTooLarge
|
return false, false, "", false, errHTTPBadRequestDelayTooLarge
|
||||||
}
|
}
|
||||||
m.Time = delay.Unix()
|
m.Time = delay.Unix()
|
||||||
}
|
}
|
||||||
unifiedpush := readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
|
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
|
||||||
if unifiedpush {
|
if unifiedpush {
|
||||||
firebase = false
|
firebase = false
|
||||||
|
unifiedpush = true
|
||||||
}
|
}
|
||||||
return cache, firebase, email, nil
|
return cache, firebase, email, unifiedpush, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message.
|
// handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message.
|
||||||
//
|
//
|
||||||
// 1. curl -H "Attach: http://example.com/file.jpg" ntfy.sh/mytopic
|
// 1. curl -T somebinarydata.bin "ntfy.sh/mytopic?up=1"
|
||||||
|
// If body is binary, encode as base64, if not do not encode
|
||||||
|
// 2. curl -H "Attach: http://example.com/file.jpg" ntfy.sh/mytopic
|
||||||
// Body must be a message, because we attached an external URL
|
// Body must be a message, because we attached an external URL
|
||||||
// 2. curl -T short.txt -H "Filename: short.txt" ntfy.sh/mytopic
|
// 3. curl -T short.txt -H "Filename: short.txt" ntfy.sh/mytopic
|
||||||
// Body must be attachment, because we passed a filename
|
// Body must be attachment, because we passed a filename
|
||||||
// 3. curl -T file.txt ntfy.sh/mytopic
|
|
||||||
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
|
|
||||||
// 4. curl -T file.txt ntfy.sh/mytopic
|
// 4. curl -T file.txt ntfy.sh/mytopic
|
||||||
|
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
|
||||||
|
// 5. curl -T file.txt ntfy.sh/mytopic
|
||||||
// If file.txt is > message limit, treat it as an attachment
|
// If file.txt is > message limit, treat it as an attachment
|
||||||
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeakedReadCloser) error {
|
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeakedReadCloser, unifiedpush bool) error {
|
||||||
if m.Attachment != nil && m.Attachment.URL != "" {
|
if unifiedpush {
|
||||||
return s.handleBodyAsMessage(m, body) // Case 1
|
return s.handleBodyAsMessageAutoDetect(m, body) // Case 1
|
||||||
|
} else if m.Attachment != nil && m.Attachment.URL != "" {
|
||||||
|
return s.handleBodyAsTextMessage(m, body) // Case 2
|
||||||
} else if m.Attachment != nil && m.Attachment.Name != "" {
|
} else if m.Attachment != nil && m.Attachment.Name != "" {
|
||||||
return s.handleBodyAsAttachment(r, v, m, body) // Case 2
|
return s.handleBodyAsAttachment(r, v, m, body) // Case 3
|
||||||
} else if !body.LimitReached && utf8.Valid(body.PeakedBytes) {
|
} else if !body.LimitReached && utf8.Valid(body.PeakedBytes) {
|
||||||
return s.handleBodyAsMessage(m, body) // Case 3
|
return s.handleBodyAsTextMessage(m, body) // Case 4
|
||||||
}
|
}
|
||||||
return s.handleBodyAsAttachment(r, v, m, body) // Case 4
|
return s.handleBodyAsAttachment(r, v, m, body) // Case 5
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleBodyAsMessage(m *message, body *util.PeakedReadCloser) error {
|
func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeakedReadCloser) error {
|
||||||
|
if utf8.Valid(body.PeakedBytes) {
|
||||||
|
m.Message = string(body.PeakedBytes) // Do not trim
|
||||||
|
} else {
|
||||||
|
m.Message = base64.StdEncoding.EncodeToString(body.PeakedBytes)
|
||||||
|
m.Encoding = encodingBase64
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeakedReadCloser) error {
|
||||||
if !utf8.Valid(body.PeakedBytes) {
|
if !utf8.Valid(body.PeakedBytes) {
|
||||||
return errHTTPBadRequestMessageNotUTF8
|
return errHTTPBadRequestMessageNotUTF8
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,10 +3,12 @@ package server
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
@ -623,6 +625,49 @@ func TestServer_UnifiedPushDiscovery(t *testing.T) {
|
||||||
require.Equal(t, `{"unifiedpush":{"version":1}}`+"\n", response.Body.String())
|
require.Equal(t, `{"unifiedpush":{"version":1}}`+"\n", response.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishUnifiedPushBinary(t *testing.T) {
|
||||||
|
b := make([]byte, 12) // Max length
|
||||||
|
_, err := rand.Read(b)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
response := request(t, s, "PUT", "/mytopic?up=1", string(b), nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
|
m := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "base64", m.Encoding)
|
||||||
|
b2, err := base64.StdEncoding.DecodeString(m.Message)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, b, b2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishUnifiedPushBinary_Truncated(t *testing.T) {
|
||||||
|
b := make([]byte, 5000) // Longer than max length
|
||||||
|
_, err := rand.Read(b)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
response := request(t, s, "PUT", "/mytopic?up=1", string(b), nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
|
m := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "base64", m.Encoding)
|
||||||
|
b2, err := base64.StdEncoding.DecodeString(m.Message)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 4096, len(b2))
|
||||||
|
require.Equal(t, b[:4096], b2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishUnifiedPushText(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
response := request(t, s, "PUT", "/mytopic?up=1", "this is a unifiedpush text message", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
|
m := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "", m.Encoding)
|
||||||
|
require.Equal(t, "this is a unifiedpush text message", m.Message)
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_PublishAttachment(t *testing.T) {
|
func TestServer_PublishAttachment(t *testing.T) {
|
||||||
content := util.RandomString(5000) // > 4096
|
content := util.RandomString(5000) // > 4096
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
|
@ -29,6 +29,7 @@ type message struct {
|
||||||
Attachment *attachment `json:"attachment,omitempty"`
|
Attachment *attachment `json:"attachment,omitempty"`
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
|
Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
type attachment struct {
|
type attachment struct {
|
||||||
|
|
Loading…
Reference in New Issue