Merge branch 'main' into debian-stripe

This commit is contained in:
binwiederhier
2025-08-07 15:34:54 +02:00
105 changed files with 11828 additions and 1271 deletions

View File

@@ -11,6 +11,8 @@ import (
// Defines default config settings (excluding limits, see below)
const (
DefaultListenHTTP = ":80"
DefaultConfigFile = "/etc/ntfy/server.yml"
DefaultTemplateDir = "/etc/ntfy/templates"
DefaultCacheDuration = 12 * time.Hour
DefaultCacheBatchTimeout = time.Duration(0)
DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!)
@@ -61,6 +63,8 @@ const (
DefaultVisitorAuthFailureLimitReplenish = time.Minute
DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB
DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB
DefaultVisitorPrefixBitsIPv4 = 32 // Use the entire IPv4 address for rate limiting
DefaultVisitorPrefixBitsIPv6 = 64 // Use /64 for IPv6 rate limiting
)
var (
@@ -91,12 +95,16 @@ type Config struct {
AuthFile string
AuthStartupQueries string
AuthDefault user.Permission
AuthUsers []*user.User
AuthAccess map[string][]*user.Grant
AuthTokens map[string][]*user.Token
AuthBcryptCost int
AuthStatsQueueWriterInterval time.Duration
AttachmentCacheDir string
AttachmentTotalSizeLimit int64
AttachmentFileSizeLimit int64
AttachmentExpiryDuration time.Duration
TemplateDir string // Directory to load named templates from
KeepaliveInterval time.Duration
ManagerInterval time.Duration
DisallowedTopics []string
@@ -133,7 +141,7 @@ type Config struct {
VisitorAttachmentDailyBandwidthLimit int64
VisitorRequestLimitBurst int
VisitorRequestLimitReplenish time.Duration
VisitorRequestExemptIPAddrs []netip.Prefix
VisitorRequestExemptPrefixes []netip.Prefix
VisitorMessageDailyLimit int
VisitorEmailLimitBurst int
VisitorEmailLimitReplenish time.Duration
@@ -141,11 +149,13 @@ type Config struct {
VisitorAccountCreationLimitReplenish time.Duration
VisitorAuthFailureLimitBurst int
VisitorAuthFailureLimitReplenish time.Duration
VisitorStatsResetTime time.Time // Time of the day at which to reset visitor stats
VisitorSubscriberRateLimiting bool // Enable subscriber-based rate limiting for UnifiedPush topics
BehindProxy bool // If true, the server will trust the proxy client IP header to determine the client IP address
ProxyForwardedHeader string // The header field to read the real/client IP address from, if BehindProxy is true, defaults to "X-Forwarded-For"
ProxyTrustedAddresses []string // List of trusted proxy addresses that will be stripped from the Forwarded header if BehindProxy is true
VisitorStatsResetTime time.Time // Time of the day at which to reset visitor stats
VisitorSubscriberRateLimiting bool // Enable subscriber-based rate limiting for UnifiedPush topics
VisitorPrefixBitsIPv4 int // Number of bits for IPv4 rate limiting (default: 32)
VisitorPrefixBitsIPv6 int // Number of bits for IPv6 rate limiting (default: 64)
BehindProxy bool // If true, the server will trust the proxy client IP header to determine the client IP address (IPv4 and IPv6 supported)
ProxyForwardedHeader string // The header field to read the real/client IP address from, if BehindProxy is true, defaults to "X-Forwarded-For" (IPv4 and IPv6 supported)
ProxyTrustedPrefixes []netip.Prefix // List of trusted proxy networks (IPv4 or IPv6) that will be stripped from the Forwarded header if BehindProxy is true
StripeSecretKey string
StripeWebhookKey string
StripePriceCacheDuration time.Duration
@@ -155,7 +165,6 @@ type Config struct {
EnableReservations bool // Allow users with role "user" to own/reserve topics
EnableMetrics bool
AccessControlAllowOrigin string // CORS header field to restrict access from web clients
Version string // injected by App
WebPushPrivateKey string
WebPushPublicKey string
WebPushFile string
@@ -163,12 +172,13 @@ type Config struct {
WebPushStartupQueries string
WebPushExpiryDuration time.Duration
WebPushExpiryWarningDuration time.Duration
Version string // injected by App
}
// NewConfig instantiates a default new server config
func NewConfig() *Config {
return &Config{
File: "", // Only used for testing
File: DefaultConfigFile, // Only used for testing
BaseURL: "",
ListenHTTP: DefaultListenHTTP,
ListenHTTPS: "",
@@ -191,6 +201,7 @@ func NewConfig() *Config {
AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit,
AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit,
AttachmentExpiryDuration: DefaultAttachmentExpiryDuration,
TemplateDir: DefaultTemplateDir,
KeepaliveInterval: DefaultKeepaliveInterval,
ManagerInterval: DefaultManagerInterval,
DisallowedTopics: DefaultDisallowedTopics,
@@ -220,11 +231,12 @@ func NewConfig() *Config {
TotalTopicLimit: DefaultTotalTopicLimit,
TotalAttachmentSizeLimit: 0,
VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit,
VisitorSubscriberRateLimiting: false,
VisitorAttachmentTotalSizeLimit: DefaultVisitorAttachmentTotalSizeLimit,
VisitorAttachmentDailyBandwidthLimit: DefaultVisitorAttachmentDailyBandwidthLimit,
VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst,
VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish,
VisitorRequestExemptIPAddrs: make([]netip.Prefix, 0),
VisitorRequestExemptPrefixes: make([]netip.Prefix, 0),
VisitorMessageDailyLimit: DefaultVisitorMessageDailyLimit,
VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst,
VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish,
@@ -233,9 +245,10 @@ func NewConfig() *Config {
VisitorAuthFailureLimitBurst: DefaultVisitorAuthFailureLimitBurst,
VisitorAuthFailureLimitReplenish: DefaultVisitorAuthFailureLimitReplenish,
VisitorStatsResetTime: DefaultVisitorStatsResetTime,
VisitorSubscriberRateLimiting: false,
BehindProxy: false, // If true, the server will trust the proxy client IP header to determine the client IP address
ProxyForwardedHeader: "X-Forwarded-For", // Default header for reverse proxy client IPs
VisitorPrefixBitsIPv4: DefaultVisitorPrefixBitsIPv4, // Default: use full IPv4 address
VisitorPrefixBitsIPv6: DefaultVisitorPrefixBitsIPv6, // Default: use /64 for IPv6
BehindProxy: false, // If true, the server will trust the proxy client IP header to determine the client IP address
ProxyForwardedHeader: "X-Forwarded-For", // Default header for reverse proxy client IPs
StripeSecretKey: "",
StripeWebhookKey: "",
StripePriceCacheDuration: DefaultStripePriceCacheDuration,

View File

@@ -123,6 +123,8 @@ 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}
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}
@@ -130,6 +132,8 @@ var (
errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", "", nil}
errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", "", nil}
errHTTPConflictPhoneNumberExists = &errHTTP{40904, http.StatusConflict, "conflict: phone number already exists", "", nil}
errHTTPConflictProvisionedUserChange = &errHTTP{40905, http.StatusConflict, "conflict: cannot change or delete provisioned user", "", nil}
errHTTPConflictProvisionedTokenChange = &errHTTP{40906, http.StatusConflict, "conflict: cannot change or delete provisioned token", "", nil}
errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil}
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}

View File

@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"net/netip"
"path/filepath"
"strings"
"time"
@@ -286,6 +287,12 @@ type messageCache struct {
// newSqliteCache creates a SQLite file-backed cache
func newSqliteCache(filename, startupQueries string, cacheDuration time.Duration, batchSize int, batchTimeout time.Duration, nop bool) (*messageCache, error) {
// Check the parent directory of the database file (makes for friendly error messages)
parentDir := filepath.Dir(filename)
if !util.FileExists(parentDir) {
return nil, fmt.Errorf("cache database directory %s does not exist or is not accessible", parentDir)
}
// Open database
db, err := sql.Open("sqlite3", filename)
if err != nil {
return nil, err

View File

@@ -9,6 +9,7 @@ import (
"encoding/json"
"errors"
"fmt"
"gopkg.in/yaml.v2"
"io"
"net"
"net/http"
@@ -34,6 +35,7 @@ import (
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
"heckel.io/ntfy/v2/util/sprig"
)
// Server is the main server, providing the UI and API for ntfy
@@ -120,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 (
@@ -129,17 +140,13 @@ 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
templateMaxOutputBytes = 1024 * 1024 // Maximum number of bytes a template can output, used to prevent DoS attacks
templateFileExtension = ".yml" // Template files must end with this extension
)
// WebSocket constants
@@ -189,7 +196,18 @@ func New(conf *Config) (*Server, error) {
}
var userManager *user.Manager
if conf.AuthFile != "" {
userManager, err = user.NewManager(conf.AuthFile, conf.AuthStartupQueries, conf.AuthDefault, conf.AuthBcryptCost, conf.AuthStatsQueueWriterInterval)
authConfig := &user.Config{
Filename: conf.AuthFile,
StartupQueries: conf.AuthStartupQueries,
DefaultAccess: conf.AuthDefault,
ProvisionEnabled: true, // Enable provisioning of users and access
Users: conf.AuthUsers,
Access: conf.AuthAccess,
Tokens: conf.AuthTokens,
BcryptCost: conf.AuthBcryptCost,
QueueWriterInterval: conf.AuthStatsQueueWriterInterval,
}
userManager, err = user.NewManager(authConfig)
if err != nil {
return nil, err
}
@@ -760,7 +778,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
// the subscription as invalid if any 400-499 code (except 429/408) is returned.
// See https://github.com/mastodon/mastodon/blob/730bb3e211a84a2f30e3e2bbeae3f77149824a68/app/workers/web/push_notification_worker.rb#L35-L46
return nil, errHTTPInsufficientStorageUnifiedPush.With(t)
} else if !util.ContainsIP(s.config.VisitorRequestExemptIPAddrs, v.ip) && !vrate.MessageAllowed() {
} else if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() {
return nil, errHTTPTooManyRequestsLimitMessages.With(t)
} else if email != "" && !vrate.EmailAllowed() {
return nil, errHTTPTooManyRequestsLimitEmails.With(t)
@@ -936,7 +954,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")
@@ -952,7 +970,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 == "" {
@@ -970,48 +988,53 @@ 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
}
template = templateMode(readParam(r, "x-template", "template", "tpl"))
messageStr := readParam(r, "x-message", "message", "m")
if !template.InlineMode() {
// Convert "\n" to literal newline everything but inline mode
messageStr = strings.ReplaceAll(messageStr, "\\n", "\n")
}
messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
if messageStr != "" {
m.Message = messageStr
}
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()
}
@@ -1019,14 +1042,13 @@ 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")
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
contentEncoding := readParam(r, "content-encoding")
if unifiedpush || contentEncoding == "aes128gcm" {
@@ -1058,7 +1080,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 {
@@ -1067,8 +1089,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
}
@@ -1104,7 +1126,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
@@ -1112,19 +1134,69 @@ 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 {
return err
if template.FileMode() {
if err := s.renderTemplateFromFile(m, template.FileName(), peekedBody); err != nil {
return err
}
} else {
if err := s.renderTemplateFromParams(m, peekedBody); err != nil {
return err
}
}
if m.Title, err = replaceTemplate(m.Title, peekedBody); err != nil {
return err
}
if len(m.Message) > s.config.MessageSizeLimit {
if len(m.Title) > s.config.MessageSizeLimit || len(m.Message) > s.config.MessageSizeLimit {
return errHTTPBadRequestTemplateMessageTooLarge
}
return nil
}
func replaceTemplate(tpl string, source string) (string, error) {
// 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 *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(*tpl.Message, peekedBody); err != nil {
return err
}
}
if tpl.Title != nil {
if m.Title, err = s.renderTemplate(*tpl.Title, peekedBody); err != nil {
return err
}
}
return nil
}
// renderTemplateFromParams transforms the JSON message body according to the inline template in the
// message and title parameters.
func (s *Server) renderTemplateFromParams(m *message, peekedBody string) error {
var err error
if m.Message, err = s.renderTemplate(m.Message, peekedBody); err != nil {
return err
}
if m.Title, err = s.renderTemplate(m.Title, peekedBody); err != nil {
return err
}
return nil
}
// renderTemplate renders a template with the given JSON source data.
func (s *Server) renderTemplate(tpl string, source string) (string, error) {
if templateDisallowedRegex.MatchString(tpl) {
return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
}
@@ -1132,15 +1204,16 @@ func replaceTemplate(tpl string, source string) (string, error) {
if err := json.Unmarshal([]byte(source), &data); err != nil {
return "", errHTTPBadRequestTemplateMessageNotJSON
}
t, err := template.New("").Parse(tpl)
t, err := template.New("").Funcs(sprig.TxtFuncMap()).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
limitWriter := util.NewLimitWriter(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), util.NewFixedLimiter(templateMaxOutputBytes))
if err := t.Execute(limitWriter, data); err != nil {
return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("%s", err.Error())
}
return buf.String(), nil
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 *message, body *util.PeekedReadCloser) error {
@@ -1937,7 +2010,7 @@ func (s *Server) authorizeTopic(next handleFunc, perm user.Permission) handleFun
// that subsequent logging calls still have a visitor context.
func (s *Server) maybeAuthenticate(r *http.Request) (*visitor, error) {
// Read the "Authorization" header value and exit out early if it's not set
ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedAddresses)
ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes)
vip := s.visitor(ip, nil)
if s.userManager == nil {
return vip, nil
@@ -2012,7 +2085,7 @@ func (s *Server) authenticateBearerAuth(r *http.Request, token string) (*user.Us
if err != nil {
return nil, err
}
ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedAddresses)
ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes)
go s.userManager.EnqueueTokenUpdate(token, &user.TokenUpdate{
LastAccess: time.Now(),
LastOrigin: ip,
@@ -2023,7 +2096,7 @@ func (s *Server) authenticateBearerAuth(r *http.Request, token string) (*user.Us
func (s *Server) visitor(ip netip.Addr, user *user.User) *visitor {
s.mu.Lock()
defer s.mu.Unlock()
id := visitorID(ip, user)
id := visitorID(ip, user, s.config)
v, exists := s.visitors[id]
if !exists {
s.visitors[id] = newVisitor(s.config, s.messageCache, s.userManager, ip, user)

View File

@@ -82,6 +82,14 @@
# set to "read-write" (default), "read-only", "write-only" or "deny-all".
# - auth-startup-queries allows you to run commands when the database is initialized, e.g. to enable
# WAL mode. This is similar to cache-startup-queries. See above for details.
# - auth-users is a list of users that are automatically created when the server starts.
# Each entry is in the format "<username>:<password-hash>:<role>", e.g. "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:user"
# Use 'ntfy user hash' to generate the password hash from a password.
# - auth-access is a list of access control entries that are automatically created when the server starts.
# Each entry is in the format "<username>:<topic-pattern>:<access>", e.g. "phil:mytopic:rw" or "phil:phil-*:rw".
# - auth-tokens is a list of access tokens that are automatically created when the server starts.
# Each entry is in the format "<username>:<token>[:<label>]", e.g. "phil:tk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef:My token".
# Use 'ntfy token generate' to generate a new access token.
#
# Debian/RPM package users:
# Use /var/lib/ntfy/user.db as user database to avoid permission issues. The package
@@ -94,6 +102,9 @@
# auth-file: <filename>
# auth-default-access: "read-write"
# auth-startup-queries:
# auth-users:
# auth-access:
# auth-tokens:
# If set, the X-Forwarded-For header (or whatever is configured in proxy-forwarded-header) is used to determine
# the visitor IP address instead of the remote address of the connection.
@@ -105,13 +116,13 @@
# proxy-forwarded-header. Without this, the remote address of the incoming connection is used.
# - proxy-forwarded-header is the header to use to identify visitors. It may be a single IP address (e.g. 1.2.3.4),
# a comma-separated list of IP addresses (e.g. "1.2.3.4, 5.6.7.8"), or an RFC 7239-style header (e.g. "for=1.2.3.4;by=proxy.example.com, for=5.6.7.8").
# - proxy-trusted-addresses is a comma-separated list of IP addresses that are removed from the forwarded header
# - proxy-trusted-hosts is a comma-separated list of IP addresses, hostnames or CIDRs that are removed from the forwarded header
# to determine the real IP address. This is only useful if there are multiple proxies involved that add themselves to
# the forwarded header.
#
# behind-proxy: false
# proxy-forwarded-header: "X-Forwarded-For"
# proxy-trusted-addresses:
# proxy-trusted-hosts:
# If enabled, clients can attach files to notifications as attachments. Minimum settings to enable attachments
# are "attachment-cache-dir" and "base-url".
@@ -126,6 +137,26 @@
# attachment-file-size-limit: "15M"
# attachment-expiry-duration: "3h"
# Template directory for message templates.
#
# When "X-Template: <name>" (aliases: "Template: <name>", "Tpl: <name>") or "?template=<name>" is set, transform the message
# based on one of the built-in pre-defined templates, or on a template defined in the "template-dir" directory.
#
# Template files must have the ".yml" extension and must be formatted as YAML. They may contain "title" and "message" keys,
# which are interpreted as Go templates.
#
# Example template file (e.g. /etc/ntfy/templates/grafana.yml):
# title: |
# {{- if eq .status "firing" }}
# {{ .title | default "Alert firing" }}
# {{- else if eq .status "resolved" }}
# {{ .title | default "Alert resolved" }}
# {{- end }}
# message: |
# {{ .message | trunc 2000 }}
#
# template-dir: "/etc/ntfy/templates"
# If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set,
# messages will additionally be sent out as e-mail using an external SMTP server.
#
@@ -292,6 +323,18 @@
# visitor-email-limit-burst: 16
# visitor-email-limit-replenish: "1h"
# Rate limiting: IPv4/IPv6 address prefix bits used for rate limiting
# - visitor-prefix-bits-ipv4: number of bits of the IPv4 address to use for rate limiting (default: 32, full address)
# - visitor-prefix-bits-ipv6: number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)
#
# This is used to group visitors by their IP address or subnet. For example, if you set visitor-prefix-bits-ipv4 to 24,
# all visitors in the 1.2.3.0/24 network are treated as one.
#
# By default, ntfy uses the full IPv4 address (32 bits) and the /64 subnet of the IPv6 address (64 bits).
#
# visitor-prefix-bits-ipv4: 32
# visitor-prefix-bits-ipv6: 64
# Rate limiting: Attachment size and bandwidth limits per visitor:
# - visitor-attachment-total-size-limit is the total storage limit used for attachments per visitor
# - visitor-attachment-daily-bandwidth-limit is the total daily attachment download/upload traffic limit per visitor

View File

@@ -85,6 +85,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
response.Username = u.Name
response.Role = string(u.Role)
response.SyncTopic = u.SyncTopic
response.Provisioned = u.Provisioned
if u.Prefs != nil {
if u.Prefs.Language != nil {
response.Language = *u.Prefs.Language
@@ -139,11 +140,12 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
lastOrigin = t.LastOrigin.String()
}
response.Tokens = append(response.Tokens, &apiAccountTokenResponse{
Token: t.Value,
Label: t.Label,
LastAccess: t.LastAccess.Unix(),
LastOrigin: lastOrigin,
Expires: t.Expires.Unix(),
Token: t.Value,
Label: t.Label,
LastAccess: t.LastAccess.Unix(),
LastOrigin: lastOrigin,
Expires: t.Expires.Unix(),
Provisioned: t.Provisioned,
})
}
}
@@ -174,6 +176,12 @@ func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v *
if _, err := s.userManager.Authenticate(u.Name, req.Password); err != nil {
return errHTTPBadRequestIncorrectPasswordConfirmation
}
if err := s.userManager.CanChangeUser(u.Name); err != nil {
if errors.Is(err, user.ErrProvisionedUserChange) {
return errHTTPConflictProvisionedUserChange
}
return err
}
if s.webPush != nil && u.ID != "" {
if err := s.webPush.RemoveSubscriptionsByUserID(u.ID); err != nil {
logvr(v, r).Err(err).Warn("Error removing web push subscriptions for %s", u.Name)
@@ -208,6 +216,9 @@ func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Requ
}
logvr(v, r).Tag(tagAccount).Debug("Changing password for user %s", u.Name)
if err := s.userManager.ChangePassword(u.Name, req.NewPassword, false); err != nil {
if errors.Is(err, user.ErrProvisionedUserChange) {
return errHTTPConflictProvisionedUserChange
}
return err
}
return s.writeJSON(w, newSuccessResponse())
@@ -234,7 +245,7 @@ func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request
"token_expires": expires,
}).
Debug("Creating token for user %s", u.Name)
token, err := s.userManager.CreateToken(u.ID, label, expires, v.IP())
token, err := s.userManager.CreateToken(u.ID, label, expires, v.IP(), false)
if err != nil {
return err
}
@@ -274,6 +285,9 @@ func (s *Server) handleAccountTokenUpdate(w http.ResponseWriter, r *http.Request
Debug("Updating token for user %s as deleted", u.Name)
token, err := s.userManager.ChangeToken(u.ID, req.Token, req.Label, expires)
if err != nil {
if errors.Is(err, user.ErrProvisionedTokenChange) {
return errHTTPConflictProvisionedTokenChange
}
return err
}
response := &apiAccountTokenResponse{
@@ -296,6 +310,9 @@ func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, r *http.Request
}
}
if err := s.userManager.RemoveToken(u.ID, token); err != nil {
if errors.Is(err, user.ErrProvisionedTokenChange) {
return errHTTPConflictProvisionedTokenChange
}
return err
}
logvr(v, r).

View File

@@ -176,7 +176,7 @@ func TestAccount_ChangeSettings(t *testing.T) {
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
u, _ := s.userManager.User("phil")
token, _ := s.userManager.CreateToken(u.ID, "", time.Unix(0, 0), netip.IPv4Unspecified())
token, _ := s.userManager.CreateToken(u.ID, "", time.Unix(0, 0), netip.IPv4Unspecified(), false)
rr := request(t, s, "PATCH", "/v1/account/settings", `{"notification": {"sound": "juntos"},"ignored": true}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
@@ -251,7 +251,11 @@ func TestAccount_Subscription_AddUpdateDelete(t *testing.T) {
}
func TestAccount_ChangePassword(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t))
conf := newTestConfigWithAuthFile(t)
conf.AuthUsers = []*user.User{
{Name: "philuser", Hash: "$2a$10$U4WSIYY6evyGmZaraavM2e2JeVG6EMGUKN1uUwufUeeRd4Jpg6cGC", Role: user.RoleUser}, // philuser:philpass
}
s := newTestServer(t, conf)
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
@@ -281,6 +285,12 @@ func TestAccount_ChangePassword(t *testing.T) {
"Authorization": util.BasicAuth("phil", "new password"),
})
require.Equal(t, 200, rr.Code)
// Cannot change password of provisioned user
rr = request(t, s, "POST", "/v1/account/password", `{"password": "philpass", "new_password": "new password"}`, map[string]string{
"Authorization": util.BasicAuth("philuser", "philpass"),
})
require.Equal(t, 409, rr.Code)
}
func TestAccount_ChangePassword_NoAccount(t *testing.T) {

View File

@@ -25,7 +25,7 @@ func (s *Server) handleUsersGet(w http.ResponseWriter, r *http.Request, v *visit
for i, g := range grants[u.ID] {
userGrants[i] = &apiUserGrantResponse{
Topic: g.TopicPattern,
Permission: g.Allow.String(),
Permission: g.Permission.String(),
}
}
usersResponse[i] = &apiUserResponse{

View File

@@ -16,7 +16,7 @@ const (
func (s *Server) limitRequests(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if util.ContainsIP(s.config.VisitorRequestExemptIPAddrs, v.ip) {
if util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) {
return next(w, r, v)
} else if !v.RequestAllowed() {
return errHTTPTooManyRequestsLimitRequests
@@ -40,7 +40,7 @@ func (s *Server) limitRequestsWithTopic(next handleFunc) handleFunc {
contextRateVisitor: vrate,
contextTopic: t,
})
if util.ContainsIP(s.config.VisitorRequestExemptIPAddrs, v.ip) {
if util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) {
return next(w, r, v)
} else if !vrate.RequestAllowed() {
return errHTTPTooManyRequestsLimitRequests

View File

@@ -4,6 +4,7 @@ import (
"bufio"
"context"
"crypto/rand"
_ "embed"
"encoding/base64"
"encoding/json"
"fmt"
@@ -1169,7 +1170,7 @@ func (t *testMailer) Count() int {
return t.count
}
func TestServer_PublishTooRequests_Defaults(t *testing.T) {
func TestServer_PublishTooManyRequests_Defaults(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
for i := 0; i < 60; i++ {
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil)
@@ -1179,10 +1180,53 @@ func TestServer_PublishTooRequests_Defaults(t *testing.T) {
require.Equal(t, 429, response.Code)
}
func TestServer_PublishTooRequests_Defaults_ExemptHosts(t *testing.T) {
func TestServer_PublishTooManyRequests_Defaults_IPv6(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
overrideRemoteAddr1 := func(r *http.Request) {
r.RemoteAddr = "[2001:db8:9999:8888:1::1]:1234"
}
overrideRemoteAddr2 := func(r *http.Request) {
r.RemoteAddr = "[2001:db8:9999:8888:2::1]:1234" // Same /64
}
for i := 0; i < 30; i++ {
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr1)
require.Equal(t, 200, response.Code)
}
for i := 0; i < 30; i++ {
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr2)
require.Equal(t, 200, response.Code)
}
response := request(t, s, "PUT", "/mytopic", "message", nil, overrideRemoteAddr1)
require.Equal(t, 429, response.Code)
}
func TestServer_PublishTooManyRequests_IPv6_Slash48(t *testing.T) {
c := newTestConfig(t)
c.VisitorRequestLimitBurst = 6
c.VisitorPrefixBitsIPv6 = 48 // Use /48 for IPv6 prefixes
s := newTestServer(t, c)
overrideRemoteAddr1 := func(r *http.Request) {
r.RemoteAddr = "[2001:db8:9999::1]:1234"
}
overrideRemoteAddr2 := func(r *http.Request) {
r.RemoteAddr = "[2001:db8:9999::2]:1234" // Same /48
}
for i := 0; i < 3; i++ {
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr1)
require.Equal(t, 200, response.Code)
}
for i := 0; i < 3; i++ {
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr2)
require.Equal(t, 200, response.Code)
}
response := request(t, s, "PUT", "/mytopic", "message", nil, overrideRemoteAddr1)
require.Equal(t, 429, response.Code)
}
func TestServer_PublishTooManyRequests_Defaults_ExemptHosts(t *testing.T) {
c := newTestConfig(t)
c.VisitorRequestLimitBurst = 3
c.VisitorRequestExemptIPAddrs = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request()
c.VisitorRequestExemptPrefixes = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request()
s := newTestServer(t, c)
for i := 0; i < 5; i++ { // > 3
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil)
@@ -1190,11 +1234,25 @@ func TestServer_PublishTooRequests_Defaults_ExemptHosts(t *testing.T) {
}
}
func TestServer_PublishTooRequests_Defaults_ExemptHosts_MessageDailyLimit(t *testing.T) {
func TestServer_PublishTooManyRequests_Defaults_ExemptHosts_IPv6(t *testing.T) {
c := newTestConfig(t)
c.VisitorRequestLimitBurst = 3
c.VisitorRequestExemptPrefixes = []netip.Prefix{netip.MustParsePrefix("2001:db8:9999::/48")}
s := newTestServer(t, c)
overrideRemoteAddr := func(r *http.Request) {
r.RemoteAddr = "[2001:db8:9999::1]:1234"
}
for i := 0; i < 5; i++ { // > 3
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr)
require.Equal(t, 200, response.Code)
}
}
func TestServer_PublishTooManyRequests_Defaults_ExemptHosts_MessageDailyLimit(t *testing.T) {
c := newTestConfig(t)
c.VisitorRequestLimitBurst = 10
c.VisitorMessageDailyLimit = 4
c.VisitorRequestExemptIPAddrs = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request()
c.VisitorRequestExemptPrefixes = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request()
s := newTestServer(t, c)
for i := 0; i < 8; i++ { // 4
response := request(t, s, "PUT", "/mytopic", "message", nil)
@@ -1202,7 +1260,7 @@ func TestServer_PublishTooRequests_Defaults_ExemptHosts_MessageDailyLimit(t *tes
}
}
func TestServer_PublishTooRequests_ShortReplenish(t *testing.T) {
func TestServer_PublishTooManyRequests_ShortReplenish(t *testing.T) {
t.Parallel()
c := newTestConfig(t)
c.VisitorRequestLimitBurst = 60
@@ -2244,11 +2302,24 @@ func TestServer_Visitor_Custom_ClientIP_Header(t *testing.T) {
require.Equal(t, "1.2.3.4", v.ip.String())
}
func TestServer_Visitor_Custom_ClientIP_Header_IPv6(t *testing.T) {
c := newTestConfig(t)
c.BehindProxy = true
c.ProxyForwardedHeader = "X-Client-IP"
s := newTestServer(t, c)
r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "[2001:db8:9999::1]:1234"
r.Header.Set("X-Client-IP", "2001:db8:7777::1")
v, err := s.maybeAuthenticate(r)
require.Nil(t, err)
require.Equal(t, "2001:db8:7777::1", v.ip.String())
}
func TestServer_Visitor_Custom_Forwarded_Header(t *testing.T) {
c := newTestConfig(t)
c.BehindProxy = true
c.ProxyForwardedHeader = "Forwarded"
c.ProxyTrustedAddresses = []string{"1.2.3.4"}
c.ProxyTrustedPrefixes = []netip.Prefix{netip.MustParsePrefix("1.2.3.0/24")}
s := newTestServer(t, c)
r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "8.9.10.11:1234"
@@ -2258,6 +2329,20 @@ func TestServer_Visitor_Custom_Forwarded_Header(t *testing.T) {
require.Equal(t, "5.6.7.8", v.ip.String())
}
func TestServer_Visitor_Custom_Forwarded_Header_IPv6(t *testing.T) {
c := newTestConfig(t)
c.BehindProxy = true
c.ProxyForwardedHeader = "Forwarded"
c.ProxyTrustedPrefixes = []netip.Prefix{netip.MustParsePrefix("2001:db8:1111::/64")}
s := newTestServer(t, c)
r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "[2001:db8:2222::1]:1234"
r.Header.Set("Forwarded", " for=[2001:db8:1111::1], by=example.com;for=[2001:db8:3333::1]")
v, err := s.maybeAuthenticate(r)
require.Nil(t, err)
require.Equal(t, "2001:db8:3333::1", v.ip.String())
}
func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) {
t.Parallel()
count := 50000
@@ -2833,7 +2918,7 @@ func TestServer_MessageTemplate_Range(t *testing.T) {
require.Equal(t, 200, response.Code)
m := toMessage(t, response.Body.String())
require.Equal(t, "Severe URLs:\n- https://severe1.com\n- https://severe2.com\n", m.Message)
require.Equal(t, "Severe URLs:\n- https://severe1.com\n- https://severe2.com", m.Message)
}
func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageOK(t *testing.T) {
@@ -2886,8 +2971,7 @@ Labels:
Annotations:
- summary = 15m load average too high
Source: localhost:3000/alerting/grafana/NW9oDw-4z/view
Silence: localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter
`, m.Message)
Silence: localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter`, m.Message)
}
func TestServer_MessageTemplate_GitHub(t *testing.T) {
@@ -2940,12 +3024,223 @@ template ""}}`,
}
}
func TestServer_MessageTemplate_SprigFunctions(t *testing.T) {
t.Parallel()
s := newTestServer(t, newTestConfig(t))
bodies := []string{
`{"foo":"bar","nested":{"title":"here"}}`,
`{"topic":"ntfy-test"}`,
`{"topic":"another-topic"}`,
}
templates := []string{
`{{.foo | upper}} is {{.nested.title | repeat 3}}`,
`{{if hasPrefix "ntfy-" .topic}}Topic: {{trimPrefix "ntfy-" .topic}}{{ else }}Topic: {{.topic}}{{end}}`,
`{{if hasPrefix "ntfy-" .topic}}Topic: {{trimPrefix "ntfy-" .topic}}{{ else }}Topic: {{.topic}}{{end}}`,
}
targets := []string{
`BAR is hereherehere`,
`Topic: test`,
`Topic: another-topic`,
}
for i, body := range bodies {
template := templates[i]
target := targets[i]
t.Run(template, func(t *testing.T) {
response := request(t, s, "PUT", `/mytopic`, body, map[string]string{
"Template": "yes",
"Message": template,
})
require.Equal(t, 200, response.Code)
m := toMessage(t, response.Body.String())
require.Equal(t, target, m.Message)
})
}
}
func TestServer_MessageTemplate_UnsafeSprigFunctions(t *testing.T) {
t.Parallel()
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
"X-Message": `{{ env "PATH" }}`,
"X-Template": "1",
})
require.Equal(t, 400, response.Code)
require.Equal(t, 40043, toHTTPError(t, response.Body.String()).Code)
}
func TestServer_MessageTemplate_InlineNewlines(t *testing.T) {
t.Parallel()
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "PUT", "/mytopic", `{}`, map[string]string{
"X-Message": `{{"New\nlines"}}`,
"X-Title": `{{"New\nlines"}}`,
"X-Template": "1",
})
require.Equal(t, 200, response.Code)
m := toMessage(t, response.Body.String())
require.Equal(t, `New
lines`, m.Message)
require.Equal(t, `New
lines`, m.Title)
}
func TestServer_MessageTemplate_InlineNewlinesOutsideOfTemplate(t *testing.T) {
t.Parallel()
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "PUT", "/mytopic", `{"foo":"bar","food":"bag"}`, map[string]string{
"X-Message": `{{.foo}}{{"\n"}}{{.food}}`,
"X-Title": `{{.food}}{{"\n"}}{{.foo}}`,
"X-Template": "1",
})
require.Equal(t, 200, response.Code)
m := toMessage(t, response.Body.String())
require.Equal(t, `bar
bag`, m.Message)
require.Equal(t, `bag
bar`, m.Title)
}
func TestServer_MessageTemplate_TemplateFileNewlines(t *testing.T) {
t.Parallel()
c := newTestConfig(t)
c.TemplateDir = t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(c.TemplateDir, "newline.yml"), []byte(`
title: |
{{.food}}{{"\n"}}{{.foo}}
message: |
{{.foo}}{{"\n"}}{{.food}}
`), 0644))
s := newTestServer(t, c)
response := request(t, s, "POST", "/mytopic?template=newline", `{"foo":"bar","food":"bag"}`, nil)
fmt.Println(response.Body.String())
require.Equal(t, 200, response.Code)
m := toMessage(t, response.Body.String())
require.Equal(t, `bar
bag`, m.Message)
require.Equal(t, `bag
bar`, m.Title)
}
var (
//go:embed testdata/webhook_github_comment_created.json
githubCommentCreatedJSON string
//go:embed testdata/webhook_github_issue_opened.json
githubIssueOpenedJSON string
)
func TestServer_MessageTemplate_FromNamedTemplate_GitHubCommentCreated(t *testing.T) {
t.Parallel()
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "POST", "/mytopic?template=github", githubCommentCreatedJSON, nil)
require.Equal(t, 200, response.Code)
m := toMessage(t, response.Body.String())
require.Equal(t, "💬 New comment on issue #1389 instant alerts without Pull to refresh", m.Title)
require.Equal(t, `Commenter: https://github.com/wunter8
Repository: https://github.com/binwiederhier/ntfy
Comment link: https://github.com/binwiederhier/ntfy/issues/1389#issuecomment-3078214289
Comment:
These are the things you need to do to get iOS push notifications to work:
1. open a browser to the web app of your ntfy instance and copy the URL (including "http://" or "https://", your domain or IP address, and any ports, and excluding any trailing slashes)
2. put the URL you copied in the ntfy `+"`"+`base-url`+"`"+` config in server.yml or NTFY_BASE_URL in env variables
3. put the URL you copied in the default server URL setting in the iOS ntfy app
4. set `+"`"+`upstream-base-url`+"`"+` in server.yml or NTFY_UPSTREAM_BASE_URL in env variables to "https://ntfy.sh" (without a trailing slash)`, m.Message)
}
func TestServer_MessageTemplate_FromNamedTemplate_GitHubIssueOpened(t *testing.T) {
t.Parallel()
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "POST", "/mytopic?template=github", githubIssueOpenedJSON, nil)
require.Equal(t, 200, response.Code)
m := toMessage(t, response.Body.String())
require.Equal(t, "🐛 Issue opened: #1391 http 500 error (ntfy error 50001)", m.Title)
require.Equal(t, `Opened by: https://github.com/TheUser-dev
Repository: https://github.com/binwiederhier/ntfy
Issue link: https://github.com/binwiederhier/ntfy/issues/1391
Labels: 🪲 bug
Description:
:lady_beetle: **Describe the bug**
When sending a notification (especially when it happens with multiple requests) this error occurs
:computer: **Components impacted**
ntfy server 2.13.0 in docker, debian 12 arm64
:bulb: **Screenshots and/or logs**
`+"```"+`
closed with HTTP 500 (ntfy error 50001) (error=database table is locked, http_method=POST, http_path=/_matrix/push/v1/notify, tag=http, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=30, visitor_id=ip:<edited>, visitor_ip=<edited>, visitor_messages=448, visitor_messages_limit=17280, visitor_messages_remaining=16832, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=57.049697891799994, visitor_seen=2025-07-16T15:06:35.429Z)
`+"```"+`
:crystal_ball: **Additional context**
Looks like this has already been fixed by #498, regression?`, m.Message)
}
func TestServer_MessageTemplate_FromNamedTemplate_GitHubIssueOpened_OverrideConfigTemplate(t *testing.T) {
t.Parallel()
c := newTestConfig(t)
c.TemplateDir = t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(c.TemplateDir, "github.yml"), []byte(`
title: |
Custom title: action={{ .action }} trunctitle={{ .issue.title | trunc 10 }}
message: |
Custom message {{ .issue.number }}
`), 0644))
s := newTestServer(t, c)
response := request(t, s, "POST", "/mytopic?template=github", githubIssueOpenedJSON, nil)
fmt.Println(response.Body.String())
require.Equal(t, 200, response.Code)
m := toMessage(t, response.Body.String())
require.Equal(t, "Custom title: action=opened trunctitle=http 500 e", m.Title)
require.Equal(t, "Custom message 1391", m.Message)
}
func TestServer_MessageTemplate_Repeat9999_TooLarge(t *testing.T) {
t.Parallel()
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
"X-Message": `{{ repeat 9999 "mystring" }}`,
"X-Template": "1",
})
require.Equal(t, 400, response.Code)
require.Equal(t, 40041, toHTTPError(t, response.Body.String()).Code)
require.Contains(t, toHTTPError(t, response.Body.String()).Message, "message or title is too large after replacing template")
}
func TestServer_MessageTemplate_Repeat10001_TooLarge(t *testing.T) {
t.Parallel()
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
"X-Message": `{{ repeat 10001 "mystring" }}`,
"X-Template": "1",
})
require.Equal(t, 400, response.Code)
require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code)
require.Contains(t, toHTTPError(t, response.Body.String()).Message, "repeat count 10001 exceeds limit of 10000")
}
func TestServer_MessageTemplate_Until100_000(t *testing.T) {
t.Parallel()
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
"X-Message": `{{ range $i, $e := until 100_000 }}{{end}}`,
"X-Template": "1",
})
require.Equal(t, 400, response.Code)
require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code)
require.Contains(t, toHTTPError(t, response.Body.String()).Message, "too many iterations")
}
func newTestConfig(t *testing.T) *Config {
conf := NewConfig()
conf.BaseURL = "http://127.0.0.1:12345"
conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")
conf.CacheStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;"
conf.AttachmentCacheDir = t.TempDir()
conf.TemplateDir = t.TempDir()
return conf
}

View File

@@ -5,8 +5,6 @@ import (
"encoding/base64"
"errors"
"fmt"
"github.com/emersion/go-smtp"
"github.com/microcosm-cc/bluemonday"
"io"
"mime"
"mime/multipart"
@@ -18,6 +16,9 @@ import (
"regexp"
"strings"
"sync"
"github.com/emersion/go-smtp"
"github.com/microcosm-cc/bluemonday"
)
var (
@@ -191,12 +192,12 @@ func (s *smtpSession) publishMessage(m *message) error {
// Call HTTP handler with fake HTTP request
url := fmt.Sprintf("%s/%s", s.backend.config.BaseURL, m.Topic)
req, err := http.NewRequest("POST", url, strings.NewReader(m.Message))
req.RequestURI = "/" + m.Topic // just for the logs
req.RemoteAddr = remoteAddr // rate limiting!!
req.Header.Set("X-Forwarded-For", remoteAddr)
if err != nil {
return err
}
req.RequestURI = "/" + m.Topic // just for the logs
req.RemoteAddr = remoteAddr // rate limiting!!
req.Header.Set(s.backend.config.ProxyForwardedHeader, remoteAddr) // Set X-Forwarded-For header
if m.Title != "" {
req.Header.Set("Title", m.Title)
}

View File

@@ -0,0 +1,27 @@
title: |
{{- if eq .status "firing" }}
🚨 Alert: {{ (first .alerts).labels.alertname }}
{{- else if eq .status "resolved" }}
✅ Resolved: {{ (first .alerts).labels.alertname }}
{{- else }}
{{ fail "Unsupported Alertmanager status." }}
{{- end }}
message: |
Status: {{ .status | title }}
Receiver: {{ .receiver }}
{{- range .alerts }}
Alert: {{ .labels.alertname }}
Instance: {{ .labels.instance }}
Severity: {{ .labels.severity }}
Starts at: {{ .startsAt }}
{{- if .endsAt }}Ends at: {{ .endsAt }}{{ end }}
{{- if .annotations.summary }}
Summary: {{ .annotations.summary }}
{{- end }}
{{- if .annotations.description }}
Description: {{ .annotations.description }}
{{- end }}
Source: {{ .generatorURL }}
{{ end }}

View File

@@ -0,0 +1,57 @@
title: |
{{- if and .starred_at (eq .action "created")}}
⭐ {{ .sender.login }} starred {{ .repository.name }}
{{- else if and .repository (eq .action "started")}}
👀 {{ .sender.login }} started watching {{ .repository.name }}
{{- else if and .comment (eq .action "created") }}
💬 New comment on issue #{{ .issue.number }} {{ .issue.title }}
{{- else if .pull_request }}
🔀 Pull request {{ .action }}: #{{ .pull_request.number }} {{ .pull_request.title }}
{{- else if .issue }}
🐛 Issue {{ .action }}: #{{ .issue.number }} {{ .issue.title }}
{{- else }}
{{ fail "Unsupported GitHub event type or action." }}
{{- end }}
message: |
{{ if and .starred_at (eq .action "created")}}
Stargazer: {{ .sender.html_url }}
Repository: {{ .repository.html_url }}
{{- else if and .repository (eq .action "started")}}
Watcher: {{ .sender.html_url }}
Repository: {{ .repository.html_url }}
{{- else if and .comment (eq .action "created") }}
Commenter: {{ .comment.user.html_url }}
Repository: {{ .repository.html_url }}
Comment link: {{ .comment.html_url }}
{{ if .comment.body }}
Comment:
{{ .comment.body | trunc 2000 }}{{ end }}
{{- else if .pull_request }}
Branch: {{ .pull_request.head.ref }} → {{ .pull_request.base.ref }}
{{ .action | title }} by: {{ .pull_request.user.html_url }}
Repository: {{ .repository.html_url }}
Pull request: {{ .pull_request.html_url }}
{{ if .pull_request.body }}
Description:
{{ .pull_request.body | trunc 2000 }}{{ end }}
{{- else if .issue }}
{{ .action | title }} by: {{ .issue.user.html_url }}
Repository: {{ .repository.html_url }}
Issue link: {{ .issue.html_url }}
{{ if .issue.labels }}Labels: {{ range .issue.labels }}{{ .name }} {{ end }}{{ end }}
{{ if .issue.body }}
Description:
{{ .issue.body | trunc 2000 }}{{ end }}
{{- else }}
{{ fail "Unsupported GitHub event type or action." }}
{{- end }}

View File

@@ -0,0 +1,10 @@
title: |
{{- if eq .status "firing" }}
🚨 {{ .title | default "Alert firing" }}
{{- else if eq .status "resolved" }}
✅ {{ .title | default "Alert resolved" }}
{{- else }}
⚠️ Unknown alert: {{ .title | default "Alert" }}
{{- end }}
message: |
{{ .message | trunc 2000 }}

View File

@@ -0,0 +1,33 @@
{
"version": "4",
"groupKey": "...",
"status": "firing",
"receiver": "webhook-receiver",
"groupLabels": {
"alertname": "HighCPUUsage"
},
"commonLabels": {
"alertname": "HighCPUUsage",
"instance": "server01",
"severity": "critical"
},
"commonAnnotations": {
"summary": "High CPU usage detected"
},
"alerts": [
{
"status": "firing",
"labels": {
"alertname": "HighCPUUsage",
"instance": "server01",
"severity": "critical"
},
"annotations": {
"summary": "High CPU usage detected"
},
"startsAt": "2025-07-17T07:00:00Z",
"endsAt": "0001-01-01T00:00:00Z",
"generatorURL": "http://prometheus.local/graph?g0.expr=..."
}
]
}

View File

@@ -0,0 +1,261 @@
{
"action": "created",
"issue": {
"url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1389",
"repository_url": "https://api.github.com/repos/binwiederhier/ntfy",
"labels_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1389/labels{/name}",
"comments_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1389/comments",
"events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1389/events",
"html_url": "https://github.com/binwiederhier/ntfy/issues/1389",
"id": 3230655753,
"node_id": "I_kwDOGRBhi87Aj-UJ",
"number": 1389,
"title": "instant alerts without Pull to refresh",
"user": {
"login": "edbraunh",
"id": 8795846,
"node_id": "MDQ6VXNlcjg3OTU4NDY=",
"avatar_url": "https://avatars.githubusercontent.com/u/8795846?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/edbraunh",
"html_url": "https://github.com/edbraunh",
"followers_url": "https://api.github.com/users/edbraunh/followers",
"following_url": "https://api.github.com/users/edbraunh/following{/other_user}",
"gists_url": "https://api.github.com/users/edbraunh/gists{/gist_id}",
"starred_url": "https://api.github.com/users/edbraunh/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/edbraunh/subscriptions",
"organizations_url": "https://api.github.com/users/edbraunh/orgs",
"repos_url": "https://api.github.com/users/edbraunh/repos",
"events_url": "https://api.github.com/users/edbraunh/events{/privacy}",
"received_events_url": "https://api.github.com/users/edbraunh/received_events",
"type": "User",
"user_view_type": "public",
"site_admin": false
},
"labels": [
{
"id": 3480884105,
"node_id": "LA_kwDOGRBhi87PehOJ",
"url": "https://api.github.com/repos/binwiederhier/ntfy/labels/enhancement",
"name": "enhancement",
"color": "a2eeef",
"default": true,
"description": "New feature or request"
}
],
"state": "open",
"locked": false,
"assignee": null,
"assignees": [
],
"milestone": null,
"comments": 3,
"created_at": "2025-07-15T03:46:30Z",
"updated_at": "2025-07-16T11:45:57Z",
"closed_at": null,
"author_association": "NONE",
"active_lock_reason": null,
"sub_issues_summary": {
"total": 0,
"completed": 0,
"percent_completed": 0
},
"body": "Hello ntfy Team,\n\nFirst off, thank you for developing such a powerful and lightweight notification app — its been invaluable for receiving timely alerts.\n\nIm a user who relies heavily on ntfy for real-time trading alerts and have noticed that while push notifications arrive instantly, the in-app alert list does not automatically refresh with new messages. Currently, I need to manually pull-to-refresh the alert list to see the latest alerts.\n\nWould it be possible to add a feature that enables automatic refreshing of the alert list as new notifications arrive? This would greatly enhance usability and streamline the user experience, especially for users monitoring time-sensitive information.\n\nThank you for considering this request. I appreciate your hard work and look forward to future updates!",
"reactions": {
"url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1389/reactions",
"total_count": 0,
"+1": 0,
"-1": 0,
"laugh": 0,
"hooray": 0,
"confused": 0,
"heart": 0,
"rocket": 0,
"eyes": 0
},
"timeline_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1389/timeline",
"performed_via_github_app": null,
"state_reason": null
},
"comment": {
"url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments/3078214289",
"html_url": "https://github.com/binwiederhier/ntfy/issues/1389#issuecomment-3078214289",
"issue_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1389",
"id": 3078214289,
"node_id": "IC_kwDOGRBhi863edKR",
"user": {
"login": "wunter8",
"id": 8421688,
"node_id": "MDQ6VXNlcjg0MjE2ODg=",
"avatar_url": "https://avatars.githubusercontent.com/u/8421688?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/wunter8",
"html_url": "https://github.com/wunter8",
"followers_url": "https://api.github.com/users/wunter8/followers",
"following_url": "https://api.github.com/users/wunter8/following{/other_user}",
"gists_url": "https://api.github.com/users/wunter8/gists{/gist_id}",
"starred_url": "https://api.github.com/users/wunter8/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/wunter8/subscriptions",
"organizations_url": "https://api.github.com/users/wunter8/orgs",
"repos_url": "https://api.github.com/users/wunter8/repos",
"events_url": "https://api.github.com/users/wunter8/events{/privacy}",
"received_events_url": "https://api.github.com/users/wunter8/received_events",
"type": "User",
"user_view_type": "public",
"site_admin": false
},
"created_at": "2025-07-16T11:45:57Z",
"updated_at": "2025-07-16T11:45:57Z",
"author_association": "CONTRIBUTOR",
"body": "These are the things you need to do to get iOS push notifications to work:\n1. open a browser to the web app of your ntfy instance and copy the URL (including \"http://\" or \"https://\", your domain or IP address, and any ports, and excluding any trailing slashes)\n2. put the URL you copied in the ntfy `base-url` config in server.yml or NTFY_BASE_URL in env variables\n3. put the URL you copied in the default server URL setting in the iOS ntfy app\n4. set `upstream-base-url` in server.yml or NTFY_UPSTREAM_BASE_URL in env variables to \"https://ntfy.sh\" (without a trailing slash)",
"reactions": {
"url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments/3078214289/reactions",
"total_count": 0,
"+1": 0,
"-1": 0,
"laugh": 0,
"hooray": 0,
"confused": 0,
"heart": 0,
"rocket": 0,
"eyes": 0
},
"performed_via_github_app": null
},
"repository": {
"id": 420503947,
"node_id": "R_kgDOGRBhiw",
"name": "ntfy",
"full_name": "binwiederhier/ntfy",
"private": false,
"owner": {
"login": "binwiederhier",
"id": 664597,
"node_id": "MDQ6VXNlcjY2NDU5Nw==",
"avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/binwiederhier",
"html_url": "https://github.com/binwiederhier",
"followers_url": "https://api.github.com/users/binwiederhier/followers",
"following_url": "https://api.github.com/users/binwiederhier/following{/other_user}",
"gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}",
"starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions",
"organizations_url": "https://api.github.com/users/binwiederhier/orgs",
"repos_url": "https://api.github.com/users/binwiederhier/repos",
"events_url": "https://api.github.com/users/binwiederhier/events{/privacy}",
"received_events_url": "https://api.github.com/users/binwiederhier/received_events",
"type": "User",
"user_view_type": "public",
"site_admin": false
},
"html_url": "https://github.com/binwiederhier/ntfy",
"description": "Send push notifications to your phone or desktop using PUT/POST",
"fork": false,
"url": "https://api.github.com/repos/binwiederhier/ntfy",
"forks_url": "https://api.github.com/repos/binwiederhier/ntfy/forks",
"keys_url": "https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}",
"collaborators_url": "https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}",
"teams_url": "https://api.github.com/repos/binwiederhier/ntfy/teams",
"hooks_url": "https://api.github.com/repos/binwiederhier/ntfy/hooks",
"issue_events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}",
"events_url": "https://api.github.com/repos/binwiederhier/ntfy/events",
"assignees_url": "https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}",
"branches_url": "https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}",
"tags_url": "https://api.github.com/repos/binwiederhier/ntfy/tags",
"blobs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}",
"git_tags_url": "https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}",
"git_refs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}",
"trees_url": "https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}",
"statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}",
"languages_url": "https://api.github.com/repos/binwiederhier/ntfy/languages",
"stargazers_url": "https://api.github.com/repos/binwiederhier/ntfy/stargazers",
"contributors_url": "https://api.github.com/repos/binwiederhier/ntfy/contributors",
"subscribers_url": "https://api.github.com/repos/binwiederhier/ntfy/subscribers",
"subscription_url": "https://api.github.com/repos/binwiederhier/ntfy/subscription",
"commits_url": "https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}",
"git_commits_url": "https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}",
"comments_url": "https://api.github.com/repos/binwiederhier/ntfy/comments{/number}",
"issue_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}",
"contents_url": "https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}",
"compare_url": "https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}",
"merges_url": "https://api.github.com/repos/binwiederhier/ntfy/merges",
"archive_url": "https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}",
"downloads_url": "https://api.github.com/repos/binwiederhier/ntfy/downloads",
"issues_url": "https://api.github.com/repos/binwiederhier/ntfy/issues{/number}",
"pulls_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}",
"milestones_url": "https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}",
"notifications_url": "https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}",
"labels_url": "https://api.github.com/repos/binwiederhier/ntfy/labels{/name}",
"releases_url": "https://api.github.com/repos/binwiederhier/ntfy/releases{/id}",
"deployments_url": "https://api.github.com/repos/binwiederhier/ntfy/deployments",
"created_at": "2021-10-23T19:25:32Z",
"updated_at": "2025-07-16T10:18:34Z",
"pushed_at": "2025-07-13T13:56:19Z",
"git_url": "git://github.com/binwiederhier/ntfy.git",
"ssh_url": "git@github.com:binwiederhier/ntfy.git",
"clone_url": "https://github.com/binwiederhier/ntfy.git",
"svn_url": "https://github.com/binwiederhier/ntfy",
"homepage": "https://ntfy.sh",
"size": 36740,
"stargazers_count": 25111,
"watchers_count": 25111,
"language": "Go",
"has_issues": true,
"has_projects": true,
"has_downloads": true,
"has_wiki": true,
"has_pages": false,
"has_discussions": false,
"forks_count": 984,
"mirror_url": null,
"archived": false,
"disabled": false,
"open_issues_count": 367,
"license": {
"key": "apache-2.0",
"name": "Apache License 2.0",
"spdx_id": "Apache-2.0",
"url": "https://api.github.com/licenses/apache-2.0",
"node_id": "MDc6TGljZW5zZTI="
},
"allow_forking": true,
"is_template": false,
"web_commit_signoff_required": false,
"topics": [
"curl",
"notifications",
"ntfy",
"ntfysh",
"pubsub",
"push-notifications",
"rest-api"
],
"visibility": "public",
"forks": 984,
"open_issues": 367,
"watchers": 25111,
"default_branch": "main"
},
"sender": {
"login": "wunter8",
"id": 8421688,
"node_id": "MDQ6VXNlcjg0MjE2ODg=",
"avatar_url": "https://avatars.githubusercontent.com/u/8421688?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/wunter8",
"html_url": "https://github.com/wunter8",
"followers_url": "https://api.github.com/users/wunter8/followers",
"following_url": "https://api.github.com/users/wunter8/following{/other_user}",
"gists_url": "https://api.github.com/users/wunter8/gists{/gist_id}",
"starred_url": "https://api.github.com/users/wunter8/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/wunter8/subscriptions",
"organizations_url": "https://api.github.com/users/wunter8/orgs",
"repos_url": "https://api.github.com/users/wunter8/repos",
"events_url": "https://api.github.com/users/wunter8/events{/privacy}",
"received_events_url": "https://api.github.com/users/wunter8/received_events",
"type": "User",
"user_view_type": "public",
"site_admin": false
}
}

View File

@@ -0,0 +1,216 @@
{
"action": "opened",
"issue": {
"url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1391",
"repository_url": "https://api.github.com/repos/binwiederhier/ntfy",
"labels_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1391/labels{/name}",
"comments_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1391/comments",
"events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1391/events",
"html_url": "https://github.com/binwiederhier/ntfy/issues/1391",
"id": 3236389051,
"node_id": "I_kwDOGRBhi87A52C7",
"number": 1391,
"title": "http 500 error (ntfy error 50001)",
"user": {
"login": "TheUser-dev",
"id": 213207407,
"node_id": "U_kgDODLVJbw",
"avatar_url": "https://avatars.githubusercontent.com/u/213207407?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/TheUser-dev",
"html_url": "https://github.com/TheUser-dev",
"followers_url": "https://api.github.com/users/TheUser-dev/followers",
"following_url": "https://api.github.com/users/TheUser-dev/following{/other_user}",
"gists_url": "https://api.github.com/users/TheUser-dev/gists{/gist_id}",
"starred_url": "https://api.github.com/users/TheUser-dev/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/TheUser-dev/subscriptions",
"organizations_url": "https://api.github.com/users/TheUser-dev/orgs",
"repos_url": "https://api.github.com/users/TheUser-dev/repos",
"events_url": "https://api.github.com/users/TheUser-dev/events{/privacy}",
"received_events_url": "https://api.github.com/users/TheUser-dev/received_events",
"type": "User",
"user_view_type": "public",
"site_admin": false
},
"labels": [
{
"id": 3480884102,
"node_id": "LA_kwDOGRBhi87PehOG",
"url": "https://api.github.com/repos/binwiederhier/ntfy/labels/%F0%9F%AA%B2%20bug",
"name": "🪲 bug",
"color": "d73a4a",
"default": false,
"description": "Something isn't working"
}
],
"state": "open",
"locked": false,
"assignee": null,
"assignees": [
],
"milestone": null,
"comments": 0,
"created_at": "2025-07-16T15:20:56Z",
"updated_at": "2025-07-16T15:20:56Z",
"closed_at": null,
"author_association": "NONE",
"active_lock_reason": null,
"sub_issues_summary": {
"total": 0,
"completed": 0,
"percent_completed": 0
},
"body": ":lady_beetle: **Describe the bug**\nWhen sending a notification (especially when it happens with multiple requests) this error occurs\n\n:computer: **Components impacted**\nntfy server 2.13.0 in docker, debian 12 arm64\n\n:bulb: **Screenshots and/or logs**\n```\nclosed with HTTP 500 (ntfy error 50001) (error=database table is locked, http_method=POST, http_path=/_matrix/push/v1/notify, tag=http, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=30, visitor_id=ip:<edited>, visitor_ip=<edited>, visitor_messages=448, visitor_messages_limit=17280, visitor_messages_remaining=16832, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=57.049697891799994, visitor_seen=2025-07-16T15:06:35.429Z)\n```\n\n:crystal_ball: **Additional context**\nLooks like this has already been fixed by #498, regression?\n",
"reactions": {
"url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1391/reactions",
"total_count": 0,
"+1": 0,
"-1": 0,
"laugh": 0,
"hooray": 0,
"confused": 0,
"heart": 0,
"rocket": 0,
"eyes": 0
},
"timeline_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1391/timeline",
"performed_via_github_app": null,
"state_reason": null
},
"repository": {
"id": 420503947,
"node_id": "R_kgDOGRBhiw",
"name": "ntfy",
"full_name": "binwiederhier/ntfy",
"private": false,
"owner": {
"login": "binwiederhier",
"id": 664597,
"node_id": "MDQ6VXNlcjY2NDU5Nw==",
"avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/binwiederhier",
"html_url": "https://github.com/binwiederhier",
"followers_url": "https://api.github.com/users/binwiederhier/followers",
"following_url": "https://api.github.com/users/binwiederhier/following{/other_user}",
"gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}",
"starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions",
"organizations_url": "https://api.github.com/users/binwiederhier/orgs",
"repos_url": "https://api.github.com/users/binwiederhier/repos",
"events_url": "https://api.github.com/users/binwiederhier/events{/privacy}",
"received_events_url": "https://api.github.com/users/binwiederhier/received_events",
"type": "User",
"user_view_type": "public",
"site_admin": false
},
"html_url": "https://github.com/binwiederhier/ntfy",
"description": "Send push notifications to your phone or desktop using PUT/POST",
"fork": false,
"url": "https://api.github.com/repos/binwiederhier/ntfy",
"forks_url": "https://api.github.com/repos/binwiederhier/ntfy/forks",
"keys_url": "https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}",
"collaborators_url": "https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}",
"teams_url": "https://api.github.com/repos/binwiederhier/ntfy/teams",
"hooks_url": "https://api.github.com/repos/binwiederhier/ntfy/hooks",
"issue_events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}",
"events_url": "https://api.github.com/repos/binwiederhier/ntfy/events",
"assignees_url": "https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}",
"branches_url": "https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}",
"tags_url": "https://api.github.com/repos/binwiederhier/ntfy/tags",
"blobs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}",
"git_tags_url": "https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}",
"git_refs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}",
"trees_url": "https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}",
"statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}",
"languages_url": "https://api.github.com/repos/binwiederhier/ntfy/languages",
"stargazers_url": "https://api.github.com/repos/binwiederhier/ntfy/stargazers",
"contributors_url": "https://api.github.com/repos/binwiederhier/ntfy/contributors",
"subscribers_url": "https://api.github.com/repos/binwiederhier/ntfy/subscribers",
"subscription_url": "https://api.github.com/repos/binwiederhier/ntfy/subscription",
"commits_url": "https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}",
"git_commits_url": "https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}",
"comments_url": "https://api.github.com/repos/binwiederhier/ntfy/comments{/number}",
"issue_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}",
"contents_url": "https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}",
"compare_url": "https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}",
"merges_url": "https://api.github.com/repos/binwiederhier/ntfy/merges",
"archive_url": "https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}",
"downloads_url": "https://api.github.com/repos/binwiederhier/ntfy/downloads",
"issues_url": "https://api.github.com/repos/binwiederhier/ntfy/issues{/number}",
"pulls_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}",
"milestones_url": "https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}",
"notifications_url": "https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}",
"labels_url": "https://api.github.com/repos/binwiederhier/ntfy/labels{/name}",
"releases_url": "https://api.github.com/repos/binwiederhier/ntfy/releases{/id}",
"deployments_url": "https://api.github.com/repos/binwiederhier/ntfy/deployments",
"created_at": "2021-10-23T19:25:32Z",
"updated_at": "2025-07-16T14:54:16Z",
"pushed_at": "2025-07-16T11:49:26Z",
"git_url": "git://github.com/binwiederhier/ntfy.git",
"ssh_url": "git@github.com:binwiederhier/ntfy.git",
"clone_url": "https://github.com/binwiederhier/ntfy.git",
"svn_url": "https://github.com/binwiederhier/ntfy",
"homepage": "https://ntfy.sh",
"size": 36831,
"stargazers_count": 25112,
"watchers_count": 25112,
"language": "Go",
"has_issues": true,
"has_projects": true,
"has_downloads": true,
"has_wiki": true,
"has_pages": false,
"has_discussions": false,
"forks_count": 984,
"mirror_url": null,
"archived": false,
"disabled": false,
"open_issues_count": 369,
"license": {
"key": "apache-2.0",
"name": "Apache License 2.0",
"spdx_id": "Apache-2.0",
"url": "https://api.github.com/licenses/apache-2.0",
"node_id": "MDc6TGljZW5zZTI="
},
"allow_forking": true,
"is_template": false,
"web_commit_signoff_required": false,
"topics": [
"curl",
"notifications",
"ntfy",
"ntfysh",
"pubsub",
"push-notifications",
"rest-api"
],
"visibility": "public",
"forks": 984,
"open_issues": 369,
"watchers": 25112,
"default_branch": "main"
},
"sender": {
"login": "TheUser-dev",
"id": 213207407,
"node_id": "U_kgDODLVJbw",
"avatar_url": "https://avatars.githubusercontent.com/u/213207407?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/TheUser-dev",
"html_url": "https://github.com/TheUser-dev",
"followers_url": "https://api.github.com/users/TheUser-dev/followers",
"following_url": "https://api.github.com/users/TheUser-dev/following{/other_user}",
"gists_url": "https://api.github.com/users/TheUser-dev/gists{/gist_id}",
"starred_url": "https://api.github.com/users/TheUser-dev/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/TheUser-dev/subscriptions",
"organizations_url": "https://api.github.com/users/TheUser-dev/orgs",
"repos_url": "https://api.github.com/users/TheUser-dev/repos",
"events_url": "https://api.github.com/users/TheUser-dev/events{/privacy}",
"received_events_url": "https://api.github.com/users/TheUser-dev/received_events",
"type": "User",
"user_view_type": "public",
"site_admin": false
}
}

View File

@@ -0,0 +1,541 @@
{
"action": "opened",
"number": 1390,
"pull_request": {
"url": "https://api.github.com/repos/binwiederhier/ntfy/pulls/1390",
"id": 2670425869,
"node_id": "PR_kwDOGRBhi86fK3cN",
"html_url": "https://github.com/binwiederhier/ntfy/pull/1390",
"diff_url": "https://github.com/binwiederhier/ntfy/pull/1390.diff",
"patch_url": "https://github.com/binwiederhier/ntfy/pull/1390.patch",
"issue_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1390",
"number": 1390,
"state": "open",
"locked": false,
"title": "WIP Template dir",
"user": {
"login": "binwiederhier",
"id": 664597,
"node_id": "MDQ6VXNlcjY2NDU5Nw==",
"avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/binwiederhier",
"html_url": "https://github.com/binwiederhier",
"followers_url": "https://api.github.com/users/binwiederhier/followers",
"following_url": "https://api.github.com/users/binwiederhier/following{/other_user}",
"gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}",
"starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions",
"organizations_url": "https://api.github.com/users/binwiederhier/orgs",
"repos_url": "https://api.github.com/users/binwiederhier/repos",
"events_url": "https://api.github.com/users/binwiederhier/events{/privacy}",
"received_events_url": "https://api.github.com/users/binwiederhier/received_events",
"type": "User",
"user_view_type": "public",
"site_admin": false
},
"body": null,
"created_at": "2025-07-16T11:49:31Z",
"updated_at": "2025-07-16T11:49:31Z",
"closed_at": null,
"merged_at": null,
"merge_commit_sha": null,
"assignee": null,
"assignees": [
],
"requested_reviewers": [
],
"requested_teams": [
],
"labels": [
],
"milestone": null,
"draft": false,
"commits_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls/1390/commits",
"review_comments_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls/1390/comments",
"review_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls/comments{/number}",
"comments_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1390/comments",
"statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/b1e935da45365c5e7e731d544a1ad4c7ea3643cd",
"head": {
"label": "binwiederhier:template-dir",
"ref": "template-dir",
"sha": "b1e935da45365c5e7e731d544a1ad4c7ea3643cd",
"user": {
"login": "binwiederhier",
"id": 664597,
"node_id": "MDQ6VXNlcjY2NDU5Nw==",
"avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/binwiederhier",
"html_url": "https://github.com/binwiederhier",
"followers_url": "https://api.github.com/users/binwiederhier/followers",
"following_url": "https://api.github.com/users/binwiederhier/following{/other_user}",
"gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}",
"starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions",
"organizations_url": "https://api.github.com/users/binwiederhier/orgs",
"repos_url": "https://api.github.com/users/binwiederhier/repos",
"events_url": "https://api.github.com/users/binwiederhier/events{/privacy}",
"received_events_url": "https://api.github.com/users/binwiederhier/received_events",
"type": "User",
"user_view_type": "public",
"site_admin": false
},
"repo": {
"id": 420503947,
"node_id": "R_kgDOGRBhiw",
"name": "ntfy",
"full_name": "binwiederhier/ntfy",
"private": false,
"owner": {
"login": "binwiederhier",
"id": 664597,
"node_id": "MDQ6VXNlcjY2NDU5Nw==",
"avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/binwiederhier",
"html_url": "https://github.com/binwiederhier",
"followers_url": "https://api.github.com/users/binwiederhier/followers",
"following_url": "https://api.github.com/users/binwiederhier/following{/other_user}",
"gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}",
"starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions",
"organizations_url": "https://api.github.com/users/binwiederhier/orgs",
"repos_url": "https://api.github.com/users/binwiederhier/repos",
"events_url": "https://api.github.com/users/binwiederhier/events{/privacy}",
"received_events_url": "https://api.github.com/users/binwiederhier/received_events",
"type": "User",
"user_view_type": "public",
"site_admin": false
},
"html_url": "https://github.com/binwiederhier/ntfy",
"description": "Send push notifications to your phone or desktop using PUT/POST",
"fork": false,
"url": "https://api.github.com/repos/binwiederhier/ntfy",
"forks_url": "https://api.github.com/repos/binwiederhier/ntfy/forks",
"keys_url": "https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}",
"collaborators_url": "https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}",
"teams_url": "https://api.github.com/repos/binwiederhier/ntfy/teams",
"hooks_url": "https://api.github.com/repos/binwiederhier/ntfy/hooks",
"issue_events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}",
"events_url": "https://api.github.com/repos/binwiederhier/ntfy/events",
"assignees_url": "https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}",
"branches_url": "https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}",
"tags_url": "https://api.github.com/repos/binwiederhier/ntfy/tags",
"blobs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}",
"git_tags_url": "https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}",
"git_refs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}",
"trees_url": "https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}",
"statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}",
"languages_url": "https://api.github.com/repos/binwiederhier/ntfy/languages",
"stargazers_url": "https://api.github.com/repos/binwiederhier/ntfy/stargazers",
"contributors_url": "https://api.github.com/repos/binwiederhier/ntfy/contributors",
"subscribers_url": "https://api.github.com/repos/binwiederhier/ntfy/subscribers",
"subscription_url": "https://api.github.com/repos/binwiederhier/ntfy/subscription",
"commits_url": "https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}",
"git_commits_url": "https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}",
"comments_url": "https://api.github.com/repos/binwiederhier/ntfy/comments{/number}",
"issue_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}",
"contents_url": "https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}",
"compare_url": "https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}",
"merges_url": "https://api.github.com/repos/binwiederhier/ntfy/merges",
"archive_url": "https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}",
"downloads_url": "https://api.github.com/repos/binwiederhier/ntfy/downloads",
"issues_url": "https://api.github.com/repos/binwiederhier/ntfy/issues{/number}",
"pulls_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}",
"milestones_url": "https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}",
"notifications_url": "https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}",
"labels_url": "https://api.github.com/repos/binwiederhier/ntfy/labels{/name}",
"releases_url": "https://api.github.com/repos/binwiederhier/ntfy/releases{/id}",
"deployments_url": "https://api.github.com/repos/binwiederhier/ntfy/deployments",
"created_at": "2021-10-23T19:25:32Z",
"updated_at": "2025-07-16T10:18:34Z",
"pushed_at": "2025-07-16T11:49:26Z",
"git_url": "git://github.com/binwiederhier/ntfy.git",
"ssh_url": "git@github.com:binwiederhier/ntfy.git",
"clone_url": "https://github.com/binwiederhier/ntfy.git",
"svn_url": "https://github.com/binwiederhier/ntfy",
"homepage": "https://ntfy.sh",
"size": 36740,
"stargazers_count": 25111,
"watchers_count": 25111,
"language": "Go",
"has_issues": true,
"has_projects": true,
"has_downloads": true,
"has_wiki": true,
"has_pages": false,
"has_discussions": false,
"forks_count": 984,
"mirror_url": null,
"archived": false,
"disabled": false,
"open_issues_count": 368,
"license": {
"key": "apache-2.0",
"name": "Apache License 2.0",
"spdx_id": "Apache-2.0",
"url": "https://api.github.com/licenses/apache-2.0",
"node_id": "MDc6TGljZW5zZTI="
},
"allow_forking": true,
"is_template": false,
"web_commit_signoff_required": false,
"topics": [
"curl",
"notifications",
"ntfy",
"ntfysh",
"pubsub",
"push-notifications",
"rest-api"
],
"visibility": "public",
"forks": 984,
"open_issues": 368,
"watchers": 25111,
"default_branch": "main",
"allow_squash_merge": true,
"allow_merge_commit": true,
"allow_rebase_merge": true,
"allow_auto_merge": true,
"delete_branch_on_merge": false,
"allow_update_branch": false,
"use_squash_pr_title_as_default": false,
"squash_merge_commit_message": "COMMIT_MESSAGES",
"squash_merge_commit_title": "COMMIT_OR_PR_TITLE",
"merge_commit_message": "PR_TITLE",
"merge_commit_title": "MERGE_MESSAGE"
}
},
"base": {
"label": "binwiederhier:main",
"ref": "main",
"sha": "81a486adc11fe24efcbedefb28ae946028597c2f",
"user": {
"login": "binwiederhier",
"id": 664597,
"node_id": "MDQ6VXNlcjY2NDU5Nw==",
"avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/binwiederhier",
"html_url": "https://github.com/binwiederhier",
"followers_url": "https://api.github.com/users/binwiederhier/followers",
"following_url": "https://api.github.com/users/binwiederhier/following{/other_user}",
"gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}",
"starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions",
"organizations_url": "https://api.github.com/users/binwiederhier/orgs",
"repos_url": "https://api.github.com/users/binwiederhier/repos",
"events_url": "https://api.github.com/users/binwiederhier/events{/privacy}",
"received_events_url": "https://api.github.com/users/binwiederhier/received_events",
"type": "User",
"user_view_type": "public",
"site_admin": false
},
"repo": {
"id": 420503947,
"node_id": "R_kgDOGRBhiw",
"name": "ntfy",
"full_name": "binwiederhier/ntfy",
"private": false,
"owner": {
"login": "binwiederhier",
"id": 664597,
"node_id": "MDQ6VXNlcjY2NDU5Nw==",
"avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/binwiederhier",
"html_url": "https://github.com/binwiederhier",
"followers_url": "https://api.github.com/users/binwiederhier/followers",
"following_url": "https://api.github.com/users/binwiederhier/following{/other_user}",
"gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}",
"starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions",
"organizations_url": "https://api.github.com/users/binwiederhier/orgs",
"repos_url": "https://api.github.com/users/binwiederhier/repos",
"events_url": "https://api.github.com/users/binwiederhier/events{/privacy}",
"received_events_url": "https://api.github.com/users/binwiederhier/received_events",
"type": "User",
"user_view_type": "public",
"site_admin": false
},
"html_url": "https://github.com/binwiederhier/ntfy",
"description": "Send push notifications to your phone or desktop using PUT/POST",
"fork": false,
"url": "https://api.github.com/repos/binwiederhier/ntfy",
"forks_url": "https://api.github.com/repos/binwiederhier/ntfy/forks",
"keys_url": "https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}",
"collaborators_url": "https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}",
"teams_url": "https://api.github.com/repos/binwiederhier/ntfy/teams",
"hooks_url": "https://api.github.com/repos/binwiederhier/ntfy/hooks",
"issue_events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}",
"events_url": "https://api.github.com/repos/binwiederhier/ntfy/events",
"assignees_url": "https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}",
"branches_url": "https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}",
"tags_url": "https://api.github.com/repos/binwiederhier/ntfy/tags",
"blobs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}",
"git_tags_url": "https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}",
"git_refs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}",
"trees_url": "https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}",
"statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}",
"languages_url": "https://api.github.com/repos/binwiederhier/ntfy/languages",
"stargazers_url": "https://api.github.com/repos/binwiederhier/ntfy/stargazers",
"contributors_url": "https://api.github.com/repos/binwiederhier/ntfy/contributors",
"subscribers_url": "https://api.github.com/repos/binwiederhier/ntfy/subscribers",
"subscription_url": "https://api.github.com/repos/binwiederhier/ntfy/subscription",
"commits_url": "https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}",
"git_commits_url": "https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}",
"comments_url": "https://api.github.com/repos/binwiederhier/ntfy/comments{/number}",
"issue_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}",
"contents_url": "https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}",
"compare_url": "https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}",
"merges_url": "https://api.github.com/repos/binwiederhier/ntfy/merges",
"archive_url": "https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}",
"downloads_url": "https://api.github.com/repos/binwiederhier/ntfy/downloads",
"issues_url": "https://api.github.com/repos/binwiederhier/ntfy/issues{/number}",
"pulls_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}",
"milestones_url": "https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}",
"notifications_url": "https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}",
"labels_url": "https://api.github.com/repos/binwiederhier/ntfy/labels{/name}",
"releases_url": "https://api.github.com/repos/binwiederhier/ntfy/releases{/id}",
"deployments_url": "https://api.github.com/repos/binwiederhier/ntfy/deployments",
"created_at": "2021-10-23T19:25:32Z",
"updated_at": "2025-07-16T10:18:34Z",
"pushed_at": "2025-07-16T11:49:26Z",
"git_url": "git://github.com/binwiederhier/ntfy.git",
"ssh_url": "git@github.com:binwiederhier/ntfy.git",
"clone_url": "https://github.com/binwiederhier/ntfy.git",
"svn_url": "https://github.com/binwiederhier/ntfy",
"homepage": "https://ntfy.sh",
"size": 36740,
"stargazers_count": 25111,
"watchers_count": 25111,
"language": "Go",
"has_issues": true,
"has_projects": true,
"has_downloads": true,
"has_wiki": true,
"has_pages": false,
"has_discussions": false,
"forks_count": 984,
"mirror_url": null,
"archived": false,
"disabled": false,
"open_issues_count": 368,
"license": {
"key": "apache-2.0",
"name": "Apache License 2.0",
"spdx_id": "Apache-2.0",
"url": "https://api.github.com/licenses/apache-2.0",
"node_id": "MDc6TGljZW5zZTI="
},
"allow_forking": true,
"is_template": false,
"web_commit_signoff_required": false,
"topics": [
"curl",
"notifications",
"ntfy",
"ntfysh",
"pubsub",
"push-notifications",
"rest-api"
],
"visibility": "public",
"forks": 984,
"open_issues": 368,
"watchers": 25111,
"default_branch": "main",
"allow_squash_merge": true,
"allow_merge_commit": true,
"allow_rebase_merge": true,
"allow_auto_merge": true,
"delete_branch_on_merge": false,
"allow_update_branch": false,
"use_squash_pr_title_as_default": false,
"squash_merge_commit_message": "COMMIT_MESSAGES",
"squash_merge_commit_title": "COMMIT_OR_PR_TITLE",
"merge_commit_message": "PR_TITLE",
"merge_commit_title": "MERGE_MESSAGE"
}
},
"_links": {
"self": {
"href": "https://api.github.com/repos/binwiederhier/ntfy/pulls/1390"
},
"html": {
"href": "https://github.com/binwiederhier/ntfy/pull/1390"
},
"issue": {
"href": "https://api.github.com/repos/binwiederhier/ntfy/issues/1390"
},
"comments": {
"href": "https://api.github.com/repos/binwiederhier/ntfy/issues/1390/comments"
},
"review_comments": {
"href": "https://api.github.com/repos/binwiederhier/ntfy/pulls/1390/comments"
},
"review_comment": {
"href": "https://api.github.com/repos/binwiederhier/ntfy/pulls/comments{/number}"
},
"commits": {
"href": "https://api.github.com/repos/binwiederhier/ntfy/pulls/1390/commits"
},
"statuses": {
"href": "https://api.github.com/repos/binwiederhier/ntfy/statuses/b1e935da45365c5e7e731d544a1ad4c7ea3643cd"
}
},
"author_association": "OWNER",
"auto_merge": null,
"active_lock_reason": null,
"merged": false,
"mergeable": null,
"rebaseable": null,
"mergeable_state": "unknown",
"merged_by": null,
"comments": 0,
"review_comments": 0,
"maintainer_can_modify": false,
"commits": 7,
"additions": 5506,
"deletions": 42,
"changed_files": 58
},
"repository": {
"id": 420503947,
"node_id": "R_kgDOGRBhiw",
"name": "ntfy",
"full_name": "binwiederhier/ntfy",
"private": false,
"owner": {
"login": "binwiederhier",
"id": 664597,
"node_id": "MDQ6VXNlcjY2NDU5Nw==",
"avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/binwiederhier",
"html_url": "https://github.com/binwiederhier",
"followers_url": "https://api.github.com/users/binwiederhier/followers",
"following_url": "https://api.github.com/users/binwiederhier/following{/other_user}",
"gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}",
"starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions",
"organizations_url": "https://api.github.com/users/binwiederhier/orgs",
"repos_url": "https://api.github.com/users/binwiederhier/repos",
"events_url": "https://api.github.com/users/binwiederhier/events{/privacy}",
"received_events_url": "https://api.github.com/users/binwiederhier/received_events",
"type": "User",
"user_view_type": "public",
"site_admin": false
},
"html_url": "https://github.com/binwiederhier/ntfy",
"description": "Send push notifications to your phone or desktop using PUT/POST",
"fork": false,
"url": "https://api.github.com/repos/binwiederhier/ntfy",
"forks_url": "https://api.github.com/repos/binwiederhier/ntfy/forks",
"keys_url": "https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}",
"collaborators_url": "https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}",
"teams_url": "https://api.github.com/repos/binwiederhier/ntfy/teams",
"hooks_url": "https://api.github.com/repos/binwiederhier/ntfy/hooks",
"issue_events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}",
"events_url": "https://api.github.com/repos/binwiederhier/ntfy/events",
"assignees_url": "https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}",
"branches_url": "https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}",
"tags_url": "https://api.github.com/repos/binwiederhier/ntfy/tags",
"blobs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}",
"git_tags_url": "https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}",
"git_refs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}",
"trees_url": "https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}",
"statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}",
"languages_url": "https://api.github.com/repos/binwiederhier/ntfy/languages",
"stargazers_url": "https://api.github.com/repos/binwiederhier/ntfy/stargazers",
"contributors_url": "https://api.github.com/repos/binwiederhier/ntfy/contributors",
"subscribers_url": "https://api.github.com/repos/binwiederhier/ntfy/subscribers",
"subscription_url": "https://api.github.com/repos/binwiederhier/ntfy/subscription",
"commits_url": "https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}",
"git_commits_url": "https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}",
"comments_url": "https://api.github.com/repos/binwiederhier/ntfy/comments{/number}",
"issue_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}",
"contents_url": "https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}",
"compare_url": "https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}",
"merges_url": "https://api.github.com/repos/binwiederhier/ntfy/merges",
"archive_url": "https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}",
"downloads_url": "https://api.github.com/repos/binwiederhier/ntfy/downloads",
"issues_url": "https://api.github.com/repos/binwiederhier/ntfy/issues{/number}",
"pulls_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}",
"milestones_url": "https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}",
"notifications_url": "https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}",
"labels_url": "https://api.github.com/repos/binwiederhier/ntfy/labels{/name}",
"releases_url": "https://api.github.com/repos/binwiederhier/ntfy/releases{/id}",
"deployments_url": "https://api.github.com/repos/binwiederhier/ntfy/deployments",
"created_at": "2021-10-23T19:25:32Z",
"updated_at": "2025-07-16T10:18:34Z",
"pushed_at": "2025-07-16T11:49:26Z",
"git_url": "git://github.com/binwiederhier/ntfy.git",
"ssh_url": "git@github.com:binwiederhier/ntfy.git",
"clone_url": "https://github.com/binwiederhier/ntfy.git",
"svn_url": "https://github.com/binwiederhier/ntfy",
"homepage": "https://ntfy.sh",
"size": 36740,
"stargazers_count": 25111,
"watchers_count": 25111,
"language": "Go",
"has_issues": true,
"has_projects": true,
"has_downloads": true,
"has_wiki": true,
"has_pages": false,
"has_discussions": false,
"forks_count": 984,
"mirror_url": null,
"archived": false,
"disabled": false,
"open_issues_count": 368,
"license": {
"key": "apache-2.0",
"name": "Apache License 2.0",
"spdx_id": "Apache-2.0",
"url": "https://api.github.com/licenses/apache-2.0",
"node_id": "MDc6TGljZW5zZTI="
},
"allow_forking": true,
"is_template": false,
"web_commit_signoff_required": false,
"topics": [
"curl",
"notifications",
"ntfy",
"ntfysh",
"pubsub",
"push-notifications",
"rest-api"
],
"visibility": "public",
"forks": 984,
"open_issues": 368,
"watchers": 25111,
"default_branch": "main"
},
"sender": {
"login": "binwiederhier",
"id": 664597,
"node_id": "MDQ6VXNlcjY2NDU5Nw==",
"avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/binwiederhier",
"html_url": "https://github.com/binwiederhier",
"followers_url": "https://api.github.com/users/binwiederhier/followers",
"following_url": "https://api.github.com/users/binwiederhier/following{/other_user}",
"gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}",
"starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions",
"organizations_url": "https://api.github.com/users/binwiederhier/orgs",
"repos_url": "https://api.github.com/users/binwiederhier/repos",
"events_url": "https://api.github.com/users/binwiederhier/events{/privacy}",
"received_events_url": "https://api.github.com/users/binwiederhier/received_events",
"type": "User",
"user_view_type": "public",
"site_admin": false
}
}

View File

@@ -0,0 +1,141 @@
{
"action": "created",
"starred_at": "2025-07-16T12:57:43Z",
"repository": {
"id": 420503947,
"node_id": "R_kgDOGRBhiw",
"name": "ntfy",
"full_name": "binwiederhier/ntfy",
"private": false,
"owner": {
"login": "binwiederhier",
"id": 664597,
"node_id": "MDQ6VXNlcjY2NDU5Nw==",
"avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/binwiederhier",
"html_url": "https://github.com/binwiederhier",
"followers_url": "https://api.github.com/users/binwiederhier/followers",
"following_url": "https://api.github.com/users/binwiederhier/following{/other_user}",
"gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}",
"starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions",
"organizations_url": "https://api.github.com/users/binwiederhier/orgs",
"repos_url": "https://api.github.com/users/binwiederhier/repos",
"events_url": "https://api.github.com/users/binwiederhier/events{/privacy}",
"received_events_url": "https://api.github.com/users/binwiederhier/received_events",
"type": "User",
"user_view_type": "public",
"site_admin": false
},
"html_url": "https://github.com/binwiederhier/ntfy",
"description": "Send push notifications to your phone or desktop using PUT/POST",
"fork": false,
"url": "https://api.github.com/repos/binwiederhier/ntfy",
"forks_url": "https://api.github.com/repos/binwiederhier/ntfy/forks",
"keys_url": "https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}",
"collaborators_url": "https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}",
"teams_url": "https://api.github.com/repos/binwiederhier/ntfy/teams",
"hooks_url": "https://api.github.com/repos/binwiederhier/ntfy/hooks",
"issue_events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}",
"events_url": "https://api.github.com/repos/binwiederhier/ntfy/events",
"assignees_url": "https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}",
"branches_url": "https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}",
"tags_url": "https://api.github.com/repos/binwiederhier/ntfy/tags",
"blobs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}",
"git_tags_url": "https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}",
"git_refs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}",
"trees_url": "https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}",
"statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}",
"languages_url": "https://api.github.com/repos/binwiederhier/ntfy/languages",
"stargazers_url": "https://api.github.com/repos/binwiederhier/ntfy/stargazers",
"contributors_url": "https://api.github.com/repos/binwiederhier/ntfy/contributors",
"subscribers_url": "https://api.github.com/repos/binwiederhier/ntfy/subscribers",
"subscription_url": "https://api.github.com/repos/binwiederhier/ntfy/subscription",
"commits_url": "https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}",
"git_commits_url": "https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}",
"comments_url": "https://api.github.com/repos/binwiederhier/ntfy/comments{/number}",
"issue_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}",
"contents_url": "https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}",
"compare_url": "https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}",
"merges_url": "https://api.github.com/repos/binwiederhier/ntfy/merges",
"archive_url": "https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}",
"downloads_url": "https://api.github.com/repos/binwiederhier/ntfy/downloads",
"issues_url": "https://api.github.com/repos/binwiederhier/ntfy/issues{/number}",
"pulls_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}",
"milestones_url": "https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}",
"notifications_url": "https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}",
"labels_url": "https://api.github.com/repos/binwiederhier/ntfy/labels{/name}",
"releases_url": "https://api.github.com/repos/binwiederhier/ntfy/releases{/id}",
"deployments_url": "https://api.github.com/repos/binwiederhier/ntfy/deployments",
"created_at": "2021-10-23T19:25:32Z",
"updated_at": "2025-07-16T12:57:43Z",
"pushed_at": "2025-07-16T11:49:26Z",
"git_url": "git://github.com/binwiederhier/ntfy.git",
"ssh_url": "git@github.com:binwiederhier/ntfy.git",
"clone_url": "https://github.com/binwiederhier/ntfy.git",
"svn_url": "https://github.com/binwiederhier/ntfy",
"homepage": "https://ntfy.sh",
"size": 36831,
"stargazers_count": 25112,
"watchers_count": 25112,
"language": "Go",
"has_issues": true,
"has_projects": true,
"has_downloads": true,
"has_wiki": true,
"has_pages": false,
"has_discussions": false,
"forks_count": 984,
"mirror_url": null,
"archived": false,
"disabled": false,
"open_issues_count": 368,
"license": {
"key": "apache-2.0",
"name": "Apache License 2.0",
"spdx_id": "Apache-2.0",
"url": "https://api.github.com/licenses/apache-2.0",
"node_id": "MDc6TGljZW5zZTI="
},
"allow_forking": true,
"is_template": false,
"web_commit_signoff_required": false,
"topics": [
"curl",
"notifications",
"ntfy",
"ntfysh",
"pubsub",
"push-notifications",
"rest-api"
],
"visibility": "public",
"forks": 984,
"open_issues": 368,
"watchers": 25112,
"default_branch": "main"
},
"sender": {
"login": "mbilby",
"id": 51273322,
"node_id": "MDQ6VXNlcjUxMjczMzIy",
"avatar_url": "https://avatars.githubusercontent.com/u/51273322?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/mbilby",
"html_url": "https://github.com/mbilby",
"followers_url": "https://api.github.com/users/mbilby/followers",
"following_url": "https://api.github.com/users/mbilby/following{/other_user}",
"gists_url": "https://api.github.com/users/mbilby/gists{/gist_id}",
"starred_url": "https://api.github.com/users/mbilby/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/mbilby/subscriptions",
"organizations_url": "https://api.github.com/users/mbilby/orgs",
"repos_url": "https://api.github.com/users/mbilby/repos",
"events_url": "https://api.github.com/users/mbilby/events{/privacy}",
"received_events_url": "https://api.github.com/users/mbilby/received_events",
"type": "User",
"user_view_type": "public",
"site_admin": false
}
}

View File

@@ -0,0 +1,139 @@
{
"action": "started",
"repository": {
"id": 420503947,
"node_id": "R_kgDOGRBhiw",
"name": "ntfy",
"full_name": "binwiederhier/ntfy",
"private": false,
"owner": {
"login": "binwiederhier",
"id": 664597,
"node_id": "MDQ6VXNlcjY2NDU5Nw==",
"avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/binwiederhier",
"html_url": "https://github.com/binwiederhier",
"followers_url": "https://api.github.com/users/binwiederhier/followers",
"following_url": "https://api.github.com/users/binwiederhier/following{/other_user}",
"gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}",
"starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions",
"organizations_url": "https://api.github.com/users/binwiederhier/orgs",
"repos_url": "https://api.github.com/users/binwiederhier/repos",
"events_url": "https://api.github.com/users/binwiederhier/events{/privacy}",
"received_events_url": "https://api.github.com/users/binwiederhier/received_events",
"type": "User",
"user_view_type": "public",
"site_admin": false
},
"html_url": "https://github.com/binwiederhier/ntfy",
"description": "Send push notifications to your phone or desktop using PUT/POST",
"fork": false,
"url": "https://api.github.com/repos/binwiederhier/ntfy",
"forks_url": "https://api.github.com/repos/binwiederhier/ntfy/forks",
"keys_url": "https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}",
"collaborators_url": "https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}",
"teams_url": "https://api.github.com/repos/binwiederhier/ntfy/teams",
"hooks_url": "https://api.github.com/repos/binwiederhier/ntfy/hooks",
"issue_events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}",
"events_url": "https://api.github.com/repos/binwiederhier/ntfy/events",
"assignees_url": "https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}",
"branches_url": "https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}",
"tags_url": "https://api.github.com/repos/binwiederhier/ntfy/tags",
"blobs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}",
"git_tags_url": "https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}",
"git_refs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}",
"trees_url": "https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}",
"statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}",
"languages_url": "https://api.github.com/repos/binwiederhier/ntfy/languages",
"stargazers_url": "https://api.github.com/repos/binwiederhier/ntfy/stargazers",
"contributors_url": "https://api.github.com/repos/binwiederhier/ntfy/contributors",
"subscribers_url": "https://api.github.com/repos/binwiederhier/ntfy/subscribers",
"subscription_url": "https://api.github.com/repos/binwiederhier/ntfy/subscription",
"commits_url": "https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}",
"git_commits_url": "https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}",
"comments_url": "https://api.github.com/repos/binwiederhier/ntfy/comments{/number}",
"issue_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}",
"contents_url": "https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}",
"compare_url": "https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}",
"merges_url": "https://api.github.com/repos/binwiederhier/ntfy/merges",
"archive_url": "https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}",
"downloads_url": "https://api.github.com/repos/binwiederhier/ntfy/downloads",
"issues_url": "https://api.github.com/repos/binwiederhier/ntfy/issues{/number}",
"pulls_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}",
"milestones_url": "https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}",
"notifications_url": "https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}",
"labels_url": "https://api.github.com/repos/binwiederhier/ntfy/labels{/name}",
"releases_url": "https://api.github.com/repos/binwiederhier/ntfy/releases{/id}",
"deployments_url": "https://api.github.com/repos/binwiederhier/ntfy/deployments",
"created_at": "2021-10-23T19:25:32Z",
"updated_at": "2025-07-16T12:57:43Z",
"pushed_at": "2025-07-16T11:49:26Z",
"git_url": "git://github.com/binwiederhier/ntfy.git",
"ssh_url": "git@github.com:binwiederhier/ntfy.git",
"clone_url": "https://github.com/binwiederhier/ntfy.git",
"svn_url": "https://github.com/binwiederhier/ntfy",
"homepage": "https://ntfy.sh",
"size": 36831,
"stargazers_count": 25112,
"watchers_count": 25112,
"language": "Go",
"has_issues": true,
"has_projects": true,
"has_downloads": true,
"has_wiki": true,
"has_pages": false,
"has_discussions": false,
"forks_count": 984,
"mirror_url": null,
"archived": false,
"disabled": false,
"open_issues_count": 368,
"license": {
"key": "apache-2.0",
"name": "Apache License 2.0",
"spdx_id": "Apache-2.0",
"url": "https://api.github.com/licenses/apache-2.0",
"node_id": "MDc6TGljZW5zZTI="
},
"allow_forking": true,
"is_template": false,
"web_commit_signoff_required": false,
"topics": [
"curl",
"notifications",
"ntfy",
"ntfysh",
"pubsub",
"push-notifications",
"rest-api"
],
"visibility": "public",
"forks": 984,
"open_issues": 368,
"watchers": 25112,
"default_branch": "main"
},
"sender": {
"login": "mbilby",
"id": 51273322,
"node_id": "MDQ6VXNlcjUxMjczMzIy",
"avatar_url": "https://avatars.githubusercontent.com/u/51273322?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/mbilby",
"html_url": "https://github.com/mbilby",
"followers_url": "https://api.github.com/users/mbilby/followers",
"following_url": "https://api.github.com/users/mbilby/following{/other_user}",
"gists_url": "https://api.github.com/users/mbilby/gists{/gist_id}",
"starred_url": "https://api.github.com/users/mbilby/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/mbilby/subscriptions",
"organizations_url": "https://api.github.com/users/mbilby/orgs",
"repos_url": "https://api.github.com/users/mbilby/repos",
"events_url": "https://api.github.com/users/mbilby/events{/privacy}",
"received_events_url": "https://api.github.com/users/mbilby/received_events",
"type": "User",
"user_view_type": "public",
"site_admin": false
}
}

View File

@@ -0,0 +1,51 @@
{
"receiver": "ntfy\\.example\\.com/alerts",
"status": "resolved",
"alerts": [
{
"status": "resolved",
"labels": {
"alertname": "Load avg 15m too high",
"grafana_folder": "Node alerts",
"instance": "10.108.0.2:9100",
"job": "node-exporter"
},
"annotations": {
"summary": "15m load average too high"
},
"startsAt": "2024-03-15T02:28:00Z",
"endsAt": "2024-03-15T02:42:00Z",
"generatorURL": "localhost:3000/alerting/grafana/NW9oDw-4z/view",
"fingerprint": "becbfb94bd81ef48",
"silenceURL": "localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter",
"dashboardURL": "",
"panelURL": "",
"values": {
"B": 18.98211314475876,
"C": 0
},
"valueString": "[ var='B' labels={__name__=node_load15, instance=10.108.0.2:9100, job=node-exporter} value=18.98211314475876 ], [ var='C' labels={__name__=node_load15, instance=10.108.0.2:9100, job=node-exporter} value=0 ]"
}
],
"groupLabels": {
"alertname": "Load avg 15m too high",
"grafana_folder": "Node alerts"
},
"commonLabels": {
"alertname": "Load avg 15m too high",
"grafana_folder": "Node alerts",
"instance": "10.108.0.2:9100",
"job": "node-exporter"
},
"commonAnnotations": {
"summary": "15m load average too high"
},
"externalURL": "localhost:3000/",
"version": "1",
"groupKey": "{}:{alertname=\"Load avg 15m too high\", grafana_folder=\"Node alerts\"}",
"truncatedAlerts": 0,
"orgId": 1,
"title": "[RESOLVED] Load avg 15m too high Node alerts (10.108.0.2:9100 node-exporter)",
"state": "ok",
"message": "**Resolved**\n\nValue: B=18.98211314475876, C=0\nLabels:\n - alertname = Load avg 15m too high\n - grafana_folder = Node alerts\n - instance = 10.108.0.2:9100\n - job = node-exporter\n"
}

View File

@@ -7,7 +7,6 @@ import (
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
)
@@ -246,6 +245,51 @@ func (q *queryFilter) Pass(msg *message) bool {
return true
}
// templateMode represents the mode in which templates are used
//
// It can be
// - empty: templating is disabled
// - a boolean string (yes/1/true/no/0/false): inline-templating mode
// - a filename (e.g. grafana): template mode with a file
type templateMode string
// Enabled returns true if templating is enabled
func (t templateMode) Enabled() bool {
return t != ""
}
// InlineMode returns true if inline-templating mode is enabled
func (t templateMode) InlineMode() bool {
return t.Enabled() && isBoolValue(string(t))
}
// FileMode returns true if file-templating mode is enabled
func (t templateMode) FileMode() bool {
return t.Enabled() && !isBoolValue(string(t))
}
// FileName returns the filename if file-templating mode is enabled, or an empty string otherwise
func (t templateMode) FileName() string {
if t.FileMode() {
return string(t)
}
return ""
}
// templateFile represents a template file with title and message
// It is used for file-based templates, e.g. grafana, influxdb, etc.
//
// Example YAML:
//
// title: "Alert: {{ .Title }}"
// message: |
// This is a {{ .Type }} alert.
// It can be multiline.
type templateFile struct {
Title *string `yaml:"title"`
Message *string `yaml:"message"`
}
type apiHealthResponse struct {
Healthy bool `json:"healthy"`
}
@@ -316,11 +360,12 @@ type apiAccountTokenUpdateRequest struct {
}
type apiAccountTokenResponse struct {
Token string `json:"token"`
Label string `json:"label,omitempty"`
LastAccess int64 `json:"last_access,omitempty"`
LastOrigin string `json:"last_origin,omitempty"`
Expires int64 `json:"expires,omitempty"` // Unix timestamp
Token string `json:"token"`
Label string `json:"label,omitempty"`
LastAccess int64 `json:"last_access,omitempty"`
LastOrigin string `json:"last_origin,omitempty"`
Expires int64 `json:"expires,omitempty"` // Unix timestamp
Provisioned bool `json:"provisioned,omitempty"` // True if this token was provisioned by the server config
}
type apiAccountPhoneNumberVerifyRequest struct {
@@ -382,6 +427,7 @@ type apiAccountResponse struct {
Username string `json:"username"`
Role string `json:"role,omitempty"`
SyncTopic string `json:"sync_topic,omitempty"`
Provisioned bool `json:"provisioned,omitempty"`
Language string `json:"language,omitempty"`
Notification *user.NotificationPrefs `json:"notification,omitempty"`
Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`

View File

@@ -4,14 +4,14 @@ import (
"context"
"errors"
"fmt"
"heckel.io/ntfy/v2/util"
"io"
"mime"
"net/http"
"net/netip"
"regexp"
"slices"
"strings"
"heckel.io/ntfy/v2/util"
)
var (
@@ -20,8 +20,14 @@ var (
// priorityHeaderIgnoreRegex matches specific patterns of the "Priority" header (RFC 9218), so that it can be ignored
priorityHeaderIgnoreRegex = regexp.MustCompile(`^u=\d,\s*(i|\d)$|^u=\d$`)
// forwardedHeaderRegex parses IPv4 addresses from the "Forwarded" header (RFC 7239)
forwardedHeaderRegex = regexp.MustCompile(`(?i)\bfor="?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})"?`)
// forwardedHeaderRegex parses IPv4 and IPv6 addresses from the "Forwarded" header (RFC 7239)
// IPv6 addresses in Forwarded header are enclosed in square brackets. The port is optional.
//
// Examples:
// for="1.2.3.4"
// for="[2001:db8::1]"; for=1.2.3.4:8080, by=phil
// for="1.2.3.4:8080"
forwardedHeaderRegex = regexp.MustCompile(`(?i)\bfor="?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|\[[0-9a-f:]+])(?::\d+)?"?`)
)
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
@@ -77,9 +83,9 @@ func readQueryParam(r *http.Request, names ...string) string {
// extractIPAddress extracts the IP address of the visitor from the request,
// either from the TCP socket or from a proxy header.
func extractIPAddress(r *http.Request, behindProxy bool, proxyForwardedHeader string, proxyTrustedAddresses []string) netip.Addr {
func extractIPAddress(r *http.Request, behindProxy bool, proxyForwardedHeader string, proxyTrustedPrefixes []netip.Prefix) netip.Addr {
if behindProxy && proxyForwardedHeader != "" {
if addr, err := extractIPAddressFromHeader(r, proxyForwardedHeader, proxyTrustedAddresses); err == nil {
if addr, err := extractIPAddressFromHeader(r, proxyForwardedHeader, proxyTrustedPrefixes); err == nil {
return addr
}
// Fall back to the remote address if the header is not found or invalid
@@ -102,7 +108,7 @@ func extractIPAddress(r *http.Request, behindProxy bool, proxyForwardedHeader st
// If there are multiple addresses, we first remove the trusted IP addresses from the list, and
// then take the right-most address in the list (as this is the one added by our proxy server).
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For for details.
func extractIPAddressFromHeader(r *http.Request, forwardedHeader string, trustedAddresses []string) (netip.Addr, error) {
func extractIPAddressFromHeader(r *http.Request, forwardedHeader string, trustedPrefixes []netip.Prefix) (netip.Addr, error) {
value := strings.TrimSpace(strings.ToLower(r.Header.Get(forwardedHeader)))
if value == "" {
return netip.IPv4Unspecified(), fmt.Errorf("no %s header found", forwardedHeader)
@@ -111,17 +117,27 @@ func extractIPAddressFromHeader(r *http.Request, forwardedHeader string, trusted
addrsStrs := util.Map(util.SplitNoEmpty(value, ","), strings.TrimSpace)
var validAddrs []netip.Addr
for _, addrStr := range addrsStrs {
if addr, err := netip.ParseAddr(addrStr); err == nil {
validAddrs = append(validAddrs, addr)
} else if m := forwardedHeaderRegex.FindStringSubmatch(addrStr); len(m) == 2 {
if addr, err := netip.ParseAddr(m[1]); err == nil {
// Handle Forwarded header with for="[IPv6]" or for="IPv4"
if m := forwardedHeaderRegex.FindStringSubmatch(addrStr); len(m) == 2 {
addrRaw := m[1]
if strings.HasPrefix(addrRaw, "[") && strings.HasSuffix(addrRaw, "]") {
addrRaw = addrRaw[1 : len(addrRaw)-1]
}
if addr, err := netip.ParseAddr(addrRaw); err == nil {
validAddrs = append(validAddrs, addr)
}
} else if addr, err := netip.ParseAddr(addrStr); err == nil {
validAddrs = append(validAddrs, addr)
}
}
// Filter out proxy addresses
clientAddrs := util.Filter(validAddrs, func(addr netip.Addr) bool {
return !slices.Contains(trustedAddresses, addr.String())
for _, prefix := range trustedPrefixes {
if prefix.Contains(addr) {
return false // Address is in the trusted range, ignore it
}
}
return true
})
if len(clientAddrs) == 0 {
return netip.IPv4Unspecified(), fmt.Errorf("no client IP address found in %s header: %s", forwardedHeader, value)

View File

@@ -4,10 +4,13 @@ import (
"bytes"
"crypto/rand"
"fmt"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/user"
"net/http"
"net/netip"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestReadBoolParam(t *testing.T) {
@@ -97,7 +100,7 @@ func TestExtractIPAddress(t *testing.T) {
r.Header.Set("X-Real-IP", "13.14.15.16, 1.1.1.1")
r.Header.Set("Forwarded", "for=17.18.19.20;by=proxy.example.com, by=2.2.2.2;for=1.1.1.1")
trustedProxies := []string{"1.1.1.1"}
trustedProxies := []netip.Prefix{netip.MustParsePrefix("1.1.1.1/32")}
require.Equal(t, "5.6.7.8", extractIPAddress(r, true, "X-Forwarded-For", trustedProxies).String())
require.Equal(t, "9.10.11.12", extractIPAddress(r, true, "X-Client-IP", trustedProxies).String())
@@ -112,9 +115,50 @@ func TestExtractIPAddress_UnixSocket(t *testing.T) {
r.Header.Set("X-Forwarded-For", "1.2.3.4, 5.6.7.8, 1.1.1.1")
r.Header.Set("Forwarded", "by=bla.example.com;for=17.18.19.20")
trustedProxies := []string{"1.1.1.1"}
trustedProxies := []netip.Prefix{netip.MustParsePrefix("1.1.1.1/32")}
require.Equal(t, "5.6.7.8", extractIPAddress(r, true, "X-Forwarded-For", trustedProxies).String())
require.Equal(t, "17.18.19.20", extractIPAddress(r, true, "Forwarded", trustedProxies).String())
require.Equal(t, "0.0.0.0", extractIPAddress(r, false, "X-Forwarded-For", trustedProxies).String())
}
func TestExtractIPAddress_MixedIPv4IPv6(t *testing.T) {
r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", nil)
r.RemoteAddr = "[2001:db8:abcd::1]:1234"
r.Header.Set("X-Forwarded-For", "1.2.3.4, 2001:db8:abcd::2, 5.6.7.8")
trustedProxies := []netip.Prefix{netip.MustParsePrefix("1.2.3.0/24")}
require.Equal(t, "5.6.7.8", extractIPAddress(r, true, "X-Forwarded-For", trustedProxies).String())
}
func TestExtractIPAddress_TrustedIPv6Prefix(t *testing.T) {
r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", nil)
r.RemoteAddr = "[2001:db8:abcd::1]:1234"
r.Header.Set("X-Forwarded-For", "2001:db8:aaaa::1, 2001:db8:aaaa::2, 2001:db8:abcd:2::3")
trustedProxies := []netip.Prefix{netip.MustParsePrefix("2001:db8:aaaa::/48")}
require.Equal(t, "2001:db8:abcd:2::3", extractIPAddress(r, true, "X-Forwarded-For", trustedProxies).String())
}
func TestVisitorID(t *testing.T) {
confWithDefaults := &Config{
VisitorPrefixBitsIPv4: 32,
VisitorPrefixBitsIPv6: 64,
}
confWithShortenedPrefixes := &Config{
VisitorPrefixBitsIPv4: 16,
VisitorPrefixBitsIPv6: 56,
}
userWithTier := &user.User{
ID: "u_123",
Tier: &user.Tier{},
}
require.Equal(t, "ip:1.2.3.4", visitorID(netip.MustParseAddr("1.2.3.4"), nil, confWithDefaults))
require.Equal(t, "ip:2a01:599:b26:2397::", visitorID(netip.MustParseAddr("2a01:599:b26:2397:dbe7:5aa2:95ce:1e83"), nil, confWithDefaults))
require.Equal(t, "ip:2001:db8:25:86::", visitorID(netip.MustParseAddr("2001:db8:25:86:1::1"), nil, confWithDefaults))
require.Equal(t, "ip:2001:db8:25:86::", visitorID(netip.MustParseAddr("2001:db8:25:86:2::1"), nil, confWithDefaults))
require.Equal(t, "user:u_123", visitorID(netip.MustParseAddr("1.2.3.4"), userWithTier, confWithDefaults))
require.Equal(t, "user:u_123", visitorID(netip.MustParseAddr("2a01:599:b26:2397:dbe7:5aa2:95ce:1e83"), userWithTier, confWithDefaults))
require.Equal(t, "ip:1.2.0.0", visitorID(netip.MustParseAddr("1.2.3.4"), nil, confWithShortenedPrefixes))
require.Equal(t, "ip:2a01:599:b26:2300::", visitorID(netip.MustParseAddr("2a01:599:b26:2397:dbe7:5aa2:95ce:1e83"), nil, confWithShortenedPrefixes))
}

View File

@@ -2,13 +2,13 @@ package server
import (
"fmt"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/user"
"net/netip"
"sync"
"time"
"golang.org/x/time/rate"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
)
@@ -151,7 +151,7 @@ func (v *visitor) Context() log.Context {
func (v *visitor) contextNoLock() log.Context {
info := v.infoLightNoLock()
fields := log.Context{
"visitor_id": visitorID(v.ip, v.user),
"visitor_id": visitorID(v.ip, v.user, v.config),
"visitor_ip": v.ip.String(),
"visitor_seen": util.FormatTime(v.seen),
"visitor_messages": info.Stats.Messages,
@@ -524,9 +524,15 @@ func dailyLimitToRate(limit int64) rate.Limit {
return rate.Limit(limit) * rate.Every(oneDay)
}
func visitorID(ip netip.Addr, u *user.User) string {
// visitorID returns a unique identifier for a visitor based on user or IP, using configurable prefix bits for IPv4/IPv6
func visitorID(ip netip.Addr, u *user.User, conf *Config) string {
if u != nil && u.Tier != nil {
return fmt.Sprintf("user:%s", u.ID)
}
if ip.Is4() {
ip = netip.PrefixFrom(ip, conf.VisitorPrefixBitsIPv4).Masked().Addr()
} else if ip.Is6() {
ip = netip.PrefixFrom(ip, conf.VisitorPrefixBitsIPv6).Masked().Addr()
}
return fmt.Sprintf("ip:%s", ip.String())
}