1
0
Fork 0
mirror of https://github.com/binwiederhier/ntfy.git synced 2024-11-22 03:13:33 +01:00

Add support for server templates

instead of sending title/message templates as part of the publishing
request, systems can simply reference existing templates from the server.
this has two advantages
a) publish URLs become shorter
b) publish URLs become easier to reuse

the changes backwards-compatible, as the `tpl` parameter continues
to support truthy boolean values.
the feature has to be enabled on the server. available templates
and their content are exposed via new API endpoints.
This commit is contained in:
Gordon Bleux 2024-11-11 11:35:09 +01:00
parent 630f2957de
commit 823070bf7e
12 changed files with 599 additions and 48 deletions

View file

@ -100,6 +100,7 @@ var flagsServe = append(
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-file", Aliases: []string{"web_push_file"}, EnvVars: []string{"NTFY_WEB_PUSH_FILE"}, Usage: "file used to store web push subscriptions"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-email-address", Aliases: []string{"web_push_email_address"}, EnvVars: []string{"NTFY_WEB_PUSH_EMAIL_ADDRESS"}, Usage: "e-mail address of sender, required to use browser push services"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-startup-queries", Aliases: []string{"web_push_startup_queries"}, EnvVars: []string{"NTFY_WEB_PUSH_STARTUP_QUERIES"}, Usage: "queries run when the web push database is initialized"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "template-directory", Aliases: []string{"template_directory"}, EnvVars: []string{"NTFY_TEMPLATE_DIRECTORY"}, Usage: "Directory to serve message templates from"}),
)
var cmdServe = &cli.Command{
@ -140,6 +141,7 @@ func execServe(c *cli.Context) error {
webPushFile := c.String("web-push-file")
webPushEmailAddress := c.String("web-push-email-address")
webPushStartupQueries := c.String("web-push-startup-queries")
templateDirectory := c.String("template-directory")
cacheFile := c.String("cache-file")
cacheDurationStr := c.String("cache-duration")
cacheStartupQueries := c.String("cache-startup-queries")
@ -256,6 +258,8 @@ func execServe(c *cli.Context) error {
return errors.New("if set, FCM key file must exist")
} else if webPushPublicKey != "" && (webPushPrivateKey == "" || webPushFile == "" || webPushEmailAddress == "" || baseURL == "") {
return errors.New("if web push is enabled, web-push-private-key, web-push-public-key, web-push-file, web-push-email-address, and base-url should be set. run 'ntfy webpush keys' to generate keys")
} else if templateDirectory != "" && !util.DirectoryExists(templateDirectory) {
return fmt.Errorf("templates directory %q does not exist", templateDirectory)
} else if keepaliveInterval < 5*time.Second {
return errors.New("keepalive interval cannot be lower than five seconds")
} else if managerInterval < 5*time.Second {
@ -417,6 +421,7 @@ func execServe(c *cli.Context) error {
conf.WebPushFile = webPushFile
conf.WebPushEmailAddress = webPushEmailAddress
conf.WebPushStartupQueries = webPushStartupQueries
conf.TemplateDirectory = templateDirectory
// Set up hot-reloading of config
go sigHandlerConfigReload(config)

View file

@ -1075,6 +1075,33 @@ This example uses the `message`/`m` and `title`/`t` query parameters, but obviou
`Message`/`Title` headers. It will send a notification with a title `phil-pc: A severe error has occurred` and a message
`Error message: Disk has run out of space`.
### Server templates
In order to avoid running into limitations related to the URL length and to simplify reusing templates across publishing systems,
title/message templates can also be provided by the server and then simply referenced during publishing.
This feature has to be enabled on the server first, by specifying a base directory for templates using the `--template-directory`.
All files within this directory (subdirectories are not supported) with the file extension *.tpl* are considered
templates to be used during publishing, referenced by their basename (without the extension).
To use a template, use `server` instead of `yes`/`1` for the templating parameter. The `message` and `title` parameters
are used as template name.
!!! info
The templating feature applies to both the title and message both in equal parts. You can *not* mix and match,
e.g. by specifying a template name for the title and an inline template for the body.
Assuming the server is running with the following template directory:
```
/etc/ntfy/templates
|_ grafana_title.tpl
|_ grafana_body.tpl
\_ hello_world.tpl
```
You can now publish your payload to the following endpoint: `https://ntfy.sh/mytopic?tpl=server&title=hello_world&message=grafana_body`
## Publish as JSON
_Supported on:_ :material-android: :material-apple: :material-firefox:

View file

@ -161,6 +161,7 @@ type Config struct {
WebPushStartupQueries string
WebPushExpiryDuration time.Duration
WebPushExpiryWarningDuration time.Duration
TemplateDirectory string
}
// NewConfig instantiates a default new server config
@ -248,5 +249,6 @@ func NewConfig() *Config {
WebPushEmailAddress: "",
WebPushExpiryDuration: DefaultWebPushExpiryDuration,
WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration,
TemplateDirectory: "",
}
}

View file

@ -123,6 +123,7 @@ var (
errHTTPBadRequestTemplateDisallowedFunctionCalls = &errHTTP{40044, http.StatusBadRequest, "invalid request: template contains disallowed function calls, e.g. template, call, or define", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestTemplateExecuteFailed = &errHTTP{40045, http.StatusBadRequest, "invalid request: template execution failed", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil}
errHTTPBadRequestTemplatesNotEnabled = &errHTTP{40047, http.StatusBadRequest, "invalid request: templates not enabled", "https://ntfy.sh/docs/config", 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}

View file

@ -23,7 +23,6 @@ import (
"strconv"
"strings"
"sync"
"text/template"
"time"
"unicode/utf8"
@ -88,6 +87,7 @@ var (
apiHealthPath = "/v1/health"
apiStatsPath = "/v1/stats"
apiWebPushPath = "/v1/webpush"
apiTemplatesPath = "/v1/templates"
apiTiersPath = "/v1/tiers"
apiUsersPath = "/v1/users"
apiUsersAccessPath = "/v1/users/access"
@ -505,6 +505,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.handleStats(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == apiTiersPath {
return s.ensurePaymentsEnabled(s.handleBillingTiersGet)(w, r, v)
} else if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, apiTemplatesPath) {
return s.ensureTemplatesEnabled(s.limitRequests(s.handleTemplates))(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath {
return s.handleMatrixDiscovery(w)
} else if r.Method == http.MethodGet && r.URL.Path == metricsPath && s.metricsHandler != nil {
@ -617,6 +619,27 @@ func (s *Server) handleWebManifest(w http.ResponseWriter, _ *http.Request, _ *vi
return s.writeJSONWithContentType(w, response, "application/manifest+json")
}
// handleTemplates either writes a list of available templates (if only the base API path
// is requested) or serves the requested template file.
func (s *Server) handleTemplates(w http.ResponseWriter, r *http.Request, _ *visitor) error {
path := r.URL.Path[len(apiTemplatesPath):]
if path == "" || path == "/" {
ls, err := s.listTemplates()
if err != nil {
return err
}
response := &templateNamesResponse{
Templates: ls,
}
return s.writeJSON(w, response)
}
return s.serveTemplate(w, r, path)
}
// handleMetrics returns Prometheus metrics. This endpoint is only called if enable-metrics is set,
// and listen-metrics-http is not set.
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request, _ *visitor) error {
@ -933,7 +956,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, template bool, unifiedpush bool, err *errHTTP) {
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template templateFeature, 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")
@ -949,7 +972,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
}
if attach != "" {
if !urlRegex.MatchString(attach) {
return false, false, "", "", false, false, errHTTPBadRequestAttachmentURLInvalid
return false, false, "", "", templateFeatureDisabled, false, errHTTPBadRequestAttachmentURLInvalid
}
m.Attachment.URL = attach
if m.Attachment.Name == "" {
@ -967,19 +990,19 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
}
if icon != "" {
if !urlRegex.MatchString(icon) {
return false, false, "", "", false, false, errHTTPBadRequestIconURLInvalid
return false, false, "", "", templateFeatureDisabled, 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, false, errHTTPBadRequestEmailDisabled
return false, false, "", "", templateFeatureDisabled, false, errHTTPBadRequestEmailDisabled
}
call = readParam(r, "x-call", "call")
if call != "" && (s.config.TwilioAccount == "" || s.userManager == nil) {
return false, false, "", "", false, false, errHTTPBadRequestPhoneCallsDisabled
return false, false, "", "", templateFeatureDisabled, false, errHTTPBadRequestPhoneCallsDisabled
} else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) {
return false, false, "", "", false, false, errHTTPBadRequestPhoneNumberInvalid
return false, false, "", "", templateFeatureDisabled, false, errHTTPBadRequestPhoneNumberInvalid
}
messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
if messageStr != "" {
@ -988,27 +1011,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, false, errHTTPBadRequestPriorityInvalid
return false, false, "", "", templateFeatureDisabled, 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, false, errHTTPBadRequestDelayNoCache
return false, false, "", "", templateFeatureDisabled, false, errHTTPBadRequestDelayNoCache
}
if email != "" {
return false, false, "", "", false, false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
return false, false, "", "", templateFeatureDisabled, false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
}
if call != "" {
return false, false, "", "", false, false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet)
return false, false, "", "", templateFeatureDisabled, false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet)
}
delay, err := util.ParseFutureTime(delayStr, time.Now())
if err != nil {
return false, false, "", "", false, false, errHTTPBadRequestDelayCannotParse
return false, false, "", "", templateFeatureDisabled, false, errHTTPBadRequestDelayCannotParse
} else if delay.Unix() < time.Now().Add(s.config.MessageDelayMin).Unix() {
return false, false, "", "", false, false, errHTTPBadRequestDelayTooSmall
return false, false, "", "", templateFeatureDisabled, false, errHTTPBadRequestDelayTooSmall
} else if delay.Unix() > time.Now().Add(s.config.MessageDelayMax).Unix() {
return false, false, "", "", false, false, errHTTPBadRequestDelayTooLarge
return false, false, "", "", templateFeatureDisabled, false, errHTTPBadRequestDelayTooLarge
}
m.Time = delay.Unix()
}
@ -1016,14 +1039,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, false, errHTTPBadRequestActionsInvalid.Wrap(e.Error())
return false, false, "", "", templateFeatureDisabled, 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"
}
template = readBoolParam(r, false, "x-template", "template", "tpl")
template = parseTemplateFeature(readParam(r, "x-template", "template", "tpl"))
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
if unifiedpush {
firebase = false
@ -1050,11 +1073,13 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
// Body must be attachment, because we passed a filename
// 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: server" -T file.txt ntfy.sh/mytopic?m=foobar
// Read foobar template from filesystem and treat message body as JSON
// 7. curl -T file.txt ntfy.sh/mytopic
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
// 8. curl -T file.txt ntfy.sh/mytopic
// 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 {
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, template templateFeature, unifiedpush bool) error {
if m.Event == pollRequestEvent { // Case 1
return s.handleBodyDiscard(body)
} else if unifiedpush {
@ -1063,12 +1088,12 @@ func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body
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 template {
return s.handleBodyAsTemplatedTextMessage(m, body) // Case 5
} else if template != templateFeatureDisabled {
return s.handleBodyAsTemplatedTextMessage(template, v, m, body) // Case 5&6
} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
return s.handleBodyAsTextMessage(m, body) // Case 6
return s.handleBodyAsTextMessage(m, body) // Case 7
}
return s.handleBodyAsAttachment(r, v, m, body) // Case 7
return s.handleBodyAsAttachment(r, v, m, body) // Case 8
}
func (s *Server) handleBodyDiscard(body *util.PeekedReadCloser) error {
@ -1100,45 +1125,51 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser
return nil
}
func (s *Server) handleBodyAsTemplatedTextMessage(m *message, body *util.PeekedReadCloser) error {
func (s *Server) handleBodyAsTemplatedTextMessage(t templateFeature, v *visitor, m *message, body *util.PeekedReadCloser) error {
var internalError error
defer func() {
if internalError != nil {
logvm(v, m).Err(internalError).Info("Failed to render %s templated message", t)
}
}()
body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit))
if err != nil {
return err
} else if body.LimitReached {
return errHTTPEntityTooLargeJSONBody
}
peekedBody := strings.TrimSpace(string(body.PeekedBytes))
if m.Message, err = replaceTemplate(m.Message, peekedBody); err != nil {
return err
peekedBody := bytes.TrimSpace(body.PeekedBytes)
var data any
if err := json.Unmarshal(peekedBody, &data); err != nil {
return errHTTPBadRequestTemplateMessageNotJSON
}
if m.Title, err = replaceTemplate(m.Title, peekedBody); err != nil {
return err
if t == templateFeatureInline {
if m.Message, internalError, err = s.replaceTemplate(m.Message, data); err != nil {
return err
}
if m.Title, internalError, err = s.replaceTemplate(m.Title, data); err != nil {
return err
}
} else if t == templateFeatureServer {
if s.config.TemplateDirectory == "" {
return errHTTPBadRequestTemplatesNotEnabled
}
if m.Message, internalError, err = s.replaceTemplateFile(m.Message, data); err != nil {
return err
}
if m.Title, internalError, err = s.replaceTemplateFile(m.Title, data); err != nil {
return err
}
}
if len(m.Message) > s.config.MessageSizeLimit {
return errHTTPBadRequestTemplateMessageTooLarge
}
return nil
}
func replaceTemplate(tpl string, source string) (string, error) {
if templateDisallowedRegex.MatchString(tpl) {
return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
}
var data any
if err := json.Unmarshal([]byte(source), &data); err != nil {
return "", errHTTPBadRequestTemplateMessageNotJSON
}
t, err := template.New("").Parse(tpl)
if err != nil {
return "", errHTTPBadRequestTemplateInvalid
}
var buf bytes.Buffer
if err := t.Execute(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), data); err != nil {
return "", errHTTPBadRequestTemplateExecuteFailed
}
return buf.String(), nil
}
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)

View file

@ -121,6 +121,15 @@ func (s *Server) ensureStripeCustomer(next handleFunc) handleFunc {
})
}
func (s *Server) ensureTemplatesEnabled(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if s.config.TemplateDirectory == "" {
return errHTTPNotFound
}
return next(w, r, v)
}
}
func (s *Server) withAccountSync(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
err := next(w, r, v)

164
server/server_templates.go Normal file
View file

@ -0,0 +1,164 @@
package server
import (
"bytes"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"text/template"
"heckel.io/ntfy/v2/util"
)
type templateFeature int
const templateExtension = ".tpl"
var (
templateEnabledDefault = "disabled"
templateEnabledInline = "inline"
templateEnabledServer = "server"
)
const (
templateFeatureDisabled templateFeature = iota
templateFeatureInline
templateFeatureServer
)
var templateFeatureNames = map[templateFeature]string{
templateFeatureDisabled: templateEnabledDefault,
templateFeatureInline: templateEnabledInline,
templateFeatureServer: templateEnabledServer,
}
func parseTemplateFeature(v string) templateFeature {
if isBoolValue(v) {
// backwards-compatibility support
if toBool(v) {
return templateFeatureInline
}
} else if v == templateEnabledInline {
return templateFeatureInline
} else if v == templateEnabledServer {
return templateFeatureServer
}
return templateFeatureDisabled
}
// String returns a human readable description of the template feature setting.
// invalid values are folded into the [templateEnabledDefault] state.
func (f templateFeature) String() string {
if n, ok := templateFeatureNames[f]; ok {
return n
}
return templateEnabledDefault
}
// listTemplates returns the base names without extensions
// of all files in the configured templates directory.
func (s *Server) listTemplates() ([]string, error) {
templates := []string{}
fileSystem := os.DirFS(s.config.TemplateDirectory)
walker := func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
} else if !d.Type().IsRegular() {
return nil
}
name := strings.TrimSuffix(d.Name(), templateExtension)
if name == d.Name() {
// entry does not have the suffix
return nil
}
templates = append(templates, name)
return nil
}
if err := fs.WalkDir(fileSystem, ".", walker); err != nil {
return nil, err
}
return templates, nil
}
// serveTemplate writes the file content of the given template into the writer.
// the filename is generated using [Server.templateFilePath].
// errors encountered during preparations are returned, errors encountered during serving
// the file content are written to the response writer instead.
func (s *Server) serveTemplate(w http.ResponseWriter, r *http.Request, name string) error {
f, err := os.Open(s.templateFilePath(name))
if err != nil {
return err
}
defer f.Close()
d, err := f.Stat()
if err != nil {
return err
}
// prevent serve content from wrongly identifying the content type
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
http.ServeContent(w, r, d.Name(), d.ModTime(), f)
return nil
}
// templateFilePath creates a filepath pointing to a file
// in the servers template directory. to prevent directory
// traversal, the input value is cleaned/sanitized. name must
// not have a file extension.
func (s *Server) templateFilePath(name string) string {
// prevent directory traversal
filename := filepath.Base(filepath.Clean(name)) + templateExtension
return filepath.Join(s.config.TemplateDirectory, filename)
}
// replaceTemplate loads the given value as template and renders
// it using the provided context/data. the method returns two errors; the first one
// is ment for logging and internal insight as it might contain potential sensitive
// information. the second error is intended to be served to clients.
func (s *Server) replaceTemplate(tpl string, data any) (string, error, error) {
if templateDisallowedRegex.MatchString(tpl) {
return "", nil, errHTTPBadRequestTemplateDisallowedFunctionCalls
}
t, err := template.New("").Parse(tpl)
if err != nil {
return "", err, errHTTPBadRequestTemplateInvalid
}
var buf bytes.Buffer
if err := t.Execute(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), data); err != nil {
return "", err, errHTTPBadRequestTemplateExecuteFailed
}
return buf.String(), nil, nil
}
// replaceTemplateFile loads the template identified by the given name and renders
// it using the provided context/data. the method returns two errors; the first one
// is ment for logging and internal insight as it might contain potential sensitive
// information. the second error is intended to be served to clients.
func (s *Server) replaceTemplateFile(name string, data any) (string, error, error) {
if name == "" {
// instead of bailing with an error, we simply tread the request as a no-op
// and return no data
return "", nil, nil
}
t, err := template.ParseFiles(s.templateFilePath(name))
if err != nil {
return "", err, errHTTPBadRequestTemplateInvalid
}
var buf bytes.Buffer
if err := t.Execute(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), data); err != nil {
return "", err, errHTTPBadRequestTemplateExecuteFailed
}
return buf.String(), nil, nil
}

View file

@ -0,0 +1,178 @@
package server
import (
"io"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func TestParseTemplateFeature(t *testing.T) {
var testCases = map[string]struct {
have string
want templateFeature
}{
"empty string": {},
"invalid string": {
have: "bogus",
},
"boolean 0": {
have: "0",
},
"boolean n": {
have: "n",
},
"boolean y": {
have: "y",
},
"boolean no": {
have: "no",
},
"boolean false": {
have: "false",
},
"boolean 1": {
have: "1",
want: templateFeatureInline,
},
"boolean yes": {
have: "yes",
want: templateFeatureInline,
},
"boolean true": {
have: "true",
want: templateFeatureInline,
},
"explicitly inline": {
have: "inline",
want: templateFeatureInline,
},
"explicitly server": {
have: "server",
want: templateFeatureServer,
},
}
for name, test := range testCases {
t.Run(name, func(t *testing.T) {
got := parseTemplateFeature(test.have)
if got != test.want {
t.Fatalf("parseTemplateFeature(%q) returned %q; expected %q", test.have, got, test.want)
}
})
}
}
func TestServer_Templates_TemplateFilePath(t *testing.T) {
s := newTestServer(t, newTestConfigWithTemplates(t))
var testCases = map[string]struct {
have string
want string
}{
"empty string": {
// filepath.Join cleans the output which would interfer with the purpose of this test case
want: s.config.TemplateDirectory + string(filepath.Separator) + "." + templateExtension,
},
"directory traversal": {
have: "../../../../etc/shadow",
want: filepath.Join(s.config.TemplateDirectory, "shadow") + templateExtension,
},
"relative path": {
have: "./foo/bar",
want: filepath.Join(s.config.TemplateDirectory, "bar") + templateExtension,
},
"file extension": {
have: "test.json",
want: filepath.Join(s.config.TemplateDirectory, "test.json") + templateExtension,
},
"simple value": {
have: "test",
want: filepath.Join(s.config.TemplateDirectory, "test") + templateExtension,
},
}
for name, test := range testCases {
t.Run(name, func(t *testing.T) {
got := s.templateFilePath(test.have)
if got != test.want {
t.Fatalf("Server.templateFilePath(%q) returned %q; expected %q", test.have, got, test.want)
}
})
}
}
func TestServer_Templates_ServeTemplate(t *testing.T) {
s := newTestServer(t, newTestConfigWithTemplates(t))
var testCases = map[string]struct {
have string
want string
wantError bool
}{
"empty string": {
wantError: true,
},
"file not found": {
have: "404",
wantError: true,
},
"directory index": {
have: "index.html",
wantError: true,
},
"valid template foo_message": {
have: "foo_message",
want: "{{.foo}}",
},
"valid template nested_title": {
have: "nested_title",
want: "{{.nested.title}}",
},
"valid template foo_repeat": {
have: "foo_repeat",
want: "{{.foo}} is {{.foo}}",
},
"valid template nested_repeat": {
have: "nested_repeat",
want: "{{.nested.title}} is {{.nested.title}}",
},
}
for name, test := range testCases {
t.Run(name, func(t *testing.T) {
r := httptest.NewRequest("GET", "http://ntfy.test/foo?tpl=server&m="+test.have, nil)
w := httptest.NewRecorder()
err := s.serveTemplate(w, r, test.have)
if test.wantError {
require.NotNil(t, err)
} else {
require.Nil(t, err)
resp := w.Result()
body, err := io.ReadAll(resp.Body)
require.Nil(t, err)
got := string(body)
if got != test.want {
t.Fatalf("Server.serveTemplate(_, _, %q) served %q; expected %q", test.have, got, test.want)
}
}
})
}
}
func TestServer_Templates_ListTemplates(t *testing.T) {
s := newTestServer(t, newTestConfigWithTemplates(t))
got, err := s.listTemplates()
require.Nil(t, err)
require.Len(t, got, 4)
require.Contains(t, got, "foo_message")
require.Contains(t, got, "nested_title")
require.Contains(t, got, "foo_repeat")
require.Contains(t, got, "nested_repeat")
}

View file

@ -2863,6 +2863,63 @@ template ""}}`,
}
}
func TestServer_MessageTemplate_Server(t *testing.T) {
s := newTestServer(t, newTestConfigWithTemplates(t))
// keep this in sync with the mock templates
// generated by newTestConfigWithTemplates
body := `{"foo":"bar", "nested":{"title":"here"}}`
var testCases = map[string]struct {
haveTitle string
haveMessage string
wantCode int
wantTitle string
wantMessage string
}{
"empty title": {
haveMessage: "foo_message",
wantCode: 200,
wantMessage: "bar",
},
"invalid title template": {
haveTitle: "does_not_exist",
wantCode: 400,
},
"invalid message template": {
haveMessage: "does_not_exist",
wantCode: 400,
},
"simple templates": {
haveTitle: "nested_title",
haveMessage: "foo_message",
wantCode: 200,
wantTitle: "here",
wantMessage: "bar",
},
"repeat templates": {
haveTitle: "nested_repeat",
haveMessage: "foo_repeat",
wantCode: 200,
wantTitle: "here is here",
wantMessage: "bar is bar",
},
}
for name, test := range testCases {
t.Run(name, func(t *testing.T) {
endpoint := "/mytopic?tpl=server&title=" + test.haveTitle + "&message=" + test.haveMessage
response := request(t, s, "PUT", endpoint, body, nil)
require.Equal(t, test.wantCode, response.Code)
if test.wantCode == 200 {
m := toMessage(t, response.Body.String())
require.Equal(t, test.wantTitle, m.Title, "message title")
require.Equal(t, test.wantMessage, m.Message, "message body")
}
})
}
}
func newTestConfig(t *testing.T) *Config {
conf := NewConfig()
conf.BaseURL = "http://127.0.0.1:12345"
@ -2896,6 +2953,42 @@ func newTestConfigWithWebPush(t *testing.T) *Config {
return conf
}
func newTestConfigWithTemplates(t *testing.T) *Config {
basedir, err := os.MkdirTemp(t.TempDir(), "templates")
require.Nil(t, err)
metadataFile1 := filepath.Join(basedir, "metadata.json")
templateFile1 := filepath.Join(basedir, "foo_message"+templateExtension)
templateFile2 := filepath.Join(basedir, "nested_title"+templateExtension)
templateFile3 := filepath.Join(basedir, "foo_repeat"+templateExtension)
templateFile4 := filepath.Join(basedir, "nested_repeat"+templateExtension)
metadataData1 := []byte("{}")
templateData1 := []byte("{{.foo}}")
templateData2 := []byte("{{.nested.title}}")
templateData3 := []byte("{{.foo}} is {{.foo}}")
templateData4 := []byte("{{.nested.title}} is {{.nested.title}}")
conf := newTestConfig(t)
conf.TemplateDirectory = basedir
err = os.WriteFile(metadataFile1, metadataData1, 0644)
require.Nil(t, err)
err = os.WriteFile(templateFile1, templateData1, 0644)
require.Nil(t, err)
err = os.WriteFile(templateFile2, templateData2, 0644)
require.Nil(t, err)
err = os.WriteFile(templateFile3, templateData3, 0644)
require.Nil(t, err)
err = os.WriteFile(templateFile4, templateData4, 0644)
require.Nil(t, err)
return conf
}
func newTestServer(t *testing.T, config *Config) *Server {
server, err := New(config)
require.Nil(t, err)

View file

@ -539,3 +539,7 @@ type webManifestIcon struct {
Sizes string `json:"sizes"`
Type string `json:"type"`
}
type templateNamesResponse struct {
Templates []string `json:"templates"`
}

View file

@ -48,6 +48,15 @@ func FileExists(filename string) bool {
return stat != nil
}
// DirectoryExists checks if a path exists and is a directory.
func DirectoryExists(filename string) bool {
stat, err := os.Stat(filename)
if err != nil {
return false
}
return stat.IsDir()
}
// Contains returns true if needle is contained in haystack
func Contains[T comparable](haystack []T, needle T) bool {
for _, s := range haystack {

View file

@ -32,6 +32,34 @@ func TestFileExists(t *testing.T) {
require.False(t, FileExists(filename+".doesnotexist"))
}
func TestDirectoryExists(t *testing.T) {
var testCases = map[string]struct {
have string
want bool
}{
"empty string": {},
"temp dir": {
have: t.TempDir(),
want: true,
},
"existing file": {
have: os.Args[0],
},
"non-existing path": {
have: filepath.Join(t.TempDir(), "foo", "bar"),
},
}
for name, test := range testCases {
t.Run(name, func(t *testing.T) {
got := DirectoryExists(test.have)
if got != test.want {
t.Fatalf("DirectoryExists(%q) returned %t; expected %t", test.have, got, test.want)
}
})
}
}
func TestInStringList(t *testing.T) {
s := []string{"one", "two"}
require.True(t, Contains(s, "two"))