diff --git a/server/server.go b/server/server.go index aebc216a..c5c398db 100644 --- a/server/server.go +++ b/server/server.go @@ -127,7 +127,7 @@ var ( errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"} errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"} errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"} - errHTTPTooManyRequestsLimitGlobalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"} errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications"} errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", ""} errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", ""} @@ -431,7 +431,7 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, _ *visitor) if err != nil { return errHTTPNotFound } - w.Header().Set("Length", fmt.Sprintf("%d", stat.Size())) + w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size())) f, err := os.Open(file) if err != nil { return err @@ -503,7 +503,7 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca firebase = readParam(r, "x-firebase", "firebase") != "no" m.Title = readParam(r, "x-title", "title", "t") m.Click = readParam(r, "x-click", "click") - attach := readParam(r, "x-attachment", "attachment", "attach", "a") + attach := readParam(r, "x-attach", "attach", "a") filename := readParam(r, "x-filename", "filename", "file", "f") if attach != "" || filename != "" { m.Attachment = &attachment{} @@ -617,7 +617,7 @@ func (s *Server) handleBodyAsMessage(m *message, body *util.PeakedReadCloser) er } func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeakedReadCloser) error { - if s.fileCache == nil { + if s.fileCache == nil || s.config.BaseURL == "" || s.config.AttachmentCacheDir == "" { return errHTTPBadRequestAttachmentsDisallowed } else if m.Time > time.Now().Add(s.config.AttachmentExpiryDuration).Unix() { return errHTTPBadRequestAttachmentsExpiryBeforeDelivery @@ -871,7 +871,7 @@ func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) { } if _, ok := s.topics[id]; !ok { if len(s.topics) >= s.config.TotalTopicLimit { - return nil, errHTTPTooManyRequestsLimitGlobalTopics + return nil, errHTTPTooManyRequestsLimitTotalTopics } s.topics[id] = newTopic(id) } diff --git a/server/server_test.go b/server/server_test.go index 339c114a..90fbee00 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -7,6 +7,7 @@ import ( "firebase.google.com/go/messaging" "fmt" "github.com/stretchr/testify/require" + "heckel.io/ntfy/util" "net/http" "net/http/httptest" "os" @@ -163,7 +164,9 @@ func TestServer_StaticSites(t *testing.T) { } func TestServer_PublishLargeMessage(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + c := newTestConfig(t) + c.AttachmentCacheDir = "" // Disable attachments + s := newTestServer(t, c) body := strings.Repeat("this is a large message", 5000) response := request(t, s, "PUT", "/mytopic", body, nil) @@ -196,6 +199,9 @@ func TestServer_PublishPriority(t *testing.T) { response = request(t, s, "GET", "/mytopic/trigger?priority=urgent", "test", nil) require.Equal(t, 5, toMessage(t, response.Body.String()).Priority) + + response = request(t, s, "GET", "/mytopic/trigger?priority=INVALID", "test", nil) + require.Equal(t, 40007, toHTTPError(t, response.Body.String()).Code) } func TestServer_PublishNoCache(t *testing.T) { @@ -259,13 +265,28 @@ func TestServer_PublishAtTooShortDelay(t *testing.T) { func TestServer_PublishAtTooLongDelay(t *testing.T) { s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ "In": "99999999h", }) require.Equal(t, 400, response.Code) } +func TestServer_PublishAtInvalidDelay(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic?delay=INVALID", "a message", nil) + err := toHTTPError(t, response.Body.String()) + require.Equal(t, 400, response.Code) + require.Equal(t, 40004, err.Code) +} + +func TestServer_PublishAtTooLarge(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic?x-in=99999h", "a message", nil) + err := toHTTPError(t, response.Body.String()) + require.Equal(t, 400, response.Code) + require.Equal(t, 40006, err.Code) +} + func TestServer_PublishAtAndPrune(t *testing.T) { s := newTestServer(t, newTestConfig(t)) @@ -347,6 +368,19 @@ func TestServer_PublishAndPollSince(t *testing.T) { messages := toMessages(t, response.Body.String()) require.Equal(t, 1, len(messages)) require.Equal(t, "test 2", messages[0].Message) + + response = request(t, s, "GET", "/mytopic/json?poll=1&since=10s", "", nil) + messages = toMessages(t, response.Body.String()) + require.Equal(t, 2, len(messages)) + require.Equal(t, "test 1", messages[0].Message) + + response = request(t, s, "GET", "/mytopic/json?poll=1&since=100ms", "", nil) + messages = toMessages(t, response.Body.String()) + require.Equal(t, 1, len(messages)) + require.Equal(t, "test 2", messages[0].Message) + + response = request(t, s, "GET", "/mytopic/json?poll=1&since=INVALID", "", nil) + require.Equal(t, 40008, toHTTPError(t, response.Body.String()).Code) } func TestServer_PublishViaGET(t *testing.T) { @@ -387,6 +421,13 @@ func TestServer_PublishFirebase(t *testing.T) { time.Sleep(500 * time.Millisecond) // Time for sends } +func TestServer_PublishInvalidTopic(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + s.mailer = &testMailer{} + response := request(t, s, "PUT", "/docs", "fail", nil) + require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code) +} + func TestServer_PollWithQueryFilters(t *testing.T) { s := newTestServer(t, newTestConfig(t)) @@ -640,9 +681,175 @@ func TestServer_MaybeTruncateFCMMessage_NotTooLong(t *testing.T) { require.Equal(t, "", notTruncatedFCMMessage.Data["truncated"]) } +func TestServer_PublishAttachment(t *testing.T) { + content := util.RandomString(5000) // > 4096 + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", content, nil) + msg := toMessage(t, response.Body.String()) + require.Equal(t, "attachment.txt", msg.Attachment.Name) + require.Equal(t, "text/plain; charset=utf-8", msg.Attachment.Type) + require.Equal(t, int64(5000), msg.Attachment.Size) + require.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(3*time.Hour).Unix()) + require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") + require.Equal(t, "", msg.Attachment.Owner) // Should never be returned + require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID)) + + path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345") + response = request(t, s, "GET", path, "", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, "5000", response.Header().Get("Content-Length")) + require.Equal(t, content, response.Body.String()) +} + +func TestServer_PublishAttachmentShortWithFilename(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + content := "this is an ATTACHMENT" + response := request(t, s, "PUT", "/mytopic?f=myfile.txt", content, nil) + msg := toMessage(t, response.Body.String()) + require.Equal(t, "myfile.txt", msg.Attachment.Name) + require.Equal(t, "text/plain; charset=utf-8", msg.Attachment.Type) + require.Equal(t, int64(21), msg.Attachment.Size) + require.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(3*time.Hour).Unix()) + require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") + require.Equal(t, "", msg.Attachment.Owner) // Should never be returned + require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID)) + + path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345") + response = request(t, s, "GET", path, "", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, "21", response.Header().Get("Content-Length")) + require.Equal(t, content, response.Body.String()) +} + +func TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", "", map[string]string{ + "Attach": "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg", + }) + msg := toMessage(t, response.Body.String()) + require.Equal(t, "You received a file: Pink_flower.jpg", msg.Message) + require.Equal(t, "Pink_flower.jpg", msg.Attachment.Name) + require.Equal(t, "image/jpeg", msg.Attachment.Type) + require.Equal(t, int64(190173), msg.Attachment.Size) + require.Equal(t, int64(0), msg.Attachment.Expires) + require.Equal(t, "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg", msg.Attachment.URL) + require.Equal(t, "", msg.Attachment.Owner) +} + +func TestServer_PublishAttachmentExternalWithFilename(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", "This is a custom message", map[string]string{ + "X-Attach": "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg", + "File": "some file.jpg", + }) + msg := toMessage(t, response.Body.String()) + require.Equal(t, "This is a custom message", msg.Message) + require.Equal(t, "some file.jpg", msg.Attachment.Name) + require.Equal(t, "image/jpeg", msg.Attachment.Type) + require.Equal(t, int64(190173), msg.Attachment.Size) + require.Equal(t, int64(0), msg.Attachment.Expires) + require.Equal(t, "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg", msg.Attachment.URL) + require.Equal(t, "", msg.Attachment.Owner) +} + +func TestServer_PublishAttachmentBadURL(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic?a=not+a+URL", "", nil) + err := toHTTPError(t, response.Body.String()) + require.Equal(t, 400, response.Code) + require.Equal(t, 400, err.HTTPCode) + require.Equal(t, 40013, err.Code) +} + +func TestServer_PublishAttachmentTooLargeContentLength(t *testing.T) { + content := util.RandomString(5000) // > 4096 + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", content, map[string]string{ + "Content-Length": "20000000", + }) + err := toHTTPError(t, response.Body.String()) + require.Equal(t, 400, response.Code) + require.Equal(t, 400, err.HTTPCode) + require.Equal(t, 40012, err.Code) +} + +func TestServer_PublishAttachmentTooLargeBodyAttachmentFileSizeLimit(t *testing.T) { + content := util.RandomString(5001) // > 5000, see below + c := newTestConfig(t) + c.AttachmentFileSizeLimit = 5000 + s := newTestServer(t, c) + response := request(t, s, "PUT", "/mytopic", content, nil) + err := toHTTPError(t, response.Body.String()) + require.Equal(t, 400, response.Code) + require.Equal(t, 400, err.HTTPCode) + require.Equal(t, 40012, err.Code) +} + +func TestServer_PublishAttachmentExpiryBeforeDelivery(t *testing.T) { + c := newTestConfig(t) + c.AttachmentExpiryDuration = 10 * time.Minute + s := newTestServer(t, c) + response := request(t, s, "PUT", "/mytopic", util.RandomString(5000), map[string]string{ + "Delay": "11 min", // > AttachmentExpiryDuration + }) + err := toHTTPError(t, response.Body.String()) + require.Equal(t, 400, response.Code) + require.Equal(t, 400, err.HTTPCode) + require.Equal(t, 40017, err.Code) +} + +func TestServer_PublishAttachmentTooLargeBodyVisitorAttachmentTotalSizeLimit(t *testing.T) { + c := newTestConfig(t) + c.VisitorAttachmentTotalSizeLimit = 10000 + s := newTestServer(t, c) + + response := request(t, s, "PUT", "/mytopic", util.RandomString(5000), nil) + msg := toMessage(t, response.Body.String()) + require.Equal(t, 200, response.Code) + require.Equal(t, "You received a file: attachment.txt", msg.Message) + require.Equal(t, int64(5000), msg.Attachment.Size) + + content := util.RandomString(5001) // 5000+5001 > , see below + response = request(t, s, "PUT", "/mytopic", content, nil) + err := toHTTPError(t, response.Body.String()) + require.Equal(t, 400, response.Code) + require.Equal(t, 400, err.HTTPCode) + require.Equal(t, 40012, err.Code) +} + +func TestServer_PublishAttachmentAndPrune(t *testing.T) { + content := util.RandomString(5000) // > 4096 + + c := newTestConfig(t) + c.AttachmentExpiryDuration = time.Millisecond // Hack + s := newTestServer(t, c) + + // Publish and make sure we can retrieve it + response := request(t, s, "PUT", "/mytopic", content, nil) + println(response.Body.String()) + msg := toMessage(t, response.Body.String()) + require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") + file := filepath.Join(s.config.AttachmentCacheDir, msg.ID) + require.FileExists(t, file) + + path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345") + response = request(t, s, "GET", path, "", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, content, response.Body.String()) + + // Prune and makes sure it's gone + time.Sleep(time.Second) // Sigh ... + s.updateStatsAndPrune() + require.NoFileExists(t, file) + response = request(t, s, "GET", path, "", nil) + require.Equal(t, 404, response.Code) +} + func newTestConfig(t *testing.T) *Config { conf := NewConfig() + conf.BaseURL = "http://127.0.0.1:12345" conf.CacheFile = filepath.Join(t.TempDir(), "cache.db") + conf.AttachmentCacheDir = t.TempDir() return conf } @@ -702,6 +909,13 @@ func toMessage(t *testing.T, s string) *message { return &m } +func tempFile(t *testing.T, length int) (filename string, content string) { + filename = filepath.Join(t.TempDir(), util.RandomString(10)) + content = util.RandomString(length) + require.Nil(t, os.WriteFile(filename, []byte(content), 0600)) + return +} + func toHTTPError(t *testing.T, s string) *errHTTP { var e errHTTP require.Nil(t, json.NewDecoder(strings.NewReader(s)).Decode(&e))