Skip to content

Commit dc9ed3f

Browse files
committed
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.
1 parent 630f295 commit dc9ed3f

12 files changed

+588
-48
lines changed

cmd/serve.go

+5
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ var flagsServe = append(
100100
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"}),
101101
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"}),
102102
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"}),
103+
altsrc.NewStringFlag(&cli.StringFlag{Name: "template-directory", Aliases: []string{"template_directory"}, EnvVars: []string{"NTFY_TEMPLATE_DIRECTORY"}, Usage: "Directory to serve message templates from"}),
103104
)
104105

105106
var cmdServe = &cli.Command{
@@ -140,6 +141,7 @@ func execServe(c *cli.Context) error {
140141
webPushFile := c.String("web-push-file")
141142
webPushEmailAddress := c.String("web-push-email-address")
142143
webPushStartupQueries := c.String("web-push-startup-queries")
144+
templateDirectory := c.String("template-directory")
143145
cacheFile := c.String("cache-file")
144146
cacheDurationStr := c.String("cache-duration")
145147
cacheStartupQueries := c.String("cache-startup-queries")
@@ -256,6 +258,8 @@ func execServe(c *cli.Context) error {
256258
return errors.New("if set, FCM key file must exist")
257259
} else if webPushPublicKey != "" && (webPushPrivateKey == "" || webPushFile == "" || webPushEmailAddress == "" || baseURL == "") {
258260
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")
261+
} else if templateDirectory != "" && !util.DirectoryExists(templateDirectory) {
262+
return fmt.Errorf("templates directory %q does not exist", templateDirectory)
259263
} else if keepaliveInterval < 5*time.Second {
260264
return errors.New("keepalive interval cannot be lower than five seconds")
261265
} else if managerInterval < 5*time.Second {
@@ -417,6 +421,7 @@ func execServe(c *cli.Context) error {
417421
conf.WebPushFile = webPushFile
418422
conf.WebPushEmailAddress = webPushEmailAddress
419423
conf.WebPushStartupQueries = webPushStartupQueries
424+
conf.TemplateDirectory = templateDirectory
420425

421426
// Set up hot-reloading of config
422427
go sigHandlerConfigReload(config)

docs/publish.md

+27
Original file line numberDiff line numberDiff line change
@@ -1075,6 +1075,33 @@ This example uses the `message`/`m` and `title`/`t` query parameters, but obviou
10751075
`Message`/`Title` headers. It will send a notification with a title `phil-pc: A severe error has occurred` and a message
10761076
`Error message: Disk has run out of space`.
10771077

1078+
### Server templates
1079+
1080+
In order to avoid running into limitations related to the URL length and to simplify reusing templates across publishing systems,
1081+
title/message templates can also be provided by the server and then simply referenced during publishing.
1082+
1083+
This feature has to be enabled on the server first, by specifying a base directory for templates using the `--template-directory`.
1084+
All files within this directory (subdirectories are not supported) with the file extension *.tpl* are considered
1085+
templates to be used during publishing, referenced by their basename (without the extension).
1086+
1087+
To use a template, use `server` instead of `yes`/`1` for the templating parameter. The `message` and `title` parameters
1088+
are used as template name.
1089+
1090+
!!! info
1091+
The templating feature applies to both the title and message both in equal parts. You can *not* mix and match,
1092+
e.g. by specifying a template name for the title and an inline template for the body.
1093+
1094+
Assuming the server is running with the following template directory:
1095+
1096+
```
1097+
/etc/ntfy/templates
1098+
|_ grafana_title.tpl
1099+
|_ grafana_body.tpl
1100+
\_ hello_world.tpl
1101+
```
1102+
1103+
You can now publish your payload to the following endpoint: `https://ntfy.sh/mytopic?tpl=server&title=hello_world&message=grafana_body`
1104+
10781105
## Publish as JSON
10791106
_Supported on:_ :material-android: :material-apple: :material-firefox:
10801107

server/config.go

+2
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ type Config struct {
161161
WebPushStartupQueries string
162162
WebPushExpiryDuration time.Duration
163163
WebPushExpiryWarningDuration time.Duration
164+
TemplateDirectory string
164165
}
165166

166167
// NewConfig instantiates a default new server config
@@ -248,5 +249,6 @@ func NewConfig() *Config {
248249
WebPushEmailAddress: "",
249250
WebPushExpiryDuration: DefaultWebPushExpiryDuration,
250251
WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration,
252+
TemplateDirectory: "",
251253
}
252254
}

server/errors.go

+1
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ var (
123123
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}
124124
errHTTPBadRequestTemplateExecuteFailed = &errHTTP{40045, http.StatusBadRequest, "invalid request: template execution failed", "https://ntfy.sh/docs/publish/#message-templating", nil}
125125
errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil}
126+
errHTTPBadRequestTemplatesNotEnabled = &errHTTP{40047, http.StatusBadRequest, "invalid request: templates not enabled", "https://ntfy.sh/docs/config", nil}
126127
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
127128
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
128129
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}

server/server.go

+79-48
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import (
2323
"strconv"
2424
"strings"
2525
"sync"
26-
"text/template"
2726
"time"
2827
"unicode/utf8"
2928

@@ -88,6 +87,7 @@ var (
8887
apiHealthPath = "/v1/health"
8988
apiStatsPath = "/v1/stats"
9089
apiWebPushPath = "/v1/webpush"
90+
apiTemplatesPath = "/v1/templates"
9191
apiTiersPath = "/v1/tiers"
9292
apiUsersPath = "/v1/users"
9393
apiUsersAccessPath = "/v1/users/access"
@@ -505,6 +505,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
505505
return s.handleStats(w, r, v)
506506
} else if r.Method == http.MethodGet && r.URL.Path == apiTiersPath {
507507
return s.ensurePaymentsEnabled(s.handleBillingTiersGet)(w, r, v)
508+
} else if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, apiTemplatesPath) {
509+
return s.ensureTemplatesEnabled(s.limitRequests(s.handleTemplates))(w, r, v)
508510
} else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath {
509511
return s.handleMatrixDiscovery(w)
510512
} 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
617619
return s.writeJSONWithContentType(w, response, "application/manifest+json")
618620
}
619621

622+
// handleTemplates either writes a list of available templates (if only the base API path
623+
// is requested) or serves the requested template file.
624+
func (s *Server) handleTemplates(w http.ResponseWriter, r *http.Request, _ *visitor) error {
625+
path := r.URL.Path[len(apiTemplatesPath):]
626+
627+
if path == "" || path == "/" {
628+
ls, err := s.listTemplates()
629+
if err != nil {
630+
return err
631+
}
632+
633+
response := &templateNamesResponse{
634+
Templates: ls,
635+
}
636+
637+
return s.writeJSON(w, response)
638+
}
639+
640+
return s.serveTemplate(w, r, path)
641+
}
642+
620643
// handleMetrics returns Prometheus metrics. This endpoint is only called if enable-metrics is set,
621644
// and listen-metrics-http is not set.
622645
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request, _ *visitor) error {
@@ -933,7 +956,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
933956
}
934957
}
935958

936-
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template bool, unifiedpush bool, err *errHTTP) {
959+
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template templateFeature, unifiedpush bool, err *errHTTP) {
937960
cache = readBoolParam(r, true, "x-cache", "cache")
938961
firebase = readBoolParam(r, true, "x-firebase", "firebase")
939962
m.Title = readParam(r, "x-title", "title", "t")
@@ -949,7 +972,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
949972
}
950973
if attach != "" {
951974
if !urlRegex.MatchString(attach) {
952-
return false, false, "", "", false, false, errHTTPBadRequestAttachmentURLInvalid
975+
return false, false, "", "", templateFeatureDisabled, false, errHTTPBadRequestAttachmentURLInvalid
953976
}
954977
m.Attachment.URL = attach
955978
if m.Attachment.Name == "" {
@@ -967,19 +990,19 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
967990
}
968991
if icon != "" {
969992
if !urlRegex.MatchString(icon) {
970-
return false, false, "", "", false, false, errHTTPBadRequestIconURLInvalid
993+
return false, false, "", "", templateFeatureDisabled, false, errHTTPBadRequestIconURLInvalid
971994
}
972995
m.Icon = icon
973996
}
974997
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
975998
if s.smtpSender == nil && email != "" {
976-
return false, false, "", "", false, false, errHTTPBadRequestEmailDisabled
999+
return false, false, "", "", templateFeatureDisabled, false, errHTTPBadRequestEmailDisabled
9771000
}
9781001
call = readParam(r, "x-call", "call")
9791002
if call != "" && (s.config.TwilioAccount == "" || s.userManager == nil) {
980-
return false, false, "", "", false, false, errHTTPBadRequestPhoneCallsDisabled
1003+
return false, false, "", "", templateFeatureDisabled, false, errHTTPBadRequestPhoneCallsDisabled
9811004
} else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) {
982-
return false, false, "", "", false, false, errHTTPBadRequestPhoneNumberInvalid
1005+
return false, false, "", "", templateFeatureDisabled, false, errHTTPBadRequestPhoneNumberInvalid
9831006
}
9841007
messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
9851008
if messageStr != "" {
@@ -988,42 +1011,42 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
9881011
var e error
9891012
m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
9901013
if e != nil {
991-
return false, false, "", "", false, false, errHTTPBadRequestPriorityInvalid
1014+
return false, false, "", "", templateFeatureDisabled, false, errHTTPBadRequestPriorityInvalid
9921015
}
9931016
m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta")
9941017
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
9951018
if delayStr != "" {
9961019
if !cache {
997-
return false, false, "", "", false, false, errHTTPBadRequestDelayNoCache
1020+
return false, false, "", "", templateFeatureDisabled, false, errHTTPBadRequestDelayNoCache
9981021
}
9991022
if email != "" {
1000-
return false, false, "", "", false, false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
1023+
return false, false, "", "", templateFeatureDisabled, false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
10011024
}
10021025
if call != "" {
1003-
return false, false, "", "", false, false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet)
1026+
return false, false, "", "", templateFeatureDisabled, false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet)
10041027
}
10051028
delay, err := util.ParseFutureTime(delayStr, time.Now())
10061029
if err != nil {
1007-
return false, false, "", "", false, false, errHTTPBadRequestDelayCannotParse
1030+
return false, false, "", "", templateFeatureDisabled, false, errHTTPBadRequestDelayCannotParse
10081031
} else if delay.Unix() < time.Now().Add(s.config.MessageDelayMin).Unix() {
1009-
return false, false, "", "", false, false, errHTTPBadRequestDelayTooSmall
1032+
return false, false, "", "", templateFeatureDisabled, false, errHTTPBadRequestDelayTooSmall
10101033
} else if delay.Unix() > time.Now().Add(s.config.MessageDelayMax).Unix() {
1011-
return false, false, "", "", false, false, errHTTPBadRequestDelayTooLarge
1034+
return false, false, "", "", templateFeatureDisabled, false, errHTTPBadRequestDelayTooLarge
10121035
}
10131036
m.Time = delay.Unix()
10141037
}
10151038
actionsStr := readParam(r, "x-actions", "actions", "action")
10161039
if actionsStr != "" {
10171040
m.Actions, e = parseActions(actionsStr)
10181041
if e != nil {
1019-
return false, false, "", "", false, false, errHTTPBadRequestActionsInvalid.Wrap(e.Error())
1042+
return false, false, "", "", templateFeatureDisabled, false, errHTTPBadRequestActionsInvalid.Wrap(e.Error())
10201043
}
10211044
}
10221045
contentType, markdown := readParam(r, "content-type", "content_type"), readBoolParam(r, false, "x-markdown", "markdown", "md")
10231046
if markdown || strings.ToLower(contentType) == "text/markdown" {
10241047
m.ContentType = "text/markdown"
10251048
}
1026-
template = readBoolParam(r, false, "x-template", "template", "tpl")
1049+
template = parseTemplateFeature(readParam(r, "x-template", "template", "tpl"))
10271050
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
10281051
if unifiedpush {
10291052
firebase = false
@@ -1050,11 +1073,13 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
10501073
// Body must be attachment, because we passed a filename
10511074
// 5. curl -H "Template: yes" -T file.txt ntfy.sh/mytopic
10521075
// If templating is enabled, read up to 32k and treat message body as JSON
1053-
// 6. curl -T file.txt ntfy.sh/mytopic
1054-
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
1076+
// 6. curl -H "Template: server" -T file.txt ntfy.sh/mytopic?m=foobar
1077+
// Read foobar template from filesystem and treat message body as JSON
10551078
// 7. curl -T file.txt ntfy.sh/mytopic
1079+
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
1080+
// 8. curl -T file.txt ntfy.sh/mytopic
10561081
// In all other cases, mostly if file.txt is > message limit, treat it as an attachment
1057-
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, template, unifiedpush bool) error {
1082+
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, template templateFeature, unifiedpush bool) error {
10581083
if m.Event == pollRequestEvent { // Case 1
10591084
return s.handleBodyDiscard(body)
10601085
} else if unifiedpush {
@@ -1063,12 +1088,12 @@ func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body
10631088
return s.handleBodyAsTextMessage(m, body) // Case 3
10641089
} else if m.Attachment != nil && m.Attachment.Name != "" {
10651090
return s.handleBodyAsAttachment(r, v, m, body) // Case 4
1066-
} else if template {
1067-
return s.handleBodyAsTemplatedTextMessage(m, body) // Case 5
1091+
} else if template != templateFeatureDisabled {
1092+
return s.handleBodyAsTemplatedTextMessage(template, v, m, body) // Case 5&6
10681093
} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
1069-
return s.handleBodyAsTextMessage(m, body) // Case 6
1094+
return s.handleBodyAsTextMessage(m, body) // Case 7
10701095
}
1071-
return s.handleBodyAsAttachment(r, v, m, body) // Case 7
1096+
return s.handleBodyAsAttachment(r, v, m, body) // Case 8
10721097
}
10731098

10741099
func (s *Server) handleBodyDiscard(body *util.PeekedReadCloser) error {
@@ -1100,45 +1125,51 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser
11001125
return nil
11011126
}
11021127

1103-
func (s *Server) handleBodyAsTemplatedTextMessage(m *message, body *util.PeekedReadCloser) error {
1128+
func (s *Server) handleBodyAsTemplatedTextMessage(t templateFeature, v *visitor, m *message, body *util.PeekedReadCloser) error {
1129+
var internalError error
1130+
defer func() {
1131+
if internalError != nil {
1132+
logvm(v, m).Err(internalError).Info("Failed to render %s templated message", t)
1133+
}
1134+
}()
1135+
11041136
body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit))
11051137
if err != nil {
11061138
return err
11071139
} else if body.LimitReached {
11081140
return errHTTPEntityTooLargeJSONBody
11091141
}
1110-
peekedBody := strings.TrimSpace(string(body.PeekedBytes))
1111-
if m.Message, err = replaceTemplate(m.Message, peekedBody); err != nil {
1112-
return err
1142+
peekedBody := bytes.TrimSpace(body.PeekedBytes)
1143+
var data any
1144+
if err := json.Unmarshal(peekedBody, &data); err != nil {
1145+
return errHTTPBadRequestTemplateMessageNotJSON
11131146
}
1114-
if m.Title, err = replaceTemplate(m.Title, peekedBody); err != nil {
1115-
return err
1147+
1148+
if t == templateFeatureInline {
1149+
if m.Message, internalError, err = s.replaceTemplate(m.Message, data); err != nil {
1150+
return err
1151+
}
1152+
if m.Title, internalError, err = s.replaceTemplate(m.Title, data); err != nil {
1153+
return err
1154+
}
1155+
} else if t == templateFeatureServer {
1156+
if s.config.TemplateDirectory == "" {
1157+
return errHTTPBadRequestTemplatesNotEnabled
1158+
}
1159+
if m.Message, internalError, err = s.replaceTemplateFile(m.Message, data); err != nil {
1160+
return err
1161+
}
1162+
if m.Title, internalError, err = s.replaceTemplateFile(m.Title, data); err != nil {
1163+
return err
1164+
}
11161165
}
1166+
11171167
if len(m.Message) > s.config.MessageSizeLimit {
11181168
return errHTTPBadRequestTemplateMessageTooLarge
11191169
}
11201170
return nil
11211171
}
11221172

1123-
func replaceTemplate(tpl string, source string) (string, error) {
1124-
if templateDisallowedRegex.MatchString(tpl) {
1125-
return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
1126-
}
1127-
var data any
1128-
if err := json.Unmarshal([]byte(source), &data); err != nil {
1129-
return "", errHTTPBadRequestTemplateMessageNotJSON
1130-
}
1131-
t, err := template.New("").Parse(tpl)
1132-
if err != nil {
1133-
return "", errHTTPBadRequestTemplateInvalid
1134-
}
1135-
var buf bytes.Buffer
1136-
if err := t.Execute(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), data); err != nil {
1137-
return "", errHTTPBadRequestTemplateExecuteFailed
1138-
}
1139-
return buf.String(), nil
1140-
}
1141-
11421173
func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) error {
11431174
if s.fileCache == nil || s.config.BaseURL == "" || s.config.AttachmentCacheDir == "" {
11441175
return errHTTPBadRequestAttachmentsDisallowed.With(m)

server/server_middleware.go

+9
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,15 @@ func (s *Server) ensureStripeCustomer(next handleFunc) handleFunc {
121121
})
122122
}
123123

124+
func (s *Server) ensureTemplatesEnabled(next handleFunc) handleFunc {
125+
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
126+
if s.config.TemplateDirectory == "" {
127+
return errHTTPNotFound
128+
}
129+
return next(w, r, v)
130+
}
131+
}
132+
124133
func (s *Server) withAccountSync(next handleFunc) handleFunc {
125134
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
126135
err := next(w, r, v)

0 commit comments

Comments
 (0)