From df7d6baec59e17b3ac07b2846d067bc17fb94133 Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Sun, 17 Mar 2024 21:55:50 -0600 Subject: [PATCH] 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 5239bbc6..cd67ed69 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 9bdfef34..c82560ac 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 1a5ecf76..a63f2ab8 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 bdd68ab7..47c8b8c5 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 f6e39be3..5af493e6 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 8d965153..4f634360 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"