TEmplate dir
This commit is contained in:
@@ -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-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-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: "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: "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.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"}),
|
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-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-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: "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{
|
var cmdServe = &cli.Command{
|
||||||
@@ -162,6 +162,7 @@ func execServe(c *cli.Context) error {
|
|||||||
attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit")
|
attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit")
|
||||||
attachmentFileSizeLimitStr := c.String("attachment-file-size-limit")
|
attachmentFileSizeLimitStr := c.String("attachment-file-size-limit")
|
||||||
attachmentExpiryDurationStr := c.String("attachment-expiry-duration")
|
attachmentExpiryDurationStr := c.String("attachment-expiry-duration")
|
||||||
|
templateDir := c.String("template-dir")
|
||||||
keepaliveIntervalStr := c.String("keepalive-interval")
|
keepaliveIntervalStr := c.String("keepalive-interval")
|
||||||
managerIntervalStr := c.String("manager-interval")
|
managerIntervalStr := c.String("manager-interval")
|
||||||
disallowedTopics := c.StringSlice("disallowed-topics")
|
disallowedTopics := c.StringSlice("disallowed-topics")
|
||||||
@@ -206,7 +207,6 @@ func execServe(c *cli.Context) error {
|
|||||||
metricsListenHTTP := c.String("metrics-listen-http")
|
metricsListenHTTP := c.String("metrics-listen-http")
|
||||||
enableMetrics := c.Bool("enable-metrics") || metricsListenHTTP != ""
|
enableMetrics := c.Bool("enable-metrics") || metricsListenHTTP != ""
|
||||||
profileListenHTTP := c.String("profile-listen-http")
|
profileListenHTTP := c.String("profile-listen-http")
|
||||||
templateDirectory := c.String("template-directory")
|
|
||||||
|
|
||||||
// Convert durations
|
// Convert durations
|
||||||
cacheDuration, err := util.ParseDuration(cacheDurationStr)
|
cacheDuration, err := util.ParseDuration(cacheDurationStr)
|
||||||
@@ -412,6 +412,7 @@ func execServe(c *cli.Context) error {
|
|||||||
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
|
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
|
||||||
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
|
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
|
||||||
conf.AttachmentExpiryDuration = attachmentExpiryDuration
|
conf.AttachmentExpiryDuration = attachmentExpiryDuration
|
||||||
|
conf.TemplateDir = templateDir
|
||||||
conf.KeepaliveInterval = keepaliveInterval
|
conf.KeepaliveInterval = keepaliveInterval
|
||||||
conf.ManagerInterval = managerInterval
|
conf.ManagerInterval = managerInterval
|
||||||
conf.DisallowedTopics = disallowedTopics
|
conf.DisallowedTopics = disallowedTopics
|
||||||
@@ -463,7 +464,6 @@ func execServe(c *cli.Context) error {
|
|||||||
conf.WebPushStartupQueries = webPushStartupQueries
|
conf.WebPushStartupQueries = webPushStartupQueries
|
||||||
conf.WebPushExpiryDuration = webPushExpiryDuration
|
conf.WebPushExpiryDuration = webPushExpiryDuration
|
||||||
conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration
|
conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration
|
||||||
conf.TemplateDirectory = templateDirectory
|
|
||||||
conf.Version = c.App.Version
|
conf.Version = c.App.Version
|
||||||
|
|
||||||
// Set up hot-reloading of config
|
// Set up hot-reloading of config
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ type Config struct {
|
|||||||
AttachmentTotalSizeLimit int64
|
AttachmentTotalSizeLimit int64
|
||||||
AttachmentFileSizeLimit int64
|
AttachmentFileSizeLimit int64
|
||||||
AttachmentExpiryDuration time.Duration
|
AttachmentExpiryDuration time.Duration
|
||||||
|
TemplateDir string // Directory to load named templates from
|
||||||
KeepaliveInterval time.Duration
|
KeepaliveInterval time.Duration
|
||||||
ManagerInterval time.Duration
|
ManagerInterval time.Duration
|
||||||
DisallowedTopics []string
|
DisallowedTopics []string
|
||||||
@@ -167,7 +168,6 @@ type Config struct {
|
|||||||
WebPushExpiryDuration time.Duration
|
WebPushExpiryDuration time.Duration
|
||||||
WebPushExpiryWarningDuration time.Duration
|
WebPushExpiryWarningDuration time.Duration
|
||||||
Version string // injected by App
|
Version string // injected by App
|
||||||
TemplateDirectory string // Directory to load named templates from
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConfig instantiates a default new server config
|
// NewConfig instantiates a default new server config
|
||||||
@@ -258,6 +258,6 @@ func NewConfig() *Config {
|
|||||||
WebPushEmailAddress: "",
|
WebPushEmailAddress: "",
|
||||||
WebPushExpiryDuration: DefaultWebPushExpiryDuration,
|
WebPushExpiryDuration: DefaultWebPushExpiryDuration,
|
||||||
WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration,
|
WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration,
|
||||||
TemplateDirectory: "",
|
TemplateDir: "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
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}
|
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}
|
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}
|
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
|
||||||
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", 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}
|
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||||
|
|||||||
143
server/server.go
143
server/server.go
@@ -9,6 +9,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"gopkg.in/yaml.v2"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -56,13 +57,12 @@ type Server struct {
|
|||||||
userManager *user.Manager // Might be nil!
|
userManager *user.Manager // Might be nil!
|
||||||
messageCache *messageCache // Database that stores the messages
|
messageCache *messageCache // Database that stores the messages
|
||||||
webPush *webPushStore // Database that stores web push subscriptions
|
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
|
stripe stripeAPI // Stripe API, can be replaced with a mock
|
||||||
priceCache *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!)
|
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
|
metricsHandler http.Handler // Handles /metrics if enable-metrics set, and listen-metrics-http not set
|
||||||
closeChan chan bool
|
closeChan chan bool
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
templates map[string]*template.Template // Loaded named templates
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleFunc extends the normal http.HandlerFunc to be able to easily return errors
|
// handleFunc extends the normal http.HandlerFunc to be able to easily return errors
|
||||||
@@ -122,6 +122,15 @@ var (
|
|||||||
//go:embed docs
|
//go:embed docs
|
||||||
docsStaticFs embed.FS
|
docsStaticFs embed.FS
|
||||||
docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs}
|
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 (
|
const (
|
||||||
@@ -131,17 +140,12 @@ const (
|
|||||||
newMessageBody = "New message" // Used in poll requests as generic message
|
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
|
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
|
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
|
unifiedPushTopicPrefix = "up" // Temporarily, we rate limit all "up*" topics based on the subscriber
|
||||||
unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part
|
unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part
|
||||||
messagesHistoryMax = 10 // Number of message count values to keep in memory
|
messagesHistoryMax = 10 // Number of message count values to keep in memory
|
||||||
templateMaxExecutionTime = 100 * time.Millisecond
|
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
|
||||||
|
|
||||||
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`)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// WebSocket constants
|
// WebSocket constants
|
||||||
@@ -223,16 +227,8 @@ func New(conf *Config) (*Server, error) {
|
|||||||
messagesHistory: []int64{messages},
|
messagesHistory: []int64{messages},
|
||||||
visitors: make(map[string]*visitor),
|
visitors: make(map[string]*visitor),
|
||||||
stripe: stripe,
|
stripe: stripe,
|
||||||
templates: make(map[string]*template.Template),
|
|
||||||
}
|
}
|
||||||
s.priceCache = util.NewLookupCache(s.fetchStripePrices, conf.StripePriceCacheDuration)
|
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
|
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")
|
cache = readBoolParam(r, true, "x-cache", "cache")
|
||||||
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
||||||
m.Title = readParam(r, "x-title", "title", "t")
|
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 attach != "" {
|
||||||
if !urlRegex.MatchString(attach) {
|
if !urlRegex.MatchString(attach) {
|
||||||
return false, false, "", "", false, false, errHTTPBadRequestAttachmentURLInvalid
|
return false, false, "", "", "", false, errHTTPBadRequestAttachmentURLInvalid
|
||||||
}
|
}
|
||||||
m.Attachment.URL = attach
|
m.Attachment.URL = attach
|
||||||
if m.Attachment.Name == "" {
|
if m.Attachment.Name == "" {
|
||||||
@@ -980,19 +976,19 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
|||||||
}
|
}
|
||||||
if icon != "" {
|
if icon != "" {
|
||||||
if !urlRegex.MatchString(icon) {
|
if !urlRegex.MatchString(icon) {
|
||||||
return false, false, "", "", false, false, errHTTPBadRequestIconURLInvalid
|
return false, false, "", "", "", false, errHTTPBadRequestIconURLInvalid
|
||||||
}
|
}
|
||||||
m.Icon = icon
|
m.Icon = icon
|
||||||
}
|
}
|
||||||
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
|
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
|
||||||
if s.smtpSender == nil && email != "" {
|
if s.smtpSender == nil && email != "" {
|
||||||
return false, false, "", "", false, false, errHTTPBadRequestEmailDisabled
|
return false, false, "", "", "", false, errHTTPBadRequestEmailDisabled
|
||||||
}
|
}
|
||||||
call = readParam(r, "x-call", "call")
|
call = readParam(r, "x-call", "call")
|
||||||
if call != "" && (s.config.TwilioAccount == "" || s.userManager == nil) {
|
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) {
|
} 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")
|
messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
|
||||||
if messageStr != "" {
|
if messageStr != "" {
|
||||||
@@ -1001,27 +997,27 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
|||||||
var e error
|
var e error
|
||||||
m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
|
m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return false, false, "", "", false, false, errHTTPBadRequestPriorityInvalid
|
return false, false, "", "", "", false, errHTTPBadRequestPriorityInvalid
|
||||||
}
|
}
|
||||||
m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta")
|
m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta")
|
||||||
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
|
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
|
||||||
if delayStr != "" {
|
if delayStr != "" {
|
||||||
if !cache {
|
if !cache {
|
||||||
return false, false, "", "", false, false, errHTTPBadRequestDelayNoCache
|
return false, false, "", "", "", false, errHTTPBadRequestDelayNoCache
|
||||||
}
|
}
|
||||||
if email != "" {
|
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 != "" {
|
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())
|
delay, err := util.ParseFutureTime(delayStr, time.Now())
|
||||||
if err != nil {
|
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() {
|
} 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() {
|
} 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()
|
m.Time = delay.Unix()
|
||||||
}
|
}
|
||||||
@@ -1029,14 +1025,14 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
|||||||
if actionsStr != "" {
|
if actionsStr != "" {
|
||||||
m.Actions, e = parseActions(actionsStr)
|
m.Actions, e = parseActions(actionsStr)
|
||||||
if e != nil {
|
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")
|
contentType, markdown := readParam(r, "content-type", "content_type"), readBoolParam(r, false, "x-markdown", "markdown", "md")
|
||||||
if markdown || strings.ToLower(contentType) == "text/markdown" {
|
if markdown || strings.ToLower(contentType) == "text/markdown" {
|
||||||
m.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!
|
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
|
||||||
contentEncoding := readParam(r, "content-encoding")
|
contentEncoding := readParam(r, "content-encoding")
|
||||||
if unifiedpush || contentEncoding == "aes128gcm" {
|
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
|
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
|
||||||
// 7. curl -T file.txt ntfy.sh/mytopic
|
// 7. curl -T file.txt ntfy.sh/mytopic
|
||||||
// In all other cases, mostly if file.txt is > message limit, treat it as an attachment
|
// 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
|
if m.Event == pollRequestEvent { // Case 1
|
||||||
return s.handleBodyDiscard(body)
|
return s.handleBodyDiscard(body)
|
||||||
} else if unifiedpush {
|
} 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
|
return s.handleBodyAsTextMessage(m, body) // Case 3
|
||||||
} else if m.Attachment != nil && m.Attachment.Name != "" {
|
} else if m.Attachment != nil && m.Attachment.Name != "" {
|
||||||
return s.handleBodyAsAttachment(r, v, m, body) // Case 4
|
return s.handleBodyAsAttachment(r, v, m, body) // Case 4
|
||||||
} else if template {
|
} else if template.Enabled() {
|
||||||
return s.handleBodyAsTemplatedTextMessage(m, body) // Case 5
|
return s.handleBodyAsTemplatedTextMessage(m, template, body) // Case 5
|
||||||
} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
|
} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
|
||||||
return s.handleBodyAsTextMessage(m, body) // Case 6
|
return s.handleBodyAsTextMessage(m, body) // Case 6
|
||||||
}
|
}
|
||||||
@@ -1114,7 +1110,7 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser
|
|||||||
return nil
|
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))
|
body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1122,15 +1118,60 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *message, body *util.PeekedR
|
|||||||
return errHTTPEntityTooLargeJSONBody
|
return errHTTPEntityTooLargeJSONBody
|
||||||
}
|
}
|
||||||
peekedBody := strings.TrimSpace(string(body.PeekedBytes))
|
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 {
|
if m.Message, err = s.replaceTemplate(m.Message, peekedBody); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if m.Title, err = s.replaceTemplate(m.Title, peekedBody); err != nil {
|
if m.Title, err = s.replaceTemplate(m.Title, peekedBody); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(m.Message) > s.config.MessageSizeLimit {
|
|
||||||
return errHTTPBadRequestTemplateMessageTooLarge
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1138,35 +1179,19 @@ func (s *Server) replaceTemplate(tpl string, source string) (string, error) {
|
|||||||
if templateDisallowedRegex.MatchString(tpl) {
|
if templateDisallowedRegex.MatchString(tpl) {
|
||||||
return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
|
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
|
var data any
|
||||||
if err := json.Unmarshal([]byte(source), &data); err != nil {
|
if err := json.Unmarshal([]byte(source), &data); err != nil {
|
||||||
return "", errHTTPBadRequestTemplateMessageNotJSON
|
return "", errHTTPBadRequestTemplateMessageNotJSON
|
||||||
}
|
}
|
||||||
t, err := template.New("").Funcs(sprig.FuncMap()).Parse(tpl)
|
t, err := template.New("").Funcs(sprig.FuncMap()).Parse(tpl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", errHTTPBadRequestTemplateInvalid
|
return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error())
|
||||||
}
|
}
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := t.Execute(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), data); err != nil {
|
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 {
|
func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) error {
|
||||||
|
|||||||
23
server/templates/github.yml
Normal file
23
server/templates/github.yml
Normal file
@@ -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
server/templates/grafana.yml
Normal file
9
server/templates/grafana.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
message: |
|
||||||
|
{{if .alerts}}
|
||||||
|
{{.alerts | len}} alert(s) triggered
|
||||||
|
{{else}}
|
||||||
|
No alerts triggered.
|
||||||
|
{{end}}
|
||||||
|
title: |
|
||||||
|
⚠️ Grafana alert: {{.title}}
|
||||||
|
|
||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
|
|
||||||
"heckel.io/ntfy/v2/log"
|
"heckel.io/ntfy/v2/log"
|
||||||
"heckel.io/ntfy/v2/user"
|
"heckel.io/ntfy/v2/user"
|
||||||
|
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -246,6 +245,24 @@ func (q *queryFilter) Pass(msg *message) bool {
|
|||||||
return true
|
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 {
|
type apiHealthResponse struct {
|
||||||
Healthy bool `json:"healthy"`
|
Healthy bool `json:"healthy"`
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user