From df7d6baec59e17b3ac07b2846d067bc17fb94133 Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Sun, 17 Mar 2024 21:55:50 -0600 Subject: [PATCH 1/8] add templating for title and message fields --- docs/publish.md | 23 ++++++++ docs/releases.md | 6 +++ go.mod | 3 ++ go.sum | 6 +++ server/server.go | 76 ++++++++++++++++++-------- server/server_test.go | 120 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 212 insertions(+), 22 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index 5239bbc6c..cd67ed695 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -3557,6 +3557,29 @@ ntfy server plays the role of the Push Gateway, as well as the Push Provider. Un !!! info This is not a generic Matrix Push Gateway. It only works in combination with UnifiedPush and ntfy. +### Message and Title Templates +Some services let you specify a webhook URL but do not let you modify the webhook body (e.g., Grafana). Instead of using a separate +bridge program to parse the webhook body into the format ntfy expects, you can include a message template and/or a title template +which will be populated based on the fields of the webhook body (so long as the webhook body is valid JSON). + +Send the message template with the header `X-Template-Message`, `Template-Message`, or `tpl-m`. Send the title template with the +header `X-Template-Title`, `Template-Title`, or `tpl-t`. (No other fields can be filled with a template at this time). + +In the template, include paths to the appropriate JSON fields surrounded by `${` and `}`. See an example below. +See [GJSON docs](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) for supported JSON path syntax. + +=== "HTTP" + ``` http + POST /mytopic HTTP/1.1 + Host: ntfy.sh + X-Template-Message: Error message: ${error.desc} + X-Template-Title: ${hostname}: A ${error.level} error has occurred + + {"hostname": "philipp-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}} + ``` + +The example above would send a notification with a title "philipp-pc: A severe error has occurred" and a message "Error message: Disk has run out of space". + ## Public topics Obviously all topics on ntfy.sh are public, but there are a few designated topics that are used in examples, and topics that you can use to try out what [authentication and access control](#authentication) looks like. diff --git a/docs/releases.md b/docs/releases.md index 9bdfef34a..c82560ac1 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1338,6 +1338,12 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet +### ntfy server v2.9.1 (UNRELEASED) + +**Features:** + +* You can now include a message and/or title template that will be filled with values from a JSON body, great for services that let you specify a webhook URL but do not let you change the webhook body (such as Grafana). ([#724](https://github.com/binwiederhier/ntfy/issues/724), thanks to [@wunter8](https://github.com/wunter8) for implementing) + ### ntfy Android app v1.16.1 (UNRELEASED) **Features:** diff --git a/go.mod b/go.mod index 1a5ecf76f..a63f2ab84 100644 --- a/go.mod +++ b/go.mod @@ -69,6 +69,9 @@ require ( github.com/prometheus/procfs v0.13.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/stretchr/objx v0.5.0 // indirect + github.com/tidwall/gjson v1.17.1 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect diff --git a/go.sum b/go.sum index bdd68ab7e..47c8b8c5b 100644 --- a/go.sum +++ b/go.sum @@ -143,6 +143,12 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stripe/stripe-go/v74 v74.30.0 h1:0Kf0KkeFnY7iRhOwvTerX0Ia1BRw+eV1CVJ51mGYAUY= github.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw= +github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= +github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho= github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e h1:+SOyEddqYF09QP7vr7CgJ1eti3pY9Fn3LHO1M1r/0sI= diff --git a/server/server.go b/server/server.go index f6e39be30..5af493e60 100644 --- a/server/server.go +++ b/server/server.go @@ -29,6 +29,7 @@ import ( "github.com/emersion/go-smtp" "github.com/gorilla/websocket" "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/tidwall/gjson" "golang.org/x/sync/errgroup" "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/user" @@ -738,7 +739,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e return nil, err } m := newDefaultMessage(t.ID, "") - cache, firebase, email, call, unifiedpush, e := s.parsePublishParams(r, m) + cache, firebase, email, call, messageTemplate, titleTemplate, unifiedpush, e := s.parsePublishParams(r, m) if e != nil { return nil, e.With(t) } @@ -769,7 +770,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e if cache { m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix() } - if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil { + if err := s.handlePublishBody(r, v, m, body, messageTemplate, titleTemplate, unifiedpush); err != nil { return nil, err } if m.Message == "" { @@ -924,7 +925,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) { } } -func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, unifiedpush bool, err *errHTTP) { +func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, messageTemplate string, titleTemplate string, unifiedpush bool, err *errHTTP) { cache = readBoolParam(r, true, "x-cache", "cache") firebase = readBoolParam(r, true, "x-firebase", "firebase") m.Title = readParam(r, "x-title", "title", "t") @@ -940,7 +941,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi } if attach != "" { if !urlRegex.MatchString(attach) { - return false, false, "", "", false, errHTTPBadRequestAttachmentURLInvalid + return false, false, "", "", "", "", false, errHTTPBadRequestAttachmentURLInvalid } m.Attachment.URL = attach if m.Attachment.Name == "" { @@ -958,19 +959,20 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi } if icon != "" { if !urlRegex.MatchString(icon) { - return false, false, "", "", false, errHTTPBadRequestIconURLInvalid + return false, false, "", "", "", "", false, errHTTPBadRequestIconURLInvalid } m.Icon = icon } email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e") if s.smtpSender == nil && email != "" { - return false, false, "", "", false, errHTTPBadRequestEmailDisabled + return false, false, "", "", "", "", false, errHTTPBadRequestEmailDisabled } call = readParam(r, "x-call", "call") if call != "" && (s.config.TwilioAccount == "" || s.userManager == nil) { - return false, false, "", "", false, errHTTPBadRequestPhoneCallsDisabled + print("call: %s", call) + return false, false, "", "", "", "", false, errHTTPBadRequestPhoneCallsDisabled } else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) { - return false, false, "", "", false, errHTTPBadRequestPhoneNumberInvalid + return false, false, "", "", "", "", false, errHTTPBadRequestPhoneNumberInvalid } messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n") if messageStr != "" { @@ -979,27 +981,27 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi var e error m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p")) if e != nil { - return false, false, "", "", false, errHTTPBadRequestPriorityInvalid + return false, false, "", "", "", "", false, errHTTPBadRequestPriorityInvalid } m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta") delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in") if delayStr != "" { if !cache { - return false, false, "", "", false, errHTTPBadRequestDelayNoCache + return false, false, "", "", "", "", false, errHTTPBadRequestDelayNoCache } if email != "" { - return false, false, "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet) + return false, false, "", "", "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet) } if call != "" { - return false, false, "", "", false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet) + return false, false, "", "", "", "", false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet) } delay, err := util.ParseFutureTime(delayStr, time.Now()) if err != nil { - return false, false, "", "", false, errHTTPBadRequestDelayCannotParse + return false, false, "", "", "", "", false, errHTTPBadRequestDelayCannotParse } else if delay.Unix() < time.Now().Add(s.config.MessageDelayMin).Unix() { - return false, false, "", "", false, errHTTPBadRequestDelayTooSmall + return false, false, "", "", "", "", false, errHTTPBadRequestDelayTooSmall } else if delay.Unix() > time.Now().Add(s.config.MessageDelayMax).Unix() { - return false, false, "", "", false, errHTTPBadRequestDelayTooLarge + return false, false, "", "", "", "", false, errHTTPBadRequestDelayTooLarge } m.Time = delay.Unix() } @@ -1007,13 +1009,15 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi if actionsStr != "" { m.Actions, e = parseActions(actionsStr) if e != nil { - return false, false, "", "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error()) + return false, false, "", "", "", "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error()) } } contentType, markdown := readParam(r, "content-type", "content_type"), readBoolParam(r, false, "x-markdown", "markdown", "md") if markdown || strings.ToLower(contentType) == "text/markdown" { m.ContentType = "text/markdown" } + messageTemplate = readParam(r, "x-template-message", "template-message", "tpl-m") + titleTemplate = readParam(r, "x-template-title", "template-title", "tpl-t") unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too! if unifiedpush { firebase = false @@ -1025,7 +1029,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi cache = false email = "" } - return cache, firebase, email, call, unifiedpush, nil + return cache, firebase, email, call, messageTemplate, titleTemplate, unifiedpush, nil } // handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message. @@ -1042,17 +1046,17 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi // If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message // 6. curl -T file.txt ntfy.sh/mytopic // If file.txt is > message limit, treat it as an attachment -func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, unifiedpush bool) error { +func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, messageTemplate string, titleTemplate string, unifiedpush bool) error { if m.Event == pollRequestEvent { // Case 1 return s.handleBodyDiscard(body) } else if unifiedpush { return s.handleBodyAsMessageAutoDetect(m, body) // Case 2 } else if m.Attachment != nil && m.Attachment.URL != "" { - return s.handleBodyAsTextMessage(m, body) // Case 3 + return s.handleBodyAsTextMessage(m, body, messageTemplate, titleTemplate) // Case 3 } else if m.Attachment != nil && m.Attachment.Name != "" { return s.handleBodyAsAttachment(r, v, m, body) // Case 4 } else if !body.LimitReached && utf8.Valid(body.PeekedBytes) { - return s.handleBodyAsTextMessage(m, body) // Case 5 + return s.handleBodyAsTextMessage(m, body, messageTemplate, titleTemplate) // Case 5 } return s.handleBodyAsAttachment(r, v, m, body) // Case 6 } @@ -1073,12 +1077,40 @@ func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeekedRead return nil } -func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser) error { +func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser, messageTemplate string, titleTemplate string) error { if !utf8.Valid(body.PeekedBytes) { return errHTTPBadRequestMessageNotUTF8.With(m) } if len(body.PeekedBytes) > 0 { // Empty body should not override message (publish via GET!) - m.Message = strings.TrimSpace(string(body.PeekedBytes)) // Truncates the message to the peek limit if required + peakedBody := strings.TrimSpace(string(body.PeekedBytes)) // Truncates the message to the peek limit if required + // Replace JSON paths in messageTemplate + if messageTemplate != "" && gjson.Valid(peakedBody) { + m.Message = messageTemplate + r := regexp.MustCompile(`\${([^}]+)}`) + messageMatches := r.FindAllStringSubmatch(messageTemplate, -1) + for _, v := range messageMatches { + query := v[1] + result := gjson.Get(peakedBody, query) + if result.Exists() { + m.Message = strings.ReplaceAll(m.Message, fmt.Sprintf("${%s}", query), result.String()) + } + } + } else { + m.Message = peakedBody + } + // Replace JSON paths in titleTemplate + if titleTemplate != "" && gjson.Valid(peakedBody) { + m.Title = titleTemplate + r := regexp.MustCompile(`\${([^}]+)}`) + titleMatches := r.FindAllStringSubmatch(titleTemplate, -1) + for _, v := range titleMatches { + query := v[1] + result := gjson.Get(peakedBody, query) + if result.Exists() { + m.Title = strings.ReplaceAll(m.Title, fmt.Sprintf("${%s}", query), result.String()) + } + } + } } if m.Attachment != nil && m.Attachment.Name != "" && m.Message == "" { m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name) diff --git a/server/server_test.go b/server/server_test.go index 8d965153c..4f6343605 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -2625,6 +2625,126 @@ func TestServer_UpstreamBaseURL_DoNotForwardUnifiedPush(t *testing.T) { time.Sleep(500 * time.Millisecond) } +func TestServer_MessageTemplate(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", `{"foo":"bar", "nested":{"title":"here"}}`, map[string]string{ + "X-Template-Message": "${foo}", + "X-Template-Title": "${nested.title}", + }) + + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "bar", m.Message) + require.Equal(t, "here", m.Title) +} + +func TestServer_MessageTemplate_RepeatPlaceholder(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", `{"foo":"bar", "nested":{"title":"here"}}`, map[string]string{ + "Template-Message": "${foo} is ${foo}", + "Template-Title": "${nested.title} is ${nested.title}", + }) + + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "bar is bar", m.Message) + require.Equal(t, "here is here", m.Title) +} + +func TestServer_MessageTemplate_JSONBody(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + body := `{"topic": "mytopic", "message": "{\"foo\":\"bar\",\"nested\":{\"title\":\"here\"}}"}` + response := request(t, s, "PUT", "/", body, map[string]string{ + "tpl-m": "${foo}", + "tpl-t": "${nested.title}", + }) + + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "bar", m.Message) + require.Equal(t, "here", m.Title) +} + +func TestServer_MessageTemplate_MalformedJSONBody(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + body := `{"topic": "mytopic", "message": "{\"foo\":\"bar\",\"nested\":{\"title\":\"here\"INVALID"}` + response := request(t, s, "PUT", "/", body, map[string]string{ + "X-Template-Message": "${foo}", + "X-Template-Title": "${nested.title}", + }) + + require.Equal(t, 200, response.Code, "Got %s", response) + m := toMessage(t, response.Body.String()) + require.Equal(t, "{\"foo\":\"bar\",\"nested\":{\"title\":\"here\"INVALID", m.Message) + require.Equal(t, "", m.Title) +} + +func TestServer_MessageTemplate_PlaceholderTypo(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", `{"foo":"bar", "nested":{"title":"here"}}`, map[string]string{ + "X-Template-Message": "${food}", + "X-Template-Title": "${nested.titl}", + }) + + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "${food}", m.Message) + require.Equal(t, "${nested.titl}", m.Title) +} + +func TestServer_MessageTemplate_MultiplePlaceholders(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", `{"foo":"bar", "nested":{"title":"here"}}`, map[string]string{ + "X-Template-Message": "${foo} is ${nested.title}", + }) + + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "bar is here", m.Message) +} + +func TestServer_MessageTemplate_NestedPlaceholders(t *testing.T) { + // not intended to work recursively for now + // i.e., ${${nested.bar}} should NOT evaluate to ${foo} and then to "bar" + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", `{"foo":"bar", "nested":{"title":"here","bar":"foo"}}`, map[string]string{ + "X-Template-Message": "${${nested.bar}}", + }) + + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "${${nested.bar}}", m.Message) +} + +func TestServer_MessageTemplate_NestedPlaceholdersFunky(t *testing.T) { + // The above example can technically work + // ${${nested.bar}} would be interpreted as a nested GJSON path with key "${nested" then key "bar" + // so you would probably expect the output to be "works!", BUT the second } in the placeholder is not + // included by the regex, so it is still there after replacing the placeholder, thus giving you "works!}" + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", `{"foo":"bar", "nested":{"title":"here","bar":"foo"}, "${nested":{"bar":"works!"}}`, map[string]string{ + "X-Template-Message": "${${nested.bar}}", + }) + + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "works!}", m.Message) +} + +func TestServer_MessageTemplate_FancyGJSON(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + jsonBody := `{"foo": "bar", "errors": [{"level": "severe", "url": "https://severe1.com"},{"level": "warning", "url": "https://warning.com"},{"level": "severe", "url": "https://severe2.com"}]}` + response := request(t, s, "PUT", "/mytopic", jsonBody, map[string]string{ + "X-Template-Message": `${errors.#(level=="severe")#.url}`, + "X-Template-Title": `${errors.#(level=="severe")#|#} Severe Errors`, + }) + + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, `["https://severe1.com","https://severe2.com"]`, m.Message) + require.Equal(t, `2 Severe Errors`, m.Title) +} + func newTestConfig(t *testing.T) *Config { conf := NewConfig() conf.BaseURL = "http://127.0.0.1:12345" From b2eb5b94bdc8efd76516aae9b5db4a10d2360bad Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Mon, 18 Mar 2024 20:04:40 -0600 Subject: [PATCH 2/8] use existing message and title fields for templates --- server/server.go | 70 ++++++++++++++++++++----------------------- server/server_test.go | 41 +++++++++++++++---------- 2 files changed, 58 insertions(+), 53 deletions(-) diff --git a/server/server.go b/server/server.go index 5af493e60..56afa8ba8 100644 --- a/server/server.go +++ b/server/server.go @@ -739,7 +739,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e return nil, err } m := newDefaultMessage(t.ID, "") - cache, firebase, email, call, messageTemplate, titleTemplate, unifiedpush, e := s.parsePublishParams(r, m) + cache, firebase, email, call, template, unifiedpush, e := s.parsePublishParams(r, m) if e != nil { return nil, e.With(t) } @@ -770,7 +770,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e if cache { m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix() } - if err := s.handlePublishBody(r, v, m, body, messageTemplate, titleTemplate, unifiedpush); err != nil { + if err := s.handlePublishBody(r, v, m, body, template, unifiedpush); err != nil { return nil, err } if m.Message == "" { @@ -925,7 +925,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) { } } -func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, messageTemplate string, titleTemplate string, unifiedpush bool, err *errHTTP) { +func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template bool, unifiedpush bool, err *errHTTP) { cache = readBoolParam(r, true, "x-cache", "cache") firebase = readBoolParam(r, true, "x-firebase", "firebase") m.Title = readParam(r, "x-title", "title", "t") @@ -941,7 +941,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi } if attach != "" { if !urlRegex.MatchString(attach) { - return false, false, "", "", "", "", false, errHTTPBadRequestAttachmentURLInvalid + return false, false, "", "", false, false, errHTTPBadRequestAttachmentURLInvalid } m.Attachment.URL = attach if m.Attachment.Name == "" { @@ -959,20 +959,20 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi } if icon != "" { if !urlRegex.MatchString(icon) { - return false, false, "", "", "", "", false, errHTTPBadRequestIconURLInvalid + return false, false, "", "", false, false, errHTTPBadRequestIconURLInvalid } m.Icon = icon } email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e") if s.smtpSender == nil && email != "" { - return false, false, "", "", "", "", false, errHTTPBadRequestEmailDisabled + return false, false, "", "", false, false, errHTTPBadRequestEmailDisabled } call = readParam(r, "x-call", "call") if call != "" && (s.config.TwilioAccount == "" || s.userManager == nil) { print("call: %s", call) - return false, false, "", "", "", "", false, errHTTPBadRequestPhoneCallsDisabled + return false, false, "", "", false, false, errHTTPBadRequestPhoneCallsDisabled } else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) { - return false, false, "", "", "", "", false, errHTTPBadRequestPhoneNumberInvalid + return false, false, "", "", false, false, errHTTPBadRequestPhoneNumberInvalid } messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n") if messageStr != "" { @@ -981,27 +981,27 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi var e error m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p")) if e != nil { - return false, false, "", "", "", "", false, errHTTPBadRequestPriorityInvalid + return false, false, "", "", false, false, errHTTPBadRequestPriorityInvalid } m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta") delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in") if delayStr != "" { if !cache { - return false, false, "", "", "", "", false, errHTTPBadRequestDelayNoCache + return false, false, "", "", false, false, errHTTPBadRequestDelayNoCache } if email != "" { - return false, false, "", "", "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet) + return false, false, "", "", false, false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet) } if call != "" { - return false, false, "", "", "", "", false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet) + return false, false, "", "", false, false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet) } delay, err := util.ParseFutureTime(delayStr, time.Now()) if err != nil { - return false, false, "", "", "", "", false, errHTTPBadRequestDelayCannotParse + return false, false, "", "", false, false, errHTTPBadRequestDelayCannotParse } else if delay.Unix() < time.Now().Add(s.config.MessageDelayMin).Unix() { - return false, false, "", "", "", "", false, errHTTPBadRequestDelayTooSmall + return false, false, "", "", false, false, errHTTPBadRequestDelayTooSmall } else if delay.Unix() > time.Now().Add(s.config.MessageDelayMax).Unix() { - return false, false, "", "", "", "", false, errHTTPBadRequestDelayTooLarge + return false, false, "", "", false, false, errHTTPBadRequestDelayTooLarge } m.Time = delay.Unix() } @@ -1009,15 +1009,14 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi if actionsStr != "" { m.Actions, e = parseActions(actionsStr) if e != nil { - return false, false, "", "", "", "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error()) + return false, false, "", "", false, false, errHTTPBadRequestActionsInvalid.Wrap(e.Error()) } } contentType, markdown := readParam(r, "content-type", "content_type"), readBoolParam(r, false, "x-markdown", "markdown", "md") if markdown || strings.ToLower(contentType) == "text/markdown" { m.ContentType = "text/markdown" } - messageTemplate = readParam(r, "x-template-message", "template-message", "tpl-m") - titleTemplate = readParam(r, "x-template-title", "template-title", "tpl-t") + template = readBoolParam(r, false, "x-template", "template", "tpl") unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too! if unifiedpush { firebase = false @@ -1029,7 +1028,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi cache = false email = "" } - return cache, firebase, email, call, messageTemplate, titleTemplate, unifiedpush, nil + return cache, firebase, email, call, template, unifiedpush, nil } // handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message. @@ -1046,17 +1045,17 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi // If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message // 6. curl -T file.txt ntfy.sh/mytopic // If file.txt is > message limit, treat it as an attachment -func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, messageTemplate string, titleTemplate string, unifiedpush bool) error { +func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, template bool, unifiedpush bool) error { if m.Event == pollRequestEvent { // Case 1 return s.handleBodyDiscard(body) } else if unifiedpush { return s.handleBodyAsMessageAutoDetect(m, body) // Case 2 } else if m.Attachment != nil && m.Attachment.URL != "" { - return s.handleBodyAsTextMessage(m, body, messageTemplate, titleTemplate) // Case 3 + return s.handleBodyAsTextMessage(m, body, template) // Case 3 } else if m.Attachment != nil && m.Attachment.Name != "" { return s.handleBodyAsAttachment(r, v, m, body) // Case 4 } else if !body.LimitReached && utf8.Valid(body.PeekedBytes) { - return s.handleBodyAsTextMessage(m, body, messageTemplate, titleTemplate) // Case 5 + return s.handleBodyAsTextMessage(m, body, template) // Case 5 } return s.handleBodyAsAttachment(r, v, m, body) // Case 6 } @@ -1077,39 +1076,36 @@ func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeekedRead return nil } -func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser, messageTemplate string, titleTemplate string) error { +func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser, template bool) error { if !utf8.Valid(body.PeekedBytes) { return errHTTPBadRequestMessageNotUTF8.With(m) } if len(body.PeekedBytes) > 0 { // Empty body should not override message (publish via GET!) peakedBody := strings.TrimSpace(string(body.PeekedBytes)) // Truncates the message to the peek limit if required - // Replace JSON paths in messageTemplate - if messageTemplate != "" && gjson.Valid(peakedBody) { - m.Message = messageTemplate + if template && gjson.Valid(peakedBody) { + // Replace JSON paths in message r := regexp.MustCompile(`\${([^}]+)}`) - messageMatches := r.FindAllStringSubmatch(messageTemplate, -1) - for _, v := range messageMatches { + matches := r.FindAllStringSubmatch(m.Message, -1) + for _, v := range matches { query := v[1] result := gjson.Get(peakedBody, query) if result.Exists() { m.Message = strings.ReplaceAll(m.Message, fmt.Sprintf("${%s}", query), result.String()) } } - } else { - m.Message = peakedBody - } - // Replace JSON paths in titleTemplate - if titleTemplate != "" && gjson.Valid(peakedBody) { - m.Title = titleTemplate - r := regexp.MustCompile(`\${([^}]+)}`) - titleMatches := r.FindAllStringSubmatch(titleTemplate, -1) - for _, v := range titleMatches { + + // Replace JSON paths in title + r = regexp.MustCompile(`\${([^}]+)}`) + matches = r.FindAllStringSubmatch(m.Title, -1) + for _, v := range matches { query := v[1] result := gjson.Get(peakedBody, query) if result.Exists() { m.Title = strings.ReplaceAll(m.Title, fmt.Sprintf("${%s}", query), result.String()) } } + } else { + m.Message = peakedBody } } if m.Attachment != nil && m.Attachment.Name != "" && m.Message == "" { diff --git a/server/server_test.go b/server/server_test.go index 4f6343605..35ea2c548 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -2628,8 +2628,9 @@ func TestServer_UpstreamBaseURL_DoNotForwardUnifiedPush(t *testing.T) { func TestServer_MessageTemplate(t *testing.T) { s := newTestServer(t, newTestConfig(t)) response := request(t, s, "PUT", "/mytopic", `{"foo":"bar", "nested":{"title":"here"}}`, map[string]string{ - "X-Template-Message": "${foo}", - "X-Template-Title": "${nested.title}", + "X-Message": "${foo}", + "X-Title": "${nested.title}", + "X-Template": "1", }) require.Equal(t, 200, response.Code) @@ -2641,8 +2642,9 @@ func TestServer_MessageTemplate(t *testing.T) { func TestServer_MessageTemplate_RepeatPlaceholder(t *testing.T) { s := newTestServer(t, newTestConfig(t)) response := request(t, s, "PUT", "/mytopic", `{"foo":"bar", "nested":{"title":"here"}}`, map[string]string{ - "Template-Message": "${foo} is ${foo}", - "Template-Title": "${nested.title} is ${nested.title}", + "Message": "${foo} is ${foo}", + "Title": "${nested.title} is ${nested.title}", + "Template": "1", }) require.Equal(t, 200, response.Code) @@ -2655,8 +2657,9 @@ func TestServer_MessageTemplate_JSONBody(t *testing.T) { s := newTestServer(t, newTestConfig(t)) body := `{"topic": "mytopic", "message": "{\"foo\":\"bar\",\"nested\":{\"title\":\"here\"}}"}` response := request(t, s, "PUT", "/", body, map[string]string{ - "tpl-m": "${foo}", - "tpl-t": "${nested.title}", + "m": "${foo}", + "t": "${nested.title}", + "tpl": "1", }) require.Equal(t, 200, response.Code) @@ -2669,21 +2672,23 @@ func TestServer_MessageTemplate_MalformedJSONBody(t *testing.T) { s := newTestServer(t, newTestConfig(t)) body := `{"topic": "mytopic", "message": "{\"foo\":\"bar\",\"nested\":{\"title\":\"here\"INVALID"}` response := request(t, s, "PUT", "/", body, map[string]string{ - "X-Template-Message": "${foo}", - "X-Template-Title": "${nested.title}", + "X-Message": "${foo}", + "X-Title": "${nested.title}", + "X-Template": "1", }) require.Equal(t, 200, response.Code, "Got %s", response) m := toMessage(t, response.Body.String()) require.Equal(t, "{\"foo\":\"bar\",\"nested\":{\"title\":\"here\"INVALID", m.Message) - require.Equal(t, "", m.Title) + require.Equal(t, "${nested.title}", m.Title) } func TestServer_MessageTemplate_PlaceholderTypo(t *testing.T) { s := newTestServer(t, newTestConfig(t)) response := request(t, s, "PUT", "/mytopic", `{"foo":"bar", "nested":{"title":"here"}}`, map[string]string{ - "X-Template-Message": "${food}", - "X-Template-Title": "${nested.titl}", + "X-Message": "${food}", + "X-Title": "${nested.titl}", + "X-Template": "1", }) require.Equal(t, 200, response.Code) @@ -2695,7 +2700,8 @@ func TestServer_MessageTemplate_PlaceholderTypo(t *testing.T) { func TestServer_MessageTemplate_MultiplePlaceholders(t *testing.T) { s := newTestServer(t, newTestConfig(t)) response := request(t, s, "PUT", "/mytopic", `{"foo":"bar", "nested":{"title":"here"}}`, map[string]string{ - "X-Template-Message": "${foo} is ${nested.title}", + "X-Message": "${foo} is ${nested.title}", + "X-Template": "1", }) require.Equal(t, 200, response.Code) @@ -2708,7 +2714,8 @@ func TestServer_MessageTemplate_NestedPlaceholders(t *testing.T) { // i.e., ${${nested.bar}} should NOT evaluate to ${foo} and then to "bar" s := newTestServer(t, newTestConfig(t)) response := request(t, s, "PUT", "/mytopic", `{"foo":"bar", "nested":{"title":"here","bar":"foo"}}`, map[string]string{ - "X-Template-Message": "${${nested.bar}}", + "X-Message": "${${nested.bar}}", + "X-Template": "1", }) require.Equal(t, 200, response.Code) @@ -2723,7 +2730,8 @@ func TestServer_MessageTemplate_NestedPlaceholdersFunky(t *testing.T) { // included by the regex, so it is still there after replacing the placeholder, thus giving you "works!}" s := newTestServer(t, newTestConfig(t)) response := request(t, s, "PUT", "/mytopic", `{"foo":"bar", "nested":{"title":"here","bar":"foo"}, "${nested":{"bar":"works!"}}`, map[string]string{ - "X-Template-Message": "${${nested.bar}}", + "X-Message": "${${nested.bar}}", + "X-Template": "1", }) require.Equal(t, 200, response.Code) @@ -2735,8 +2743,9 @@ func TestServer_MessageTemplate_FancyGJSON(t *testing.T) { s := newTestServer(t, newTestConfig(t)) jsonBody := `{"foo": "bar", "errors": [{"level": "severe", "url": "https://severe1.com"},{"level": "warning", "url": "https://warning.com"},{"level": "severe", "url": "https://severe2.com"}]}` response := request(t, s, "PUT", "/mytopic", jsonBody, map[string]string{ - "X-Template-Message": `${errors.#(level=="severe")#.url}`, - "X-Template-Title": `${errors.#(level=="severe")#|#} Severe Errors`, + "X-Message": `${errors.#(level=="severe")#.url}`, + "X-Title": `${errors.#(level=="severe")#|#} Severe Errors`, + "X-Template": "1", }) require.Equal(t, 200, response.Code) From 867cf2808068fd50678b6588f8780497afb88a83 Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Tue, 19 Mar 2024 20:21:45 -0600 Subject: [PATCH 3/8] refactor gjson parsing code --- server/server.go | 41 +++++++++++++++++------------------------ 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/server/server.go b/server/server.go index 56afa8ba8..8be6ce758 100644 --- a/server/server.go +++ b/server/server.go @@ -110,6 +110,7 @@ var ( fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`) urlRegex = regexp.MustCompile(`^https?://`) phoneNumberRegex = regexp.MustCompile(`^\+\d{1,100}$`) + templateVarRegex = regexp.MustCompile(`\${([^}]+)}`) //go:embed site webFs embed.FS @@ -1076,36 +1077,28 @@ func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeekedRead return nil } +func replaceGJSONTemplate(template string, source string) string { + matches := templateVarRegex.FindAllStringSubmatch(template, -1) + for _, v := range matches { + query := v[1] + if result := gjson.Get(source, query); result.Exists() { + template = strings.ReplaceAll(template, fmt.Sprintf("${%s}", query), result.String()) + } + } + return template +} + func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser, template bool) error { if !utf8.Valid(body.PeekedBytes) { return errHTTPBadRequestMessageNotUTF8.With(m) } if len(body.PeekedBytes) > 0 { // Empty body should not override message (publish via GET!) - peakedBody := strings.TrimSpace(string(body.PeekedBytes)) // Truncates the message to the peek limit if required - if template && gjson.Valid(peakedBody) { - // Replace JSON paths in message - r := regexp.MustCompile(`\${([^}]+)}`) - matches := r.FindAllStringSubmatch(m.Message, -1) - for _, v := range matches { - query := v[1] - result := gjson.Get(peakedBody, query) - if result.Exists() { - m.Message = strings.ReplaceAll(m.Message, fmt.Sprintf("${%s}", query), result.String()) - } - } - - // Replace JSON paths in title - r = regexp.MustCompile(`\${([^}]+)}`) - matches = r.FindAllStringSubmatch(m.Title, -1) - for _, v := range matches { - query := v[1] - result := gjson.Get(peakedBody, query) - if result.Exists() { - m.Title = strings.ReplaceAll(m.Title, fmt.Sprintf("${%s}", query), result.String()) - } - } + peekedBody := strings.TrimSpace(string(body.PeekedBytes)) // Truncates the message to the peek limit if required + if template && gjson.Valid(peekedBody) { + m.Message = replaceGJSONTemplate(m.Message, peekedBody) + m.Title = replaceGJSONTemplate(m.Title, peekedBody) } else { - m.Message = peakedBody + m.Message = peekedBody } } if m.Attachment != nil && m.Attachment.Name != "" && m.Message == "" { From 03737dbf5c521a6e87d2f37509822bcf4f853de2 Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Tue, 19 Mar 2024 20:55:36 -0600 Subject: [PATCH 4/8] update docs --- docs/publish.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index cd67ed695..672f2fa3d 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -3559,27 +3559,32 @@ ntfy server plays the role of the Push Gateway, as well as the Push Provider. Un ### Message and Title Templates Some services let you specify a webhook URL but do not let you modify the webhook body (e.g., Grafana). Instead of using a separate -bridge program to parse the webhook body into the format ntfy expects, you can include a message template and/or a title template +bridge program to parse the webhook body into the format ntfy expects, you can include a templated message and/or a templated title which will be populated based on the fields of the webhook body (so long as the webhook body is valid JSON). -Send the message template with the header `X-Template-Message`, `Template-Message`, or `tpl-m`. Send the title template with the -header `X-Template-Title`, `Template-Title`, or `tpl-t`. (No other fields can be filled with a template at this time). - -In the template, include paths to the appropriate JSON fields surrounded by `${` and `}`. See an example below. +Enable templating by setting the `X-Template` header (or its aliases `Template` or `tpl`) to "yes". Then, include templates +in your message and/or title (no other fields can be filled with a template at this time) by including paths to the +appropriate JSON fields surrounded by `${` and `}`. See an example below. See [GJSON docs](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) for supported JSON path syntax. +[https://gjson.dev/](https://gjson.dev/) is a great resource for testing your templates. === "HTTP" ``` http POST /mytopic HTTP/1.1 Host: ntfy.sh - X-Template-Message: Error message: ${error.desc} - X-Template-Title: ${hostname}: A ${error.level} error has occurred + X-Message: Error message: ${error.desc} + X-Title: ${hostname}: A ${error.level} error has occurred + X-Template: yes {"hostname": "philipp-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}} ``` The example above would send a notification with a title "philipp-pc: A severe error has occurred" and a message "Error message: Disk has run out of space". +For Grafana webhooks, you might find it helpful to use the headers `X-Title: Grafana alert: ${title}` and `X-Message: ${message}`. +Alternatively, you can include the params in the webhook URL. For example, by +appending `?template=yes&title=Grafana alert: ${title}&message=${message}` to the URL. + ## Public topics Obviously all topics on ntfy.sh are public, but there are a few designated topics that are used in examples, and topics that you can use to try out what [authentication and access control](#authentication) looks like. From 7fd5f0b29d4d0b9cd34bd9d5c1e3759d48a6916b Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Tue, 19 Mar 2024 21:56:55 -0600 Subject: [PATCH 5/8] allow large HTTP body so long as resulting message is small --- server/errors.go | 1 + server/server.go | 21 ++++++++++++++++++--- server/server_test.go | 29 +++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/server/errors.go b/server/errors.go index 072bdc011..05adeb66d 100644 --- a/server/errors.go +++ b/server/errors.go @@ -117,6 +117,7 @@ var ( errHTTPBadRequestWebPushSubscriptionInvalid = &errHTTP{40038, http.StatusBadRequest, "invalid request: web push payload malformed", "", nil} errHTTPBadRequestWebPushEndpointUnknown = &errHTTP{40039, http.StatusBadRequest, "invalid request: web push endpoint unknown", "", nil} errHTTPBadRequestWebPushTopicCountTooHigh = &errHTTP{40040, http.StatusBadRequest, "invalid request: too many web push topic subscriptions", "", nil} + errHTTPBadRequestTemplatedMessageTooLarge = &errHTTP{40041, http.StatusBadRequest, "invalid request: message is too large after replacing template", "", nil} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil} errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil} diff --git a/server/server.go b/server/server.go index 8be6ce758..e6b1b88e4 100644 --- a/server/server.go +++ b/server/server.go @@ -1044,8 +1044,11 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi // Body must be attachment, because we passed a filename // 5. curl -T file.txt ntfy.sh/mytopic // If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message -// 6. curl -T file.txt ntfy.sh/mytopic -// If file.txt is > message limit, treat it as an attachment +// 6. curl -H "Template: yes" -T file.txt ntfy.sh/mytopic +// If file.txt is < 4096*2 (message limit*2) and a template is used, try parsing under the assumption +// that the message generated by the template will be less than 4096 +// 7. curl -T file.txt ntfy.sh/mytopic +// If file.txt is > message limit or template && file.txt > message limit*2, treat it as an attachment func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, template bool, unifiedpush bool) error { if m.Event == pollRequestEvent { // Case 1 return s.handleBodyDiscard(body) @@ -1057,8 +1060,16 @@ func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body return s.handleBodyAsAttachment(r, v, m, body) // Case 4 } else if !body.LimitReached && utf8.Valid(body.PeekedBytes) { return s.handleBodyAsTextMessage(m, body, template) // Case 5 + } else if template { + templateBody, err := util.Peek(body, s.config.MessageSizeLimit*2) + if err != nil { + return err + } + if !templateBody.LimitReached { + return s.handleBodyAsTextMessage(m, templateBody, template) // Case 6 + } } - return s.handleBodyAsAttachment(r, v, m, body) // Case 6 + return s.handleBodyAsAttachment(r, v, m, body) // Case 7 } func (s *Server) handleBodyDiscard(body *util.PeekedReadCloser) error { @@ -1104,6 +1115,10 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser if m.Attachment != nil && m.Attachment.Name != "" && m.Message == "" { m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name) } + // Ensure message is less than message limit after templating + if len(m.Message) > s.config.MessageSizeLimit { + return errHTTPBadRequestTemplatedMessageTooLarge + } return nil } diff --git a/server/server_test.go b/server/server_test.go index 35ea2c548..99f4c4de3 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -2754,6 +2754,35 @@ func TestServer_MessageTemplate_FancyGJSON(t *testing.T) { require.Equal(t, `2 Severe Errors`, m.Title) } +func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageOK(t *testing.T) { + c := newTestConfig(t) + c.MessageSizeLimit = 25 // 25 < len(HTTP body) < 25*2 && len(m.Message) < 25 + s := newTestServer(t, c) + response := request(t, s, "PUT", "/mytopic", `{"foo":"bar", "nested":{"title":"here"}}`, map[string]string{ + "X-Message": "${foo}", + "X-Title": "${nested.title}", + "X-Template": "1", + }) + + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "bar", m.Message) + require.Equal(t, "here", m.Title) +} + +func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageTooLong(t *testing.T) { + c := newTestConfig(t) + c.MessageSizeLimit = 21 // 21 < len(HTTP body) < 21*2 && !len(m.Message) < 21 + s := newTestServer(t, c) + response := request(t, s, "PUT", "/mytopic", `{"foo":"This is a long message"}`, map[string]string{ + "X-Message": "${foo}", + "X-Template": "1", + }) + + require.Equal(t, 400, response.Code) + require.Equal(t, 40041, toHTTPError(t, response.Body.String()).Code) +} + func newTestConfig(t *testing.T) *Config { conf := NewConfig() conf.BaseURL = "http://127.0.0.1:12345" From 83356f565e5327b2bb20c94530c12e17af2796da Mon Sep 17 00:00:00 2001 From: wunter8 Date: Wed, 20 Mar 2024 10:54:41 -0600 Subject: [PATCH 6/8] remove debug print statement --- server/server.go | 1 - 1 file changed, 1 deletion(-) diff --git a/server/server.go b/server/server.go index e6b1b88e4..337a3d166 100644 --- a/server/server.go +++ b/server/server.go @@ -970,7 +970,6 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi } call = readParam(r, "x-call", "call") if call != "" && (s.config.TwilioAccount == "" || s.userManager == nil) { - print("call: %s", call) return false, false, "", "", false, false, errHTTPBadRequestPhoneCallsDisabled } else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) { return false, false, "", "", false, false, errHTTPBadRequestPhoneNumberInvalid From de65d0751803306d07081f41501688cb0e9b5120 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 20 Mar 2024 21:33:54 -0400 Subject: [PATCH 7/8] Simplify(?) templating cases --- cmd/access_test.go | 2 + cmd/config_loader_test.go | 1 + cmd/publish_test.go | 3 ++ server/errors.go | 3 +- server/server.go | 84 ++++++++++++++++++++------------------- server/server_account.go | 24 +++++------ server/server_admin.go | 17 ++++---- server/server_payments.go | 6 +-- server/server_test.go | 38 ++++++++++++++---- server/server_webpush.go | 4 +- server/util.go | 5 ++- test/server.go | 2 +- util/peek.go | 5 ++- 13 files changed, 115 insertions(+), 79 deletions(-) diff --git a/cmd/access_test.go b/cmd/access_test.go index 81c9f2b91..47aa9dae3 100644 --- a/cmd/access_test.go +++ b/cmd/access_test.go @@ -10,6 +10,7 @@ import ( ) func TestCLI_Access_Show(t *testing.T) { + t.Parallel() s, conf, port := newTestServerWithAuth(t) defer test.StopServer(t, s, port) @@ -19,6 +20,7 @@ func TestCLI_Access_Show(t *testing.T) { } func TestCLI_Access_Grant_And_Publish(t *testing.T) { + t.Parallel() s, conf, port := newTestServerWithAuth(t) defer test.StopServer(t, s, port) diff --git a/cmd/config_loader_test.go b/cmd/config_loader_test.go index 7a7f2bf14..67a4bcbe8 100644 --- a/cmd/config_loader_test.go +++ b/cmd/config_loader_test.go @@ -8,6 +8,7 @@ import ( ) func TestNewYamlSourceFromFile(t *testing.T) { + t.Parallel() filename := filepath.Join(t.TempDir(), "server.yml") contents := ` # Normal options diff --git a/cmd/publish_test.go b/cmd/publish_test.go index 31d01cb58..e03ae1dc5 100644 --- a/cmd/publish_test.go +++ b/cmd/publish_test.go @@ -17,6 +17,7 @@ import ( ) func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) { + t.Parallel() testMessage := util.RandomString(10) app, _, _, _ := newTestApp() require.Nil(t, app.Run([]string{"ntfy", "publish", "ntfytest", "ntfy unit test " + testMessage})) @@ -35,6 +36,7 @@ func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) { } func TestCLI_Publish_Subscribe_Poll(t *testing.T) { + t.Parallel() s, port := test.StartServer(t) defer test.StopServer(t, s, port) topic := fmt.Sprintf("http://127.0.0.1:%d/mytopic", port) @@ -51,6 +53,7 @@ func TestCLI_Publish_Subscribe_Poll(t *testing.T) { } func TestCLI_Publish_All_The_Things(t *testing.T) { + t.Parallel() s, port := test.StartServer(t) defer test.StopServer(t, s, port) topic := fmt.Sprintf("http://127.0.0.1:%d/mytopic", port) diff --git a/server/errors.go b/server/errors.go index 05adeb66d..92ea0ee65 100644 --- a/server/errors.go +++ b/server/errors.go @@ -117,7 +117,8 @@ var ( errHTTPBadRequestWebPushSubscriptionInvalid = &errHTTP{40038, http.StatusBadRequest, "invalid request: web push payload malformed", "", nil} errHTTPBadRequestWebPushEndpointUnknown = &errHTTP{40039, http.StatusBadRequest, "invalid request: web push endpoint unknown", "", nil} errHTTPBadRequestWebPushTopicCountTooHigh = &errHTTP{40040, http.StatusBadRequest, "invalid request: too many web push topic subscriptions", "", nil} - errHTTPBadRequestTemplatedMessageTooLarge = &errHTTP{40041, http.StatusBadRequest, "invalid request: message is too large after replacing template", "", nil} + errHTTPBadRequestTemplatedMessageTooLarge = &errHTTP{40041, http.StatusBadRequest, "invalid request: message or title is too large after replacing template", "", nil} + errHTTPBadRequestTemplatedMessageNotJSON = &errHTTP{40042, http.StatusBadRequest, "invalid request: message body must be JSON if templating is enabled", "", nil} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil} errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil} diff --git a/server/server.go b/server/server.go index 337a3d166..1c1950d10 100644 --- a/server/server.go +++ b/server/server.go @@ -111,6 +111,7 @@ var ( urlRegex = regexp.MustCompile(`^https?://`) phoneNumberRegex = regexp.MustCompile(`^\+\d{1,100}$`) templateVarRegex = regexp.MustCompile(`\${([^}]+)}`) + templateVarFormat = "${%s}" //go:embed site webFs embed.FS @@ -125,12 +126,12 @@ var ( const ( firebaseControlTopic = "~control" // See Android if changed - firebasePollTopic = "~poll" // See iOS if changed + firebasePollTopic = "~poll" // See iOS if changed (DISABLED for now) emptyMessageBody = "triggered" // Used if message body is empty newMessageBody = "New message" // Used in poll requests as generic message defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment encodingBase64 = "base64" // Used mainly for binary UnifiedPush messages - jsonBodyBytesLimit = 16384 // Max number of bytes for a JSON request body + httpBodyBytesLimit = 32768 // Max number of bytes for a request bodys (unless MessageLimit is higher) unifiedPushTopicPrefix = "up" // Temporarily, we rate limit all "up*" topics based on the subscriber unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part messagesHistoryMax = 10 // Number of message count values to keep in memory @@ -675,7 +676,7 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor) // - avoid abuse (e.g. 1 uploader, 1k downloaders) // - and also uses the higher bandwidth limits of a paying user m, err := s.messageCache.Message(messageID) - if err == errMessageNotFound { + if errors.Is(err, errMessageNotFound) { if s.config.CacheBatchTimeout > 0 { // Strange edge case: If we immediately after upload request the file (the web app does this for images), // and messages are persisted asynchronously, retry fetching from the database @@ -874,7 +875,7 @@ func (s *Server) sendToFirebase(v *visitor, m *message) { logvm(v, m).Tag(tagFirebase).Debug("Publishing to Firebase") if err := s.firebaseClient.Send(v, m); err != nil { minc(metricFirebasePublishedFailure) - if err == errFirebaseTemporarilyBanned { + if errors.Is(err, errFirebaseTemporarilyBanned) { logvm(v, m).Tag(tagFirebase).Err(err).Debug("Unable to publish to Firebase: %v", err.Error()) } else { logvm(v, m).Tag(tagFirebase).Err(err).Warn("Unable to publish to Firebase: %v", err.Error()) @@ -1036,37 +1037,30 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi // 1. curl -X POST -H "Poll: 1234" ntfy.sh/... // If a message is flagged as poll request, the body does not matter and is discarded // 2. curl -T somebinarydata.bin "ntfy.sh/mytopic?up=1" -// If body is binary, encode as base64, if not do not encode +// If UnifiedPush is enabled, encode as base64 if body is binary, and do not trim // 3. curl -H "Attach: http://example.com/file.jpg" ntfy.sh/mytopic // Body must be a message, because we attached an external URL // 4. curl -T short.txt -H "Filename: short.txt" ntfy.sh/mytopic // Body must be attachment, because we passed a filename -// 5. curl -T file.txt ntfy.sh/mytopic +// 5. curl -H "Template: yes" -T file.txt ntfy.sh/mytopic +// If templating is enabled, read up to 32k and treat message body as JSON +// 6. curl -T file.txt ntfy.sh/mytopic // If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message -// 6. curl -H "Template: yes" -T file.txt ntfy.sh/mytopic -// If file.txt is < 4096*2 (message limit*2) and a template is used, try parsing under the assumption -// that the message generated by the template will be less than 4096 // 7. curl -T file.txt ntfy.sh/mytopic // If file.txt is > message limit or template && file.txt > message limit*2, treat it as an attachment -func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, template bool, unifiedpush bool) error { +func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, template, unifiedpush bool) error { if m.Event == pollRequestEvent { // Case 1 return s.handleBodyDiscard(body) } else if unifiedpush { return s.handleBodyAsMessageAutoDetect(m, body) // Case 2 } else if m.Attachment != nil && m.Attachment.URL != "" { - return s.handleBodyAsTextMessage(m, body, template) // Case 3 + return s.handleBodyAsTextMessage(m, body) // Case 3 } else if m.Attachment != nil && m.Attachment.Name != "" { return s.handleBodyAsAttachment(r, v, m, body) // Case 4 - } else if !body.LimitReached && utf8.Valid(body.PeekedBytes) { - return s.handleBodyAsTextMessage(m, body, template) // Case 5 } else if template { - templateBody, err := util.Peek(body, s.config.MessageSizeLimit*2) - if err != nil { - return err - } - if !templateBody.LimitReached { - return s.handleBodyAsTextMessage(m, templateBody, template) // Case 6 - } + return s.handleBodyAsTemplatedTextMessage(m, body) // Case 5 + } else if !body.LimitReached && utf8.Valid(body.PeekedBytes) { + return s.handleBodyAsTextMessage(m, body) // Case 6 } return s.handleBodyAsAttachment(r, v, m, body) // Case 7 } @@ -1087,34 +1081,32 @@ func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeekedRead return nil } -func replaceGJSONTemplate(template string, source string) string { - matches := templateVarRegex.FindAllStringSubmatch(template, -1) - for _, v := range matches { - query := v[1] - if result := gjson.Get(source, query); result.Exists() { - template = strings.ReplaceAll(template, fmt.Sprintf("${%s}", query), result.String()) - } - } - return template -} - -func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser, template bool) error { +func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser) error { if !utf8.Valid(body.PeekedBytes) { return errHTTPBadRequestMessageNotUTF8.With(m) } if len(body.PeekedBytes) > 0 { // Empty body should not override message (publish via GET!) - peekedBody := strings.TrimSpace(string(body.PeekedBytes)) // Truncates the message to the peek limit if required - if template && gjson.Valid(peekedBody) { - m.Message = replaceGJSONTemplate(m.Message, peekedBody) - m.Title = replaceGJSONTemplate(m.Title, peekedBody) - } else { - m.Message = peekedBody - } + m.Message = strings.TrimSpace(string(body.PeekedBytes)) // Truncates the message to the peek limit if required } if m.Attachment != nil && m.Attachment.Name != "" && m.Message == "" { m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name) } - // Ensure message is less than message limit after templating + return nil +} + +func (s *Server) handleBodyAsTemplatedTextMessage(m *message, body *util.PeekedReadCloser) error { + body, err := util.Peek(body, httpBodyBytesLimit) + if err != nil { + return err + } else if body.LimitReached { + return errHTTPEntityTooLargeJSONBody + } + peekedBody := strings.TrimSpace(string(body.PeekedBytes)) + if !gjson.Valid(peekedBody) { + return errHTTPBadRequestTemplatedMessageNotJSON + } + m.Message = replaceGJSONTemplate(m.Message, peekedBody) + m.Title = replaceGJSONTemplate(m.Title, peekedBody) if len(m.Message) > s.config.MessageSizeLimit { return errHTTPBadRequestTemplatedMessageTooLarge } @@ -1163,7 +1155,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, util.NewFixedLimiter(vinfo.Stats.AttachmentTotalSizeRemaining), } m.Attachment.Size, err = s.fileCache.Write(m.ID, body, limiters...) - if err == util.ErrLimitReached { + if errors.Is(err, util.ErrLimitReached) { return errHTTPEntityTooLargeAttachment.With(m) } else if err != nil { return err @@ -1171,6 +1163,16 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, return nil } +func replaceGJSONTemplate(template string, source string) string { + matches := templateVarRegex.FindAllStringSubmatch(template, -1) + for _, m := range matches { + if result := gjson.Get(source, m[1]); result.Exists() { + template = strings.ReplaceAll(template, fmt.Sprintf(templateVarFormat, m[1]), result.String()) + } + } + return template +} + func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *visitor) error { encoder := func(msg *message) (string, error) { var buf bytes.Buffer diff --git a/server/server_account.go b/server/server_account.go index cb841d077..e457464d6 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -28,7 +28,7 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v * return errHTTPTooManyRequestsLimitAccountCreation } } - newAccount, err := readJSONWithLimit[apiAccountCreateRequest](r.Body, jsonBodyBytesLimit, false) + newAccount, err := readJSONWithLimit[apiAccountCreateRequest](r.Body, httpBodyBytesLimit, false) if err != nil { return err } @@ -160,7 +160,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis } func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { - req, err := readJSONWithLimit[apiAccountDeleteRequest](r.Body, jsonBodyBytesLimit, false) + req, err := readJSONWithLimit[apiAccountDeleteRequest](r.Body, httpBodyBytesLimit, false) if err != nil { return err } else if req.Password == "" { @@ -192,7 +192,7 @@ func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v * } func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Request, v *visitor) error { - req, err := readJSONWithLimit[apiAccountPasswordChangeRequest](r.Body, jsonBodyBytesLimit, false) + req, err := readJSONWithLimit[apiAccountPasswordChangeRequest](r.Body, httpBodyBytesLimit, false) if err != nil { return err } else if req.Password == "" || req.NewPassword == "" { @@ -210,7 +210,7 @@ func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Requ } func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { - req, err := readJSONWithLimit[apiAccountTokenIssueRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body! + req, err := readJSONWithLimit[apiAccountTokenIssueRequest](r.Body, httpBodyBytesLimit, true) // Allow empty body! if err != nil { return err } @@ -246,7 +246,7 @@ func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request func (s *Server) handleAccountTokenUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error { u := v.User() - req, err := readJSONWithLimit[apiAccountTokenUpdateRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body! + req, err := readJSONWithLimit[apiAccountTokenUpdateRequest](r.Body, httpBodyBytesLimit, true) // Allow empty body! if err != nil { return err } else if req.Token == "" { @@ -302,7 +302,7 @@ func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, r *http.Request } func (s *Server) handleAccountSettingsChange(w http.ResponseWriter, r *http.Request, v *visitor) error { - newPrefs, err := readJSONWithLimit[user.Prefs](r.Body, jsonBodyBytesLimit, false) + newPrefs, err := readJSONWithLimit[user.Prefs](r.Body, httpBodyBytesLimit, false) if err != nil { return err } @@ -336,7 +336,7 @@ func (s *Server) handleAccountSettingsChange(w http.ResponseWriter, r *http.Requ } func (s *Server) handleAccountSubscriptionAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { - newSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit, false) + newSubscription, err := readJSONWithLimit[user.Subscription](r.Body, httpBodyBytesLimit, false) if err != nil { return err } @@ -359,7 +359,7 @@ func (s *Server) handleAccountSubscriptionAdd(w http.ResponseWriter, r *http.Req } func (s *Server) handleAccountSubscriptionChange(w http.ResponseWriter, r *http.Request, v *visitor) error { - updatedSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit, false) + updatedSubscription, err := readJSONWithLimit[user.Subscription](r.Body, httpBodyBytesLimit, false) if err != nil { return err } @@ -417,7 +417,7 @@ func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http. // it is already reserved by someone else. func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { u := v.User() - req, err := readJSONWithLimit[apiAccountReservationRequest](r.Body, jsonBodyBytesLimit, false) + req, err := readJSONWithLimit[apiAccountReservationRequest](r.Body, httpBodyBytesLimit, false) if err != nil { return err } @@ -532,7 +532,7 @@ func (s *Server) maybeRemoveMessagesAndExcessReservations(r *http.Request, v *vi func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.Request, v *visitor) error { u := v.User() - req, err := readJSONWithLimit[apiAccountPhoneNumberVerifyRequest](r.Body, jsonBodyBytesLimit, false) + req, err := readJSONWithLimit[apiAccountPhoneNumberVerifyRequest](r.Body, httpBodyBytesLimit, false) if err != nil { return err } else if !phoneNumberRegex.MatchString(req.Number) { @@ -563,7 +563,7 @@ func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.R func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { u := v.User() - req, err := readJSONWithLimit[apiAccountPhoneNumberAddRequest](r.Body, jsonBodyBytesLimit, false) + req, err := readJSONWithLimit[apiAccountPhoneNumberAddRequest](r.Body, httpBodyBytesLimit, false) if err != nil { return err } @@ -582,7 +582,7 @@ func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Requ func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { u := v.User() - req, err := readJSONWithLimit[apiAccountPhoneNumberAddRequest](r.Body, jsonBodyBytesLimit, false) + req, err := readJSONWithLimit[apiAccountPhoneNumberAddRequest](r.Body, httpBodyBytesLimit, false) if err != nil { return err } diff --git a/server/server_admin.go b/server/server_admin.go index fc9dfed1b..ec0b69b67 100644 --- a/server/server_admin.go +++ b/server/server_admin.go @@ -1,6 +1,7 @@ package server import ( + "errors" "heckel.io/ntfy/v2/user" "net/http" ) @@ -38,14 +39,14 @@ func (s *Server) handleUsersGet(w http.ResponseWriter, r *http.Request, v *visit } func (s *Server) handleUsersAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { - req, err := readJSONWithLimit[apiUserAddRequest](r.Body, jsonBodyBytesLimit, false) + req, err := readJSONWithLimit[apiUserAddRequest](r.Body, httpBodyBytesLimit, false) if err != nil { return err } else if !user.AllowedUsername(req.Username) || req.Password == "" { return errHTTPBadRequest.Wrap("username invalid, or password missing") } u, err := s.userManager.User(req.Username) - if err != nil && err != user.ErrUserNotFound { + if err != nil && !errors.Is(err, user.ErrUserNotFound) { return err } else if u != nil { return errHTTPConflictUserExists @@ -53,7 +54,7 @@ func (s *Server) handleUsersAdd(w http.ResponseWriter, r *http.Request, v *visit var tier *user.Tier if req.Tier != "" { tier, err = s.userManager.Tier(req.Tier) - if err == user.ErrTierNotFound { + if errors.Is(err, user.ErrTierNotFound) { return errHTTPBadRequestTierInvalid } else if err != nil { return err @@ -71,12 +72,12 @@ func (s *Server) handleUsersAdd(w http.ResponseWriter, r *http.Request, v *visit } func (s *Server) handleUsersDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { - req, err := readJSONWithLimit[apiUserDeleteRequest](r.Body, jsonBodyBytesLimit, false) + req, err := readJSONWithLimit[apiUserDeleteRequest](r.Body, httpBodyBytesLimit, false) if err != nil { return err } u, err := s.userManager.User(req.Username) - if err == user.ErrUserNotFound { + if errors.Is(err, user.ErrUserNotFound) { return errHTTPBadRequestUserNotFound } else if err != nil { return err @@ -93,12 +94,12 @@ func (s *Server) handleUsersDelete(w http.ResponseWriter, r *http.Request, v *vi } func (s *Server) handleAccessAllow(w http.ResponseWriter, r *http.Request, v *visitor) error { - req, err := readJSONWithLimit[apiAccessAllowRequest](r.Body, jsonBodyBytesLimit, false) + req, err := readJSONWithLimit[apiAccessAllowRequest](r.Body, httpBodyBytesLimit, false) if err != nil { return err } _, err = s.userManager.User(req.Username) - if err == user.ErrUserNotFound { + if errors.Is(err, user.ErrUserNotFound) { return errHTTPBadRequestUserNotFound } else if err != nil { return err @@ -114,7 +115,7 @@ func (s *Server) handleAccessAllow(w http.ResponseWriter, r *http.Request, v *vi } func (s *Server) handleAccessReset(w http.ResponseWriter, r *http.Request, v *visitor) error { - req, err := readJSONWithLimit[apiAccessResetRequest](r.Body, jsonBodyBytesLimit, false) + req, err := readJSONWithLimit[apiAccessResetRequest](r.Body, httpBodyBytesLimit, false) if err != nil { return err } diff --git a/server/server_payments.go b/server/server_payments.go index 334301bb3..2fb42d315 100644 --- a/server/server_payments.go +++ b/server/server_payments.go @@ -115,7 +115,7 @@ func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r if u.Billing.StripeSubscriptionID != "" { return errHTTPBadRequestBillingSubscriptionExists } - req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit, false) + req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, httpBodyBytesLimit, false) if err != nil { return err } @@ -245,7 +245,7 @@ func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r if u.Billing.StripeSubscriptionID == "" { return errNoBillingSubscription } - req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit, false) + req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, httpBodyBytesLimit, false) if err != nil { return err } @@ -342,7 +342,7 @@ func (s *Server) handleAccountBillingWebhook(_ http.ResponseWriter, r *http.Requ if stripeSignature == "" { return errHTTPBadRequestBillingRequestInvalid } - body, err := util.Peek(r.Body, jsonBodyBytesLimit) + body, err := util.Peek(r.Body, httpBodyBytesLimit) if err != nil { return err } else if body.LimitReached { diff --git a/server/server_test.go b/server/server_test.go index 99f4c4de3..66c54fd04 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -2669,6 +2669,7 @@ func TestServer_MessageTemplate_JSONBody(t *testing.T) { } func TestServer_MessageTemplate_MalformedJSONBody(t *testing.T) { + t.Parallel() s := newTestServer(t, newTestConfig(t)) body := `{"topic": "mytopic", "message": "{\"foo\":\"bar\",\"nested\":{\"title\":\"here\"INVALID"}` response := request(t, s, "PUT", "/", body, map[string]string{ @@ -2677,13 +2678,12 @@ func TestServer_MessageTemplate_MalformedJSONBody(t *testing.T) { "X-Template": "1", }) - require.Equal(t, 200, response.Code, "Got %s", response) - m := toMessage(t, response.Body.String()) - require.Equal(t, "{\"foo\":\"bar\",\"nested\":{\"title\":\"here\"INVALID", m.Message) - require.Equal(t, "${nested.title}", m.Title) + require.Equal(t, 400, response.Code) + require.Equal(t, 40042, toHTTPError(t, response.Body.String()).Code) } func TestServer_MessageTemplate_PlaceholderTypo(t *testing.T) { + t.Parallel() s := newTestServer(t, newTestConfig(t)) response := request(t, s, "PUT", "/mytopic", `{"foo":"bar", "nested":{"title":"here"}}`, map[string]string{ "X-Message": "${food}", @@ -2756,12 +2756,12 @@ func TestServer_MessageTemplate_FancyGJSON(t *testing.T) { func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageOK(t *testing.T) { c := newTestConfig(t) - c.MessageSizeLimit = 25 // 25 < len(HTTP body) < 25*2 && len(m.Message) < 25 + c.MessageSizeLimit = 25 // 25 < len(HTTP body) < 32k, and len(m.Message) < 25 s := newTestServer(t, c) response := request(t, s, "PUT", "/mytopic", `{"foo":"bar", "nested":{"title":"here"}}`, map[string]string{ "X-Message": "${foo}", "X-Title": "${nested.title}", - "X-Template": "1", + "X-Template": "yes", }) require.Equal(t, 200, response.Code) @@ -2772,7 +2772,7 @@ func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageOK(t *testing. func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageTooLong(t *testing.T) { c := newTestConfig(t) - c.MessageSizeLimit = 21 // 21 < len(HTTP body) < 21*2 && !len(m.Message) < 21 + c.MessageSizeLimit = 21 // 21 < len(HTTP body) < 32k, but !len(m.Message) < 21 s := newTestServer(t, c) response := request(t, s, "PUT", "/mytopic", `{"foo":"This is a long message"}`, map[string]string{ "X-Message": "${foo}", @@ -2783,6 +2783,30 @@ func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageTooLong(t *tes require.Equal(t, 40041, toHTTPError(t, response.Body.String()).Code) } +func TestServer_MessageTemplate_Grafana(t *testing.T) { + c := newTestConfig(t) + s := newTestServer(t, c) + body := `{"receiver":"ntfy\\.example\\.com/alerts","status":"resolved","alerts":[{"status":"resolved","labels":{"alertname":"Load avg 15m too high","grafana_folder":"Node alerts","instance":"10.108.0.2:9100","job":"node-exporter"},"annotations":{"summary":"15m load average too high"},"startsAt":"2024-03-15T02:28:00Z","endsAt":"2024-03-15T02:42:00Z","generatorURL":"localhost:3000/alerting/grafana/NW9oDw-4z/view","fingerprint":"becbfb94bd81ef48","silenceURL":"localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter","dashboardURL":"","panelURL":"","values":{"B":18.98211314475876,"C":0},"valueString":"[ var='B' labels={__name__=node_load15, instance=10.108.0.2:9100, job=node-exporter} value=18.98211314475876 ], [ var='C' labels={__name__=node_load15, instance=10.108.0.2:9100, job=node-exporter} value=0 ]"}],"groupLabels":{"alertname":"Load avg 15m too high","grafana_folder":"Node alerts"},"commonLabels":{"alertname":"Load avg 15m too high","grafana_folder":"Node alerts","instance":"10.108.0.2:9100","job":"node-exporter"},"commonAnnotations":{"summary":"15m load average too high"},"externalURL":"localhost:3000/","version":"1","groupKey":"{}:{alertname=\"Load avg 15m too high\", grafana_folder=\"Node alerts\"}","truncatedAlerts":0,"orgId":1,"title":"[RESOLVED] Load avg 15m too high Node alerts (10.108.0.2:9100 node-exporter)","state":"ok","message":"**Resolved**\n\nValue: B=18.98211314475876, C=0\nLabels:\n - alertname = Load avg 15m too high\n - grafana_folder = Node alerts\n - instance = 10.108.0.2:9100\n - job = node-exporter\nAnnotations:\n - summary = 15m load average too high\nSource: localhost:3000/alerting/grafana/NW9oDw-4z/view\nSilence: localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter\n"}` + response := request(t, s, "PUT", "/mytopic?tpl=yes&title=Grafana+alert:+${title}&message=${message}", body, nil) + + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "Grafana alert: [RESOLVED] Load avg 15m too high Node alerts (10.108.0.2:9100 node-exporter)", m.Title) + require.Equal(t, `**Resolved** + +Value: B=18.98211314475876, C=0 +Labels: + - alertname = Load avg 15m too high + - grafana_folder = Node alerts + - instance = 10.108.0.2:9100 + - job = node-exporter +Annotations: + - summary = 15m load average too high +Source: localhost:3000/alerting/grafana/NW9oDw-4z/view +Silence: localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter +`, m.Message) +} + func newTestConfig(t *testing.T) *Config { conf := NewConfig() conf.BaseURL = "http://127.0.0.1:12345" diff --git a/server/server_webpush.go b/server/server_webpush.go index cd41759db..cf4929a9c 100644 --- a/server/server_webpush.go +++ b/server/server_webpush.go @@ -38,7 +38,7 @@ func init() { } func (s *Server) handleWebPushUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error { - req, err := readJSONWithLimit[apiWebPushUpdateSubscriptionRequest](r.Body, jsonBodyBytesLimit, false) + req, err := readJSONWithLimit[apiWebPushUpdateSubscriptionRequest](r.Body, httpBodyBytesLimit, false) if err != nil || req.Endpoint == "" || req.P256dh == "" || req.Auth == "" { return errHTTPBadRequestWebPushSubscriptionInvalid } else if !webPushAllowedEndpointsRegex.MatchString(req.Endpoint) { @@ -66,7 +66,7 @@ func (s *Server) handleWebPushUpdate(w http.ResponseWriter, r *http.Request, v * } func (s *Server) handleWebPushDelete(w http.ResponseWriter, r *http.Request, _ *visitor) error { - req, err := readJSONWithLimit[apiWebPushUpdateSubscriptionRequest](r.Body, jsonBodyBytesLimit, false) + req, err := readJSONWithLimit[apiWebPushUpdateSubscriptionRequest](r.Body, httpBodyBytesLimit, false) if err != nil || req.Endpoint == "" { return errHTTPBadRequestWebPushSubscriptionInvalid } diff --git a/server/util.go b/server/util.go index fe5b3ea33..bcfe30375 100644 --- a/server/util.go +++ b/server/util.go @@ -2,6 +2,7 @@ package server import ( "context" + "errors" "fmt" "heckel.io/ntfy/v2/util" "io" @@ -104,9 +105,9 @@ func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr { func readJSONWithLimit[T any](r io.ReadCloser, limit int, allowEmpty bool) (*T, error) { obj, err := util.UnmarshalJSONWithLimit[T](r, limit, allowEmpty) - if err == util.ErrUnmarshalJSON { + if errors.Is(err, util.ErrUnmarshalJSON) { return nil, errHTTPBadRequestJSONInvalid - } else if err == util.ErrTooLargeJSON { + } else if errors.Is(err, util.ErrTooLargeJSON) { return nil, errHTTPEntityTooLargeJSONBody } else if err != nil { return nil, err diff --git a/test/server.go b/test/server.go index 9d75a2c7b..5398cf9e3 100644 --- a/test/server.go +++ b/test/server.go @@ -16,7 +16,7 @@ func StartServer(t *testing.T) (*server.Server, int) { // StartServerWithConfig starts a server.Server with a random port and waits for the server to be up func StartServerWithConfig(t *testing.T, conf *server.Config) (*server.Server, int) { - port := 10000 + rand.Intn(20000) + port := 10000 + rand.Intn(30000) conf.ListenHTTP = fmt.Sprintf(":%d", port) conf.AttachmentCacheDir = t.TempDir() conf.CacheFile = filepath.Join(t.TempDir(), "cache.db") diff --git a/util/peek.go b/util/peek.go index 40150cbcb..03d2e20a2 100644 --- a/util/peek.go +++ b/util/peek.go @@ -2,6 +2,7 @@ package util import ( "bytes" + "errors" "io" "strings" ) @@ -26,7 +27,7 @@ func Peek(underlying io.ReadCloser, limit int) (*PeekedReadCloser, error) { } peeked := make([]byte, limit) read, err := io.ReadFull(underlying, peeked) - if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { + if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && err != io.EOF { return nil, err } return &PeekedReadCloser{ @@ -44,7 +45,7 @@ func (r *PeekedReadCloser) Read(p []byte) (n int, err error) { return 0, io.EOF } n, err = r.peeked.Read(p) - if err == io.EOF { + if errors.Is(err, io.EOF) { return r.underlying.Read(p) } else if err != nil { return 0, err From 9247dac50db7d5a4617079755fde479b50907f80 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 20 Mar 2024 21:39:17 -0400 Subject: [PATCH 8/8] Move things, revert naming --- server/server.go | 26 +++++++++++++------------- server/server_account.go | 24 ++++++++++++------------ server/server_admin.go | 8 ++++---- server/server_payments.go | 6 +++--- server/server_webpush.go | 4 ++-- 5 files changed, 34 insertions(+), 34 deletions(-) diff --git a/server/server.go b/server/server.go index 1c1950d10..975e2dac0 100644 --- a/server/server.go +++ b/server/server.go @@ -131,7 +131,7 @@ const ( newMessageBody = "New message" // Used in poll requests as generic message defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment encodingBase64 = "base64" // Used mainly for binary UnifiedPush messages - httpBodyBytesLimit = 32768 // Max number of bytes for a request bodys (unless MessageLimit is higher) + jsonBodyBytesLimit = 32768 // Max number of bytes for a request bodys (unless MessageLimit is higher) unifiedPushTopicPrefix = "up" // Temporarily, we rate limit all "up*" topics based on the subscriber unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part messagesHistoryMax = 10 // Number of message count values to keep in memory @@ -1047,7 +1047,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi // 6. curl -T file.txt ntfy.sh/mytopic // If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message // 7. curl -T file.txt ntfy.sh/mytopic -// If file.txt is > message limit or template && file.txt > message limit*2, treat it as an attachment +// In all other cases, mostly if file.txt is > message limit, treat it as an attachment func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, template, unifiedpush bool) error { if m.Event == pollRequestEvent { // Case 1 return s.handleBodyDiscard(body) @@ -1095,7 +1095,7 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser } func (s *Server) handleBodyAsTemplatedTextMessage(m *message, body *util.PeekedReadCloser) error { - body, err := util.Peek(body, httpBodyBytesLimit) + body, err := util.Peek(body, jsonBodyBytesLimit) if err != nil { return err } else if body.LimitReached { @@ -1113,6 +1113,16 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *message, body *util.PeekedR return nil } +func replaceGJSONTemplate(template string, source string) string { + matches := templateVarRegex.FindAllStringSubmatch(template, -1) + for _, m := range matches { + if result := gjson.Get(source, m[1]); result.Exists() { + template = strings.ReplaceAll(template, fmt.Sprintf(templateVarFormat, m[1]), result.String()) + } + } + return template +} + func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) error { if s.fileCache == nil || s.config.BaseURL == "" || s.config.AttachmentCacheDir == "" { return errHTTPBadRequestAttachmentsDisallowed.With(m) @@ -1163,16 +1173,6 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, return nil } -func replaceGJSONTemplate(template string, source string) string { - matches := templateVarRegex.FindAllStringSubmatch(template, -1) - for _, m := range matches { - if result := gjson.Get(source, m[1]); result.Exists() { - template = strings.ReplaceAll(template, fmt.Sprintf(templateVarFormat, m[1]), result.String()) - } - } - return template -} - func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *visitor) error { encoder := func(msg *message) (string, error) { var buf bytes.Buffer diff --git a/server/server_account.go b/server/server_account.go index e457464d6..cb841d077 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -28,7 +28,7 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v * return errHTTPTooManyRequestsLimitAccountCreation } } - newAccount, err := readJSONWithLimit[apiAccountCreateRequest](r.Body, httpBodyBytesLimit, false) + newAccount, err := readJSONWithLimit[apiAccountCreateRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } @@ -160,7 +160,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis } func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { - req, err := readJSONWithLimit[apiAccountDeleteRequest](r.Body, httpBodyBytesLimit, false) + req, err := readJSONWithLimit[apiAccountDeleteRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } else if req.Password == "" { @@ -192,7 +192,7 @@ func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v * } func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Request, v *visitor) error { - req, err := readJSONWithLimit[apiAccountPasswordChangeRequest](r.Body, httpBodyBytesLimit, false) + req, err := readJSONWithLimit[apiAccountPasswordChangeRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } else if req.Password == "" || req.NewPassword == "" { @@ -210,7 +210,7 @@ func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Requ } func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { - req, err := readJSONWithLimit[apiAccountTokenIssueRequest](r.Body, httpBodyBytesLimit, true) // Allow empty body! + req, err := readJSONWithLimit[apiAccountTokenIssueRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body! if err != nil { return err } @@ -246,7 +246,7 @@ func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request func (s *Server) handleAccountTokenUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error { u := v.User() - req, err := readJSONWithLimit[apiAccountTokenUpdateRequest](r.Body, httpBodyBytesLimit, true) // Allow empty body! + req, err := readJSONWithLimit[apiAccountTokenUpdateRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body! if err != nil { return err } else if req.Token == "" { @@ -302,7 +302,7 @@ func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, r *http.Request } func (s *Server) handleAccountSettingsChange(w http.ResponseWriter, r *http.Request, v *visitor) error { - newPrefs, err := readJSONWithLimit[user.Prefs](r.Body, httpBodyBytesLimit, false) + newPrefs, err := readJSONWithLimit[user.Prefs](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } @@ -336,7 +336,7 @@ func (s *Server) handleAccountSettingsChange(w http.ResponseWriter, r *http.Requ } func (s *Server) handleAccountSubscriptionAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { - newSubscription, err := readJSONWithLimit[user.Subscription](r.Body, httpBodyBytesLimit, false) + newSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } @@ -359,7 +359,7 @@ func (s *Server) handleAccountSubscriptionAdd(w http.ResponseWriter, r *http.Req } func (s *Server) handleAccountSubscriptionChange(w http.ResponseWriter, r *http.Request, v *visitor) error { - updatedSubscription, err := readJSONWithLimit[user.Subscription](r.Body, httpBodyBytesLimit, false) + updatedSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } @@ -417,7 +417,7 @@ func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http. // it is already reserved by someone else. func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { u := v.User() - req, err := readJSONWithLimit[apiAccountReservationRequest](r.Body, httpBodyBytesLimit, false) + req, err := readJSONWithLimit[apiAccountReservationRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } @@ -532,7 +532,7 @@ func (s *Server) maybeRemoveMessagesAndExcessReservations(r *http.Request, v *vi func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.Request, v *visitor) error { u := v.User() - req, err := readJSONWithLimit[apiAccountPhoneNumberVerifyRequest](r.Body, httpBodyBytesLimit, false) + req, err := readJSONWithLimit[apiAccountPhoneNumberVerifyRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } else if !phoneNumberRegex.MatchString(req.Number) { @@ -563,7 +563,7 @@ func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.R func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { u := v.User() - req, err := readJSONWithLimit[apiAccountPhoneNumberAddRequest](r.Body, httpBodyBytesLimit, false) + req, err := readJSONWithLimit[apiAccountPhoneNumberAddRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } @@ -582,7 +582,7 @@ func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Requ func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { u := v.User() - req, err := readJSONWithLimit[apiAccountPhoneNumberAddRequest](r.Body, httpBodyBytesLimit, false) + req, err := readJSONWithLimit[apiAccountPhoneNumberAddRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } diff --git a/server/server_admin.go b/server/server_admin.go index ec0b69b67..ac2957188 100644 --- a/server/server_admin.go +++ b/server/server_admin.go @@ -39,7 +39,7 @@ func (s *Server) handleUsersGet(w http.ResponseWriter, r *http.Request, v *visit } func (s *Server) handleUsersAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { - req, err := readJSONWithLimit[apiUserAddRequest](r.Body, httpBodyBytesLimit, false) + req, err := readJSONWithLimit[apiUserAddRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } else if !user.AllowedUsername(req.Username) || req.Password == "" { @@ -72,7 +72,7 @@ func (s *Server) handleUsersAdd(w http.ResponseWriter, r *http.Request, v *visit } func (s *Server) handleUsersDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { - req, err := readJSONWithLimit[apiUserDeleteRequest](r.Body, httpBodyBytesLimit, false) + req, err := readJSONWithLimit[apiUserDeleteRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } @@ -94,7 +94,7 @@ func (s *Server) handleUsersDelete(w http.ResponseWriter, r *http.Request, v *vi } func (s *Server) handleAccessAllow(w http.ResponseWriter, r *http.Request, v *visitor) error { - req, err := readJSONWithLimit[apiAccessAllowRequest](r.Body, httpBodyBytesLimit, false) + req, err := readJSONWithLimit[apiAccessAllowRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } @@ -115,7 +115,7 @@ func (s *Server) handleAccessAllow(w http.ResponseWriter, r *http.Request, v *vi } func (s *Server) handleAccessReset(w http.ResponseWriter, r *http.Request, v *visitor) error { - req, err := readJSONWithLimit[apiAccessResetRequest](r.Body, httpBodyBytesLimit, false) + req, err := readJSONWithLimit[apiAccessResetRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } diff --git a/server/server_payments.go b/server/server_payments.go index 2fb42d315..334301bb3 100644 --- a/server/server_payments.go +++ b/server/server_payments.go @@ -115,7 +115,7 @@ func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r if u.Billing.StripeSubscriptionID != "" { return errHTTPBadRequestBillingSubscriptionExists } - req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, httpBodyBytesLimit, false) + req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } @@ -245,7 +245,7 @@ func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r if u.Billing.StripeSubscriptionID == "" { return errNoBillingSubscription } - req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, httpBodyBytesLimit, false) + req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } @@ -342,7 +342,7 @@ func (s *Server) handleAccountBillingWebhook(_ http.ResponseWriter, r *http.Requ if stripeSignature == "" { return errHTTPBadRequestBillingRequestInvalid } - body, err := util.Peek(r.Body, httpBodyBytesLimit) + body, err := util.Peek(r.Body, jsonBodyBytesLimit) if err != nil { return err } else if body.LimitReached { diff --git a/server/server_webpush.go b/server/server_webpush.go index cf4929a9c..cd41759db 100644 --- a/server/server_webpush.go +++ b/server/server_webpush.go @@ -38,7 +38,7 @@ func init() { } func (s *Server) handleWebPushUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error { - req, err := readJSONWithLimit[apiWebPushUpdateSubscriptionRequest](r.Body, httpBodyBytesLimit, false) + req, err := readJSONWithLimit[apiWebPushUpdateSubscriptionRequest](r.Body, jsonBodyBytesLimit, false) if err != nil || req.Endpoint == "" || req.P256dh == "" || req.Auth == "" { return errHTTPBadRequestWebPushSubscriptionInvalid } else if !webPushAllowedEndpointsRegex.MatchString(req.Endpoint) { @@ -66,7 +66,7 @@ func (s *Server) handleWebPushUpdate(w http.ResponseWriter, r *http.Request, v * } func (s *Server) handleWebPushDelete(w http.ResponseWriter, r *http.Request, _ *visitor) error { - req, err := readJSONWithLimit[apiWebPushUpdateSubscriptionRequest](r.Body, httpBodyBytesLimit, false) + req, err := readJSONWithLimit[apiWebPushUpdateSubscriptionRequest](r.Body, jsonBodyBytesLimit, false) if err != nil || req.Endpoint == "" { return errHTTPBadRequestWebPushSubscriptionInvalid }