diff --git a/cmd/serve.go b/cmd/serve.go index ef4d98d5..d762a7c6 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -107,6 +107,7 @@ var flagsServe = append( 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: "web-push-expiry-duration", Aliases: []string{"web_push_expiry_duration"}, EnvVars: []string{"NTFY_WEB_PUSH_EXPIRY_DURATION"}, Value: util.FormatDuration(server.DefaultWebPushExpiryDuration), Usage: "automatically expire unused subscriptions after this time"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-expiry-warning-duration", Aliases: []string{"web_push_expiry_warning_duration"}, EnvVars: []string{"NTFY_WEB_PUSH_EXPIRY_WARNING_DURATION"}, Value: util.FormatDuration(server.DefaultWebPushExpiryWarningDuration), Usage: "send web push warning notification after this time before expiring unused subscriptions"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "template-directory", Aliases: []string{"template_directory"}, EnvVars: []string{"NTFY_TEMPLATE_DIRECTORY"}, Usage: "directory to load named templates from"}), ) var cmdServe = &cli.Command{ @@ -205,6 +206,7 @@ func execServe(c *cli.Context) error { metricsListenHTTP := c.String("metrics-listen-http") enableMetrics := c.Bool("enable-metrics") || metricsListenHTTP != "" profileListenHTTP := c.String("profile-listen-http") + templateDirectory := c.String("template-directory") // Convert durations cacheDuration, err := util.ParseDuration(cacheDurationStr) @@ -461,6 +463,7 @@ func execServe(c *cli.Context) error { conf.WebPushStartupQueries = webPushStartupQueries conf.WebPushExpiryDuration = webPushExpiryDuration conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration + conf.TemplateDirectory = templateDirectory conf.Version = c.App.Version // Set up hot-reloading of config diff --git a/server/config.go b/server/config.go index 59b11c16..46848fe5 100644 --- a/server/config.go +++ b/server/config.go @@ -167,6 +167,7 @@ type Config struct { WebPushExpiryDuration time.Duration WebPushExpiryWarningDuration time.Duration Version string // injected by App + TemplateDirectory string // Directory to load named templates from } // NewConfig instantiates a default new server config @@ -257,5 +258,6 @@ func NewConfig() *Config { WebPushEmailAddress: "", WebPushExpiryDuration: DefaultWebPushExpiryDuration, WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration, + TemplateDirectory: "", } } diff --git a/server/server.go b/server/server.go index 7e5fbb94..51c56f3e 100644 --- a/server/server.go +++ b/server/server.go @@ -62,6 +62,7 @@ type Server struct { metricsHandler http.Handler // Handles /metrics if enable-metrics set, and listen-metrics-http not set closeChan chan bool mu sync.RWMutex + templates map[string]*template.Template // Loaded named templates } // handleFunc extends the normal http.HandlerFunc to be able to easily return errors @@ -222,8 +223,16 @@ func New(conf *Config) (*Server, error) { messagesHistory: []int64{messages}, visitors: make(map[string]*visitor), stripe: stripe, + templates: make(map[string]*template.Template), } s.priceCache = util.NewLookupCache(s.fetchStripePrices, conf.StripePriceCacheDuration) + if conf.TemplateDirectory != "" { + tmpls, err := loadTemplatesFromDir(conf.TemplateDirectory) + if err != nil { + return nil, fmt.Errorf("failed to load templates from %s: %w", conf.TemplateDirectory, err) + } + s.templates = tmpls + } return s, nil } @@ -1113,10 +1122,10 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *message, body *util.PeekedR return errHTTPEntityTooLargeJSONBody } peekedBody := strings.TrimSpace(string(body.PeekedBytes)) - if m.Message, err = replaceTemplate(m.Message, peekedBody); err != nil { + if m.Message, err = s.replaceTemplate(m.Message, peekedBody); err != nil { return err } - if m.Title, err = replaceTemplate(m.Title, peekedBody); err != nil { + if m.Title, err = s.replaceTemplate(m.Title, peekedBody); err != nil { return err } if len(m.Message) > s.config.MessageSizeLimit { @@ -1125,10 +1134,26 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *message, body *util.PeekedR return nil } -func replaceTemplate(tpl string, source string) (string, error) { +func (s *Server) replaceTemplate(tpl string, source string) (string, error) { if templateDisallowedRegex.MatchString(tpl) { return "", errHTTPBadRequestTemplateDisallowedFunctionCalls } + if strings.HasPrefix(tpl, "@") { + name := strings.TrimPrefix(tpl, "@") + t, ok := s.templates[name] + if !ok { + return "", fmt.Errorf("template '@%s' not found", name) + } + var data any + if err := json.Unmarshal([]byte(source), &data); err != nil { + return "", errHTTPBadRequestTemplateMessageNotJSON + } + var buf bytes.Buffer + if err := t.Execute(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), data); err != nil { + return "", errHTTPBadRequestTemplateExecuteFailed + } + return buf.String(), nil + } var data any if err := json.Unmarshal([]byte(source), &data); err != nil { return "", errHTTPBadRequestTemplateMessageNotJSON @@ -2061,3 +2086,32 @@ func (s *Server) updateAndWriteStats(messagesCount int64) { } }() } + +func loadTemplatesFromDir(dir string) (map[string]*template.Template, error) { + templates := make(map[string]*template.Template) + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasSuffix(name, ".tmpl") { + continue + } + path := filepath.Join(dir, name) + content, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read template %s: %w", name, err) + } + tmpl, err := template.New(name).Funcs(sprig.FuncMap()).Parse(string(content)) + if err != nil { + return nil, fmt.Errorf("failed to parse template %s: %w", name, err) + } + base := strings.TrimSuffix(name, ".tmpl") + templates[base] = tmpl + } + return templates, nil +}