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:
parent
630f2957de
commit
823070bf7e
12 changed files with 599 additions and 48 deletions
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
||||
|
|
|
@ -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: "",
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
127
server/server.go
127
server/server.go
|
@ -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)
|
||||
|
|
|
@ -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
164
server/server_templates.go
Normal 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
|
||||
}
|
178
server/server_templates_test.go
Normal file
178
server/server_templates_test.go
Normal 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")
|
||||
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -539,3 +539,7 @@ type webManifestIcon struct {
|
|||
Sizes string `json:"sizes"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type templateNamesResponse struct {
|
||||
Templates []string `json:"templates"`
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"))
|
||||
|
|
Loading…
Reference in a new issue