Support for templating the priority header

This commit is contained in:
binwiederhier
2026-02-04 09:46:09 -08:00
parent b34d23870b
commit 570b188a88
5 changed files with 194 additions and 50 deletions

View File

@@ -2643,7 +2643,7 @@ You can enable templating by setting the `X-Template` header (or its aliases `Te
will use a custom template file from the template directory (defaults to `/etc/ntfy/templates`, can be overridden with `template-dir`). will use a custom template file from the template directory (defaults to `/etc/ntfy/templates`, can be overridden with `template-dir`).
See [custom templates](#custom-templates) for more details. See [custom templates](#custom-templates) for more details.
* **Inline templating**: Setting the `X-Template` header or query parameter to `yes` or `1` (e.g. `?template=yes`) * **Inline templating**: Setting the `X-Template` header or query parameter to `yes` or `1` (e.g. `?template=yes`)
will enable inline templating, which means that the `message` and/or `title` will be parsed as a Go template. will enable inline templating, which means that the `message`, `title`, and/or `priority` will be parsed as a Go template.
See [inline templating](#inline-templating) for more details. See [inline templating](#inline-templating) for more details.
To learn the basics of Go's templating language, please see [template syntax](#template-syntax). To learn the basics of Go's templating language, please see [template syntax](#template-syntax).
@@ -2686,7 +2686,7 @@ and set the `X-Template` header or query parameter to the name of the template f
For example, if you have a template file `/etc/ntfy/templates/myapp.yml`, you can set the header `X-Template: myapp` or For example, if you have a template file `/etc/ntfy/templates/myapp.yml`, you can set the header `X-Template: myapp` or
the query parameter `?template=myapp` to use it. the query parameter `?template=myapp` to use it.
Template files must have the `.yml` (not: `.yaml`!) extension and must be formatted as YAML. They may contain `title` and `message` keys, Template files must have the `.yml` (not: `.yaml`!) extension and must be formatted as YAML. They may contain `title`, `message`, and `priority` keys,
which are interpreted as Go templates. which are interpreted as Go templates.
Here's an **example custom template**: Here's an **example custom template**:
@@ -2704,6 +2704,11 @@ Here's an **example custom template**:
Status: {{ .status }} Status: {{ .status }}
Type: {{ .type | upper }} ({{ .percent }}%) Type: {{ .type | upper }} ({{ .percent }}%)
Server: {{ .server }} Server: {{ .server }}
priority: |
{{ if gt .percent 90.0 }}5
{{ else if gt .percent 75.0 }}4
{{ else }}3
{{ end }}
``` ```
Once you have the template file in place, you can send the payload to your topic using the `X-Template` Once you have the template file in place, you can send the payload to your topic using the `X-Template`
@@ -2785,7 +2790,7 @@ Which will result in a notification that looks like this:
### Inline templating ### Inline templating
When `X-Template: yes` (aliases: `Template: yes`, `Tpl: yes`) or `?template=yes` is set, you can use Go templates in the `message` and `title` fields of your When `X-Template: yes` (aliases: `Template: yes`, `Tpl: yes`) or `?template=yes` is set, you can use Go templates in the `message`, `title`, and `priority` fields of your
webhook payload. webhook payload.
Inline templates are most useful for templated one-off messages, or if you do not control the ntfy server (e.g., if you're using ntfy.sh). Inline templates are most useful for templated one-off messages, or if you do not control the ntfy server (e.g., if you're using ntfy.sh).
@@ -2841,12 +2846,12 @@ Here's an **easier example with a shorter JSON payload**:
curl \ curl \
--globoff \ --globoff \
-d '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}' \ -d '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}' \
'ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}' 'ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}&p={{if+eq+.error.level+"severe"}}5{{else}}3{{end}}'
``` ```
=== "HTTP" === "HTTP"
``` http ``` http
POST /mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}} HTTP/1.1 POST /mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}&p={{if+eq+.error.level+"severe"}}5{{else}}3{{end}} HTTP/1.1
Host: ntfy.sh Host: ntfy.sh
{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}} {"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}
@@ -2854,7 +2859,7 @@ Here's an **easier example with a shorter JSON payload**:
=== "JavaScript" === "JavaScript"
``` javascript ``` javascript
fetch('https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}', { fetch('https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}&p={{if+eq+.error.level+"severe"}}5{{else}}3{{end}}', {
method: 'POST', method: 'POST',
body: '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}' body: '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}'
}) })
@@ -2863,7 +2868,7 @@ Here's an **easier example with a shorter JSON payload**:
=== "Go" === "Go"
``` go ``` go
body := `{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}` body := `{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}`
uri := "https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}" uri := `https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}&p={{if eq .error.level "severe"}}5{{else}}3{{end}}`
req, _ := http.NewRequest("POST", uri, strings.NewReader(body)) req, _ := http.NewRequest("POST", uri, strings.NewReader(body))
http.DefaultClient.Do(req) http.DefaultClient.Do(req)
``` ```
@@ -2873,7 +2878,7 @@ Here's an **easier example with a shorter JSON payload**:
``` powershell ``` powershell
$Request = @{ $Request = @{
Method = "POST" Method = "POST"
URI = "https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}" URI = 'https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}&p={{if+eq+.error.level+"severe"}}5{{else}}3{{end}}'
Body = '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}' Body = '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}'
ContentType = "application/json" ContentType = "application/json"
} }
@@ -2883,14 +2888,14 @@ Here's an **easier example with a shorter JSON payload**:
=== "Python" === "Python"
``` python ``` python
requests.post( requests.post(
"https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}", 'https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}&p={{if+eq+.error.level+"severe"}}5{{else}}3{{end}}',
data='{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}' data='{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}'
) )
``` ```
=== "PHP" === "PHP"
``` php-inline ``` php-inline
file_get_contents("https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}", false, stream_context_create([ file_get_contents('https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}&p={{if+eq+.error.level+"severe"}}5{{else}}3{{end}}', false, stream_context_create([
'http' => [ 'http' => [
'method' => 'POST', 'method' => 'POST',
'header' => "Content-Type: application/json", 'header' => "Content-Type: application/json",
@@ -2899,9 +2904,9 @@ Here's an **easier example with a shorter JSON payload**:
])); ]));
``` ```
This example uses the `message`/`m` and `title`/`t` query parameters, but obviously this also works with the corresponding This example uses the `message`/`m`, `title`/`t`, and `priority`/`p` query parameters, but obviously this also works with the
`Message`/`Title` headers. It will send a notification with a title `phil-pc: A severe error has occurred` and a message corresponding headers. It will send a notification with a title `phil-pc: A severe error has occurred`, a message
`Error message: Disk has run out of space`. `Error message: Disk has run out of space`, and priority `5` (max) if the level is "severe", or `3` (default) otherwise.
### Template syntax ### Template syntax
ntfy uses [Go templates](https://pkg.go.dev/text/template) for its templates, which is arguably one of the most powerful, ntfy uses [Go templates](https://pkg.go.dev/text/template) for its templates, which is arguably one of the most powerful,
@@ -2920,7 +2925,7 @@ your templates there first ([example for Grafana alert](https://repeatit.io/#/sh
ntfy supports a subset of the **[Sprig template functions](publish/template-functions.md)** (originally copied from [Sprig](https://github.com/Masterminds/sprig), ntfy supports a subset of the **[Sprig template functions](publish/template-functions.md)** (originally copied from [Sprig](https://github.com/Masterminds/sprig),
thank you to the Sprig developers 🙏). This is useful for advanced message templating and for transforming the data provided through the JSON payload. thank you to the Sprig developers 🙏). This is useful for advanced message templating and for transforming the data provided through the JSON payload.
Below are the functions that are available to use inside your message/title templates. Below are the functions that are available to use inside your message, title, and priority templates.
* [String Functions](publish/template-functions.md#string-functions): `trim`, `trunc`, `substr`, `plural`, etc. * [String Functions](publish/template-functions.md#string-functions): `trim`, `trunc`, `substr`, `plural`, etc.
* [String List Functions](publish/template-functions.md#string-list-functions): `splitList`, `sortAlpha`, etc. * [String List Functions](publish/template-functions.md#string-list-functions): `splitList`, `sortAlpha`, etc.

View File

@@ -1681,6 +1681,10 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
### ntfy server v2.17.x (UNRELEASED) ### ntfy server v2.17.x (UNRELEASED)
**Features:**
* Support templating in the priority field ([#1426](https://github.com/binwiederhier/ntfy/issues/1426), thanks to [@seantomburke](https://github.com/seantomburke) for reporting)
**Bug fixes + maintenance:** **Bug fixes + maintenance:**
* Fix crash when commit string is shorter than 7 characters in non-GitHub-Action builds ([#1493](https://github.com/binwiederhier/ntfy/issues/1493), thanks to [@cyrinux](https://github.com/cyrinux) for reporting) * Fix crash when commit string is shorter than 7 characters in non-GitHub-Action builds ([#1493](https://github.com/binwiederhier/ntfy/issues/1493), thanks to [@cyrinux](https://github.com/cyrinux) for reporting)

View File

@@ -793,7 +793,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
return nil, err return nil, err
} }
m := newDefaultMessage(t.ID, "") m := newDefaultMessage(t.ID, "")
cache, firebase, email, call, template, unifiedpush, e := s.parsePublishParams(r, m) cache, firebase, email, call, template, unifiedpush, priorityStr, e := s.parsePublishParams(r, m)
if e != nil { if e != nil {
return nil, e.With(t) return nil, e.With(t)
} }
@@ -824,7 +824,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
if cache { if cache {
m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix() m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix()
} }
if err := s.handlePublishBody(r, v, m, body, template, unifiedpush); err != nil { if err := s.handlePublishBody(r, v, m, body, template, unifiedpush, priorityStr); err != nil {
return nil, err return nil, err
} }
if m.Message == "" { if m.Message == "" {
@@ -1055,11 +1055,11 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
} }
} }
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template templateMode, unifiedpush bool, err *errHTTP) { func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template templateMode, unifiedpush bool, priorityStr string, err *errHTTP) {
if r.Method != http.MethodGet && updatePathRegex.MatchString(r.URL.Path) { if r.Method != http.MethodGet && updatePathRegex.MatchString(r.URL.Path) {
pathSequenceID, err := s.sequenceIDFromPath(r.URL.Path) pathSequenceID, err := s.sequenceIDFromPath(r.URL.Path)
if err != nil { if err != nil {
return false, false, "", "", "", false, err return false, false, "", "", "", false, "", err
} }
m.SequenceID = pathSequenceID m.SequenceID = pathSequenceID
} else { } else {
@@ -1068,7 +1068,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
if sequenceIDRegex.MatchString(sequenceID) { if sequenceIDRegex.MatchString(sequenceID) {
m.SequenceID = sequenceID m.SequenceID = sequenceID
} else { } else {
return false, false, "", "", "", false, errHTTPBadRequestSequenceIDInvalid return false, false, "", "", "", false, "", errHTTPBadRequestSequenceIDInvalid
} }
} else { } else {
m.SequenceID = m.ID m.SequenceID = m.ID
@@ -1089,7 +1089,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
} }
if attach != "" { if attach != "" {
if !urlRegex.MatchString(attach) { if !urlRegex.MatchString(attach) {
return false, false, "", "", "", false, errHTTPBadRequestAttachmentURLInvalid return false, false, "", "", "", false, "", errHTTPBadRequestAttachmentURLInvalid
} }
m.Attachment.URL = attach m.Attachment.URL = attach
if m.Attachment.Name == "" { if m.Attachment.Name == "" {
@@ -1107,19 +1107,19 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
} }
if icon != "" { if icon != "" {
if !urlRegex.MatchString(icon) { if !urlRegex.MatchString(icon) {
return false, false, "", "", "", false, errHTTPBadRequestIconURLInvalid return false, false, "", "", "", false, "", errHTTPBadRequestIconURLInvalid
} }
m.Icon = icon m.Icon = icon
} }
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e") email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
if s.smtpSender == nil && email != "" { if s.smtpSender == nil && email != "" {
return false, false, "", "", "", false, errHTTPBadRequestEmailDisabled return false, false, "", "", "", false, "", errHTTPBadRequestEmailDisabled
} }
call = readParam(r, "x-call", "call") call = readParam(r, "x-call", "call")
if call != "" && (s.config.TwilioAccount == "" || s.userManager == nil) { if call != "" && (s.config.TwilioAccount == "" || s.userManager == nil) {
return false, false, "", "", "", false, errHTTPBadRequestPhoneCallsDisabled return false, false, "", "", "", false, "", errHTTPBadRequestPhoneCallsDisabled
} else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) { } else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) {
return false, false, "", "", "", false, errHTTPBadRequestPhoneNumberInvalid return false, false, "", "", "", false, "", errHTTPBadRequestPhoneNumberInvalid
} }
template = templateMode(readParam(r, "x-template", "template", "tpl")) template = templateMode(readParam(r, "x-template", "template", "tpl"))
messageStr := readParam(r, "x-message", "message", "m") messageStr := readParam(r, "x-message", "message", "m")
@@ -1131,29 +1131,33 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
m.Message = messageStr m.Message = messageStr
} }
var e error var e error
m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p")) priorityStr = readParam(r, "x-priority", "priority", "prio", "p")
if e != nil { if !template.Enabled() {
return false, false, "", "", "", false, errHTTPBadRequestPriorityInvalid m.Priority, e = util.ParsePriority(priorityStr)
if e != nil {
return false, false, "", "", "", false, "", errHTTPBadRequestPriorityInvalid
}
priorityStr = "" // Clear since it's already parsed
} }
m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta") m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta")
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in") delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
if delayStr != "" { if delayStr != "" {
if !cache { if !cache {
return false, false, "", "", "", false, errHTTPBadRequestDelayNoCache return false, false, "", "", "", false, "", errHTTPBadRequestDelayNoCache
} }
if email != "" { 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 != "" { 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()) delay, err := util.ParseFutureTime(delayStr, time.Now())
if err != nil { if err != nil {
return false, false, "", "", "", false, errHTTPBadRequestDelayCannotParse return false, false, "", "", "", false, "", errHTTPBadRequestDelayCannotParse
} else if delay.Unix() < time.Now().Add(s.config.MessageDelayMin).Unix() { } 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() { } 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() m.Time = delay.Unix()
} }
@@ -1161,7 +1165,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
if actionsStr != "" { if actionsStr != "" {
m.Actions, e = parseActions(actionsStr) m.Actions, e = parseActions(actionsStr)
if e != nil { if e != nil {
return false, false, "", "", "", false, errHTTPBadRequestActionsInvalid.Wrap("%s", e.Error()) return false, false, "", "", "", false, "", errHTTPBadRequestActionsInvalid.Wrap("%s", e.Error())
} }
} }
contentType, markdown := readParam(r, "content-type", "content_type"), readBoolParam(r, false, "x-markdown", "markdown", "md") contentType, markdown := readParam(r, "content-type", "content_type"), readBoolParam(r, false, "x-markdown", "markdown", "md")
@@ -1180,7 +1184,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
cache = false cache = false
email = "" email = ""
} }
return cache, firebase, email, call, template, unifiedpush, nil return cache, firebase, email, call, template, unifiedpush, priorityStr, nil
} }
// handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message. // handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message.
@@ -1199,7 +1203,7 @@ 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 // If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
// 7. curl -T file.txt ntfy.sh/mytopic // 7. curl -T file.txt ntfy.sh/mytopic
// In all other cases, mostly if file.txt is > message limit, 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 templateMode, unifiedpush bool) error { func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, template templateMode, unifiedpush bool, priorityStr string) error {
if m.Event == pollRequestEvent { // Case 1 if m.Event == pollRequestEvent { // Case 1
return s.handleBodyDiscard(body) return s.handleBodyDiscard(body)
} else if unifiedpush { } else if unifiedpush {
@@ -1209,7 +1213,7 @@ func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body
} else if m.Attachment != nil && m.Attachment.Name != "" { } else if m.Attachment != nil && m.Attachment.Name != "" {
return s.handleBodyAsAttachment(r, v, m, body) // Case 4 return s.handleBodyAsAttachment(r, v, m, body) // Case 4
} else if template.Enabled() { } else if template.Enabled() {
return s.handleBodyAsTemplatedTextMessage(m, template, body) // Case 5 return s.handleBodyAsTemplatedTextMessage(m, template, body, priorityStr) // Case 5
} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) { } else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
return s.handleBodyAsTextMessage(m, body) // Case 6 return s.handleBodyAsTextMessage(m, body) // Case 6
} }
@@ -1245,7 +1249,7 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser
return nil return nil
} }
func (s *Server) handleBodyAsTemplatedTextMessage(m *message, template templateMode, body *util.PeekedReadCloser) error { func (s *Server) handleBodyAsTemplatedTextMessage(m *message, template templateMode, body *util.PeekedReadCloser, priorityStr string) error {
body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit)) body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit))
if err != nil { if err != nil {
return err return err
@@ -1258,7 +1262,7 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *message, template templateM
return err return err
} }
} else { } else {
if err := s.renderTemplateFromParams(m, peekedBody); err != nil { if err := s.renderTemplateFromParams(m, peekedBody, priorityStr); err != nil {
return err return err
} }
} }
@@ -1289,33 +1293,51 @@ func (s *Server) renderTemplateFromFile(m *message, templateName, peekedBody str
} }
var err error var err error
if tpl.Message != nil { if tpl.Message != nil {
if m.Message, err = s.renderTemplate(*tpl.Message, peekedBody); err != nil { if m.Message, err = s.renderTemplate(templateName+" (message)", *tpl.Message, peekedBody); err != nil {
return err return err
} }
} }
if tpl.Title != nil { if tpl.Title != nil {
if m.Title, err = s.renderTemplate(*tpl.Title, peekedBody); err != nil { if m.Title, err = s.renderTemplate(templateName+" (title)", *tpl.Title, peekedBody); err != nil {
return err return err
} }
} }
if tpl.Priority != nil {
renderedPriority, err := s.renderTemplate(templateName+" (priority)", *tpl.Priority, peekedBody)
if err != nil {
return err
}
if m.Priority, err = util.ParsePriority(renderedPriority); err != nil {
return errHTTPBadRequestPriorityInvalid
}
}
return nil return nil
} }
// renderTemplateFromParams transforms the JSON message body according to the inline template in the // renderTemplateFromParams transforms the JSON message body according to the inline template in the
// message and title parameters. // message, title, and priority parameters.
func (s *Server) renderTemplateFromParams(m *message, peekedBody string) error { func (s *Server) renderTemplateFromParams(m *message, peekedBody string, priorityStr string) error {
var err error var err error
if m.Message, err = s.renderTemplate(m.Message, peekedBody); err != nil { if m.Message, err = s.renderTemplate("priority query parameter", m.Message, peekedBody); err != nil {
return err return err
} }
if m.Title, err = s.renderTemplate(m.Title, peekedBody); err != nil { if m.Title, err = s.renderTemplate("title query parameter", m.Title, peekedBody); err != nil {
return err return err
} }
if priorityStr != "" {
renderedPriority, err := s.renderTemplate("priority query parameter", priorityStr, peekedBody)
if err != nil {
return err
}
if m.Priority, err = util.ParsePriority(renderedPriority); err != nil {
return errHTTPBadRequestPriorityInvalid
}
}
return nil return nil
} }
// renderTemplate renders a template with the given JSON source data. // renderTemplate renders a template with the given JSON source data.
func (s *Server) renderTemplate(tpl string, source string) (string, error) { func (s *Server) renderTemplate(name, tpl, source string) (string, error) {
if templateDisallowedRegex.MatchString(tpl) { if templateDisallowedRegex.MatchString(tpl) {
return "", errHTTPBadRequestTemplateDisallowedFunctionCalls return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
} }
@@ -1330,7 +1352,7 @@ func (s *Server) renderTemplate(tpl string, source string) (string, error) {
var buf bytes.Buffer var buf bytes.Buffer
limitWriter := util.NewLimitWriter(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), util.NewFixedLimiter(templateMaxOutputBytes)) limitWriter := util.NewLimitWriter(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), util.NewFixedLimiter(templateMaxOutputBytes))
if err := t.Execute(limitWriter, data); err != nil { if err := t.Execute(limitWriter, data); err != nil {
return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("%s", err.Error()) return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("template %s: %s", name, err.Error())
} }
return strings.TrimSpace(strings.ReplaceAll(buf.String(), "\\n", "\n")), nil // replace any remaining "\n" (those outside of template curly braces) with newlines return strings.TrimSpace(strings.ReplaceAll(buf.String(), "\\n", "\n")), nil // replace any remaining "\n" (those outside of template curly braces) with newlines
} }

View File

@@ -3290,6 +3290,117 @@ func TestServer_MessageTemplate_Until100_000(t *testing.T) {
require.Contains(t, toHTTPError(t, response.Body.String()).Message, "too many iterations") require.Contains(t, toHTTPError(t, response.Body.String()).Message, "too many iterations")
} }
func TestServer_MessageTemplate_Priority(t *testing.T) {
t.Parallel()
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "PUT", "/mytopic", `{"priority":"5"}`, map[string]string{
"X-Message": "Test message",
"X-Priority": "{{.priority}}",
"X-Template": "1",
})
require.Equal(t, 200, response.Code)
m := toMessage(t, response.Body.String())
require.Equal(t, "Test message", m.Message)
require.Equal(t, 5, m.Priority)
}
func TestServer_MessageTemplate_Priority_Conditional(t *testing.T) {
t.Parallel()
s := newTestServer(t, newTestConfig(t))
// Test with error status -> priority 5
response := request(t, s, "PUT", "/mytopic", `{"status":"Error","message":"Something went wrong"}`, map[string]string{
"X-Message": "Status: {{.status}} - {{.message}}",
"X-Priority": `{{if eq .status "Error"}}5{{else}}3{{end}}`,
"X-Template": "1",
})
require.Equal(t, 200, response.Code)
m := toMessage(t, response.Body.String())
require.Equal(t, "Status: Error - Something went wrong", m.Message)
require.Equal(t, 5, m.Priority)
// Test with success status -> priority 3
response = request(t, s, "PUT", "/mytopic", `{"status":"Success","message":"All good"}`, map[string]string{
"X-Message": "Status: {{.status}} - {{.message}}",
"X-Priority": `{{if eq .status "Error"}}5{{else}}3{{end}}`,
"X-Template": "1",
})
require.Equal(t, 200, response.Code)
m = toMessage(t, response.Body.String())
require.Equal(t, "Status: Success - All good", m.Message)
require.Equal(t, 3, m.Priority)
}
func TestServer_MessageTemplate_Priority_NamedValue(t *testing.T) {
t.Parallel()
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "PUT", "/mytopic", `{"severity":"high"}`, map[string]string{
"X-Message": "Alert",
"X-Priority": "{{.severity}}",
"X-Template": "1",
})
require.Equal(t, 200, response.Code)
m := toMessage(t, response.Body.String())
require.Equal(t, 4, m.Priority) // "high" = 4
}
func TestServer_MessageTemplate_Priority_Invalid(t *testing.T) {
t.Parallel()
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "PUT", "/mytopic", `{"priority":"invalid"}`, map[string]string{
"X-Message": "Test message",
"X-Priority": "{{.priority}}",
"X-Template": "1",
})
require.Equal(t, 400, response.Code)
require.Equal(t, 40007, toHTTPError(t, response.Body.String()).Code)
}
func TestServer_MessageTemplate_Priority_QueryParam(t *testing.T) {
t.Parallel()
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "PUT", "/mytopic?template=1&priority={{.priority}}", `{"priority":"max"}`, nil)
require.Equal(t, 200, response.Code)
m := toMessage(t, response.Body.String())
require.Equal(t, 5, m.Priority) // "max" = 5
}
func TestServer_MessageTemplate_Priority_FromTemplateFile(t *testing.T) {
t.Parallel()
c := newTestConfig(t)
c.TemplateDir = t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(c.TemplateDir, "priority-test.yml"), []byte(`
title: "{{.title}}"
message: "{{.message}}"
priority: '{{if eq .level "critical"}}5{{else if eq .level "warning"}}4{{else}}3{{end}}'
`), 0644))
s := newTestServer(t, c)
// Test with critical level
response := request(t, s, "POST", "/mytopic?template=priority-test", `{"title":"Alert","message":"System down","level":"critical"}`, nil)
require.Equal(t, 200, response.Code)
m := toMessage(t, response.Body.String())
require.Equal(t, "Alert", m.Title)
require.Equal(t, "System down", m.Message)
require.Equal(t, 5, m.Priority)
// Test with warning level
response = request(t, s, "POST", "/mytopic?template=priority-test", `{"title":"Alert","message":"High load","level":"warning"}`, nil)
require.Equal(t, 200, response.Code)
m = toMessage(t, response.Body.String())
require.Equal(t, 4, m.Priority)
// Test with info level
response = request(t, s, "POST", "/mytopic?template=priority-test", `{"title":"Alert","message":"All good","level":"info"}`, nil)
require.Equal(t, 200, response.Code)
m = toMessage(t, response.Body.String())
require.Equal(t, 3, m.Priority)
}
func TestServer_DeleteMessage(t *testing.T) { func TestServer_DeleteMessage(t *testing.T) {
t.Parallel() t.Parallel()
s := newTestServer(t, newTestConfig(t)) s := newTestServer(t, newTestConfig(t))

View File

@@ -299,7 +299,7 @@ func (t templateMode) FileName() string {
return "" return ""
} }
// templateFile represents a template file with title and message // templateFile represents a template file with title, message, and priority
// It is used for file-based templates, e.g. grafana, influxdb, etc. // It is used for file-based templates, e.g. grafana, influxdb, etc.
// //
// Example YAML: // Example YAML:
@@ -308,9 +308,11 @@ func (t templateMode) FileName() string {
// message: | // message: |
// This is a {{ .Type }} alert. // This is a {{ .Type }} alert.
// It can be multiline. // It can be multiline.
// priority: '{{ if eq .status "Error" }}5{{ else }}3{{ end }}'
type templateFile struct { type templateFile struct {
Title *string `yaml:"title"` Title *string `yaml:"title"`
Message *string `yaml:"message"` Message *string `yaml:"message"`
Priority *string `yaml:"priority"`
} }
type apiHealthResponse struct { type apiHealthResponse struct {