diff --git a/docs/integrations.md b/docs/integrations.md index 23c5f9e9..01c415d5 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -96,6 +96,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had - [Ntfy_CSV_Reminders](https://github.com/thiswillbeyourgithub/Ntfy_CSV_Reminders) - A Python tool that sends random-timing phone notifications for recurring tasks by using daily probability checks based on CSV-defined frequencies. - [Daily Fact Ntfy](https://github.com/thiswillbeyourgithub/Daily_Fact_Ntfy) - Generate [llm](https://github.com/simonw/llm) generated fact every day about any topic you're interested in. - [ntfyexec](https://github.com/alecthomas/ntfyexec) - Send a notification through ntfy.sh if a command fails +- [Ntfy Desktop](https://github.com/emmaexe/ntfyDesktop) - Fully featured desktop client for Linux, built with Qt and C++. ## Projects + scripts diff --git a/server/server.go b/server/server.go index f3d2ac51..ac7b10dc 100644 --- a/server/server.go +++ b/server/server.go @@ -991,7 +991,12 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi } else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) { return false, false, "", "", "", false, errHTTPBadRequestPhoneNumberInvalid } - messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n") + template = templateMode(readParam(r, "x-template", "template", "tpl")) + messageStr := readParam(r, "x-message", "message", "m") + if !template.InlineMode() { + // Convert "\n" to literal newline everything but inline mode + messageStr = strings.ReplaceAll(messageStr, "\\n", "\n") + } if messageStr != "" { m.Message = messageStr } @@ -1033,7 +1038,6 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi if markdown || strings.ToLower(contentType) == "text/markdown" { m.ContentType = "text/markdown" } - template = templateMode(readParam(r, "x-template", "template", "tpl")) unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too! contentEncoding := readParam(r, "content-encoding") if unifiedpush || contentEncoding == "aes128gcm" { @@ -1119,8 +1123,8 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *message, template templateM return errHTTPEntityTooLargeJSONBody } peekedBody := strings.TrimSpace(string(body.PeekedBytes)) - if templateName := template.Name(); templateName != "" { - if err := s.renderTemplateFromFile(m, templateName, peekedBody); err != nil { + if template.FileMode() { + if err := s.renderTemplateFromFile(m, template.FileName(), peekedBody); err != nil { return err } } else { @@ -1198,7 +1202,7 @@ func (s *Server) renderTemplate(tpl string, source string) (string, error) { if err := t.Execute(limitWriter, data); err != nil { return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("%s", err.Error()) } - return strings.TrimSpace(buf.String()), nil + return strings.TrimSpace(strings.ReplaceAll(buf.String(), "\\n", "\n")), nil // replace any remaining "\n" (those outside of template curly braces) with newlines } func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) error { diff --git a/server/server_test.go b/server/server_test.go index 36bbae3f..41633dd5 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -3069,6 +3069,61 @@ func TestServer_MessageTemplate_UnsafeSprigFunctions(t *testing.T) { require.Equal(t, 40043, toHTTPError(t, response.Body.String()).Code) } +func TestServer_MessageTemplate_InlineNewlines(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", `{}`, map[string]string{ + "X-Message": `{{"New\nlines"}}`, + "X-Title": `{{"New\nlines"}}`, + "X-Template": "1", + }) + + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, `New +lines`, m.Message) + require.Equal(t, `New +lines`, m.Title) +} + +func TestServer_MessageTemplate_InlineNewlinesOutsideOfTemplate(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", `{"foo":"bar","food":"bag"}`, map[string]string{ + "X-Message": `{{.foo}}{{"\n"}}{{.food}}`, + "X-Title": `{{.food}}{{"\n"}}{{.foo}}`, + "X-Template": "1", + }) + + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, `bar +bag`, m.Message) + require.Equal(t, `bag +bar`, m.Title) +} + +func TestServer_MessageTemplate_TemplateFileNewlines(t *testing.T) { + t.Parallel() + c := newTestConfig(t) + c.TemplateDir = t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(c.TemplateDir, "newline.yml"), []byte(` +title: | + {{.food}}{{"\n"}}{{.foo}} +message: | + {{.foo}}{{"\n"}}{{.food}} +`), 0644)) + s := newTestServer(t, c) + response := request(t, s, "POST", "/mytopic?template=newline", `{"foo":"bar","food":"bag"}`, nil) + fmt.Println(response.Body.String()) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, `bar +bag`, m.Message) + require.Equal(t, `bag +bar`, m.Title) +} + var ( //go:embed testdata/webhook_github_comment_created.json githubCommentCreatedJSON string diff --git a/server/types.go b/server/types.go index ea6b8615..65492e46 100644 --- a/server/types.go +++ b/server/types.go @@ -245,19 +245,46 @@ func (q *queryFilter) Pass(msg *message) bool { return true } +// templateMode represents the mode in which templates are used +// +// It can be +// - empty: templating is disabled +// - a boolean string (yes/1/true/no/0/false): inline-templating mode +// - a filename (e.g. grafana): template mode with a file type templateMode string +// Enabled returns true if templating is enabled func (t templateMode) Enabled() bool { return t != "" } -func (t templateMode) Name() string { - if isBoolValue(string(t)) { - return "" - } - return string(t) +// InlineMode returns true if inline-templating mode is enabled +func (t templateMode) InlineMode() bool { + return t.Enabled() && isBoolValue(string(t)) } +// FileMode returns true if file-templating mode is enabled +func (t templateMode) FileMode() bool { + return t.Enabled() && !isBoolValue(string(t)) +} + +// FileName returns the filename if file-templating mode is enabled, or an empty string otherwise +func (t templateMode) FileName() string { + if t.FileMode() { + return string(t) + } + return "" +} + +// templateFile represents a template file with title and message +// It is used for file-based templates, e.g. grafana, influxdb, etc. +// +// Example YAML: +// +// title: "Alert: {{ .Title }}" +// message: | +// This is a {{ .Type }} alert. +// It can be multiline. type templateFile struct { Title *string `yaml:"title"` Message *string `yaml:"message"`