diff --git a/.goreleaser.yml b/.goreleaser.yml
index f0cf08f6..3c4e9c76 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -48,13 +48,15 @@ builds:
- id: ntfy_windows_amd64
binary: ntfy
env:
- - CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
- tags: [ noserver ] # don't include server files
+ - CGO_ENABLED=1 # required for go-sqlite3
+ - CC=x86_64-w64-mingw32-gcc # apt install gcc-mingw-w64-x86-64
+ tags: [ sqlite_omit_load_extension,osusergo,netgo ]
ldflags:
- - "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
+ - "-s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
goos: [ windows ]
- goarch: [ amd64 ]
- - id: ntfy_darwin_all
+ goarch: [amd64 ]
+ -
+ id: ntfy_darwin_all
binary: ntfy
env:
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
@@ -201,4 +203,4 @@ docker_manifests:
- *amd64_image
- *arm64v8_image
- *armv7_image
- - *armv6_image
\ No newline at end of file
+ - *armv6_image
diff --git a/Makefile b/Makefile
index df131c7a..ed16cabc 100644
--- a/Makefile
+++ b/Makefile
@@ -31,6 +31,7 @@ help:
@echo "Build server & client (without GoReleaser):"
@echo " make cli-linux-server - Build client & server (no GoReleaser, current arch, Linux)"
@echo " make cli-darwin-server - Build client & server (no GoReleaser, current arch, macOS)"
+ @echo " make cli-windows-server - Build client & server (no GoReleaser, amd64 only, Windows)"
@echo " make cli-client - Build client only (no GoReleaser, current arch, Linux/macOS/Windows)"
@echo
@echo "Build dev Docker:"
@@ -106,6 +107,7 @@ build-deps-ubuntu:
curl \
gcc-aarch64-linux-gnu \
gcc-arm-linux-gnueabi \
+ gcc-mingw-w64-x86-64 \
python3 \
python3-venv \
jq
@@ -201,6 +203,16 @@ cli-darwin-server: cli-deps-static-sites
-ldflags \
"-linkmode=external -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)"
+cli-windows-server: cli-deps-static-sites
+ # This is a target to build the CLI (including the server) for Windows.
+ # Use this for Windows development, if you really don't want to install GoReleaser ...
+ mkdir -p dist/ntfy_windows_server server/docs
+ CC=x86_64-w64-mingw32-gcc GOOS=windows GOARCH=amd64 CGO_ENABLED=1 go build \
+ -o dist/ntfy_windows_server/ntfy.exe \
+ -tags sqlite_omit_load_extension,osusergo,netgo \
+ -ldflags \
+ "-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)"
+
cli-client: cli-deps-static-sites
# This is a target to build the CLI (excluding the server) manually. This should work on Linux/macOS/Windows.
# Use this for development, if you really don't want to install GoReleaser ...
@@ -213,7 +225,7 @@ cli-client: cli-deps-static-sites
cli-deps: cli-deps-static-sites cli-deps-all cli-deps-gcc
-cli-deps-gcc: cli-deps-gcc-armv6-armv7 cli-deps-gcc-arm64
+cli-deps-gcc: cli-deps-gcc-armv6-armv7 cli-deps-gcc-arm64 cli-deps-gcc-windows
cli-deps-static-sites:
mkdir -p server/docs server/site
@@ -228,6 +240,9 @@ cli-deps-gcc-armv6-armv7:
cli-deps-gcc-arm64:
which aarch64-linux-gnu-gcc || { echo "ERROR: ARM64 cross compiler not installed. On Ubuntu, run: apt install gcc-aarch64-linux-gnu"; exit 1; }
+cli-deps-gcc-windows:
+ which x86_64-w64-mingw32-gcc || { echo "ERROR: Windows cross compiler not installed. On Ubuntu, run: apt install gcc-mingw-w64-x86-64"; exit 1; }
+
cli-deps-update:
go get -u
go install honnef.co/go/tools/cmd/staticcheck@latest
diff --git a/client/config.go b/client/config.go
index 870c835b..444460d6 100644
--- a/client/config.go
+++ b/client/config.go
@@ -11,6 +11,9 @@ const (
DefaultBaseURL = "https://ntfy.sh"
)
+// DefaultConfigFile is the default path to the client config file (set in config_*.go)
+var DefaultConfigFile string
+
// Config is the config struct for a Client
type Config struct {
DefaultHost string `yaml:"default-host"`
diff --git a/client/config_darwin.go b/client/config_darwin.go
new file mode 100644
index 00000000..c2488849
--- /dev/null
+++ b/client/config_darwin.go
@@ -0,0 +1,18 @@
+//go:build darwin
+
+package client
+
+import (
+ "os"
+ "os/user"
+ "path/filepath"
+)
+
+func init() {
+ u, err := user.Current()
+ if err == nil && u.Uid == "0" {
+ DefaultConfigFile = "/etc/ntfy/client.yml"
+ } else if configDir, err := os.UserConfigDir(); err == nil {
+ DefaultConfigFile = filepath.Join(configDir, "ntfy", "client.yml")
+ }
+}
diff --git a/client/config_unix.go b/client/config_unix.go
new file mode 100644
index 00000000..273340e1
--- /dev/null
+++ b/client/config_unix.go
@@ -0,0 +1,18 @@
+//go:build linux || dragonfly || freebsd || netbsd || openbsd
+
+package client
+
+import (
+ "os"
+ "os/user"
+ "path/filepath"
+)
+
+func init() {
+ u, err := user.Current()
+ if err == nil && u.Uid == "0" {
+ DefaultConfigFile = "/etc/ntfy/client.yml"
+ } else if configDir, err := os.UserConfigDir(); err == nil {
+ DefaultConfigFile = filepath.Join(configDir, "ntfy", "client.yml")
+ }
+}
diff --git a/client/config_windows.go b/client/config_windows.go
new file mode 100644
index 00000000..2ee55328
--- /dev/null
+++ b/client/config_windows.go
@@ -0,0 +1,14 @@
+//go:build windows
+
+package client
+
+import (
+ "os"
+ "path/filepath"
+)
+
+func init() {
+ if configDir, err := os.UserConfigDir(); err == nil {
+ DefaultConfigFile = filepath.Join(configDir, "ntfy", "client.yml")
+ }
+}
diff --git a/client/options.go b/client/options.go
index f4711834..b99f1673 100644
--- a/client/options.go
+++ b/client/options.go
@@ -88,6 +88,11 @@ func WithFilename(filename string) PublishOption {
return WithHeader("X-Filename", filename)
}
+// WithSequenceID sets a sequence ID for the message, allowing updates to existing notifications
+func WithSequenceID(sequenceID string) PublishOption {
+ return WithHeader("X-Sequence-ID", sequenceID)
+}
+
// WithEmail instructs the server to also send the message to the given e-mail address
func WithEmail(email string) PublishOption {
return WithHeader("X-Email", email)
diff --git a/cmd/app.go b/cmd/app.go
index d88a9d58..c62af2fb 100644
--- a/cmd/app.go
+++ b/cmd/app.go
@@ -3,11 +3,12 @@ package cmd
import (
"fmt"
+ "os"
+ "regexp"
+
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/v2/log"
- "os"
- "regexp"
)
const (
@@ -15,6 +16,12 @@ const (
categoryServer = "Server commands"
)
+// Build metadata keys for app.Metadata
+const (
+ MetadataKeyCommit = "commit"
+ MetadataKeyDate = "date"
+)
+
var commands = make([]*cli.Command, 0)
var flagsDefault = []cli.Flag{
diff --git a/cmd/publish.go b/cmd/publish.go
index f3139a63..c80c140b 100644
--- a/cmd/publish.go
+++ b/cmd/publish.go
@@ -34,6 +34,7 @@ var flagsPublish = append(
&cli.BoolFlag{Name: "markdown", Aliases: []string{"md"}, EnvVars: []string{"NTFY_MARKDOWN"}, Usage: "Message is formatted as Markdown"},
&cli.StringFlag{Name: "template", Aliases: []string{"tpl"}, EnvVars: []string{"NTFY_TEMPLATE"}, Usage: "use templates to transform JSON message body"},
&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"},
+ &cli.StringFlag{Name: "sequence-id", Aliases: []string{"sequence_id", "sid", "S"}, EnvVars: []string{"NTFY_SEQUENCE_ID"}, Usage: "sequence ID for updating notifications"},
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"},
&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"},
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
@@ -70,6 +71,7 @@ Examples:
ntfy pub --icon="http://some.tld/icon.png" 'Icon!' # Send notification with custom icon
ntfy pub --attach="http://some.tld/file.zip" files # Send ZIP archive from URL as attachment
ntfy pub --file=flower.jpg flowers 'Nice!' # Send image.jpg as attachment
+ ntfy pub -S my-id mytopic 'Update me' # Send with sequence ID for updates
echo 'message' | ntfy publish mytopic # Send message from stdin
ntfy pub -u phil:mypass secret Psst # Publish with username/password
ntfy pub --wait-pid 1234 mytopic # Wait for process 1234 to exit before publishing
@@ -101,6 +103,7 @@ func execPublish(c *cli.Context) error {
markdown := c.Bool("markdown")
template := c.String("template")
filename := c.String("filename")
+ sequenceID := c.String("sequence-id")
file := c.String("file")
email := c.String("email")
user := c.String("user")
@@ -154,6 +157,9 @@ func execPublish(c *cli.Context) error {
if filename != "" {
options = append(options, client.WithFilename(filename))
}
+ if sequenceID != "" {
+ options = append(options, client.WithSequenceID(sequenceID))
+ }
if email != "" {
options = append(options, client.WithEmail(email))
}
diff --git a/cmd/serve.go b/cmd/serve.go
index ab8d75ec..b451a118 100644
--- a/cmd/serve.go
+++ b/cmd/serve.go
@@ -10,10 +10,9 @@ import (
"net"
"net/netip"
"net/url"
- "os"
- "os/signal"
+ "runtime"
"strings"
- "syscall"
+ "text/template"
"time"
"github.com/urfave/cli/v2"
@@ -77,6 +76,7 @@ var flagsServe = append(
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-auth-token", Aliases: []string{"twilio_auth_token"}, EnvVars: []string{"NTFY_TWILIO_AUTH_TOKEN"}, Usage: "Twilio auth token"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-phone-number", Aliases: []string{"twilio_phone_number"}, EnvVars: []string{"NTFY_TWILIO_PHONE_NUMBER"}, Usage: "Twilio number to use for outgoing calls"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-verify-service", Aliases: []string{"twilio_verify_service"}, EnvVars: []string{"NTFY_TWILIO_VERIFY_SERVICE"}, Usage: "Twilio Verify service ID, used for phone number verification"}),
+ altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-call-format", Aliases: []string{"twilio_call_format"}, EnvVars: []string{"NTFY_TWILIO_CALL_FORMAT"}, Usage: "Twilio/TwiML format string for phone calls"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "message-size-limit", Aliases: []string{"message_size_limit"}, EnvVars: []string{"NTFY_MESSAGE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultMessageSizeLimit), Usage: "size limit for the message (see docs for limitations)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "message-delay-limit", Aliases: []string{"message_delay_limit"}, EnvVars: []string{"NTFY_MESSAGE_DELAY_LIMIT"}, Value: util.FormatDuration(server.DefaultMessageDelayMax), Usage: "max duration a message can be scheduled into the future"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
@@ -187,6 +187,7 @@ func execServe(c *cli.Context) error {
twilioAuthToken := c.String("twilio-auth-token")
twilioPhoneNumber := c.String("twilio-phone-number")
twilioVerifyService := c.String("twilio-verify-service")
+ twilioCallFormat := c.String("twilio-call-format")
messageSizeLimitStr := c.String("message-size-limit")
messageDelayLimitStr := c.String("message-delay-limit")
totalTopicLimit := c.Int("global-topic-limit")
@@ -347,6 +348,8 @@ func execServe(c *cli.Context) error {
return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32")
} else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 {
return errors.New("visitor-prefix-bits-ipv6 must be between 1 and 128")
+ } else if runtime.GOOS == "windows" && listenUnix != "" {
+ return errors.New("listen-unix is not supported on Windows")
}
// Backwards compatibility
@@ -456,6 +459,13 @@ func execServe(c *cli.Context) error {
conf.TwilioAuthToken = twilioAuthToken
conf.TwilioPhoneNumber = twilioPhoneNumber
conf.TwilioVerifyService = twilioVerifyService
+ if twilioCallFormat != "" {
+ tmpl, err := template.New("twiml").Parse(twilioCallFormat)
+ if err != nil {
+ return fmt.Errorf("failed to parse twilio-call-format template: %w", err)
+ }
+ conf.TwilioCallFormat = tmpl
+ }
conf.MessageSizeLimit = int(messageSizeLimit)
conf.MessageDelayMax = messageDelayLimit
conf.TotalTopicLimit = totalTopicLimit
@@ -491,7 +501,17 @@ func execServe(c *cli.Context) error {
conf.WebPushStartupQueries = webPushStartupQueries
conf.WebPushExpiryDuration = webPushExpiryDuration
conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration
- conf.Version = c.App.Version
+ conf.BuildVersion = c.App.Version
+ conf.BuildDate = maybeFromMetadata(c.App.Metadata, MetadataKeyDate)
+ conf.BuildCommit = maybeFromMetadata(c.App.Metadata, MetadataKeyCommit)
+
+ // Check if we should run as a Windows service
+ if ranAsService, err := maybeRunAsService(conf); err != nil {
+ log.Fatal("%s", err.Error())
+ } else if ranAsService {
+ log.Info("Exiting.")
+ return nil
+ }
// Set up hot-reloading of config
go sigHandlerConfigReload(config)
@@ -507,22 +527,6 @@ func execServe(c *cli.Context) error {
return nil
}
-func sigHandlerConfigReload(config string) {
- sigs := make(chan os.Signal, 1)
- signal.Notify(sigs, syscall.SIGHUP)
- for range sigs {
- log.Info("Partially hot reloading configuration ...")
- inputSource, err := newYamlSourceFromFile(config, flagsServe)
- if err != nil {
- log.Warn("Hot reload failed: %s", err.Error())
- continue
- }
- if err := reloadLogLevel(inputSource); err != nil {
- log.Warn("Reloading log level failed: %s", err.Error())
- }
- }
-}
-
func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) {
// Try parsing as prefix, e.g. 10.0.1.0/24 or 2001:db8::/32
prefix, err := netip.ParsePrefix(host)
@@ -654,24 +658,17 @@ func parseTokens(users []*user.User, tokensRaw []string) (map[string][]*user.Tok
return tokens, nil
}
-func reloadLogLevel(inputSource altsrc.InputSourceContext) error {
- newLevelStr, err := inputSource.String("log-level")
- if err != nil {
- return fmt.Errorf("cannot load log level: %s", err.Error())
+func maybeFromMetadata(m map[string]any, key string) string {
+ if m == nil {
+ return ""
}
- overrides, err := inputSource.StringSlice("log-level-overrides")
- if err != nil {
- return fmt.Errorf("cannot load log level overrides (1): %s", err.Error())
+ v, exists := m[key]
+ if !exists {
+ return ""
}
- log.ResetLevelOverrides()
- if err := applyLogLevelOverrides(overrides); err != nil {
- return fmt.Errorf("cannot load log level overrides (2): %s", err.Error())
+ s, ok := v.(string)
+ if !ok {
+ return ""
}
- log.SetLevel(log.ToLevel(newLevelStr))
- if len(overrides) > 0 {
- log.Info("Log level is %v, %d override(s) in place", strings.ToUpper(newLevelStr), len(overrides))
- } else {
- log.Info("Log level is %v", strings.ToUpper(newLevelStr))
- }
- return nil
+ return s
}
diff --git a/cmd/serve_unix.go b/cmd/serve_unix.go
new file mode 100644
index 00000000..f12bb85b
--- /dev/null
+++ b/cmd/serve_unix.go
@@ -0,0 +1,55 @@
+//go:build linux || dragonfly || freebsd || netbsd || openbsd
+
+package cmd
+
+import (
+ "os"
+ "os/signal"
+ "syscall"
+
+ "github.com/urfave/cli/v2/altsrc"
+ "heckel.io/ntfy/v2/log"
+ "heckel.io/ntfy/v2/server"
+)
+
+func sigHandlerConfigReload(config string) {
+ sigs := make(chan os.Signal, 1)
+ signal.Notify(sigs, syscall.SIGHUP)
+ for range sigs {
+ log.Info("Partially hot reloading configuration ...")
+ inputSource, err := newYamlSourceFromFile(config, flagsServe)
+ if err != nil {
+ log.Warn("Hot reload failed: %s", err.Error())
+ continue
+ }
+ if err := reloadLogLevel(inputSource); err != nil {
+ log.Warn("Reloading log level failed: %s", err.Error())
+ }
+ }
+}
+
+func reloadLogLevel(inputSource altsrc.InputSourceContext) error {
+ newLevelStr, err := inputSource.String("log-level")
+ if err != nil {
+ return err
+ }
+ overrides, err := inputSource.StringSlice("log-level-overrides")
+ if err != nil {
+ return err
+ }
+ log.ResetLevelOverrides()
+ if err := applyLogLevelOverrides(overrides); err != nil {
+ return err
+ }
+ log.SetLevel(log.ToLevel(newLevelStr))
+ if len(overrides) > 0 {
+ log.Info("Log level is %v, %d override(s) in place", newLevelStr, len(overrides))
+ } else {
+ log.Info("Log level is %v", newLevelStr)
+ }
+ return nil
+}
+
+func maybeRunAsService(conf *server.Config) (bool, error) {
+ return false, nil
+}
diff --git a/cmd/serve_windows.go b/cmd/serve_windows.go
new file mode 100644
index 00000000..e917a079
--- /dev/null
+++ b/cmd/serve_windows.go
@@ -0,0 +1,100 @@
+//go:build windows && !noserver
+
+package cmd
+
+import (
+ "fmt"
+ "sync"
+
+ "golang.org/x/sys/windows/svc"
+ "heckel.io/ntfy/v2/log"
+ "heckel.io/ntfy/v2/server"
+)
+
+const serviceName = "ntfy"
+
+// sigHandlerConfigReload is a no-op on Windows since SIGHUP is not available.
+// Windows users can restart the service to reload configuration.
+func sigHandlerConfigReload(config string) {
+ log.Debug("Config hot-reload via SIGHUP is not supported on Windows")
+}
+
+// runAsWindowsService runs the ntfy server as a Windows service
+func runAsWindowsService(conf *server.Config) error {
+ return svc.Run(serviceName, &windowsService{conf: conf})
+}
+
+// windowsService implements the svc.Handler interface
+type windowsService struct {
+ conf *server.Config
+ server *server.Server
+ mu sync.Mutex
+}
+
+// Execute is the main entry point for the Windows service
+func (s *windowsService) Execute(args []string, requests <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) {
+ const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown
+ status <- svc.Status{State: svc.StartPending}
+
+ // Create and start the server
+ var err error
+ s.mu.Lock()
+ s.server, err = server.New(s.conf)
+ s.mu.Unlock()
+ if err != nil {
+ log.Error("Failed to create server: %s", err.Error())
+ return true, 1
+ }
+
+ // Start server in a goroutine
+ serverErrChan := make(chan error, 1)
+ go func() {
+ serverErrChan <- s.server.Run()
+ }()
+
+ status <- svc.Status{State: svc.Running, Accepts: cmdsAccepted}
+ log.Info("Windows service started")
+
+ for {
+ select {
+ case err := <-serverErrChan:
+ if err != nil {
+ log.Error("Server error: %s", err.Error())
+ return true, 1
+ }
+ return false, 0
+ case req := <-requests:
+ switch req.Cmd {
+ case svc.Interrogate:
+ status <- req.CurrentStatus
+ case svc.Stop, svc.Shutdown:
+ log.Info("Windows service stopping...")
+ status <- svc.Status{State: svc.StopPending}
+ s.mu.Lock()
+ if s.server != nil {
+ s.server.Stop()
+ }
+ s.mu.Unlock()
+ return false, 0
+ default:
+ log.Warn("Unexpected service control request: %d", req.Cmd)
+ }
+ }
+ }
+}
+
+// maybeRunAsService checks if the process is running as a Windows service,
+// and if so, runs the server as a service. Returns true if it ran as a service.
+func maybeRunAsService(conf *server.Config) (bool, error) {
+ isService, err := svc.IsWindowsService()
+ if err != nil {
+ return false, fmt.Errorf("failed to detect Windows service mode: %w", err)
+ } else if !isService {
+ return false, nil
+ }
+ log.Info("Running as Windows service")
+ if err := runAsWindowsService(conf); err != nil {
+ return true, fmt.Errorf("failed to run as Windows service: %w", err)
+ }
+ return true, nil
+}
diff --git a/cmd/subscribe.go b/cmd/subscribe.go
index 5ebf9627..84450927 100644
--- a/cmd/subscribe.go
+++ b/cmd/subscribe.go
@@ -3,28 +3,21 @@ package cmd
import (
"errors"
"fmt"
+ "os"
+ "os/exec"
+ "sort"
+ "strings"
+
"github.com/urfave/cli/v2"
"heckel.io/ntfy/v2/client"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/util"
- "os"
- "os/exec"
- "os/user"
- "path/filepath"
- "sort"
- "strings"
)
func init() {
commands = append(commands, cmdSubscribe)
}
-const (
- clientRootConfigFileUnixAbsolute = "/etc/ntfy/client.yml"
- clientUserConfigFileUnixRelative = "ntfy/client.yml"
- clientUserConfigFileWindowsRelative = "ntfy\\client.yml"
-)
-
var flagsSubscribe = append(
append([]cli.Flag{}, flagsDefault...),
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
@@ -310,45 +303,16 @@ func loadConfig(c *cli.Context) (*client.Config, error) {
if filename != "" {
return client.LoadConfig(filename)
}
- configFile, err := defaultClientConfigFile()
- if err != nil {
- log.Warn("Could not determine default client config file: %s", err.Error())
- } else {
- if s, _ := os.Stat(configFile); s != nil {
- return client.LoadConfig(configFile)
+ if client.DefaultConfigFile != "" {
+ if s, _ := os.Stat(client.DefaultConfigFile); s != nil {
+ return client.LoadConfig(client.DefaultConfigFile)
}
- log.Debug("Config file %s not found", configFile)
+ log.Debug("Config file %s not found", client.DefaultConfigFile)
}
log.Debug("Loading default config")
return client.NewConfig(), nil
}
-//lint:ignore U1000 Conditionally used in different builds
-func defaultClientConfigFileUnix() (string, error) {
- u, err := user.Current()
- if err != nil {
- return "", fmt.Errorf("could not determine current user: %w", err)
- }
- configFile := clientRootConfigFileUnixAbsolute
- if u.Uid != "0" {
- homeDir, err := os.UserConfigDir()
- if err != nil {
- return "", fmt.Errorf("could not determine user config dir: %w", err)
- }
- return filepath.Join(homeDir, clientUserConfigFileUnixRelative), nil
- }
- return configFile, nil
-}
-
-//lint:ignore U1000 Conditionally used in different builds
-func defaultClientConfigFileWindows() (string, error) {
- homeDir, err := os.UserConfigDir()
- if err != nil {
- return "", fmt.Errorf("could not determine user config dir: %w", err)
- }
- return filepath.Join(homeDir, clientUserConfigFileWindowsRelative), nil
-}
-
func logMessagePrefix(m *client.Message) string {
return fmt.Sprintf("%s/%s", util.ShortTopicURL(m.TopicURL), m.ID)
}
diff --git a/cmd/subscribe_darwin.go b/cmd/subscribe_darwin.go
index 487f0641..00335540 100644
--- a/cmd/subscribe_darwin.go
+++ b/cmd/subscribe_darwin.go
@@ -1,3 +1,5 @@
+//go:build darwin
+
package cmd
const (
@@ -10,7 +12,3 @@ or "~/Library/Application Support/ntfy/client.yml" for all other users.`
var (
scriptLauncher = []string{"sh", "-c"}
)
-
-func defaultClientConfigFile() (string, error) {
- return defaultClientConfigFileUnix()
-}
diff --git a/cmd/subscribe_unix.go b/cmd/subscribe_unix.go
index 3f5f526f..4c9c6039 100644
--- a/cmd/subscribe_unix.go
+++ b/cmd/subscribe_unix.go
@@ -12,7 +12,3 @@ or ~/.config/ntfy/client.yml for all other users.`
var (
scriptLauncher = []string{"sh", "-c"}
)
-
-func defaultClientConfigFile() (string, error) {
- return defaultClientConfigFileUnix()
-}
diff --git a/cmd/subscribe_windows.go b/cmd/subscribe_windows.go
index 22c07d81..ea5f09f0 100644
--- a/cmd/subscribe_windows.go
+++ b/cmd/subscribe_windows.go
@@ -1,3 +1,5 @@
+//go:build windows
+
package cmd
const (
@@ -9,7 +11,3 @@ const (
var (
scriptLauncher = []string{"cmd.exe", "/Q", "/C"}
)
-
-func defaultClientConfigFile() (string, error) {
- return defaultClientConfigFileWindows()
-}
diff --git a/docs/config.md b/docs/config.md
index 02418b19..8a125146 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -1261,10 +1261,85 @@ are the easiest), and then configure the following options:
* `twilio-auth-token` is the Twilio auth token, e.g. affebeef258625862586258625862586
* `twilio-phone-number` is the outgoing phone number you purchased, e.g. +18775132586
* `twilio-verify-service` is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586
+* `twilio-call-format` is the custom Twilio markup ([TwiML](https://www.twilio.com/docs/voice/twiml)) to use for phone calls (optional)
After you have configured phone calls, create a [tier](#tiers) with a call limit (e.g. `ntfy tier create --call-limit=10 ...`),
and then assign it to a user. Users may then use the `X-Call` header to receive a phone call when publishing a message.
+To customize the message that is spoken out loud, set the `twilio-call-format` option with [TwiML](https://www.twilio.com/docs/voice/twiml). The format is
+rendered as a [Go template](https://pkg.go.dev/text/template), so you can use the following fields from the message:
+
+* `{{.Topic}}` is the topic name
+* `{{.Message}}` is the message body
+* `{{.Title}}` is the message title
+* `{{.Tags}}` is a list of tags
+* `{{.Priority}}` is the message priority
+* `{{.Sender}}` is the IP address or username of the sender
+
+Here's an example:
+
+=== "Custom TwiML (English)"
+ ``` yaml
+ twilio-account: "AC12345beefbeef67890beefbeef122586"
+ twilio-auth-token: "affebeef258625862586258625862586"
+ twilio-phone-number: "+18775132586"
+ twilio-verify-service: "VA12345beefbeef67890beefbeef122586"
+ twilio-call-format: |
+
+
+
+ Yo yo yo, you should totally check out this message for {{.Topic}}.
+ {{ if eq .Priority 5 }}
+ It's really really important, dude. So listen up!
+ {{ end }}
+
+ {{ if neq .Title "" }}
+ Bro, it's titled: {{.Title}}.
+ {{ end }}
+
+ {{.Message}}
+
+ That is all.
+
+ You know who this message is from? It is from {{.Sender}}.
+
+
+ See ya!
+
+ ```
+
+=== "Custom TwiML (German)"
+ ``` yaml
+ twilio-account: "AC12345beefbeef67890beefbeef122586"
+ twilio-auth-token: "affebeef258625862586258625862586"
+ twilio-phone-number: "+18775132586"
+ twilio-verify-service: "VA12345beefbeef67890beefbeef122586"
+ twilio-call-format: |
+
+
+
+ Du hast eine Nachricht zum Thema {{.Topic}}.
+ {{ if eq .Priority 5 }}
+ Achtung. Die Nachricht ist sehr wichtig.
+ {{ end }}
+
+ {{ if neq .Title "" }}
+ Titel der Nachricht: {{.Title}}.
+ {{ end }}
+
+ Nachricht:
+
+ {{.Message}}
+
+ Ende der Nachricht.
+
+ Diese Nachricht wurde vom Benutzer {{.Sender}} gesendet. Sie wird drei Mal wiederholt.
+
+
+ Alla mol!
+
+ ```
+
## Message limits
There are a few message limits that you can configure:
diff --git a/docs/develop.md b/docs/develop.md
index 4ddff5ec..ecf35e1d 100644
--- a/docs/develop.md
+++ b/docs/develop.md
@@ -441,6 +441,6 @@ To have instant notifications/better notification delivery when using firebase,
1. In XCode, find the NTFY app target. **Not** the NSE app target.
1. Find the Asset/ folder in the project navigator
1. Drag the `GoogleService-Info.plist` file into the Asset/ folder that you get from the firebase console. It can be
- found in the "Project settings" > "General" > "Your apps" with a button labled "GoogleService-Info.plist"
+ found in the "Project settings" > "General" > "Your apps" with a button labeled "GoogleService-Info.plist"
After that, you should be all set!
diff --git a/docs/faq.md b/docs/faq.md
index 5fa5252c..5153c700 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -96,8 +96,8 @@ appreciated.
## Can I email you? Can I DM you on Discord/Matrix?
For community support, please use the public channels listed on the [contact page](contact.md). I generally
**do not respond to direct messages** about ntfy, unless you are paying for a [ntfy Pro](https://ntfy.sh/#pricing)
-plan (see [paid support](contact.md#paid-support-ntfy-pro-subscribers)), or you are inquiring about business
-opportunities (see [general inquiries](contact.md#general-inquiries)).
+plan (see [paid support](contact.md#paid-support)), or you are inquiring about business
+opportunities (see [other inquiries](contact.md#other-inquiries)).
I am sorry, but answering individual questions about ntfy on a 1-on-1 basis is not scalable. Answering your questions
in public forums benefits others, since I can link to the discussion at a later point in time, or other users
diff --git a/docs/install.md b/docs/install.md
index dc50e222..02d1672b 100644
--- a/docs/install.md
+++ b/docs/install.md
@@ -30,37 +30,37 @@ deb/rpm packages.
=== "x86_64/amd64"
```bash
- wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_amd64.tar.gz
- tar zxvf ntfy_2.15.0_linux_amd64.tar.gz
- sudo cp -a ntfy_2.15.0_linux_amd64/ntfy /usr/local/bin/ntfy
- sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_amd64/{client,server}/*.yml /etc/ntfy
+ wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_amd64.tar.gz
+ tar zxvf ntfy_2.16.0_linux_amd64.tar.gz
+ sudo cp -a ntfy_2.16.0_linux_amd64/ntfy /usr/local/bin/ntfy
+ sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.0_linux_amd64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv6"
```bash
- wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv6.tar.gz
- tar zxvf ntfy_2.15.0_linux_armv6.tar.gz
- sudo cp -a ntfy_2.15.0_linux_armv6/ntfy /usr/bin/ntfy
- sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_armv6/{client,server}/*.yml /etc/ntfy
+ wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv6.tar.gz
+ tar zxvf ntfy_2.16.0_linux_armv6.tar.gz
+ sudo cp -a ntfy_2.16.0_linux_armv6/ntfy /usr/bin/ntfy
+ sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.0_linux_armv6/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv7/armhf"
```bash
- wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv7.tar.gz
- tar zxvf ntfy_2.15.0_linux_armv7.tar.gz
- sudo cp -a ntfy_2.15.0_linux_armv7/ntfy /usr/bin/ntfy
- sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_armv7/{client,server}/*.yml /etc/ntfy
+ wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv7.tar.gz
+ tar zxvf ntfy_2.16.0_linux_armv7.tar.gz
+ sudo cp -a ntfy_2.16.0_linux_armv7/ntfy /usr/bin/ntfy
+ sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.0_linux_armv7/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "arm64"
```bash
- wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_arm64.tar.gz
- tar zxvf ntfy_2.15.0_linux_arm64.tar.gz
- sudo cp -a ntfy_2.15.0_linux_arm64/ntfy /usr/bin/ntfy
- sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_arm64/{client,server}/*.yml /etc/ntfy
+ wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_arm64.tar.gz
+ tar zxvf ntfy_2.16.0_linux_arm64.tar.gz
+ sudo cp -a ntfy_2.16.0_linux_arm64/ntfy /usr/bin/ntfy
+ sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.0_linux_arm64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
@@ -116,7 +116,7 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
- wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_amd64.deb
+ wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_amd64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -124,7 +124,7 @@ Manually installing the .deb file:
=== "armv6"
```bash
- wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv6.deb
+ wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv6.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -132,7 +132,7 @@ Manually installing the .deb file:
=== "armv7/armhf"
```bash
- wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv7.deb
+ wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv7.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -140,7 +140,7 @@ Manually installing the .deb file:
=== "arm64"
```bash
- wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_arm64.deb
+ wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_arm64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -150,33 +150,35 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
- sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_amd64.rpm
+ sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_amd64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv6"
```bash
- sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv6.rpm
+ sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv6.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv7/armhf"
```bash
- sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv7.rpm
+ sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv7.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "arm64"
```bash
- sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_arm64.rpm
+ sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_arm64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
## Arch Linux
+ Community maintained
+
ntfy can be installed using an [AUR package](https://aur.archlinux.org/packages/ntfysh-bin/).
You can use an [AUR helper](https://wiki.archlinux.org/title/AUR_helpers) like `paru`, `yay` or others to download,
build and install ntfy and keep it up to date.
@@ -191,7 +193,9 @@ cd ntfysh-bin
makepkg -si
```
-## NixOS / Nix
+## NixOS / Nix
+ Community maintained
+
ntfy is packaged in nixpkgs as `ntfy-sh`. It can be installed by adding the package name to the configuration file and calling `nixos-rebuild`. Alternatively, the following command can be used to install ntfy in the current user environment:
```
nix-env -iA ntfy-sh
@@ -199,20 +203,28 @@ nix-env -iA ntfy-sh
NixOS also supports [declarative setup of the ntfy server](https://search.nixos.org/options?channel=unstable&show=services.ntfy-sh.enable&from=0&size=50&sort=relevance&type=packages&query=ntfy).
+## FreeBSD
+ Community maintained
+
+ntfy is ported to FreeBSD and available via the ports collection as [sysutils/go-ntfy](https://www.freshports.org/sysutils/go-ntfy/). You can install it via `pkg`:
+```
+pkg install go-ntfy
+```
+
## macOS
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
-To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_darwin_all.tar.gz),
+To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_darwin_all.tar.gz),
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
```bash
-curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_darwin_all.tar.gz > ntfy_2.15.0_darwin_all.tar.gz
-tar zxvf ntfy_2.15.0_darwin_all.tar.gz
-sudo cp -a ntfy_2.15.0_darwin_all/ntfy /usr/local/bin/ntfy
+curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_darwin_all.tar.gz > ntfy_2.16.0_darwin_all.tar.gz
+tar zxvf ntfy_2.16.0_darwin_all.tar.gz
+sudo cp -a ntfy_2.16.0_darwin_all/ntfy /usr/local/bin/ntfy
mkdir ~/Library/Application\ Support/ntfy
-cp ntfy_2.15.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
+cp ntfy_2.16.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
ntfy --help
```
@@ -221,6 +233,8 @@ ntfy --help
development as well. Check out the [build instructions](develop.md) for details.
## Homebrew
+ Community maintained
+
To install the [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) via Homebrew (Linux and macOS),
simply run:
```
@@ -228,19 +242,29 @@ brew install ntfy
```
## Windows
-The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
-To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_windows_amd64.zip),
+The ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service.
+To install, you can either
+
+* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_windows_amd64.zip),
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
+* Or install ntfy from the [Scoop](https://scoop.sh) main repository via `scoop install ntfy`
-The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
+Once installed, you can run the ntfy CLI commands like so:
-Also available in [Scoop's](https://scoop.sh) Main repository:
+```
+ntfy.exe -h
+```
-`scoop install ntfy`
+The default configuration file location on Windows is `%ProgramData%\ntfy\server.yml` (e.g., `C:\ProgramData\ntfy\server.yml`)
+for the server, and `%AppData%\ntfy\client.yml` for the client. You may need to create the directory and config file manually.
-!!! info
- There is currently no installer for Windows, and the binary is not signed. If this is desired, please create a
- [GitHub issue](https://github.com/binwiederhier/ntfy/issues) to let me know.
+To install the ntfy server as a Windows service, you can use the built-in `sc` command. For example, run this in an
+elevated command prompt (adjust the path to `ntfy.exe` accordingly):
+
+```
+sc create ntfy binPath="C:\path\to\ntfy.exe serve" start=auto
+sc start ntfy
+```
## Docker
The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv6, armv7 and arm64. It should
diff --git a/docs/integrations.md b/docs/integrations.md
index 948cd758..1ed61faf 100644
--- a/docs/integrations.md
+++ b/docs/integrations.md
@@ -90,7 +90,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [ntfy-desktop](https://github.com/Aetherinox/ntfy-desktop) - Desktop client for Windows, Linux, and MacOS with push notifications
- [ntfy svelte front-end](https://github.com/novatorem/Ntfy) - Front-end built with svelte
- [wio-ntfy-ticker](https://github.com/nachotp/wio-ntfy-ticker) - Ticker display for a ntfy.sh topic
-- [ntfysh-windows](https://github.com/lucas-bortoli/ntfysh-windows) - A ntfy client for Windows Desktop
+- [ntfysh-windows](https://github.com/mshafer1/ntfysh-windows) - A ntfy client for Windows Desktop
- [ntfyr](https://github.com/haxwithaxe/ntfyr) - A simple commandline tool to send notifications to ntfy
- [ntfy.py](https://github.com/ioqy/ntfy-client-python) - ntfy.py is a simple nfty.sh client for sending notifications
- [wlzntfy](https://github.com/Walzen-Group/ntfy-toaster) - A minimalistic, receive-only toast notification client for Windows 11
diff --git a/docs/publish.md b/docs/publish.md
index 9c409523..9e5a7503 100644
--- a/docs/publish.md
+++ b/docs/publish.md
@@ -1,6 +1,6 @@
# Publishing
-Publishing messages can be done via HTTP PUT/POST or via the [ntfy CLI](install.md). Topics are created on the fly by
-subscribing or publishing to them. Because there is no sign-up, **the topic is essentially a password**, so pick
+Publishing messages can be done via HTTP PUT/POST or via the [ntfy CLI](subscribe/cli.md#publish-messages) ([install instructions](install.md)).
+Topics are created on the fly by subscribing or publishing to them. Because there is no sign-up, **the topic is essentially a password**, so pick
something that's not easily guessable.
Here's an example showing how to publish a simple message using a POST request:
@@ -641,7 +641,7 @@ You can format messages using [Markdown](https://www.markdownguide.org/basic-syn
By default, messages sent to ntfy are rendered as plain text. To enable Markdown, set the `X-Markdown` header (or any of
its aliases: `Markdown`, or `md`) to `true` (or `1` or `yes`), or set the `Content-Type` header to `text/markdown`.
-As of today, **Markdown is only supported in the web app.** Here's an example of how to enable Markdown formatting:
+Here's an example of how to enable Markdown formatting:
=== "Command line (curl)"
```
@@ -727,61 +727,65 @@ Here's what that looks like in the web app:
Markdown formatting in the web app
-## Scheduled delivery
+## Click action
_Supported on:_ :material-android: :material-apple: :material-firefox:
-You can delay the delivery of messages and let ntfy send them at a later date. This can be used to send yourself
-reminders or even to execute commands at a later date (if your subscriber acts on messages).
+You can define which URL to open when a notification is clicked. This may be useful if your notification is related
+to a Zabbix alert or a transaction that you'd like to provide the deep-link for. Tapping the notification will open
+the web browser (or the app) and open the website.
-Usage is pretty straight forward. You can set the delivery time using the `X-Delay` header (or any of its aliases: `Delay`,
-`X-At`, `At`, `X-In` or `In`), either by specifying a Unix timestamp (e.g. `1639194738`), a duration (e.g. `30m`,
-`3h`, `2 days`), or a natural language time string (e.g. `10am`, `8:30pm`, `tomorrow, 3pm`, `Tuesday, 7am`,
-[and more](https://github.com/olebedev/when)).
+To define a click action for the notification, pass a URL as the value of the `X-Click` header (or its alias `Click`).
+If you pass a website URL (`http://` or `https://`) the web browser will open. If you pass another URI that can be handled
+by another app, the responsible app may open.
-As of today, the minimum delay you can set is **10 seconds** and the maximum delay is **3 days**. This can be configured
-with the `message-delay-limit` option).
+Examples:
-For the purposes of [message caching](config.md#message-cache), scheduled messages are kept in the cache until 12 hours
-after they were delivered (or whatever the server-side cache duration is set to). For instance, if a message is scheduled
-to be delivered in 3 days, it'll remain in the cache for 3 days and 12 hours. Also note that naturally,
-[turning off server-side caching](#message-caching) is not possible in combination with this feature.
+* `http://` or `https://` will open your browser (or an app if it registered for a URL)
+* `mailto:` links will open your mail app, e.g. `mailto:phil@example.com`
+* `geo:` links will open Google Maps, e.g. `geo:0,0?q=1600+Amphitheatre+Parkway,+Mountain+View,+CA`
+* `ntfy://` links will open ntfy (see [ntfy:// links](subscribe/phone.md#ntfy-links)), e.g. `ntfy://ntfy.sh/stats`
+* `twitter://` links will open Twitter, e.g. `twitter://user?screen_name=..`
+* ...
+
+Here's an example that will open Reddit when the notification is clicked:
=== "Command line (curl)"
```
- curl -H "At: tomorrow, 10am" -d "Good morning" ntfy.sh/hello
- curl -H "In: 30min" -d "It's 30 minutes later now" ntfy.sh/reminder
- curl -H "Delay: 1639194738" -d "Unix timestamps are awesome" ntfy.sh/itsaunixsystem
+ curl \
+ -d "New messages on Reddit" \
+ -H "Click: https://www.reddit.com/message/messages" \
+ ntfy.sh/reddit_alerts
```
=== "ntfy CLI"
```
ntfy publish \
- --at="tomorrow, 10am" \
- hello "Good morning"
+ --click="https://www.reddit.com/message/messages" \
+ reddit_alerts "New messages on Reddit"
```
=== "HTTP"
``` http
- POST /hello HTTP/1.1
+ POST /reddit_alerts HTTP/1.1
Host: ntfy.sh
- At: tomorrow, 10am
+ Click: https://www.reddit.com/message/messages
- Good morning
+ New messages on Reddit
```
=== "JavaScript"
``` javascript
- fetch('https://ntfy.sh/hello', {
+ fetch('https://ntfy.sh/reddit_alerts', {
method: 'POST',
- body: 'Good morning',
- headers: { 'At': 'tomorrow, 10am' }
+ body: 'New messages on Reddit',
+ headers: { 'Click': 'https://www.reddit.com/message/messages' }
})
```
=== "Go"
``` go
- req, _ := http.NewRequest("POST", "https://ntfy.sh/hello", strings.NewReader("Good morning"))
- req.Header.Set("At", "tomorrow, 10am")
+ req, _ := http.NewRequest("POST", "https://ntfy.sh/reddit_alerts", strings.NewReader("New messages on Reddit"))
+ req.Header.Set("Click", "https://www.reddit.com/message/messages")
http.DefaultClient.Do(req)
```
@@ -789,281 +793,207 @@ to be delivered in 3 days, it'll remain in the cache for 3 days and 12 hours. Al
``` powershell
$Request = @{
Method = "POST"
- URI = "https://ntfy.sh/hello"
- Headers = @{
- At = "tomorrow, 10am"
- }
- Body = "Good morning"
+ URI = "https://ntfy.sh/reddit_alerts"
+ Headers = @{ Click="https://www.reddit.com/message/messages" }
+ Body = "New messages on Reddit"
}
Invoke-RestMethod @Request
```
-
+
=== "Python"
``` python
- requests.post("https://ntfy.sh/hello",
- data="Good morning",
- headers={ "At": "tomorrow, 10am" })
+ requests.post("https://ntfy.sh/reddit_alerts",
+ data="New messages on Reddit",
+ headers={ "Click": "https://www.reddit.com/message/messages" })
```
=== "PHP"
``` php-inline
- file_get_contents('https://ntfy.sh/backups', false, stream_context_create([
+ file_get_contents('https://ntfy.sh/reddit_alerts', false, stream_context_create([
'http' => [
'method' => 'POST',
'header' =>
"Content-Type: text/plain\r\n" .
- "At: tomorrow, 10am",
- 'content' => 'Good morning'
+ "Click: https://www.reddit.com/message/messages",
+ 'content' => 'New messages on Reddit'
]
]));
```
-Here are a few examples (assuming today's date is **12/10/2021, 9am, Eastern Time Zone**):
+## Icons
+_Supported on:_ :material-android:
-
-
-
Delay/At/In header
Message will be delivered at
Explanation
-
30m
12/10/2021, 9:30am
30 minutes from now
-
2 hours
12/10/2021, 11:30am
2 hours from now
-
1 day
12/11/2021, 9am
24 hours from now
-
10am
12/10/2021, 10am
Today at 10am (same day, because it's only 9am)
-
8am
12/11/2021, 8am
Tomorrow at 8am (because it's 9am already)
-
1639152000
12/10/2021, 11am (EST)
Today at 11am (EST)
-
-
-
+You can include an icon that will appear next to the text of the notification. Simply pass the `X-Icon` header or query
+parameter (or its alias `Icon`) to specify the URL that the icon is located at. The client will automatically download
+the icon (unless it is already cached locally, and less than 24 hours old), and show it in the notification. Icons are
+cached locally in the client until the notification is deleted. **Only JPEG and PNG images are supported at this time**.
-## Webhooks (publish via GET)
-_Supported on:_ :material-android: :material-apple: :material-firefox:
-
-In addition to using PUT/POST, you can also send to topics via simple HTTP GET requests. This makes it easy to use
-a ntfy topic as a [webhook](https://en.wikipedia.org/wiki/Webhook), or if your client has limited HTTP support.
-
-To send messages via HTTP GET, simply call the `/publish` endpoint (or its aliases `/send` and `/trigger`). Without
-any arguments, this will send the message `triggered` to the topic. However, you can provide all arguments that are
-also supported as HTTP headers as URL-encoded arguments. Be sure to check the list of all
-[supported parameters and headers](#list-of-all-parameters) for details.
-
-For instance, assuming your topic is `mywebhook`, you can simply call `/mywebhook/trigger` to send a message
-(aka trigger the webhook):
+Here's an example showing how to include an icon:
=== "Command line (curl)"
```
- curl ntfy.sh/mywebhook/trigger
- ```
-
-=== "ntfy CLI"
- ```
- ntfy trigger mywebhook
- ```
-
-=== "HTTP"
- ``` http
- GET /mywebhook/trigger HTTP/1.1
- Host: ntfy.sh
- ```
-
-=== "JavaScript"
- ``` javascript
- fetch('https://ntfy.sh/mywebhook/trigger')
- ```
-
-=== "Go"
- ``` go
- http.Get("https://ntfy.sh/mywebhook/trigger")
- ```
-
-=== "PowerShell"
- ``` powershell
- Invoke-RestMethod "ntfy.sh/mywebhook/trigger"
- ```
-
-=== "Python"
- ``` python
- requests.get("https://ntfy.sh/mywebhook/trigger")
- ```
-
-=== "PHP"
- ``` php-inline
- file_get_contents('https://ntfy.sh/mywebhook/trigger');
- ```
-
-To add a custom message, simply append the `message=` URL parameter. And of course you can set the
-[message priority](#message-priority), the [message title](#message-title), and [tags](#tags-emojis) as well.
-For a full list of possible parameters, check the list of [supported parameters and headers](#list-of-all-parameters).
-
-Here's an example with a custom message, tags and a priority:
-
-=== "Command line (curl)"
- ```
- curl "ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull"
+ curl \
+ -H "Icon: https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png" \
+ -H "Title: Kodi: Resuming Playback" \
+ -H "Tags: arrow_forward" \
+ -d "The Wire, S01E01" \
+ ntfy.sh/tvshows
```
=== "ntfy CLI"
```
ntfy publish \
- -p 5 --tags=warning,skull \
- mywebhook "Webhook triggered"
+ --icon="https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png" \
+ --title="Kodi: Resuming Playback" \
+ --tags="arrow_forward" \
+ tvshows \
+ "The Wire, S01E01"
```
=== "HTTP"
``` http
- GET /mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull HTTP/1.1
+ POST /tvshows HTTP/1.1
Host: ntfy.sh
+ Icon: https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png
+ Tags: arrow_forward
+ Title: Kodi: Resuming Playback
+
+ The Wire, S01E01
```
=== "JavaScript"
``` javascript
- fetch('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull')
+ fetch('https://ntfy.sh/tvshows', {
+ method: 'POST',
+ headers: {
+ 'Icon': 'https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png',
+ 'Title': 'Kodi: Resuming Playback',
+ 'Tags': 'arrow_forward'
+ },
+ body: "The Wire, S01E01"
+ })
```
=== "Go"
``` go
- http.Get("https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull")
+ req, _ := http.NewRequest("POST", "https://ntfy.sh/tvshows", strings.NewReader("The Wire, S01E01"))
+ req.Header.Set("Icon", "https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png")
+ req.Header.Set("Tags", "arrow_forward")
+ req.Header.Set("Title", "Kodi: Resuming Playback")
+ http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
- Invoke-RestMethod "ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull"
+ $Request = @{
+ Method = "POST"
+ URI = "https://ntfy.sh/tvshows"
+ Headers = @{
+ Title = "Kodi: Resuming Playback"
+ Tags = "arrow_forward"
+ Icon = "https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png"
+ }
+ Body = "The Wire, S01E01"
+ }
+ Invoke-RestMethod @Request
```
=== "Python"
``` python
- requests.get("https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull")
+ requests.post("https://ntfy.sh/tvshows",
+ data="The Wire, S01E01",
+ headers={
+ "Title": "Kodi: Resuming Playback",
+ "Tags": "arrow_forward",
+ "Icon": "https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png"
+ })
```
=== "PHP"
``` php-inline
- file_get_contents('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull');
+ file_get_contents('https://ntfy.sh/tvshows', false, stream_context_create([
+ 'http' => [
+ 'method' => 'PUT',
+ 'header' =>
+ "Content-Type: text/plain\r\n" . // Does not matter
+ "Title: Kodi: Resuming Playback\r\n" .
+ "Tags: arrow_forward\r\n" .
+ "Icon: https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png",
+ ],
+ 'content' => "The Wire, S01E01"
+ ]));
```
-## Message templating
-_Supported on:_ :material-android: :material-apple: :material-firefox:
-
-Templating lets you **format a JSON message body into human-friendly message and title text** using
-[Go templates](https://pkg.go.dev/text/template) (see tutorials [here](https://blog.gopheracademy.com/advent-2017/using-go-templates/),
-[here](https://www.digitalocean.com/community/tutorials/how-to-use-templates-in-go), and
-[here](https://developer.hashicorp.com/nomad/tutorials/templates/go-template-syntax)). This is specifically useful when
-**combined with webhooks** from services such as [GitHub](https://docs.github.com/en/webhooks/about-webhooks),
-[Grafana](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier/),
-[Alertmanager](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config), or other services that emit JSON webhooks.
-
-Instead of using a separate bridge program to parse the webhook body into the format ntfy expects, you can include a templated
-message and/or a templated title which will be populated based on the fields of the webhook body (so long as the webhook body
-is valid JSON).
-
-You can enable templating by setting the `X-Template` header (or its aliases `Template` or `tpl`, or the query parameter `?template=...`):
-
-* **Pre-defined template files**: Setting the `X-Template` header or query parameter to a pre-defined template name (one of `github`,
- `grafana`, or `alertmanager`, such as `?template=github`) will use the built-in template with that name.
- See [pre-defined templates](#pre-defined-templates) for more details.
-* **Custom template files**: Setting the `X-Template` header or query parameter to a custom template name (e.g. `?template=myapp`)
- will use a custom template file from the template directory (defaults to `/etc/ntfy/templates`, can be overridden with `template-dir`).
- See [custom templates](#custom-templates) for more details.
-* **Inline templating**: Setting the `X-Template` header or query parameter to `yes` or `1` (e.g. `?template=yes`)
- will enable inline templating, which means that the `message` and/or `title` will be parsed as a Go template.
- See [inline templating](#inline-templating) for more details.
-
-To learn the basics of Go's templating language, please see [template syntax](#template-syntax).
-
-### Pre-defined templates
-
-When `X-Template: ` (aliases: `Template: `, `Tpl: `) or `?template=` is set, ntfy will transform the
-message and/or title based on one of the built-in pre-defined templates.
-
-The following **pre-defined templates** are available:
-
-* `github`: Formats a subset of [GitHub webhook](https://docs.github.com/en/webhooks/about-webhooks) payloads (PRs, issues, new star, new watcher, new comment). See [github.yml](https://github.com/binwiederhier/ntfy/blob/main/server/templates/github.yml).
-* `grafana`: Formats [Grafana webhook](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier/) payloads (firing/resolved alerts). See [grafana.yml](https://github.com/binwiederhier/ntfy/blob/main/server/templates/grafana.yml).
-* `alertmanager`: Formats [Alertmanager webhook](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config) payloads (firing/resolved alerts). See [alertmanager.yml](https://github.com/binwiederhier/ntfy/blob/main/server/templates/alertmanager.yml).
-
-To override the pre-defined templates, you can place a file with the same name in the template directory (defaults to `/etc/ntfy/templates`,
-can be overridden with `template-dir`). See [custom templates](#custom-templates) for more details.
-
-Here's an example of how to use the **pre-defined `github` template**:
-
-First, configure the webhook in GitHub to send a webhook to your ntfy topic, e.g. `https://ntfy.sh/mytopic?template=github`.
-
- { width=600 }
- GitHub webhook configuration
-
-
-After that, when GitHub publishes a JSON webhook to the topic, ntfy will transform it according to the template rules
-and you'll receive notifications in the ntfy app. Here's an example for when somebody stars your repository:
+Here's an example of how it will look on Android:
- { width=500 }
- Receiving a webhook, formatted using the pre-defined "github" template
+ { width=500 }
+ Custom icon from an external URL
-### Custom templates
+## Attachments
+_Supported on:_ :material-android: :material-firefox:
-To define **your own custom templates**, place a template file in the template directory (defaults to `/etc/ntfy/templates`, can be overridden with `template-dir`)
-and set the `X-Template` header or query parameter to the name of the template file (without the `.yml` extension).
+You can **send images and other files to your phone** as attachments to a notification. The attachments are then downloaded
+onto your phone (depending on size and setting automatically), and can be used from the Downloads folder.
-For example, if you have a template file `/etc/ntfy/templates/myapp.yml`, you can set the header `X-Template: myapp` or
-the query parameter `?template=myapp` to use it.
+There are two different ways to send attachments:
-Template files must have the `.yml` (not: `.yaml`!) extension and must be formatted as YAML. They may contain `title` and `message` keys,
-which are interpreted as Go templates.
+* sending [a local file](#attach-local-file) via PUT, e.g. from `~/Flowers/flower.jpg` or `ringtone.mp3`
+* or by [passing an external URL](#attach-file-from-a-url) as an attachment, e.g. `https://f-droid.org/F-Droid.apk`
-Here's an **example custom template**:
+### Attach local file
+To **send a file from your computer** as an attachment, you can send it as the PUT request body. If a message is greater
+than the maximum message size (4,096 bytes) or consists of non UTF-8 characters, the ntfy server will automatically
+detect the mime type and size, and send the message as an attachment file. To send smaller text-only messages or files
+as attachments, you must pass a filename by passing the `X-Filename` header or query parameter (or any of its aliases
+`Filename`, `File` or `f`).
-=== "Custom template (/etc/ntfy/templates/myapp.yml)"
- ```yaml
- title: |
- {{- if eq .status "firing" }}
- {{- if gt .percent 90.0 }}🚨 Critical alert
- {{- else }}⚠️ Alert{{- end }}
- {{- else if eq .status "resolved" }}
- ✅ Alert resolved
- {{- end }}
- message: |
- Status: {{ .status }}
- Type: {{ .type | upper }} ({{ .percent }}%)
- Server: {{ .server }}
- ```
+By default, and how ntfy.sh is configured, the **max attachment size is 15 MB** (with 100 MB total per visitor).
+Attachments **expire after 3 hours**, which typically is plenty of time for the user to download it, or for the Android app
+to auto-download it. Please also check out the [other limits below](#limitations).
-Once you have the template file in place, you can send the payload to your topic using the `X-Template`
-header or query parameter:
+Here's an example showing how to upload an image:
=== "Command line (curl)"
```
- echo '{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}' | \
- curl -sT- "https://ntfy.example.com/mytopic?template=myapp"
+ curl \
+ -T flower.jpg \
+ -H "Filename: flower.jpg" \
+ ntfy.sh/flowers
```
=== "ntfy CLI"
```
- echo '{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}' | \
- ntfy publish --template=myapp https://ntfy.example.com/mytopic
+ ntfy publish \
+ --file=flower.jpg \
+ flowers
```
=== "HTTP"
``` http
- POST /mytopic?template=myapp HTTP/1.1
- Host: ntfy.example.com
-
- {
- "status": "firing",
- "type": "cpu",
- "server": "ntfy.sh",
- "percent": 99
- }
+ PUT /flowers HTTP/1.1
+ Host: ntfy.sh
+ Filename: flower.jpg
+ Content-Type: 52312
+
+ (binary JPEG data)
```
=== "JavaScript"
``` javascript
- fetch('https://ntfy.example.com/mytopic?template=myapp', {
- method: 'POST',
- body: '{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}'
+ fetch('https://ntfy.sh/flowers', {
+ method: 'PUT',
+ body: document.getElementById("file").files[0],
+ headers: { 'Filename': 'flower.jpg' }
})
```
=== "Go"
``` go
- payload := `{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}`
- req, _ := http.NewRequest("POST", "https://ntfy.example.com/mytopic?template=myapp", strings.NewReader(payload))
+ file, _ := os.Open("flower.jpg")
+ req, _ := http.NewRequest("PUT", "https://ntfy.sh/flowers", file)
+ req.Header.Set("Filename", "flower.jpg")
http.DefaultClient.Do(req)
```
@@ -1071,281 +1001,88 @@ header or query parameter:
``` powershell
$Request = @{
Method = "POST"
- Uri = "https://ntfy.example.com/mytopic?template=myapp"
- Body = '{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}'
+ Uri = "ntfy.sh/flowers"
+ InFile = "flower.jpg"
+ Headers = @{"Filename" = "flower.jpg"}
}
Invoke-RestMethod @Request
```
=== "Python"
``` python
- requests.post("https://ntfy.example.com/mytopic?template=myapp",
- json={"status":"firing","type":"cpu","server":"ntfy.sh","percent":99})
+ requests.put("https://ntfy.sh/flowers",
+ data=open("flower.jpg", 'rb'),
+ headers={ "Filename": "flower.jpg" })
```
=== "PHP"
``` php-inline
- file_get_contents('https://ntfy.example.com/mytopic?template=myapp', false, stream_context_create([
+ file_get_contents('https://ntfy.sh/flowers', false, stream_context_create([
'http' => [
- 'method' => 'POST',
- 'header' => "Content-Type: application/json",
- 'content' => '{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}'
+ 'method' => 'PUT',
+ 'header' =>
+ "Content-Type: application/octet-stream\r\n" . // Does not matter
+ "Filename: flower.jpg",
+ 'content' => file_get_contents('flower.jpg') // Dangerous for large files
]
]));
```
-Which will result in a notification that looks like this:
+Here's what that looks like on Android:
- { width=500 }
- JSON webhook, transformed using a custom template
+ { width=500 }
+ Image attachment sent from a local file
-### Inline templating
+### Attach file from a URL
+Instead of sending a local file to your phone, you can use **an external URL** to specify where the attachment is hosted.
+This could be a Dropbox link, a file from social media, or any other publicly available URL. Since the files are
+externally hosted, the expiration or size limits from above do not apply here.
-When `X-Template: yes` (aliases: `Template: yes`, `Tpl: yes`) or `?template=yes` is set, you can use Go templates in the `message` and `title` fields of your
-webhook payload.
+To attach an external file, simple pass the `X-Attach` header or query parameter (or any of its aliases `Attach` or `a`)
+to specify the attachment URL. It can be any type of file.
-Inline templates are most useful for templated one-off messages, or if you do not control the ntfy server (e.g., if you're using ntfy.sh).
-Consider using [pre-defined templates](#pre-defined-templates) or [custom templates](#custom-templates) instead,
-if you control the ntfy server, as templates are much easier to maintain.
+ntfy will automatically try to derive the file name from the URL (e.g `https://example.com/flower.jpg` will yield a
+filename `flower.jpg`). To override this filename, you may send the `X-Filename` header or query parameter (or any of its
+aliases `Filename`, `File` or `f`).
-Here's an **example for a Grafana alert**:
-
-
- { width=500 }
- Grafana webhook, formatted using templates
-
-
-This was sent using the following templates and payloads
-
-=== "Message template"
- ```
- {{range .alerts}}
- {{.annotations.summary}}
-
- Values:
- {{range $k,$v := .values}}
- - {{$k}}={{$v}}
- {{end}}
- {{end}}
- ```
-
-=== "Title template"
- ```
- {{.title}}
- ```
-
-=== "Encoded webhook URL"
- ```
- # Additional URL encoding (see https://www.urlencoder.org/) is necessary for Grafana,
- # and may be required for other tools too
-
- https://ntfy.sh/mytopic?tpl=1&t=%7B%7B.title%7D%7D&m=%7B%7Brange%20.alerts%7D%7D%7B%7B.annotations.summary%7D%7D%5Cn%5CnValues%3A%5Cn%7B%7Brange%20%24k%2C%24v%20%3A%3D%20.values%7D%7D-%20%7B%7B%24k%7D%7D%3D%7B%7B%24v%7D%7D%5Cn%7B%7Bend%7D%7D%7B%7Bend%7D%7D
- ```
-
-=== "Grafana-sent payload"
- ```
- {"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\nAnnotations:\n - summary = 15m load average too high\nSource: localhost:3000/alerting/grafana/NW9oDw-4z/view\nSilence: 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\n"}
- ```
-
-Here's an **easier example with a shorter JSON payload**:
+Here's an example showing how to attach an APK file:
=== "Command line (curl)"
```
- # To use { and } in the URL without encoding, we need to turn off
- # curl's globbing using --globoff
-
curl \
- --globoff \
- -d '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}' \
- 'ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}'
+ -X POST \
+ -H "Attach: https://f-droid.org/F-Droid.apk" \
+ ntfy.sh/mydownloads
+ ```
+
+=== "ntfy CLI"
+ ```
+ ntfy publish \
+ --attach="https://f-droid.org/F-Droid.apk" \
+ mydownloads
```
=== "HTTP"
``` http
- POST /mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}} HTTP/1.1
+ POST /mydownloads HTTP/1.1
Host: ntfy.sh
-
- {"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}
+ Attach: https://f-droid.org/F-Droid.apk
```
=== "JavaScript"
``` javascript
- fetch('https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}', {
+ fetch('https://ntfy.sh/mydownloads', {
method: 'POST',
- body: '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}'
+ headers: { 'Attach': 'https://f-droid.org/F-Droid.apk' }
})
```
=== "Go"
``` go
- body := `{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}`
- uri := "https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}"
- req, _ := http.NewRequest("POST", uri, strings.NewReader(body))
- http.DefaultClient.Do(req)
- ```
-
-
-=== "PowerShell"
- ``` powershell
- $Request = @{
- Method = "POST"
- URI = "https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}"
- Body = '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}'
- ContentType = "application/json"
- }
- Invoke-RestMethod @Request
- ```
-
-=== "Python"
- ``` python
- requests.post(
- "https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}",
- data='{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}'
- )
- ```
-
-=== "PHP"
- ``` php-inline
- file_get_contents("https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}", false, stream_context_create([
- 'http' => [
- 'method' => 'POST',
- 'header' => "Content-Type: application/json",
- 'content' => '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}'
- ]
- ]));
- ```
-
-This example uses the `message`/`m` and `title`/`t` query parameters, but obviously this also works with the corresponding
-`Message`/`Title` headers. It will send a notification with a title `phil-pc: A severe error has occurred` and a message
-`Error message: Disk has run out of space`.
-
-### Template syntax
-ntfy uses [Go templates](https://pkg.go.dev/text/template) for its templates, which is arguably one of the most powerful,
-yet also one of the worst templating languages out there.
-
-You can use the following features in your templates:
-
-* Variables, e.g. `{{.alert.title}}` or `An error occurred: {{.error.desc}}`
-* Conditionals (if/else, e.g. `{{if eq .action "opened"}}..{{else}}..{{end}}`, see [example](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6Ilt7ey5wdWxsX3JlcXVlc3QuaGVhZC5yZXBvLmZ1bGxfbmFtZX19XSBQdWxsIHJlcXVlc3Qge3tpZiBlcSAuYWN0aW9uIFwib3BlbmVkXCJ9fU9QRU5FRHt7ZWxzZX19Q0xPU0VEe3tlbmR9fToge3sucHVsbF9yZXF1ZXN0LnRpdGxlfX0iLCJpbnB1dCI6IntcbiAgXCJhY3Rpb25cIjogXCJvcGVuZWRcIixcbiAgXCJudW1iZXJcIjogMSxcbiAgXCJwdWxsX3JlcXVlc3RcIjoge1xuICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxscy8xXCIsXG4gICAgXCJpZFwiOiAxNzgzNDIwOTcyLFxuICAgIFwibm9kZV9pZFwiOiBcIlBSX2t3RE9IQWJkbzg1cVROZ3NcIixcbiAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGwvMVwiLFxuICAgIFwiZGlmZl91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllci9kYWJibGUvcHVsbC8xLmRpZmZcIixcbiAgICBcInBhdGNoX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxsLzEucGF0Y2hcIixcbiAgICBcImlzc3VlX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzLzFcIixcbiAgICBcIm51bWJlclwiOiAxLFxuICAgIFwic3RhdGVcIjogXCJvcGVuXCIsXG4gICAgXCJsb2NrZWRcIjogZmFsc2UsXG4gICAgXCJ0aXRsZVwiOiBcIkEgc2FtcGxlIFBSIGZyb20gUGhpbFwiLFxuICAgIFwidXNlclwiOiB7XG4gICAgICBcImxvZ2luXCI6IFwiYmlud2llZGVyaGllclwiLFxuICAgICAgXCJpZFwiOiA2NjQ1OTcsXG4gICAgICBcIm5vZGVfaWRcIjogXCJNRFE2VlhObGNqWTJORFU1Tnc9PVwiLFxuICAgICAgXCJhdmF0YXJfdXJsXCI6IFwiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzY2NDU5Nz92PTRcIixcbiAgICAgIFwiZ3JhdmF0YXJfaWRcIjogXCJcIixcbiAgICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyXCIsXG4gICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXJcIixcbiAgICAgIFwiZm9sbG93ZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dlcnNcIixcbiAgICAgIFwiZm9sbG93aW5nX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dpbmd7L290aGVyX3VzZXJ9XCIsXG4gICAgICBcImdpc3RzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9naXN0c3svZ2lzdF9pZH1cIixcbiAgICAgIFwic3RhcnJlZF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfVwiLFxuICAgICAgXCJzdWJzY3JpcHRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdWJzY3JpcHRpb25zXCIsXG4gICAgICBcIm9yZ2FuaXphdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL29yZ3NcIixcbiAgICAgIFwicmVwb3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlcG9zXCIsXG4gICAgICBcImV2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZXZlbnRzey9wcml2YWN5fVwiLFxuICAgICAgXCJyZWNlaXZlZF9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlY2VpdmVkX2V2ZW50c1wiLFxuICAgICAgXCJ0eXBlXCI6IFwiVXNlclwiLFxuICAgICAgXCJzaXRlX2FkbWluXCI6IGZhbHNlXG4gICAgfSxcbiAgICBcImJvZHlcIjogbnVsbCxcbiAgICBcImNyZWF0ZWRfYXRcIjogXCIyMDI0LTAzLTIxVDAyOjUyOjA5WlwiLFxuICAgIFwidXBkYXRlZF9hdFwiOiBcIjIwMjQtMDMtMjFUMDI6NTI6MDlaXCIsXG4gICAgXCJjbG9zZWRfYXRcIjogbnVsbCxcbiAgICBcIm1lcmdlZF9hdFwiOiBudWxsLFxuICAgIFwibWVyZ2VfY29tbWl0X3NoYVwiOiBudWxsLFxuICAgIFwiYXNzaWduZWVcIjogbnVsbCxcbiAgICBcImFzc2lnbmVlc1wiOiBbXSxcbiAgICBcInJlcXVlc3RlZF9yZXZpZXdlcnNcIjogW10sXG4gICAgXCJyZXF1ZXN0ZWRfdGVhbXNcIjogW10sXG4gICAgXCJsYWJlbHNcIjogW10sXG4gICAgXCJtaWxlc3RvbmVcIjogbnVsbCxcbiAgICBcImRyYWZ0XCI6IGZhbHNlLFxuICAgIFwiY29tbWl0c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGxzLzEvY29tbWl0c1wiLFxuICAgIFwicmV2aWV3X2NvbW1lbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMS9jb21tZW50c1wiLFxuICAgIFwicmV2aWV3X2NvbW1lbnRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxscy9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgIFwiY29tbWVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXMvMS9jb21tZW50c1wiLFxuICAgIFwic3RhdHVzZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGF0dXNlcy81NzAzODQyY2M1NzE1ZWQxZTM1OGQyM2ViYjY5M2RiMDk3NDdhZTliXCIsXG4gICAgXCJoZWFkXCI6IHtcbiAgICAgIFwibGFiZWxcIjogXCJiaW53aWVkZXJoaWVyOmFhXCIsXG4gICAgICBcInJlZlwiOiBcImFhXCIsXG4gICAgICBcInNoYVwiOiBcIjU3MDM4NDJjYzU3MTVlZDFlMzU4ZDIzZWJiNjkzZGIwOTc0N2FlOWJcIixcbiAgICAgIFwidXNlclwiOiB7XG4gICAgICAgIFwibG9naW5cIjogXCJiaW53aWVkZXJoaWVyXCIsXG4gICAgICAgIFwiaWRcIjogNjY0NTk3LFxuICAgICAgICBcIm5vZGVfaWRcIjogXCJNRFE2VlhObGNqWTJORFU1Tnc9PVwiLFxuICAgICAgICBcImF2YXRhcl91cmxcIjogXCJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvNjY0NTk3P3Y9NFwiLFxuICAgICAgICBcImdyYXZhdGFyX2lkXCI6IFwiXCIsXG4gICAgICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyXCIsXG4gICAgICAgIFwiaHRtbF91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllclwiLFxuICAgICAgICBcImZvbGxvd2Vyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93ZXJzXCIsXG4gICAgICAgIFwiZm9sbG93aW5nX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dpbmd7L290aGVyX3VzZXJ9XCIsXG4gICAgICAgIFwiZ2lzdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2dpc3Rzey9naXN0X2lkfVwiLFxuICAgICAgICBcInN0YXJyZWRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N0YXJyZWR7L293bmVyfXsvcmVwb31cIixcbiAgICAgICAgXCJzdWJzY3JpcHRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdWJzY3JpcHRpb25zXCIsXG4gICAgICAgIFwib3JnYW5pemF0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvb3Jnc1wiLFxuICAgICAgICBcInJlcG9zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZXBvc1wiLFxuICAgICAgICBcImV2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZXZlbnRzey9wcml2YWN5fVwiLFxuICAgICAgICBcInJlY2VpdmVkX2V2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVjZWl2ZWRfZXZlbnRzXCIsXG4gICAgICAgIFwidHlwZVwiOiBcIlVzZXJcIixcbiAgICAgICAgXCJzaXRlX2FkbWluXCI6IGZhbHNlXG4gICAgICB9LFxuICAgICAgXCJyZXBvXCI6IHtcbiAgICAgICAgXCJpZFwiOiA0NzAyMTIwMDMsXG4gICAgICAgIFwibm9kZV9pZFwiOiBcIlJfa2dET0hBYmRvd1wiLFxuICAgICAgICBcIm5hbWVcIjogXCJkYWJibGVcIixcbiAgICAgICAgXCJmdWxsX25hbWVcIjogXCJiaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcInByaXZhdGVcIjogZmFsc2UsXG4gICAgICAgIFwib3duZXJcIjoge1xuICAgICAgICAgIFwibG9naW5cIjogXCJiaW53aWVkZXJoaWVyXCIsXG4gICAgICAgICAgXCJpZFwiOiA2NjQ1OTcsXG4gICAgICAgICAgXCJub2RlX2lkXCI6IFwiTURRNlZYTmxjalkyTkRVNU53PT1cIixcbiAgICAgICAgICBcImF2YXRhcl91cmxcIjogXCJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvNjY0NTk3P3Y9NFwiLFxuICAgICAgICAgIFwiZ3JhdmF0YXJfaWRcIjogXCJcIixcbiAgICAgICAgICBcInVybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllclwiLFxuICAgICAgICAgIFwiaHRtbF91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllclwiLFxuICAgICAgICAgIFwiZm9sbG93ZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dlcnNcIixcbiAgICAgICAgICBcImZvbGxvd2luZ191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93aW5ney9vdGhlcl91c2VyfVwiLFxuICAgICAgICAgIFwiZ2lzdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2dpc3Rzey9naXN0X2lkfVwiLFxuICAgICAgICAgIFwic3RhcnJlZF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfVwiLFxuICAgICAgICAgIFwic3Vic2NyaXB0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3Vic2NyaXB0aW9uc1wiLFxuICAgICAgICAgIFwib3JnYW5pemF0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvb3Jnc1wiLFxuICAgICAgICAgIFwicmVwb3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlcG9zXCIsXG4gICAgICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2V2ZW50c3svcHJpdmFjeX1cIixcbiAgICAgICAgICBcInJlY2VpdmVkX2V2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVjZWl2ZWRfZXZlbnRzXCIsXG4gICAgICAgICAgXCJ0eXBlXCI6IFwiVXNlclwiLFxuICAgICAgICAgIFwic2l0ZV9hZG1pblwiOiBmYWxzZVxuICAgICAgICB9LFxuICAgICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlXCIsXG4gICAgICAgIFwiZGVzY3JpcHRpb25cIjogXCJBIHJlcG8gZm9yIGRhYmJsaW5nXCIsXG4gICAgICAgIFwiZm9ya1wiOiBmYWxzZSxcbiAgICAgICAgXCJ1cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlXCIsXG4gICAgICAgIFwiZm9ya3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9mb3Jrc1wiLFxuICAgICAgICBcImtleXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9rZXlzey9rZXlfaWR9XCIsXG4gICAgICAgIFwiY29sbGFib3JhdG9yc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbGxhYm9yYXRvcnN7L2NvbGxhYm9yYXRvcn1cIixcbiAgICAgICAgXCJ0ZWFtc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3RlYW1zXCIsXG4gICAgICAgIFwiaG9va3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9ob29rc1wiLFxuICAgICAgICBcImlzc3VlX2V2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy9ldmVudHN7L251bWJlcn1cIixcbiAgICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9ldmVudHNcIixcbiAgICAgICAgXCJhc3NpZ25lZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9hc3NpZ25lZXN7L3VzZXJ9XCIsXG4gICAgICAgIFwiYnJhbmNoZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9icmFuY2hlc3svYnJhbmNofVwiLFxuICAgICAgICBcInRhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS90YWdzXCIsXG4gICAgICAgIFwiYmxvYnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvYmxvYnN7L3NoYX1cIixcbiAgICAgICAgXCJnaXRfdGFnc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2dpdC90YWdzey9zaGF9XCIsXG4gICAgICAgIFwiZ2l0X3JlZnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvcmVmc3svc2hhfVwiLFxuICAgICAgICBcInRyZWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L3RyZWVzey9zaGF9XCIsXG4gICAgICAgIFwic3RhdHVzZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGF0dXNlcy97c2hhfVwiLFxuICAgICAgICBcImxhbmd1YWdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2xhbmd1YWdlc1wiLFxuICAgICAgICBcInN0YXJnYXplcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGFyZ2F6ZXJzXCIsXG4gICAgICAgIFwiY29udHJpYnV0b3JzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29udHJpYnV0b3JzXCIsXG4gICAgICAgIFwic3Vic2NyaWJlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdWJzY3JpYmVyc1wiLFxuICAgICAgICBcInN1YnNjcmlwdGlvbl91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3N1YnNjcmlwdGlvblwiLFxuICAgICAgICBcImNvbW1pdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb21taXRzey9zaGF9XCIsXG4gICAgICAgIFwiZ2l0X2NvbW1pdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvY29tbWl0c3svc2hhfVwiLFxuICAgICAgICBcImNvbW1lbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tbWVudHN7L251bWJlcn1cIixcbiAgICAgICAgXCJpc3N1ZV9jb21tZW50X3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzL2NvbW1lbnRzey9udW1iZXJ9XCIsXG4gICAgICAgIFwiY29udGVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb250ZW50cy97K3BhdGh9XCIsXG4gICAgICAgIFwiY29tcGFyZV91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbXBhcmUve2Jhc2V9Li4ue2hlYWR9XCIsXG4gICAgICAgIFwibWVyZ2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbWVyZ2VzXCIsXG4gICAgICAgIFwiYXJjaGl2ZV91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3thcmNoaXZlX2Zvcm1hdH17L3JlZn1cIixcbiAgICAgICAgXCJkb3dubG9hZHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9kb3dubG9hZHNcIixcbiAgICAgICAgXCJpc3N1ZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXN7L251bWJlcn1cIixcbiAgICAgICAgXCJwdWxsc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGxzey9udW1iZXJ9XCIsXG4gICAgICAgIFwibWlsZXN0b25lc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL21pbGVzdG9uZXN7L251bWJlcn1cIixcbiAgICAgICAgXCJub3RpZmljYXRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbm90aWZpY2F0aW9uc3s/c2luY2UsYWxsLHBhcnRpY2lwYXRpbmd9XCIsXG4gICAgICAgIFwibGFiZWxzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbGFiZWxzey9uYW1lfVwiLFxuICAgICAgICBcInJlbGVhc2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcmVsZWFzZXN7L2lkfVwiLFxuICAgICAgICBcImRlcGxveW1lbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZGVwbG95bWVudHNcIixcbiAgICAgICAgXCJjcmVhdGVkX2F0XCI6IFwiMjAyMi0wMy0xNVQxNTowNjoxN1pcIixcbiAgICAgICAgXCJ1cGRhdGVkX2F0XCI6IFwiMjAyMi0wMy0xNVQxNTowNjoxN1pcIixcbiAgICAgICAgXCJwdXNoZWRfYXRcIjogXCIyMDI0LTAzLTIxVDAyOjUyOjEwWlwiLFxuICAgICAgICBcImdpdF91cmxcIjogXCJnaXQ6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgICAgICBcInNzaF91cmxcIjogXCJnaXRAZ2l0aHViLmNvbTpiaW53aWVkZXJoaWVyL2RhYmJsZS5naXRcIixcbiAgICAgICAgXCJjbG9uZV91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllci9kYWJibGUuZ2l0XCIsXG4gICAgICAgIFwic3ZuX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcImhvbWVwYWdlXCI6IG51bGwsXG4gICAgICAgIFwic2l6ZVwiOiAxLFxuICAgICAgICBcInN0YXJnYXplcnNfY291bnRcIjogMCxcbiAgICAgICAgXCJ3YXRjaGVyc19jb3VudFwiOiAwLFxuICAgICAgICBcImxhbmd1YWdlXCI6IG51bGwsXG4gICAgICAgIFwiaGFzX2lzc3Vlc1wiOiB0cnVlLFxuICAgICAgICBcImhhc19wcm9qZWN0c1wiOiB0cnVlLFxuICAgICAgICBcImhhc19kb3dubG9hZHNcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfd2lraVwiOiB0cnVlLFxuICAgICAgICBcImhhc19wYWdlc1wiOiBmYWxzZSxcbiAgICAgICAgXCJoYXNfZGlzY3Vzc2lvbnNcIjogZmFsc2UsXG4gICAgICAgIFwiZm9ya3NfY291bnRcIjogMCxcbiAgICAgICAgXCJtaXJyb3JfdXJsXCI6IG51bGwsXG4gICAgICAgIFwiYXJjaGl2ZWRcIjogZmFsc2UsXG4gICAgICAgIFwiZGlzYWJsZWRcIjogZmFsc2UsXG4gICAgICAgIFwib3Blbl9pc3N1ZXNfY291bnRcIjogMSxcbiAgICAgICAgXCJsaWNlbnNlXCI6IG51bGwsXG4gICAgICAgIFwiYWxsb3dfZm9ya2luZ1wiOiB0cnVlLFxuICAgICAgICBcImlzX3RlbXBsYXRlXCI6IGZhbHNlLFxuICAgICAgICBcIndlYl9jb21taXRfc2lnbm9mZl9yZXF1aXJlZFwiOiBmYWxzZSxcbiAgICAgICAgXCJ0b3BpY3NcIjogW10sXG4gICAgICAgIFwidmlzaWJpbGl0eVwiOiBcInB1YmxpY1wiLFxuICAgICAgICBcImZvcmtzXCI6IDAsXG4gICAgICAgIFwib3Blbl9pc3N1ZXNcIjogMSxcbiAgICAgICAgXCJ3YXRjaGVyc1wiOiAwLFxuICAgICAgICBcImRlZmF1bHRfYnJhbmNoXCI6IFwibWFpblwiLFxuICAgICAgICBcImFsbG93X3NxdWFzaF9tZXJnZVwiOiB0cnVlLFxuICAgICAgICBcImFsbG93X21lcmdlX2NvbW1pdFwiOiB0cnVlLFxuICAgICAgICBcImFsbG93X3JlYmFzZV9tZXJnZVwiOiB0cnVlLFxuICAgICAgICBcImFsbG93X2F1dG9fbWVyZ2VcIjogZmFsc2UsXG4gICAgICAgIFwiZGVsZXRlX2JyYW5jaF9vbl9tZXJnZVwiOiBmYWxzZSxcbiAgICAgICAgXCJhbGxvd191cGRhdGVfYnJhbmNoXCI6IGZhbHNlLFxuICAgICAgICBcInVzZV9zcXVhc2hfcHJfdGl0bGVfYXNfZGVmYXVsdFwiOiBmYWxzZSxcbiAgICAgICAgXCJzcXVhc2hfbWVyZ2VfY29tbWl0X21lc3NhZ2VcIjogXCJDT01NSVRfTUVTU0FHRVNcIixcbiAgICAgICAgXCJzcXVhc2hfbWVyZ2VfY29tbWl0X3RpdGxlXCI6IFwiQ09NTUlUX09SX1BSX1RJVExFXCIsXG4gICAgICAgIFwibWVyZ2VfY29tbWl0X21lc3NhZ2VcIjogXCJQUl9USVRMRVwiLFxuICAgICAgICBcIm1lcmdlX2NvbW1pdF90aXRsZVwiOiBcIk1FUkdFX01FU1NBR0VcIlxuICAgICAgfVxuICAgIH0sXG4gICAgXCJiYXNlXCI6IHtcbiAgICAgIFwibGFiZWxcIjogXCJiaW53aWVkZXJoaWVyOm1haW5cIixcbiAgICAgIFwicmVmXCI6IFwibWFpblwiLFxuICAgICAgXCJzaGFcIjogXCI3MmQ5MzFhMjBiYjgzZDEyM2FiNDVhY2NhZjc2MTE1MGM4YjAxMjExXCIsXG4gICAgICBcInVzZXJcIjoge1xuICAgICAgICBcImxvZ2luXCI6IFwiYmlud2llZGVyaGllclwiLFxuICAgICAgICBcImlkXCI6IDY2NDU5NyxcbiAgICAgICAgXCJub2RlX2lkXCI6IFwiTURRNlZYTmxjalkyTkRVNU53PT1cIixcbiAgICAgICAgXCJhdmF0YXJfdXJsXCI6IFwiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzY2NDU5Nz92PTRcIixcbiAgICAgICAgXCJncmF2YXRhcl9pZFwiOiBcIlwiLFxuICAgICAgICBcInVybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllclwiLFxuICAgICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXJcIixcbiAgICAgICAgXCJmb2xsb3dlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2ZvbGxvd2Vyc1wiLFxuICAgICAgICBcImZvbGxvd2luZ191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93aW5ney9vdGhlcl91c2VyfVwiLFxuICAgICAgICBcImdpc3RzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9naXN0c3svZ2lzdF9pZH1cIixcbiAgICAgICAgXCJzdGFycmVkX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdGFycmVkey9vd25lcn17L3JlcG99XCIsXG4gICAgICAgIFwic3Vic2NyaXB0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3Vic2NyaXB0aW9uc1wiLFxuICAgICAgICBcIm9yZ2FuaXphdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL29yZ3NcIixcbiAgICAgICAgXCJyZXBvc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVwb3NcIixcbiAgICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2V2ZW50c3svcHJpdmFjeX1cIixcbiAgICAgICAgXCJyZWNlaXZlZF9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlY2VpdmVkX2V2ZW50c1wiLFxuICAgICAgICBcInR5cGVcIjogXCJVc2VyXCIsXG4gICAgICAgIFwic2l0ZV9hZG1pblwiOiBmYWxzZVxuICAgICAgfSxcbiAgICAgIFwicmVwb1wiOiB7XG4gICAgICAgIFwiaWRcIjogNDcwMjEyMDAzLFxuICAgICAgICBcIm5vZGVfaWRcIjogXCJSX2tnRE9IQWJkb3dcIixcbiAgICAgICAgXCJuYW1lXCI6IFwiZGFiYmxlXCIsXG4gICAgICAgIFwiZnVsbF9uYW1lXCI6IFwiYmlud2llZGVyaGllci9kYWJibGVcIixcbiAgICAgICAgXCJwcml2YXRlXCI6IGZhbHNlLFxuICAgICAgICBcIm93bmVyXCI6IHtcbiAgICAgICAgICBcImxvZ2luXCI6IFwiYmlud2llZGVyaGllclwiLFxuICAgICAgICAgIFwiaWRcIjogNjY0NTk3LFxuICAgICAgICAgIFwibm9kZV9pZFwiOiBcIk1EUTZWWE5sY2pZMk5EVTVOdz09XCIsXG4gICAgICAgICAgXCJhdmF0YXJfdXJsXCI6IFwiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzY2NDU5Nz92PTRcIixcbiAgICAgICAgICBcImdyYXZhdGFyX2lkXCI6IFwiXCIsXG4gICAgICAgICAgXCJ1cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXJcIixcbiAgICAgICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXJcIixcbiAgICAgICAgICBcImZvbGxvd2Vyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93ZXJzXCIsXG4gICAgICAgICAgXCJmb2xsb3dpbmdfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2ZvbGxvd2luZ3svb3RoZXJfdXNlcn1cIixcbiAgICAgICAgICBcImdpc3RzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9naXN0c3svZ2lzdF9pZH1cIixcbiAgICAgICAgICBcInN0YXJyZWRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N0YXJyZWR7L293bmVyfXsvcmVwb31cIixcbiAgICAgICAgICBcInN1YnNjcmlwdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N1YnNjcmlwdGlvbnNcIixcbiAgICAgICAgICBcIm9yZ2FuaXphdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL29yZ3NcIixcbiAgICAgICAgICBcInJlcG9zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZXBvc1wiLFxuICAgICAgICAgIFwiZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9ldmVudHN7L3ByaXZhY3l9XCIsXG4gICAgICAgICAgXCJyZWNlaXZlZF9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlY2VpdmVkX2V2ZW50c1wiLFxuICAgICAgICAgIFwidHlwZVwiOiBcIlVzZXJcIixcbiAgICAgICAgICBcInNpdGVfYWRtaW5cIjogZmFsc2VcbiAgICAgICAgfSxcbiAgICAgICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcImRlc2NyaXB0aW9uXCI6IFwiQSByZXBvIGZvciBkYWJibGluZ1wiLFxuICAgICAgICBcImZvcmtcIjogZmFsc2UsXG4gICAgICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcImZvcmtzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZm9ya3NcIixcbiAgICAgICAgXCJrZXlzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUva2V5c3sva2V5X2lkfVwiLFxuICAgICAgICBcImNvbGxhYm9yYXRvcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb2xsYWJvcmF0b3Jzey9jb2xsYWJvcmF0b3J9XCIsXG4gICAgICAgIFwidGVhbXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS90ZWFtc1wiLFxuICAgICAgICBcImhvb2tzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaG9va3NcIixcbiAgICAgICAgXCJpc3N1ZV9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXMvZXZlbnRzey9udW1iZXJ9XCIsXG4gICAgICAgIFwiZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZXZlbnRzXCIsXG4gICAgICAgIFwiYXNzaWduZWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvYXNzaWduZWVzey91c2VyfVwiLFxuICAgICAgICBcImJyYW5jaGVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvYnJhbmNoZXN7L2JyYW5jaH1cIixcbiAgICAgICAgXCJ0YWdzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvdGFnc1wiLFxuICAgICAgICBcImJsb2JzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L2Jsb2Jzey9zaGF9XCIsXG4gICAgICAgIFwiZ2l0X3RhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvdGFnc3svc2hhfVwiLFxuICAgICAgICBcImdpdF9yZWZzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L3JlZnN7L3NoYX1cIixcbiAgICAgICAgXCJ0cmVlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2dpdC90cmVlc3svc2hhfVwiLFxuICAgICAgICBcInN0YXR1c2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3RhdHVzZXMve3NoYX1cIixcbiAgICAgICAgXCJsYW5ndWFnZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9sYW5ndWFnZXNcIixcbiAgICAgICAgXCJzdGFyZ2F6ZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3RhcmdhemVyc1wiLFxuICAgICAgICBcImNvbnRyaWJ1dG9yc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbnRyaWJ1dG9yc1wiLFxuICAgICAgICBcInN1YnNjcmliZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3Vic2NyaWJlcnNcIixcbiAgICAgICAgXCJzdWJzY3JpcHRpb25fdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdWJzY3JpcHRpb25cIixcbiAgICAgICAgXCJjb21taXRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tbWl0c3svc2hhfVwiLFxuICAgICAgICBcImdpdF9jb21taXRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L2NvbW1pdHN7L3NoYX1cIixcbiAgICAgICAgXCJjb21tZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbW1lbnRzey9udW1iZXJ9XCIsXG4gICAgICAgIFwiaXNzdWVfY29tbWVudF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgICAgICBcImNvbnRlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29udGVudHMveytwYXRofVwiLFxuICAgICAgICBcImNvbXBhcmVfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb21wYXJlL3tiYXNlfS4uLntoZWFkfVwiLFxuICAgICAgICBcIm1lcmdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL21lcmdlc1wiLFxuICAgICAgICBcImFyY2hpdmVfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS97YXJjaGl2ZV9mb3JtYXR9ey9yZWZ9XCIsXG4gICAgICAgIFwiZG93bmxvYWRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZG93bmxvYWRzXCIsXG4gICAgICAgIFwiaXNzdWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzey9udW1iZXJ9XCIsXG4gICAgICAgIFwicHVsbHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxsc3svbnVtYmVyfVwiLFxuICAgICAgICBcIm1pbGVzdG9uZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9taWxlc3RvbmVzey9udW1iZXJ9XCIsXG4gICAgICAgIFwibm90aWZpY2F0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL25vdGlmaWNhdGlvbnN7P3NpbmNlLGFsbCxwYXJ0aWNpcGF0aW5nfVwiLFxuICAgICAgICBcImxhYmVsc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2xhYmVsc3svbmFtZX1cIixcbiAgICAgICAgXCJyZWxlYXNlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3JlbGVhc2Vzey9pZH1cIixcbiAgICAgICAgXCJkZXBsb3ltZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2RlcGxveW1lbnRzXCIsXG4gICAgICAgIFwiY3JlYXRlZF9hdFwiOiBcIjIwMjItMDMtMTVUMTU6MDY6MTdaXCIsXG4gICAgICAgIFwidXBkYXRlZF9hdFwiOiBcIjIwMjItMDMtMTVUMTU6MDY6MTdaXCIsXG4gICAgICAgIFwicHVzaGVkX2F0XCI6IFwiMjAyNC0wMy0yMVQwMjo1MjoxMFpcIixcbiAgICAgICAgXCJnaXRfdXJsXCI6IFwiZ2l0Oi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZS5naXRcIixcbiAgICAgICAgXCJzc2hfdXJsXCI6IFwiZ2l0QGdpdGh1Yi5jb206Ymlud2llZGVyaGllci9kYWJibGUuZ2l0XCIsXG4gICAgICAgIFwiY2xvbmVfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgICAgICBcInN2bl91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllci9kYWJibGVcIixcbiAgICAgICAgXCJob21lcGFnZVwiOiBudWxsLFxuICAgICAgICBcInNpemVcIjogMSxcbiAgICAgICAgXCJzdGFyZ2F6ZXJzX2NvdW50XCI6IDAsXG4gICAgICAgIFwid2F0Y2hlcnNfY291bnRcIjogMCxcbiAgICAgICAgXCJsYW5ndWFnZVwiOiBudWxsLFxuICAgICAgICBcImhhc19pc3N1ZXNcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfcHJvamVjdHNcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfZG93bmxvYWRzXCI6IHRydWUsXG4gICAgICAgIFwiaGFzX3dpa2lcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfcGFnZXNcIjogZmFsc2UsXG4gICAgICAgIFwiaGFzX2Rpc2N1c3Npb25zXCI6IGZhbHNlLFxuICAgICAgICBcImZvcmtzX2NvdW50XCI6IDAsXG4gICAgICAgIFwibWlycm9yX3VybFwiOiBudWxsLFxuICAgICAgICBcImFyY2hpdmVkXCI6IGZhbHNlLFxuICAgICAgICBcImRpc2FibGVkXCI6IGZhbHNlLFxuICAgICAgICBcIm9wZW5faXNzdWVzX2NvdW50XCI6IDEsXG4gICAgICAgIFwibGljZW5zZVwiOiBudWxsLFxuICAgICAgICBcImFsbG93X2ZvcmtpbmdcIjogdHJ1ZSxcbiAgICAgICAgXCJpc190ZW1wbGF0ZVwiOiBmYWxzZSxcbiAgICAgICAgXCJ3ZWJfY29tbWl0X3NpZ25vZmZfcmVxdWlyZWRcIjogZmFsc2UsXG4gICAgICAgIFwidG9waWNzXCI6IFtdLFxuICAgICAgICBcInZpc2liaWxpdHlcIjogXCJwdWJsaWNcIixcbiAgICAgICAgXCJmb3Jrc1wiOiAwLFxuICAgICAgICBcIm9wZW5faXNzdWVzXCI6IDEsXG4gICAgICAgIFwid2F0Y2hlcnNcIjogMCxcbiAgICAgICAgXCJkZWZhdWx0X2JyYW5jaFwiOiBcIm1haW5cIixcbiAgICAgICAgXCJhbGxvd19zcXVhc2hfbWVyZ2VcIjogdHJ1ZSxcbiAgICAgICAgXCJhbGxvd19tZXJnZV9jb21taXRcIjogdHJ1ZSxcbiAgICAgICAgXCJhbGxvd19yZWJhc2VfbWVyZ2VcIjogdHJ1ZSxcbiAgICAgICAgXCJhbGxvd19hdXRvX21lcmdlXCI6IGZhbHNlLFxuICAgICAgICBcImRlbGV0ZV9icmFuY2hfb25fbWVyZ2VcIjogZmFsc2UsXG4gICAgICAgIFwiYWxsb3dfdXBkYXRlX2JyYW5jaFwiOiBmYWxzZSxcbiAgICAgICAgXCJ1c2Vfc3F1YXNoX3ByX3RpdGxlX2FzX2RlZmF1bHRcIjogZmFsc2UsXG4gICAgICAgIFwic3F1YXNoX21lcmdlX2NvbW1pdF9tZXNzYWdlXCI6IFwiQ09NTUlUX01FU1NBR0VTXCIsXG4gICAgICAgIFwic3F1YXNoX21lcmdlX2NvbW1pdF90aXRsZVwiOiBcIkNPTU1JVF9PUl9QUl9USVRMRVwiLFxuICAgICAgICBcIm1lcmdlX2NvbW1pdF9tZXNzYWdlXCI6IFwiUFJfVElUTEVcIixcbiAgICAgICAgXCJtZXJnZV9jb21taXRfdGl0bGVcIjogXCJNRVJHRV9NRVNTQUdFXCJcbiAgICAgIH1cbiAgICB9LFxuICAgIFwiX2xpbmtzXCI6IHtcbiAgICAgIFwic2VsZlwiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMVwiXG4gICAgICB9LFxuICAgICAgXCJodG1sXCI6IHtcbiAgICAgICAgXCJocmVmXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGwvMVwiXG4gICAgICB9LFxuICAgICAgXCJpc3N1ZVwiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzLzFcIlxuICAgICAgfSxcbiAgICAgIFwiY29tbWVudHNcIjoge1xuICAgICAgICBcImhyZWZcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy8xL2NvbW1lbnRzXCJcbiAgICAgIH0sXG4gICAgICBcInJldmlld19jb21tZW50c1wiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMS9jb21tZW50c1wiXG4gICAgICB9LFxuICAgICAgXCJyZXZpZXdfY29tbWVudFwiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvY29tbWVudHN7L251bWJlcn1cIlxuICAgICAgfSxcbiAgICAgIFwiY29tbWl0c1wiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMS9jb21taXRzXCJcbiAgICAgIH0sXG4gICAgICBcInN0YXR1c2VzXCI6IHtcbiAgICAgICAgXCJocmVmXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGF0dXNlcy81NzAzODQyY2M1NzE1ZWQxZTM1OGQyM2ViYjY5M2RiMDk3NDdhZTliXCJcbiAgICAgIH1cbiAgICB9LFxuICAgIFwiYXV0aG9yX2Fzc29jaWF0aW9uXCI6IFwiT1dORVJcIixcbiAgICBcImF1dG9fbWVyZ2VcIjogbnVsbCxcbiAgICBcImFjdGl2ZV9sb2NrX3JlYXNvblwiOiBudWxsLFxuICAgIFwibWVyZ2VkXCI6IGZhbHNlLFxuICAgIFwibWVyZ2VhYmxlXCI6IG51bGwsXG4gICAgXCJyZWJhc2VhYmxlXCI6IG51bGwsXG4gICAgXCJtZXJnZWFibGVfc3RhdGVcIjogXCJ1bmtub3duXCIsXG4gICAgXCJtZXJnZWRfYnlcIjogbnVsbCxcbiAgICBcImNvbW1lbnRzXCI6IDAsXG4gICAgXCJyZXZpZXdfY29tbWVudHNcIjogMCxcbiAgICBcIm1haW50YWluZXJfY2FuX21vZGlmeVwiOiBmYWxzZSxcbiAgICBcImNvbW1pdHNcIjogMSxcbiAgICBcImFkZGl0aW9uc1wiOiAxLFxuICAgIFwiZGVsZXRpb25zXCI6IDEsXG4gICAgXCJjaGFuZ2VkX2ZpbGVzXCI6IDFcbiAgfSxcbiAgXCJyZXBvc2l0b3J5XCI6IHtcbiAgICBcImlkXCI6IDQ3MDIxMjAwMyxcbiAgICBcIm5vZGVfaWRcIjogXCJSX2tnRE9IQWJkb3dcIixcbiAgICBcIm5hbWVcIjogXCJkYWJibGVcIixcbiAgICBcImZ1bGxfbmFtZVwiOiBcImJpbndpZWRlcmhpZXIvZGFiYmxlXCIsXG4gICAgXCJwcml2YXRlXCI6IGZhbHNlLFxuICAgIFwib3duZXJcIjoge1xuICAgICAgXCJsb2dpblwiOiBcImJpbndpZWRlcmhpZXJcIixcbiAgICAgIFwiaWRcIjogNjY0NTk3LFxuICAgICAgXCJub2RlX2lkXCI6IFwiTURRNlZYTmxjalkyTkRVNU53PT1cIixcbiAgICAgIFwiYXZhdGFyX3VybFwiOiBcImh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS82NjQ1OTc/dj00XCIsXG4gICAgICBcImdyYXZhdGFyX2lkXCI6IFwiXCIsXG4gICAgICBcInVybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllclwiLFxuICAgICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyXCIsXG4gICAgICBcImZvbGxvd2Vyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93ZXJzXCIsXG4gICAgICBcImZvbGxvd2luZ191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93aW5ney9vdGhlcl91c2VyfVwiLFxuICAgICAgXCJnaXN0c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZ2lzdHN7L2dpc3RfaWR9XCIsXG4gICAgICBcInN0YXJyZWRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N0YXJyZWR7L293bmVyfXsvcmVwb31cIixcbiAgICAgIFwic3Vic2NyaXB0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3Vic2NyaXB0aW9uc1wiLFxuICAgICAgXCJvcmdhbml6YXRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9vcmdzXCIsXG4gICAgICBcInJlcG9zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZXBvc1wiLFxuICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2V2ZW50c3svcHJpdmFjeX1cIixcbiAgICAgIFwicmVjZWl2ZWRfZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZWNlaXZlZF9ldmVudHNcIixcbiAgICAgIFwidHlwZVwiOiBcIlVzZXJcIixcbiAgICAgIFwic2l0ZV9hZG1pblwiOiBmYWxzZVxuICAgIH0sXG4gICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgIFwiZGVzY3JpcHRpb25cIjogXCJBIHJlcG8gZm9yIGRhYmJsaW5nXCIsXG4gICAgXCJmb3JrXCI6IGZhbHNlLFxuICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgIFwiZm9ya3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9mb3Jrc1wiLFxuICAgIFwia2V5c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2tleXN7L2tleV9pZH1cIixcbiAgICBcImNvbGxhYm9yYXRvcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb2xsYWJvcmF0b3Jzey9jb2xsYWJvcmF0b3J9XCIsXG4gICAgXCJ0ZWFtc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3RlYW1zXCIsXG4gICAgXCJob29rc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2hvb2tzXCIsXG4gICAgXCJpc3N1ZV9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXMvZXZlbnRzey9udW1iZXJ9XCIsXG4gICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9ldmVudHNcIixcbiAgICBcImFzc2lnbmVlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2Fzc2lnbmVlc3svdXNlcn1cIixcbiAgICBcImJyYW5jaGVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvYnJhbmNoZXN7L2JyYW5jaH1cIixcbiAgICBcInRhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS90YWdzXCIsXG4gICAgXCJibG9ic191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2dpdC9ibG9ic3svc2hhfVwiLFxuICAgIFwiZ2l0X3RhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvdGFnc3svc2hhfVwiLFxuICAgIFwiZ2l0X3JlZnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvcmVmc3svc2hhfVwiLFxuICAgIFwidHJlZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvdHJlZXN7L3NoYX1cIixcbiAgICBcInN0YXR1c2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3RhdHVzZXMve3NoYX1cIixcbiAgICBcImxhbmd1YWdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2xhbmd1YWdlc1wiLFxuICAgIFwic3RhcmdhemVyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3N0YXJnYXplcnNcIixcbiAgICBcImNvbnRyaWJ1dG9yc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbnRyaWJ1dG9yc1wiLFxuICAgIFwic3Vic2NyaWJlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdWJzY3JpYmVyc1wiLFxuICAgIFwic3Vic2NyaXB0aW9uX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3Vic2NyaXB0aW9uXCIsXG4gICAgXCJjb21taXRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tbWl0c3svc2hhfVwiLFxuICAgIFwiZ2l0X2NvbW1pdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvY29tbWl0c3svc2hhfVwiLFxuICAgIFwiY29tbWVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgIFwiaXNzdWVfY29tbWVudF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgIFwiY29udGVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb250ZW50cy97K3BhdGh9XCIsXG4gICAgXCJjb21wYXJlX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tcGFyZS97YmFzZX0uLi57aGVhZH1cIixcbiAgICBcIm1lcmdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL21lcmdlc1wiLFxuICAgIFwiYXJjaGl2ZV91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3thcmNoaXZlX2Zvcm1hdH17L3JlZn1cIixcbiAgICBcImRvd25sb2Fkc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2Rvd25sb2Fkc1wiLFxuICAgIFwiaXNzdWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzey9udW1iZXJ9XCIsXG4gICAgXCJwdWxsc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGxzey9udW1iZXJ9XCIsXG4gICAgXCJtaWxlc3RvbmVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbWlsZXN0b25lc3svbnVtYmVyfVwiLFxuICAgIFwibm90aWZpY2F0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL25vdGlmaWNhdGlvbnN7P3NpbmNlLGFsbCxwYXJ0aWNpcGF0aW5nfVwiLFxuICAgIFwibGFiZWxzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbGFiZWxzey9uYW1lfVwiLFxuICAgIFwicmVsZWFzZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9yZWxlYXNlc3svaWR9XCIsXG4gICAgXCJkZXBsb3ltZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2RlcGxveW1lbnRzXCIsXG4gICAgXCJjcmVhdGVkX2F0XCI6IFwiMjAyMi0wMy0xNVQxNTowNjoxN1pcIixcbiAgICBcInVwZGF0ZWRfYXRcIjogXCIyMDIyLTAzLTE1VDE1OjA2OjE3WlwiLFxuICAgIFwicHVzaGVkX2F0XCI6IFwiMjAyNC0wMy0yMVQwMjo1MjoxMFpcIixcbiAgICBcImdpdF91cmxcIjogXCJnaXQ6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgIFwic3NoX3VybFwiOiBcImdpdEBnaXRodWIuY29tOmJpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgIFwiY2xvbmVfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgIFwic3ZuX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgIFwiaG9tZXBhZ2VcIjogbnVsbCxcbiAgICBcInNpemVcIjogMSxcbiAgICBcInN0YXJnYXplcnNfY291bnRcIjogMCxcbiAgICBcIndhdGNoZXJzX2NvdW50XCI6IDAsXG4gICAgXCJsYW5ndWFnZVwiOiBudWxsLFxuICAgIFwiaGFzX2lzc3Vlc1wiOiB0cnVlLFxuICAgIFwiaGFzX3Byb2plY3RzXCI6IHRydWUsXG4gICAgXCJoYXNfZG93bmxvYWRzXCI6IHRydWUsXG4gICAgXCJoYXNfd2lraVwiOiB0cnVlLFxuICAgIFwiaGFzX3BhZ2VzXCI6IGZhbHNlLFxuICAgIFwiaGFzX2Rpc2N1c3Npb25zXCI6IGZhbHNlLFxuICAgIFwiZm9ya3NfY291bnRcIjogMCxcbiAgICBcIm1pcnJvcl91cmxcIjogbnVsbCxcbiAgICBcImFyY2hpdmVkXCI6IGZhbHNlLFxuICAgIFwiZGlzYWJsZWRcIjogZmFsc2UsXG4gICAgXCJvcGVuX2lzc3Vlc19jb3VudFwiOiAxLFxuICAgIFwibGljZW5zZVwiOiBudWxsLFxuICAgIFwiYWxsb3dfZm9ya2luZ1wiOiB0cnVlLFxuICAgIFwiaXNfdGVtcGxhdGVcIjogZmFsc2UsXG4gICAgXCJ3ZWJfY29tbWl0X3NpZ25vZmZfcmVxdWlyZWRcIjogZmFsc2UsXG4gICAgXCJ0b3BpY3NcIjogW10sXG4gICAgXCJ2aXNpYmlsaXR5XCI6IFwicHVibGljXCIsXG4gICAgXCJmb3Jrc1wiOiAwLFxuICAgIFwib3Blbl9pc3N1ZXNcIjogMSxcbiAgICBcIndhdGNoZXJzXCI6IDAsXG4gICAgXCJkZWZhdWx0X2JyYW5jaFwiOiBcIm1haW5cIlxuICB9LFxuICBcInNlbmRlclwiOiB7XG4gICAgXCJsb2dpblwiOiBcImJpbndpZWRlcmhpZXJcIixcbiAgICBcImlkXCI6IDY2NDU5NyxcbiAgICBcIm5vZGVfaWRcIjogXCJNRFE2VlhObGNqWTJORFU1Tnc9PVwiLFxuICAgIFwiYXZhdGFyX3VybFwiOiBcImh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS82NjQ1OTc/dj00XCIsXG4gICAgXCJncmF2YXRhcl9pZFwiOiBcIlwiLFxuICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyXCIsXG4gICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyXCIsXG4gICAgXCJmb2xsb3dlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2ZvbGxvd2Vyc1wiLFxuICAgIFwiZm9sbG93aW5nX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dpbmd7L290aGVyX3VzZXJ9XCIsXG4gICAgXCJnaXN0c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZ2lzdHN7L2dpc3RfaWR9XCIsXG4gICAgXCJzdGFycmVkX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdGFycmVkey9vd25lcn17L3JlcG99XCIsXG4gICAgXCJzdWJzY3JpcHRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdWJzY3JpcHRpb25zXCIsXG4gICAgXCJvcmdhbml6YXRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9vcmdzXCIsXG4gICAgXCJyZXBvc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVwb3NcIixcbiAgICBcImV2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZXZlbnRzey9wcml2YWN5fVwiLFxuICAgIFwicmVjZWl2ZWRfZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZWNlaXZlZF9ldmVudHNcIixcbiAgICBcInR5cGVcIjogXCJVc2VyXCIsXG4gICAgXCJzaXRlX2FkbWluXCI6IGZhbHNlXG4gIH1cbn1cbiIsImNvbmZpZyI6eyJ0ZW1wbGF0ZSI6InRleHQiLCJmdWxsU2NyZWVuSFRNTCI6ZmFsc2UsImZ1bmN0aW9ucyI6WyJzcHJpZyJdLCJvcHRpb25zIjpbImxpdmUiXSwiaW5wdXRUeXBlIjoieWFtbCJ9fQ==))
-* Loops (e.g. `{{range .errors}}..{{end}}`, see [example](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6IlNldmVyZSBVUkxzOlxue3tyYW5nZSAuZXJyb3JzfX17e2lmIGVxIC5sZXZlbCBcInNldmVyZVwifX0tIHt7LnVybH19XG57e2VuZH19e3tlbmR9fSIsImlucHV0Ijoie1wiZm9vXCI6IFwiYmFyXCIsIFwiZXJyb3JzXCI6IFt7XCJsZXZlbFwiOiBcInNldmVyZVwiLCBcInVybFwiOiBcImh0dHBzOi8vc2V2ZXJlMS5jb21cIn0se1wibGV2ZWxcIjogXCJ3YXJuaW5nXCIsIFwidXJsXCI6IFwiaHR0cHM6Ly93YXJuaW5nLmNvbVwifSx7XCJsZXZlbFwiOiBcInNldmVyZVwiLCBcInVybFwiOiBcImh0dHBzOi8vc2V2ZXJlMi5jb21cIn1dfSIsImNvbmZpZyI6eyJ0ZW1wbGF0ZSI6InRleHQiLCJmdWxsU2NyZWVuSFRNTCI6ZmFsc2UsImZ1bmN0aW9ucyI6WyJzcHJpZyJdLCJvcHRpb25zIjpbImxpdmUiXSwiaW5wdXRUeXBlIjoieWFtbCJ9fQ==))
-
-A good way to experiment with Go templates is the **[Go Template Playground](https://repeatit.io)**. It is _highly recommended_ to test
-your templates there first ([example for Grafana alert](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6InRpdGxlPUdyYWZhbmErYWxlcnQ6K3t7LnRpdGxlfX0mbWVzc2FnZT17ey5tZXNzYWdlfX0iLCJpbnB1dCI6IntcbiAgXCJyZWNlaXZlclwiOiBcIm50ZnlcXFxcLmV4YW1wbGVcXFxcLmNvbS9hbGVydHNcIixcbiAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICBcImFsZXJ0c1wiOiBbXG4gICAge1xuICAgICAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICAgICAgXCJsYWJlbHNcIjoge1xuICAgICAgICBcImFsZXJ0bmFtZVwiOiBcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFwiLFxuICAgICAgICBcImdyYWZhbmFfZm9sZGVyXCI6IFwiTm9kZSBhbGVydHNcIixcbiAgICAgICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgICAgICBcImpvYlwiOiBcIm5vZGUtZXhwb3J0ZXJcIlxuICAgICAgfSxcbiAgICAgIFwiYW5ub3RhdGlvbnNcIjoge1xuICAgICAgICBcInN1bW1hcnlcIjogXCIxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXCJcbiAgICAgIH0sXG4gICAgICBcInN0YXJ0c0F0XCI6IFwiMjAyNC0wMy0xNVQwMjoyODowMFpcIixcbiAgICAgIFwiZW5kc0F0XCI6IFwiMjAyNC0wMy0xNVQwMjo0MjowMFpcIixcbiAgICAgIFwiZ2VuZXJhdG9yVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvZ3JhZmFuYS9OVzlvRHctNHovdmlld1wiLFxuICAgICAgXCJmaW5nZXJwcmludFwiOiBcImJlY2JmYjk0YmQ4MWVmNDhcIixcbiAgICAgIFwic2lsZW5jZVVSTFwiOiBcImxvY2FsaG9zdDozMDAwL2FsZXJ0aW5nL3NpbGVuY2UvbmV3P2FsZXJ0bWFuYWdlcj1ncmFmYW5hJm1hdGNoZXI9YWxlcnRuYW1lJTNETG9hZCthdmcrMTVtK3RvbytoaWdoJm1hdGNoZXI9Z3JhZmFuYV9mb2xkZXIlM0ROb2RlK2FsZXJ0cyZtYXRjaGVyPWluc3RhbmNlJTNEMTAuMTA4LjAuMiUzQTkxMDAmbWF0Y2hlcj1qb2IlM0Rub2RlLWV4cG9ydGVyXCIsXG4gICAgICBcImRhc2hib2FyZFVSTFwiOiBcIlwiLFxuICAgICAgXCJwYW5lbFVSTFwiOiBcIlwiLFxuICAgICAgXCJ2YWx1ZXNcIjoge1xuICAgICAgICBcIkJcIjogMTguOTgyMTEzMTQ0NzU4NzYsXG4gICAgICAgIFwiQ1wiOiAwXG4gICAgICB9LFxuICAgICAgXCJ2YWx1ZVN0cmluZ1wiOiBcIlsgdmFyPSdCJyBsYWJlbHM9e19fbmFtZV9fPW5vZGVfbG9hZDE1LCBpbnN0YW5jZT0xMC4xMDguMC4yOjkxMDAsIGpvYj1ub2RlLWV4cG9ydGVyfSB2YWx1ZT0xOC45ODIxMTMxNDQ3NTg3NiBdLCBbIHZhcj0nQycgbGFiZWxzPXtfX25hbWVfXz1ub2RlX2xvYWQxNSwgaW5zdGFuY2U9MTAuMTA4LjAuMjo5MTAwLCBqb2I9bm9kZS1leHBvcnRlcn0gdmFsdWU9MCBdXCJcbiAgICB9XG4gIF0sXG4gIFwiZ3JvdXBMYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCJcbiAgfSxcbiAgXCJjb21tb25MYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCIsXG4gICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgIFwiam9iXCI6IFwibm9kZS1leHBvcnRlclwiXG4gIH0sXG4gIFwiY29tbW9uQW5ub3RhdGlvbnNcIjoge1xuICAgIFwic3VtbWFyeVwiOiBcIjE1bSBsb2FkIGF2ZXJhZ2UgdG9vIGhpZ2hcIlxuICB9LFxuICBcImV4dGVybmFsVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvXCIsXG4gIFwidmVyc2lvblwiOiBcIjFcIixcbiAgXCJncm91cEtleVwiOiBcInt9OnthbGVydG5hbWU9XFxcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFxcXCIsIGdyYWZhbmFfZm9sZGVyPVxcXCJOb2RlIGFsZXJ0c1xcXCJ9XCIsXG4gIFwidHJ1bmNhdGVkQWxlcnRzXCI6IDAsXG4gIFwib3JnSWRcIjogMSxcbiAgXCJ0aXRsZVwiOiBcIltSRVNPTFZFRF0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoIE5vZGUgYWxlcnRzICgxMC4xMDguMC4yOjkxMDAgbm9kZS1leHBvcnRlcilcIixcbiAgXCJzdGF0ZVwiOiBcIm9rXCIsXG4gIFwibWVzc2FnZVwiOiBcIioqUmVzb2x2ZWQqKlxcblxcblZhbHVlOiBCPTE4Ljk4MjExMzE0NDc1ODc2LCBDPTBcXG5MYWJlbHM6XFxuIC0gYWxlcnRuYW1lID0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoXFxuIC0gZ3JhZmFuYV9mb2xkZXIgPSBOb2RlIGFsZXJ0c1xcbiAtIGluc3RhbmNlID0gMTAuMTA4LjAuMjo5MTAwXFxuIC0gam9iID0gbm9kZS1leHBvcnRlclxcbkFubm90YXRpb25zOlxcbiAtIHN1bW1hcnkgPSAxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXFxuU291cmNlOiBsb2NhbGhvc3Q6MzAwMC9hbGVydGluZy9ncmFmYW5hL05XOW9Edy00ei92aWV3XFxuU2lsZW5jZTogbG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvc2lsZW5jZS9uZXc/YWxlcnRtYW5hZ2VyPWdyYWZhbmEmbWF0Y2hlcj1hbGVydG5hbWUlM0RMb2FkK2F2ZysxNW0rdG9vK2hpZ2gmbWF0Y2hlcj1ncmFmYW5hX2ZvbGRlciUzRE5vZGUrYWxlcnRzJm1hdGNoZXI9aW5zdGFuY2UlM0QxMC4xMDguMC4yJTNBOTEwMCZtYXRjaGVyPWpvYiUzRG5vZGUtZXhwb3J0ZXJcXG5cIlxufVxuIiwiY29uZmlnIjp7InRlbXBsYXRlIjoidGV4dCIsImZ1bGxTY3JlZW5IVE1MIjpmYWxzZSwiZnVuY3Rpb25zIjpbInNwcmlnIl0sIm9wdGlvbnMiOlsibGl2ZSJdLCJpbnB1dFR5cGUiOiJ5YW1sIn19)).
-
-### Template functions
-ntfy supports a subset of the **[Sprig template functions](publish/template-functions.md)** (originally copied from [Sprig](https://github.com/Masterminds/sprig),
-thank you to the Sprig developers 🙏). This is useful for advanced message templating and for transforming the data provided through the JSON payload.
-
-Below are the functions that are available to use inside your message/title templates.
-
-* [String Functions](publish/template-functions.md#string-functions): `trim`, `trunc`, `substr`, `plural`, etc.
-* [String List Functions](publish/template-functions.md#string-list-functions): `splitList`, `sortAlpha`, etc.
-* [Integer Math Functions](publish/template-functions.md#integer-math-functions): `add`, `max`, `mul`, etc.
-* [Integer List Functions](publish/template-functions.md#integer-list-functions): `until`, `untilStep`
-* [Float Math Functions](publish/template-functions.md#float-math-functions): `maxf`, `minf`
-* [Date Functions](publish/template-functions.md#date-functions): `now`, `date`, etc.
-* [Defaults Functions](publish/template-functions.md#default-functions): `default`, `empty`, `coalesce`, `fromJSON`, `toJSON`, `toPrettyJSON`, `toRawJSON`, `ternary`
-* [Encoding Functions](publish/template-functions.md#encoding-functions): `b64enc`, `b64dec`, etc.
-* [Lists and List Functions](publish/template-functions.md#lists-and-list-functions): `list`, `first`, `uniq`, etc.
-* [Dictionaries and Dict Functions](publish/template-functions.md#dictionaries-and-dict-functions): `get`, `set`, `dict`, `hasKey`, `pluck`, `dig`, etc.
-* [Type Conversion Functions](publish/template-functions.md#type-conversion-functions): `atoi`, `int64`, `toString`, etc.
-* [Path and Filepath Functions](publish/template-functions.md#path-and-filepath-functions): `base`, `dir`, `ext`, `clean`, `isAbs`, `osBase`, `osDir`, `osExt`, `osClean`, `osIsAbs`
-* [Flow Control Functions](publish/template-functions.md#flow-control-functions): `fail`
-* Advanced Functions
- * [Reflection](publish/template-functions.md#reflection-functions): `typeOf`, `kindIs`, `typeIsLike`, etc.
- * [Cryptographic and Security Functions](publish/template-functions.md#cryptographic-and-security-functions): `sha256sum`, etc.
- * [URL](publish/template-functions.md#url-functions): `urlParse`, `urlJoin`
-
-
-## Publish as JSON
-_Supported on:_ :material-android: :material-apple: :material-firefox:
-
-For some integrations with other tools (e.g. [Jellyfin](https://jellyfin.org/), [overseerr](https://overseerr.dev/)),
-adding custom headers to HTTP requests may be tricky or impossible, so ntfy also allows publishing the entire message
-as JSON in the request body.
-
-To publish as JSON, simple PUT/POST the JSON object directly to the ntfy root URL. The message format is described below
-the example.
-
-!!! info
- To publish as JSON, you must **PUT/POST to the ntfy root URL**, not to the topic URL. Be sure to check that you're
- POST-ing to `https://ntfy.sh/` (correct), and not to `https://ntfy.sh/mytopic` (incorrect).
-
-Here's an example using most supported parameters. Check the table below for a complete list. The `topic` parameter
-is the only required one:
-
-=== "Command line (curl)"
- ```
- curl ntfy.sh \
- -d '{
- "topic": "mytopic",
- "message": "Disk space is low at 5.1 GB",
- "title": "Low disk space alert",
- "tags": ["warning","cd"],
- "priority": 4,
- "attach": "https://filesrv.lan/space.jpg",
- "filename": "diskspace.jpg",
- "click": "https://homecamera.lan/xasds1h2xsSsa/",
- "actions": [{ "action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" }]
- }'
- ```
-
-=== "HTTP"
- ``` http
- POST / HTTP/1.1
- Host: ntfy.sh
-
- {
- "topic": "mytopic",
- "message": "Disk space is low at 5.1 GB",
- "title": "Low disk space alert",
- "tags": ["warning","cd"],
- "priority": 4,
- "attach": "https://filesrv.lan/space.jpg",
- "filename": "diskspace.jpg",
- "click": "https://homecamera.lan/xasds1h2xsSsa/",
- "actions": [{ "action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" }]
- }
- ```
-
-=== "JavaScript"
- ``` javascript
- fetch('https://ntfy.sh', {
- method: 'POST',
- body: JSON.stringify({
- "topic": "mytopic",
- "message": "Disk space is low at 5.1 GB",
- "title": "Low disk space alert",
- "tags": ["warning","cd"],
- "priority": 4,
- "attach": "https://filesrv.lan/space.jpg",
- "filename": "diskspace.jpg",
- "click": "https://homecamera.lan/xasds1h2xsSsa/",
- "actions": [{ "action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" }]
- })
- })
- ```
-
-=== "Go"
- ``` go
- // You should probably use json.Marshal() instead and make a proper struct,
- // or even just use req.Header.Set() like in the other examples, but for the
- // sake of the example, this is easier.
-
- body := `{
- "topic": "mytopic",
- "message": "Disk space is low at 5.1 GB",
- "title": "Low disk space alert",
- "tags": ["warning","cd"],
- "priority": 4,
- "attach": "https://filesrv.lan/space.jpg",
- "filename": "diskspace.jpg",
- "click": "https://homecamera.lan/xasds1h2xsSsa/",
- "actions": [{ "action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" }]
- }`
- req, _ := http.NewRequest("POST", "https://ntfy.sh/", strings.NewReader(body))
+ req, _ := http.NewRequest("POST", "https://ntfy.sh/mydownloads", file)
+ req.Header.Set("Attach", "https://f-droid.org/F-Droid.apk")
http.DefaultClient.Do(req)
```
@@ -1353,87 +1090,34 @@ is the only required one:
``` powershell
$Request = @{
Method = "POST"
- URI = "https://ntfy.sh"
- Body = ConvertTo-JSON @{
- Topic = "mytopic"
- Title = "Low disk space alert"
- Message = "Disk space is low at 5.1 GB"
- Priority = 4
- Attach = "https://filesrv.lan/space.jpg"
- FileName = "diskspace.jpg"
- Tags = @("warning", "cd")
- Click = "https://homecamera.lan/xasds1h2xsSsa/"
- Actions = @(
- @{
- Action = "view"
- Label = "Admin panel"
- URL = "https://filesrv.lan/admin"
- }
- )
- }
- ContentType = "application/json"
+ URI = "https://ntfy.sh/mydownloads"
+ Headers = @{ Attach="https://f-droid.org/F-Droid.apk" }
}
Invoke-RestMethod @Request
```
=== "Python"
``` python
- requests.post("https://ntfy.sh/",
- data=json.dumps({
- "topic": "mytopic",
- "message": "Disk space is low at 5.1 GB",
- "title": "Low disk space alert",
- "tags": ["warning","cd"],
- "priority": 4,
- "attach": "https://filesrv.lan/space.jpg",
- "filename": "diskspace.jpg",
- "click": "https://homecamera.lan/xasds1h2xsSsa/",
- "actions": [{ "action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" }]
- })
- )
+ requests.put("https://ntfy.sh/mydownloads",
+ headers={ "Attach": "https://f-droid.org/F-Droid.apk" })
```
=== "PHP"
``` php-inline
- file_get_contents('https://ntfy.sh/', false, stream_context_create([
+ file_get_contents('https://ntfy.sh/mydownloads', false, stream_context_create([
'http' => [
- 'method' => 'POST',
- 'header' => "Content-Type: application/json",
- 'content' => json_encode([
- "topic": "mytopic",
- "message": "Disk space is low at 5.1 GB",
- "title": "Low disk space alert",
- "tags": ["warning","cd"],
- "priority": 4,
- "attach": "https://filesrv.lan/space.jpg",
- "filename": "diskspace.jpg",
- "click": "https://homecamera.lan/xasds1h2xsSsa/",
- "actions": [["action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" ]]
- ])
+ 'method' => 'PUT',
+ 'header' =>
+ "Content-Type: text/plain\r\n" . // Does not matter
+ "Attach: https://f-droid.org/F-Droid.apk",
]
]));
```
-The JSON message format closely mirrors the format of the message you can consume when you [subscribe via the API](subscribe/api.md)
-(see [JSON message format](subscribe/api.md#json-message-format) for details), but is not exactly identical. Here's an overview of
-all the supported fields:
-
-| Field | Required | Type | Example | Description |
-|------------|----------|----------------------------------|-------------------------------------------|-----------------------------------------------------------------------|
-| `topic` | ✔️ | *string* | `topic1` | Target topic name |
-| `message` | - | *string* | `Some message` | Message body; set to `triggered` if empty or not passed |
-| `title` | - | *string* | `Some title` | Message [title](#message-title) |
-| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](#tags-emojis) that may or not map to emojis |
-| `priority` | - | *int (one of: 1, 2, 3, 4, or 5)* | `4` | Message [priority](#message-priority) with 1=min, 3=default and 5=max |
-| `actions` | - | *JSON array* | *(see [action buttons](#action-buttons))* | Custom [user action buttons](#action-buttons) for notifications |
-| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) |
-| `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-a-url) |
-| `markdown` | - | *bool* | `true` | Set to true if the `message` is Markdown-formatted |
-| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) |
-| `filename` | - | *string* | `file.jpg` | File name of the attachment |
-| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery |
-| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications |
-| `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) |
+
+ { width=500 }
+ File attachment sent from an external URL
+
## Action buttons
_Supported on:_ :material-android: :material-apple: :material-firefox:
@@ -2601,65 +2285,61 @@ The `http` action supports the following fields:
| `body` | -️ | *string* | *empty* | `some body, somebody?` | HTTP body |
| `clear` | -️ | *boolean* | `false` | `true` | Clear notification after HTTP request succeeds. If the request fails, the notification is not cleared. |
-## Click action
+## Scheduled delivery
_Supported on:_ :material-android: :material-apple: :material-firefox:
-You can define which URL to open when a notification is clicked. This may be useful if your notification is related
-to a Zabbix alert or a transaction that you'd like to provide the deep-link for. Tapping the notification will open
-the web browser (or the app) and open the website.
+You can delay the delivery of messages and let ntfy send them at a later date. This can be used to send yourself
+reminders or even to execute commands at a later date (if your subscriber acts on messages).
-To define a click action for the notification, pass a URL as the value of the `X-Click` header (or its alias `Click`).
-If you pass a website URL (`http://` or `https://`) the web browser will open. If you pass another URI that can be handled
-by another app, the responsible app may open.
+Usage is pretty straight forward. You can set the delivery time using the `X-Delay` header (or any of its aliases: `Delay`,
+`X-At`, `At`, `X-In` or `In`), either by specifying a Unix timestamp (e.g. `1639194738`), a duration (e.g. `30m`,
+`3h`, `2 days`), or a natural language time string (e.g. `10am`, `8:30pm`, `tomorrow, 3pm`, `Tuesday, 7am`,
+[and more](https://github.com/olebedev/when)).
-Examples:
+As of today, the minimum delay you can set is **10 seconds** and the maximum delay is **3 days**. This can be configured
+with the `message-delay-limit` option.
-* `http://` or `https://` will open your browser (or an app if it registered for a URL)
-* `mailto:` links will open your mail app, e.g. `mailto:phil@example.com`
-* `geo:` links will open Google Maps, e.g. `geo:0,0?q=1600+Amphitheatre+Parkway,+Mountain+View,+CA`
-* `ntfy://` links will open ntfy (see [ntfy:// links](subscribe/phone.md#ntfy-links)), e.g. `ntfy://ntfy.sh/stats`
-* `twitter://` links will open Twitter, e.g. `twitter://user?screen_name=..`
-* ...
-
-Here's an example that will open Reddit when the notification is clicked:
+For the purposes of [message caching](config.md#message-cache), scheduled messages are kept in the cache until 12 hours
+after they were delivered (or whatever the server-side cache duration is set to). For instance, if a message is scheduled
+to be delivered in 3 days, it'll remain in the cache for 3 days and 12 hours. Also note that naturally,
+[turning off server-side caching](#message-caching) is not possible in combination with this feature.
=== "Command line (curl)"
```
- curl \
- -d "New messages on Reddit" \
- -H "Click: https://www.reddit.com/message/messages" \
- ntfy.sh/reddit_alerts
+ curl -H "At: tomorrow, 10am" -d "Good morning" ntfy.sh/hello
+ curl -H "In: 30min" -d "It's 30 minutes later now" ntfy.sh/reminder
+ curl -H "Delay: 1639194738" -d "Unix timestamps are awesome" ntfy.sh/itsaunixsystem
```
=== "ntfy CLI"
```
ntfy publish \
- --click="https://www.reddit.com/message/messages" \
- reddit_alerts "New messages on Reddit"
+ --at="tomorrow, 10am" \
+ hello "Good morning"
```
=== "HTTP"
``` http
- POST /reddit_alerts HTTP/1.1
+ POST /hello HTTP/1.1
Host: ntfy.sh
- Click: https://www.reddit.com/message/messages
+ At: tomorrow, 10am
- New messages on Reddit
+ Good morning
```
=== "JavaScript"
``` javascript
- fetch('https://ntfy.sh/reddit_alerts', {
+ fetch('https://ntfy.sh/hello', {
method: 'POST',
- body: 'New messages on Reddit',
- headers: { 'Click': 'https://www.reddit.com/message/messages' }
+ body: 'Good morning',
+ headers: { 'At': 'tomorrow, 10am' }
})
```
=== "Go"
``` go
- req, _ := http.NewRequest("POST", "https://ntfy.sh/reddit_alerts", strings.NewReader("New messages on Reddit"))
- req.Header.Set("Click", "https://www.reddit.com/message/messages")
+ req, _ := http.NewRequest("POST", "https://ntfy.sh/hello", strings.NewReader("Good morning"))
+ req.Header.Set("At", "tomorrow, 10am")
http.DefaultClient.Do(req)
```
@@ -2667,332 +2347,599 @@ Here's an example that will open Reddit when the notification is clicked:
``` powershell
$Request = @{
Method = "POST"
- URI = "https://ntfy.sh/reddit_alerts"
- Headers = @{ Click="https://www.reddit.com/message/messages" }
- Body = "New messages on Reddit"
+ URI = "https://ntfy.sh/hello"
+ Headers = @{
+ At = "tomorrow, 10am"
+ }
+ Body = "Good morning"
}
Invoke-RestMethod @Request
```
-
+
=== "Python"
``` python
- requests.post("https://ntfy.sh/reddit_alerts",
- data="New messages on Reddit",
- headers={ "Click": "https://www.reddit.com/message/messages" })
+ requests.post("https://ntfy.sh/hello",
+ data="Good morning",
+ headers={ "At": "tomorrow, 10am" })
```
=== "PHP"
``` php-inline
- file_get_contents('https://ntfy.sh/reddit_alerts', false, stream_context_create([
+ file_get_contents('https://ntfy.sh/backups', false, stream_context_create([
'http' => [
'method' => 'POST',
'header' =>
"Content-Type: text/plain\r\n" .
- "Click: https://www.reddit.com/message/messages",
- 'content' => 'New messages on Reddit'
+ "At: tomorrow, 10am",
+ 'content' => 'Good morning'
]
]));
```
-## Attachments
-_Supported on:_ :material-android: :material-firefox:
+Here are a few examples (assuming today's date is **12/10/2021, 9am, Eastern Time Zone**):
-You can **send images and other files to your phone** as attachments to a notification. The attachments are then downloaded
-onto your phone (depending on size and setting automatically), and can be used from the Downloads folder.
+
+
+
Delay/At/In header
Message will be delivered at
Explanation
+
30m
12/10/2021, 9:30am
30 minutes from now
+
2 hours
12/10/2021, 11:30am
2 hours from now
+
1 day
12/11/2021, 9am
24 hours from now
+
10am
12/10/2021, 10am
Today at 10am (same day, because it's only 9am)
+
8am
12/11/2021, 8am
Tomorrow at 8am (because it's 9am already)
+
1639152000
12/10/2021, 11am (EST)
Today at 11am (EST)
+
+
+
-There are two different ways to send attachments:
+### Updating scheduled notifications
-* sending [a local file](#attach-local-file) via PUT, e.g. from `~/Flowers/flower.jpg` or `ringtone.mp3`
-* or by [passing an external URL](#attach-file-from-a-url) as an attachment, e.g. `https://f-droid.org/F-Droid.apk`
+You can update or replace a scheduled message before it is delivered by publishing a new message with the same
+[sequence ID](#updating-deleting-notifications). When you do this, the **original scheduled message is deleted**
+from the server and replaced with the new one. This is different from [updating notifications](#updating-notifications)
+after delivery, where both messages are kept in the cache.
-### Attach local file
-To **send a file from your computer** as an attachment, you can send it as the PUT request body. If a message is greater
-than the maximum message size (4,096 bytes) or consists of non UTF-8 characters, the ntfy server will automatically
-detect the mime type and size, and send the message as an attachment file. To send smaller text-only messages or files
-as attachments, you must pass a filename by passing the `X-Filename` header or query parameter (or any of its aliases
-`Filename`, `File` or `f`).
+This is particularly useful for implementing a **watchdog that triggers when your script stops sending heartbeat messages**.
+This mechanism is also called a [dead man's switch](https://en.wikipedia.org/wiki/Dead_man%27s_switch).
-By default, and how ntfy.sh is configured, the **max attachment size is 15 MB** (with 100 MB total per visitor).
-Attachments **expire after 3 hours**, which typically is plenty of time for the user to download it, or for the Android app
-to auto-download it. Please also check out the [other limits below](#limitations).
+For example, you could schedule a message to be delivered in 5 minutes, but continuously update it every minute to push
+the delivery time further into the future. If your script or system stops running, the message will eventually be delivered as an alert.
-Here's an example showing how to upload an image:
+Here's an example of a dead man's switch that sends an alert if the script stops running for more than 5 minutes:
=== "Command line (curl)"
- ```
- curl \
- -T flower.jpg \
- -H "Filename: flower.jpg" \
- ntfy.sh/flowers
+ ```bash
+ # Dead man's switch: keeps pushing a scheduled message into the future
+ # If this script stops, the alert will be delivered after 5 minutes
+ while true; do
+ curl -H "In: 5m" -d "Warning: Server heartbeat stopped!" \
+ ntfy.sh/mytopic/heartbeat-check
+ sleep 60 # Update every minute
+ done
```
=== "ntfy CLI"
- ```
- ntfy publish \
- --file=flower.jpg \
- flowers
+ ```bash
+ # Dead man's switch: keeps pushing a scheduled message into the future
+ # If this script stops, the alert will be delivered after 5 minutes
+ while true; do
+ ntfy publish \
+ --in="5m" \
+ --sequence-id="heartbeat-check" \
+ mytopic "Warning: Server heartbeat stopped!"
+ sleep 60 # Update every minute
+ done
```
=== "HTTP"
``` http
- PUT /flowers HTTP/1.1
+ POST /mytopic/heartbeat-check HTTP/1.1
Host: ntfy.sh
- Filename: flower.jpg
- Content-Type: 52312
-
- (binary JPEG data)
+ In: 5m
+
+ Warning: Server heartbeat stopped!
```
=== "JavaScript"
``` javascript
- fetch('https://ntfy.sh/flowers', {
- method: 'PUT',
- body: document.getElementById("file").files[0],
- headers: { 'Filename': 'flower.jpg' }
- })
- ```
-
-=== "Go"
- ``` go
- file, _ := os.Open("flower.jpg")
- req, _ := http.NewRequest("PUT", "https://ntfy.sh/flowers", file)
- req.Header.Set("Filename", "flower.jpg")
- http.DefaultClient.Do(req)
- ```
-
-=== "PowerShell"
- ``` powershell
- $Request = @{
- Method = "POST"
- Uri = "ntfy.sh/flowers"
- InFile = "flower.jpg"
- Headers = @{"Filename" = "flower.jpg"}
- }
- Invoke-RestMethod @Request
- ```
-
-=== "Python"
- ``` python
- requests.put("https://ntfy.sh/flowers",
- data=open("flower.jpg", 'rb'),
- headers={ "Filename": "flower.jpg" })
- ```
-
-=== "PHP"
- ``` php-inline
- file_get_contents('https://ntfy.sh/flowers', false, stream_context_create([
- 'http' => [
- 'method' => 'PUT',
- 'header' =>
- "Content-Type: application/octet-stream\r\n" . // Does not matter
- "Filename: flower.jpg",
- 'content' => file_get_contents('flower.jpg') // Dangerous for large files
- ]
- ]));
- ```
-
-Here's what that looks like on Android:
-
-
- { width=500 }
- Image attachment sent from a local file
-
-
-### Attach file from a URL
-Instead of sending a local file to your phone, you can use **an external URL** to specify where the attachment is hosted.
-This could be a Dropbox link, a file from social media, or any other publicly available URL. Since the files are
-externally hosted, the expiration or size limits from above do not apply here.
-
-To attach an external file, simple pass the `X-Attach` header or query parameter (or any of its aliases `Attach` or `a`)
-to specify the attachment URL. It can be any type of file.
-
-ntfy will automatically try to derive the file name from the URL (e.g `https://example.com/flower.jpg` will yield a
-filename `flower.jpg`). To override this filename, you may send the `X-Filename` header or query parameter (or any of its
-aliases `Filename`, `File` or `f`).
-
-Here's an example showing how to attach an APK file:
-
-=== "Command line (curl)"
- ```
- curl \
- -X POST \
- -H "Attach: https://f-droid.org/F-Droid.apk" \
- ntfy.sh/mydownloads
- ```
-
-=== "ntfy CLI"
- ```
- ntfy publish \
- --attach="https://f-droid.org/F-Droid.apk" \
- mydownloads
- ```
-
-=== "HTTP"
- ``` http
- POST /mydownloads HTTP/1.1
- Host: ntfy.sh
- Attach: https://f-droid.org/F-Droid.apk
- ```
-
-=== "JavaScript"
- ``` javascript
- fetch('https://ntfy.sh/mydownloads', {
- method: 'POST',
- headers: { 'Attach': 'https://f-droid.org/F-Droid.apk' }
- })
- ```
-
-=== "Go"
- ``` go
- req, _ := http.NewRequest("POST", "https://ntfy.sh/mydownloads", file)
- req.Header.Set("Attach", "https://f-droid.org/F-Droid.apk")
- http.DefaultClient.Do(req)
- ```
-
-=== "PowerShell"
- ``` powershell
- $Request = @{
- Method = "POST"
- URI = "https://ntfy.sh/mydownloads"
- Headers = @{ Attach="https://f-droid.org/F-Droid.apk" }
- }
- Invoke-RestMethod @Request
- ```
-
-=== "Python"
- ``` python
- requests.put("https://ntfy.sh/mydownloads",
- headers={ "Attach": "https://f-droid.org/F-Droid.apk" })
- ```
-
-=== "PHP"
- ``` php-inline
- file_get_contents('https://ntfy.sh/mydownloads', false, stream_context_create([
- 'http' => [
- 'method' => 'PUT',
- 'header' =>
- "Content-Type: text/plain\r\n" . // Does not matter
- "Attach: https://f-droid.org/F-Droid.apk",
- ]
- ]));
- ```
-
-
- { width=500 }
- File attachment sent from an external URL
-
-
-## Icons
-_Supported on:_ :material-android:
-
-You can include an icon that will appear next to the text of the notification. Simply pass the `X-Icon` header or query
-parameter (or its alias `Icon`) to specify the URL that the icon is located at. The client will automatically download
-the icon (unless it is already cached locally, and less than 24 hours old), and show it in the notification. Icons are
-cached locally in the client until the notification is deleted. **Only JPEG and PNG images are supported at this time**.
-
-Here's an example showing how to include an icon:
-
-=== "Command line (curl)"
- ```
- curl \
- -H "Icon: https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png" \
- -H "Title: Kodi: Resuming Playback" \
- -H "Tags: arrow_forward" \
- -d "The Wire, S01E01" \
- ntfy.sh/tvshows
- ```
-
-=== "ntfy CLI"
- ```
- ntfy publish \
- --icon="https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png" \
- --title="Kodi: Resuming Playback" \
- --tags="arrow_forward" \
- tvshows \
- "The Wire, S01E01"
- ```
-
-=== "HTTP"
- ``` http
- POST /tvshows HTTP/1.1
- Host: ntfy.sh
- Icon: https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png
- Tags: arrow_forward
- Title: Kodi: Resuming Playback
-
- The Wire, S01E01
- ```
-
-=== "JavaScript"
- ``` javascript
- fetch('https://ntfy.sh/tvshows', {
- method: 'POST',
- headers: {
- 'Icon': 'https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png',
- 'Title': 'Kodi: Resuming Playback',
- 'Tags': 'arrow_forward'
- },
- body: "The Wire, S01E01"
- })
- ```
-
-=== "Go"
- ``` go
- req, _ := http.NewRequest("POST", "https://ntfy.sh/tvshows", strings.NewReader("The Wire, S01E01"))
- req.Header.Set("Icon", "https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png")
- req.Header.Set("Tags", "arrow_forward")
- req.Header.Set("Title", "Kodi: Resuming Playback")
- http.DefaultClient.Do(req)
- ```
-
-=== "PowerShell"
- ``` powershell
- $Request = @{
- Method = "POST"
- URI = "https://ntfy.sh/tvshows"
- Headers = @{
- Title = "Kodi: Resuming Playback"
- Tags = "arrow_forward"
- Icon = "https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png"
- }
- Body = "The Wire, S01E01"
- }
- Invoke-RestMethod @Request
- ```
-
-=== "Python"
- ``` python
- requests.post("https://ntfy.sh/tvshows",
- data="The Wire, S01E01",
- headers={
- "Title": "Kodi: Resuming Playback",
- "Tags": "arrow_forward",
- "Icon": "https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png"
+ // Dead man's switch: keeps pushing a scheduled message into the future
+ // If this script stops, the alert will be delivered after 5 minutes
+ setInterval(() => {
+ fetch('https://ntfy.sh/mytopic/heartbeat-check', {
+ method: 'POST',
+ body: 'Warning: Server heartbeat stopped!',
+ headers: { 'In': '5m' }
})
+ }, 60000) // Update every minute
+ ```
+
+=== "Go"
+ ``` go
+ // Dead man's switch: keeps pushing a scheduled message into the future
+ // If this script stops, the alert will be delivered after 5 minutes
+ for {
+ req, _ := http.NewRequest("POST", "https://ntfy.sh/mytopic/heartbeat-check",
+ strings.NewReader("Warning: Server heartbeat stopped!"))
+ req.Header.Set("In", "5m")
+ http.DefaultClient.Do(req)
+ time.Sleep(60 * time.Second) // Update every minute
+ }
+ ```
+
+=== "PowerShell"
+ ``` powershell
+ # Dead man's switch: keeps pushing a scheduled message into the future
+ # If this script stops, the alert will be delivered after 5 minutes
+ while ($true) {
+ $Request = @{
+ Method = "POST"
+ URI = "https://ntfy.sh/mytopic/heartbeat-check"
+ Headers = @{ In = "5m" }
+ Body = "Warning: Server heartbeat stopped!"
+ }
+ Invoke-RestMethod @Request
+ Start-Sleep -Seconds 60 # Update every minute
+ }
+ ```
+
+=== "Python"
+ ``` python
+ import requests
+ import time
+
+ # Dead man's switch: keeps pushing a scheduled message into the future
+ # If this script stops, the alert will be delivered after 5 minutes
+ while True:
+ requests.post(
+ "https://ntfy.sh/mytopic/heartbeat-check",
+ data="Warning: Server heartbeat stopped!",
+ headers={"In": "5m"}
+ )
+ time.sleep(60) # Update every minute
```
=== "PHP"
``` php-inline
- file_get_contents('https://ntfy.sh/tvshows', false, stream_context_create([
+ // Dead man's switch: keeps pushing a scheduled message into the future
+ // If this script stops, the alert will be delivered after 5 minutes
+ while (true) {
+ file_get_contents('https://ntfy.sh/mytopic/heartbeat-check', false, stream_context_create([
+ 'http' => [
+ 'method' => 'POST',
+ 'header' => "Content-Type: text/plain\r\nIn: 5m",
+ 'content' => 'Warning: Server heartbeat stopped!'
+ ]
+ ]));
+ sleep(60); // Update every minute
+ }
+ ```
+
+### Canceling scheduled notifications
+
+You can cancel a scheduled message before it is delivered by sending a DELETE request to the
+`//` endpoint, just like [deleting notifications](#deleting-notifications). This will remove the
+scheduled message from the server so it will never be delivered, and emit a `message_delete` event to any subscribers.
+
+=== "Command line (curl)"
+ ```bash
+ # Schedule a reminder for 2 hours from now
+ curl -H "In: 2h" -d "Take a break!" ntfy.sh/mytopic/break-reminder
+
+ # Changed your mind? Cancel the scheduled message
+ curl -X DELETE ntfy.sh/mytopic/break-reminder
+ ```
+
+=== "ntfy CLI"
+ ```bash
+ # Schedule a reminder for 2 hours from now
+ ntfy publish --in="2h" mytopic/break-reminder "Take a break!"
+
+ # Changed your mind? Cancel the scheduled message
+ # (ntfy CLI does not support DELETE, use curl instead)
+ curl -X DELETE ntfy.sh/mytopic/break-reminder
+ ```
+
+=== "HTTP"
+ ``` http
+ DELETE /mytopic/break-reminder HTTP/1.1
+ Host: ntfy.sh
+ ```
+
+=== "JavaScript"
+ ``` javascript
+ // Schedule a reminder for 2 hours from now
+ await fetch('https://ntfy.sh/mytopic/break-reminder', {
+ method: 'POST',
+ body: 'Take a break!',
+ headers: { 'In': '2h' }
+ });
+
+ // Changed your mind? Cancel the scheduled message
+ await fetch('https://ntfy.sh/mytopic/break-reminder', {
+ method: 'DELETE'
+ });
+ ```
+
+=== "Go"
+ ``` go
+ // Schedule a reminder for 2 hours from now
+ req, _ := http.NewRequest("POST", "https://ntfy.sh/mytopic/break-reminder",
+ strings.NewReader("Take a break!"))
+ req.Header.Set("In", "2h")
+ http.DefaultClient.Do(req)
+
+ // Changed your mind? Cancel the scheduled message
+ req, _ = http.NewRequest("DELETE", "https://ntfy.sh/mytopic/break-reminder", nil)
+ http.DefaultClient.Do(req)
+ ```
+
+=== "PowerShell"
+ ``` powershell
+ # Schedule a reminder for 2 hours from now
+ $Request = @{
+ Method = "POST"
+ URI = "https://ntfy.sh/mytopic/break-reminder"
+ Headers = @{ In = "2h" }
+ Body = "Take a break!"
+ }
+ Invoke-RestMethod @Request
+
+ # Changed your mind? Cancel the scheduled message
+ Invoke-RestMethod -Method DELETE -Uri "https://ntfy.sh/mytopic/break-reminder"
+ ```
+
+=== "Python"
+ ``` python
+ import requests
+
+ # Schedule a reminder for 2 hours from now
+ requests.post(
+ "https://ntfy.sh/mytopic/break-reminder",
+ data="Take a break!",
+ headers={"In": "2h"}
+ )
+
+ # Changed your mind? Cancel the scheduled message
+ requests.delete("https://ntfy.sh/mytopic/break-reminder")
+ ```
+
+=== "PHP"
+ ``` php-inline
+ // Schedule a reminder for 2 hours from now
+ file_get_contents('https://ntfy.sh/mytopic/break-reminder', false, stream_context_create([
'http' => [
- 'method' => 'PUT',
- 'header' =>
- "Content-Type: text/plain\r\n" . // Does not matter
- "Title: Kodi: Resuming Playback\r\n" .
- "Tags: arrow_forward\r\n" .
- "Icon: https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png",
- ],
- 'content' => "The Wire, S01E01"
+ 'method' => 'POST',
+ 'header' => "Content-Type: text/plain\r\nIn: 2h",
+ 'content' => 'Take a break!'
+ ]
+ ]));
+
+ // Changed your mind? Cancel the scheduled message
+ file_get_contents('https://ntfy.sh/mytopic/break-reminder', false, stream_context_create([
+ 'http' => ['method' => 'DELETE']
]));
```
-Here's an example of how it will look on Android:
+## Message templating
+_Supported on:_ :material-android: :material-apple: :material-firefox:
+
+Templating lets you **format a JSON message body into human-friendly message and title text** using
+[Go templates](https://pkg.go.dev/text/template) (see tutorials [here](https://blog.gopheracademy.com/advent-2017/using-go-templates/),
+[here](https://www.digitalocean.com/community/tutorials/how-to-use-templates-in-go), and
+[here](https://developer.hashicorp.com/nomad/tutorials/templates/go-template-syntax)). This is specifically useful when
+**combined with webhooks** from services such as [GitHub](https://docs.github.com/en/webhooks/about-webhooks),
+[Grafana](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier/),
+[Alertmanager](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config), or other services that emit JSON webhooks.
+
+Instead of using a separate bridge program to parse the webhook body into the format ntfy expects, you can include a templated
+message and/or a templated title which will be populated based on the fields of the webhook body (so long as the webhook body
+is valid JSON).
+
+You can enable templating by setting the `X-Template` header (or its aliases `Template` or `tpl`, or the query parameter `?template=...`):
+
+* **Pre-defined template files**: Setting the `X-Template` header or query parameter to a pre-defined template name (one of `github`,
+ `grafana`, or `alertmanager`, such as `?template=github`) will use the built-in template with that name.
+ See [pre-defined templates](#pre-defined-templates) for more details.
+* **Custom template files**: Setting the `X-Template` header or query parameter to a custom template name (e.g. `?template=myapp`)
+ will use a custom template file from the template directory (defaults to `/etc/ntfy/templates`, can be overridden with `template-dir`).
+ See [custom templates](#custom-templates) for more details.
+* **Inline templating**: Setting the `X-Template` header or query parameter to `yes` or `1` (e.g. `?template=yes`)
+ will enable inline templating, which means that the `message` and/or `title` will be parsed as a Go template.
+ See [inline templating](#inline-templating) for more details.
+
+To learn the basics of Go's templating language, please see [template syntax](#template-syntax).
+
+### Pre-defined templates
+
+When `X-Template: ` (aliases: `Template: `, `Tpl: `) or `?template=` is set, ntfy will transform the
+message and/or title based on one of the built-in pre-defined templates.
+
+The following **pre-defined templates** are available:
+
+* `github`: Formats a subset of [GitHub webhook](https://docs.github.com/en/webhooks/about-webhooks) payloads (PRs, issues, new star, new watcher, new comment). See [github.yml](https://github.com/binwiederhier/ntfy/blob/main/server/templates/github.yml).
+* `grafana`: Formats [Grafana webhook](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier/) payloads (firing/resolved alerts). See [grafana.yml](https://github.com/binwiederhier/ntfy/blob/main/server/templates/grafana.yml).
+* `alertmanager`: Formats [Alertmanager webhook](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config) payloads (firing/resolved alerts). See [alertmanager.yml](https://github.com/binwiederhier/ntfy/blob/main/server/templates/alertmanager.yml).
+
+To override the pre-defined templates, you can place a file with the same name in the template directory (defaults to `/etc/ntfy/templates`,
+can be overridden with `template-dir`). See [custom templates](#custom-templates) for more details.
+
+Here's an example of how to use the **pre-defined `github` template**:
+
+First, configure the webhook in GitHub to send a webhook to your ntfy topic, e.g. `https://ntfy.sh/mytopic?template=github`.
+
+ { width=600 }
+ GitHub webhook configuration
+
+
+After that, when GitHub publishes a JSON webhook to the topic, ntfy will transform it according to the template rules
+and you'll receive notifications in the ntfy app. Here's an example for when somebody stars your repository:
- { width=500 }
- Custom icon from an external URL
+ { width=500 }
+ Receiving a webhook, formatted using the pre-defined "github" template
+### Custom templates
+
+To define **your own custom templates**, place a template file in the template directory (defaults to `/etc/ntfy/templates`, can be overridden with `template-dir`)
+and set the `X-Template` header or query parameter to the name of the template file (without the `.yml` extension).
+
+For example, if you have a template file `/etc/ntfy/templates/myapp.yml`, you can set the header `X-Template: myapp` or
+the query parameter `?template=myapp` to use it.
+
+Template files must have the `.yml` (not: `.yaml`!) extension and must be formatted as YAML. They may contain `title` and `message` keys,
+which are interpreted as Go templates.
+
+Here's an **example custom template**:
+
+=== "Custom template (/etc/ntfy/templates/myapp.yml)"
+ ```yaml
+ title: |
+ {{- if eq .status "firing" }}
+ {{- if gt .percent 90.0 }}🚨 Critical alert
+ {{- else }}⚠️ Alert{{- end }}
+ {{- else if eq .status "resolved" }}
+ ✅ Alert resolved
+ {{- end }}
+ message: |
+ Status: {{ .status }}
+ Type: {{ .type | upper }} ({{ .percent }}%)
+ Server: {{ .server }}
+ ```
+
+Once you have the template file in place, you can send the payload to your topic using the `X-Template`
+header or query parameter:
+
+=== "Command line (curl)"
+ ```
+ echo '{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}' | \
+ curl -sT- "https://ntfy.example.com/mytopic?template=myapp"
+ ```
+
+=== "ntfy CLI"
+ ```
+ echo '{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}' | \
+ ntfy publish --template=myapp https://ntfy.example.com/mytopic
+ ```
+
+=== "HTTP"
+ ``` http
+ POST /mytopic?template=myapp HTTP/1.1
+ Host: ntfy.example.com
+
+ {
+ "status": "firing",
+ "type": "cpu",
+ "server": "ntfy.sh",
+ "percent": 99
+ }
+ ```
+
+=== "JavaScript"
+ ``` javascript
+ fetch('https://ntfy.example.com/mytopic?template=myapp', {
+ method: 'POST',
+ body: '{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}'
+ })
+ ```
+
+=== "Go"
+ ``` go
+ payload := `{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}`
+ req, _ := http.NewRequest("POST", "https://ntfy.example.com/mytopic?template=myapp", strings.NewReader(payload))
+ http.DefaultClient.Do(req)
+ ```
+
+=== "PowerShell"
+ ``` powershell
+ $Request = @{
+ Method = "POST"
+ Uri = "https://ntfy.example.com/mytopic?template=myapp"
+ Body = '{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}'
+ }
+ Invoke-RestMethod @Request
+ ```
+
+=== "Python"
+ ``` python
+ requests.post("https://ntfy.example.com/mytopic?template=myapp",
+ json={"status":"firing","type":"cpu","server":"ntfy.sh","percent":99})
+ ```
+
+=== "PHP"
+ ``` php-inline
+ file_get_contents('https://ntfy.example.com/mytopic?template=myapp', false, stream_context_create([
+ 'http' => [
+ 'method' => 'POST',
+ 'header' => "Content-Type: application/json",
+ 'content' => '{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}'
+ ]
+ ]));
+ ```
+
+Which will result in a notification that looks like this:
+
+
+ { width=500 }
+ JSON webhook, transformed using a custom template
+
+
+### Inline templating
+
+When `X-Template: yes` (aliases: `Template: yes`, `Tpl: yes`) or `?template=yes` is set, you can use Go templates in the `message` and `title` fields of your
+webhook payload.
+
+Inline templates are most useful for templated one-off messages, or if you do not control the ntfy server (e.g., if you're using ntfy.sh).
+Consider using [pre-defined templates](#pre-defined-templates) or [custom templates](#custom-templates) instead,
+if you control the ntfy server, as templates are much easier to maintain.
+
+Here's an **example for a Grafana alert**:
+
+
+ { width=500 }
+ Grafana webhook, formatted using templates
+
+
+This was sent using the following templates and payloads
+
+=== "Message template"
+ ```
+ {{range .alerts}}
+ {{.annotations.summary}}
+
+ Values:
+ {{range $k,$v := .values}}
+ - {{$k}}={{$v}}
+ {{end}}
+ {{end}}
+ ```
+
+=== "Title template"
+ ```
+ {{.title}}
+ ```
+
+=== "Encoded webhook URL"
+ ```
+ # Additional URL encoding (see https://www.urlencoder.org/) is necessary for Grafana,
+ # and may be required for other tools too
+
+ https://ntfy.sh/mytopic?tpl=1&t=%7B%7B.title%7D%7D&m=%7B%7Brange%20.alerts%7D%7D%7B%7B.annotations.summary%7D%7D%5Cn%5CnValues%3A%5Cn%7B%7Brange%20%24k%2C%24v%20%3A%3D%20.values%7D%7D-%20%7B%7B%24k%7D%7D%3D%7B%7B%24v%7D%7D%5Cn%7B%7Bend%7D%7D%7B%7Bend%7D%7D
+ ```
+
+=== "Grafana-sent payload"
+ ```
+ {"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\nAnnotations:\n - summary = 15m load average too high\nSource: localhost:3000/alerting/grafana/NW9oDw-4z/view\nSilence: 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\n"}
+ ```
+
+Here's an **easier example with a shorter JSON payload**:
+
+=== "Command line (curl)"
+ ```
+ # To use { and } in the URL without encoding, we need to turn off
+ # curl's globbing using --globoff
+
+ curl \
+ --globoff \
+ -d '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}' \
+ 'ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}'
+ ```
+
+=== "HTTP"
+ ``` http
+ POST /mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}} HTTP/1.1
+ Host: ntfy.sh
+
+ {"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}
+ ```
+
+=== "JavaScript"
+ ``` javascript
+ fetch('https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}', {
+ method: 'POST',
+ body: '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}'
+ })
+ ```
+
+=== "Go"
+ ``` go
+ body := `{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}`
+ uri := "https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}"
+ req, _ := http.NewRequest("POST", uri, strings.NewReader(body))
+ http.DefaultClient.Do(req)
+ ```
+
+
+=== "PowerShell"
+ ``` powershell
+ $Request = @{
+ Method = "POST"
+ URI = "https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}"
+ Body = '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}'
+ ContentType = "application/json"
+ }
+ Invoke-RestMethod @Request
+ ```
+
+=== "Python"
+ ``` python
+ requests.post(
+ "https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}",
+ data='{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}'
+ )
+ ```
+
+=== "PHP"
+ ``` php-inline
+ file_get_contents("https://ntfy.sh/mytopic?tpl=yes&t={{.hostname}}:+A+{{.error.level}}+error+has+occurred&m=Error+message:+{{.error.desc}}", false, stream_context_create([
+ 'http' => [
+ 'method' => 'POST',
+ 'header' => "Content-Type: application/json",
+ 'content' => '{"hostname": "phil-pc", "error": {"level": "severe", "desc": "Disk has run out of space"}}'
+ ]
+ ]));
+ ```
+
+This example uses the `message`/`m` and `title`/`t` query parameters, but obviously this also works with the corresponding
+`Message`/`Title` headers. It will send a notification with a title `phil-pc: A severe error has occurred` and a message
+`Error message: Disk has run out of space`.
+
+### Template syntax
+ntfy uses [Go templates](https://pkg.go.dev/text/template) for its templates, which is arguably one of the most powerful,
+yet also one of the worst templating languages out there.
+
+You can use the following features in your templates:
+
+* Variables, e.g. `{{.alert.title}}` or `An error occurred: {{.error.desc}}`
+* Conditionals (if/else, e.g. `{{if eq .action "opened"}}..{{else}}..{{end}}`, see [example](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6Ilt7ey5wdWxsX3JlcXVlc3QuaGVhZC5yZXBvLmZ1bGxfbmFtZX19XSBQdWxsIHJlcXVlc3Qge3tpZiBlcSAuYWN0aW9uIFwib3BlbmVkXCJ9fU9QRU5FRHt7ZWxzZX19Q0xPU0VEe3tlbmR9fToge3sucHVsbF9yZXF1ZXN0LnRpdGxlfX0iLCJpbnB1dCI6IntcbiAgXCJhY3Rpb25cIjogXCJvcGVuZWRcIixcbiAgXCJudW1iZXJcIjogMSxcbiAgXCJwdWxsX3JlcXVlc3RcIjoge1xuICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxscy8xXCIsXG4gICAgXCJpZFwiOiAxNzgzNDIwOTcyLFxuICAgIFwibm9kZV9pZFwiOiBcIlBSX2t3RE9IQWJkbzg1cVROZ3NcIixcbiAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGwvMVwiLFxuICAgIFwiZGlmZl91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllci9kYWJibGUvcHVsbC8xLmRpZmZcIixcbiAgICBcInBhdGNoX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxsLzEucGF0Y2hcIixcbiAgICBcImlzc3VlX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzLzFcIixcbiAgICBcIm51bWJlclwiOiAxLFxuICAgIFwic3RhdGVcIjogXCJvcGVuXCIsXG4gICAgXCJsb2NrZWRcIjogZmFsc2UsXG4gICAgXCJ0aXRsZVwiOiBcIkEgc2FtcGxlIFBSIGZyb20gUGhpbFwiLFxuICAgIFwidXNlclwiOiB7XG4gICAgICBcImxvZ2luXCI6IFwiYmlud2llZGVyaGllclwiLFxuICAgICAgXCJpZFwiOiA2NjQ1OTcsXG4gICAgICBcIm5vZGVfaWRcIjogXCJNRFE2VlhObGNqWTJORFU1Tnc9PVwiLFxuICAgICAgXCJhdmF0YXJfdXJsXCI6IFwiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzY2NDU5Nz92PTRcIixcbiAgICAgIFwiZ3JhdmF0YXJfaWRcIjogXCJcIixcbiAgICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyXCIsXG4gICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXJcIixcbiAgICAgIFwiZm9sbG93ZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dlcnNcIixcbiAgICAgIFwiZm9sbG93aW5nX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dpbmd7L290aGVyX3VzZXJ9XCIsXG4gICAgICBcImdpc3RzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9naXN0c3svZ2lzdF9pZH1cIixcbiAgICAgIFwic3RhcnJlZF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfVwiLFxuICAgICAgXCJzdWJzY3JpcHRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdWJzY3JpcHRpb25zXCIsXG4gICAgICBcIm9yZ2FuaXphdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL29yZ3NcIixcbiAgICAgIFwicmVwb3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlcG9zXCIsXG4gICAgICBcImV2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZXZlbnRzey9wcml2YWN5fVwiLFxuICAgICAgXCJyZWNlaXZlZF9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlY2VpdmVkX2V2ZW50c1wiLFxuICAgICAgXCJ0eXBlXCI6IFwiVXNlclwiLFxuICAgICAgXCJzaXRlX2FkbWluXCI6IGZhbHNlXG4gICAgfSxcbiAgICBcImJvZHlcIjogbnVsbCxcbiAgICBcImNyZWF0ZWRfYXRcIjogXCIyMDI0LTAzLTIxVDAyOjUyOjA5WlwiLFxuICAgIFwidXBkYXRlZF9hdFwiOiBcIjIwMjQtMDMtMjFUMDI6NTI6MDlaXCIsXG4gICAgXCJjbG9zZWRfYXRcIjogbnVsbCxcbiAgICBcIm1lcmdlZF9hdFwiOiBudWxsLFxuICAgIFwibWVyZ2VfY29tbWl0X3NoYVwiOiBudWxsLFxuICAgIFwiYXNzaWduZWVcIjogbnVsbCxcbiAgICBcImFzc2lnbmVlc1wiOiBbXSxcbiAgICBcInJlcXVlc3RlZF9yZXZpZXdlcnNcIjogW10sXG4gICAgXCJyZXF1ZXN0ZWRfdGVhbXNcIjogW10sXG4gICAgXCJsYWJlbHNcIjogW10sXG4gICAgXCJtaWxlc3RvbmVcIjogbnVsbCxcbiAgICBcImRyYWZ0XCI6IGZhbHNlLFxuICAgIFwiY29tbWl0c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGxzLzEvY29tbWl0c1wiLFxuICAgIFwicmV2aWV3X2NvbW1lbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMS9jb21tZW50c1wiLFxuICAgIFwicmV2aWV3X2NvbW1lbnRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxscy9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgIFwiY29tbWVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXMvMS9jb21tZW50c1wiLFxuICAgIFwic3RhdHVzZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGF0dXNlcy81NzAzODQyY2M1NzE1ZWQxZTM1OGQyM2ViYjY5M2RiMDk3NDdhZTliXCIsXG4gICAgXCJoZWFkXCI6IHtcbiAgICAgIFwibGFiZWxcIjogXCJiaW53aWVkZXJoaWVyOmFhXCIsXG4gICAgICBcInJlZlwiOiBcImFhXCIsXG4gICAgICBcInNoYVwiOiBcIjU3MDM4NDJjYzU3MTVlZDFlMzU4ZDIzZWJiNjkzZGIwOTc0N2FlOWJcIixcbiAgICAgIFwidXNlclwiOiB7XG4gICAgICAgIFwibG9naW5cIjogXCJiaW53aWVkZXJoaWVyXCIsXG4gICAgICAgIFwiaWRcIjogNjY0NTk3LFxuICAgICAgICBcIm5vZGVfaWRcIjogXCJNRFE2VlhObGNqWTJORFU1Tnc9PVwiLFxuICAgICAgICBcImF2YXRhcl91cmxcIjogXCJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvNjY0NTk3P3Y9NFwiLFxuICAgICAgICBcImdyYXZhdGFyX2lkXCI6IFwiXCIsXG4gICAgICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyXCIsXG4gICAgICAgIFwiaHRtbF91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllclwiLFxuICAgICAgICBcImZvbGxvd2Vyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93ZXJzXCIsXG4gICAgICAgIFwiZm9sbG93aW5nX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dpbmd7L290aGVyX3VzZXJ9XCIsXG4gICAgICAgIFwiZ2lzdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2dpc3Rzey9naXN0X2lkfVwiLFxuICAgICAgICBcInN0YXJyZWRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N0YXJyZWR7L293bmVyfXsvcmVwb31cIixcbiAgICAgICAgXCJzdWJzY3JpcHRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdWJzY3JpcHRpb25zXCIsXG4gICAgICAgIFwib3JnYW5pemF0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvb3Jnc1wiLFxuICAgICAgICBcInJlcG9zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZXBvc1wiLFxuICAgICAgICBcImV2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZXZlbnRzey9wcml2YWN5fVwiLFxuICAgICAgICBcInJlY2VpdmVkX2V2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVjZWl2ZWRfZXZlbnRzXCIsXG4gICAgICAgIFwidHlwZVwiOiBcIlVzZXJcIixcbiAgICAgICAgXCJzaXRlX2FkbWluXCI6IGZhbHNlXG4gICAgICB9LFxuICAgICAgXCJyZXBvXCI6IHtcbiAgICAgICAgXCJpZFwiOiA0NzAyMTIwMDMsXG4gICAgICAgIFwibm9kZV9pZFwiOiBcIlJfa2dET0hBYmRvd1wiLFxuICAgICAgICBcIm5hbWVcIjogXCJkYWJibGVcIixcbiAgICAgICAgXCJmdWxsX25hbWVcIjogXCJiaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcInByaXZhdGVcIjogZmFsc2UsXG4gICAgICAgIFwib3duZXJcIjoge1xuICAgICAgICAgIFwibG9naW5cIjogXCJiaW53aWVkZXJoaWVyXCIsXG4gICAgICAgICAgXCJpZFwiOiA2NjQ1OTcsXG4gICAgICAgICAgXCJub2RlX2lkXCI6IFwiTURRNlZYTmxjalkyTkRVNU53PT1cIixcbiAgICAgICAgICBcImF2YXRhcl91cmxcIjogXCJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvNjY0NTk3P3Y9NFwiLFxuICAgICAgICAgIFwiZ3JhdmF0YXJfaWRcIjogXCJcIixcbiAgICAgICAgICBcInVybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllclwiLFxuICAgICAgICAgIFwiaHRtbF91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllclwiLFxuICAgICAgICAgIFwiZm9sbG93ZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dlcnNcIixcbiAgICAgICAgICBcImZvbGxvd2luZ191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93aW5ney9vdGhlcl91c2VyfVwiLFxuICAgICAgICAgIFwiZ2lzdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2dpc3Rzey9naXN0X2lkfVwiLFxuICAgICAgICAgIFwic3RhcnJlZF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfVwiLFxuICAgICAgICAgIFwic3Vic2NyaXB0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3Vic2NyaXB0aW9uc1wiLFxuICAgICAgICAgIFwib3JnYW5pemF0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvb3Jnc1wiLFxuICAgICAgICAgIFwicmVwb3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlcG9zXCIsXG4gICAgICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2V2ZW50c3svcHJpdmFjeX1cIixcbiAgICAgICAgICBcInJlY2VpdmVkX2V2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVjZWl2ZWRfZXZlbnRzXCIsXG4gICAgICAgICAgXCJ0eXBlXCI6IFwiVXNlclwiLFxuICAgICAgICAgIFwic2l0ZV9hZG1pblwiOiBmYWxzZVxuICAgICAgICB9LFxuICAgICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlXCIsXG4gICAgICAgIFwiZGVzY3JpcHRpb25cIjogXCJBIHJlcG8gZm9yIGRhYmJsaW5nXCIsXG4gICAgICAgIFwiZm9ya1wiOiBmYWxzZSxcbiAgICAgICAgXCJ1cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlXCIsXG4gICAgICAgIFwiZm9ya3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9mb3Jrc1wiLFxuICAgICAgICBcImtleXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9rZXlzey9rZXlfaWR9XCIsXG4gICAgICAgIFwiY29sbGFib3JhdG9yc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbGxhYm9yYXRvcnN7L2NvbGxhYm9yYXRvcn1cIixcbiAgICAgICAgXCJ0ZWFtc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3RlYW1zXCIsXG4gICAgICAgIFwiaG9va3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9ob29rc1wiLFxuICAgICAgICBcImlzc3VlX2V2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy9ldmVudHN7L251bWJlcn1cIixcbiAgICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9ldmVudHNcIixcbiAgICAgICAgXCJhc3NpZ25lZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9hc3NpZ25lZXN7L3VzZXJ9XCIsXG4gICAgICAgIFwiYnJhbmNoZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9icmFuY2hlc3svYnJhbmNofVwiLFxuICAgICAgICBcInRhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS90YWdzXCIsXG4gICAgICAgIFwiYmxvYnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvYmxvYnN7L3NoYX1cIixcbiAgICAgICAgXCJnaXRfdGFnc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2dpdC90YWdzey9zaGF9XCIsXG4gICAgICAgIFwiZ2l0X3JlZnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvcmVmc3svc2hhfVwiLFxuICAgICAgICBcInRyZWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L3RyZWVzey9zaGF9XCIsXG4gICAgICAgIFwic3RhdHVzZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGF0dXNlcy97c2hhfVwiLFxuICAgICAgICBcImxhbmd1YWdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2xhbmd1YWdlc1wiLFxuICAgICAgICBcInN0YXJnYXplcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGFyZ2F6ZXJzXCIsXG4gICAgICAgIFwiY29udHJpYnV0b3JzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29udHJpYnV0b3JzXCIsXG4gICAgICAgIFwic3Vic2NyaWJlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdWJzY3JpYmVyc1wiLFxuICAgICAgICBcInN1YnNjcmlwdGlvbl91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3N1YnNjcmlwdGlvblwiLFxuICAgICAgICBcImNvbW1pdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb21taXRzey9zaGF9XCIsXG4gICAgICAgIFwiZ2l0X2NvbW1pdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvY29tbWl0c3svc2hhfVwiLFxuICAgICAgICBcImNvbW1lbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tbWVudHN7L251bWJlcn1cIixcbiAgICAgICAgXCJpc3N1ZV9jb21tZW50X3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzL2NvbW1lbnRzey9udW1iZXJ9XCIsXG4gICAgICAgIFwiY29udGVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb250ZW50cy97K3BhdGh9XCIsXG4gICAgICAgIFwiY29tcGFyZV91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbXBhcmUve2Jhc2V9Li4ue2hlYWR9XCIsXG4gICAgICAgIFwibWVyZ2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbWVyZ2VzXCIsXG4gICAgICAgIFwiYXJjaGl2ZV91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3thcmNoaXZlX2Zvcm1hdH17L3JlZn1cIixcbiAgICAgICAgXCJkb3dubG9hZHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9kb3dubG9hZHNcIixcbiAgICAgICAgXCJpc3N1ZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXN7L251bWJlcn1cIixcbiAgICAgICAgXCJwdWxsc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGxzey9udW1iZXJ9XCIsXG4gICAgICAgIFwibWlsZXN0b25lc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL21pbGVzdG9uZXN7L251bWJlcn1cIixcbiAgICAgICAgXCJub3RpZmljYXRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbm90aWZpY2F0aW9uc3s/c2luY2UsYWxsLHBhcnRpY2lwYXRpbmd9XCIsXG4gICAgICAgIFwibGFiZWxzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbGFiZWxzey9uYW1lfVwiLFxuICAgICAgICBcInJlbGVhc2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcmVsZWFzZXN7L2lkfVwiLFxuICAgICAgICBcImRlcGxveW1lbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZGVwbG95bWVudHNcIixcbiAgICAgICAgXCJjcmVhdGVkX2F0XCI6IFwiMjAyMi0wMy0xNVQxNTowNjoxN1pcIixcbiAgICAgICAgXCJ1cGRhdGVkX2F0XCI6IFwiMjAyMi0wMy0xNVQxNTowNjoxN1pcIixcbiAgICAgICAgXCJwdXNoZWRfYXRcIjogXCIyMDI0LTAzLTIxVDAyOjUyOjEwWlwiLFxuICAgICAgICBcImdpdF91cmxcIjogXCJnaXQ6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgICAgICBcInNzaF91cmxcIjogXCJnaXRAZ2l0aHViLmNvbTpiaW53aWVkZXJoaWVyL2RhYmJsZS5naXRcIixcbiAgICAgICAgXCJjbG9uZV91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllci9kYWJibGUuZ2l0XCIsXG4gICAgICAgIFwic3ZuX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcImhvbWVwYWdlXCI6IG51bGwsXG4gICAgICAgIFwic2l6ZVwiOiAxLFxuICAgICAgICBcInN0YXJnYXplcnNfY291bnRcIjogMCxcbiAgICAgICAgXCJ3YXRjaGVyc19jb3VudFwiOiAwLFxuICAgICAgICBcImxhbmd1YWdlXCI6IG51bGwsXG4gICAgICAgIFwiaGFzX2lzc3Vlc1wiOiB0cnVlLFxuICAgICAgICBcImhhc19wcm9qZWN0c1wiOiB0cnVlLFxuICAgICAgICBcImhhc19kb3dubG9hZHNcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfd2lraVwiOiB0cnVlLFxuICAgICAgICBcImhhc19wYWdlc1wiOiBmYWxzZSxcbiAgICAgICAgXCJoYXNfZGlzY3Vzc2lvbnNcIjogZmFsc2UsXG4gICAgICAgIFwiZm9ya3NfY291bnRcIjogMCxcbiAgICAgICAgXCJtaXJyb3JfdXJsXCI6IG51bGwsXG4gICAgICAgIFwiYXJjaGl2ZWRcIjogZmFsc2UsXG4gICAgICAgIFwiZGlzYWJsZWRcIjogZmFsc2UsXG4gICAgICAgIFwib3Blbl9pc3N1ZXNfY291bnRcIjogMSxcbiAgICAgICAgXCJsaWNlbnNlXCI6IG51bGwsXG4gICAgICAgIFwiYWxsb3dfZm9ya2luZ1wiOiB0cnVlLFxuICAgICAgICBcImlzX3RlbXBsYXRlXCI6IGZhbHNlLFxuICAgICAgICBcIndlYl9jb21taXRfc2lnbm9mZl9yZXF1aXJlZFwiOiBmYWxzZSxcbiAgICAgICAgXCJ0b3BpY3NcIjogW10sXG4gICAgICAgIFwidmlzaWJpbGl0eVwiOiBcInB1YmxpY1wiLFxuICAgICAgICBcImZvcmtzXCI6IDAsXG4gICAgICAgIFwib3Blbl9pc3N1ZXNcIjogMSxcbiAgICAgICAgXCJ3YXRjaGVyc1wiOiAwLFxuICAgICAgICBcImRlZmF1bHRfYnJhbmNoXCI6IFwibWFpblwiLFxuICAgICAgICBcImFsbG93X3NxdWFzaF9tZXJnZVwiOiB0cnVlLFxuICAgICAgICBcImFsbG93X21lcmdlX2NvbW1pdFwiOiB0cnVlLFxuICAgICAgICBcImFsbG93X3JlYmFzZV9tZXJnZVwiOiB0cnVlLFxuICAgICAgICBcImFsbG93X2F1dG9fbWVyZ2VcIjogZmFsc2UsXG4gICAgICAgIFwiZGVsZXRlX2JyYW5jaF9vbl9tZXJnZVwiOiBmYWxzZSxcbiAgICAgICAgXCJhbGxvd191cGRhdGVfYnJhbmNoXCI6IGZhbHNlLFxuICAgICAgICBcInVzZV9zcXVhc2hfcHJfdGl0bGVfYXNfZGVmYXVsdFwiOiBmYWxzZSxcbiAgICAgICAgXCJzcXVhc2hfbWVyZ2VfY29tbWl0X21lc3NhZ2VcIjogXCJDT01NSVRfTUVTU0FHRVNcIixcbiAgICAgICAgXCJzcXVhc2hfbWVyZ2VfY29tbWl0X3RpdGxlXCI6IFwiQ09NTUlUX09SX1BSX1RJVExFXCIsXG4gICAgICAgIFwibWVyZ2VfY29tbWl0X21lc3NhZ2VcIjogXCJQUl9USVRMRVwiLFxuICAgICAgICBcIm1lcmdlX2NvbW1pdF90aXRsZVwiOiBcIk1FUkdFX01FU1NBR0VcIlxuICAgICAgfVxuICAgIH0sXG4gICAgXCJiYXNlXCI6IHtcbiAgICAgIFwibGFiZWxcIjogXCJiaW53aWVkZXJoaWVyOm1haW5cIixcbiAgICAgIFwicmVmXCI6IFwibWFpblwiLFxuICAgICAgXCJzaGFcIjogXCI3MmQ5MzFhMjBiYjgzZDEyM2FiNDVhY2NhZjc2MTE1MGM4YjAxMjExXCIsXG4gICAgICBcInVzZXJcIjoge1xuICAgICAgICBcImxvZ2luXCI6IFwiYmlud2llZGVyaGllclwiLFxuICAgICAgICBcImlkXCI6IDY2NDU5NyxcbiAgICAgICAgXCJub2RlX2lkXCI6IFwiTURRNlZYTmxjalkyTkRVNU53PT1cIixcbiAgICAgICAgXCJhdmF0YXJfdXJsXCI6IFwiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzY2NDU5Nz92PTRcIixcbiAgICAgICAgXCJncmF2YXRhcl9pZFwiOiBcIlwiLFxuICAgICAgICBcInVybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllclwiLFxuICAgICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXJcIixcbiAgICAgICAgXCJmb2xsb3dlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2ZvbGxvd2Vyc1wiLFxuICAgICAgICBcImZvbGxvd2luZ191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93aW5ney9vdGhlcl91c2VyfVwiLFxuICAgICAgICBcImdpc3RzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9naXN0c3svZ2lzdF9pZH1cIixcbiAgICAgICAgXCJzdGFycmVkX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdGFycmVkey9vd25lcn17L3JlcG99XCIsXG4gICAgICAgIFwic3Vic2NyaXB0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3Vic2NyaXB0aW9uc1wiLFxuICAgICAgICBcIm9yZ2FuaXphdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL29yZ3NcIixcbiAgICAgICAgXCJyZXBvc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVwb3NcIixcbiAgICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2V2ZW50c3svcHJpdmFjeX1cIixcbiAgICAgICAgXCJyZWNlaXZlZF9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlY2VpdmVkX2V2ZW50c1wiLFxuICAgICAgICBcInR5cGVcIjogXCJVc2VyXCIsXG4gICAgICAgIFwic2l0ZV9hZG1pblwiOiBmYWxzZVxuICAgICAgfSxcbiAgICAgIFwicmVwb1wiOiB7XG4gICAgICAgIFwiaWRcIjogNDcwMjEyMDAzLFxuICAgICAgICBcIm5vZGVfaWRcIjogXCJSX2tnRE9IQWJkb3dcIixcbiAgICAgICAgXCJuYW1lXCI6IFwiZGFiYmxlXCIsXG4gICAgICAgIFwiZnVsbF9uYW1lXCI6IFwiYmlud2llZGVyaGllci9kYWJibGVcIixcbiAgICAgICAgXCJwcml2YXRlXCI6IGZhbHNlLFxuICAgICAgICBcIm93bmVyXCI6IHtcbiAgICAgICAgICBcImxvZ2luXCI6IFwiYmlud2llZGVyaGllclwiLFxuICAgICAgICAgIFwiaWRcIjogNjY0NTk3LFxuICAgICAgICAgIFwibm9kZV9pZFwiOiBcIk1EUTZWWE5sY2pZMk5EVTVOdz09XCIsXG4gICAgICAgICAgXCJhdmF0YXJfdXJsXCI6IFwiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzY2NDU5Nz92PTRcIixcbiAgICAgICAgICBcImdyYXZhdGFyX2lkXCI6IFwiXCIsXG4gICAgICAgICAgXCJ1cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXJcIixcbiAgICAgICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXJcIixcbiAgICAgICAgICBcImZvbGxvd2Vyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93ZXJzXCIsXG4gICAgICAgICAgXCJmb2xsb3dpbmdfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2ZvbGxvd2luZ3svb3RoZXJfdXNlcn1cIixcbiAgICAgICAgICBcImdpc3RzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9naXN0c3svZ2lzdF9pZH1cIixcbiAgICAgICAgICBcInN0YXJyZWRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N0YXJyZWR7L293bmVyfXsvcmVwb31cIixcbiAgICAgICAgICBcInN1YnNjcmlwdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N1YnNjcmlwdGlvbnNcIixcbiAgICAgICAgICBcIm9yZ2FuaXphdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL29yZ3NcIixcbiAgICAgICAgICBcInJlcG9zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZXBvc1wiLFxuICAgICAgICAgIFwiZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9ldmVudHN7L3ByaXZhY3l9XCIsXG4gICAgICAgICAgXCJyZWNlaXZlZF9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlY2VpdmVkX2V2ZW50c1wiLFxuICAgICAgICAgIFwidHlwZVwiOiBcIlVzZXJcIixcbiAgICAgICAgICBcInNpdGVfYWRtaW5cIjogZmFsc2VcbiAgICAgICAgfSxcbiAgICAgICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcImRlc2NyaXB0aW9uXCI6IFwiQSByZXBvIGZvciBkYWJibGluZ1wiLFxuICAgICAgICBcImZvcmtcIjogZmFsc2UsXG4gICAgICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcImZvcmtzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZm9ya3NcIixcbiAgICAgICAgXCJrZXlzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUva2V5c3sva2V5X2lkfVwiLFxuICAgICAgICBcImNvbGxhYm9yYXRvcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb2xsYWJvcmF0b3Jzey9jb2xsYWJvcmF0b3J9XCIsXG4gICAgICAgIFwidGVhbXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS90ZWFtc1wiLFxuICAgICAgICBcImhvb2tzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaG9va3NcIixcbiAgICAgICAgXCJpc3N1ZV9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXMvZXZlbnRzey9udW1iZXJ9XCIsXG4gICAgICAgIFwiZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZXZlbnRzXCIsXG4gICAgICAgIFwiYXNzaWduZWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvYXNzaWduZWVzey91c2VyfVwiLFxuICAgICAgICBcImJyYW5jaGVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvYnJhbmNoZXN7L2JyYW5jaH1cIixcbiAgICAgICAgXCJ0YWdzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvdGFnc1wiLFxuICAgICAgICBcImJsb2JzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L2Jsb2Jzey9zaGF9XCIsXG4gICAgICAgIFwiZ2l0X3RhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvdGFnc3svc2hhfVwiLFxuICAgICAgICBcImdpdF9yZWZzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L3JlZnN7L3NoYX1cIixcbiAgICAgICAgXCJ0cmVlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2dpdC90cmVlc3svc2hhfVwiLFxuICAgICAgICBcInN0YXR1c2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3RhdHVzZXMve3NoYX1cIixcbiAgICAgICAgXCJsYW5ndWFnZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9sYW5ndWFnZXNcIixcbiAgICAgICAgXCJzdGFyZ2F6ZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3RhcmdhemVyc1wiLFxuICAgICAgICBcImNvbnRyaWJ1dG9yc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbnRyaWJ1dG9yc1wiLFxuICAgICAgICBcInN1YnNjcmliZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3Vic2NyaWJlcnNcIixcbiAgICAgICAgXCJzdWJzY3JpcHRpb25fdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdWJzY3JpcHRpb25cIixcbiAgICAgICAgXCJjb21taXRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tbWl0c3svc2hhfVwiLFxuICAgICAgICBcImdpdF9jb21taXRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L2NvbW1pdHN7L3NoYX1cIixcbiAgICAgICAgXCJjb21tZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbW1lbnRzey9udW1iZXJ9XCIsXG4gICAgICAgIFwiaXNzdWVfY29tbWVudF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgICAgICBcImNvbnRlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29udGVudHMveytwYXRofVwiLFxuICAgICAgICBcImNvbXBhcmVfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb21wYXJlL3tiYXNlfS4uLntoZWFkfVwiLFxuICAgICAgICBcIm1lcmdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL21lcmdlc1wiLFxuICAgICAgICBcImFyY2hpdmVfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS97YXJjaGl2ZV9mb3JtYXR9ey9yZWZ9XCIsXG4gICAgICAgIFwiZG93bmxvYWRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZG93bmxvYWRzXCIsXG4gICAgICAgIFwiaXNzdWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzey9udW1iZXJ9XCIsXG4gICAgICAgIFwicHVsbHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxsc3svbnVtYmVyfVwiLFxuICAgICAgICBcIm1pbGVzdG9uZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9taWxlc3RvbmVzey9udW1iZXJ9XCIsXG4gICAgICAgIFwibm90aWZpY2F0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL25vdGlmaWNhdGlvbnN7P3NpbmNlLGFsbCxwYXJ0aWNpcGF0aW5nfVwiLFxuICAgICAgICBcImxhYmVsc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2xhYmVsc3svbmFtZX1cIixcbiAgICAgICAgXCJyZWxlYXNlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3JlbGVhc2Vzey9pZH1cIixcbiAgICAgICAgXCJkZXBsb3ltZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2RlcGxveW1lbnRzXCIsXG4gICAgICAgIFwiY3JlYXRlZF9hdFwiOiBcIjIwMjItMDMtMTVUMTU6MDY6MTdaXCIsXG4gICAgICAgIFwidXBkYXRlZF9hdFwiOiBcIjIwMjItMDMtMTVUMTU6MDY6MTdaXCIsXG4gICAgICAgIFwicHVzaGVkX2F0XCI6IFwiMjAyNC0wMy0yMVQwMjo1MjoxMFpcIixcbiAgICAgICAgXCJnaXRfdXJsXCI6IFwiZ2l0Oi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZS5naXRcIixcbiAgICAgICAgXCJzc2hfdXJsXCI6IFwiZ2l0QGdpdGh1Yi5jb206Ymlud2llZGVyaGllci9kYWJibGUuZ2l0XCIsXG4gICAgICAgIFwiY2xvbmVfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgICAgICBcInN2bl91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllci9kYWJibGVcIixcbiAgICAgICAgXCJob21lcGFnZVwiOiBudWxsLFxuICAgICAgICBcInNpemVcIjogMSxcbiAgICAgICAgXCJzdGFyZ2F6ZXJzX2NvdW50XCI6IDAsXG4gICAgICAgIFwid2F0Y2hlcnNfY291bnRcIjogMCxcbiAgICAgICAgXCJsYW5ndWFnZVwiOiBudWxsLFxuICAgICAgICBcImhhc19pc3N1ZXNcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfcHJvamVjdHNcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfZG93bmxvYWRzXCI6IHRydWUsXG4gICAgICAgIFwiaGFzX3dpa2lcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfcGFnZXNcIjogZmFsc2UsXG4gICAgICAgIFwiaGFzX2Rpc2N1c3Npb25zXCI6IGZhbHNlLFxuICAgICAgICBcImZvcmtzX2NvdW50XCI6IDAsXG4gICAgICAgIFwibWlycm9yX3VybFwiOiBudWxsLFxuICAgICAgICBcImFyY2hpdmVkXCI6IGZhbHNlLFxuICAgICAgICBcImRpc2FibGVkXCI6IGZhbHNlLFxuICAgICAgICBcIm9wZW5faXNzdWVzX2NvdW50XCI6IDEsXG4gICAgICAgIFwibGljZW5zZVwiOiBudWxsLFxuICAgICAgICBcImFsbG93X2ZvcmtpbmdcIjogdHJ1ZSxcbiAgICAgICAgXCJpc190ZW1wbGF0ZVwiOiBmYWxzZSxcbiAgICAgICAgXCJ3ZWJfY29tbWl0X3NpZ25vZmZfcmVxdWlyZWRcIjogZmFsc2UsXG4gICAgICAgIFwidG9waWNzXCI6IFtdLFxuICAgICAgICBcInZpc2liaWxpdHlcIjogXCJwdWJsaWNcIixcbiAgICAgICAgXCJmb3Jrc1wiOiAwLFxuICAgICAgICBcIm9wZW5faXNzdWVzXCI6IDEsXG4gICAgICAgIFwid2F0Y2hlcnNcIjogMCxcbiAgICAgICAgXCJkZWZhdWx0X2JyYW5jaFwiOiBcIm1haW5cIixcbiAgICAgICAgXCJhbGxvd19zcXVhc2hfbWVyZ2VcIjogdHJ1ZSxcbiAgICAgICAgXCJhbGxvd19tZXJnZV9jb21taXRcIjogdHJ1ZSxcbiAgICAgICAgXCJhbGxvd19yZWJhc2VfbWVyZ2VcIjogdHJ1ZSxcbiAgICAgICAgXCJhbGxvd19hdXRvX21lcmdlXCI6IGZhbHNlLFxuICAgICAgICBcImRlbGV0ZV9icmFuY2hfb25fbWVyZ2VcIjogZmFsc2UsXG4gICAgICAgIFwiYWxsb3dfdXBkYXRlX2JyYW5jaFwiOiBmYWxzZSxcbiAgICAgICAgXCJ1c2Vfc3F1YXNoX3ByX3RpdGxlX2FzX2RlZmF1bHRcIjogZmFsc2UsXG4gICAgICAgIFwic3F1YXNoX21lcmdlX2NvbW1pdF9tZXNzYWdlXCI6IFwiQ09NTUlUX01FU1NBR0VTXCIsXG4gICAgICAgIFwic3F1YXNoX21lcmdlX2NvbW1pdF90aXRsZVwiOiBcIkNPTU1JVF9PUl9QUl9USVRMRVwiLFxuICAgICAgICBcIm1lcmdlX2NvbW1pdF9tZXNzYWdlXCI6IFwiUFJfVElUTEVcIixcbiAgICAgICAgXCJtZXJnZV9jb21taXRfdGl0bGVcIjogXCJNRVJHRV9NRVNTQUdFXCJcbiAgICAgIH1cbiAgICB9LFxuICAgIFwiX2xpbmtzXCI6IHtcbiAgICAgIFwic2VsZlwiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMVwiXG4gICAgICB9LFxuICAgICAgXCJodG1sXCI6IHtcbiAgICAgICAgXCJocmVmXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGwvMVwiXG4gICAgICB9LFxuICAgICAgXCJpc3N1ZVwiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzLzFcIlxuICAgICAgfSxcbiAgICAgIFwiY29tbWVudHNcIjoge1xuICAgICAgICBcImhyZWZcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy8xL2NvbW1lbnRzXCJcbiAgICAgIH0sXG4gICAgICBcInJldmlld19jb21tZW50c1wiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMS9jb21tZW50c1wiXG4gICAgICB9LFxuICAgICAgXCJyZXZpZXdfY29tbWVudFwiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvY29tbWVudHN7L251bWJlcn1cIlxuICAgICAgfSxcbiAgICAgIFwiY29tbWl0c1wiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMS9jb21taXRzXCJcbiAgICAgIH0sXG4gICAgICBcInN0YXR1c2VzXCI6IHtcbiAgICAgICAgXCJocmVmXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGF0dXNlcy81NzAzODQyY2M1NzE1ZWQxZTM1OGQyM2ViYjY5M2RiMDk3NDdhZTliXCJcbiAgICAgIH1cbiAgICB9LFxuICAgIFwiYXV0aG9yX2Fzc29jaWF0aW9uXCI6IFwiT1dORVJcIixcbiAgICBcImF1dG9fbWVyZ2VcIjogbnVsbCxcbiAgICBcImFjdGl2ZV9sb2NrX3JlYXNvblwiOiBudWxsLFxuICAgIFwibWVyZ2VkXCI6IGZhbHNlLFxuICAgIFwibWVyZ2VhYmxlXCI6IG51bGwsXG4gICAgXCJyZWJhc2VhYmxlXCI6IG51bGwsXG4gICAgXCJtZXJnZWFibGVfc3RhdGVcIjogXCJ1bmtub3duXCIsXG4gICAgXCJtZXJnZWRfYnlcIjogbnVsbCxcbiAgICBcImNvbW1lbnRzXCI6IDAsXG4gICAgXCJyZXZpZXdfY29tbWVudHNcIjogMCxcbiAgICBcIm1haW50YWluZXJfY2FuX21vZGlmeVwiOiBmYWxzZSxcbiAgICBcImNvbW1pdHNcIjogMSxcbiAgICBcImFkZGl0aW9uc1wiOiAxLFxuICAgIFwiZGVsZXRpb25zXCI6IDEsXG4gICAgXCJjaGFuZ2VkX2ZpbGVzXCI6IDFcbiAgfSxcbiAgXCJyZXBvc2l0b3J5XCI6IHtcbiAgICBcImlkXCI6IDQ3MDIxMjAwMyxcbiAgICBcIm5vZGVfaWRcIjogXCJSX2tnRE9IQWJkb3dcIixcbiAgICBcIm5hbWVcIjogXCJkYWJibGVcIixcbiAgICBcImZ1bGxfbmFtZVwiOiBcImJpbndpZWRlcmhpZXIvZGFiYmxlXCIsXG4gICAgXCJwcml2YXRlXCI6IGZhbHNlLFxuICAgIFwib3duZXJcIjoge1xuICAgICAgXCJsb2dpblwiOiBcImJpbndpZWRlcmhpZXJcIixcbiAgICAgIFwiaWRcIjogNjY0NTk3LFxuICAgICAgXCJub2RlX2lkXCI6IFwiTURRNlZYTmxjalkyTkRVNU53PT1cIixcbiAgICAgIFwiYXZhdGFyX3VybFwiOiBcImh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS82NjQ1OTc/dj00XCIsXG4gICAgICBcImdyYXZhdGFyX2lkXCI6IFwiXCIsXG4gICAgICBcInVybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllclwiLFxuICAgICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyXCIsXG4gICAgICBcImZvbGxvd2Vyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93ZXJzXCIsXG4gICAgICBcImZvbGxvd2luZ191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93aW5ney9vdGhlcl91c2VyfVwiLFxuICAgICAgXCJnaXN0c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZ2lzdHN7L2dpc3RfaWR9XCIsXG4gICAgICBcInN0YXJyZWRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N0YXJyZWR7L293bmVyfXsvcmVwb31cIixcbiAgICAgIFwic3Vic2NyaXB0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3Vic2NyaXB0aW9uc1wiLFxuICAgICAgXCJvcmdhbml6YXRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9vcmdzXCIsXG4gICAgICBcInJlcG9zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZXBvc1wiLFxuICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2V2ZW50c3svcHJpdmFjeX1cIixcbiAgICAgIFwicmVjZWl2ZWRfZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZWNlaXZlZF9ldmVudHNcIixcbiAgICAgIFwidHlwZVwiOiBcIlVzZXJcIixcbiAgICAgIFwic2l0ZV9hZG1pblwiOiBmYWxzZVxuICAgIH0sXG4gICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgIFwiZGVzY3JpcHRpb25cIjogXCJBIHJlcG8gZm9yIGRhYmJsaW5nXCIsXG4gICAgXCJmb3JrXCI6IGZhbHNlLFxuICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgIFwiZm9ya3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9mb3Jrc1wiLFxuICAgIFwia2V5c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2tleXN7L2tleV9pZH1cIixcbiAgICBcImNvbGxhYm9yYXRvcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb2xsYWJvcmF0b3Jzey9jb2xsYWJvcmF0b3J9XCIsXG4gICAgXCJ0ZWFtc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3RlYW1zXCIsXG4gICAgXCJob29rc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2hvb2tzXCIsXG4gICAgXCJpc3N1ZV9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXMvZXZlbnRzey9udW1iZXJ9XCIsXG4gICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9ldmVudHNcIixcbiAgICBcImFzc2lnbmVlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2Fzc2lnbmVlc3svdXNlcn1cIixcbiAgICBcImJyYW5jaGVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvYnJhbmNoZXN7L2JyYW5jaH1cIixcbiAgICBcInRhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS90YWdzXCIsXG4gICAgXCJibG9ic191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2dpdC9ibG9ic3svc2hhfVwiLFxuICAgIFwiZ2l0X3RhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvdGFnc3svc2hhfVwiLFxuICAgIFwiZ2l0X3JlZnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvcmVmc3svc2hhfVwiLFxuICAgIFwidHJlZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvdHJlZXN7L3NoYX1cIixcbiAgICBcInN0YXR1c2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3RhdHVzZXMve3NoYX1cIixcbiAgICBcImxhbmd1YWdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2xhbmd1YWdlc1wiLFxuICAgIFwic3RhcmdhemVyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3N0YXJnYXplcnNcIixcbiAgICBcImNvbnRyaWJ1dG9yc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbnRyaWJ1dG9yc1wiLFxuICAgIFwic3Vic2NyaWJlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdWJzY3JpYmVyc1wiLFxuICAgIFwic3Vic2NyaXB0aW9uX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3Vic2NyaXB0aW9uXCIsXG4gICAgXCJjb21taXRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tbWl0c3svc2hhfVwiLFxuICAgIFwiZ2l0X2NvbW1pdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvY29tbWl0c3svc2hhfVwiLFxuICAgIFwiY29tbWVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgIFwiaXNzdWVfY29tbWVudF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgIFwiY29udGVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb250ZW50cy97K3BhdGh9XCIsXG4gICAgXCJjb21wYXJlX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tcGFyZS97YmFzZX0uLi57aGVhZH1cIixcbiAgICBcIm1lcmdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL21lcmdlc1wiLFxuICAgIFwiYXJjaGl2ZV91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3thcmNoaXZlX2Zvcm1hdH17L3JlZn1cIixcbiAgICBcImRvd25sb2Fkc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2Rvd25sb2Fkc1wiLFxuICAgIFwiaXNzdWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzey9udW1iZXJ9XCIsXG4gICAgXCJwdWxsc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGxzey9udW1iZXJ9XCIsXG4gICAgXCJtaWxlc3RvbmVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbWlsZXN0b25lc3svbnVtYmVyfVwiLFxuICAgIFwibm90aWZpY2F0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL25vdGlmaWNhdGlvbnN7P3NpbmNlLGFsbCxwYXJ0aWNpcGF0aW5nfVwiLFxuICAgIFwibGFiZWxzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbGFiZWxzey9uYW1lfVwiLFxuICAgIFwicmVsZWFzZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9yZWxlYXNlc3svaWR9XCIsXG4gICAgXCJkZXBsb3ltZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2RlcGxveW1lbnRzXCIsXG4gICAgXCJjcmVhdGVkX2F0XCI6IFwiMjAyMi0wMy0xNVQxNTowNjoxN1pcIixcbiAgICBcInVwZGF0ZWRfYXRcIjogXCIyMDIyLTAzLTE1VDE1OjA2OjE3WlwiLFxuICAgIFwicHVzaGVkX2F0XCI6IFwiMjAyNC0wMy0yMVQwMjo1MjoxMFpcIixcbiAgICBcImdpdF91cmxcIjogXCJnaXQ6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgIFwic3NoX3VybFwiOiBcImdpdEBnaXRodWIuY29tOmJpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgIFwiY2xvbmVfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgIFwic3ZuX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgIFwiaG9tZXBhZ2VcIjogbnVsbCxcbiAgICBcInNpemVcIjogMSxcbiAgICBcInN0YXJnYXplcnNfY291bnRcIjogMCxcbiAgICBcIndhdGNoZXJzX2NvdW50XCI6IDAsXG4gICAgXCJsYW5ndWFnZVwiOiBudWxsLFxuICAgIFwiaGFzX2lzc3Vlc1wiOiB0cnVlLFxuICAgIFwiaGFzX3Byb2plY3RzXCI6IHRydWUsXG4gICAgXCJoYXNfZG93bmxvYWRzXCI6IHRydWUsXG4gICAgXCJoYXNfd2lraVwiOiB0cnVlLFxuICAgIFwiaGFzX3BhZ2VzXCI6IGZhbHNlLFxuICAgIFwiaGFzX2Rpc2N1c3Npb25zXCI6IGZhbHNlLFxuICAgIFwiZm9ya3NfY291bnRcIjogMCxcbiAgICBcIm1pcnJvcl91cmxcIjogbnVsbCxcbiAgICBcImFyY2hpdmVkXCI6IGZhbHNlLFxuICAgIFwiZGlzYWJsZWRcIjogZmFsc2UsXG4gICAgXCJvcGVuX2lzc3Vlc19jb3VudFwiOiAxLFxuICAgIFwibGljZW5zZVwiOiBudWxsLFxuICAgIFwiYWxsb3dfZm9ya2luZ1wiOiB0cnVlLFxuICAgIFwiaXNfdGVtcGxhdGVcIjogZmFsc2UsXG4gICAgXCJ3ZWJfY29tbWl0X3NpZ25vZmZfcmVxdWlyZWRcIjogZmFsc2UsXG4gICAgXCJ0b3BpY3NcIjogW10sXG4gICAgXCJ2aXNpYmlsaXR5XCI6IFwicHVibGljXCIsXG4gICAgXCJmb3Jrc1wiOiAwLFxuICAgIFwib3Blbl9pc3N1ZXNcIjogMSxcbiAgICBcIndhdGNoZXJzXCI6IDAsXG4gICAgXCJkZWZhdWx0X2JyYW5jaFwiOiBcIm1haW5cIlxuICB9LFxuICBcInNlbmRlclwiOiB7XG4gICAgXCJsb2dpblwiOiBcImJpbndpZWRlcmhpZXJcIixcbiAgICBcImlkXCI6IDY2NDU5NyxcbiAgICBcIm5vZGVfaWRcIjogXCJNRFE2VlhObGNqWTJORFU1Tnc9PVwiLFxuICAgIFwiYXZhdGFyX3VybFwiOiBcImh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS82NjQ1OTc/dj00XCIsXG4gICAgXCJncmF2YXRhcl9pZFwiOiBcIlwiLFxuICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyXCIsXG4gICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyXCIsXG4gICAgXCJmb2xsb3dlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2ZvbGxvd2Vyc1wiLFxuICAgIFwiZm9sbG93aW5nX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dpbmd7L290aGVyX3VzZXJ9XCIsXG4gICAgXCJnaXN0c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZ2lzdHN7L2dpc3RfaWR9XCIsXG4gICAgXCJzdGFycmVkX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdGFycmVkey9vd25lcn17L3JlcG99XCIsXG4gICAgXCJzdWJzY3JpcHRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdWJzY3JpcHRpb25zXCIsXG4gICAgXCJvcmdhbml6YXRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9vcmdzXCIsXG4gICAgXCJyZXBvc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVwb3NcIixcbiAgICBcImV2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZXZlbnRzey9wcml2YWN5fVwiLFxuICAgIFwicmVjZWl2ZWRfZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZWNlaXZlZF9ldmVudHNcIixcbiAgICBcInR5cGVcIjogXCJVc2VyXCIsXG4gICAgXCJzaXRlX2FkbWluXCI6IGZhbHNlXG4gIH1cbn1cbiIsImNvbmZpZyI6eyJ0ZW1wbGF0ZSI6InRleHQiLCJmdWxsU2NyZWVuSFRNTCI6ZmFsc2UsImZ1bmN0aW9ucyI6WyJzcHJpZyJdLCJvcHRpb25zIjpbImxpdmUiXSwiaW5wdXRUeXBlIjoieWFtbCJ9fQ==))
+* Loops (e.g. `{{range .errors}}..{{end}}`, see [example](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6IlNldmVyZSBVUkxzOlxue3tyYW5nZSAuZXJyb3JzfX17e2lmIGVxIC5sZXZlbCBcInNldmVyZVwifX0tIHt7LnVybH19XG57e2VuZH19e3tlbmR9fSIsImlucHV0Ijoie1wiZm9vXCI6IFwiYmFyXCIsIFwiZXJyb3JzXCI6IFt7XCJsZXZlbFwiOiBcInNldmVyZVwiLCBcInVybFwiOiBcImh0dHBzOi8vc2V2ZXJlMS5jb21cIn0se1wibGV2ZWxcIjogXCJ3YXJuaW5nXCIsIFwidXJsXCI6IFwiaHR0cHM6Ly93YXJuaW5nLmNvbVwifSx7XCJsZXZlbFwiOiBcInNldmVyZVwiLCBcInVybFwiOiBcImh0dHBzOi8vc2V2ZXJlMi5jb21cIn1dfSIsImNvbmZpZyI6eyJ0ZW1wbGF0ZSI6InRleHQiLCJmdWxsU2NyZWVuSFRNTCI6ZmFsc2UsImZ1bmN0aW9ucyI6WyJzcHJpZyJdLCJvcHRpb25zIjpbImxpdmUiXSwiaW5wdXRUeXBlIjoieWFtbCJ9fQ==))
+
+A good way to experiment with Go templates is the **[Go Template Playground](https://repeatit.io)**. It is _highly recommended_ to test
+your templates there first ([example for Grafana alert](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6InRpdGxlPUdyYWZhbmErYWxlcnQ6K3t7LnRpdGxlfX0mbWVzc2FnZT17ey5tZXNzYWdlfX0iLCJpbnB1dCI6IntcbiAgXCJyZWNlaXZlclwiOiBcIm50ZnlcXFxcLmV4YW1wbGVcXFxcLmNvbS9hbGVydHNcIixcbiAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICBcImFsZXJ0c1wiOiBbXG4gICAge1xuICAgICAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICAgICAgXCJsYWJlbHNcIjoge1xuICAgICAgICBcImFsZXJ0bmFtZVwiOiBcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFwiLFxuICAgICAgICBcImdyYWZhbmFfZm9sZGVyXCI6IFwiTm9kZSBhbGVydHNcIixcbiAgICAgICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgICAgICBcImpvYlwiOiBcIm5vZGUtZXhwb3J0ZXJcIlxuICAgICAgfSxcbiAgICAgIFwiYW5ub3RhdGlvbnNcIjoge1xuICAgICAgICBcInN1bW1hcnlcIjogXCIxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXCJcbiAgICAgIH0sXG4gICAgICBcInN0YXJ0c0F0XCI6IFwiMjAyNC0wMy0xNVQwMjoyODowMFpcIixcbiAgICAgIFwiZW5kc0F0XCI6IFwiMjAyNC0wMy0xNVQwMjo0MjowMFpcIixcbiAgICAgIFwiZ2VuZXJhdG9yVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvZ3JhZmFuYS9OVzlvRHctNHovdmlld1wiLFxuICAgICAgXCJmaW5nZXJwcmludFwiOiBcImJlY2JmYjk0YmQ4MWVmNDhcIixcbiAgICAgIFwic2lsZW5jZVVSTFwiOiBcImxvY2FsaG9zdDozMDAwL2FsZXJ0aW5nL3NpbGVuY2UvbmV3P2FsZXJ0bWFuYWdlcj1ncmFmYW5hJm1hdGNoZXI9YWxlcnRuYW1lJTNETG9hZCthdmcrMTVtK3RvbytoaWdoJm1hdGNoZXI9Z3JhZmFuYV9mb2xkZXIlM0ROb2RlK2FsZXJ0cyZtYXRjaGVyPWluc3RhbmNlJTNEMTAuMTA4LjAuMiUzQTkxMDAmbWF0Y2hlcj1qb2IlM0Rub2RlLWV4cG9ydGVyXCIsXG4gICAgICBcImRhc2hib2FyZFVSTFwiOiBcIlwiLFxuICAgICAgXCJwYW5lbFVSTFwiOiBcIlwiLFxuICAgICAgXCJ2YWx1ZXNcIjoge1xuICAgICAgICBcIkJcIjogMTguOTgyMTEzMTQ0NzU4NzYsXG4gICAgICAgIFwiQ1wiOiAwXG4gICAgICB9LFxuICAgICAgXCJ2YWx1ZVN0cmluZ1wiOiBcIlsgdmFyPSdCJyBsYWJlbHM9e19fbmFtZV9fPW5vZGVfbG9hZDE1LCBpbnN0YW5jZT0xMC4xMDguMC4yOjkxMDAsIGpvYj1ub2RlLWV4cG9ydGVyfSB2YWx1ZT0xOC45ODIxMTMxNDQ3NTg3NiBdLCBbIHZhcj0nQycgbGFiZWxzPXtfX25hbWVfXz1ub2RlX2xvYWQxNSwgaW5zdGFuY2U9MTAuMTA4LjAuMjo5MTAwLCBqb2I9bm9kZS1leHBvcnRlcn0gdmFsdWU9MCBdXCJcbiAgICB9XG4gIF0sXG4gIFwiZ3JvdXBMYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCJcbiAgfSxcbiAgXCJjb21tb25MYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCIsXG4gICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgIFwiam9iXCI6IFwibm9kZS1leHBvcnRlclwiXG4gIH0sXG4gIFwiY29tbW9uQW5ub3RhdGlvbnNcIjoge1xuICAgIFwic3VtbWFyeVwiOiBcIjE1bSBsb2FkIGF2ZXJhZ2UgdG9vIGhpZ2hcIlxuICB9LFxuICBcImV4dGVybmFsVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvXCIsXG4gIFwidmVyc2lvblwiOiBcIjFcIixcbiAgXCJncm91cEtleVwiOiBcInt9OnthbGVydG5hbWU9XFxcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFxcXCIsIGdyYWZhbmFfZm9sZGVyPVxcXCJOb2RlIGFsZXJ0c1xcXCJ9XCIsXG4gIFwidHJ1bmNhdGVkQWxlcnRzXCI6IDAsXG4gIFwib3JnSWRcIjogMSxcbiAgXCJ0aXRsZVwiOiBcIltSRVNPTFZFRF0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoIE5vZGUgYWxlcnRzICgxMC4xMDguMC4yOjkxMDAgbm9kZS1leHBvcnRlcilcIixcbiAgXCJzdGF0ZVwiOiBcIm9rXCIsXG4gIFwibWVzc2FnZVwiOiBcIioqUmVzb2x2ZWQqKlxcblxcblZhbHVlOiBCPTE4Ljk4MjExMzE0NDc1ODc2LCBDPTBcXG5MYWJlbHM6XFxuIC0gYWxlcnRuYW1lID0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoXFxuIC0gZ3JhZmFuYV9mb2xkZXIgPSBOb2RlIGFsZXJ0c1xcbiAtIGluc3RhbmNlID0gMTAuMTA4LjAuMjo5MTAwXFxuIC0gam9iID0gbm9kZS1leHBvcnRlclxcbkFubm90YXRpb25zOlxcbiAtIHN1bW1hcnkgPSAxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXFxuU291cmNlOiBsb2NhbGhvc3Q6MzAwMC9hbGVydGluZy9ncmFmYW5hL05XOW9Edy00ei92aWV3XFxuU2lsZW5jZTogbG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvc2lsZW5jZS9uZXc/YWxlcnRtYW5hZ2VyPWdyYWZhbmEmbWF0Y2hlcj1hbGVydG5hbWUlM0RMb2FkK2F2ZysxNW0rdG9vK2hpZ2gmbWF0Y2hlcj1ncmFmYW5hX2ZvbGRlciUzRE5vZGUrYWxlcnRzJm1hdGNoZXI9aW5zdGFuY2UlM0QxMC4xMDguMC4yJTNBOTEwMCZtYXRjaGVyPWpvYiUzRG5vZGUtZXhwb3J0ZXJcXG5cIlxufVxuIiwiY29uZmlnIjp7InRlbXBsYXRlIjoidGV4dCIsImZ1bGxTY3JlZW5IVE1MIjpmYWxzZSwiZnVuY3Rpb25zIjpbInNwcmlnIl0sIm9wdGlvbnMiOlsibGl2ZSJdLCJpbnB1dFR5cGUiOiJ5YW1sIn19)).
+
+### Template functions
+ntfy supports a subset of the **[Sprig template functions](publish/template-functions.md)** (originally copied from [Sprig](https://github.com/Masterminds/sprig),
+thank you to the Sprig developers 🙏). This is useful for advanced message templating and for transforming the data provided through the JSON payload.
+
+Below are the functions that are available to use inside your message/title templates.
+
+* [String Functions](publish/template-functions.md#string-functions): `trim`, `trunc`, `substr`, `plural`, etc.
+* [String List Functions](publish/template-functions.md#string-list-functions): `splitList`, `sortAlpha`, etc.
+* [Integer Math Functions](publish/template-functions.md#integer-math-functions): `add`, `max`, `mul`, etc.
+* [Integer List Functions](publish/template-functions.md#integer-list-functions): `until`, `untilStep`
+* [Float Math Functions](publish/template-functions.md#float-math-functions): `maxf`, `minf`
+* [Date Functions](publish/template-functions.md#date-functions): `now`, `date`, etc.
+* [Defaults Functions](publish/template-functions.md#default-functions): `default`, `empty`, `coalesce`, `fromJSON`, `toJSON`, `toPrettyJSON`, `toRawJSON`, `ternary`
+* [Encoding Functions](publish/template-functions.md#encoding-functions): `b64enc`, `b64dec`, etc.
+* [Lists and List Functions](publish/template-functions.md#lists-and-list-functions): `list`, `first`, `uniq`, etc.
+* [Dictionaries and Dict Functions](publish/template-functions.md#dictionaries-and-dict-functions): `get`, `set`, `dict`, `hasKey`, `pluck`, `dig`, etc.
+* [Type Conversion Functions](publish/template-functions.md#type-conversion-functions): `atoi`, `int64`, `toString`, etc.
+* [Path and Filepath Functions](publish/template-functions.md#path-and-filepath-functions): `base`, `dir`, `ext`, `clean`, `isAbs`, `osBase`, `osDir`, `osExt`, `osClean`, `osIsAbs`
+* [Flow Control Functions](publish/template-functions.md#flow-control-functions): `fail`
+* Advanced Functions
+ * [Reflection](publish/template-functions.md#reflection-functions): `typeOf`, `kindIs`, `typeIsLike`, etc.
+ * [Cryptographic and Security Functions](publish/template-functions.md#cryptographic-and-security-functions): `sha256sum`, etc.
+ * [URL](publish/template-functions.md#url-functions): `urlParse`, `urlJoin`
+
## E-mail notifications
_Supported on:_ :material-android: :material-apple: :material-firefox:
@@ -3268,10 +3215,727 @@ Here's what a phone call from ntfy sounds like:
Audio transcript:
-> You have a notification from ntfy on topic alerts.
+> You have a notification from ntfy on topic alerts.
> Message: Your garage seems to be on fire. You should probably check that out. End message.
> This message was sent by user phil. It will be repeated up to three times.
+## Publish as JSON
+_Supported on:_ :material-android: :material-apple: :material-firefox:
+
+For some integrations with other tools (e.g. [Jellyfin](https://jellyfin.org/), [overseerr](https://overseerr.dev/)),
+adding custom headers to HTTP requests may be tricky or impossible, so ntfy also allows publishing the entire message
+as JSON in the request body.
+
+To publish as JSON, simple PUT/POST the JSON object directly to the ntfy root URL. The message format is described below
+the example.
+
+!!! info
+ To publish as JSON, you must **PUT/POST to the ntfy root URL**, not to the topic URL. Be sure to check that you're
+ POST-ing to `https://ntfy.sh/` (correct), and not to `https://ntfy.sh/mytopic` (incorrect).
+
+Here's an example using most supported parameters. Check the table below for a complete list. The `topic` parameter
+is the only required one:
+
+=== "Command line (curl)"
+ ```
+ curl ntfy.sh \
+ -d '{
+ "topic": "mytopic",
+ "message": "Disk space is low at 5.1 GB",
+ "title": "Low disk space alert",
+ "tags": ["warning","cd"],
+ "priority": 4,
+ "attach": "https://filesrv.lan/space.jpg",
+ "filename": "diskspace.jpg",
+ "click": "https://homecamera.lan/xasds1h2xsSsa/",
+ "actions": [{ "action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" }]
+ }'
+ ```
+
+=== "HTTP"
+ ``` http
+ POST / HTTP/1.1
+ Host: ntfy.sh
+
+ {
+ "topic": "mytopic",
+ "message": "Disk space is low at 5.1 GB",
+ "title": "Low disk space alert",
+ "tags": ["warning","cd"],
+ "priority": 4,
+ "attach": "https://filesrv.lan/space.jpg",
+ "filename": "diskspace.jpg",
+ "click": "https://homecamera.lan/xasds1h2xsSsa/",
+ "actions": [{ "action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" }]
+ }
+ ```
+
+=== "JavaScript"
+ ``` javascript
+ fetch('https://ntfy.sh', {
+ method: 'POST',
+ body: JSON.stringify({
+ "topic": "mytopic",
+ "message": "Disk space is low at 5.1 GB",
+ "title": "Low disk space alert",
+ "tags": ["warning","cd"],
+ "priority": 4,
+ "attach": "https://filesrv.lan/space.jpg",
+ "filename": "diskspace.jpg",
+ "click": "https://homecamera.lan/xasds1h2xsSsa/",
+ "actions": [{ "action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" }]
+ })
+ })
+ ```
+
+=== "Go"
+ ``` go
+ // You should probably use json.Marshal() instead and make a proper struct,
+ // or even just use req.Header.Set() like in the other examples, but for the
+ // sake of the example, this is easier.
+
+ body := `{
+ "topic": "mytopic",
+ "message": "Disk space is low at 5.1 GB",
+ "title": "Low disk space alert",
+ "tags": ["warning","cd"],
+ "priority": 4,
+ "attach": "https://filesrv.lan/space.jpg",
+ "filename": "diskspace.jpg",
+ "click": "https://homecamera.lan/xasds1h2xsSsa/",
+ "actions": [{ "action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" }]
+ }`
+ req, _ := http.NewRequest("POST", "https://ntfy.sh/", strings.NewReader(body))
+ http.DefaultClient.Do(req)
+ ```
+
+=== "PowerShell"
+ ``` powershell
+ $Request = @{
+ Method = "POST"
+ URI = "https://ntfy.sh"
+ Body = ConvertTo-JSON @{
+ Topic = "mytopic"
+ Title = "Low disk space alert"
+ Message = "Disk space is low at 5.1 GB"
+ Priority = 4
+ Attach = "https://filesrv.lan/space.jpg"
+ FileName = "diskspace.jpg"
+ Tags = @("warning", "cd")
+ Click = "https://homecamera.lan/xasds1h2xsSsa/"
+ Actions = @(
+ @{
+ Action = "view"
+ Label = "Admin panel"
+ URL = "https://filesrv.lan/admin"
+ }
+ )
+ }
+ ContentType = "application/json"
+ }
+ Invoke-RestMethod @Request
+ ```
+
+=== "Python"
+ ``` python
+ requests.post("https://ntfy.sh/",
+ data=json.dumps({
+ "topic": "mytopic",
+ "message": "Disk space is low at 5.1 GB",
+ "title": "Low disk space alert",
+ "tags": ["warning","cd"],
+ "priority": 4,
+ "attach": "https://filesrv.lan/space.jpg",
+ "filename": "diskspace.jpg",
+ "click": "https://homecamera.lan/xasds1h2xsSsa/",
+ "actions": [{ "action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" }]
+ })
+ )
+ ```
+
+=== "PHP"
+ ``` php-inline
+ file_get_contents('https://ntfy.sh/', false, stream_context_create([
+ 'http' => [
+ 'method' => 'POST',
+ 'header' => "Content-Type: application/json",
+ 'content' => json_encode([
+ "topic": "mytopic",
+ "message": "Disk space is low at 5.1 GB",
+ "title": "Low disk space alert",
+ "tags": ["warning","cd"],
+ "priority": 4,
+ "attach": "https://filesrv.lan/space.jpg",
+ "filename": "diskspace.jpg",
+ "click": "https://homecamera.lan/xasds1h2xsSsa/",
+ "actions": [["action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" ]]
+ ])
+ ]
+ ]));
+ ```
+
+The JSON message format closely mirrors the format of the message you can consume when you [subscribe via the API](subscribe/api.md)
+(see [JSON message format](subscribe/api.md#json-message-format) for details), but is not exactly identical. Here's an overview of
+all the supported fields:
+
+| Field | Required | Type | Example | Description |
+|---------------|----------|----------------------------------|-------------------------------------------|-------------------------------------------------------------------------------------------|
+| `topic` | ✔️ | *string* | `topic1` | Target topic name |
+| `message` | - | *string* | `Some message` | Message body; set to `triggered` if empty or not passed |
+| `title` | - | *string* | `Some title` | Message [title](#message-title) |
+| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](#tags-emojis) that may or not map to emojis |
+| `priority` | - | *int (one of: 1, 2, 3, 4, or 5)* | `4` | Message [priority](#message-priority) with 1=min, 3=default and 5=max |
+| `actions` | - | *JSON array* | *(see [action buttons](#action-buttons))* | Custom [user action buttons](#action-buttons) for notifications |
+| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) |
+| `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-a-url) |
+| `markdown` | - | *bool* | `true` | Set to true if the `message` is Markdown-formatted |
+| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) |
+| `filename` | - | *string* | `file.jpg` | File name of the attachment |
+| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery |
+| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications |
+| `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) |
+| `sequence_id` | - | *string* | `my-sequence-123` | Sequence ID for [updating/deleting notifications](#updating-deleting-notifications) |
+
+## Webhooks (publish via GET)
+_Supported on:_ :material-android: :material-apple: :material-firefox:
+
+In addition to using PUT/POST, you can also send to topics via simple HTTP GET requests. This makes it easy to use
+a ntfy topic as a [webhook](https://en.wikipedia.org/wiki/Webhook), or if your client has limited HTTP support.
+
+To send messages via HTTP GET, simply call the `/publish` endpoint (or its aliases `/send` and `/trigger`). Without
+any arguments, this will send the message `triggered` to the topic. However, you can provide all arguments that are
+also supported as HTTP headers as URL-encoded arguments. Be sure to check the list of all
+[supported parameters and headers](#list-of-all-parameters) for details.
+
+For instance, assuming your topic is `mywebhook`, you can simply call `/mywebhook/trigger` to send a message
+(aka trigger the webhook):
+
+=== "Command line (curl)"
+ ```
+ curl ntfy.sh/mywebhook/trigger
+ ```
+
+=== "ntfy CLI"
+ ```
+ ntfy trigger mywebhook
+ ```
+
+=== "HTTP"
+ ``` http
+ GET /mywebhook/trigger HTTP/1.1
+ Host: ntfy.sh
+ ```
+
+=== "JavaScript"
+ ``` javascript
+ fetch('https://ntfy.sh/mywebhook/trigger')
+ ```
+
+=== "Go"
+ ``` go
+ http.Get("https://ntfy.sh/mywebhook/trigger")
+ ```
+
+=== "PowerShell"
+ ``` powershell
+ Invoke-RestMethod "ntfy.sh/mywebhook/trigger"
+ ```
+
+=== "Python"
+ ``` python
+ requests.get("https://ntfy.sh/mywebhook/trigger")
+ ```
+
+=== "PHP"
+ ``` php-inline
+ file_get_contents('https://ntfy.sh/mywebhook/trigger');
+ ```
+
+To add a custom message, simply append the `message=` URL parameter. And of course you can set the
+[message priority](#message-priority), the [message title](#message-title), and [tags](#tags-emojis) as well.
+For a full list of possible parameters, check the list of [supported parameters and headers](#list-of-all-parameters).
+
+Here's an example with a custom message, tags and a priority:
+
+=== "Command line (curl)"
+ ```
+ curl "ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull"
+ ```
+
+=== "ntfy CLI"
+ ```
+ ntfy publish \
+ -p 5 --tags=warning,skull \
+ mywebhook "Webhook triggered"
+ ```
+
+=== "HTTP"
+ ``` http
+ GET /mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull HTTP/1.1
+ Host: ntfy.sh
+ ```
+
+=== "JavaScript"
+ ``` javascript
+ fetch('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull')
+ ```
+
+=== "Go"
+ ``` go
+ http.Get("https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull")
+ ```
+
+=== "PowerShell"
+ ``` powershell
+ Invoke-RestMethod "ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull"
+ ```
+
+=== "Python"
+ ``` python
+ requests.get("https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull")
+ ```
+
+=== "PHP"
+ ``` php-inline
+ file_get_contents('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull');
+ ```
+
+## Updating + deleting notifications
+_Supported on:_ :material-android: :material-firefox:
+
+You can **update, clear (mark as read and dismiss), or delete notifications** that have already been delivered. This is useful for scenarios
+like download progress updates, replacing outdated information, or dismissing notifications that are no longer relevant.
+
+* [Updating notifications](#updating-notifications) will alter the content of an existing notification.
+* [Clearing notifications](#clearing-notifications) will mark them as read and dismiss them from the notification drawer.
+* [Deleting notifications](#deleting-notifications) will remove them from the notification drawer and remove them in the clients as well (if supported).
+
+Here's an example of a download progress notification being updated over time on Android:
+
+
+
+
+
+
+To facilitate updating notifications and altering existing notifications, ntfy messages are linked together in a sequence,
+using a **sequence ID**. When a notification is meant to be updated, cleared, or deleted, you publish a new message with the
+same sequence ID and the clients will perform the appropriate action on the existing notification.
+
+Existing ntfy messages will not be updated on the server or in the message cache. Instead, a new message is created that indicates
+the update, clear, or delete action. This append-only behavior ensures that message history remains intact.
+
+### Updating notifications
+To update an existing notification, publish a new message with the same sequence ID. Clients will replace the previous
+notification with the new one. You can either:
+
+1. **Use the message ID**: First publish like normal to `POST /` without a sequence ID, then use the returned message `id` as the sequence ID for updates
+2. **Use a custom sequence ID**: Publish directly to `POST //` with your own identifier, or use `POST /` with the
+ `X-Sequence-ID` header (or any of its aliases: `Sequence-ID` or`SID`)
+
+If you don't know the sequence ID ahead of time, you can publish a message first and then use the returned
+message `id` to update it. Here's an example:
+
+=== "Command line (curl)"
+ ```bash
+ # First, publish a message and capture the message ID
+ curl -d "Downloading file..." ntfy.sh/mytopic
+ # Returns: {"id":"xE73Iyuabi","time":1673542291,...}
+
+ # Then use the message ID to update it (via URL path)
+ curl -d "Download 50% ..." ntfy.sh/mytopic/xE73Iyuabi
+
+ # Or update using the X-Sequence-ID header
+ curl -H "X-Sequence-ID: xE73Iyuabi" -d "Download complete" ntfy.sh/mytopic
+ ```
+
+=== "ntfy CLI"
+ ```bash
+ # First, publish a message and capture the message ID
+ ntfy pub mytopic "Downloading file..."
+ # Returns: {"id":"xE73Iyuabi","time":1673542291,...}
+
+ # Then use the message ID to update it
+ ntfy pub --sequence-id=xE73Iyuabi mytopic "Download 50% ..."
+
+ # Update again with the same sequence ID
+ ntfy pub -S xE73Iyuabi mytopic "Download complete"
+ ```
+
+=== "HTTP"
+ ``` http
+ # First, publish a message and capture the message ID
+ POST /mytopic HTTP/1.1
+ Host: ntfy.sh
+
+ Downloading file...
+
+ # Returns: {"id":"xE73Iyuabi","time":1673542291,...}
+
+ # Then use the message ID to update it
+ POST /mytopic/xE73Iyuabi HTTP/1.1
+ Host: ntfy.sh
+
+ Download 50% ...
+
+ # Update again with the same sequence ID, this time using the header
+ POST /mytopic HTTP/1.1
+ Host: ntfy.sh
+ X-Sequence-ID: xE73Iyuabi
+
+ Download complete
+ ```
+
+=== "JavaScript"
+ ``` javascript
+ // First, publish and get the message ID
+ const response = await fetch('https://ntfy.sh/mytopic', {
+ method: 'POST',
+ body: 'Downloading file...'
+ });
+ const { id } = await response.json();
+
+ // Update via URL path
+ await fetch(`https://ntfy.sh/mytopic/${id}`, {
+ method: 'POST',
+ body: 'Download 50% ...'
+ });
+
+ // Or update using the X-Sequence-ID header
+ await fetch('https://ntfy.sh/mytopic', {
+ method: 'POST',
+ headers: { 'X-Sequence-ID': id },
+ body: 'Download complete'
+ });
+ ```
+
+=== "Go"
+ ``` go
+ // Publish and parse the response to get the message ID
+ resp, _ := http.Post("https://ntfy.sh/mytopic", "text/plain",
+ strings.NewReader("Downloading file..."))
+ var msg struct { ID string `json:"id"` }
+ json.NewDecoder(resp.Body).Decode(&msg)
+
+ // Update via URL path
+ http.Post("https://ntfy.sh/mytopic/"+msg.ID, "text/plain",
+ strings.NewReader("Download 50% ..."))
+
+ // Or update using the X-Sequence-ID header
+ req, _ := http.NewRequest("POST", "https://ntfy.sh/mytopic",
+ strings.NewReader("Download complete"))
+ req.Header.Set("X-Sequence-ID", msg.ID)
+ http.DefaultClient.Do(req)
+ ```
+
+=== "PowerShell"
+ ``` powershell
+ # Publish and get the message ID
+ $response = Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic" -Body "Downloading file..."
+ $messageId = $response.id
+
+ # Update via URL path
+ Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic/$messageId" -Body "Download 50% ..."
+
+ # Or update using the X-Sequence-ID header
+ Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic" `
+ -Headers @{"X-Sequence-ID"=$messageId} -Body "Download complete"
+ ```
+
+=== "Python"
+ ``` python
+ import requests
+
+ # Publish and get the message ID
+ response = requests.post("https://ntfy.sh/mytopic", data="Downloading file...")
+ message_id = response.json()["id"]
+
+ # Update via URL path
+ requests.post(f"https://ntfy.sh/mytopic/{message_id}", data="Download 50% ...")
+
+ # Or update using the X-Sequence-ID header
+ requests.post("https://ntfy.sh/mytopic",
+ headers={"X-Sequence-ID": message_id}, data="Download complete")
+ ```
+
+=== "PHP"
+ ``` php-inline
+ // Publish and get the message ID
+ $response = file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([
+ 'http' => ['method' => 'POST', 'content' => 'Downloading file...']
+ ]));
+ $messageId = json_decode($response)->id;
+
+ // Update via URL path
+ file_get_contents("https://ntfy.sh/mytopic/$messageId", false, stream_context_create([
+ 'http' => ['method' => 'POST', 'content' => 'Download 50% ...']
+ ]));
+
+ // Or update using the X-Sequence-ID header
+ file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([
+ 'http' => [
+ 'method' => 'POST',
+ 'header' => "X-Sequence-ID: $messageId",
+ 'content' => 'Download complete'
+ ]
+ ]));
+ ```
+
+You can also use a **custom sequence ID** (e.g., a download ID, job ID, etc.) when publishing the first message.
+**This is less cumbersome**, since you don't need to capture the message ID first. Just publish directly to
+`//`:
+
+=== "Command line (curl)"
+ ```bash
+ # Publish with a custom sequence ID
+ curl -d "Downloading file..." ntfy.sh/mytopic/my-download-123
+
+ # Update using the same sequence ID (via URL path)
+ curl -d "Download 50% ..." ntfy.sh/mytopic/my-download-123
+
+ # Or update using the X-Sequence-ID header
+ curl -H "X-Sequence-ID: my-download-123" -d "Download complete" ntfy.sh/mytopic
+ ```
+
+=== "ntfy CLI"
+ ```bash
+ # Publish with a sequence ID
+ ntfy pub --sequence-id=my-download-123 mytopic "Downloading file..."
+
+ # Update using the same sequence ID
+ ntfy pub --sequence-id=my-download-123 mytopic "Download 50% ..."
+
+ # Update again
+ ntfy pub -S my-download-123 mytopic "Download complete"
+ ```
+
+=== "HTTP"
+ ``` http
+ # Publish a message with a custom sequence ID
+ POST /mytopic/my-download-123 HTTP/1.1
+ Host: ntfy.sh
+
+ Downloading file...
+
+ # Update again using the X-Sequence-ID header
+ POST /mytopic HTTP/1.1
+ Host: ntfy.sh
+ X-Sequence-ID: my-download-123
+
+ Download complete
+ ```
+
+=== "JavaScript"
+ ``` javascript
+ // First message
+ await fetch('https://ntfy.sh/mytopic/my-download-123', {
+ method: 'POST',
+ body: 'Downloading file...'
+ });
+
+ // Update via URL path
+ await fetch('https://ntfy.sh/mytopic/my-download-123', {
+ method: 'POST',
+ body: 'Download 50% ...'
+ });
+
+ // Or update using the X-Sequence-ID header
+ await fetch('https://ntfy.sh/mytopic', {
+ method: 'POST',
+ headers: { 'X-Sequence-ID': 'my-download-123' },
+ body: 'Download complete'
+ });
+ ```
+
+=== "Go"
+ ``` go
+ // Publish with sequence ID in URL path
+ http.Post("https://ntfy.sh/mytopic/my-download-123", "text/plain",
+ strings.NewReader("Downloading file..."))
+
+ // Update via URL path
+ http.Post("https://ntfy.sh/mytopic/my-download-123", "text/plain",
+ strings.NewReader("Download 50% ..."))
+
+ // Or update using the X-Sequence-ID header
+ req, _ := http.NewRequest("POST", "https://ntfy.sh/mytopic",
+ strings.NewReader("Download complete"))
+ req.Header.Set("X-Sequence-ID", "my-download-123")
+ http.DefaultClient.Do(req)
+ ```
+
+=== "PowerShell"
+ ``` powershell
+ # Publish with sequence ID
+ Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic/my-download-123" -Body "Downloading file..."
+
+ # Update via URL path
+ Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic/my-download-123" -Body "Download 50% ..."
+
+ # Or update using the X-Sequence-ID header
+ Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic" `
+ -Headers @{"X-Sequence-ID"="my-download-123"} -Body "Download complete"
+ ```
+
+=== "Python"
+ ``` python
+ import requests
+
+ # Publish with sequence ID
+ requests.post("https://ntfy.sh/mytopic/my-download-123", data="Downloading file...")
+
+ # Update via URL path
+ requests.post("https://ntfy.sh/mytopic/my-download-123", data="Download 50% ...")
+
+ # Or update using the X-Sequence-ID header
+ requests.post("https://ntfy.sh/mytopic",
+ headers={"X-Sequence-ID": "my-download-123"}, data="Download complete")
+ ```
+
+=== "PHP"
+ ``` php-inline
+ // Publish with sequence ID
+ file_get_contents('https://ntfy.sh/mytopic/my-download-123', false, stream_context_create([
+ 'http' => ['method' => 'POST', 'content' => 'Downloading file...']
+ ]));
+
+ // Update via URL path
+ file_get_contents('https://ntfy.sh/mytopic/my-download-123', false, stream_context_create([
+ 'http' => ['method' => 'POST', 'content' => 'Download 50% ...']
+ ]));
+
+ // Or update using the X-Sequence-ID header
+ file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([
+ 'http' => [
+ 'method' => 'POST',
+ 'header' => 'X-Sequence-ID: my-download-123',
+ 'content' => 'Download complete'
+ ]
+ ]));
+ ```
+
+You can also set the sequence ID via the `sequence-id` [query parameter](#list-of-all-parameters), or when
+[publishing as JSON](#publish-as-json) using the `sequence_id` field.
+
+If the message ID (`id`) and the sequence ID (`sequence_id`) are different, the ntfy server will include the `sequence_id`
+field the response. A sequence of updates may look like this (first example from above):
+
+```json
+{"id":"xE73Iyuabi","time":1673542291,"event":"message","topic":"mytopic","message":"Downloading file..."}
+{"id":"yF84Jzvbcj","time":1673542295,"event":"message","topic":"mytopic","sequence_id":"xE73Iyuabi","message":"Download 50% ..."}
+{"id":"zG95Kawdde","time":1673542300,"event":"message","topic":"mytopic","sequence_id":"xE73Iyuabi","message":"Download complete"}
+```
+
+### Clearing notifications
+Clearing a notification means **marking it as read and dismissing it from the notification drawer**.
+
+To do this, send a PUT request to the `///clear` endpoint (or `///read` as an alias).
+This will then emit a `message_clear` event that is used by the clients (web app and Android app) to update the read status
+and dismiss the notification.
+
+=== "Command line (curl)"
+ ```bash
+ curl -X PUT ntfy.sh/mytopic/my-download-123/clear
+ ```
+
+=== "HTTP"
+ ``` http
+ PUT /mytopic/my-download-123/clear HTTP/1.1
+ Host: ntfy.sh
+ ```
+
+=== "JavaScript"
+ ``` javascript
+ await fetch('https://ntfy.sh/mytopic/my-download-123/clear', {
+ method: 'PUT'
+ });
+ ```
+
+=== "Go"
+ ``` go
+ req, _ := http.NewRequest("PUT", "https://ntfy.sh/mytopic/my-download-123/clear", nil)
+ http.DefaultClient.Do(req)
+ ```
+
+=== "PowerShell"
+ ``` powershell
+ Invoke-RestMethod -Method PUT -Uri "https://ntfy.sh/mytopic/my-download-123/clear"
+ ```
+
+=== "Python"
+ ``` python
+ requests.put("https://ntfy.sh/mytopic/my-download-123/clear")
+ ```
+
+=== "PHP"
+ ``` php-inline
+ file_get_contents('https://ntfy.sh/mytopic/my-download-123/clear', false, stream_context_create([
+ 'http' => ['method' => 'PUT']
+ ]));
+ ```
+
+An example response from the server with the `message_clear` event may look like this:
+
+```json
+{"id":"jkl012","time":1673542305,"event":"message_clear","topic":"mytopic","sequence_id":"my-download-123"}
+```
+
+### Deleting notifications
+Deleting a notification means **removing it from the notification drawer and from the client's database**.
+
+To do this, send a DELETE request to the `//` endpoint. This will emit a `message_delete` event
+that is used by the clients (web app and Android app) to remove the notification entirely.
+
+=== "Command line (curl)"
+ ```bash
+ curl -X DELETE ntfy.sh/mytopic/my-download-123
+ ```
+
+=== "HTTP"
+ ``` http
+ DELETE /mytopic/my-download-123 HTTP/1.1
+ Host: ntfy.sh
+ ```
+
+=== "JavaScript"
+ ``` javascript
+ await fetch('https://ntfy.sh/mytopic/my-download-123', {
+ method: 'DELETE'
+ });
+ ```
+
+=== "Go"
+ ``` go
+ req, _ := http.NewRequest("DELETE", "https://ntfy.sh/mytopic/my-download-123", nil)
+ http.DefaultClient.Do(req)
+ ```
+
+=== "PowerShell"
+ ``` powershell
+ Invoke-RestMethod -Method DELETE -Uri "https://ntfy.sh/mytopic/my-download-123"
+ ```
+
+=== "Python"
+ ``` python
+ requests.delete("https://ntfy.sh/mytopic/my-download-123")
+ ```
+
+=== "PHP"
+ ``` php-inline
+ file_get_contents('https://ntfy.sh/mytopic/my-download-123', false, stream_context_create([
+ 'http' => ['method' => 'DELETE']
+ ]));
+ ```
+
+An example response from the server with the `message_delete` event may look like this:
+
+```json
+{"id":"mno345","time":1673542400,"event":"message_delete","topic":"mytopic","sequence_id":"my-download-123"}
+```
+
+!!! info
+ Deleted sequences can be revived by publishing a new message with the same sequence ID. The notification will
+ reappear as a new message.
+
## Authentication
Depending on whether the server is configured to support [access control](config.md#access-control), some topics
may be read/write protected so that only users with the correct credentials can subscribe or publish to them.
@@ -3931,6 +4595,7 @@ table in their canonical form.
|-----------------|--------------------------------------------|-----------------------------------------------------------------------------------------------|
| `X-Message` | `Message`, `m` | Main body of the message as shown in the notification |
| `X-Title` | `Title`, `t` | [Message title](#message-title) |
+| `X-Sequence-ID` | `Sequence-ID`, `SID` | [Sequence ID](#updating-deleting-notifications) for updating/clearing/deleting notifications |
| `X-Priority` | `Priority`, `prio`, `p` | [Message priority](#message-priority) |
| `X-Tags` | `Tags`, `Tag`, `ta` | [Tags and emojis](#tags-emojis) |
| `X-Delay` | `Delay`, `X-At`, `At`, `X-In`, `In` | Timestamp or duration for [delayed delivery](#scheduled-delivery) |
diff --git a/docs/publish/template-functions.md b/docs/publish/template-functions.md
index 79848080..53026627 100644
--- a/docs/publish/template-functions.md
+++ b/docs/publish/template-functions.md
@@ -1174,7 +1174,7 @@ keys $myDict | sortAlpha
```
When supplying multiple dictionaries, the keys will be concatenated. Use the `uniq`
-function along with `sortAlpha` to get a unqiue, sorted list of keys.
+function along with `sortAlpha` to get a unique, sorted list of keys.
```
keys $myDict $myOtherDict | uniq | sortAlpha
diff --git a/docs/releases.md b/docs/releases.md
index afcd849a..254c92af 100644
--- a/docs/releases.md
+++ b/docs/releases.md
@@ -4,14 +4,107 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Current stable releases
-| Component | Version | Release date |
-|------------------------------------------|---------|--------------|
-| ntfy server | v2.15.0 | Nov 16, 2025 |
-| ntfy Android app (_is being rolled out_) | v1.20.0 | Dec 28, 2025 |
-| ntfy iOS app | v1.3 | Nov 26, 2023 |
+| Component | Version | Release date |
+|------------------|---------|--------------|
+| ntfy server | v2.16.0 | Jan 19, 2026 |
+| ntfy Android app | v1.22.1 | Jan 20, 2026 |
+| ntfy iOS app | v1.3 | Nov 26, 2023 |
Please check out the release notes for [upcoming releases](#not-released-yet) below.
+### ntfy Android app v1.22.1
+Released January 20, 2026
+
+This release adds support for [updating and deleting notifications](publish.md#updating--deleting-notifications) (requires server v2.16.0),
+as well as [certificate management for self-signed certs and mTLS client certificates](subscribe/phone.md#manage-certificates),
+and a new connection error dialog to help [troubleshoot connection issues](subscribe/phone.md#troubleshooting).
+
+
+
+
+
+
+
+
+
+
+
+
+**Features:**
+
+* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications)
+ ([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536),
+ [ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8)
+ for the initial implementation)
+* Support for self-signed certs and client certs for mTLS ([#215](https://github.com/binwiederhier/ntfy/issues/215),
+ [#530](https://github.com/binwiederhier/ntfy/issues/530), [ntfy-android#149](https://github.com/binwiederhier/ntfy-android/pull/149),
+ thanks to [@cyb3rko](https://github.com/cyb3rko) for reviewing)
+* Connection error dialog to help diagnose connection issues
+
+**Bug fixes + maintenance:**
+
+* Use server-specific user for attachment downloads ([#1529](https://github.com/binwiederhier/ntfy/issues/1529),
+ thanks to [@ManInDark](https://github.com/ManInDark) for reporting and testing)
+* Fix crash in sharing dialog (thanks to [@rogeliodh](https://github.com/rogeliodh))
+* Fix crash when exiting multi-delete in detail view
+* Fix potential crashes with icon downloader and backuper
+
+## ntfy server v2.16.0
+Released January 19, 2026
+
+This release adds support for updating and deleting notifications, heartbeat-style / dead man's switch notifications,
+custom Twilio call formats, and makes `ntfy serve` work on Windows. It also adds a "New version available" banner to the web app.
+
+This one is very exciting, as it brings a lot of highly requested features to ntfy.
+
+**Features:**
+
+* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications) ([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536),
+ [ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) for the initial implementation)
+* Support for heartbeat-style / [dead man's switch](https://en.wikipedia.org/wiki/Dead_man%27s_switch) notifications aka
+ [updating and deleting scheduled notifications](publish.md#scheduled-delivery) ([#1556](https://github.com/binwiederhier/ntfy/pull/1556),
+ [#1142](https://github.com/binwiederhier/ntfy/pull/1142), [#954](https://github.com/binwiederhier/ntfy/issues/954),
+ thanks to [@GamerGirlandCo](https://github.com/GamerGirlandCo) for the initial implementation)
+* Configure [custom Twilio call format](config.md#phone-calls) for phone calls ([#1289](https://github.com/binwiederhier/ntfy/pull/1289), thanks to [@mmichaa](https://github.com/mmichaa) for the initial implementation)
+* `ntfy serve` now works on Windows, including support for running it as a Windows service ([#1104](https://github.com/binwiederhier/ntfy/issues/1104),
+ [#1552](https://github.com/binwiederhier/ntfy/pull/1552), originally [#1328](https://github.com/binwiederhier/ntfy/pull/1328),
+ thanks to [@wtf911](https://github.com/wtf911))
+* Web app: "New version available" banner ([#1554](https://github.com/binwiederhier/ntfy/pull/1554))
+
+## ntfy Android app v1.21.1
+Released January 6, 2026
+
+This is the first feature release in a long time. After all the SDK updates, fixes to comply with the Google Play policies
+and the framework updates, this release ships a lot of highly requested features: Sending messages through the app (WhatsApp-style),
+support for passing headers to your proxy, an in-app language switcher, and more.
+
+
+
+
+
+
+
+
+If you are waiting for a feature, please 👍 the corresponding [GitHub issue](https://github.com/binwiederhier/ntfy/issues?q=is%3Aissue%20state%3Aopen%20sort%3Areactions-%2B1-desc).
+If you like ntfy, please consider purchasing [ntfy Pro](https://ntfy.sh/app) to support us.
+
+**Features:**
+
+* Allow publishing messages through the message bar and publish dialog ([#98](https://github.com/binwiederhier/ntfy/issues/98), [ntfy-android#144](https://github.com/binwiederhier/ntfy-android/pull/144))
+* Define custom HTTP headers to support authenticated proxies, tunnels and SSO ([ntfy-android#116](https://github.com/binwiederhier/ntfy-android/issues/116), [#1018](https://github.com/binwiederhier/ntfy/issues/1018), [ntfy-android#132](https://github.com/binwiederhier/ntfy-android/pull/132), [ntfy-android#146](https://github.com/binwiederhier/ntfy-android/pull/146), thanks to [@CrazyWolf13](https://github.com/CrazyWolf13))
+* Implement UnifiedPush "raise to foreground" requirement ([ntfy-android#98](https://github.com/binwiederhier/ntfy-android/pull/98), [ntfy-android#148](https://github.com/binwiederhier/ntfy-android/pull/148), thanks to [@p1gp1g](https://github.com/p1gp1g))
+* Language selector to allow overriding the system language ([#1508](https://github.com/binwiederhier/ntfy/issues/1508), [ntfy-android#145](https://github.com/binwiederhier/ntfy-android/pull/145), thanks to [@hudsonm62](https://github.com/hudsonm62) for reporting)
+* Highlight phone numbers and email addresses in notifications ([#957](https://github.com/binwiederhier/ntfy/issues/957), [ntfy-android#71](https://github.com/binwiederhier/ntfy-android/pull/71), thanks to [@brennenputh](https://github.com/brennenputh), and [@XylenSky](https://github.com/XylenSky) for reporting)
+* Support for port and display name in [ntfy://](subscribe/phone.md#ntfy-links) links ([ntfy-android#130](https://github.com/binwiederhier/ntfy-android/pull/130), thanks to [@godovski](https://github.com/godovski))
+
+**Bug fixes + maintenance:**
+
+* Add support for (technically incorrect) 'image/jpg' MIME type ([ntfy-android#142](https://github.com/binwiederhier/ntfy-android/pull/142), thanks to [@Murilobeluco](https://github.com/Murilobeluco))
+* Unify "copy to clipboard" notifications, use Android 13 style ([ntfy-android#61](https://github.com/binwiederhier/ntfy-android/pull/61), thanks to [@thgoebel](https://github.com/thgoebel))
+* Fix crash in user add dialog (onAddUser)
+* Fix ForegroundServiceDidNotStartInTimeException (attempt 2, see [#1520](https://github.com/binwiederhier/ntfy/issues/1520))
+* Hide "Exact alarms" setting if battery optimization exemption has been granted ([#1456](https://github.com/binwiederhier/ntfy/issues/1456), thanks for reporting [@HappyLer](https://github.com/HappyLer))
+
## ntfy Android app v1.20.0
Released December 28, 2025
@@ -1572,21 +1665,4 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet
-### ntfy Android app v1.21.1-rc1 (IN TESTING)
-
-**Features:**
-
-* Allow publishing messages through the message bar and publish dialog ([#98](https://github.com/binwiederhier/ntfy/issues/98), [ntfy-android#144](https://github.com/binwiederhier/ntfy-android/pull/144))
-* Define custom HTTP headers to support authenticated proxies, tunnels and SSO ([ntfy-android#116](https://github.com/binwiederhier/ntfy-android/issues/116), [#1018](https://github.com/binwiederhier/ntfy/issues/1018), [ntfy-android#132](https://github.com/binwiederhier/ntfy-android/pull/132), [ntfy-android#146](https://github.com/binwiederhier/ntfy-android/pull/146), thanks to [@CrazyWolf13](https://github.com/CrazyWolf13))
-* Implement UnifiedPush "raise to foreground" requirement ([ntfy-android#98](https://github.com/binwiederhier/ntfy-android/pull/98), [ntfy-android#148](https://github.com/binwiederhier/ntfy-android/pull/148), thanks to [@p1gp1g](https://github.com/p1gp1g))
-* Language selector to allow overriding the system language ([#1508](https://github.com/binwiederhier/ntfy/issues/1508), [ntfy-android#145](https://github.com/binwiederhier/ntfy-android/pull/145), thanks to [@hudsonm62](https://github.com/hudsonm62) for reporting)
-* Highlight phone numbers and email addresses in notifications ([#957](https://github.com/binwiederhier/ntfy/issues/957), [ntfy-android#71](https://github.com/binwiederhier/ntfy-android/pull/71), thanks to [@brennenputh](https://github.com/brennenputh), and [@XylenSky](https://github.com/XylenSky) for reporting)
-* Support for port and display name in [ntfy://](subscribe/phone.md#ntfy-links) links ([ntfy-android#130](https://github.com/binwiederhier/ntfy-android/pull/130), thanks to [@godovski](https://github.com/godovski))
-
-**Bug fixes + maintenance:**
-
-* Add support for (technically incorrect) 'image/jpg' MIME type ([ntfy-android#142](https://github.com/binwiederhier/ntfy-android/pull/142), thanks to [@Murilobeluco](https://github.com/Murilobeluco))
-* Unify "copy to clipboard" notifications, use Android 13 style ([ntfy-android#61](https://github.com/binwiederhier/ntfy-android/pull/61), thanks to [@thgoebel](https://github.com/thgoebel))
-* Fix crash in user add dialog (onAddUser)
-* Fix ForegroundServiceDidNotStartInTimeException (attempt 2, see [#1520](https://github.com/binwiederhier/ntfy/issues/1520))
-* Hide "Exact alarms" setting if battery optimization exemption has been granted ([#1456](https://github.com/binwiederhier/ntfy/issues/1456), thanks for reporting [@HappyLer](https://github.com/HappyLer))
+_Nothing here_
\ No newline at end of file
diff --git a/docs/static/css/extra.css b/docs/static/css/extra.css
index 3c53aed6..fb44f4cc 100644
--- a/docs/static/css/extra.css
+++ b/docs/static/css/extra.css
@@ -1,10 +1,10 @@
:root > * {
- --md-primary-fg-color: #338574;
+ --md-primary-fg-color: #338574;
--md-primary-fg-color--light: #338574;
- --md-primary-fg-color--dark: #338574;
- --md-footer-bg-color: #353744;
- --md-text-font: "Roboto";
- --md-code-font: "Roboto Mono";
+ --md-primary-fg-color--dark: #338574;
+ --md-footer-bg-color: #353744;
+ --md-text-font: "Roboto";
+ --md-code-font: "Roboto Mono";
}
.md-header__button.md-logo :is(img, svg) {
@@ -34,7 +34,7 @@ figure img, figure video {
}
header {
- background: linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%);
+ background: linear-gradient(150deg, rgba(51, 133, 116, 1) 0%, rgba(86, 189, 168, 1) 100%);
}
body[data-md-color-scheme="default"] header {
@@ -92,8 +92,8 @@ figure video {
}
.screenshots img {
- max-height: 230px;
- max-width: 300px;
+ max-height: 350px;
+ max-width: 350px;
margin: 3px;
border-radius: 5px;
filter: drop-shadow(2px 2px 2px #ddd);
@@ -107,7 +107,7 @@ figure video {
opacity: 0;
visibility: hidden;
position: fixed;
- left:0;
+ left: 0;
right: 0;
top: 0;
bottom: 0;
@@ -119,7 +119,7 @@ figure video {
}
.lightbox.show {
- background-color: rgba(0,0,0, 0.75);
+ background-color: rgba(0, 0, 0, 0.75);
opacity: 1;
visibility: visible;
z-index: 1000;
@@ -214,3 +214,30 @@ figure video {
font-weight: 400;
src: url('../fonts/roboto-mono-v22-latin-regular.woff2') format('woff2');
}
+
+/* Community maintained badge */
+.community-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35em;
+ background-color: rgba(51, 133, 116, 0.1);
+ border: 1px solid rgba(51, 133, 116, 0.3);
+ border-radius: 0.7em;
+ padding: 0.1em 0.7em;
+ font-size: 0.75rem;
+ color: #338574;
+ margin-top: 0;
+ margin-bottom: 0.5em;
+}
+
+.community-badge svg {
+ width: 1em;
+ height: 1em;
+ fill: currentColor;
+}
+
+body[data-md-color-scheme="slate"] .community-badge {
+ background-color: rgba(86, 189, 168, 0.15);
+ border-color: rgba(86, 189, 168, 0.4);
+ color: #56bda8;
+}
diff --git a/docs/static/img/android-screenshot-certs-manage.jpg b/docs/static/img/android-screenshot-certs-manage.jpg
new file mode 100644
index 00000000..613e9745
Binary files /dev/null and b/docs/static/img/android-screenshot-certs-manage.jpg differ
diff --git a/docs/static/img/android-screenshot-certs-warning-dialog.jpg b/docs/static/img/android-screenshot-certs-warning-dialog.jpg
new file mode 100644
index 00000000..e92105e9
Binary files /dev/null and b/docs/static/img/android-screenshot-certs-warning-dialog.jpg differ
diff --git a/docs/static/img/android-screenshot-connection-error-dialog.jpg b/docs/static/img/android-screenshot-connection-error-dialog.jpg
new file mode 100644
index 00000000..a004ad73
Binary files /dev/null and b/docs/static/img/android-screenshot-connection-error-dialog.jpg differ
diff --git a/docs/static/img/android-screenshot-connection-error-warning.jpg b/docs/static/img/android-screenshot-connection-error-warning.jpg
new file mode 100644
index 00000000..a019046f
Binary files /dev/null and b/docs/static/img/android-screenshot-connection-error-warning.jpg differ
diff --git a/docs/static/img/android-screenshot-custom-headers-add.jpg b/docs/static/img/android-screenshot-custom-headers-add.jpg
new file mode 100644
index 00000000..3a1e7d89
Binary files /dev/null and b/docs/static/img/android-screenshot-custom-headers-add.jpg differ
diff --git a/docs/static/img/android-screenshot-custom-headers.jpg b/docs/static/img/android-screenshot-custom-headers.jpg
new file mode 100644
index 00000000..090071f9
Binary files /dev/null and b/docs/static/img/android-screenshot-custom-headers.jpg differ
diff --git a/docs/static/img/android-screenshot-language-chinese.jpg b/docs/static/img/android-screenshot-language-chinese.jpg
new file mode 100644
index 00000000..f31cc9cc
Binary files /dev/null and b/docs/static/img/android-screenshot-language-chinese.jpg differ
diff --git a/docs/static/img/android-screenshot-language-german.jpg b/docs/static/img/android-screenshot-language-german.jpg
new file mode 100644
index 00000000..254f4999
Binary files /dev/null and b/docs/static/img/android-screenshot-language-german.jpg differ
diff --git a/docs/static/img/android-screenshot-language-hebrew.jpg b/docs/static/img/android-screenshot-language-hebrew.jpg
new file mode 100644
index 00000000..c4235666
Binary files /dev/null and b/docs/static/img/android-screenshot-language-hebrew.jpg differ
diff --git a/docs/static/img/android-screenshot-language-selection.jpg b/docs/static/img/android-screenshot-language-selection.jpg
new file mode 100644
index 00000000..f39befde
Binary files /dev/null and b/docs/static/img/android-screenshot-language-selection.jpg differ
diff --git a/docs/static/img/android-screenshot-notification-update-1.png b/docs/static/img/android-screenshot-notification-update-1.png
new file mode 100644
index 00000000..16320de4
Binary files /dev/null and b/docs/static/img/android-screenshot-notification-update-1.png differ
diff --git a/docs/static/img/android-screenshot-notification-update-2.png b/docs/static/img/android-screenshot-notification-update-2.png
new file mode 100644
index 00000000..e94c4f21
Binary files /dev/null and b/docs/static/img/android-screenshot-notification-update-2.png differ
diff --git a/docs/static/img/android-screenshot-publish-dialog.jpg b/docs/static/img/android-screenshot-publish-dialog.jpg
new file mode 100644
index 00000000..6d1e2f6c
Binary files /dev/null and b/docs/static/img/android-screenshot-publish-dialog.jpg differ
diff --git a/docs/static/img/android-screenshot-publish-message-bar.jpg b/docs/static/img/android-screenshot-publish-message-bar.jpg
new file mode 100644
index 00000000..12957400
Binary files /dev/null and b/docs/static/img/android-screenshot-publish-message-bar.jpg differ
diff --git a/docs/subscribe/api.md b/docs/subscribe/api.md
index a52e17f6..a549885b 100644
--- a/docs/subscribe/api.md
+++ b/docs/subscribe/api.md
@@ -324,20 +324,21 @@ format of the message. It's very straight forward:
**Message**:
-| Field | Required | Type | Example | Description |
-|--------------|----------|---------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
-| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
-| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp |
-| `expires` | (✔)️ | *number* | `1673542291` | Unix time stamp indicating when the message will be deleted, not set if `Cache: no` is sent |
-| `event` | ✔️ | `open`, `keepalive`, `message`, or `poll_request` | `message` | Message type, typically you'd be only interested in `message` |
-| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events |
-| `message` | - | *string* | `Some message` | Message body; always present in `message` events |
-| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/` |
-| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis |
-| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
-| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) |
-| `actions` | - | *JSON array* | *see [actions buttons](../publish.md#action-buttons)* | [Action buttons](../publish.md#action-buttons) that can be displayed in the notification |
-| `attachment` | - | *JSON object* | *see below* | Details about an attachment (name, URL, size, ...) |
+| Field | Required | Type | Example | Description |
+|---------------|----------|---------------------------------------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
+| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
+| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp |
+| `expires` | (✔)️ | *number* | `1673542291` | Unix time stamp indicating when the message will be deleted, not set if `Cache: no` is sent |
+| `event` | ✔️ | `open`, `keepalive`, `message`, `message_delete`, `message_clear`, `poll_request` | `message` | Message type, typically you'd be only interested in `message` |
+| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events |
+| `sequence_id` | - | *string* | `my-sequence-123` | Sequence ID for [updating/deleting notifications](../publish.md#updating-deleting-notifications) |
+| `message` | - | *string* | `Some message` | Message body; always present in `message` events |
+| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/` |
+| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis |
+| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
+| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) |
+| `actions` | - | *JSON array* | *see [actions buttons](../publish.md#action-buttons)* | [Action buttons](../publish.md#action-buttons) that can be displayed in the notification |
+| `attachment` | - | *JSON object* | *see below* | Details about an attachment (name, URL, size, ...) |
**Attachment** (part of the message, see [attachments](../publish.md#attachments) for details):
diff --git a/docs/subscribe/phone.md b/docs/subscribe/phone.md
index 3015be88..a022b2a7 100644
--- a/docs/subscribe/phone.md
+++ b/docs/subscribe/phone.md
@@ -100,7 +100,24 @@ The reason for this is [Firebase Cloud Messaging (FCM)](https://firebase.google.
notifications. Firebase is overall pretty bad at delivering messages in time, but on Android, most apps are stuck with it.
The ntfy Android app uses Firebase only for the main host `ntfy.sh`, and only in the Google Play flavor of the app.
-It won't use Firebase for any self-hosted servers, and not at all in the the F-Droid flavor.
+It won't use Firebase for any self-hosted servers, and not at all in the F-Droid flavor.
+
+## Publishing messages
+_Supported on:_ :material-android:
+
+The Android app allows you to **publish messages directly from the app**, without needing to use curl or any other
+tool. When enabled in the settings (Settings → General → Show message bar), a **message bar** appears at the bottom
+of the topic view (it's enabled by default). You can type a message and tap the send button to publish it instantly.
+If the message bar is disabled, you can tap the floating action button (FAB) at the bottom right instead.
+
+For more options, tap the expand button next to the send button to open the full **publish dialog**. The dialog lets
+you compose a full notification with all available options, including title, tags, priority, click URL, email
+forwarding, delayed delivery, attachments, Markdown formatting, and phone calls.
+
+
+
+
+
## Share to topic
_Supported on:_ :material-android:
@@ -135,6 +152,67 @@ or to simply directly link to a topic from a mobile website.
| `ntfy:///?display=` | `ntfy://ntfy.sh/mytopic?display=My+Topic` | Same as above, but also defines a display name for the topic. |
| `ntfy:///?secure=false` | `ntfy://example.com/mytopic?secure=false` | Same as above, except that this will use HTTP instead of HTTPS as topic URL. This is equivalent to the web view `http://example.com/mytopic` (HTTP!) |
+## Advanced settings
+
+### Custom headers
+_Supported on:_ :material-android:
+
+If your ntfy server is behind an **authenticated proxy or tunnel** (e.g., Cloudflare Access, Tailscale Funnel, or
+a reverse proxy with basic auth), you can configure custom HTTP headers that will be sent with every request to
+that server. You could set headers such as `Authorization`, `CF-Access-Client-Id`, or any other headers required by
+your setup. To add custom headers, go to **Settings → Advanced → Custom headers**.
+
+
+
+
+
+
+!!! warning
+ If you have a user configured for a server, you cannot add an `Authorization` header for that server, as ntfy
+ sets this header automatically. Similarly, if you have a custom `Authorization` header, you cannot add a user
+ for that server.
+
+### Manage certificates
+_Supported on:_ :material-android:
+
+If you're running a self-hosted ntfy server with a **self-signed certificate** or need to use **mutual TLS (mTLS)**
+for client authentication, you can manage certificates in the app settings.
+
+Go to **Settings → Advanced → Manage certificates** to:
+
+- **Add trusted certificates**: Import a server certificate (PEM format) to trust when connecting to your ntfy server.
+ This is useful for self-signed certificates that are not trusted by the Android system.
+- **Add client certificates**: Import a client certificate (PKCS#12 format) for mutual TLS authentication. This
+ certificate will be presented to the server when connecting.
+
+When you subscribe to a topic on a server with an untrusted certificate, the app will show a security warning and
+allow you to review and trust the certificate.
+
+
+
+
+
+
+### Language
+_Supported on:_ :material-android:
+
+The Android app supports many languages and uses the **system language by default**. If you'd like to use the app in
+a different language than your system, you can override it in **Settings → General → Language**.
+
+
+
+
+
+
+
+
+The app currently supports over 30 languages, including English, German, French, Spanish, Chinese, Japanese, and many
+more. Languages with more than 80% of strings translated are shown in the language picker.
+
+!!! tip "Help translate ntfy"
+ If you'd like to help translate ntfy into your language or improve existing translations, please visit the
+ [ntfy Weblate project](https://hosted.weblate.org/projects/ntfy/). Contributions are very welcome!
+
## Integrations
### UnifiedPush
@@ -168,10 +246,13 @@ Here's an example using [MacroDroid](https://play.google.com/store/apps/details?
and [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm), but any app that can catch
broadcasts is supported:
-
+
+
+
+
@@ -239,3 +320,29 @@ The following intent extras are supported when for the intent with the `io.hecke
| `message` ❤️ | ✔ | *String* | `Some message` | Message body; **you must set this** |
| `tags` | - | *String* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
| `priority` | - | *String or Int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
+
+## Troubleshooting
+
+### Connection error dialog
+_Supported on:_ :material-android:
+
+If the app has trouble connecting to a ntfy server, a **warning icon** will appear in the app bar. Tapping it opens
+the **connection error dialog**, which shows detailed information about the connection problem and helps you diagnose
+the issue.
+
+
+
+
+
+
+Common connection errors include:
+
+| Error | Description |
+|-------|-------------|
+| Connection refused | The server may be down or the address may be incorrect |
+| WebSocket not supported | The server may not support WebSocket connections, or a proxy is blocking them |
+| Not authorized (401/403) | Username/password may be incorrect, or access credentials have expired |
+| Certificate not trusted | The server is using a self-signed certificate (see [Manage certificates](#manage-certificates)) |
+
+If you're having persistent connection issues, you can also check the app logs under **Settings → Advanced → Record logs**
+and share them for debugging.
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index 3d090306..9215bad2 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -129,3 +129,15 @@ keyboard.
## iOS app
Sorry, there is no way to debug or get the logs from the iOS app (yet), outside of running the app in Xcode.
+
+## Other
+
+### "Reconnecting..." / Late notifications on mobile (self-hosted)
+
+If all of your topics are showing as "Reconnecting" and notifications are taking a long time (30+ minutes) to come in, or if you're only getting new pushes with a manual refresh, double-check your configuration:
+
+* If ntfy is behind a reverse proxy (such as Nginx):
+ * Make sure `behind-proxy` is enabled in ntfy's config.
+ * Make sure WebSockets are enabled in the reverse proxy config.
+* Make sure you have granted permission to access all of your topics, either to a logged-in user account or to `everyone`. All subscribed topics are joined into a single WebSocket/JSON request, so a single topic that receives `403 Forbidden` will prevent the entire request from going through.
+ * In particular, double-check that `everyone` has permission to write to `up*` and your user has permission to read `up*` if you are using UnifiedPush.
diff --git a/go.mod b/go.mod
index e6d91101..1f7f6b4c 100644
--- a/go.mod
+++ b/go.mod
@@ -5,23 +5,23 @@ go 1.24.0
toolchain go1.24.5
require (
- cloud.google.com/go/firestore v1.20.0 // indirect
- cloud.google.com/go/storage v1.58.0 // indirect
+ cloud.google.com/go/firestore v1.21.0 // indirect
+ cloud.google.com/go/storage v1.59.1 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/emersion/go-smtp v0.18.0
github.com/gabriel-vasile/mimetype v1.4.12
github.com/gorilla/websocket v1.5.3
- github.com/mattn/go-sqlite3 v1.14.32
+ github.com/mattn/go-sqlite3 v1.14.33
github.com/olebedev/when v1.1.0
github.com/stretchr/testify v1.11.1
github.com/urfave/cli/v2 v2.27.7
- golang.org/x/crypto v0.46.0
+ golang.org/x/crypto v0.47.0
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.19.0
- golang.org/x/term v0.38.0
+ golang.org/x/term v0.39.0
golang.org/x/time v0.14.0
- google.golang.org/api v0.258.0
+ google.golang.org/api v0.260.0
gopkg.in/yaml.v2 v2.4.0
)
@@ -35,7 +35,8 @@ require (
github.com/microcosm-cc/bluemonday v1.0.27
github.com/prometheus/client_golang v1.23.2
github.com/stripe/stripe-go/v74 v74.30.0
- golang.org/x/text v0.32.0
+ golang.org/x/sys v0.40.0
+ golang.org/x/text v0.33.0
)
require (
@@ -45,7 +46,7 @@ require (
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.5.3 // indirect
- cloud.google.com/go/longrunning v0.7.0 // indirect
+ cloud.google.com/go/longrunning v0.8.0 // indirect
cloud.google.com/go/monitoring v1.24.3 // indirect
github.com/AlekSi/pointer v1.2.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect
@@ -69,14 +70,14 @@ require (
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
- github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
+ github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
- github.com/prometheus/common v0.67.4 // indirect
+ github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
@@ -92,13 +93,12 @@ require (
go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
- golang.org/x/net v0.48.0 // indirect
- golang.org/x/sys v0.39.0 // indirect
+ golang.org/x/net v0.49.0 // indirect
google.golang.org/appengine/v2 v2.0.6 // indirect
- google.golang.org/genproto v0.0.0-20251213004720-97cd9d5aeac2 // indirect
- google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
- google.golang.org/grpc v1.77.0 // indirect
+ google.golang.org/genproto v0.0.0-20260114163908-3f89685c29c3 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 // indirect
+ google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index babf7049..77e16c2b 100644
--- a/go.sum
+++ b/go.sum
@@ -8,18 +8,18 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
-cloud.google.com/go/firestore v1.20.0 h1:JLlT12QP0fM2SJirKVyu2spBCO8leElaW0OOtPm6HEo=
-cloud.google.com/go/firestore v1.20.0/go.mod h1:jqu4yKdBmDN5srneWzx3HlKrHFWFdlkgjgQ6BKIOFQo=
+cloud.google.com/go/firestore v1.21.0 h1:BhopUsx7kh6NFx77ccRsHhrtkbJUmDAxNY3uapWdjcM=
+cloud.google.com/go/firestore v1.21.0/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4=
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY=
cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw=
-cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E=
-cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY=
+cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8=
+cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk=
cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
-cloud.google.com/go/storage v1.58.0 h1:PflFXlmFJjG/nBeR9B7pKddLQWaFaRWx4uUi/LyNxxo=
-cloud.google.com/go/storage v1.58.0/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI=
+cloud.google.com/go/storage v1.59.1 h1:DXAZLcTimtiXdGqDSnebROVPd9QvRsFVVlptz02Wk58=
+cloud.google.com/go/storage v1.59.1/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI=
cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
firebase.google.com/go/v4 v4.18.0 h1:S+g0P72oDGqOaG4wlLErX3zQmU9plVdu7j+Bc3R1qFw=
@@ -96,8 +96,8 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
-github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
+github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao=
+github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y=
github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
@@ -112,8 +112,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
-github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
-github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
+github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
@@ -131,8 +131,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
-github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
-github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
+github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
+github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -184,8 +184,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
-golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
-golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
+golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
+golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -200,8 +200,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
-golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
-golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
+golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
+golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -225,8 +225,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
-golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
+golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -236,8 +236,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
-golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
-golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
+golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
+golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -249,8 +249,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
-golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
-golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
+golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
+golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -263,18 +263,18 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
-google.golang.org/api v0.258.0 h1:IKo1j5FBlN74fe5isA2PVozN3Y5pwNKriEgAXPOkDAc=
-google.golang.org/api v0.258.0/go.mod h1:qhOMTQEZ6lUps63ZNq9jhODswwjkjYYguA7fA3TBFww=
+google.golang.org/api v0.260.0 h1:XbNi5E6bOVEj/uLXQRlt6TKuEzMD7zvW/6tNwltE4P4=
+google.golang.org/api v0.260.0/go.mod h1:Shj1j0Phr/9sloYrKomICzdYgsSDImpTxME8rGLaZ/o=
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
-google.golang.org/genproto v0.0.0-20251213004720-97cd9d5aeac2 h1:stRtB2UVzFOWnorVuwF0BVVEjQ3AN6SjHWdg811UIQM=
-google.golang.org/genproto v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0=
-google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 h1:7LRqPCEdE4TP4/9psdaB7F2nhZFfBiGJomA5sojLWdU=
-google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
-google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
-google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
+google.golang.org/genproto v0.0.0-20260114163908-3f89685c29c3 h1:rUamZFBwsWVWg4Yb7iTbwYp81XVHUvOXNdrFCoYRRNE=
+google.golang.org/genproto v0.0.0-20260114163908-3f89685c29c3/go.mod h1:wE6SUYr3iNtF/D0GxVAjT+0CbDFktQNssYs9PVptCt4=
+google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3 h1:X9z6obt+cWRX8XjDVOn+SZWhWe5kZHm46TThU9j+jss=
+google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3/go.mod h1:dd646eSK+Dk9kxVBl1nChEOhJPtMXriCcVb4x3o6J+E=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 h1:C4WAdL+FbjnGlpp2S+HMVhBeCq2Lcib4xZqfPNF6OoQ=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
+google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
+google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
diff --git a/main.go b/main.go
index 4e01a0d6..b9bef369 100644
--- a/main.go
+++ b/main.go
@@ -2,12 +2,14 @@ package main
import (
"fmt"
- "github.com/urfave/cli/v2"
- "heckel.io/ntfy/v2/cmd"
"os"
"runtime"
+
+ "github.com/urfave/cli/v2"
+ "heckel.io/ntfy/v2/cmd"
)
+// These variables are set during build time using -ldflags
var (
version = "dev"
commit = "unknown"
@@ -24,13 +26,24 @@ the Matrix room (https://matrix.to/#/#ntfy:matrix.org).
ntfy %s (%s), runtime %s, built at %s
Copyright (C) Philipp C. Heckel, licensed under Apache License 2.0 & GPLv2
-`, version, commit[:7], runtime.Version(), date)
+`, version, maybeShortCommit(commit), runtime.Version(), date)
app := cmd.New()
app.Version = version
+ app.Metadata = map[string]any{
+ cmd.MetadataKeyDate: date,
+ cmd.MetadataKeyCommit: commit,
+ }
if err := app.Run(os.Args); err != nil {
fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1)
}
}
+
+func maybeShortCommit(commit string) string {
+ if len(commit) > 7 {
+ return commit[:7]
+ }
+ return commit
+}
diff --git a/server/config.go b/server/config.go
index 8e7dcda2..278b6aed 100644
--- a/server/config.go
+++ b/server/config.go
@@ -1,8 +1,13 @@
package server
import (
+ "crypto/sha256"
+ "encoding/json"
+ "fmt"
"io/fs"
"net/netip"
+ "reflect"
+ "text/template"
"time"
"heckel.io/ntfy/v2/user"
@@ -11,8 +16,6 @@ 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!)
@@ -26,6 +29,12 @@ const (
DefaultStripePriceCacheDuration = 3 * time.Hour // Time to keep Stripe prices cached in memory before a refresh is needed
)
+// Platform-specific default paths (set in config_unix.go or config_windows.go)
+var (
+ DefaultConfigFile string
+ DefaultTemplateDir string
+)
+
// Defines default Web Push settings
const (
DefaultWebPushExpiryWarningDuration = 55 * 24 * time.Hour
@@ -128,6 +137,7 @@ type Config struct {
TwilioCallsBaseURL string
TwilioVerifyBaseURL string
TwilioVerifyService string
+ TwilioCallFormat *template.Template
MetricsEnable bool
MetricsListenHTTP string
ProfileListenHTTP string
@@ -173,7 +183,9 @@ type Config struct {
WebPushStartupQueries string
WebPushExpiryDuration time.Duration
WebPushExpiryWarningDuration time.Duration
- Version string // injected by App
+ BuildVersion string // Injected by App
+ BuildDate string // Injected by App
+ BuildCommit string // Injected by App
}
// NewConfig instantiates a default new server config
@@ -226,6 +238,7 @@ func NewConfig() *Config {
TwilioPhoneNumber: "",
TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests
TwilioVerifyService: "",
+ TwilioCallFormat: nil,
MessageSizeLimit: DefaultMessageSizeLimit,
MessageDelayMin: DefaultMessageDelayMin,
MessageDelayMax: DefaultMessageDelayMax,
@@ -259,12 +272,32 @@ func NewConfig() *Config {
EnableReservations: false,
RequireLogin: false,
AccessControlAllowOrigin: "*",
- Version: "",
WebPushPrivateKey: "",
WebPushPublicKey: "",
WebPushFile: "",
WebPushEmailAddress: "",
WebPushExpiryDuration: DefaultWebPushExpiryDuration,
WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration,
+ BuildVersion: "",
+ BuildDate: "",
+ BuildCommit: "",
}
}
+
+// Hash computes an SHA-256 hash of the configuration. This is used to detect
+// configuration changes for the web app version check feature. It uses reflection
+// to include all JSON-serializable fields automatically.
+func (c *Config) Hash() string {
+ v := reflect.ValueOf(*c)
+ t := v.Type()
+ var result string
+ for i := 0; i < v.NumField(); i++ {
+ field := v.Field(i)
+ fieldName := t.Field(i).Name
+ // Try to marshal the field and skip if it fails (e.g. *template.Template, netip.Prefix)
+ if b, err := json.Marshal(field.Interface()); err == nil {
+ result += fmt.Sprintf("%s:%s|", fieldName, string(b))
+ }
+ }
+ return fmt.Sprintf("%x", sha256.Sum256([]byte(result)))
+}
diff --git a/server/config_unix.go b/server/config_unix.go
new file mode 100644
index 00000000..5b9812b2
--- /dev/null
+++ b/server/config_unix.go
@@ -0,0 +1,8 @@
+//go:build !windows
+
+package server
+
+func init() {
+ DefaultConfigFile = "/etc/ntfy/server.yml"
+ DefaultTemplateDir = "/etc/ntfy/templates"
+}
diff --git a/server/config_windows.go b/server/config_windows.go
new file mode 100644
index 00000000..fc883c38
--- /dev/null
+++ b/server/config_windows.go
@@ -0,0 +1,17 @@
+//go:build windows
+
+package server
+
+import (
+ "os"
+ "path/filepath"
+)
+
+func init() {
+ programData := os.Getenv("ProgramData")
+ if programData == "" {
+ programData = `C:\ProgramData`
+ }
+ DefaultConfigFile = filepath.Join(programData, "ntfy", "server.yml")
+ DefaultTemplateDir = filepath.Join(programData, "ntfy", "templates")
+}
diff --git a/server/errors.go b/server/errors.go
index 098f785d..a29ff27d 100644
--- a/server/errors.go
+++ b/server/errors.go
@@ -3,8 +3,9 @@ package server
import (
"encoding/json"
"fmt"
- "heckel.io/ntfy/v2/log"
"net/http"
+
+ "heckel.io/ntfy/v2/log"
)
// errHTTP is a generic HTTP error for any non-200 HTTP error
@@ -125,6 +126,7 @@ var (
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}
+ errHTTPBadRequestSequenceIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#updating-deleting-notifications", 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}
diff --git a/server/message_cache.go b/server/message_cache.go
index 902cac1c..84083aee 100644
--- a/server/message_cache.go
+++ b/server/message_cache.go
@@ -29,7 +29,9 @@ const (
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mid TEXT NOT NULL,
+ sequence_id TEXT NOT NULL,
time INT NOT NULL,
+ event TEXT NOT NULL,
expires INT NOT NULL,
topic TEXT NOT NULL,
message TEXT NOT NULL,
@@ -52,6 +54,7 @@ const (
published INT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid);
+ CREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id);
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);
@@ -66,50 +69,52 @@ const (
COMMIT;
`
insertMessageQuery = `
- INSERT INTO messages (mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ INSERT INTO messages (mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
- deleteMessageQuery = `DELETE FROM messages WHERE mid = ?`
- updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?`
- selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
- selectMessagesByIDQuery = `
- SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
+ deleteMessageQuery = `DELETE FROM messages WHERE mid = ?`
+ selectScheduledMessageIDsBySeqIDQuery = `SELECT mid FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0`
+ deleteScheduledBySequenceIDQuery = `DELETE FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0`
+ updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?`
+ selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
+ selectMessagesByIDQuery = `
+ SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
FROM messages
WHERE mid = ?
`
selectMessagesSinceTimeQuery = `
- SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
+ SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
FROM messages
WHERE topic = ? AND time >= ? AND published = 1
ORDER BY time, id
`
selectMessagesSinceTimeIncludeScheduledQuery = `
- SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
+ SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
FROM messages
WHERE topic = ? AND time >= ?
ORDER BY time, id
`
selectMessagesSinceIDQuery = `
- SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
+ SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
FROM messages
- WHERE topic = ? AND id > ? AND published = 1
+ WHERE topic = ? AND id > ? AND published = 1
ORDER BY time, id
`
selectMessagesSinceIDIncludeScheduledQuery = `
- SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
+ SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
FROM messages
WHERE topic = ? AND (id > ? OR published = 0)
ORDER BY time, id
`
selectMessagesLatestQuery = `
- SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
+ SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
FROM messages
WHERE topic = ? AND published = 1
ORDER BY time DESC, id DESC
LIMIT 1
`
selectMessagesDueQuery = `
- SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
+ SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
FROM messages
WHERE time <= ? AND published = 0
ORDER BY time, id
@@ -131,7 +136,7 @@ const (
// Schema management queries
const (
- currentSchemaVersion = 13
+ currentSchemaVersion = 14
createSchemaVersionTableQuery = `
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
@@ -260,6 +265,13 @@ const (
migrate12To13AlterMessagesTableQuery = `
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
`
+
+ //13 -> 14
+ migrate13To14AlterMessagesTableQuery = `
+ ALTER TABLE messages ADD COLUMN sequence_id TEXT NOT NULL DEFAULT('');
+ ALTER TABLE messages ADD COLUMN event TEXT NOT NULL DEFAULT('message');
+ CREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id);
+ `
)
var (
@@ -277,6 +289,7 @@ var (
10: migrateFrom10,
11: migrateFrom11,
12: migrateFrom12,
+ 13: migrateFrom13,
}
)
@@ -369,7 +382,7 @@ func (c *messageCache) addMessages(ms []*message) error {
}
defer stmt.Close()
for _, m := range ms {
- if m.Event != messageEvent {
+ if m.Event != messageEvent && m.Event != messageDeleteEvent && m.Event != messageClearEvent {
return errUnexpectedMessageType
}
published := m.Time <= time.Now().Unix()
@@ -397,7 +410,9 @@ func (c *messageCache) addMessages(ms []*message) error {
}
_, err := stmt.Exec(
m.ID,
+ m.SequenceID,
m.Time,
+ m.Event,
m.Expires,
m.Topic,
m.Message,
@@ -594,6 +609,44 @@ func (c *messageCache) DeleteMessages(ids ...string) error {
return tx.Commit()
}
+// DeleteScheduledBySequenceID deletes unpublished (scheduled) messages with the given topic and sequence ID.
+// It returns the message IDs of the deleted messages, which can be used to clean up attachment files.
+func (c *messageCache) DeleteScheduledBySequenceID(topic, sequenceID string) ([]string, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ tx, err := c.db.Begin()
+ if err != nil {
+ return nil, err
+ }
+ defer tx.Rollback()
+ // First, get the message IDs of scheduled messages to be deleted
+ rows, err := tx.Query(selectScheduledMessageIDsBySeqIDQuery, topic, sequenceID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ ids := make([]string, 0)
+ for rows.Next() {
+ var id string
+ if err := rows.Scan(&id); err != nil {
+ return nil, err
+ }
+ ids = append(ids, id)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ rows.Close() // Close rows before executing delete in same transaction
+ // Then delete the messages
+ if _, err := tx.Exec(deleteScheduledBySequenceIDQuery, topic, sequenceID); err != nil {
+ return nil, err
+ }
+ if err := tx.Commit(); err != nil {
+ return nil, err
+ }
+ return ids, nil
+}
+
func (c *messageCache) ExpireMessages(topics ...string) error {
c.mu.Lock()
defer c.mu.Unlock()
@@ -706,10 +759,12 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
func readMessage(rows *sql.Rows) (*message, error) {
var timestamp, expires, attachmentSize, attachmentExpires int64
var priority int
- var id, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string
+ var id, sequenceID, event, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string
err := rows.Scan(
&id,
+ &sequenceID,
×tamp,
+ &event,
&expires,
&topic,
&msg,
@@ -758,9 +813,10 @@ func readMessage(rows *sql.Rows) (*message, error) {
}
return &message{
ID: id,
+ SequenceID: sequenceID,
Time: timestamp,
Expires: expires,
- Event: messageEvent,
+ Event: event,
Topic: topic,
Message: msg,
Title: title,
@@ -1030,3 +1086,19 @@ func migrateFrom12(db *sql.DB, _ time.Duration) error {
}
return tx.Commit()
}
+
+func migrateFrom13(db *sql.DB, _ time.Duration) error {
+ log.Tag(tagMessageCache).Info("Migrating cache database schema: from 13 to 14")
+ tx, err := db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ if _, err := tx.Exec(migrate13To14AlterMessagesTableQuery); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(updateSchemaVersion, 14); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
diff --git a/server/message_cache_test.go b/server/message_cache_test.go
index f0a02b2e..672f91b0 100644
--- a/server/message_cache_test.go
+++ b/server/message_cache_test.go
@@ -319,6 +319,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
expires1 := time.Now().Add(-4 * time.Hour).Unix() // Expired
m := newDefaultMessage("mytopic", "flower for you")
m.ID = "m1"
+ m.SequenceID = "m1"
m.Sender = netip.MustParseAddr("1.2.3.4")
m.Attachment = &attachment{
Name: "flower.jpg",
@@ -332,6 +333,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
expires2 := time.Now().Add(2 * time.Hour).Unix() // Future
m = newDefaultMessage("mytopic", "sending you a car")
m.ID = "m2"
+ m.SequenceID = "m2"
m.Sender = netip.MustParseAddr("1.2.3.4")
m.Attachment = &attachment{
Name: "car.jpg",
@@ -345,6 +347,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
expires3 := time.Now().Add(1 * time.Hour).Unix() // Future
m = newDefaultMessage("another-topic", "sending you another car")
m.ID = "m3"
+ m.SequenceID = "m3"
m.User = "u_BAsbaAa"
m.Sender = netip.MustParseAddr("5.6.7.8")
m.Attachment = &attachment{
@@ -400,11 +403,13 @@ func TestMemCache_Attachments_Expired(t *testing.T) {
func testCacheAttachmentsExpired(t *testing.T, c *messageCache) {
m := newDefaultMessage("mytopic", "flower for you")
m.ID = "m1"
+ m.SequenceID = "m1"
m.Expires = time.Now().Add(time.Hour).Unix()
require.Nil(t, c.AddMessage(m))
m = newDefaultMessage("mytopic", "message with attachment")
m.ID = "m2"
+ m.SequenceID = "m2"
m.Expires = time.Now().Add(2 * time.Hour).Unix()
m.Attachment = &attachment{
Name: "car.jpg",
@@ -417,6 +422,7 @@ func testCacheAttachmentsExpired(t *testing.T, c *messageCache) {
m = newDefaultMessage("mytopic", "message with external attachment")
m.ID = "m3"
+ m.SequenceID = "m3"
m.Expires = time.Now().Add(2 * time.Hour).Unix()
m.Attachment = &attachment{
Name: "car.jpg",
@@ -428,6 +434,7 @@ func testCacheAttachmentsExpired(t *testing.T, c *messageCache) {
m = newDefaultMessage("mytopic2", "message with expired attachment")
m.ID = "m4"
+ m.SequenceID = "m4"
m.Expires = time.Now().Add(2 * time.Hour).Unix()
m.Attachment = &attachment{
Name: "expired-car.jpg",
@@ -696,6 +703,79 @@ func testSender(t *testing.T, c *messageCache) {
require.Equal(t, messages[1].Sender, netip.Addr{})
}
+func TestSqliteCache_DeleteScheduledBySequenceID(t *testing.T) {
+ testDeleteScheduledBySequenceID(t, newSqliteTestCache(t))
+}
+
+func TestMemCache_DeleteScheduledBySequenceID(t *testing.T) {
+ testDeleteScheduledBySequenceID(t, newMemTestCache(t))
+}
+
+func testDeleteScheduledBySequenceID(t *testing.T, c *messageCache) {
+ // Create a scheduled (unpublished) message
+ scheduledMsg := newDefaultMessage("mytopic", "scheduled message")
+ scheduledMsg.ID = "scheduled1"
+ scheduledMsg.SequenceID = "seq123"
+ scheduledMsg.Time = time.Now().Add(time.Hour).Unix() // Future time makes it scheduled
+ require.Nil(t, c.AddMessage(scheduledMsg))
+
+ // Create a published message with different sequence ID
+ publishedMsg := newDefaultMessage("mytopic", "published message")
+ publishedMsg.ID = "published1"
+ publishedMsg.SequenceID = "seq456"
+ publishedMsg.Time = time.Now().Add(-time.Hour).Unix() // Past time makes it published
+ require.Nil(t, c.AddMessage(publishedMsg))
+
+ // Create a scheduled message in a different topic
+ otherTopicMsg := newDefaultMessage("othertopic", "other scheduled")
+ otherTopicMsg.ID = "other1"
+ otherTopicMsg.SequenceID = "seq123" // Same sequence ID as scheduledMsg
+ otherTopicMsg.Time = time.Now().Add(time.Hour).Unix()
+ require.Nil(t, c.AddMessage(otherTopicMsg))
+
+ // Verify all messages exist (including scheduled)
+ messages, err := c.Messages("mytopic", sinceAllMessages, true)
+ require.Nil(t, err)
+ require.Equal(t, 2, len(messages))
+
+ messages, err = c.Messages("othertopic", sinceAllMessages, true)
+ require.Nil(t, err)
+ require.Equal(t, 1, len(messages))
+
+ // Delete scheduled message by sequence ID and verify returned IDs
+ deletedIDs, err := c.DeleteScheduledBySequenceID("mytopic", "seq123")
+ require.Nil(t, err)
+ require.Equal(t, 1, len(deletedIDs))
+ require.Equal(t, "scheduled1", deletedIDs[0])
+
+ // Verify scheduled message is deleted
+ messages, err = c.Messages("mytopic", sinceAllMessages, true)
+ require.Nil(t, err)
+ require.Equal(t, 1, len(messages))
+ require.Equal(t, "published message", messages[0].Message)
+
+ // Verify other topic's message still exists (topic-scoped deletion)
+ messages, err = c.Messages("othertopic", sinceAllMessages, true)
+ require.Nil(t, err)
+ require.Equal(t, 1, len(messages))
+ require.Equal(t, "other scheduled", messages[0].Message)
+
+ // Deleting non-existent sequence ID should return empty list
+ deletedIDs, err = c.DeleteScheduledBySequenceID("mytopic", "nonexistent")
+ require.Nil(t, err)
+ require.Empty(t, deletedIDs)
+
+ // Deleting published message should not affect it (only deletes unpublished)
+ deletedIDs, err = c.DeleteScheduledBySequenceID("mytopic", "seq456")
+ require.Nil(t, err)
+ require.Empty(t, deletedIDs)
+
+ messages, err = c.Messages("mytopic", sinceAllMessages, true)
+ require.Nil(t, err)
+ require.Equal(t, 1, len(messages))
+ require.Equal(t, "published message", messages[0].Message)
+}
+
func checkSchemaVersion(t *testing.T, db *sql.DB) {
rows, err := db.Query(`SELECT version FROM schemaVersion`)
require.Nil(t, err)
diff --git a/server/server.go b/server/server.go
index fc04d50f..04133dac 100644
--- a/server/server.go
+++ b/server/server.go
@@ -80,15 +80,17 @@ var (
wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`)
authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
+ updatePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}$`)
+ clearPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/(read|clear)$`)
+ sequenceIDRegex = topicRegex
webConfigPath = "/config.js"
webManifestPath = "/manifest.webmanifest"
- webRootHTMLPath = "/app.html"
- webServiceWorkerPath = "/sw.js"
accountPath = "/account"
matrixPushPath = "/_matrix/push/v1/notify"
metricsPath = "/metrics"
apiHealthPath = "/v1/health"
+ apiConfigPath = "/v1/config"
apiStatsPath = "/v1/stats"
apiWebPushPath = "/v1/webpush"
apiTiersPath = "/v1/tiers"
@@ -108,7 +110,7 @@ var (
apiAccountBillingSubscriptionCheckoutSuccessTemplate = "/v1/account/billing/subscription/success/{CHECKOUT_SESSION_ID}"
apiAccountBillingSubscriptionCheckoutSuccessRegex = regexp.MustCompile(`/v1/account/billing/subscription/success/(.+)$`)
apiAccountReservationSingleRegex = regexp.MustCompile(`/v1/account/reservation/([-_A-Za-z0-9]{1,64})$`)
- staticRegex = regexp.MustCompile(`^/static/.+`)
+ staticRegex = regexp.MustCompile(`^/(static/.+|app.html|sw.js|sw.js.map)$`)
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
urlRegex = regexp.MustCompile(`^https?://`)
@@ -137,7 +139,7 @@ var (
const (
firebaseControlTopic = "~control" // See Android if changed
firebasePollTopic = "~poll" // See iOS if changed (DISABLED for now)
- emptyMessageBody = "triggered" // Used if message body is empty
+ emptyMessageBody = "triggered" // Used when a message body is empty
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
@@ -276,9 +278,9 @@ func (s *Server) Run() error {
if s.config.ProfileListenHTTP != "" {
listenStr += fmt.Sprintf(" %s[http/profile]", s.config.ProfileListenHTTP)
}
- log.Tag(tagStartup).Info("Listening on%s, ntfy %s, log level is %s", listenStr, s.config.Version, log.CurrentLevel().String())
+ log.Tag(tagStartup).Info("Listening on%s, ntfy %s, log level is %s", listenStr, s.config.BuildVersion, log.CurrentLevel().String())
if log.IsFile() {
- fmt.Fprintf(os.Stderr, "Listening on%s, ntfy %s\n", listenStr, s.config.Version)
+ fmt.Fprintf(os.Stderr, "Listening on%s, ntfy %s\n", listenStr, s.config.BuildVersion)
fmt.Fprintf(os.Stderr, "Logs are written to %s\n", log.File())
}
mux := http.NewServeMux()
@@ -459,6 +461,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.ensureWebEnabled(s.handleEmpty)(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == apiHealthPath {
return s.handleHealth(w, r, v)
+ } else if r.Method == http.MethodGet && r.URL.Path == apiConfigPath {
+ return s.handleConfig(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == webManifestPath {
@@ -531,7 +535,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.handleMatrixDiscovery(w)
} else if r.Method == http.MethodGet && r.URL.Path == metricsPath && s.metricsHandler != nil {
return s.handleMetrics(w, r, v)
- } else if r.Method == http.MethodGet && (staticRegex.MatchString(r.URL.Path) || r.URL.Path == webServiceWorkerPath || r.URL.Path == webRootHTMLPath) {
+ } else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
return s.ensureWebEnabled(s.handleStatic)(w, r, v)
} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
return s.ensureWebEnabled(s.handleDocs)(w, r, v)
@@ -543,8 +547,12 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.transformBodyJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish)))(w, r, v)
} else if r.Method == http.MethodPost && r.URL.Path == matrixPushPath {
return s.transformMatrixJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublishMatrix)))(w, r, v)
- } else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicPathRegex.MatchString(r.URL.Path) {
+ } else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && (topicPathRegex.MatchString(r.URL.Path) || updatePathRegex.MatchString(r.URL.Path)) {
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
+ } else if r.Method == http.MethodDelete && updatePathRegex.MatchString(r.URL.Path) {
+ return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleDelete))(w, r, v)
+ } else if r.Method == http.MethodPut && clearPathRegex.MatchString(r.URL.Path) {
+ return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleClear))(w, r, v)
} else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) {
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
} else if r.Method == http.MethodGet && jsonPathRegex.MatchString(r.URL.Path) {
@@ -595,8 +603,24 @@ func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request, _ *visitor
return s.writeJSON(w, response)
}
+func (s *Server) handleConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
+ w.Header().Set("Cache-Control", "no-cache")
+ return s.writeJSON(w, s.configResponse())
+}
+
func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
- response := &apiConfigResponse{
+ b, err := json.MarshalIndent(s.configResponse(), "", " ")
+ if err != nil {
+ return err
+ }
+ w.Header().Set("Content-Type", "text/javascript")
+ w.Header().Set("Cache-Control", "no-cache")
+ _, err = io.WriteString(w, fmt.Sprintf("// Generated server configuration\nvar config = %s;\n", string(b)))
+ return err
+}
+
+func (s *Server) configResponse() *apiConfigResponse {
+ return &apiConfigResponse{
BaseURL: "", // Will translate to window.location.origin
AppRoot: s.config.WebRoot,
EnableLogin: s.config.EnableLogin,
@@ -610,21 +634,14 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
BillingContact: s.config.BillingContact,
WebPushPublicKey: s.config.WebPushPublicKey,
DisallowedTopics: s.config.DisallowedTopics,
+ ConfigHash: s.config.Hash(),
}
- b, err := json.MarshalIndent(response, "", " ")
- if err != nil {
- return err
- }
- w.Header().Set("Content-Type", "text/javascript")
- w.Header().Set("Cache-Control", "no-cache")
- _, err = io.WriteString(w, fmt.Sprintf("// Generated server configuration\nvar config = %s;\n", string(b)))
- return err
}
// handleWebManifest serves the web app manifest for the progressive web app (PWA)
func (s *Server) handleWebManifest(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
response := &webManifestResponse{
- Name: "ntfy web",
+ Name: "ntfy",
Description: "ntfy lets you send push notifications via scripts from any computer or phone",
ShortName: "ntfy",
Scope: "/",
@@ -846,6 +863,17 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
logvrm(v, r, m).Tag(tagPublish).Debug("Message delayed, will process later")
}
if cache {
+ // Delete any existing scheduled message with the same sequence ID
+ deletedIDs, err := s.messageCache.DeleteScheduledBySequenceID(t.ID, m.SequenceID)
+ if err != nil {
+ return nil, err
+ }
+ // Delete attachment files for deleted scheduled messages
+ if s.fileCache != nil && len(deletedIDs) > 0 {
+ if err := s.fileCache.Remove(deletedIDs...); err != nil {
+ logvrm(v, r, m).Tag(tagPublish).Err(err).Warn("Error removing attachments for deleted scheduled messages")
+ }
+ }
logvrm(v, r, m).Tag(tagPublish).Debug("Adding message to cache")
if err := s.messageCache.AddMessage(m); err != nil {
return nil, err
@@ -872,7 +900,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
return err
}
minc(metricMessagesPublishedSuccess)
- return s.writeJSON(w, m)
+ return s.writeJSON(w, m.forJSON())
}
func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error {
@@ -900,6 +928,71 @@ func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *
return writeMatrixSuccess(w)
}
+func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
+ return s.handleActionMessage(w, r, v, messageDeleteEvent)
+}
+
+func (s *Server) handleClear(w http.ResponseWriter, r *http.Request, v *visitor) error {
+ return s.handleActionMessage(w, r, v, messageClearEvent)
+}
+
+func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *visitor, event string) error {
+ t, err := fromContext[*topic](r, contextTopic)
+ if err != nil {
+ return err
+ }
+ vrate, err := fromContext[*visitor](r, contextRateVisitor)
+ if err != nil {
+ return err
+ }
+ if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() {
+ return errHTTPTooManyRequestsLimitMessages.With(t)
+ }
+ sequenceID, e := s.sequenceIDFromPath(r.URL.Path)
+ if e != nil {
+ return e.With(t)
+ }
+ // Create an action message with the given event type
+ m := newActionMessage(event, t.ID, sequenceID)
+ m.Sender = v.IP()
+ m.User = v.MaybeUserID()
+ m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix()
+ // Publish to subscribers
+ if err := t.Publish(v, m); err != nil {
+ return err
+ }
+ // Send to Firebase for Android clients
+ if s.firebaseClient != nil {
+ go s.sendToFirebase(v, m)
+ }
+ // Send to web push endpoints
+ if s.config.WebPushPublicKey != "" {
+ go s.publishToWebPushEndpoints(v, m)
+ }
+ if event == messageDeleteEvent {
+ // Delete any existing scheduled message with the same sequence ID
+ deletedIDs, err := s.messageCache.DeleteScheduledBySequenceID(t.ID, sequenceID)
+ if err != nil {
+ return err
+ }
+ // Delete attachment files for deleted scheduled messages
+ if s.fileCache != nil && len(deletedIDs) > 0 {
+ if err := s.fileCache.Remove(deletedIDs...); err != nil {
+ logvrm(v, r, m).Tag(tagPublish).Err(err).Warn("Error removing attachments for deleted scheduled messages")
+ }
+ }
+ }
+ // Add to message cache
+ if err := s.messageCache.AddMessage(m); err != nil {
+ return err
+ }
+ logvrm(v, r, m).Tag(tagPublish).Debug("Published %s for sequence ID %s", event, sequenceID)
+ s.mu.Lock()
+ s.messages++
+ s.mu.Unlock()
+ return s.writeJSON(w, m.forJSON())
+}
+
func (s *Server) sendToFirebase(v *visitor, m *message) {
logvm(v, m).Tag(tagFirebase).Debug("Publishing to Firebase")
if err := s.firebaseClient.Send(v, m); err != nil {
@@ -934,7 +1027,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
logvm(v, m).Err(err).Warn("Unable to publish poll request")
return
}
- req.Header.Set("User-Agent", "ntfy/"+s.config.Version)
+ req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion)
req.Header.Set("X-Poll-ID", m.ID)
if s.config.UpstreamAccessToken != "" {
req.Header.Set("Authorization", util.BearerAuth(s.config.UpstreamAccessToken))
@@ -957,6 +1050,24 @@ 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 templateMode, unifiedpush bool, err *errHTTP) {
+ if r.Method != http.MethodGet && updatePathRegex.MatchString(r.URL.Path) {
+ pathSequenceID, err := s.sequenceIDFromPath(r.URL.Path)
+ if err != nil {
+ return false, false, "", "", "", false, err
+ }
+ m.SequenceID = pathSequenceID
+ } else {
+ sequenceID := readParam(r, "x-sequence-id", "sequence-id", "sid")
+ if sequenceID != "" {
+ if sequenceIDRegex.MatchString(sequenceID) {
+ m.SequenceID = sequenceID
+ } else {
+ return false, false, "", "", "", false, errHTTPBadRequestSequenceIDInvalid
+ }
+ } else {
+ m.SequenceID = m.ID
+ }
+ }
cache = readBoolParam(r, true, "x-cache", "cache")
firebase = readBoolParam(r, true, "x-firebase", "firebase")
m.Title = readParam(r, "x-title", "title", "t")
@@ -1271,7 +1382,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *visitor) error {
encoder := func(msg *message) (string, error) {
var buf bytes.Buffer
- if err := json.NewEncoder(&buf).Encode(&msg); err != nil {
+ if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil {
return "", err
}
return buf.String(), nil
@@ -1282,10 +1393,10 @@ func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *
func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *visitor) error {
encoder := func(msg *message) (string, error) {
var buf bytes.Buffer
- if err := json.NewEncoder(&buf).Encode(&msg); err != nil {
+ if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil {
return "", err
}
- if msg.Event != messageEvent {
+ if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageClearEvent {
return fmt.Sprintf("event: %s\ndata: %s\n", msg.Event, buf.String()), nil // Browser's .onmessage() does not fire on this!
}
return fmt.Sprintf("data: %s\n", buf.String()), nil
@@ -1695,6 +1806,15 @@ func (s *Server) topicsFromPath(path string) ([]*topic, string, error) {
return topics, parts[1], nil
}
+// sequenceIDFromPath returns the sequence ID from a path like /mytopic/sequenceIdHere
+func (s *Server) sequenceIDFromPath(path string) (string, *errHTTP) {
+ parts := strings.Split(path, "/")
+ if len(parts) < 3 {
+ return "", errHTTPBadRequestSequenceIDInvalid
+ }
+ return parts[2], nil
+}
+
// topicsFromIDs returns the topics with the given IDs, creating them if they don't exist.
func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) {
s.mu.Lock()
@@ -1949,6 +2069,9 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
if m.Firebase != "" {
r.Header.Set("X-Firebase", m.Firebase)
}
+ if m.SequenceID != "" {
+ r.Header.Set("X-Sequence-ID", m.SequenceID)
+ }
return next(w, r, v)
}
}
diff --git a/server/server.yml b/server/server.yml
index d9e85453..639ed492 100644
--- a/server/server.yml
+++ b/server/server.yml
@@ -216,11 +216,13 @@
# - twilio-auth-token is the Twilio auth token, e.g. affebeef258625862586258625862586
# - twilio-phone-number is the outgoing phone number you purchased, e.g. +18775132586
# - twilio-verify-service is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586
+# - twilio-call-format is the custom TwiML send to the Call API (optional, see https://www.twilio.com/docs/voice/twiml)
#
# twilio-account:
# twilio-auth-token:
# twilio-phone-number:
# twilio-verify-service:
+# twilio-call-format:
# Interval in which keepalive messages are sent to the client. This is to prevent
# intermediaries closing the connection for inactivity.
diff --git a/server/server_firebase.go b/server/server_firebase.go
index 13e80b93..9fde63a3 100644
--- a/server/server_firebase.go
+++ b/server/server_firebase.go
@@ -143,6 +143,15 @@ func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, erro
"poll_id": m.PollID,
}
apnsConfig = createAPNSAlertConfig(m, data)
+ case messageDeleteEvent, messageClearEvent:
+ data = map[string]string{
+ "id": m.ID,
+ "time": fmt.Sprintf("%d", m.Time),
+ "event": m.Event,
+ "topic": m.Topic,
+ "sequence_id": m.SequenceID,
+ }
+ apnsConfig = createAPNSBackgroundConfig(data)
case messageEvent:
if auther != nil {
// If "anonymous read" for a topic is not allowed, we cannot send the message along
@@ -161,6 +170,7 @@ func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, erro
"time": fmt.Sprintf("%d", m.Time),
"event": m.Event,
"topic": m.Topic,
+ "sequence_id": m.SequenceID,
"priority": fmt.Sprintf("%d", m.Priority),
"tags": strings.Join(m.Tags, ","),
"click": m.Click,
diff --git a/server/server_firebase_test.go b/server/server_firebase_test.go
index 89004cd3..c98f528f 100644
--- a/server/server_firebase_test.go
+++ b/server/server_firebase_test.go
@@ -177,6 +177,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
"time": fmt.Sprintf("%d", m.Time),
"event": "message",
"topic": "mytopic",
+ "sequence_id": "",
"priority": "4",
"tags": strings.Join(m.Tags, ","),
"click": "https://google.com",
@@ -199,6 +200,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
"time": fmt.Sprintf("%d", m.Time),
"event": "message",
"topic": "mytopic",
+ "sequence_id": "",
"priority": "4",
"tags": strings.Join(m.Tags, ","),
"click": "https://google.com",
@@ -232,6 +234,7 @@ func TestToFirebaseMessage_Message_Normal_Not_Allowed(t *testing.T) {
"time": fmt.Sprintf("%d", m.Time),
"event": "poll_request",
"topic": "mytopic",
+ "sequence_id": "",
"message": "New message",
"title": "",
"tags": "",
diff --git a/server/server_test.go b/server/server_test.go
index 19c0165c..0b125638 100644
--- a/server/server_test.go
+++ b/server/server_test.go
@@ -8,8 +8,6 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
- "golang.org/x/crypto/bcrypt"
- "heckel.io/ntfy/v2/user"
"io"
"net/http"
"net/http/httptest"
@@ -24,7 +22,9 @@ import (
"time"
"github.com/stretchr/testify/require"
+ "golang.org/x/crypto/bcrypt"
"heckel.io/ntfy/v2/log"
+ "heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
)
@@ -678,6 +678,86 @@ func TestServer_PublishInvalidTopic(t *testing.T) {
require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code)
}
+func TestServer_PublishWithSIDInPath(t *testing.T) {
+ s := newTestServer(t, newTestConfig(t))
+
+ response := request(t, s, "POST", "/mytopic/sid", "message", nil)
+ msg := toMessage(t, response.Body.String())
+ require.NotEmpty(t, msg.ID)
+ require.Equal(t, "sid", msg.SequenceID)
+}
+
+func TestServer_PublishWithSIDInHeader(t *testing.T) {
+ s := newTestServer(t, newTestConfig(t))
+
+ response := request(t, s, "POST", "/mytopic", "message", map[string]string{
+ "sid": "sid",
+ })
+ msg := toMessage(t, response.Body.String())
+ require.NotEmpty(t, msg.ID)
+ require.Equal(t, "sid", msg.SequenceID)
+}
+
+func TestServer_PublishWithSIDInPathAndHeader(t *testing.T) {
+ s := newTestServer(t, newTestConfig(t))
+
+ response := request(t, s, "PUT", "/mytopic/sid1", "message", map[string]string{
+ "sid": "sid2",
+ })
+ msg := toMessage(t, response.Body.String())
+ require.NotEmpty(t, msg.ID)
+ require.Equal(t, "sid1", msg.SequenceID) // Sequence ID in path has priority over header
+}
+
+func TestServer_PublishWithSIDInQuery(t *testing.T) {
+ s := newTestServer(t, newTestConfig(t))
+
+ response := request(t, s, "PUT", "/mytopic?sid=sid1", "message", nil)
+ msg := toMessage(t, response.Body.String())
+ require.NotEmpty(t, msg.ID)
+ require.Equal(t, "sid1", msg.SequenceID)
+}
+
+func TestServer_PublishWithSIDViaGet(t *testing.T) {
+ s := newTestServer(t, newTestConfig(t))
+
+ response := request(t, s, "GET", "/mytopic/publish?sid=sid1", "message", nil)
+ msg := toMessage(t, response.Body.String())
+ require.NotEmpty(t, msg.ID)
+ require.Equal(t, "sid1", msg.SequenceID)
+}
+
+func TestServer_PublishAsJSON_WithSequenceID(t *testing.T) {
+ s := newTestServer(t, newTestConfig(t))
+
+ body := `{"topic":"mytopic","message":"A message","sequence_id":"my-sequence-123"}`
+ response := request(t, s, "PUT", "/", body, nil)
+ require.Equal(t, 200, response.Code)
+
+ msg := toMessage(t, response.Body.String())
+ require.NotEmpty(t, msg.ID)
+ require.Equal(t, "my-sequence-123", msg.SequenceID)
+}
+
+func TestServer_PublishWithInvalidSIDInPath(t *testing.T) {
+ s := newTestServer(t, newTestConfig(t))
+
+ response := request(t, s, "POST", "/mytopic/.", "message", nil)
+
+ require.Equal(t, 404, response.Code)
+}
+
+func TestServer_PublishWithInvalidSIDInHeader(t *testing.T) {
+ s := newTestServer(t, newTestConfig(t))
+
+ response := request(t, s, "POST", "/mytopic", "message", map[string]string{
+ "X-Sequence-ID": "*&?",
+ })
+
+ require.Equal(t, 400, response.Code)
+ require.Equal(t, 40049, toHTTPError(t, response.Body.String()).Code)
+}
+
func TestServer_PollWithQueryFilters(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
@@ -3209,6 +3289,368 @@ func TestServer_MessageTemplate_Until100_000(t *testing.T) {
require.Contains(t, toHTTPError(t, response.Body.String()).Message, "too many iterations")
}
+func TestServer_DeleteMessage(t *testing.T) {
+ t.Parallel()
+ s := newTestServer(t, newTestConfig(t))
+
+ // Publish a message with a sequence ID
+ response := request(t, s, "PUT", "/mytopic/seq123", "original message", nil)
+ require.Equal(t, 200, response.Code)
+ msg := toMessage(t, response.Body.String())
+ require.Equal(t, "seq123", msg.SequenceID)
+ require.Equal(t, "message", msg.Event)
+
+ // Delete the message using DELETE method
+ response = request(t, s, "DELETE", "/mytopic/seq123", "", nil)
+ require.Equal(t, 200, response.Code)
+ deleteMsg := toMessage(t, response.Body.String())
+ require.Equal(t, "seq123", deleteMsg.SequenceID)
+ require.Equal(t, "message_delete", deleteMsg.Event)
+
+ // Poll and verify both messages are returned
+ response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
+ require.Equal(t, 200, response.Code)
+ lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n")
+ require.Equal(t, 2, len(lines))
+
+ msg1 := toMessage(t, lines[0])
+ msg2 := toMessage(t, lines[1])
+ require.Equal(t, "message", msg1.Event)
+ require.Equal(t, "message_delete", msg2.Event)
+ require.Equal(t, "seq123", msg1.SequenceID)
+ require.Equal(t, "seq123", msg2.SequenceID)
+}
+
+func TestServer_ClearMessage(t *testing.T) {
+ t.Parallel()
+ s := newTestServer(t, newTestConfig(t))
+
+ // Publish a message with a sequence ID
+ response := request(t, s, "PUT", "/mytopic/seq456", "original message", nil)
+ require.Equal(t, 200, response.Code)
+ msg := toMessage(t, response.Body.String())
+ require.Equal(t, "seq456", msg.SequenceID)
+ require.Equal(t, "message", msg.Event)
+
+ // Clear the message using PUT /topic/seq/clear
+ response = request(t, s, "PUT", "/mytopic/seq456/clear", "", nil)
+ require.Equal(t, 200, response.Code)
+ clearMsg := toMessage(t, response.Body.String())
+ require.Equal(t, "seq456", clearMsg.SequenceID)
+ require.Equal(t, "message_clear", clearMsg.Event)
+
+ // Poll and verify both messages are returned
+ response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
+ require.Equal(t, 200, response.Code)
+ lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n")
+ require.Equal(t, 2, len(lines))
+
+ msg1 := toMessage(t, lines[0])
+ msg2 := toMessage(t, lines[1])
+ require.Equal(t, "message", msg1.Event)
+ require.Equal(t, "message_clear", msg2.Event)
+ require.Equal(t, "seq456", msg1.SequenceID)
+ require.Equal(t, "seq456", msg2.SequenceID)
+}
+
+func TestServer_ClearMessage_ReadEndpoint(t *testing.T) {
+ // Test that /topic/seq/read also works
+ t.Parallel()
+ s := newTestServer(t, newTestConfig(t))
+
+ // Publish a message
+ response := request(t, s, "PUT", "/mytopic/seq789", "original message", nil)
+ require.Equal(t, 200, response.Code)
+
+ // Clear using /read endpoint
+ response = request(t, s, "PUT", "/mytopic/seq789/read", "", nil)
+ require.Equal(t, 200, response.Code)
+ clearMsg := toMessage(t, response.Body.String())
+ require.Equal(t, "seq789", clearMsg.SequenceID)
+ require.Equal(t, "message_clear", clearMsg.Event)
+}
+
+func TestServer_UpdateMessage(t *testing.T) {
+ t.Parallel()
+ s := newTestServer(t, newTestConfig(t))
+
+ // Publish original message
+ response := request(t, s, "PUT", "/mytopic/update-seq", "original message", nil)
+ require.Equal(t, 200, response.Code)
+ msg1 := toMessage(t, response.Body.String())
+ require.Equal(t, "update-seq", msg1.SequenceID)
+ require.Equal(t, "original message", msg1.Message)
+
+ // Update the message (same sequence ID, new content)
+ response = request(t, s, "PUT", "/mytopic/update-seq", "updated message", nil)
+ require.Equal(t, 200, response.Code)
+ msg2 := toMessage(t, response.Body.String())
+ require.Equal(t, "update-seq", msg2.SequenceID)
+ require.Equal(t, "updated message", msg2.Message)
+ require.NotEqual(t, msg1.ID, msg2.ID) // Different message IDs
+
+ // Poll and verify both versions are returned
+ response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
+ require.Equal(t, 200, response.Code)
+ lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n")
+ require.Equal(t, 2, len(lines))
+
+ polledMsg1 := toMessage(t, lines[0])
+ polledMsg2 := toMessage(t, lines[1])
+ require.Equal(t, "original message", polledMsg1.Message)
+ require.Equal(t, "updated message", polledMsg2.Message)
+ require.Equal(t, "update-seq", polledMsg1.SequenceID)
+ require.Equal(t, "update-seq", polledMsg2.SequenceID)
+}
+
+func TestServer_UpdateMessage_UsingMessageID(t *testing.T) {
+ t.Parallel()
+ s := newTestServer(t, newTestConfig(t))
+
+ // Publish original message without a sequence ID
+ response := request(t, s, "PUT", "/mytopic", "original message", nil)
+ require.Equal(t, 200, response.Code)
+ msg1 := toMessage(t, response.Body.String())
+ require.NotEmpty(t, msg1.ID)
+ require.Empty(t, msg1.SequenceID) // No sequence ID provided
+ require.Equal(t, "original message", msg1.Message)
+
+ // Update the message using the message ID as the sequence ID
+ response = request(t, s, "PUT", "/mytopic/"+msg1.ID, "updated message", nil)
+ require.Equal(t, 200, response.Code)
+ msg2 := toMessage(t, response.Body.String())
+ require.Equal(t, msg1.ID, msg2.SequenceID) // Message ID is now used as sequence ID
+ require.Equal(t, "updated message", msg2.Message)
+ require.NotEqual(t, msg1.ID, msg2.ID) // Different message IDs
+
+ // Poll and verify both versions are returned
+ response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
+ require.Equal(t, 200, response.Code)
+ lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n")
+ require.Equal(t, 2, len(lines))
+
+ polledMsg1 := toMessage(t, lines[0])
+ polledMsg2 := toMessage(t, lines[1])
+ require.Equal(t, "original message", polledMsg1.Message)
+ require.Equal(t, "updated message", polledMsg2.Message)
+ require.Empty(t, polledMsg1.SequenceID) // Original has no sequence ID
+ require.Equal(t, msg1.ID, polledMsg2.SequenceID) // Update uses original message ID as sequence ID
+}
+
+func TestServer_DeleteAndClear_InvalidSequenceID(t *testing.T) {
+ t.Parallel()
+ s := newTestServer(t, newTestConfig(t))
+
+ // Test invalid sequence ID for delete (returns 404 because route doesn't match)
+ response := request(t, s, "DELETE", "/mytopic/invalid*seq", "", nil)
+ require.Equal(t, 404, response.Code)
+
+ // Test invalid sequence ID for clear (returns 404 because route doesn't match)
+ response = request(t, s, "PUT", "/mytopic/invalid*seq/clear", "", nil)
+ require.Equal(t, 404, response.Code)
+}
+
+func TestServer_DeleteMessage_WithFirebase(t *testing.T) {
+ sender := newTestFirebaseSender(10)
+ s := newTestServer(t, newTestConfig(t))
+ s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true})
+
+ // Publish a message
+ response := request(t, s, "PUT", "/mytopic/firebase-seq", "test message", nil)
+ require.Equal(t, 200, response.Code)
+
+ time.Sleep(100 * time.Millisecond) // Firebase publishing happens
+ require.Equal(t, 1, len(sender.Messages()))
+ require.Equal(t, "message", sender.Messages()[0].Data["event"])
+
+ // Delete the message
+ response = request(t, s, "DELETE", "/mytopic/firebase-seq", "", nil)
+ require.Equal(t, 200, response.Code)
+
+ time.Sleep(100 * time.Millisecond) // Firebase publishing happens
+ require.Equal(t, 2, len(sender.Messages()))
+ require.Equal(t, "message_delete", sender.Messages()[1].Data["event"])
+ require.Equal(t, "firebase-seq", sender.Messages()[1].Data["sequence_id"])
+}
+
+func TestServer_ClearMessage_WithFirebase(t *testing.T) {
+ sender := newTestFirebaseSender(10)
+ s := newTestServer(t, newTestConfig(t))
+ s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true})
+
+ // Publish a message
+ response := request(t, s, "PUT", "/mytopic/firebase-clear-seq", "test message", nil)
+ require.Equal(t, 200, response.Code)
+
+ time.Sleep(100 * time.Millisecond)
+ require.Equal(t, 1, len(sender.Messages()))
+
+ // Clear the message
+ response = request(t, s, "PUT", "/mytopic/firebase-clear-seq/clear", "", nil)
+ require.Equal(t, 200, response.Code)
+
+ time.Sleep(100 * time.Millisecond)
+ require.Equal(t, 2, len(sender.Messages()))
+ require.Equal(t, "message_clear", sender.Messages()[1].Data["event"])
+ require.Equal(t, "firebase-clear-seq", sender.Messages()[1].Data["sequence_id"])
+}
+
+func TestServer_UpdateScheduledMessage(t *testing.T) {
+ t.Parallel()
+ s := newTestServer(t, newTestConfig(t))
+
+ // Publish a scheduled message (future delivery)
+ response := request(t, s, "PUT", "/mytopic/sched-seq?delay=1h", "original scheduled message", nil)
+ require.Equal(t, 200, response.Code)
+ msg1 := toMessage(t, response.Body.String())
+ require.Equal(t, "sched-seq", msg1.SequenceID)
+ require.Equal(t, "original scheduled message", msg1.Message)
+
+ // Verify scheduled message exists
+ response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
+ require.Equal(t, 200, response.Code)
+ messages := toMessages(t, response.Body.String())
+ require.Equal(t, 1, len(messages))
+ require.Equal(t, "original scheduled message", messages[0].Message)
+
+ // Update the scheduled message (same sequence ID, new content)
+ response = request(t, s, "PUT", "/mytopic/sched-seq?delay=2h", "updated scheduled message", nil)
+ require.Equal(t, 200, response.Code)
+ msg2 := toMessage(t, response.Body.String())
+ require.Equal(t, "sched-seq", msg2.SequenceID)
+ require.Equal(t, "updated scheduled message", msg2.Message)
+ require.NotEqual(t, msg1.ID, msg2.ID)
+
+ // Verify only the updated message exists (old scheduled was deleted)
+ response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
+ require.Equal(t, 200, response.Code)
+ messages = toMessages(t, response.Body.String())
+ require.Equal(t, 1, len(messages))
+ require.Equal(t, "updated scheduled message", messages[0].Message)
+ require.Equal(t, msg2.ID, messages[0].ID)
+}
+
+func TestServer_DeleteScheduledMessage(t *testing.T) {
+ t.Parallel()
+ s := newTestServer(t, newTestConfig(t))
+
+ // Publish a scheduled message (future delivery)
+ response := request(t, s, "PUT", "/mytopic/delete-sched-seq?delay=1h", "scheduled message to delete", nil)
+ require.Equal(t, 200, response.Code)
+ msg := toMessage(t, response.Body.String())
+ require.Equal(t, "delete-sched-seq", msg.SequenceID)
+
+ // Verify scheduled message exists
+ response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
+ require.Equal(t, 200, response.Code)
+ messages := toMessages(t, response.Body.String())
+ require.Equal(t, 1, len(messages))
+ require.Equal(t, "scheduled message to delete", messages[0].Message)
+
+ // Delete the scheduled message
+ response = request(t, s, "DELETE", "/mytopic/delete-sched-seq", "", nil)
+ require.Equal(t, 200, response.Code)
+ deleteMsg := toMessage(t, response.Body.String())
+ require.Equal(t, "delete-sched-seq", deleteMsg.SequenceID)
+ require.Equal(t, "message_delete", deleteMsg.Event)
+
+ // Verify scheduled message was deleted, only delete event remains
+ response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
+ require.Equal(t, 200, response.Code)
+ messages = toMessages(t, response.Body.String())
+ require.Equal(t, 1, len(messages))
+ require.Equal(t, "message_delete", messages[0].Event)
+ require.Equal(t, "delete-sched-seq", messages[0].SequenceID)
+}
+
+func TestServer_UpdateScheduledMessage_TopicScoped(t *testing.T) {
+ t.Parallel()
+ s := newTestServer(t, newTestConfig(t))
+
+ // Publish scheduled messages with same sequence ID in different topics
+ response := request(t, s, "PUT", "/topic1/shared-seq?delay=1h", "topic1 scheduled", nil)
+ require.Equal(t, 200, response.Code)
+
+ response = request(t, s, "PUT", "/topic2/shared-seq?delay=1h", "topic2 scheduled", nil)
+ require.Equal(t, 200, response.Code)
+
+ // Update scheduled message in topic1 only
+ response = request(t, s, "PUT", "/topic1/shared-seq?delay=2h", "topic1 updated", nil)
+ require.Equal(t, 200, response.Code)
+
+ // Verify topic1 has only the updated message
+ response = request(t, s, "GET", "/topic1/json?poll=1&scheduled=1", "", nil)
+ require.Equal(t, 200, response.Code)
+ messages := toMessages(t, response.Body.String())
+ require.Equal(t, 1, len(messages))
+ require.Equal(t, "topic1 updated", messages[0].Message)
+
+ // Verify topic2 still has its original scheduled message (not affected)
+ response = request(t, s, "GET", "/topic2/json?poll=1&scheduled=1", "", nil)
+ require.Equal(t, 200, response.Code)
+ messages = toMessages(t, response.Body.String())
+ require.Equal(t, 1, len(messages))
+ require.Equal(t, "topic2 scheduled", messages[0].Message)
+}
+
+func TestServer_UpdateScheduledMessage_WithAttachment(t *testing.T) {
+ t.Parallel()
+ s := newTestServer(t, newTestConfig(t))
+
+ // Publish a scheduled message with an attachment
+ content := util.RandomString(5000) // > 4096 to trigger attachment
+ response := request(t, s, "PUT", "/mytopic/attach-seq?delay=1h", content, nil)
+ require.Equal(t, 200, response.Code)
+ msg1 := toMessage(t, response.Body.String())
+ require.Equal(t, "attach-seq", msg1.SequenceID)
+ require.NotNil(t, msg1.Attachment)
+
+ // Verify attachment file exists
+ attachmentFile1 := filepath.Join(s.config.AttachmentCacheDir, msg1.ID)
+ require.FileExists(t, attachmentFile1)
+
+ // Update the scheduled message with a new attachment
+ newContent := util.RandomString(5000)
+ response = request(t, s, "PUT", "/mytopic/attach-seq?delay=2h", newContent, nil)
+ require.Equal(t, 200, response.Code)
+ msg2 := toMessage(t, response.Body.String())
+ require.Equal(t, "attach-seq", msg2.SequenceID)
+ require.NotEqual(t, msg1.ID, msg2.ID)
+
+ // Verify old attachment file was deleted
+ require.NoFileExists(t, attachmentFile1)
+
+ // Verify new attachment file exists
+ attachmentFile2 := filepath.Join(s.config.AttachmentCacheDir, msg2.ID)
+ require.FileExists(t, attachmentFile2)
+}
+
+func TestServer_DeleteScheduledMessage_WithAttachment(t *testing.T) {
+ t.Parallel()
+ s := newTestServer(t, newTestConfig(t))
+
+ // Publish a scheduled message with an attachment
+ content := util.RandomString(5000) // > 4096 to trigger attachment
+ response := request(t, s, "PUT", "/mytopic/delete-attach-seq?delay=1h", content, nil)
+ require.Equal(t, 200, response.Code)
+ msg := toMessage(t, response.Body.String())
+ require.Equal(t, "delete-attach-seq", msg.SequenceID)
+ require.NotNil(t, msg.Attachment)
+
+ // Verify attachment file exists
+ attachmentFile := filepath.Join(s.config.AttachmentCacheDir, msg.ID)
+ require.FileExists(t, attachmentFile)
+
+ // Delete the scheduled message
+ response = request(t, s, "DELETE", "/mytopic/delete-attach-seq", "", nil)
+ require.Equal(t, 200, response.Code)
+ deleteMsg := toMessage(t, response.Body.String())
+ require.Equal(t, "message_delete", deleteMsg.Event)
+
+ // Verify attachment file was deleted
+ require.NoFileExists(t, attachmentFile)
+}
+
func newTestConfig(t *testing.T) *Config {
conf := NewConfig()
conf.BaseURL = "http://127.0.0.1:12345"
diff --git a/server/server_twilio.go b/server/server_twilio.go
index 9a8ef8ad..c1761613 100644
--- a/server/server_twilio.go
+++ b/server/server_twilio.go
@@ -4,33 +4,49 @@ import (
"bytes"
"encoding/xml"
"fmt"
- "heckel.io/ntfy/v2/log"
- "heckel.io/ntfy/v2/user"
- "heckel.io/ntfy/v2/util"
"io"
"net/http"
"net/url"
"strings"
+ "text/template"
+
+ "heckel.io/ntfy/v2/log"
+ "heckel.io/ntfy/v2/user"
+ "heckel.io/ntfy/v2/util"
)
-const (
- twilioCallFormat = `
+// defaultTwilioCallFormatTemplate is the default TwiML template used for Twilio calls.
+// It can be overridden in the server configuration's twilio-call-format field.
+//
+// The format uses Go template syntax with the following fields:
+// {{.Topic}}, {{.Title}}, {{.Message}}, {{.Priority}}, {{.Tags}}, {{.Sender}}
+// String fields are automatically XML-escaped.
+var defaultTwilioCallFormatTemplate = template.Must(template.New("twiml").Parse(`
- You have a message from notify on topic %s. Message:
+ You have a message from notify on topic {{.Topic}}. Message:
- %s
+ {{.Message}}
End of message.
- This message was sent by user %s. It will be repeated three times.
+ This message was sent by user {{.Sender}}. It will be repeated three times.
To unsubscribe from calls like this, remove your phone number in the notify web app.
Goodbye.
-`
-)
+`))
+
+// twilioCallData holds the data passed to the Twilio call format template
+type twilioCallData struct {
+ Topic string
+ Title string
+ Message string
+ Priority int
+ Tags []string
+ Sender string
+}
// convertPhoneNumber checks if the given phone number is verified for the given user, and if so, returns the verified
// phone number. It also converts a boolean string ("yes", "1", "true") to the first verified phone number.
@@ -65,7 +81,29 @@ func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) {
if u != nil {
sender = u.Name
}
- body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(sender))
+ tmpl := defaultTwilioCallFormatTemplate
+ if s.config.TwilioCallFormat != nil {
+ tmpl = s.config.TwilioCallFormat
+ }
+ tags := make([]string, len(m.Tags))
+ for i, tag := range m.Tags {
+ tags[i] = xmlEscapeText(tag)
+ }
+ templateData := &twilioCallData{
+ Topic: xmlEscapeText(m.Topic),
+ Title: xmlEscapeText(m.Title),
+ Message: xmlEscapeText(m.Message),
+ Priority: m.Priority,
+ Tags: tags,
+ Sender: xmlEscapeText(sender),
+ }
+ var bodyBuf bytes.Buffer
+ if err := tmpl.Execute(&bodyBuf, templateData); err != nil {
+ logvrm(v, r, m).Tag(tagTwilio).Err(err).Warn("Error executing Twilio call format template")
+ minc(metricCallsMadeFailure)
+ return
+ }
+ body := bodyBuf.String()
data := url.Values{}
data.Set("From", s.config.TwilioPhoneNumber)
data.Set("To", to)
@@ -87,7 +125,7 @@ func (s *Server) callPhoneInternal(data url.Values) (string, error) {
if err != nil {
return "", err
}
- req.Header.Set("User-Agent", "ntfy/"+s.config.Version)
+ req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
resp, err := http.DefaultClient.Do(req)
@@ -111,7 +149,7 @@ func (s *Server) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber, cha
if err != nil {
return err
}
- req.Header.Set("User-Agent", "ntfy/"+s.config.Version)
+ req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
resp, err := http.DefaultClient.Do(req)
@@ -137,7 +175,7 @@ func (s *Server) verifyPhoneNumberCheck(v *visitor, r *http.Request, phoneNumber
if err != nil {
return err
}
- req.Header.Set("User-Agent", "ntfy/"+s.config.Version)
+ req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
resp, err := http.DefaultClient.Do(req)
diff --git a/server/server_twilio_test.go b/server/server_twilio_test.go
index 2501916a..9b6dcff5 100644
--- a/server/server_twilio_test.go
+++ b/server/server_twilio_test.go
@@ -1,14 +1,16 @@
package server
import (
- "github.com/stretchr/testify/require"
- "heckel.io/ntfy/v2/user"
- "heckel.io/ntfy/v2/util"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
+ "text/template"
+
+ "github.com/stretchr/testify/require"
+ "heckel.io/ntfy/v2/user"
+ "heckel.io/ntfy/v2/util"
)
func TestServer_Twilio_Call_Add_Verify_Call_Delete_Success(t *testing.T) {
@@ -202,6 +204,67 @@ func TestServer_Twilio_Call_Success_With_Yes(t *testing.T) {
})
}
+func TestServer_Twilio_Call_Success_with_custom_twiml(t *testing.T) {
+ var called atomic.Bool
+ twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if called.Load() {
+ t.Fatal("Should be only called once")
+ }
+ body, err := io.ReadAll(r.Body)
+ require.Nil(t, err)
+ require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
+ require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
+ require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+language%3D%22de-DE%22+loop%3D%223%22%3E%0A%09%09Du+hast+eine+Nachricht+von+notify+im+Thema+mytopic.+Nachricht%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09Ende+der+Nachricht.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09Diese+Nachricht+wurde+von+Benutzer+phil+gesendet.+Sie+wird+drei+Mal+wiederholt.%0A%09%09Um+dich+von+Anrufen+wie+diesen+abzumelden%2C+entferne+deine+Telefonnummer+in+der+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay+language%3D%22de-DE%22%3EAuf+Wiederh%C3%B6ren.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
+ called.Store(true)
+ }))
+ defer twilioServer.Close()
+
+ c := newTestConfigWithAuthFile(t)
+ c.TwilioCallsBaseURL = twilioServer.URL
+ c.TwilioAccount = "AC1234567890"
+ c.TwilioAuthToken = "AAEAA1234567890"
+ c.TwilioPhoneNumber = "+1234567890"
+ c.TwilioCallFormat = template.Must(template.New("twiml").Parse(`
+
+
+
+ Du hast eine Nachricht von notify im Thema {{.Topic}}. Nachricht:
+
+ {{.Message}}
+
+ Ende der Nachricht.
+
+ Diese Nachricht wurde von Benutzer {{.Sender}} gesendet. Sie wird drei Mal wiederholt.
+ Um dich von Anrufen wie diesen abzumelden, entferne deine Telefonnummer in der notify web app.
+
+
+ Auf Wiederhören.
+`))
+ s := newTestServer(t, c)
+
+ // Add tier and user
+ require.Nil(t, s.userManager.AddTier(&user.Tier{
+ Code: "pro",
+ MessageLimit: 10,
+ CallLimit: 1,
+ }))
+ require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
+ require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
+ u, err := s.userManager.User("phil")
+ require.Nil(t, err)
+ require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344"))
+
+ // Do the thing
+ response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{
+ "authorization": util.BasicAuth("phil", "phil"),
+ "x-call": "+11122233344",
+ })
+ require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
+ waitFor(t, func() bool {
+ return called.Load()
+ })
+}
+
func TestServer_Twilio_Call_UnverifiedNumber(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.TwilioCallsBaseURL = "http://dummy.invalid"
diff --git a/server/server_webpush.go b/server/server_webpush.go
index 526e06f2..d3f09bd9 100644
--- a/server/server_webpush.go
+++ b/server/server_webpush.go
@@ -89,7 +89,7 @@ func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) {
return
}
log.Tag(tagWebPush).With(v, m).Debug("Publishing web push message to %d subscribers", len(subscriptions))
- payload, err := json.Marshal(newWebPushPayload(fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic), m))
+ payload, err := json.Marshal(newWebPushPayload(fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic), m.forJSON()))
if err != nil {
log.Tag(tagWebPush).Err(err).With(v, m).Warn("Unable to marshal expiring payload")
return
diff --git a/server/types.go b/server/types.go
index d9519b94..fae44e5b 100644
--- a/server/types.go
+++ b/server/types.go
@@ -12,10 +12,12 @@ import (
// List of possible events
const (
- openEvent = "open"
- keepaliveEvent = "keepalive"
- messageEvent = "message"
- pollRequestEvent = "poll_request"
+ openEvent = "open"
+ keepaliveEvent = "keepalive"
+ messageEvent = "message"
+ messageDeleteEvent = "message_delete"
+ messageClearEvent = "message_clear"
+ pollRequestEvent = "poll_request"
)
const (
@@ -24,10 +26,11 @@ const (
// message represents a message published to a topic
type message struct {
- ID string `json:"id"` // Random message ID
- Time int64 `json:"time"` // Unix time in seconds
- Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive)
- Event string `json:"event"` // One of the above
+ ID string `json:"id"` // Random message ID
+ SequenceID string `json:"sequence_id,omitempty"` // Message sequence ID for updating message contents (omitted if same as ID)
+ Time int64 `json:"time"` // Unix time in seconds
+ Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive)
+ Event string `json:"event"` // One of the above
Topic string `json:"topic"`
Title string `json:"title,omitempty"`
Message string `json:"message,omitempty"`
@@ -39,18 +42,19 @@ type message struct {
Attachment *attachment `json:"attachment,omitempty"`
PollID string `json:"poll_id,omitempty"`
ContentType string `json:"content_type,omitempty"` // text/plain by default (if empty), or text/markdown
- Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes
+ Encoding string `json:"encoding,omitempty"` // Empty for raw UTF-8, or "base64" for encoded bytes
Sender netip.Addr `json:"-"` // IP address of uploader, used for rate limiting
User string `json:"-"` // UserID of the uploader, used to associated attachments
}
func (m *message) Context() log.Context {
fields := map[string]any{
- "topic": m.Topic,
- "message_id": m.ID,
- "message_time": m.Time,
- "message_event": m.Event,
- "message_body_size": len(m.Message),
+ "topic": m.Topic,
+ "message_id": m.ID,
+ "message_sequence_id": m.SequenceID,
+ "message_time": m.Time,
+ "message_event": m.Event,
+ "message_body_size": len(m.Message),
}
if m.Sender.IsValid() {
fields["message_sender"] = m.Sender.String()
@@ -61,6 +65,17 @@ func (m *message) Context() log.Context {
return fields
}
+// forJSON returns a copy of the message suitable for JSON output.
+// It clears the SequenceID if it equals the ID to reduce redundancy.
+func (m *message) forJSON() *message {
+ if m.SequenceID == m.ID {
+ clone := *m
+ clone.SequenceID = ""
+ return &clone
+ }
+ return m
+}
+
type attachment struct {
Name string `json:"name"`
Type string `json:"type,omitempty"`
@@ -91,22 +106,23 @@ func newAction() *action {
// publishMessage is used as input when publishing as JSON
type publishMessage struct {
- Topic string `json:"topic"`
- Title string `json:"title"`
- Message string `json:"message"`
- Priority int `json:"priority"`
- Tags []string `json:"tags"`
- Click string `json:"click"`
- Icon string `json:"icon"`
- Actions []action `json:"actions"`
- Attach string `json:"attach"`
- Markdown bool `json:"markdown"`
- Filename string `json:"filename"`
- Email string `json:"email"`
- Call string `json:"call"`
- Cache string `json:"cache"` // use string as it defaults to true (or use &bool instead)
- Firebase string `json:"firebase"` // use string as it defaults to true (or use &bool instead)
- Delay string `json:"delay"`
+ Topic string `json:"topic"`
+ SequenceID string `json:"sequence_id"`
+ Title string `json:"title"`
+ Message string `json:"message"`
+ Priority int `json:"priority"`
+ Tags []string `json:"tags"`
+ Click string `json:"click"`
+ Icon string `json:"icon"`
+ Actions []action `json:"actions"`
+ Attach string `json:"attach"`
+ Markdown bool `json:"markdown"`
+ Filename string `json:"filename"`
+ Email string `json:"email"`
+ Call string `json:"call"`
+ Cache string `json:"cache"` // use string as it defaults to true (or use &bool instead)
+ Firebase string `json:"firebase"` // use string as it defaults to true (or use &bool instead)
+ Delay string `json:"delay"`
}
// messageEncoder is a function that knows how to encode a message
@@ -145,6 +161,13 @@ func newPollRequestMessage(topic, pollID string) *message {
return m
}
+// newActionMessage creates a new action message (message_delete or message_clear)
+func newActionMessage(event, topic, sequenceID string) *message {
+ m := newMessage(event, topic, "")
+ m.SequenceID = sequenceID
+ return m
+}
+
func validMessageID(s string) bool {
return util.ValidRandomString(s, messageIDLength)
}
@@ -223,7 +246,7 @@ func parseQueryFilters(r *http.Request) (*queryFilter, error) {
}
func (q *queryFilter) Pass(msg *message) bool {
- if msg.Event != messageEvent {
+ if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageClearEvent {
return true // filters only apply to messages
} else if q.ID != "" && msg.ID != q.ID {
return false
@@ -459,6 +482,7 @@ type apiConfigResponse struct {
BillingContact string `json:"billing_contact"`
WebPushPublicKey string `json:"web_push_public_key"`
DisallowedTopics []string `json:"disallowed_topics"`
+ ConfigHash string `json:"config_hash"`
}
type apiAccountBillingPrices struct {
diff --git a/tools/shrink-png.sh b/tools/shrink-png.sh
new file mode 100755
index 00000000..b64ae53b
--- /dev/null
+++ b/tools/shrink-png.sh
@@ -0,0 +1,27 @@
+#!/bin/bash
+#
+# Shrinks PNG files to a max height of 1200px
+# Usage: ./shrink-png.sh file1.png file2.png ...
+#
+
+MAX_HEIGHT=1200
+
+if [ $# -eq 0 ]; then
+ echo "Usage: $0 file1.png file2.png ..."
+ exit 1
+fi
+
+for file in "$@"; do
+ if [ ! -f "$file" ]; then
+ echo "File not found: $file"
+ continue
+ fi
+
+ height=$(identify -format "%h" "$file")
+ if [ "$height" -gt "$MAX_HEIGHT" ]; then
+ echo "Shrinking $file (${height}px -> ${MAX_HEIGHT}px)"
+ convert "$file" -resize "x${MAX_HEIGHT}" "$file"
+ else
+ echo "Skipping $file (${height}px <= ${MAX_HEIGHT}px)"
+ fi
+done
diff --git a/web/package-lock.json b/web/package-lock.json
index 789b95f7..e0a8c3c7 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -46,12 +46,12 @@
}
},
"node_modules/@babel/code-frame": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
- "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
+ "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==",
"license": "MIT",
"dependencies": {
- "@babel/helper-validator-identifier": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@@ -60,9 +60,9 @@
}
},
"node_modules/@babel/compat-data": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz",
- "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz",
+ "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -70,21 +70,21 @@
}
},
"node_modules/@babel/core": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
- "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz",
+ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.27.1",
- "@babel/generator": "^7.28.5",
- "@babel/helper-compilation-targets": "^7.27.2",
- "@babel/helper-module-transforms": "^7.28.3",
- "@babel/helpers": "^7.28.4",
- "@babel/parser": "^7.28.5",
- "@babel/template": "^7.27.2",
- "@babel/traverse": "^7.28.5",
- "@babel/types": "^7.28.5",
+ "@babel/code-frame": "^7.28.6",
+ "@babel/generator": "^7.28.6",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
@@ -108,13 +108,13 @@
"license": "MIT"
},
"node_modules/@babel/generator": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz",
- "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz",
+ "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==",
"license": "MIT",
"dependencies": {
- "@babel/parser": "^7.28.5",
- "@babel/types": "^7.28.5",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -137,13 +137,13 @@
}
},
"node_modules/@babel/helper-compilation-targets": {
- "version": "7.27.2",
- "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
- "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/compat-data": "^7.27.2",
+ "@babel/compat-data": "^7.28.6",
"@babel/helper-validator-option": "^7.27.1",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
@@ -154,18 +154,18 @@
}
},
"node_modules/@babel/helper-create-class-features-plugin": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.5.tgz",
- "integrity": "sha512-q3WC4JfdODypvxArsJQROfupPBq9+lMwjKq7C33GhbFYJsufD0yd/ziwD+hJucLeWsnFPWZjsU2DNFqBPE7jwQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz",
+ "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.27.3",
"@babel/helper-member-expression-to-functions": "^7.28.5",
"@babel/helper-optimise-call-expression": "^7.27.1",
- "@babel/helper-replace-supers": "^7.27.1",
+ "@babel/helper-replace-supers": "^7.28.6",
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
- "@babel/traverse": "^7.28.5",
+ "@babel/traverse": "^7.28.6",
"semver": "^6.3.1"
},
"engines": {
@@ -234,28 +234,28 @@
}
},
"node_modules/@babel/helper-module-imports": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
- "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
"license": "MIT",
"dependencies": {
- "@babel/traverse": "^7.27.1",
- "@babel/types": "^7.27.1"
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
- "version": "7.28.3",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
- "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-module-imports": "^7.27.1",
- "@babel/helper-validator-identifier": "^7.27.1",
- "@babel/traverse": "^7.28.3"
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -278,9 +278,9 @@
}
},
"node_modules/@babel/helper-plugin-utils": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz",
- "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
"dev": true,
"license": "MIT",
"engines": {
@@ -306,15 +306,15 @@
}
},
"node_modules/@babel/helper-replace-supers": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz",
- "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz",
+ "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-member-expression-to-functions": "^7.27.1",
+ "@babel/helper-member-expression-to-functions": "^7.28.5",
"@babel/helper-optimise-call-expression": "^7.27.1",
- "@babel/traverse": "^7.27.1"
+ "@babel/traverse": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -366,41 +366,41 @@
}
},
"node_modules/@babel/helper-wrap-function": {
- "version": "7.28.3",
- "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.3.tgz",
- "integrity": "sha512-zdf983tNfLZFletc0RRXYrHrucBEg95NIFMkn6K9dbeMYnsgHaSBGcQqdsCSStG2PYwRre0Qc2NNSCXbG+xc6g==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz",
+ "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/template": "^7.27.2",
- "@babel/traverse": "^7.28.3",
- "@babel/types": "^7.28.2"
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helpers": {
- "version": "7.28.4",
- "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz",
- "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
+ "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/template": "^7.27.2",
- "@babel/types": "^7.28.4"
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
- "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz",
+ "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==",
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.28.5"
+ "@babel/types": "^7.28.6"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -477,14 +477,14 @@
}
},
"node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": {
- "version": "7.28.3",
- "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.3.tgz",
- "integrity": "sha512-b6YTX108evsvE4YgWyQ921ZAFFQm3Bn+CA3+ZXlNVnPhx+UfsVURoPjfGAPCjBgrqo30yX/C2nZGX96DxvR9Iw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz",
+ "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1",
- "@babel/traverse": "^7.28.3"
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/traverse": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -507,13 +507,13 @@
}
},
"node_modules/@babel/plugin-syntax-import-assertions": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz",
- "integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz",
+ "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -523,13 +523,13 @@
}
},
"node_modules/@babel/plugin-syntax-import-attributes": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz",
- "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz",
+ "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -572,15 +572,15 @@
}
},
"node_modules/@babel/plugin-transform-async-generator-functions": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.0.tgz",
- "integrity": "sha512-BEOdvX4+M765icNPZeidyADIvQ1m1gmunXufXxvRESy/jNNyfovIqUyE7MVgGBjWktCoJlzvFA1To2O4ymIO3Q==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.6.tgz",
+ "integrity": "sha512-9knsChgsMzBV5Yh3kkhrZNxH3oCYAfMBkNNaVN4cP2RVlFPe8wYdwwcnOsAbkdDoV9UjFtOXWrWB52M8W4jNeA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/helper-plugin-utils": "^7.28.6",
"@babel/helper-remap-async-to-generator": "^7.27.1",
- "@babel/traverse": "^7.28.0"
+ "@babel/traverse": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -590,14 +590,14 @@
}
},
"node_modules/@babel/plugin-transform-async-to-generator": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.27.1.tgz",
- "integrity": "sha512-NREkZsZVJS4xmTr8qzE5y8AfIPqsdQfRuUiLRTEzb7Qii8iFWCyDKaUV2c0rCuh4ljDZ98ALHP/PetiBV2nddA==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz",
+ "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-module-imports": "^7.27.1",
- "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
"@babel/helper-remap-async-to-generator": "^7.27.1"
},
"engines": {
@@ -624,13 +624,13 @@
}
},
"node_modules/@babel/plugin-transform-block-scoping": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.5.tgz",
- "integrity": "sha512-45DmULpySVvmq9Pj3X9B+62Xe+DJGov27QravQJU1LLcapR6/10i+gYVAucGGJpHBp5mYxIMK4nDAT/QDLr47g==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz",
+ "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -640,14 +640,14 @@
}
},
"node_modules/@babel/plugin-transform-class-properties": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz",
- "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz",
+ "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-create-class-features-plugin": "^7.27.1",
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-create-class-features-plugin": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -657,14 +657,14 @@
}
},
"node_modules/@babel/plugin-transform-class-static-block": {
- "version": "7.28.3",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.3.tgz",
- "integrity": "sha512-LtPXlBbRoc4Njl/oh1CeD/3jC+atytbnf/UqLoqTDcEYGUPj022+rvfkbDYieUrSj3CaV4yHDByPE+T2HwfsJg==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz",
+ "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-create-class-features-plugin": "^7.28.3",
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-create-class-features-plugin": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -674,18 +674,18 @@
}
},
"node_modules/@babel/plugin-transform-classes": {
- "version": "7.28.4",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz",
- "integrity": "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz",
+ "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.27.3",
- "@babel/helper-compilation-targets": "^7.27.2",
+ "@babel/helper-compilation-targets": "^7.28.6",
"@babel/helper-globals": "^7.28.0",
- "@babel/helper-plugin-utils": "^7.27.1",
- "@babel/helper-replace-supers": "^7.27.1",
- "@babel/traverse": "^7.28.4"
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-replace-supers": "^7.28.6",
+ "@babel/traverse": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -695,14 +695,14 @@
}
},
"node_modules/@babel/plugin-transform-computed-properties": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz",
- "integrity": "sha512-lj9PGWvMTVksbWiDT2tW68zGS/cyo4AkZ/QTp0sQT0mjPopCmrSkzxeXkznjqBxzDI6TclZhOJbBmbBLjuOZUw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz",
+ "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1",
- "@babel/template": "^7.27.1"
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/template": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -729,14 +729,14 @@
}
},
"node_modules/@babel/plugin-transform-dotall-regex": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.27.1.tgz",
- "integrity": "sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz",
+ "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-create-regexp-features-plugin": "^7.27.1",
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -762,14 +762,14 @@
}
},
"node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.27.1.tgz",
- "integrity": "sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.28.6.tgz",
+ "integrity": "sha512-5suVoXjC14lUN6ZL9OLKIHCNVWCrqGqlmEp/ixdXjvgnEl/kauLvvMO/Xw9NyMc95Joj1AeLVPVMvibBgSoFlA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-create-regexp-features-plugin": "^7.27.1",
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -795,14 +795,14 @@
}
},
"node_modules/@babel/plugin-transform-explicit-resource-management": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.0.tgz",
- "integrity": "sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz",
+ "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1",
- "@babel/plugin-transform-destructuring": "^7.28.0"
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/plugin-transform-destructuring": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
@@ -812,13 +812,13 @@
}
},
"node_modules/@babel/plugin-transform-exponentiation-operator": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.5.tgz",
- "integrity": "sha512-D4WIMaFtwa2NizOp+dnoFjRez/ClKiC2BqqImwKd1X28nqBtZEyCYJ2ozQrrzlxAFrcrjxo39S6khe9RNDlGzw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz",
+ "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -879,13 +879,13 @@
}
},
"node_modules/@babel/plugin-transform-json-strings": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.27.1.tgz",
- "integrity": "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz",
+ "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -911,13 +911,13 @@
}
},
"node_modules/@babel/plugin-transform-logical-assignment-operators": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.5.tgz",
- "integrity": "sha512-axUuqnUTBuXyHGcJEVVh9pORaN6wC5bYfE7FGzPiaWa3syib9m7g+/IT/4VgCOe2Upef43PHzeAvcrVek6QuuA==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz",
+ "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -960,14 +960,14 @@
}
},
"node_modules/@babel/plugin-transform-modules-commonjs": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz",
- "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz",
+ "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-module-transforms": "^7.27.1",
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1046,13 +1046,13 @@
}
},
"node_modules/@babel/plugin-transform-nullish-coalescing-operator": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz",
- "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz",
+ "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1062,13 +1062,13 @@
}
},
"node_modules/@babel/plugin-transform-numeric-separator": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.27.1.tgz",
- "integrity": "sha512-fdPKAcujuvEChxDBJ5c+0BTaS6revLV7CJL08e4m3de8qJfNIuCc2nc7XJYOjBoTMJeqSmwXJ0ypE14RCjLwaw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz",
+ "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1078,17 +1078,17 @@
}
},
"node_modules/@babel/plugin-transform-object-rest-spread": {
- "version": "7.28.4",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.4.tgz",
- "integrity": "sha512-373KA2HQzKhQCYiRVIRr+3MjpCObqzDlyrM6u4I201wL8Mp2wHf7uB8GhDwis03k2ti8Zr65Zyyqs1xOxUF/Ew==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz",
+ "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-compilation-targets": "^7.27.2",
- "@babel/helper-plugin-utils": "^7.27.1",
- "@babel/plugin-transform-destructuring": "^7.28.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/plugin-transform-destructuring": "^7.28.5",
"@babel/plugin-transform-parameters": "^7.27.7",
- "@babel/traverse": "^7.28.4"
+ "@babel/traverse": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1115,13 +1115,13 @@
}
},
"node_modules/@babel/plugin-transform-optional-catch-binding": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.27.1.tgz",
- "integrity": "sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz",
+ "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1131,13 +1131,13 @@
}
},
"node_modules/@babel/plugin-transform-optional-chaining": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.5.tgz",
- "integrity": "sha512-N6fut9IZlPnjPwgiQkXNhb+cT8wQKFlJNqcZkWlcTqkcqx6/kU4ynGmLFoa4LViBSirn05YAwk+sQBbPfxtYzQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz",
+ "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/helper-plugin-utils": "^7.28.6",
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1"
},
"engines": {
@@ -1164,14 +1164,14 @@
}
},
"node_modules/@babel/plugin-transform-private-methods": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.27.1.tgz",
- "integrity": "sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz",
+ "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-create-class-features-plugin": "^7.27.1",
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-create-class-features-plugin": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1181,15 +1181,15 @@
}
},
"node_modules/@babel/plugin-transform-private-property-in-object": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.27.1.tgz",
- "integrity": "sha512-5J+IhqTi1XPa0DXF83jYOaARrX+41gOewWbkPyjMNRDqgOCqdffGh8L3f/Ek5utaEBZExjSAzcyjmV9SSAWObQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz",
+ "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-annotate-as-pure": "^7.27.1",
- "@babel/helper-create-class-features-plugin": "^7.27.1",
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-annotate-as-pure": "^7.27.3",
+ "@babel/helper-create-class-features-plugin": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1247,13 +1247,13 @@
}
},
"node_modules/@babel/plugin-transform-regenerator": {
- "version": "7.28.4",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.4.tgz",
- "integrity": "sha512-+ZEdQlBoRg9m2NnzvEeLgtvBMO4tkFBw5SQIUgLICgTrumLoU7lr+Oghi6km2PFj+dbUt2u1oby2w3BDO9YQnA==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.6.tgz",
+ "integrity": "sha512-eZhoEZHYQLL5uc1gS5e9/oTknS0sSSAtd5TkKMUp3J+S/CaUjagc0kOUPsEbDmMeva0nC3WWl4SxVY6+OBuxfw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1263,14 +1263,14 @@
}
},
"node_modules/@babel/plugin-transform-regexp-modifiers": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.27.1.tgz",
- "integrity": "sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz",
+ "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-create-regexp-features-plugin": "^7.27.1",
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1312,13 +1312,13 @@
}
},
"node_modules/@babel/plugin-transform-spread": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.27.1.tgz",
- "integrity": "sha512-kpb3HUqaILBJcRFVhFUs6Trdd4mkrzcGXss+6/mxUd273PfbWqSDHRzMT2234gIg2QYfAjvXLSquP1xECSg09Q==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz",
+ "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/helper-plugin-utils": "^7.28.6",
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1"
},
"engines": {
@@ -1393,14 +1393,14 @@
}
},
"node_modules/@babel/plugin-transform-unicode-property-regex": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.27.1.tgz",
- "integrity": "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz",
+ "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-create-regexp-features-plugin": "^7.27.1",
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1427,14 +1427,14 @@
}
},
"node_modules/@babel/plugin-transform-unicode-sets-regex": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.27.1.tgz",
- "integrity": "sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz",
+ "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-create-regexp-features-plugin": "^7.27.1",
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1444,76 +1444,76 @@
}
},
"node_modules/@babel/preset-env": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.5.tgz",
- "integrity": "sha512-S36mOoi1Sb6Fz98fBfE+UZSpYw5mJm0NUHtIKrOuNcqeFauy1J6dIvXm2KRVKobOSaGq4t/hBXdN4HGU3wL9Wg==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.6.tgz",
+ "integrity": "sha512-GaTI4nXDrs7l0qaJ6Rg06dtOXTBCG6TMDB44zbqofCIC4PqC7SEvmFFtpxzCDw9W5aJ7RKVshgXTLvLdBFV/qw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/compat-data": "^7.28.5",
- "@babel/helper-compilation-targets": "^7.27.2",
- "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
"@babel/helper-validator-option": "^7.27.1",
"@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5",
"@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1",
"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1",
"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1",
- "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.3",
+ "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6",
"@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2",
- "@babel/plugin-syntax-import-assertions": "^7.27.1",
- "@babel/plugin-syntax-import-attributes": "^7.27.1",
+ "@babel/plugin-syntax-import-assertions": "^7.28.6",
+ "@babel/plugin-syntax-import-attributes": "^7.28.6",
"@babel/plugin-syntax-unicode-sets-regex": "^7.18.6",
"@babel/plugin-transform-arrow-functions": "^7.27.1",
- "@babel/plugin-transform-async-generator-functions": "^7.28.0",
- "@babel/plugin-transform-async-to-generator": "^7.27.1",
+ "@babel/plugin-transform-async-generator-functions": "^7.28.6",
+ "@babel/plugin-transform-async-to-generator": "^7.28.6",
"@babel/plugin-transform-block-scoped-functions": "^7.27.1",
- "@babel/plugin-transform-block-scoping": "^7.28.5",
- "@babel/plugin-transform-class-properties": "^7.27.1",
- "@babel/plugin-transform-class-static-block": "^7.28.3",
- "@babel/plugin-transform-classes": "^7.28.4",
- "@babel/plugin-transform-computed-properties": "^7.27.1",
+ "@babel/plugin-transform-block-scoping": "^7.28.6",
+ "@babel/plugin-transform-class-properties": "^7.28.6",
+ "@babel/plugin-transform-class-static-block": "^7.28.6",
+ "@babel/plugin-transform-classes": "^7.28.6",
+ "@babel/plugin-transform-computed-properties": "^7.28.6",
"@babel/plugin-transform-destructuring": "^7.28.5",
- "@babel/plugin-transform-dotall-regex": "^7.27.1",
+ "@babel/plugin-transform-dotall-regex": "^7.28.6",
"@babel/plugin-transform-duplicate-keys": "^7.27.1",
- "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.27.1",
+ "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.28.6",
"@babel/plugin-transform-dynamic-import": "^7.27.1",
- "@babel/plugin-transform-explicit-resource-management": "^7.28.0",
- "@babel/plugin-transform-exponentiation-operator": "^7.28.5",
+ "@babel/plugin-transform-explicit-resource-management": "^7.28.6",
+ "@babel/plugin-transform-exponentiation-operator": "^7.28.6",
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
"@babel/plugin-transform-for-of": "^7.27.1",
"@babel/plugin-transform-function-name": "^7.27.1",
- "@babel/plugin-transform-json-strings": "^7.27.1",
+ "@babel/plugin-transform-json-strings": "^7.28.6",
"@babel/plugin-transform-literals": "^7.27.1",
- "@babel/plugin-transform-logical-assignment-operators": "^7.28.5",
+ "@babel/plugin-transform-logical-assignment-operators": "^7.28.6",
"@babel/plugin-transform-member-expression-literals": "^7.27.1",
"@babel/plugin-transform-modules-amd": "^7.27.1",
- "@babel/plugin-transform-modules-commonjs": "^7.27.1",
+ "@babel/plugin-transform-modules-commonjs": "^7.28.6",
"@babel/plugin-transform-modules-systemjs": "^7.28.5",
"@babel/plugin-transform-modules-umd": "^7.27.1",
"@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1",
"@babel/plugin-transform-new-target": "^7.27.1",
- "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1",
- "@babel/plugin-transform-numeric-separator": "^7.27.1",
- "@babel/plugin-transform-object-rest-spread": "^7.28.4",
+ "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6",
+ "@babel/plugin-transform-numeric-separator": "^7.28.6",
+ "@babel/plugin-transform-object-rest-spread": "^7.28.6",
"@babel/plugin-transform-object-super": "^7.27.1",
- "@babel/plugin-transform-optional-catch-binding": "^7.27.1",
- "@babel/plugin-transform-optional-chaining": "^7.28.5",
+ "@babel/plugin-transform-optional-catch-binding": "^7.28.6",
+ "@babel/plugin-transform-optional-chaining": "^7.28.6",
"@babel/plugin-transform-parameters": "^7.27.7",
- "@babel/plugin-transform-private-methods": "^7.27.1",
- "@babel/plugin-transform-private-property-in-object": "^7.27.1",
+ "@babel/plugin-transform-private-methods": "^7.28.6",
+ "@babel/plugin-transform-private-property-in-object": "^7.28.6",
"@babel/plugin-transform-property-literals": "^7.27.1",
- "@babel/plugin-transform-regenerator": "^7.28.4",
- "@babel/plugin-transform-regexp-modifiers": "^7.27.1",
+ "@babel/plugin-transform-regenerator": "^7.28.6",
+ "@babel/plugin-transform-regexp-modifiers": "^7.28.6",
"@babel/plugin-transform-reserved-words": "^7.27.1",
"@babel/plugin-transform-shorthand-properties": "^7.27.1",
- "@babel/plugin-transform-spread": "^7.27.1",
+ "@babel/plugin-transform-spread": "^7.28.6",
"@babel/plugin-transform-sticky-regex": "^7.27.1",
"@babel/plugin-transform-template-literals": "^7.27.1",
"@babel/plugin-transform-typeof-symbol": "^7.27.1",
"@babel/plugin-transform-unicode-escapes": "^7.27.1",
- "@babel/plugin-transform-unicode-property-regex": "^7.27.1",
+ "@babel/plugin-transform-unicode-property-regex": "^7.28.6",
"@babel/plugin-transform-unicode-regex": "^7.27.1",
- "@babel/plugin-transform-unicode-sets-regex": "^7.27.1",
+ "@babel/plugin-transform-unicode-sets-regex": "^7.28.6",
"@babel/preset-modules": "0.1.6-no-external-plugins",
"babel-plugin-polyfill-corejs2": "^0.4.14",
"babel-plugin-polyfill-corejs3": "^0.13.0",
@@ -1544,40 +1544,40 @@
}
},
"node_modules/@babel/runtime": {
- "version": "7.28.4",
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
- "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
+ "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
- "version": "7.27.2",
- "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
- "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.27.1",
- "@babel/parser": "^7.27.2",
- "@babel/types": "^7.27.1"
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz",
- "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz",
+ "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==",
"license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.27.1",
- "@babel/generator": "^7.28.5",
+ "@babel/code-frame": "^7.28.6",
+ "@babel/generator": "^7.28.6",
"@babel/helper-globals": "^7.28.0",
- "@babel/parser": "^7.28.5",
- "@babel/template": "^7.27.2",
- "@babel/types": "^7.28.5",
+ "@babel/parser": "^7.28.6",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.28.6",
"debug": "^4.3.1"
},
"engines": {
@@ -1585,9 +1585,9 @@
}
},
"node_modules/@babel/types": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
- "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz",
+ "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
@@ -2198,9 +2198,9 @@
}
},
"node_modules/@eslint-community/eslint-utils": {
- "version": "4.9.0",
- "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
- "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2711,9 +2711,9 @@
}
},
"node_modules/@remix-run/router": {
- "version": "1.23.1",
- "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz",
- "integrity": "sha512-vDbaOzF7yT2Qs4vO6XV1MHcJv+3dgR1sT+l3B8xxOVhUC336prMvqrvsLL/9Dnw2xr6Qhz4J0dmS0llNAbnUmQ==",
+ "version": "1.23.2",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
+ "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
@@ -2798,9 +2798,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.54.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz",
- "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==",
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz",
+ "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==",
"cpu": [
"arm"
],
@@ -2812,9 +2812,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
- "version": "4.54.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz",
- "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==",
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz",
+ "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==",
"cpu": [
"arm64"
],
@@ -2826,9 +2826,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.54.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz",
- "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==",
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz",
+ "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==",
"cpu": [
"arm64"
],
@@ -2840,9 +2840,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.54.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz",
- "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==",
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz",
+ "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==",
"cpu": [
"x64"
],
@@ -2854,9 +2854,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
- "version": "4.54.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz",
- "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==",
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz",
+ "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==",
"cpu": [
"arm64"
],
@@ -2868,9 +2868,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
- "version": "4.54.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz",
- "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==",
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz",
+ "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==",
"cpu": [
"x64"
],
@@ -2882,9 +2882,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.54.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz",
- "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==",
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz",
+ "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==",
"cpu": [
"arm"
],
@@ -2896,9 +2896,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.54.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz",
- "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==",
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz",
+ "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==",
"cpu": [
"arm"
],
@@ -2910,9 +2910,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.54.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz",
- "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==",
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz",
+ "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==",
"cpu": [
"arm64"
],
@@ -2924,9 +2924,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.54.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz",
- "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==",
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz",
+ "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==",
"cpu": [
"arm64"
],
@@ -2938,9 +2938,23 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
- "version": "4.54.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz",
- "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==",
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz",
+ "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz",
+ "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==",
"cpu": [
"loong64"
],
@@ -2952,9 +2966,23 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
- "version": "4.54.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz",
- "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==",
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz",
+ "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz",
+ "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==",
"cpu": [
"ppc64"
],
@@ -2966,9 +2994,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.54.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz",
- "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==",
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz",
+ "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==",
"cpu": [
"riscv64"
],
@@ -2980,9 +3008,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
- "version": "4.54.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz",
- "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==",
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz",
+ "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==",
"cpu": [
"riscv64"
],
@@ -2994,9 +3022,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.54.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz",
- "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==",
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz",
+ "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==",
"cpu": [
"s390x"
],
@@ -3008,9 +3036,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.54.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz",
- "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==",
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz",
+ "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==",
"cpu": [
"x64"
],
@@ -3022,9 +3050,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.54.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz",
- "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==",
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz",
+ "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==",
"cpu": [
"x64"
],
@@ -3035,10 +3063,24 @@
"linux"
]
},
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz",
+ "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
"node_modules/@rollup/rollup-openharmony-arm64": {
- "version": "4.54.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz",
- "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==",
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz",
+ "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==",
"cpu": [
"arm64"
],
@@ -3050,9 +3092,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.54.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz",
- "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==",
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz",
+ "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==",
"cpu": [
"arm64"
],
@@ -3064,9 +3106,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.54.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz",
- "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==",
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz",
+ "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==",
"cpu": [
"ia32"
],
@@ -3078,9 +3120,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
- "version": "4.54.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz",
- "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==",
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz",
+ "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==",
"cpu": [
"x64"
],
@@ -3092,9 +3134,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.54.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz",
- "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==",
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz",
+ "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==",
"cpu": [
"x64"
],
@@ -3206,9 +3248,9 @@
"license": "MIT"
},
"node_modules/@types/react": {
- "version": "19.2.7",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
- "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz",
+ "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -3566,9 +3608,9 @@
}
},
"node_modules/axe-core": {
- "version": "4.11.0",
- "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz",
- "integrity": "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==",
+ "version": "4.11.1",
+ "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz",
+ "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==",
"dev": true,
"license": "MPL-2.0",
"engines": {
@@ -3660,9 +3702,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
- "version": "2.9.11",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
- "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==",
+ "version": "2.9.15",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz",
+ "integrity": "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -3781,9 +3823,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001761",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz",
- "integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==",
+ "version": "1.0.30001765",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz",
+ "integrity": "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==",
"dev": true,
"funding": [
{
@@ -4872,9 +4914,9 @@
}
},
"node_modules/esquery": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
- "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -4969,9 +5011,9 @@
"license": "BSD-3-Clause"
},
"node_modules/fastq": {
- "version": "1.19.1",
- "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
- "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
+ "version": "1.20.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -7329,12 +7371,12 @@
}
},
"node_modules/react-router": {
- "version": "6.30.2",
- "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.2.tgz",
- "integrity": "sha512-H2Bm38Zu1bm8KUE5NVWRMzuIyAV8p/JrOaBJAwVmp37AXG72+CZJlEBw6pdn9i5TBgLMhNDgijS4ZlblpHyWTA==",
+ "version": "6.30.3",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
+ "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
"license": "MIT",
"dependencies": {
- "@remix-run/router": "1.23.1"
+ "@remix-run/router": "1.23.2"
},
"engines": {
"node": ">=14.0.0"
@@ -7344,13 +7386,13 @@
}
},
"node_modules/react-router-dom": {
- "version": "6.30.2",
- "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.2.tgz",
- "integrity": "sha512-l2OwHn3UUnEVUqc6/1VMmR1cvZryZ3j3NzapC2eUXO1dB0sYp5mvwdjiXhpUbRb21eFow3qSxpP8Yv6oAU824Q==",
+ "version": "6.30.3",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
+ "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
"license": "MIT",
"dependencies": {
- "@remix-run/router": "1.23.1",
- "react-router": "6.30.2"
+ "@remix-run/router": "1.23.2",
+ "react-router": "6.30.3"
},
"engines": {
"node": ">=14.0.0"
@@ -7586,9 +7628,9 @@
}
},
"node_modules/rollup": {
- "version": "4.54.0",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
- "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz",
+ "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7602,28 +7644,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
- "@rollup/rollup-android-arm-eabi": "4.54.0",
- "@rollup/rollup-android-arm64": "4.54.0",
- "@rollup/rollup-darwin-arm64": "4.54.0",
- "@rollup/rollup-darwin-x64": "4.54.0",
- "@rollup/rollup-freebsd-arm64": "4.54.0",
- "@rollup/rollup-freebsd-x64": "4.54.0",
- "@rollup/rollup-linux-arm-gnueabihf": "4.54.0",
- "@rollup/rollup-linux-arm-musleabihf": "4.54.0",
- "@rollup/rollup-linux-arm64-gnu": "4.54.0",
- "@rollup/rollup-linux-arm64-musl": "4.54.0",
- "@rollup/rollup-linux-loong64-gnu": "4.54.0",
- "@rollup/rollup-linux-ppc64-gnu": "4.54.0",
- "@rollup/rollup-linux-riscv64-gnu": "4.54.0",
- "@rollup/rollup-linux-riscv64-musl": "4.54.0",
- "@rollup/rollup-linux-s390x-gnu": "4.54.0",
- "@rollup/rollup-linux-x64-gnu": "4.54.0",
- "@rollup/rollup-linux-x64-musl": "4.54.0",
- "@rollup/rollup-openharmony-arm64": "4.54.0",
- "@rollup/rollup-win32-arm64-msvc": "4.54.0",
- "@rollup/rollup-win32-ia32-msvc": "4.54.0",
- "@rollup/rollup-win32-x64-gnu": "4.54.0",
- "@rollup/rollup-win32-x64-msvc": "4.54.0",
+ "@rollup/rollup-android-arm-eabi": "4.55.1",
+ "@rollup/rollup-android-arm64": "4.55.1",
+ "@rollup/rollup-darwin-arm64": "4.55.1",
+ "@rollup/rollup-darwin-x64": "4.55.1",
+ "@rollup/rollup-freebsd-arm64": "4.55.1",
+ "@rollup/rollup-freebsd-x64": "4.55.1",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.55.1",
+ "@rollup/rollup-linux-arm-musleabihf": "4.55.1",
+ "@rollup/rollup-linux-arm64-gnu": "4.55.1",
+ "@rollup/rollup-linux-arm64-musl": "4.55.1",
+ "@rollup/rollup-linux-loong64-gnu": "4.55.1",
+ "@rollup/rollup-linux-loong64-musl": "4.55.1",
+ "@rollup/rollup-linux-ppc64-gnu": "4.55.1",
+ "@rollup/rollup-linux-ppc64-musl": "4.55.1",
+ "@rollup/rollup-linux-riscv64-gnu": "4.55.1",
+ "@rollup/rollup-linux-riscv64-musl": "4.55.1",
+ "@rollup/rollup-linux-s390x-gnu": "4.55.1",
+ "@rollup/rollup-linux-x64-gnu": "4.55.1",
+ "@rollup/rollup-linux-x64-musl": "4.55.1",
+ "@rollup/rollup-openbsd-x64": "4.55.1",
+ "@rollup/rollup-openharmony-arm64": "4.55.1",
+ "@rollup/rollup-win32-arm64-msvc": "4.55.1",
+ "@rollup/rollup-win32-ia32-msvc": "4.55.1",
+ "@rollup/rollup-win32-x64-gnu": "4.55.1",
+ "@rollup/rollup-win32-x64-msvc": "4.55.1",
"fsevents": "~2.3.2"
}
},
@@ -8391,9 +8436,9 @@
}
},
"node_modules/terser": {
- "version": "5.44.1",
- "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz",
- "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==",
+ "version": "5.46.0",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz",
+ "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
@@ -9076,9 +9121,9 @@
}
},
"node_modules/which-typed-array": {
- "version": "1.1.19",
- "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
- "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==",
+ "version": "1.1.20",
+ "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
+ "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==",
"dev": true,
"license": "MIT",
"dependencies": {
diff --git a/web/public/config.js b/web/public/config.js
index fcc567aa..62f49ed4 100644
--- a/web/public/config.js
+++ b/web/public/config.js
@@ -19,4 +19,5 @@ var config = {
billing_contact: "",
web_push_public_key: "",
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"],
+ config_hash: "dev", // Placeholder for development; actual value is generated server-side
};
diff --git a/web/public/static/images/ntfy.png b/web/public/static/images/ntfy.png
index 6b969a84..05c6fd88 100644
Binary files a/web/public/static/images/ntfy.png and b/web/public/static/images/ntfy.png differ
diff --git a/web/public/static/images/pwa-192x192.png b/web/public/static/images/pwa-192x192.png
index 8aaebcc4..a82c28df 100644
Binary files a/web/public/static/images/pwa-192x192.png and b/web/public/static/images/pwa-192x192.png differ
diff --git a/web/public/static/images/pwa-512x512.png b/web/public/static/images/pwa-512x512.png
index d9003a19..777f457a 100644
Binary files a/web/public/static/images/pwa-512x512.png and b/web/public/static/images/pwa-512x512.png differ
diff --git a/web/public/static/langs/cs.json b/web/public/static/langs/cs.json
index 2279cad1..dde71051 100644
--- a/web/public/static/langs/cs.json
+++ b/web/public/static/langs/cs.json
@@ -403,5 +403,7 @@
"prefs_appearance_theme_light": "Světlý režim",
"web_push_subscription_expiring_title": "Oznámení budou pozastavena",
"web_push_unknown_notification_title": "Neznámé oznámení přijaté ze serveru",
- "web_push_unknown_notification_body": "Možná bude nutné aktualizovat ntfy otevřením webové aplikace"
+ "web_push_unknown_notification_body": "Možná bude nutné aktualizovat ntfy otevřením webové aplikace",
+ "account_basics_cannot_edit_or_delete_provisioned_user": "Přiděleného uživatele nelze upravovat ani odstranit",
+ "account_tokens_table_cannot_delete_or_edit_provisioned_token": "Nelze upravit ani odstranit přidělený token"
}
diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json
index b0d3c545..15e78976 100644
--- a/web/public/static/langs/en.json
+++ b/web/public/static/langs/en.json
@@ -4,6 +4,9 @@
"common_add": "Add",
"common_back": "Back",
"common_copy_to_clipboard": "Copy to clipboard",
+ "common_refresh": "Refresh",
+ "version_update_available_title": "New version available",
+ "version_update_available_description": "The ntfy server has been updated. Please refresh the page.",
"signup_title": "Create a ntfy account",
"signup_form_username": "Username",
"signup_form_password": "Password",
diff --git a/web/public/static/langs/gl.json b/web/public/static/langs/gl.json
index 2fa8c32d..d69da79c 100644
--- a/web/public/static/langs/gl.json
+++ b/web/public/static/langs/gl.json
@@ -406,5 +406,7 @@
"web_push_subscription_expiring_body": "Abrir ntfy para seguir recibindo notificacións",
"web_push_unknown_notification_title": "Recibida unha notificación descoñecida desde o servidor",
"web_push_unknown_notification_body": "Poderías ter que actualizar ntfy abrindo a app web",
- "subscribe_dialog_subscribe_use_another_background_info": "As notificacións procedentes doutros servidores non se van recibir cando a app web estea pechada"
+ "subscribe_dialog_subscribe_use_another_background_info": "As notificacións procedentes doutros servidores non se van recibir cando a app web estea pechada",
+ "account_basics_cannot_edit_or_delete_provisioned_user": "Unha usuaria predefinida non se pode editar ou eliminar",
+ "account_tokens_table_cannot_delete_or_edit_provisioned_token": "Non se pode editar un token de usuaria predefinida"
}
diff --git a/web/public/static/langs/id.json b/web/public/static/langs/id.json
index a149e570..39c6d1af 100644
--- a/web/public/static/langs/id.json
+++ b/web/public/static/langs/id.json
@@ -50,10 +50,10 @@
"publish_dialog_progress_uploading": "Mengunggah …",
"notifications_more_details": "Untuk informasi lanjut, lihat situs web atau dokumentasi.",
"publish_dialog_progress_uploading_detail": "Mengunggah {{loaded}}/{{total}} ({{percent}}%) …",
- "publish_dialog_message_published": "Notifikasi terpublikasi",
+ "publish_dialog_message_published": "Notifikasi dipublikasikan",
"notifications_loading": "Memuat notifikasi …",
"publish_dialog_base_url_label": "URL Layanan",
- "publish_dialog_title_placeholder": "Judul notifikasi, mis. Peringatan ruang disk",
+ "publish_dialog_title_placeholder": "Judul notifikasi, contoh: Peringatan ruang penyimpanan disk",
"publish_dialog_tags_label": "Tanda",
"publish_dialog_priority_label": "Prioritas",
"publish_dialog_base_url_placeholder": "URL Layanan, mis. https://contoh.com",
@@ -71,12 +71,12 @@
"publish_dialog_priority_high": "Prioritas tinggi",
"publish_dialog_priority_max": "Prioritas maksimal",
"publish_dialog_topic_label": "Nama topik",
- "publish_dialog_message_placeholder": "Ketik sebuah pesan di sini",
+ "publish_dialog_message_placeholder": "Tulis pesan di sini",
"publish_dialog_click_label": "Klik URL",
- "publish_dialog_tags_placeholder": "Daftar tanda yang dipisah dengan koma, mis. peringatan, cadangan-srv1",
+ "publish_dialog_tags_placeholder": "Daftar label yang dipisahkan koma, contoh: peringatan, cadangan-srv1",
"publish_dialog_click_placeholder": "URL yang dibuka ketika notifikasi diklik",
"publish_dialog_email_label": "Email",
- "publish_dialog_email_placeholder": "Alamat untuk meneruskan notifikasi, mis. andi@contoh.com",
+ "publish_dialog_email_placeholder": "Alamat untuk meneruskan notifikasi, contoh: phil@example.com",
"publish_dialog_attach_label": "URL Lampiran",
"publish_dialog_filename_label": "Nama File",
"publish_dialog_filename_placeholder": "Nama file lampiran",
@@ -404,5 +404,7 @@
"web_push_subscription_expiring_title": "Notifikasi akan dijeda",
"web_push_subscription_expiring_body": "Buka ntfy untuk terus menerima notifikasi",
"web_push_unknown_notification_title": "Notifikasi yang tidak diketahui diterima dari server",
- "web_push_unknown_notification_body": "Anda mungkin harus memperbarui ntfy dengan membuka aplikasi web"
+ "web_push_unknown_notification_body": "Anda mungkin harus memperbarui ntfy dengan membuka aplikasi web",
+ "account_basics_cannot_edit_or_delete_provisioned_user": "Pengguna yang telah ditetapkan tidak dapat diedit atau dihapus",
+ "account_tokens_table_cannot_delete_or_edit_provisioned_token": "Tidak dapat mengedit atau menghapus token yang disediakan"
}
diff --git a/web/public/static/langs/ja.json b/web/public/static/langs/ja.json
index ebd76b54..1f672b2c 100644
--- a/web/public/static/langs/ja.json
+++ b/web/public/static/langs/ja.json
@@ -14,7 +14,7 @@
"publish_dialog_title_no_topic": "通知を送信",
"publish_dialog_progress_uploading": "アップロード中…",
"publish_dialog_progress_uploading_detail": "アップロード中 {{loaded}}/{{total}} ({{percent}}%) …",
- "publish_dialog_message_published": "通知を送信しました",
+ "publish_dialog_message_published": "通知送信済み",
"publish_dialog_title_label": "タイトル",
"publish_dialog_filename_label": "ファイル名",
"subscribe_dialog_login_description": "このトピックはログインする必要があります。ユーザー名とパスワードを入力してください。",
@@ -69,10 +69,10 @@
"publish_dialog_attachment_limits_quota_reached": "クォータを超過しました、残り{{remainingBytes}}",
"publish_dialog_priority_high": "優先度 高",
"publish_dialog_topic_placeholder": "トピック名の例 phil_alerts",
- "publish_dialog_title_placeholder": "通知タイトル 例: ディスクスペース警告",
+ "publish_dialog_title_placeholder": "通知タイトル、例: ディスクスペース警告",
"publish_dialog_message_placeholder": "メッセージ本文を入力してください",
"publish_dialog_tags_label": "タグ",
- "publish_dialog_tags_placeholder": "コンマ区切りでタグを列挙してください 例: warning, srv1-backup",
+ "publish_dialog_tags_placeholder": "コンマ区切りでタグを列挙してください、例: warning, srv1-backup",
"publish_dialog_topic_label": "トピック名",
"publish_dialog_delay_label": "遅延",
"publish_dialog_click_placeholder": "通知をクリックしたときに開くURL",
diff --git a/web/public/static/langs/mk.json b/web/public/static/langs/mk.json
index b1caef44..bb19fe1a 100644
--- a/web/public/static/langs/mk.json
+++ b/web/public/static/langs/mk.json
@@ -50,5 +50,47 @@
"nav_topics_title": "Претплатени теми",
"nav_button_all_notifications": "Сите нотификации",
"nav_button_publish_message": "Објави нотификација",
- "nav_button_subscribe": "Претплати се на тема"
+ "nav_button_subscribe": "Претплати се на тема",
+ "action_bar_unmute_notifications": "Одглуши ги нотификациите",
+ "action_bar_toggle_mute": "Заглуши/Загуши ги нотификациите",
+ "message_bar_publish": "Објави порака",
+ "nav_button_connecting": "се конектира",
+ "nav_upgrade_banner_label": "Надградете на ntfy Pro",
+ "nav_upgrade_banner_description": "Резервирајте теми, повеќе пораки и е-пораки и поголеми прилози",
+ "alert_notification_permission_required_title": "Известувањата се исклучени",
+ "alert_notification_permission_required_description": "Дајте му дозвола на вашиот прелистувач да прикажува известувања",
+ "nav_button_muted": "Известувањата се загушени",
+ "alert_not_supported_title": "Известувањата не се поддржани",
+ "alert_not_supported_description": "Известувањата не се поддржани во вашиот прелистувач",
+ "alert_not_supported_context_description": "Известувањата се поддржани само преку HTTPS. Ова е ограничување на Notifications API .",
+ "notifications_list": "Список на известувања",
+ "notifications_list_item": "Известување",
+ "notifications_mark_read": "Означи како прочитано",
+ "publish_dialog_attached_file_filename_placeholder": "Име на фајл за прилог",
+ "notifications_attachment_file_app": "Фајл со апликација за Android",
+ "notifications_attachment_file_document": "друг документ",
+ "alert_notification_permission_required_button": "Дајте дозвола сега",
+ "alert_notification_permission_denied_title": "Известувањата се блокирани",
+ "alert_notification_permission_denied_description": "Ве молиме повторно овозможете ги во вашиот пребарувач",
+ "alert_notification_ios_install_required_title": "Потребна е инсталација на iOS",
+ "alert_notification_ios_install_required_description": "Кликнете на иконата Сподели и Додај на почетниот екран за да овозможите известувања на iOS",
+ "notifications_delete": "Избриши",
+ "notifications_copied_to_clipboard": "Копирано во таблата со исечоци",
+ "notifications_tags": "Ознаки",
+ "notifications_priority_x": "Приоритет {{приоритет}}",
+ "notifications_new_indicator": "Ново известување",
+ "notifications_attachment_image": "Слика од прилог",
+ "notifications_attachment_copy_url_title": "Копирај URL-адресата на прилогот во таблата со исечоци",
+ "notifications_attachment_open_title": "Оди на {{url}}",
+ "notifications_attachment_open_button": "Отвори го прилогот",
+ "notifications_attachment_link_expires": "линкот истекува {{date}}",
+ "notifications_attachment_link_expired": "линкот за преземање е истечен",
+ "notifications_attachment_file_image": "слика фајл",
+ "notifications_attachment_file_video": "видео фајл",
+ "notifications_attachment_file_audio": "аудио фајл",
+ "notifications_click_copy_url_button": "Копирај линк",
+ "notifications_click_open_button": "Отвори линк",
+ "notifications_actions_open_url_title": "Оди на {{url}}",
+ "notifications_actions_not_supported": "Дејството не е поддржано во веб-апликацијата",
+ "notifications_actions_http_request_title": "Испрати HTTP {{method}} на {{url}}"
}
diff --git a/web/public/static/langs/zh_Hans.json b/web/public/static/langs/zh_Hans.json
index d421bc35..fbbc1a2f 100644
--- a/web/public/static/langs/zh_Hans.json
+++ b/web/public/static/langs/zh_Hans.json
@@ -182,7 +182,7 @@
"subscribe_dialog_subscribe_topic_placeholder": "主题名,例如 phil_alerts",
"notifications_no_subscriptions_description": "单击 \"{{linktext}}\" 链接以创建或订阅主题。之后,您可以使用 PUT 或 POST 发送消息,您将在这里收到通知。",
"publish_dialog_attachment_limits_file_reached": "超过 {{fileSizeLimit}} 文件限制",
- "publish_dialog_title_placeholder": "主题标题,例如 磁盘空间告警",
+ "publish_dialog_title_placeholder": "通知标题,如磁盘空间告警",
"publish_dialog_email_label": "电子邮件",
"publish_dialog_button_send": "发送",
"publish_dialog_checkbox_markdown": "格式化为 Markdown",
@@ -206,7 +206,7 @@
"publish_dialog_tags_placeholder": "英文逗号分隔的标签列表,例如 warning, srv1-backup",
"publish_dialog_details_examples_description": "有关所有发送功能的示例和详细说明,请参阅文档。",
"subscribe_dialog_subscribe_description": "主题可能不受密码保护,因此请选择一个不容易被猜中的名字。订阅后,您可以使用 PUT/POST 通知。",
- "publish_dialog_delay_placeholder": "延期投递,例如 {{unixTimestamp}}、{{relativeTime}}或「{{naturalLanguage}}」(仅限英语)",
+ "publish_dialog_delay_placeholder": "延期投递,例如 {{unixTimestamp}}、{{relativeTime}} 或 {{naturalLanguage}} (仅限英语)",
"account_usage_basis_ip_description": "此账户的使用统计信息和限制基于您的 IP 地址,因此可能会与其他用户共享。上面显示的限制是基于现有速率限制的近似值。",
"account_usage_cannot_create_portal_session": "无法打开计费门户",
"account_delete_title": "删除账户",
diff --git a/web/public/sw.js b/web/public/sw.js
index 56d66f16..c8808145 100644
--- a/web/public/sw.js
+++ b/web/public/sw.js
@@ -3,11 +3,16 @@ import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from
import { NavigationRoute, registerRoute } from "workbox-routing";
import { NetworkFirst } from "workbox-strategies";
import { clientsClaim } from "workbox-core";
-
import { dbAsync } from "../src/app/db";
-
-import { toNotificationParams, icon, badge } from "../src/app/notificationUtils";
+import { badge, icon, messageWithSequenceId, notificationTag, toNotificationParams } from "../src/app/notificationUtils";
import initI18n from "../src/app/i18n";
+import {
+ EVENT_MESSAGE,
+ EVENT_MESSAGE_CLEAR,
+ EVENT_MESSAGE_DELETE,
+ WEBPUSH_EVENT_MESSAGE,
+ WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING,
+} from "../src/app/events";
/**
* General docs for service workers and PWAs:
@@ -21,25 +26,6 @@ import initI18n from "../src/app/i18n";
const broadcastChannel = new BroadcastChannel("web-push-broadcast");
-const addNotification = async ({ subscriptionId, message }) => {
- const db = await dbAsync();
-
- await db.notifications.add({
- ...message,
- subscriptionId,
- // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
- new: 1,
- });
-
- await db.subscriptions.update(subscriptionId, {
- last: message.id,
- });
-
- const badgeCount = await db.notifications.where({ new: 1 }).count();
- console.log("[ServiceWorker] Setting new app badge count", { badgeCount });
- self.navigator.setAppBadge?.(badgeCount);
-};
-
/**
* Handle a received web push message and show notification.
*
@@ -48,25 +34,127 @@ const addNotification = async ({ subscriptionId, message }) => {
*/
const handlePushMessage = async (data) => {
const { subscription_id: subscriptionId, message } = data;
+ const db = await dbAsync();
- broadcastChannel.postMessage(message); // To potentially play sound
+ console.log("[ServiceWorker] Message received", data);
+
+ // Look up subscription for baseUrl and topic
+ const subscription = await db.subscriptions.get(subscriptionId);
+ if (!subscription) {
+ console.log("[ServiceWorker] Subscription not found", subscriptionId);
+ return;
+ }
+
+ // Delete existing notification with same sequence ID (if any)
+ const sequenceId = message.sequence_id || message.id;
+ if (sequenceId) {
+ await db.notifications.where({ subscriptionId, sequenceId }).delete();
+ }
+
+ // Add notification to database
+ await db.notifications.add({
+ ...messageWithSequenceId(message),
+ subscriptionId,
+ new: 1, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
+ });
+
+ // Update subscription last message id (for ?since=... queries)
+ await db.subscriptions.update(subscriptionId, {
+ last: message.id,
+ });
+
+ // Update badge in PWA
+ const badgeCount = await db.notifications.where({ new: 1 }).count();
+ self.navigator.setAppBadge?.(badgeCount);
+
+ // Broadcast the message to potentially play a sound
+ broadcastChannel.postMessage(message);
- await addNotification({ subscriptionId, message });
await self.registration.showNotification(
...toNotificationParams({
- subscriptionId,
message,
defaultTitle: message.topic,
topicRoute: new URL(message.topic, self.location.origin).toString(),
+ baseUrl: subscription.baseUrl,
+ topic: subscription.topic,
})
);
};
+/**
+ * Handle a message_delete event: delete the notification from the database.
+ */
+const handlePushMessageDelete = async (data) => {
+ const { subscription_id: subscriptionId, message } = data;
+ const db = await dbAsync();
+ console.log("[ServiceWorker] Deleting notification sequence", data);
+
+ // Look up subscription for baseUrl and topic
+ const subscription = await db.subscriptions.get(subscriptionId);
+ if (!subscription) {
+ console.log("[ServiceWorker] Subscription not found", subscriptionId);
+ return;
+ }
+
+ // Delete notification with the same sequence_id
+ const sequenceId = message.sequence_id;
+ if (sequenceId) {
+ await db.notifications.where({ subscriptionId, sequenceId }).delete();
+ }
+
+ // Close browser notification with matching tag (scoped by topic)
+ const tag = notificationTag(subscription.baseUrl, subscription.topic, message.sequence_id || message.id);
+ const notifications = await self.registration.getNotifications({ tag });
+ notifications.forEach((notification) => notification.close());
+
+ // Update subscription last message id (for ?since=... queries)
+ await db.subscriptions.update(subscriptionId, {
+ last: message.id,
+ });
+};
+
+/**
+ * Handle a message_clear event: clear/dismiss the notification.
+ */
+const handlePushMessageClear = async (data) => {
+ const { subscription_id: subscriptionId, message } = data;
+ const db = await dbAsync();
+ console.log("[ServiceWorker] Marking notification as read", data);
+
+ // Look up subscription for baseUrl and topic
+ const subscription = await db.subscriptions.get(subscriptionId);
+ if (!subscription) {
+ console.log("[ServiceWorker] Subscription not found", subscriptionId);
+ return;
+ }
+
+ // Mark notification as read (set new = 0)
+ const sequenceId = message.sequence_id;
+ if (sequenceId) {
+ await db.notifications.where({ subscriptionId, sequenceId }).modify({ new: 0 });
+ }
+
+ // Close browser notification with matching tag (scoped by topic)
+ const tag = notificationTag(subscription.baseUrl, subscription.topic, message.sequence_id || message.id);
+ const notifications = await self.registration.getNotifications({ tag });
+ notifications.forEach((notification) => notification.close());
+
+ // Update subscription last message id (for ?since=... queries)
+ await db.subscriptions.update(subscriptionId, {
+ last: message.id,
+ });
+
+ // Update badge count
+ const badgeCount = await db.notifications.where({ new: 1 }).count();
+ self.navigator.setAppBadge?.(badgeCount);
+};
+
/**
* Handle a received web push subscription expiring.
*/
const handlePushSubscriptionExpiring = async (data) => {
const t = await initI18n();
+ console.log("[ServiceWorker] Handling incoming subscription expiring event", data);
await self.registration.showNotification(t("web_push_subscription_expiring_title"), {
body: t("web_push_subscription_expiring_body"),
@@ -82,6 +170,7 @@ const handlePushSubscriptionExpiring = async (data) => {
*/
const handlePushUnknown = async (data) => {
const t = await initI18n();
+ console.log("[ServiceWorker] Unknown event received", data);
await self.registration.showNotification(t("web_push_unknown_notification_title"), {
body: t("web_push_unknown_notification_body"),
@@ -96,13 +185,26 @@ const handlePushUnknown = async (data) => {
* @param {object} data see server/types.go, type webPushPayload
*/
const handlePush = async (data) => {
- if (data.event === "message") {
- await handlePushMessage(data);
- } else if (data.event === "subscription_expiring") {
- await handlePushSubscriptionExpiring(data);
- } else {
- await handlePushUnknown(data);
+ // This logic is (partially) duplicated in
+ // - Android: SubscriberService::onNotificationReceived()
+ // - Android: FirebaseService::onMessageReceived()
+ // - Web app: hooks.js:handleNotification()
+ // - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ...
+
+ if (data.event === WEBPUSH_EVENT_MESSAGE) {
+ const { message } = data;
+ if (message.event === EVENT_MESSAGE) {
+ return await handlePushMessage(data);
+ } else if (message.event === EVENT_MESSAGE_DELETE) {
+ return await handlePushMessageDelete(data);
+ } else if (message.event === EVENT_MESSAGE_CLEAR) {
+ return await handlePushMessageClear(data);
+ }
+ } else if (data.event === WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING) {
+ return await handlePushSubscriptionExpiring(data);
}
+
+ return await handlePushUnknown(data);
};
/**
@@ -113,10 +215,8 @@ const handleClick = async (event) => {
const t = await initI18n();
const clients = await self.clients.matchAll({ type: "window" });
-
const rootUrl = new URL(self.location.origin);
const rootClient = clients.find((client) => client.url === rootUrl.toString());
- // perhaps open on another topic
const fallbackClient = clients[0];
if (!event.notification.data?.message) {
@@ -232,6 +332,7 @@ precacheAndRoute(
// Claim all open windows
clientsClaim();
+
// Delete any cached old dist files from previous service worker versions
cleanupOutdatedCaches();
diff --git a/web/src/app/Connection.js b/web/src/app/Connection.js
index 5358cdde..8e02d6f7 100644
--- a/web/src/app/Connection.js
+++ b/web/src/app/Connection.js
@@ -1,5 +1,6 @@
/* eslint-disable max-classes-per-file */
import { basicAuth, bearerAuth, encodeBase64Url, topicShortUrl, topicUrlWs } from "./utils";
+import { EVENT_OPEN, isNotificationEvent } from "./events";
const retryBackoffSeconds = [5, 10, 20, 30, 60, 120];
@@ -48,10 +49,11 @@ class Connection {
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}`);
try {
const data = JSON.parse(event.data);
- if (data.event === "open") {
+ if (data.event === EVENT_OPEN) {
return;
}
- const relevantAndValid = data.event === "message" && "id" in data && "time" in data && "message" in data;
+ // Accept message, message_delete, and message_clear events
+ const relevantAndValid = isNotificationEvent(data.event) && "id" in data && "time" in data;
if (!relevantAndValid) {
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`);
return;
diff --git a/web/src/app/Notifier.js b/web/src/app/Notifier.js
index 77bbdb1e..908df469 100644
--- a/web/src/app/Notifier.js
+++ b/web/src/app/Notifier.js
@@ -1,5 +1,5 @@
import { playSound, topicDisplayName, topicShortUrl, urlB64ToUint8Array } from "./utils";
-import { toNotificationParams } from "./notificationUtils";
+import { notificationTag, toNotificationParams } from "./notificationUtils";
import prefs from "./Prefs";
import routes from "../components/routes";
@@ -23,14 +23,31 @@ class Notifier {
const registration = await this.serviceWorkerRegistration();
await registration.showNotification(
...toNotificationParams({
- subscriptionId: subscription.id,
message: notification,
defaultTitle,
topicRoute: new URL(routes.forSubscription(subscription), window.location.origin).toString(),
+ baseUrl: subscription.baseUrl,
+ topic: subscription.topic,
})
);
}
+ async cancel(subscription, notification) {
+ if (!this.supported()) {
+ return;
+ }
+ try {
+ const sequenceId = notification.sequence_id || notification.id;
+ const tag = notificationTag(subscription.baseUrl, subscription.topic, sequenceId);
+ console.log(`[Notifier] Cancelling notification with tag ${tag}`);
+ const registration = await this.serviceWorkerRegistration();
+ const notifications = await registration.getNotifications({ tag });
+ notifications.forEach((n) => n.close());
+ } catch (e) {
+ console.log(`[Notifier] Error cancelling notification`, e);
+ }
+ }
+
async playSound() {
// Play sound
const sound = await prefs.sound();
diff --git a/web/src/app/Poller.js b/web/src/app/Poller.js
index 2261dddc..b455a308 100644
--- a/web/src/app/Poller.js
+++ b/web/src/app/Poller.js
@@ -1,5 +1,7 @@
import api from "./Api";
+import prefs from "./Prefs";
import subscriptionManager from "./SubscriptionManager";
+import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE } from "./events";
const delayMillis = 2000; // 2 seconds
const intervalMillis = 300000; // 5 minutes
@@ -42,12 +44,35 @@ class Poller {
const since = subscription.last;
const notifications = await api.poll(subscription.baseUrl, subscription.topic, since);
- if (!notifications || notifications.length === 0) {
- console.log(`[Poller] No new notifications found for ${subscription.id}`);
- return;
+
+ // Filter out notifications older than the prune threshold
+ const deleteAfterSeconds = await prefs.deleteAfter();
+ const pruneThresholdTimestamp = deleteAfterSeconds > 0 ? Math.round(Date.now() / 1000) - deleteAfterSeconds : 0;
+ const recentNotifications =
+ pruneThresholdTimestamp > 0 ? notifications.filter((n) => n.time >= pruneThresholdTimestamp) : notifications;
+
+ // Find the latest notification for each sequence ID
+ const latestBySequenceId = this.latestNotificationsBySequenceId(recentNotifications);
+
+ // Delete all existing notifications for which the latest notification is marked as deleted
+ const deletedSequenceIds = Object.entries(latestBySequenceId)
+ .filter(([, notification]) => notification.event === EVENT_MESSAGE_DELETE)
+ .map(([sequenceId]) => sequenceId);
+ if (deletedSequenceIds.length > 0) {
+ console.log(`[Poller] Deleting notifications with deleted sequence IDs for ${subscription.id}`, deletedSequenceIds);
+ await Promise.all(
+ deletedSequenceIds.map((sequenceId) => subscriptionManager.deleteNotificationBySequenceId(subscription.id, sequenceId))
+ );
+ }
+
+ // Add only the latest notification for each non-deleted sequence
+ const notificationsToAdd = Object.values(latestBySequenceId).filter((n) => n.event === EVENT_MESSAGE);
+ if (notificationsToAdd.length > 0) {
+ console.log(`[Poller] Adding ${notificationsToAdd.length} notification(s) for ${subscription.id}`);
+ await subscriptionManager.addNotifications(subscription.id, notificationsToAdd);
+ } else {
+ console.log(`[Poller] No new notifications found for ${subscription.id}`);
}
- console.log(`[Poller] Adding ${notifications.length} notification(s) for ${subscription.id}`);
- await subscriptionManager.addNotifications(subscription.id, notifications);
}
pollInBackground(subscription) {
@@ -59,6 +84,21 @@ class Poller {
}
})();
}
+
+ /**
+ * Groups notifications by sequenceId and returns only the latest (highest time) for each sequence.
+ * Returns an object mapping sequenceId -> latest notification.
+ */
+ latestNotificationsBySequenceId(notifications) {
+ const latestBySequenceId = {};
+ notifications.forEach((notification) => {
+ const sequenceId = notification.sequence_id || notification.id;
+ if (!(sequenceId in latestBySequenceId) || notification.time >= latestBySequenceId[sequenceId].time) {
+ latestBySequenceId[sequenceId] = notification;
+ }
+ });
+ return latestBySequenceId;
+ }
}
const poller = new Poller();
diff --git a/web/src/app/Pruner.js b/web/src/app/Pruner.js
index f9568a33..074370b2 100644
--- a/web/src/app/Pruner.js
+++ b/web/src/app/Pruner.js
@@ -19,7 +19,11 @@ class Pruner {
}
stopWorker() {
- clearTimeout(this.timer);
+ if (this.timer) {
+ clearTimeout(this.timer);
+ this.timer = null;
+ }
+ console.log("[Pruner] Stopped worker");
}
async prune() {
diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js
index de99b642..f909778e 100644
--- a/web/src/app/SubscriptionManager.js
+++ b/web/src/app/SubscriptionManager.js
@@ -3,6 +3,8 @@ import notifier from "./Notifier";
import prefs from "./Prefs";
import db from "./db";
import { topicUrl } from "./utils";
+import { messageWithSequenceId } from "./notificationUtils";
+import { EVENT_MESSAGE, EVENT_MESSAGE_CLEAR, EVENT_MESSAGE_DELETE } from "./events";
class SubscriptionManager {
constructor(dbImpl) {
@@ -48,16 +50,17 @@ class SubscriptionManager {
}
async notify(subscriptionId, notification) {
+ if (notification.event !== EVENT_MESSAGE) {
+ return;
+ }
const subscription = await this.get(subscriptionId);
if (subscription.mutedUntil > 0) {
return;
}
-
const priority = notification.priority ?? 3;
if (priority < (await prefs.minPriority())) {
return;
}
-
await notifier.notify(subscription, notification);
}
@@ -157,7 +160,7 @@ class SubscriptionManager {
// killing performance. See https://dexie.org/docs/Collection/Collection.offset()#a-better-paging-approach
return this.db.notifications
- .orderBy("time") // Sort by time first
+ .orderBy("time") // Sort by time
.filter((n) => n.subscriptionId === subscriptionId)
.reverse()
.toArray();
@@ -173,17 +176,22 @@ class SubscriptionManager {
/** Adds notification, or returns false if it already exists */
async addNotification(subscriptionId, notification) {
const exists = await this.db.notifications.get(notification.id);
- if (exists) {
+ if (exists || notification.event === EVENT_MESSAGE_DELETE || notification.event === EVENT_MESSAGE_CLEAR) {
return false;
}
try {
- // sw.js duplicates this logic, so if you change it here, change it there too
+ // Note: Service worker (sw.js) and addNotifications() duplicates this logic,
+ // so if you change it here, change it there too.
+
+ // Add notification to database
await this.db.notifications.add({
- ...notification,
+ ...messageWithSequenceId(notification),
subscriptionId,
- // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
- new: 1,
- }); // FIXME consider put() for double tab
+ new: 1, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
+ });
+
+ // FIXME consider put() for double tab
+ // Update subscription last message id (for ?since=... queries)
await this.db.subscriptions.update(subscriptionId, {
last: notification.id,
});
@@ -195,7 +203,10 @@ class SubscriptionManager {
/** Adds/replaces notifications, will not throw if they exist */
async addNotifications(subscriptionId, notifications) {
- const notificationsWithSubscriptionId = notifications.map((notification) => ({ ...notification, subscriptionId }));
+ const notificationsWithSubscriptionId = notifications.map((notification) => ({
+ ...messageWithSequenceId(notification),
+ subscriptionId,
+ }));
const lastNotificationId = notifications.at(-1).id;
await this.db.notifications.bulkPut(notificationsWithSubscriptionId);
await this.db.subscriptions.update(subscriptionId, {
@@ -220,6 +231,10 @@ class SubscriptionManager {
await this.db.notifications.delete(notificationId);
}
+ async deleteNotificationBySequenceId(subscriptionId, sequenceId) {
+ await this.db.notifications.where({ subscriptionId, sequenceId }).delete();
+ }
+
async deleteNotifications(subscriptionId) {
await this.db.notifications.where({ subscriptionId }).delete();
}
@@ -228,6 +243,10 @@ class SubscriptionManager {
await this.db.notifications.where({ id: notificationId }).modify({ new: 0 });
}
+ async markNotificationReadBySequenceId(subscriptionId, sequenceId) {
+ await this.db.notifications.where({ subscriptionId, sequenceId }).modify({ new: 0 });
+ }
+
async markNotificationsRead(subscriptionId) {
await this.db.notifications.where({ subscriptionId, new: 1 }).modify({ new: 0 });
}
diff --git a/web/src/app/VersionChecker.js b/web/src/app/VersionChecker.js
new file mode 100644
index 00000000..8f7c2c30
--- /dev/null
+++ b/web/src/app/VersionChecker.js
@@ -0,0 +1,72 @@
+/**
+ * VersionChecker polls the /v1/config endpoint to detect new server versions
+ * or configuration changes, prompting users to refresh the page.
+ */
+
+const intervalMillis = 5 * 60 * 1000; // 5 minutes
+
+class VersionChecker {
+ constructor() {
+ this.initialConfigHash = null;
+ this.listener = null;
+ this.timer = null;
+ }
+
+ /**
+ * Starts the version checker worker. It stores the initial config hash
+ * from the config.js and polls the server every 5 minutes.
+ */
+ startWorker() {
+ // Store initial config hash from the config loaded at page load
+ this.initialConfigHash = window.config?.config_hash || "";
+ console.log("[VersionChecker] Starting version checker");
+ this.timer = setInterval(() => this.checkVersion(), intervalMillis);
+ }
+
+ stopWorker() {
+ if (this.timer) {
+ clearInterval(this.timer);
+ this.timer = null;
+ }
+ console.log("[VersionChecker] Stopped version checker");
+ }
+
+ registerListener(listener) {
+ this.listener = listener;
+ }
+
+ resetListener() {
+ this.listener = null;
+ }
+
+ async checkVersion() {
+ if (!this.initialConfigHash) {
+ return;
+ }
+
+ try {
+ const response = await fetch(`${window.config?.base_url || ""}/v1/config`);
+ if (!response.ok) {
+ console.log("[VersionChecker] Failed to fetch config:", response.status);
+ return;
+ }
+
+ const data = await response.json();
+ const currentHash = data.config_hash;
+
+ if (currentHash && currentHash !== this.initialConfigHash) {
+ console.log("[VersionChecker] Version or config changed, showing banner");
+ if (this.listener) {
+ this.listener();
+ }
+ } else {
+ console.log("[VersionChecker] No version change detected");
+ }
+ } catch (error) {
+ console.log("[VersionChecker] Error checking config:", error);
+ }
+ }
+}
+
+const versionChecker = new VersionChecker();
+export default versionChecker;
diff --git a/web/src/app/db.js b/web/src/app/db.js
index b28fb716..e088a267 100644
--- a/web/src/app/db.js
+++ b/web/src/app/db.js
@@ -11,13 +11,20 @@ const createDatabase = (username) => {
const dbName = username ? `ntfy-${username}` : "ntfy"; // IndexedDB database is based on the logged-in user
const db = new Dexie(dbName);
- db.version(2).stores({
+ db.version(3).stores({
subscriptions: "&id,baseUrl,[baseUrl+mutedUntil]",
- notifications: "&id,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance
+ notifications: "&id,sequenceId,subscriptionId,time,new,[subscriptionId+new],[subscriptionId+sequenceId]",
users: "&baseUrl,username",
prefs: "&key",
});
+ // When another connection (e.g., service worker or another tab) wants to upgrade,
+ // close this connection gracefully to allow the upgrade to proceed
+ db.on("versionchange", () => {
+ console.log("[db] versionchange event: closing database");
+ db.close();
+ });
+
return db;
};
diff --git a/web/src/app/events.js b/web/src/app/events.js
new file mode 100644
index 00000000..d5c5ab88
--- /dev/null
+++ b/web/src/app/events.js
@@ -0,0 +1,15 @@
+// Event types for ntfy messages
+// These correspond to the server event types in server/types.go
+
+export const EVENT_OPEN = "open";
+export const EVENT_KEEPALIVE = "keepalive";
+export const EVENT_MESSAGE = "message";
+export const EVENT_MESSAGE_DELETE = "message_delete";
+export const EVENT_MESSAGE_CLEAR = "message_clear";
+export const EVENT_POLL_REQUEST = "poll_request";
+
+export const WEBPUSH_EVENT_MESSAGE = "message";
+export const WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING = "subscription_expiring";
+
+// Check if an event is a notification event (message, delete, or read)
+export const isNotificationEvent = (event) => event === EVENT_MESSAGE || event === EVENT_MESSAGE_DELETE || event === EVENT_MESSAGE_CLEAR;
diff --git a/web/src/app/notificationUtils.js b/web/src/app/notificationUtils.js
index 0bd5136d..a3025f67 100644
--- a/web/src/app/notificationUtils.js
+++ b/web/src/app/notificationUtils.js
@@ -25,13 +25,13 @@ const formatTitleWithDefault = (m, fallback) => {
export const formatMessage = (m) => {
if (m.title) {
- return m.message;
+ return m.message || "";
}
const emojiList = toEmojis(m.tags);
if (emojiList.length > 0) {
- return `${emojiList.join(" ")} ${m.message}`;
+ return `${emojiList.join(" ")} ${m.message || ""}`;
}
- return m.message;
+ return m.message || "";
};
const imageRegex = /\.(png|jpe?g|gif|webp)$/i;
@@ -50,8 +50,16 @@ export const isImage = (attachment) => {
export const icon = "/static/images/ntfy.png";
export const badge = "/static/images/mask-icon.svg";
-export const toNotificationParams = ({ subscriptionId, message, defaultTitle, topicRoute }) => {
+/**
+ * Computes a unique notification tag scoped by baseUrl, topic, and sequence ID.
+ * This ensures notifications from different topics with the same sequence ID don't collide.
+ */
+export const notificationTag = (baseUrl, topic, sequenceId) => `${baseUrl}/${topic}/${sequenceId}`;
+
+export const toNotificationParams = ({ message, defaultTitle, topicRoute, baseUrl, topic }) => {
const image = isImage(message.attachment) ? message.attachment.url : undefined;
+ const sequenceId = message.sequence_id || message.id;
+ const tag = notificationTag(baseUrl, topic, sequenceId);
// https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API
return [
@@ -61,8 +69,8 @@ export const toNotificationParams = ({ subscriptionId, message, defaultTitle, to
badge,
icon,
image,
- timestamp: message.time * 1_000,
- tag: subscriptionId,
+ timestamp: message.time * 1000,
+ tag, // Scoped by baseUrl/topic/sequenceId to avoid cross-topic collisions
renotify: true,
silent: false,
// This is used by the notification onclick event
@@ -79,3 +87,10 @@ export const toNotificationParams = ({ subscriptionId, message, defaultTitle, to
},
];
};
+
+export const messageWithSequenceId = (message) => {
+ if (message.sequenceId) {
+ return message;
+ }
+ return { ...message, sequenceId: message.sequence_id || message.id };
+};
diff --git a/web/src/components/Navigation.jsx b/web/src/components/Navigation.jsx
index 7e30931a..89381cb3 100644
--- a/web/src/components/Navigation.jsx
+++ b/web/src/components/Navigation.jsx
@@ -1,23 +1,23 @@
import {
- Drawer,
- ListItemButton,
- ListItemIcon,
- ListItemText,
- Toolbar,
- Divider,
- List,
Alert,
AlertTitle,
Badge,
+ Box,
+ Button,
CircularProgress,
+ Divider,
+ Drawer,
+ IconButton,
Link,
+ List,
+ ListItemButton,
+ ListItemIcon,
+ ListItemText,
ListSubheader,
Portal,
+ Toolbar,
Tooltip,
Typography,
- Box,
- IconButton,
- Button,
useTheme,
} from "@mui/material";
import * as React from "react";
@@ -44,7 +44,7 @@ import UpgradeDialog from "./UpgradeDialog";
import { AccountContext } from "./App";
import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
import { SubscriptionPopup } from "./SubscriptionPopup";
-import { useNotificationPermissionListener } from "./hooks";
+import { useNotificationPermissionListener, useVersionChangeListener } from "./hooks";
const navWidth = 280;
@@ -91,6 +91,13 @@ const NavList = (props) => {
const { account } = useContext(AccountContext);
const [subscribeDialogKey, setSubscribeDialogKey] = useState(0);
const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false);
+ const [versionChanged, setVersionChanged] = useState(false);
+
+ const handleVersionChange = () => {
+ setVersionChanged(true);
+ };
+
+ useVersionChangeListener(handleVersionChange);
const handleSubscribeReset = () => {
setSubscribeDialogOpen(false);
@@ -119,6 +126,7 @@ const NavList = (props) => {
const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
const alertVisible =
+ versionChanged ||
showNotificationPermissionRequired ||
showNotificationPermissionDenied ||
showNotificationIOSInstallRequired ||
@@ -129,6 +137,7 @@ const NavList = (props) => {
<>
+ {versionChanged && }
{showNotificationPermissionRequired && }
{showNotificationPermissionDenied && }
{showNotificationBrowserNotSupportedBox && }
@@ -425,4 +434,20 @@ const NotificationContextNotSupportedAlert = () => {
);
};
+const VersionUpdateBanner = () => {
+ const { t } = useTranslation();
+ const handleRefresh = () => {
+ window.location.reload();
+ };
+ return (
+
+ {t("version_update_available_title")}
+ {t("version_update_available_description")}
+
+
+ );
+};
+
export default Navigation;
diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js
index 519d4c6a..1b4a78b7 100644
--- a/web/src/components/hooks.js
+++ b/web/src/components/hooks.js
@@ -9,9 +9,11 @@ import poller from "../app/Poller";
import pruner from "../app/Pruner";
import session from "../app/Session";
import accountApi from "../app/AccountApi";
+import versionChecker from "../app/VersionChecker";
import { UnauthorizedError } from "../app/errors";
import notifier from "../app/Notifier";
import prefs from "../app/Prefs";
+import { EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR } from "../app/events";
/**
* Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection
@@ -49,10 +51,29 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop
}
};
- const handleNotification = async (subscriptionId, notification) => {
- const added = await subscriptionManager.addNotification(subscriptionId, notification);
- if (added) {
- await subscriptionManager.notify(subscriptionId, notification);
+ const handleNotification = async (subscription, notification) => {
+ // This logic is (partially) duplicated in
+ // - Android: SubscriberService::onNotificationReceived()
+ // - Android: FirebaseService::onMessageReceived()
+ // - Web app: hooks.js:handleNotification()
+ // - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ...
+
+ if (notification.event === EVENT_MESSAGE_DELETE && notification.sequence_id) {
+ await subscriptionManager.deleteNotificationBySequenceId(subscription.id, notification.sequence_id);
+ await notifier.cancel(subscription, notification);
+ } else if (notification.event === EVENT_MESSAGE_CLEAR && notification.sequence_id) {
+ await subscriptionManager.markNotificationReadBySequenceId(subscription.id, notification.sequence_id);
+ await notifier.cancel(subscription, notification);
+ } else {
+ // Regular message: delete existing and add new
+ const sequenceId = notification.sequence_id || notification.id;
+ if (sequenceId) {
+ await subscriptionManager.deleteNotificationBySequenceId(subscription.id, sequenceId);
+ }
+ const added = await subscriptionManager.addNotification(subscription.id, notification);
+ if (added) {
+ await subscriptionManager.notify(subscription.id, notification);
+ }
}
};
@@ -68,7 +89,7 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop
if (subscription.internal) {
await handleInternalMessage(message);
} else {
- await handleNotification(subscriptionId, message);
+ await handleNotification(subscription, message);
}
};
@@ -231,7 +252,9 @@ export const useIsLaunchedPWA = () => {
useEffect(() => {
if (isIOSStandalone) {
- return () => {}; // No need to listen for events on iOS
+ return () => {
+ // No need to listen for events on iOS
+ };
}
const handler = (evt) => {
console.log(`[useIsLaunchedPWA] App is now running ${evt.matches ? "standalone" : "in the browser"}`);
@@ -270,12 +293,14 @@ const startWorkers = () => {
poller.startWorker();
pruner.startWorker();
accountApi.startWorker();
+ versionChecker.startWorker();
};
const stopWorkers = () => {
poller.stopWorker();
pruner.stopWorker();
accountApi.stopWorker();
+ versionChecker.stopWorker();
};
export const useBackgroundProcesses = () => {
@@ -301,3 +326,15 @@ export const useAccountListener = (setAccount) => {
};
}, []);
};
+
+/**
+ * Hook to detect version/config changes and call the provided callback when a change is detected.
+ */
+export const useVersionChangeListener = (onVersionChange) => {
+ useEffect(() => {
+ versionChecker.registerListener(onVersionChange);
+ return () => {
+ versionChecker.resetListener();
+ };
+ }, [onVersionChange]);
+};
diff --git a/web/src/registerSW.js b/web/src/registerSW.js
index adef4746..842cf80e 100644
--- a/web/src/registerSW.js
+++ b/web/src/registerSW.js
@@ -5,10 +5,19 @@ import { registerSW as viteRegisterSW } from "virtual:pwa-register";
const intervalMS = 60 * 60 * 1000;
// https://vite-pwa-org.netlify.app/guide/periodic-sw-updates.html
-const registerSW = () =>
+const registerSW = () => {
+ console.log("[ServiceWorker] Registering service worker");
+ if (!("serviceWorker" in navigator)) {
+ console.warn("[ServiceWorker] Service workers not supported");
+ return;
+ }
+
viteRegisterSW({
onRegisteredSW(swUrl, registration) {
+ console.log("[ServiceWorker] Registered:", { swUrl, registration });
+
if (!registration) {
+ console.warn("[ServiceWorker] No registration returned");
return;
}
@@ -23,9 +32,16 @@ const registerSW = () =>
},
});
- if (resp?.status === 200) await registration.update();
+ if (resp?.status === 200) {
+ console.log("[ServiceWorker] Updating service worker");
+ await registration.update();
+ }
}, intervalMS);
},
+ onRegisterError(error) {
+ console.error("[ServiceWorker] Registration error:", error);
+ },
});
+};
export default registerSW;