binwiederhier hace 7 meses
padre
commit
b1e935da45
Se han modificado 7 ficheros con 142 adiciones y 65 borrados
  1. 3 3
      cmd/serve.go
  2. 2 2
      server/config.go
  3. 3 0
      server/errors.go
  4. 84 59
      server/server.go
  5. 23 0
      server/templates/github.yml
  6. 9 0
      server/templates/grafana.yml
  7. 18 1
      server/types.go

+ 3 - 3
cmd/serve.go

@@ -56,6 +56,7 @@ var flagsServe = append(
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentTotalSizeLimit), Usage: "limit of the on-disk attachment cache"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentFileSizeLimit), Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: util.FormatDuration(server.DefaultAttachmentExpiryDuration), Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}),
+	altsrc.NewStringFlag(&cli.StringFlag{Name: "template-dir", Aliases: []string{"template_dir"}, EnvVars: []string{"NTFY_TEMPLATE_DIR"}, Usage: "directory to load named message templates from"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: util.FormatDuration(server.DefaultKeepaliveInterval), Usage: "interval of keepalive messages"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: util.FormatDuration(server.DefaultManagerInterval), Usage: "interval of for message pruning and stats printing"}),
 	altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "disallowed-topics", Aliases: []string{"disallowed_topics"}, EnvVars: []string{"NTFY_DISALLOWED_TOPICS"}, Usage: "topics that are not allowed to be used"}),
@@ -107,7 +108,6 @@ 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{
@@ -162,6 +162,7 @@ func execServe(c *cli.Context) error {
 	attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit")
 	attachmentFileSizeLimitStr := c.String("attachment-file-size-limit")
 	attachmentExpiryDurationStr := c.String("attachment-expiry-duration")
+	templateDir := c.String("template-dir")
 	keepaliveIntervalStr := c.String("keepalive-interval")
 	managerIntervalStr := c.String("manager-interval")
 	disallowedTopics := c.StringSlice("disallowed-topics")
@@ -206,7 +207,6 @@ 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)
@@ -412,6 +412,7 @@ func execServe(c *cli.Context) error {
 	conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
 	conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
 	conf.AttachmentExpiryDuration = attachmentExpiryDuration
+	conf.TemplateDir = templateDir
 	conf.KeepaliveInterval = keepaliveInterval
 	conf.ManagerInterval = managerInterval
 	conf.DisallowedTopics = disallowedTopics
@@ -463,7 +464,6 @@ 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

+ 2 - 2
server/config.go

@@ -99,6 +99,7 @@ type Config struct {
 	AttachmentTotalSizeLimit             int64
 	AttachmentFileSizeLimit              int64
 	AttachmentExpiryDuration             time.Duration
+	TemplateDir                          string // Directory to load named templates from
 	KeepaliveInterval                    time.Duration
 	ManagerInterval                      time.Duration
 	DisallowedTopics                     []string
@@ -167,7 +168,6 @@ 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
@@ -258,6 +258,6 @@ func NewConfig() *Config {
 		WebPushEmailAddress:                  "",
 		WebPushExpiryDuration:                DefaultWebPushExpiryDuration,
 		WebPushExpiryWarningDuration:         DefaultWebPushExpiryWarningDuration,
-		TemplateDirectory:                    "",
+		TemplateDir:                          "",
 	}
 }

+ 3 - 0
server/errors.go

@@ -123,6 +123,9 @@ 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}
+	errHTTPBadRequestTemplateDirectoryNotConfigured  = &errHTTP{40046, http.StatusBadRequest, "invalid request: template directory not configured", "https://ntfy.sh/docs/publish/#message-templating", nil}
+	errHTTPBadRequestTemplateFileNotFound            = &errHTTP{40047, http.StatusBadRequest, "invalid request: template file not found", "https://ntfy.sh/docs/publish/#message-templating", nil}
+	errHTTPBadRequestTemplateFileInvalid             = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", 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}

+ 84 - 59
server/server.go

@@ -9,6 +9,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"gopkg.in/yaml.v2"
 	"io"
 	"net"
 	"net/http"
@@ -56,13 +57,12 @@ type Server struct {
 	userManager       *user.Manager                       // Might be nil!
 	messageCache      *messageCache                       // Database that stores the messages
 	webPush           *webPushStore                       // Database that stores web push subscriptions
-	fileCache         *fileCache                          // File system based cache that stores attachments
+	fileCache         *fileCache                          // Name system based cache that stores attachments
 	stripe            stripeAPI                           // Stripe API, can be replaced with a mock
 	priceCache        *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!)
 	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
@@ -122,6 +122,15 @@ var (
 	//go:embed docs
 	docsStaticFs     embed.FS
 	docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs}
+
+	//go:embed templates
+	templatesFs  embed.FS // Contains template config files (e.g. grafana.yml, github.yml, ...)
+	templatesDir = "templates"
+
+	// templateDisallowedRegex tests a template for disallowed expressions. While not really dangerous, they
+	// are not useful, and seem potentially troublesome.
+	templateDisallowedRegex = regexp.MustCompile(`(?m)\{\{-?\s*(call|template|define)\b`)
+	templateNameRegex       = regexp.MustCompile(`^[-_A-Za-z0-9]+$`)
 )
 
 const (
@@ -131,17 +140,12 @@ const (
 	newMessageBody           = "New message"             // Used in poll requests as generic message
 	defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
 	encodingBase64           = "base64"                  // Used mainly for binary UnifiedPush messages
-	jsonBodyBytesLimit       = 32768                     // Max number of bytes for a request bodys (unless MessageLimit is higher)
+	jsonBodyBytesLimit       = 131072                    // Max number of bytes for a request bodys (unless MessageLimit is higher)
 	unifiedPushTopicPrefix   = "up"                      // Temporarily, we rate limit all "up*" topics based on the subscriber
 	unifiedPushTopicLength   = 14                        // Length of UnifiedPush topics, including the "up" part
 	messagesHistoryMax       = 10                        // Number of message count values to keep in memory
-	templateMaxExecutionTime = 100 * time.Millisecond
-)
-
-var (
-	// templateDisallowedRegex tests a template for disallowed expressions. While not really dangerous, they
-	// are not useful, and seem potentially troublesome.
-	templateDisallowedRegex = regexp.MustCompile(`(?m)\{\{-?\s*(call|template|define)\b`)
+	templateMaxExecutionTime = 100 * time.Millisecond    // Maximum time a template can take to execute, used to prevent DoS attacks
+	templateFileExtension    = ".yml"                    // Template files must end with this extension
 )
 
 // WebSocket constants
@@ -223,16 +227,8 @@ 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
 }
 
@@ -946,7 +942,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 templateMode, 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")
@@ -962,7 +958,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, "", "", "", false, errHTTPBadRequestAttachmentURLInvalid
 		}
 		m.Attachment.URL = attach
 		if m.Attachment.Name == "" {
@@ -980,19 +976,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, "", "", "", 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, "", "", "", 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, "", "", "", false, errHTTPBadRequestPhoneCallsDisabled
 	} else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) {
-		return false, false, "", "", false, false, errHTTPBadRequestPhoneNumberInvalid
+		return false, false, "", "", "", false, errHTTPBadRequestPhoneNumberInvalid
 	}
 	messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
 	if messageStr != "" {
@@ -1001,27 +997,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, "", "", "", 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, "", "", "", false, errHTTPBadRequestDelayNoCache
 		}
 		if email != "" {
-			return false, false, "", "", false, false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
+			return false, false, "", "", "", 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, "", "", "", 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, "", "", "", false, errHTTPBadRequestDelayCannotParse
 		} else if delay.Unix() < time.Now().Add(s.config.MessageDelayMin).Unix() {
-			return false, false, "", "", false, false, errHTTPBadRequestDelayTooSmall
+			return false, false, "", "", "", false, errHTTPBadRequestDelayTooSmall
 		} else if delay.Unix() > time.Now().Add(s.config.MessageDelayMax).Unix() {
-			return false, false, "", "", false, false, errHTTPBadRequestDelayTooLarge
+			return false, false, "", "", "", false, errHTTPBadRequestDelayTooLarge
 		}
 		m.Time = delay.Unix()
 	}
@@ -1029,14 +1025,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("%s", e.Error())
+			return false, false, "", "", "", false, errHTTPBadRequestActionsInvalid.Wrap("%s", 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 = templateMode(readParam(r, "x-template", "template", "tpl"))
 	unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
 	contentEncoding := readParam(r, "content-encoding")
 	if unifiedpush || contentEncoding == "aes128gcm" {
@@ -1068,7 +1064,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
 //     If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
 //  7. 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 templateMode, unifiedpush bool) error {
 	if m.Event == pollRequestEvent { // Case 1
 		return s.handleBodyDiscard(body)
 	} else if unifiedpush {
@@ -1077,8 +1073,8 @@ 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.Enabled() {
+		return s.handleBodyAsTemplatedTextMessage(m, template, body) // Case 5
 	} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
 		return s.handleBodyAsTextMessage(m, body) // Case 6
 	}
@@ -1114,7 +1110,7 @@ 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(m *message, template templateMode, body *util.PeekedReadCloser) error {
 	body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit))
 	if err != nil {
 		return err
@@ -1122,15 +1118,60 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *message, body *util.PeekedR
 		return errHTTPEntityTooLargeJSONBody
 	}
 	peekedBody := strings.TrimSpace(string(body.PeekedBytes))
+	if templateName := template.Name(); templateName != "" {
+		if err := s.replaceTemplateFromFile(m, templateName, peekedBody); err != nil {
+			return err
+		}
+	} else {
+		if err := s.replaceTemplateFromParams(m, peekedBody); err != nil {
+			return err
+		}
+	}
+	if len(m.Message) > s.config.MessageSizeLimit {
+		return errHTTPBadRequestTemplateMessageTooLarge
+	}
+	return nil
+}
+
+func (s *Server) replaceTemplateFromFile(m *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.replaceTemplate(*tpl.Message, peekedBody); err != nil {
+			return err
+		}
+	}
+	if tpl.Title != nil {
+		if m.Title, err = s.replaceTemplate(*tpl.Title, peekedBody); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (s *Server) replaceTemplateFromParams(m *message, peekedBody string) error {
+	var err error
 	if m.Message, err = s.replaceTemplate(m.Message, peekedBody); err != nil {
 		return err
 	}
 	if m.Title, err = s.replaceTemplate(m.Title, peekedBody); err != nil {
 		return err
 	}
-	if len(m.Message) > s.config.MessageSizeLimit {
-		return errHTTPBadRequestTemplateMessageTooLarge
-	}
 	return nil
 }
 
@@ -1138,35 +1179,19 @@ 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
 	}
 	t, err := template.New("").Funcs(sprig.FuncMap()).Parse(tpl)
 	if err != nil {
-		return "", errHTTPBadRequestTemplateInvalid
+		return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error())
 	}
 	var buf bytes.Buffer
 	if err := t.Execute(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), data); err != nil {
-		return "", errHTTPBadRequestTemplateExecuteFailed
+		return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("%s", err.Error())
 	}
-	return buf.String(), nil
+	return strings.TrimSpace(buf.String()), nil
 }
 
 func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) error {

+ 23 - 0
server/templates/github.yml

@@ -0,0 +1,23 @@
+message: |
+    {{- if .pull_request }}
+    🔀 PR {{ .action }}: #{{ .pull_request.number }} — {{ .pull_request.title }}
+    📦 {{ .repository.full_name }}
+    👤 {{ .pull_request.user.login }}
+    🌿 {{ .pull_request.head.ref }} → {{ .pull_request.base.ref }}
+    🔗 {{ .pull_request.html_url }}
+    📝 {{ .pull_request.body | default "(no description)" }}
+    {{- else if and .starred_at (eq .action "created")}}
+    ⭐ {{ .sender.login }} starred {{ .repository.full_name }}
+    📦 {{ .repository.description | default "(no description)" }}
+    🔗 {{ .repository.html_url }}
+    📅 {{ .starred_at }}
+    {{- else if and .comment (eq .action "created") }}
+    💬 New comment on issue #{{ .issue.number }} — {{ .issue.title }}
+    📦 {{ .repository.full_name }}
+    👤 {{ .comment.user.login }}
+    🔗 {{ .comment.html_url }}
+    📝 {{ .comment.body | default "(no comment body)" }}
+    {{- else }}
+    {{ fail "Unsupported GitHub event type or action." }}
+    {{- end }}
+

+ 9 - 0
server/templates/grafana.yml

@@ -0,0 +1,9 @@
+message: |
+    {{if .alerts}}
+        {{.alerts | len}} alert(s) triggered
+    {{else}}
+        No alerts triggered.
+    {{end}}
+title: |
+  ⚠️ Grafana alert: {{.title}}
+

+ 18 - 1
server/types.go

@@ -7,7 +7,6 @@ import (
 
 	"heckel.io/ntfy/v2/log"
 	"heckel.io/ntfy/v2/user"
-
 	"heckel.io/ntfy/v2/util"
 )
 
@@ -246,6 +245,24 @@ func (q *queryFilter) Pass(msg *message) bool {
 	return true
 }
 
+type templateMode string
+
+func (t templateMode) Enabled() bool {
+	return t != ""
+}
+
+func (t templateMode) Name() string {
+	if isBoolValue(string(t)) {
+		return ""
+	}
+	return string(t)
+}
+
+type templateFile struct {
+	Title   *string `yaml:"title"`
+	Message *string `yaml:"message"`
+}
+
 type apiHealthResponse struct {
 	Healthy bool `json:"healthy"`
 }