diff --git a/client/client_test.go b/client/client_test.go index a6784ff8a..dbcf0978c 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -5,8 +5,10 @@ import ( "github.com/stretchr/testify/require" "heckel.io/ntfy/v2/client" "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/server" "heckel.io/ntfy/v2/test" "os" + "strings" "testing" "time" ) @@ -101,6 +103,24 @@ func TestClient_Publish_Poll(t *testing.T) { require.Equal(t, "some delayed message", messages[1].Message) } +func TestClient_Publish_HeaderWithNewlines(t *testing.T) { + conf := server.NewConfig() + conf.BaseURL = "http://127.0.0.1" // Attachments require a base URL + s, port := test.StartServerWithConfig(t, conf) + defer test.StopServer(t, s, port) + c := client.New(newTestConfig(port)) + + // With an attachment as the body, message and title must travel as headers (see #1808) + msg, err := c.PublishReader("mytopic", strings.NewReader("file contents"), + client.WithFilename("test.txt"), + client.WithMessage("message\n with\n newlines"), + client.WithTitle("title\nwith newline")) + require.Nil(t, err) + require.Equal(t, "message\n with\n newlines", msg.Message) + require.Equal(t, "title\nwith newline", msg.Title) + require.Equal(t, "test.txt", msg.Attachment.Name) +} + func newTestConfig(port int) *client.Config { c := client.NewConfig() c.DefaultHost = fmt.Sprintf("http://127.0.0.1:%d", port) diff --git a/client/options.go b/client/options.go index b99f16731..6c5950f67 100644 --- a/client/options.go +++ b/client/options.go @@ -3,6 +3,7 @@ package client import ( "fmt" "heckel.io/ntfy/v2/util" + "mime" "net/http" "strings" "time" @@ -182,11 +183,12 @@ func WithTagsFilter(tags []string) SubscribeOption { return WithQueryParam("tags", strings.Join(tags, ",")) } -// WithHeader is a generic option to add headers to a request +// WithHeader is a generic option to add headers to a request. Values with characters that are not +// allowed in HTTP headers (e.g. newlines) are RFC 2047 encoded, which the server decodes. func WithHeader(header, value string) RequestOption { return func(r *http.Request) error { if value != "" { - r.Header.Set(header, value) + r.Header.Set(header, mime.BEncoding.Encode("utf-8", value)) } return nil } diff --git a/server/server.go b/server/server.go index 4e7997bab..286c3ee38 100644 --- a/server/server.go +++ b/server/server.go @@ -1405,7 +1405,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *model.Message) (cache bo } } contentType, markdown := readParam(r, "content-type", "content_type"), readBoolParam(r, false, "x-markdown", "markdown", "md") - if markdown || strings.ToLower(contentType) == "text/markdown" { + if markdown || isMarkdownContentType(contentType) { m.ContentType = "text/markdown" } unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too! diff --git a/server/server_test.go b/server/server_test.go index b5eb89dba..3f27bfd00 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -2231,6 +2231,48 @@ func TestServer_PublishMarkdown_NotMarkdown(t *testing.T) { }) } +func TestServer_PublishMarkdown_ContentTypeParameters(t *testing.T) { + tests := []struct { + contentType string + expected string + }{ + {"text/markdown; charset=utf-8", "text/markdown"}, + {"TEXT/MARKDOWN", "text/markdown"}, + {"text/markdown;charset=UTF-8", "text/markdown"}, + {"TEXT/Markdown ; charset=utf-8", "text/markdown"}, + {"text/markdown; variant=GFM", "text/markdown"}, + {"text/markdownx; charset=utf-8", ""}, + {"text/plain; charset=utf-8", ""}, + {"text/markdown; charset=\"unterminated", ""}, + } + forEachBackend(t, func(t *testing.T, databaseURL string) { + for _, tc := range tests { + t.Run(tc.contentType, func(t *testing.T) { + s := newTestServer(t, newTestConfig(t, databaseURL)) + response := request(t, s, "PUT", "/mytopic", "**make this bold**", map[string]string{ + "Content-Type": tc.contentType, + }) + require.Equal(t, 200, response.Code) + + m := toMessage(t, response.Body.String()) + require.Equal(t, "**make this bold**", m.Message) + require.Equal(t, tc.expected, m.ContentType) + }) + } + }) +} + +func TestServer_PublishMarkdown_ContentTypeQueryParamWithCharset(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) + response := request(t, s, "PUT", "/mytopic?content-type=text/markdown%3B%20charset=utf-8", "**make this bold**", nil) + require.Equal(t, 200, response.Code) + + m := toMessage(t, response.Body.String()) + require.Equal(t, "text/markdown", m.ContentType) + }) +} + func TestServer_PublishAsJSON(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfig(t, databaseURL)) diff --git a/server/util.go b/server/util.go index 305f63eaa..33df051ae 100644 --- a/server/util.go +++ b/server/util.go @@ -196,3 +196,13 @@ func maybeIgnoreSpecialHeader(name, value string) string { } return value } + +// isMarkdownContentType returns true if the given Content-Type header value is "text/markdown", +// ignoring case and any media type parameters, e.g. "text/markdown; charset=utf-8". +func isMarkdownContentType(contentType string) bool { + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil { + return strings.ToLower(contentType) == "text/markdown" + } + return strings.ToLower(mediaType) == "text/markdown" +}