diff --git a/server/server.go b/server/server.go index b6e07b3b..cc197fbc 100644 --- a/server/server.go +++ b/server/server.go @@ -17,13 +17,11 @@ import ( "net/url" "os" "path" - "path/filepath" "regexp" "sort" "strconv" "strings" "sync" - "text/template" "time" "unicode/utf8" @@ -31,7 +29,6 @@ import ( "github.com/gorilla/websocket" "github.com/prometheus/client_golang/prometheus/promhttp" "golang.org/x/sync/errgroup" - "gopkg.in/yaml.v2" "heckel.io/ntfy/v2/attachment" "heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/db/pg" @@ -42,7 +39,6 @@ import ( "heckel.io/ntfy/v2/payments" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" - "heckel.io/ntfy/v2/util/sprig" "heckel.io/ntfy/v2/webpush" ) @@ -1285,114 +1281,6 @@ func (s *Server) handleBodyAsTextMessage(m *model.Message, body *util.PeekedRead return nil } -func (s *Server) handleBodyAsTemplatedTextMessage(m *model.Message, template templateMode, body *util.PeekedReadCloser, priorityStr string) error { - 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 template.FileMode() { - if err := s.renderTemplateFromFile(m, template.FileName(), peekedBody); err != nil { - return err - } - } else { - if err := s.renderTemplateFromParams(m, peekedBody, priorityStr); err != nil { - return err - } - } - if len(m.Title) > s.config.MessageSizeLimit || len(m.Message) > s.config.MessageSizeLimit { - return errHTTPBadRequestTemplateMessageTooLarge - } - return nil -} - -// renderTemplateFromFile transforms the JSON message body according to a template from the filesystem. -// The template file must be in the templates directory, or in the configured template directory. -func (s *Server) renderTemplateFromFile(m *model.Message, templateName, peekedBody string) error { - if !templateNameRegex.MatchString(templateName) { - return errHTTPBadRequestTemplateFileNotFound - } - templateContent, _ := templatesFs.ReadFile(filepath.Join(templatesDir, templateName+templateFileExtension)) // Read from the embedded filesystem first - if s.config.TemplateDir != "" { - if b, _ := os.ReadFile(filepath.Join(s.config.TemplateDir, templateName+templateFileExtension)); len(b) > 0 { - templateContent = b - } - } - if len(templateContent) == 0 { - return errHTTPBadRequestTemplateFileNotFound - } - var tpl templateFile - if err := yaml.Unmarshal(templateContent, &tpl); err != nil { - return errHTTPBadRequestTemplateFileInvalid - } - var err error - if tpl.Message != nil { - if m.Message, err = s.renderTemplate(templateName+" (message)", *tpl.Message, peekedBody); err != nil { - return err - } - } - if tpl.Title != nil { - if m.Title, err = s.renderTemplate(templateName+" (title)", *tpl.Title, peekedBody); err != nil { - return err - } - } - if tpl.Priority != nil { - renderedPriority, err := s.renderTemplate(templateName+" (priority)", *tpl.Priority, peekedBody) - if err != nil { - return err - } - if m.Priority, err = util.ParsePriority(renderedPriority); err != nil { - return errHTTPBadRequestPriorityInvalid - } - } - return nil -} - -// renderTemplateFromParams transforms the JSON message body according to the inline template in the -// message, title, and priority parameters. -func (s *Server) renderTemplateFromParams(m *model.Message, peekedBody string, priorityStr string) error { - var err error - if m.Message, err = s.renderTemplate("priority query parameter", m.Message, peekedBody); err != nil { - return err - } - if m.Title, err = s.renderTemplate("title query parameter", m.Title, peekedBody); err != nil { - return err - } - if priorityStr != "" { - renderedPriority, err := s.renderTemplate("priority query parameter", priorityStr, peekedBody) - if err != nil { - return err - } - if m.Priority, err = util.ParsePriority(renderedPriority); err != nil { - return errHTTPBadRequestPriorityInvalid - } - } - return nil -} - -// renderTemplate renders a template with the given JSON source data. -func (s *Server) renderTemplate(name, tpl, 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("").Funcs(sprig.TxtFuncMap()).Parse(tpl) - if err != nil { - return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error()) - } - var buf bytes.Buffer - limitWriter := util.NewLimitWriter(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), util.NewFixedLimiter(templateMaxOutputBytes)) - if err := t.Execute(limitWriter, data); err != nil { - return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("template %s: %s", name, err.Error()) - } - return strings.TrimSpace(strings.ReplaceAll(buf.String(), "\\n", "\n")), nil // replace any remaining "\n" (those outside of template curly braces) with newlines -} - func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *model.Message, body *util.PeekedReadCloser) error { if s.attachment == nil || s.config.BaseURL == "" { return errHTTPBadRequestAttachmentsDisallowed.With(m) diff --git a/server/server_template.go b/server/server_template.go new file mode 100644 index 00000000..bf5ae3e4 --- /dev/null +++ b/server/server_template.go @@ -0,0 +1,123 @@ +package server + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "text/template" + + "gopkg.in/yaml.v2" + "heckel.io/ntfy/v2/model" + "heckel.io/ntfy/v2/util" + "heckel.io/ntfy/v2/util/sprig" +) + +func (s *Server) handleBodyAsTemplatedTextMessage(m *model.Message, template templateMode, body *util.PeekedReadCloser, priorityStr string) error { + 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 template.FileMode() { + if err := s.renderTemplateFromFile(m, template.FileName(), peekedBody); err != nil { + return err + } + } else { + if err := s.renderTemplateFromParams(m, peekedBody, priorityStr); err != nil { + return err + } + } + if len(m.Title) > s.config.MessageSizeLimit || len(m.Message) > s.config.MessageSizeLimit { + return errHTTPBadRequestTemplateMessageTooLarge + } + return nil +} + +// renderTemplateFromFile transforms the JSON message body according to a template from the filesystem. +// The template file must be in the templates directory, or in the configured template directory. +func (s *Server) renderTemplateFromFile(m *model.Message, templateName, peekedBody string) error { + if !templateNameRegex.MatchString(templateName) { + return errHTTPBadRequestTemplateFileNotFound + } + templateContent, _ := templatesFs.ReadFile(filepath.Join(templatesDir, templateName+templateFileExtension)) // Read from the embedded filesystem first + if s.config.TemplateDir != "" { + if b, _ := os.ReadFile(filepath.Join(s.config.TemplateDir, templateName+templateFileExtension)); len(b) > 0 { + templateContent = b + } + } + if len(templateContent) == 0 { + return errHTTPBadRequestTemplateFileNotFound + } + var tpl templateFile + if err := yaml.Unmarshal(templateContent, &tpl); err != nil { + return errHTTPBadRequestTemplateFileInvalid + } + var err error + if tpl.Message != nil { + if m.Message, err = s.renderTemplate(templateName+" (message)", *tpl.Message, peekedBody); err != nil { + return err + } + } + if tpl.Title != nil { + if m.Title, err = s.renderTemplate(templateName+" (title)", *tpl.Title, peekedBody); err != nil { + return err + } + } + if tpl.Priority != nil { + renderedPriority, err := s.renderTemplate(templateName+" (priority)", *tpl.Priority, peekedBody) + if err != nil { + return err + } + if m.Priority, err = util.ParsePriority(renderedPriority); err != nil { + return errHTTPBadRequestPriorityInvalid + } + } + return nil +} + +// renderTemplateFromParams transforms the JSON message body according to the inline template in the +// message, title, and priority parameters. +func (s *Server) renderTemplateFromParams(m *model.Message, peekedBody string, priorityStr string) error { + var err error + if m.Message, err = s.renderTemplate("priority query parameter", m.Message, peekedBody); err != nil { + return err + } + if m.Title, err = s.renderTemplate("title query parameter", m.Title, peekedBody); err != nil { + return err + } + if priorityStr != "" { + renderedPriority, err := s.renderTemplate("priority query parameter", priorityStr, peekedBody) + if err != nil { + return err + } + if m.Priority, err = util.ParsePriority(renderedPriority); err != nil { + return errHTTPBadRequestPriorityInvalid + } + } + return nil +} + +// renderTemplate renders a template with the given JSON source data. +func (s *Server) renderTemplate(name, tpl, 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("").Funcs(sprig.TxtFuncMap()).Parse(tpl) + if err != nil { + return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error()) + } + var buf bytes.Buffer + limitWriter := util.NewLimitWriter(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), util.NewFixedLimiter(templateMaxOutputBytes)) + if err := t.Execute(limitWriter, data); err != nil { + return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("template %s: %s", name, err.Error()) + } + return strings.TrimSpace(strings.ReplaceAll(buf.String(), "\\n", "\n")), nil // replace any remaining "\n" (those outside of template curly braces) with newlines +}