diff --git a/Makefile b/Makefile index 82ab53e2..575bb788 100644 --- a/Makefile +++ b/Makefile @@ -301,7 +301,7 @@ release: clean cli-deps release-checks docs web check goreleaser release --clean release-snapshot: clean cli-deps docs web check - goreleaser release --snapshot --skip-publish --clean + goreleaser release --snapshot --clean release-checks: $(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-)) diff --git a/README.md b/README.md index 61591ca6..9942e138 100644 --- a/README.md +++ b/README.md @@ -253,3 +253,4 @@ Third-party libraries and resources: * [Statically linking go-sqlite3](https://www.arp242.net/static-go.html) * [Linked tabs in mkdocs](https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs) * [webpush-go](https://github.com/SherClockHolmes/webpush-go) (MIT) is used to send web push notifications +* [Sprig](https://github.com/Masterminds/sprig) (MIT) is used to add template parsing functions diff --git a/client/options.go b/client/options.go index 027b7fb5..f4711834 100644 --- a/client/options.go +++ b/client/options.go @@ -77,6 +77,12 @@ func WithMarkdown() PublishOption { return WithHeader("X-Markdown", "yes") } +// WithTemplate instructs the server to use a specific template for the message. If templateName is is "yes" or "1", +// the server will interpret the message and title as a template. +func WithTemplate(templateName string) PublishOption { + return WithHeader("X-Template", templateName) +} + // WithFilename sets a filename for the attachment, and/or forces the HTTP body to interpreted as an attachment func WithFilename(filename string) PublishOption { return WithHeader("X-Filename", filename) diff --git a/cmd/publish.go b/cmd/publish.go index c15761ab..f3139a63 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -32,6 +32,7 @@ var flagsPublish = append( &cli.StringFlag{Name: "actions", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ACTIONS"}, Usage: "actions JSON array or simple definition"}, &cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"}, &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: "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"}, @@ -98,6 +99,7 @@ func execPublish(c *cli.Context) error { actions := c.String("actions") attach := c.String("attach") markdown := c.Bool("markdown") + template := c.String("template") filename := c.String("filename") file := c.String("file") email := c.String("email") @@ -146,6 +148,9 @@ func execPublish(c *cli.Context) error { if markdown { options = append(options, client.WithMarkdown()) } + if template != "" { + options = append(options, client.WithTemplate(template)) + } if filename != "" { options = append(options, client.WithFilename(filename)) } diff --git a/cmd/serve.go b/cmd/serve.go index abd9ac06..50314b88 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -29,13 +29,9 @@ func init() { commands = append(commands, cmdServe) } -const ( - defaultServerConfigFile = "/etc/ntfy/server.yml" -) - var flagsServe = append( append([]cli.Flag{}, flagsDefault...), - &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, Usage: "config file"}, + &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: server.DefaultConfigFile, Usage: "config file"}, altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used as HTTP listen address"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"listen_https", "L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used as HTTPS listen address"}), @@ -57,6 +53,7 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentTotalSizeLimit), Usage: "limit of the on-disk attachment cache"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentFileSizeLimit), Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: util.FormatDuration(server.DefaultAttachmentExpiryDuration), Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "template-dir", Aliases: []string{"template_dir"}, EnvVars: []string{"NTFY_TEMPLATE_DIR"}, Value: server.DefaultTemplateDir, Usage: "directory to load named message templates from"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: util.FormatDuration(server.DefaultKeepaliveInterval), Usage: "interval of keepalive messages"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: util.FormatDuration(server.DefaultManagerInterval), Usage: "interval of for message pruning and stats printing"}), altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "disallowed-topics", Aliases: []string{"disallowed_topics"}, EnvVars: []string{"NTFY_DISALLOWED_TOPICS"}, Usage: "topics that are not allowed to be used"}), @@ -164,6 +161,7 @@ func execServe(c *cli.Context) error { attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit") attachmentFileSizeLimitStr := c.String("attachment-file-size-limit") attachmentExpiryDurationStr := c.String("attachment-expiry-duration") + templateDir := c.String("template-dir") keepaliveIntervalStr := c.String("keepalive-interval") managerIntervalStr := c.String("manager-interval") disallowedTopics := c.StringSlice("disallowed-topics") @@ -437,6 +435,7 @@ func execServe(c *cli.Context) error { conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit conf.AttachmentFileSizeLimit = attachmentFileSizeLimit conf.AttachmentExpiryDuration = attachmentExpiryDuration + conf.TemplateDir = templateDir conf.KeepaliveInterval = keepaliveInterval conf.ManagerInterval = managerInterval conf.DisallowedTopics = disallowedTopics diff --git a/cmd/user.go b/cmd/user.go index 7519438c..31f4c31b 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -6,6 +6,7 @@ import ( "crypto/subtle" "errors" "fmt" + "heckel.io/ntfy/v2/server" "heckel.io/ntfy/v2/user" "os" "strings" @@ -25,7 +26,7 @@ func init() { var flagsUser = append( append([]cli.Flag{}, flagsDefault...), - &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, Usage: "config file"}, + &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: server.DefaultConfigFile, DefaultText: server.DefaultConfigFile, Usage: "config file"}, altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}), ) diff --git a/docs/install.md b/docs/install.md index 42c868fc..b841e950 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.12.0/ntfy_2.12.0_linux_amd64.tar.gz - tar zxvf ntfy_2.12.0_linux_amd64.tar.gz - sudo cp -a ntfy_2.12.0_linux_amd64/ntfy /usr/local/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.12.0_linux_amd64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_amd64.tar.gz + tar zxvf ntfy_2.13.0_linux_amd64.tar.gz + sudo cp -a ntfy_2.13.0_linux_amd64/ntfy /usr/local/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.0_linux_amd64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv6" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv6.tar.gz - tar zxvf ntfy_2.12.0_linux_armv6.tar.gz - sudo cp -a ntfy_2.12.0_linux_armv6/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.12.0_linux_armv6/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv6.tar.gz + tar zxvf ntfy_2.13.0_linux_armv6.tar.gz + sudo cp -a ntfy_2.13.0_linux_armv6/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.0_linux_armv6/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv7.tar.gz - tar zxvf ntfy_2.12.0_linux_armv7.tar.gz - sudo cp -a ntfy_2.12.0_linux_armv7/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.12.0_linux_armv7/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv7.tar.gz + tar zxvf ntfy_2.13.0_linux_armv7.tar.gz + sudo cp -a ntfy_2.13.0_linux_armv7/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.0_linux_armv7/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_arm64.tar.gz - tar zxvf ntfy_2.12.0_linux_arm64.tar.gz - sudo cp -a ntfy_2.12.0_linux_arm64/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.12.0_linux_arm64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_arm64.tar.gz + tar zxvf ntfy_2.13.0_linux_arm64.tar.gz + sudo cp -a ntfy_2.13.0_linux_arm64/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.0_linux_arm64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` @@ -110,7 +110,7 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_amd64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_amd64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -118,7 +118,7 @@ Manually installing the .deb file: === "armv6" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv6.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv6.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -126,7 +126,7 @@ Manually installing the .deb file: === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv7.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv7.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -134,7 +134,7 @@ Manually installing the .deb file: === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_arm64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_arm64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -144,28 +144,28 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_amd64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.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.12.0/ntfy_2.12.0_linux_armv6.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.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.12.0/ntfy_2.12.0_linux_armv7.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.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.12.0/ntfy_2.12.0_linux_arm64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_arm64.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` @@ -195,18 +195,18 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos. ## 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.12.0/ntfy_2.12.0_darwin_all.tar.gz), +To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.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.12.0/ntfy_2.12.0_darwin_all.tar.gz > ntfy_2.12.0_darwin_all.tar.gz -tar zxvf ntfy_2.12.0_darwin_all.tar.gz -sudo cp -a ntfy_2.12.0_darwin_all/ntfy /usr/local/bin/ntfy +curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_darwin_all.tar.gz > ntfy_2.13.0_darwin_all.tar.gz +tar zxvf ntfy_2.13.0_darwin_all.tar.gz +sudo cp -a ntfy_2.13.0_darwin_all/ntfy /usr/local/bin/ntfy mkdir ~/Library/Application\ Support/ntfy -cp ntfy_2.12.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml +cp ntfy_2.13.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml ntfy --help ``` @@ -224,7 +224,7 @@ 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.12.0/ntfy_2.12.0_windows_amd64.zip), +To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_windows_amd64.zip), extract it and place the `ntfy.exe` binary somewhere in your `%Path%`. The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file). diff --git a/docs/publish.md b/docs/publish.md index 25bff035..1085a5a2 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -944,25 +944,171 @@ Templating lets you **format a JSON message body into human-friendly message and [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, Grafana, or other services that emit JSON webhooks. +**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`) to `yes` or `1`, or (more appropriately -for webhooks) by setting the `?template=yes` query parameter. Then, include templates in your `message` and/or `title`, using the following stanzas (see [Go docs](https://pkg.go.dev/text/template) for detailed syntax): +You can enable templating by setting the `X-Template` header (or its aliases `Template` or `tpl`, or the query parameter `?template=...`): -* 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==)) +* **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. -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)). +To learn the basics of Go's templating language, please see [template syntax](#template-syntax). -!!! info - Please note that the Go templating language is quite terrible. My apologies for using it for this feature. It is the best option for Go-based - programs like ntfy. Stay calm and don't harm yourself or others in despair. **You can do it. I believe in you!** +### 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`. +
+ ![GitHub webhook config](static/img/screenshot-github-webhook-config.png){ 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: + +
+ ![pre-defined template](static/img/android-screenshot-template-predefined.png){ 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: + +
+ ![notification from custom JSON webhook template](static/img/android-screenshot-template-custom.png){ 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, of 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**: @@ -1075,6 +1221,44 @@ This example uses the `message`/`m` and `title`/`t` query parameters, but obviou `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: diff --git a/docs/publish/template-functions.md b/docs/publish/template-functions.md new file mode 100644 index 00000000..79848080 --- /dev/null +++ b/docs/publish/template-functions.md @@ -0,0 +1,1507 @@ +# Template Functions + +These template functions may be used in the **[message template](../publish.md#message-templating)** feature of ntfy. Please refer to the examples in the documentation for how to use them. + +The original set of template functions is based on the [Sprig library](https://masterminds.github.io/sprig/). This documentation page is a (slightly modified) copy of their docs. **Thank you to the Sprig developers for their work!** 🙏 + +## Table of Contents + +- [String Functions](#string-functions) +- [String List Functions](#string-list-functions) +- [Integer Math Functions](#integer-math-functions) +- [Integer List Functions](#integer-list-functions) +- [Float Math Functions](#float-math-functions) +- [Date Functions](#date-functions) +- [Default Functions](#default-functions) +- [Encoding Functions](#encoding-functions) +- [Lists and List Functions](#lists-and-list-functions) +- [Dictionaries and Dict Functions](#dictionaries-and-dict-functions) +- [Type Conversion Functions](#type-conversion-functions) +- [Path and Filepath Functions](#path-and-filepath-functions) +- [Flow Control Functions](#flow-control-functions) +- [Reflection Functions](#reflection-functions) +- [Cryptographic and Security Functions](#cryptographic-and-security-functions) +- [URL Functions](#url-functions) + +## String Functions + +Sprig has a number of string manipulation functions. + +### trim + +The `trim` function removes space from either side of a string: + +``` +trim " hello " +``` + +The above produces `hello` + +### trimAll + +Remove given characters from the front or back of a string: + +``` +trimAll "$" "$5.00" +``` + +The above returns `5.00` (as a string). + +### trimSuffix + +Trim just the suffix from a string: + +``` +trimSuffix "-" "hello-" +``` + +The above returns `hello` + +### trimPrefix + +Trim just the prefix from a string: + +``` +trimPrefix "-" "-hello" +``` + +The above returns `hello` + +### upper + +Convert the entire string to uppercase: + +``` +upper "hello" +``` + +The above returns `HELLO` + +### lower + +Convert the entire string to lowercase: + +``` +lower "HELLO" +``` + +The above returns `hello` + +### title + +Convert to title case: + +``` +title "hello world" +``` + +The above returns `Hello World` + +### repeat + +Repeat a string multiple times: + +``` +repeat 3 "hello" +``` + +The above returns `hellohellohello` + +### substr + +Get a substring from a string. It takes three parameters: + +- start (int) +- end (int) +- string (string) + +``` +substr 0 5 "hello world" +``` + +The above returns `hello` + +### trunc + +Truncate a string (and add no suffix) + +``` +trunc 5 "hello world" +``` + +The above produces `hello`. + +``` +trunc -5 "hello world" +``` + +The above produces `world`. + +### contains + +Test to see if one string is contained inside of another: + +``` +contains "cat" "catch" +``` + +The above returns `true` because `catch` contains `cat`. + +### hasPrefix and hasSuffix + +The `hasPrefix` and `hasSuffix` functions test whether a string has a given +prefix or suffix: + +``` +hasPrefix "cat" "catch" +``` + +The above returns `true` because `catch` has the prefix `cat`. + +### quote and squote + +These functions wrap a string in double quotes (`quote`) or single quotes +(`squote`). + +### cat + +The `cat` function concatenates multiple strings together into one, separating +them with spaces: + +``` +cat "hello" "beautiful" "world" +``` + +The above produces `hello beautiful world` + +### indent + +The `indent` function indents every line in a given string to the specified +indent width. This is useful when aligning multi-line strings: + +``` +indent 4 $lots_of_text +``` + +The above will indent every line of text by 4 space characters. + +### nindent + +The `nindent` function is the same as the indent function, but prepends a new +line to the beginning of the string. + +``` +nindent 4 $lots_of_text +``` + +The above will indent every line of text by 4 space characters and add a new +line to the beginning. + +### replace + +Perform simple string replacement. + +It takes three arguments: + +- string to replace +- string to replace with +- source string + +``` +"I Am Henry VIII" | replace " " "-" +``` + +The above will produce `I-Am-Henry-VIII` + +### plural + +Pluralize a string. + +``` +len $fish | plural "one anchovy" "many anchovies" +``` + +In the above, if the length of the string is 1, the first argument will be +printed (`one anchovy`). Otherwise, the second argument will be printed +(`many anchovies`). + +The arguments are: + +- singular string +- plural string +- length integer + +NOTE: Sprig does not currently support languages with more complex pluralization +rules. And `0` is considered a plural because the English language treats it +as such (`zero anchovies`). The Sprig developers are working on a solution for +better internationalization. + +### regexMatch, mustRegexMatch + +Returns true if the input string contains any match of the regular expression. + +``` +regexMatch "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$" "test@acme.com" +``` + +The above produces `true` + +`regexMatch` panics if there is a problem and `mustRegexMatch` returns an error to the +template engine if there is a problem. + +### regexFindAll, mustRegexFindAll + +Returns a slice of all matches of the regular expression in the input string. +The last parameter n determines the number of substrings to return, where -1 means return all matches + +``` +regexFindAll "[2,4,6,8]" "123456789" -1 +``` + +The above produces `[2 4 6 8]` + +`regexFindAll` panics if there is a problem and `mustRegexFindAll` returns an error to the +template engine if there is a problem. + +### regexFind, mustRegexFind + +Return the first (left most) match of the regular expression in the input string + +``` +regexFind "[a-zA-Z][1-9]" "abcd1234" +``` + +The above produces `d1` + +`regexFind` panics if there is a problem and `mustRegexFind` returns an error to the +template engine if there is a problem. + +### regexReplaceAll, mustRegexReplaceAll + +Returns a copy of the input string, replacing matches of the Regexp with the replacement string replacement. +Inside string replacement, $ signs are interpreted as in Expand, so for instance $1 represents the text of the first submatch + +``` +regexReplaceAll "a(x*)b" "-ab-axxb-" "${1}W" +``` + +The above produces `-W-xxW-` + +`regexReplaceAll` panics if there is a problem and `mustRegexReplaceAll` returns an error to the +template engine if there is a problem. + +### regexReplaceAllLiteral, mustRegexReplaceAllLiteral + +Returns a copy of the input string, replacing matches of the Regexp with the replacement string replacement +The replacement string is substituted directly, without using Expand + +``` +regexReplaceAllLiteral "a(x*)b" "-ab-axxb-" "${1}" +``` + +The above produces `-${1}-${1}-` + +`regexReplaceAllLiteral` panics if there is a problem and `mustRegexReplaceAllLiteral` returns an error to the +template engine if there is a problem. + +### regexSplit, mustRegexSplit + +Slices the input string into substrings separated by the expression and returns a slice of the substrings between those expression matches. The last parameter `n` determines the number of substrings to return, where `-1` means return all matches + +``` +regexSplit "z+" "pizza" -1 +``` + +The above produces `[pi a]` + +`regexSplit` panics if there is a problem and `mustRegexSplit` returns an error to the +template engine if there is a problem. + +### regexQuoteMeta + +Returns a string that escapes all regular expression metacharacters inside the argument text; +the returned string is a regular expression matching the literal text. + +``` +regexQuoteMeta "1.2.3" +``` + +The above produces `1\.2\.3` + +### See Also... + +The [Conversion Functions](#type-conversion-functions) contain functions for converting strings. The [String List Functions](#string-list-functions) contains +functions for working with an array of strings. + +## String List Functions + +These functions operate on or generate slices of strings. In Go, a slice is a +growable array. In Sprig, it's a special case of a `list`. + +### join + +Join a list of strings into a single string, with the given separator. + +``` +list "hello" "world" | join "_" +``` + +The above will produce `hello_world` + +`join` will try to convert non-strings to a string value: + +``` +list 1 2 3 | join "+" +``` + +The above will produce `1+2+3` + +### splitList and split + +Split a string into a list of strings: + +``` +splitList "$" "foo$bar$baz" +``` + +The above will return `[foo bar baz]` + +The older `split` function splits a string into a `dict`. It is designed to make +it easy to use template dot notation for accessing members: + +``` +$a := split "$" "foo$bar$baz" +``` + +The above produces a map with index keys. `{_0: foo, _1: bar, _2: baz}` + +``` +$a._0 +``` + +The above produces `foo` + +### splitn + +`splitn` function splits a string into a `dict` with `n` keys. It is designed to make +it easy to use template dot notation for accessing members: + +``` +$a := splitn "$" 2 "foo$bar$baz" +``` + +The above produces a map with index keys. `{_0: foo, _1: bar$baz}` + +``` +$a._0 +``` + +The above produces `foo` + +### sortAlpha + +The `sortAlpha` function sorts a list of strings into alphabetical (lexicographical) +order. + +It does _not_ sort in place, but returns a sorted copy of the list, in keeping +with the immutability of lists. + +## Integer Math Functions + +The following math functions operate on `int64` values. + +### add + +Sum numbers with `add`. Accepts two or more inputs. + +``` +add 1 2 3 +``` + +### add1 + +To increment by 1, use `add1` + +### sub + +To subtract, use `sub` + +### div + +Perform integer division with `div` + +### mod + +Modulo with `mod` + +### mul + +Multiply with `mul`. Accepts two or more inputs. + +``` +mul 1 2 3 +``` + +### max + +Return the largest of a series of integers: + +This will return `3`: + +``` +max 1 2 3 +``` + +### min + +Return the smallest of a series of integers. + +`min 1 2 3` will return `1` + +### floor + +Returns the greatest float value less than or equal to input value + +`floor 123.9999` will return `123.0` + +### ceil + +Returns the greatest float value greater than or equal to input value + +`ceil 123.001` will return `124.0` + +### round + +Returns a float value with the remainder rounded to the given number to digits after the decimal point. + +`round 123.555555 3` will return `123.556` + +### randInt +Returns a random integer value from min (inclusive) to max (exclusive). + +``` +randInt 12 30 +``` + +The above will produce a random number in the range [12,30]. + +## Integer List Functions + +### until + +The `until` function builds a range of integers. + +``` +until 5 +``` + +The above generates the list `[0, 1, 2, 3, 4]`. + +This is useful for looping with `range $i, $e := until 5`. + +### untilStep + +Like `until`, `untilStep` generates a list of counting integers. But it allows +you to define a start, stop, and step: + +``` +untilStep 3 6 2 +``` + +The above will produce `[3 5]` by starting with 3, and adding 2 until it is equal +or greater than 6. This is similar to Python's `range` function. + +### seq + +Works like the bash `seq` command. +* 1 parameter (end) - will generate all counting integers between 1 and `end` inclusive. +* 2 parameters (start, end) - will generate all counting integers between `start` and `end` inclusive incrementing or decrementing by 1. +* 3 parameters (start, step, end) - will generate all counting integers between `start` and `end` inclusive incrementing or decrementing by `step`. + +``` +seq 5 => 1 2 3 4 5 +seq -3 => 1 0 -1 -2 -3 +seq 0 2 => 0 1 2 +seq 2 -2 => 2 1 0 -1 -2 +seq 0 2 10 => 0 2 4 6 8 10 +seq 0 -2 -5 => 0 -2 -4 +``` + +## Float Math Functions + +### maxf + +Return the largest of a series of floats: + +This will return `3`: + +``` +maxf 1 2.5 3 +``` + +### minf + +Return the smallest of a series of floats. + +This will return `1.5`: + +``` +minf 1.5 2 3 +``` + +## Date Functions + +### now + +The current date/time. Use this in conjunction with other date functions. + +### ago + +The `ago` function returns duration from time.Now in seconds resolution. + +``` +ago .CreatedAt +``` + +returns in `time.Duration` String() format + +``` +2h34m7s +``` + +### date + +The `date` function formats a date. + +Format the date to YEAR-MONTH-DAY: + +``` +now | date "2006-01-02" +``` + +Date formatting in Go is a [little bit different](https://pauladamsmith.com/blog/2011/05/go_time.html). + +In short, take this as the base date: + +``` +Mon Jan 2 15:04:05 MST 2006 +``` + +Write it in the format you want. Above, `2006-01-02` is the same date, but +in the format we want. + +### dateInZone + +Same as `date`, but with a timezone. + +``` +dateInZone "2006-01-02" (now) "UTC" +``` + +### duration + +Formats a given amount of seconds as a `time.Duration`. + +This returns 1m35s + +``` +duration "95" +``` + +### durationRound + +Rounds a given duration to the most significant unit. Strings and `time.Duration` +gets parsed as a duration, while a `time.Time` is calculated as the duration since. + +This return 2h + +``` +durationRound "2h10m5s" +``` + +This returns 3mo + +``` +durationRound "2400h10m5s" +``` + +### unixEpoch + +Returns the seconds since the unix epoch for a `time.Time`. + +``` +now | unixEpoch +``` + +### dateModify, mustDateModify + +The `dateModify` takes a modification and a date and returns the timestamp. + +Subtract an hour and thirty minutes from the current time: + +``` +now | dateModify "-1.5h" +``` + +If the modification format is wrong `dateModify` will return the date unmodified. `mustDateModify` will return an error otherwise. + +### htmlDate + +The `htmlDate` function formats a date for inserting into an HTML date picker +input field. + +``` +now | htmlDate +``` + +### htmlDateInZone + +Same as htmlDate, but with a timezone. + +``` +htmlDateInZone (now) "UTC" +``` + +### toDate, mustToDate + +`toDate` converts a string to a date. The first argument is the date layout and +the second the date string. If the string can't be convert it returns the zero +value. +`mustToDate` will return an error in case the string cannot be converted. + +This is useful when you want to convert a string date to another format +(using pipe). The example below converts "2017-12-31" to "31/12/2017". + +``` +toDate "2006-01-02" "2017-12-31" | date "02/01/2006" +``` + +## Default Functions + +Sprig provides tools for setting default values for templates. + +### default + +To set a simple default value, use `default`: + +``` +default "foo" .Bar +``` + +In the above, if `.Bar` evaluates to a non-empty value, it will be used. But if +it is empty, `foo` will be returned instead. + +The definition of "empty" depends on type: + +- Numeric: 0 +- String: "" +- Lists: `[]` +- Dicts: `{}` +- Boolean: `false` +- And always `nil` (aka null) + +For structs, there is no definition of empty, so a struct will never return the +default. + +### empty + +The `empty` function returns `true` if the given value is considered empty, and +`false` otherwise. The empty values are listed in the `default` section. + +``` +empty .Foo +``` + +Note that in Go template conditionals, emptiness is calculated for you. Thus, +you rarely need `if empty .Foo`. Instead, just use `if .Foo`. + +### coalesce + +The `coalesce` function takes a list of values and returns the first non-empty +one. + +``` +coalesce 0 1 2 +``` + +The above returns `1`. + +This function is useful for scanning through multiple variables or values: + +``` +coalesce .name .parent.name "Matt" +``` + +The above will first check to see if `.name` is empty. If it is not, it will return +that value. If it _is_ empty, `coalesce` will evaluate `.parent.name` for emptiness. +Finally, if both `.name` and `.parent.name` are empty, it will return `Matt`. + +### all + +The `all` function takes a list of values and returns true if all values are non-empty. + +``` +all 0 1 2 +``` + +The above returns `false`. + +This function is useful for evaluating multiple conditions of variables or values: + +``` +all (eq .Request.TLS.Version 0x0304) (.Request.ProtoAtLeast 2 0) (eq .Request.Method "POST") +``` + +The above will check http.Request is POST with tls 1.3 and http/2. + +### any + +The `any` function takes a list of values and returns true if any value is non-empty. + +``` +any 0 1 2 +``` + +The above returns `true`. + +This function is useful for evaluating multiple conditions of variables or values: + +``` +any (eq .Request.Method "GET") (eq .Request.Method "POST") (eq .Request.Method "OPTIONS") +``` + +The above will check http.Request method is one of GET/POST/OPTIONS. + +### fromJSON, mustFromJSON + +`fromJSON` decodes a JSON document into a structure. If the input cannot be decoded as JSON the function will return an empty string. +`mustFromJSON` will return an error in case the JSON is invalid. + +``` +fromJSON "{\"foo\": 55}" +``` + +### toJSON, mustToJSON + +The `toJSON` function encodes an item into a JSON string. If the item cannot be converted to JSON the function will return an empty string. +`mustToJSON` will return an error in case the item cannot be encoded in JSON. + +``` +toJSON .Item +``` + +The above returns JSON string representation of `.Item`. + +### toPrettyJSON, mustToPrettyJSON + +The `toPrettyJSON` function encodes an item into a pretty (indented) JSON string. + +``` +toPrettyJSON .Item +``` + +The above returns indented JSON string representation of `.Item`. + +### toRawJSON, mustToRawJSON + +The `toRawJSON` function encodes an item into JSON string with HTML characters unescaped. + +``` +toRawJSON .Item +``` + +The above returns unescaped JSON string representation of `.Item`. + +### ternary + +The `ternary` function takes two values, and a test value. If the test value is +true, the first value will be returned. If the test value is empty, the second +value will be returned. This is similar to the c ternary operator. + +#### true test value + +``` +ternary "foo" "bar" true +``` + +or + +``` +true | ternary "foo" "bar" +``` + +The above returns `"foo"`. + +#### false test value + +``` +ternary "foo" "bar" false +``` + +or + +``` +false | ternary "foo" "bar" +``` + +The above returns `"bar"`. + +## Encoding Functions + +Sprig has the following encoding and decoding functions: + +- `b64enc`/`b64dec`: Encode or decode with Base64 +- `b32enc`/`b32dec`: Encode or decode with Base32 + +## Lists and List Functions + +Sprig provides a simple `list` type that can contain arbitrary sequential lists +of data. This is similar to arrays or slices, but lists are designed to be used +as immutable data types. + +Create a list of integers: + +``` +$myList := list 1 2 3 4 5 +``` + +The above creates a list of `[1 2 3 4 5]`. + +### first, mustFirst + +To get the head item on a list, use `first`. + +`first $myList` returns `1` + +`first` panics if there is a problem while `mustFirst` returns an error to the +template engine if there is a problem. + +### rest, mustRest + +To get the tail of the list (everything but the first item), use `rest`. + +`rest $myList` returns `[2 3 4 5]` + +`rest` panics if there is a problem while `mustRest` returns an error to the +template engine if there is a problem. + +### last, mustLast + +To get the last item on a list, use `last`: + +`last $myList` returns `5`. This is roughly analogous to reversing a list and +then calling `first`. + +`last` panics if there is a problem while `mustLast` returns an error to the +template engine if there is a problem. + +### initial, mustInitial + +This compliments `last` by returning all _but_ the last element. +`initial $myList` returns `[1 2 3 4]`. + +`initial` panics if there is a problem while `mustInitial` returns an error to the +template engine if there is a problem. + +### append, mustAppend + +Append a new item to an existing list, creating a new list. + +``` +$new = append $myList 6 +``` + +The above would set `$new` to `[1 2 3 4 5 6]`. `$myList` would remain unaltered. + +`append` panics if there is a problem while `mustAppend` returns an error to the +template engine if there is a problem. + +### prepend, mustPrepend + +Push an element onto the front of a list, creating a new list. + +``` +prepend $myList 0 +``` + +The above would produce `[0 1 2 3 4 5]`. `$myList` would remain unaltered. + +`prepend` panics if there is a problem while `mustPrepend` returns an error to the +template engine if there is a problem. + +### concat + +Concatenate arbitrary number of lists into one. + +``` +concat $myList ( list 6 7 ) ( list 8 ) +``` + +The above would produce `[1 2 3 4 5 6 7 8]`. `$myList` would remain unaltered. + +### reverse, mustReverse + +Produce a new list with the reversed elements of the given list. + +``` +reverse $myList +``` + +The above would generate the list `[5 4 3 2 1]`. + +`reverse` panics if there is a problem while `mustReverse` returns an error to the +template engine if there is a problem. + +### uniq, mustUniq + +Generate a list with all of the duplicates removed. + +``` +list 1 1 1 2 | uniq +``` + +The above would produce `[1 2]` + +`uniq` panics if there is a problem while `mustUniq` returns an error to the +template engine if there is a problem. + +### without, mustWithout + +The `without` function filters items out of a list. + +``` +without $myList 3 +``` + +The above would produce `[1 2 4 5]` + +Without can take more than one filter: + +``` +without $myList 1 3 5 +``` + +That would produce `[2 4]` + +`without` panics if there is a problem while `mustWithout` returns an error to the +template engine if there is a problem. + +### has, mustHas + +Test to see if a list has a particular element. + +``` +has 4 $myList +``` + +The above would return `true`, while `has "hello" $myList` would return false. + +`has` panics if there is a problem while `mustHas` returns an error to the +template engine if there is a problem. + +### compact, mustCompact + +Accepts a list and removes entries with empty values. + +``` +$list := list 1 "a" "foo" "" +$copy := compact $list +``` + +`compact` will return a new list with the empty (i.e., "") item removed. + +`compact` panics if there is a problem and `mustCompact` returns an error to the +template engine if there is a problem. + +### slice, mustSlice + +To get partial elements of a list, use `slice list [n] [m]`. It is +equivalent of `list[n:m]`. + +- `slice $myList` returns `[1 2 3 4 5]`. It is same as `myList[:]`. +- `slice $myList 3` returns `[4 5]`. It is same as `myList[3:]`. +- `slice $myList 1 3` returns `[2 3]`. It is same as `myList[1:3]`. +- `slice $myList 0 3` returns `[1 2 3]`. It is same as `myList[:3]`. + +`slice` panics if there is a problem while `mustSlice` returns an error to the +template engine if there is a problem. + +### chunk + +To split a list into chunks of given size, use `chunk size list`. This is useful for pagination. + +``` +chunk 3 (list 1 2 3 4 5 6 7 8) +``` + +This produces list of lists `[ [ 1 2 3 ] [ 4 5 6 ] [ 7 8 ] ]`. + +### A Note on List Internals + +A list is implemented in Go as a `[]any`. For Go developers embedding +Sprig, you may pass `[]any` items into your template context and be +able to use all of the `list` functions on those items. + +## Dictionaries and Dict Functions + +Sprig provides a key/value storage type called a `dict` (short for "dictionary", +as in Python). A `dict` is an _unorder_ type. + +The key to a dictionary **must be a string**. However, the value can be any +type, even another `dict` or `list`. + +Unlike `list`s, `dict`s are not immutable. The `set` and `unset` functions will +modify the contents of a dictionary. + +### dict + +Creating dictionaries is done by calling the `dict` function and passing it a +list of pairs. + +The following creates a dictionary with three items: + +``` +$myDict := dict "name1" "value1" "name2" "value2" "name3" "value 3" +``` + +### get + +Given a map and a key, get the value from the map. + +``` +get $myDict "name1" +``` + +The above returns `"value1"` + +Note that if the key is not found, this operation will simply return `""`. No error +will be generated. + +### set + +Use `set` to add a new key/value pair to a dictionary. + +``` +$_ := set $myDict "name4" "value4" +``` + +Note that `set` _returns the dictionary_ (a requirement of Go template functions), +so you may need to trap the value as done above with the `$_` assignment. + +### unset + +Given a map and a key, delete the key from the map. + +``` +$_ := unset $myDict "name4" +``` + +As with `set`, this returns the dictionary. + +Note that if the key is not found, this operation will simply return. No error +will be generated. + +### hasKey + +The `hasKey` function returns `true` if the given dict contains the given key. + +``` +hasKey $myDict "name1" +``` + +If the key is not found, this returns `false`. + +### pluck + +The `pluck` function makes it possible to give one key and multiple maps, and +get a list of all of the matches: + +``` +pluck "name1" $myDict $myOtherDict +``` + +The above will return a `list` containing every found value (`[value1 otherValue1]`). + +If the give key is _not found_ in a map, that map will not have an item in the +list (and the length of the returned list will be less than the number of dicts +in the call to `pluck`. + +If the key is _found_ but the value is an empty value, that value will be +inserted. + +A common idiom in Sprig templates is to uses `pluck... | first` to get the first +matching key out of a collection of dictionaries. + +### dig + +The `dig` function traverses a nested set of dicts, selecting keys from a list +of values. It returns a default value if any of the keys are not found at the +associated dict. + +``` +dig "user" "role" "humanName" "guest" $dict +``` + +Given a dict structured like +``` +{ + user: { + role: { + humanName: "curator" + } + } +} +``` + +the above would return `"curator"`. If the dict lacked even a `user` field, +the result would be `"guest"`. + +Dig can be very useful in cases where you'd like to avoid guard clauses, +especially since Go's template package's `and` doesn't shortcut. For instance +`and a.maybeNil a.maybeNil.iNeedThis` will always evaluate +`a.maybeNil.iNeedThis`, and panic if `a` lacks a `maybeNil` field.) + +`dig` accepts its dict argument last in order to support pipelining. + +### keys + +The `keys` function will return a `list` of all of the keys in one or more `dict` +types. Since a dictionary is _unordered_, the keys will not be in a predictable order. +They can be sorted with `sortAlpha`. + +``` +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. + +``` +keys $myDict $myOtherDict | uniq | sortAlpha +``` + +### pick + +The `pick` function selects just the given keys out of a dictionary, creating a +new `dict`. + +``` +$new := pick $myDict "name1" "name2" +``` + +The above returns `{name1: value1, name2: value2}` + +### omit + +The `omit` function is similar to `pick`, except it returns a new `dict` with all +the keys that _do not_ match the given keys. + +``` +$new := omit $myDict "name1" "name3" +``` + +The above returns `{name2: value2}` + +### values + +The `values` function is similar to `keys`, except it returns a new `list` with +all the values of the source `dict` (only one dictionary is supported). + +``` +$vals := values $myDict +``` + +The above returns `list["value1", "value2", "value 3"]`. Note that the `values` +function gives no guarantees about the result ordering- if you care about this, +then use `sortAlpha`. + +## Type Conversion Functions + +The following type conversion functions are provided by Sprig: + +- `atoi`: Convert a string to an integer. +- `float64`: Convert to a `float64`. +- `int`: Convert to an `int` at the system's width. +- `int64`: Convert to an `int64`. +- `toDecimal`: Convert a unix octal to a `int64`. +- `toString`: Convert to a string. +- `toStrings`: Convert a list, slice, or array to a list of strings. + +Only `atoi` requires that the input be a specific type. The others will attempt +to convert from any type to the destination type. For example, `int64` can convert +floats to ints, and it can also convert strings to ints. + +### toStrings + +Given a list-like collection, produce a slice of strings. + +``` +list 1 2 3 | toStrings +``` + +The above converts `1` to `"1"`, `2` to `"2"`, and so on, and then returns +them as a list. + +### toDecimal + +Given a unix octal permission, produce a decimal. + +``` +"0777" | toDecimal +``` + +The above converts `0777` to `511` and returns the value as an int64. + +## Path and Filepath Functions + +While Sprig does not grant access to the filesystem, it does provide functions +for working with strings that follow file path conventions. + +### Paths + +Paths separated by the slash character (`/`), processed by the `path` package. + +Examples: + +* The [Linux](https://en.wikipedia.org/wiki/Linux) and + [MacOS](https://en.wikipedia.org/wiki/MacOS) + [filesystems](https://en.wikipedia.org/wiki/File_system): + `/home/user/file`, `/etc/config`; +* The path component of + [URIs](https://en.wikipedia.org/wiki/Uniform_Resource_Identifier): + `https://example.com/some/content/`, `ftp://example.com/file/`. + +#### base + +Return the last element of a path. + +``` +base "foo/bar/baz" +``` + +The above prints "baz". + +#### dir + +Return the directory, stripping the last part of the path. So `dir "foo/bar/baz"` +returns `foo/bar`. + +#### clean + +Clean up a path. + +``` +clean "foo/bar/../baz" +``` + +The above resolves the `..` and returns `foo/baz`. + +#### ext + +Return the file extension. + +``` +ext "foo.bar" +``` + +The above returns `.bar`. + +#### isAbs + +To check whether a path is absolute, use `isAbs`. + +### Filepaths + +Paths separated by the `os.PathSeparator` variable, processed by the `path/filepath` package. + +These are the recommended functions to use when parsing paths of local filesystems, usually when dealing with local files, directories, etc. + +Examples: + +* Running on Linux or MacOS the filesystem path is separated by the slash character (`/`): + `/home/user/file`, `/etc/config`; +* Running on [Windows](https://en.wikipedia.org/wiki/Microsoft_Windows) + the filesystem path is separated by the backslash character (`\`): + `C:\Users\Username\`, `C:\Program Files\Application\`; + +#### osBase + +Return the last element of a filepath. + +``` +osBase "/foo/bar/baz" +osBase "C:\\foo\\bar\\baz" +``` + +The above prints "baz" on Linux and Windows, respectively. + +#### osDir + +Return the directory, stripping the last part of the path. So `osDir "/foo/bar/baz"` +returns `/foo/bar` on Linux, and `osDir "C:\\foo\\bar\\baz"` +returns `C:\\foo\\bar` on Windows. + +#### osClean + +Clean up a path. + +``` +osClean "/foo/bar/../baz" +osClean "C:\\foo\\bar\\..\\baz" +``` + +The above resolves the `..` and returns `foo/baz` on Linux and `C:\\foo\\baz` on Windows. + +#### osExt + +Return the file extension. + +``` +osExt "/foo.bar" +osExt "C:\\foo.bar" +``` + +The above returns `.bar` on Linux and Windows, respectively. + +#### osIsAbs + +To check whether a file path is absolute, use `osIsAbs`. + +## Flow Control Functions + +### fail + +Unconditionally returns an empty `string` and an `error` with the specified +text. This is useful in scenarios where other conditionals have determined that +template rendering should fail. + +``` +fail "Please accept the end user license agreement" +``` + +## Reflection Functions + +Sprig provides rudimentary reflection tools. These help advanced template +developers understand the underlying Go type information for a particular value. + +Go has several primitive _kinds_, like `string`, `slice`, `int64`, and `bool`. + +Go has an open _type_ system that allows developers to create their own types. + +Sprig provides a set of functions for each. + +### Kind Functions + +There are two Kind functions: `kindOf` returns the kind of an object. + +``` +kindOf "hello" +``` + +The above would return `string`. For simple tests (like in `if` blocks), the +`kindIs` function will let you verify that a value is a particular kind: + +``` +kindIs "int" 123 +``` + +The above will return `true` + +### Type Functions + +Types are slightly harder to work with, so there are three different functions: + +- `typeOf` returns the underlying type of a value: `typeOf $foo` +- `typeIs` is like `kindIs`, but for types: `typeIs "*io.Buffer" $myVal` +- `typeIsLike` works as `typeIs`, except that it also dereferences pointers. + +**Note:** None of these can test whether or not something implements a given +interface, since doing so would require compiling the interface in ahead of time. + +### deepEqual + +`deepEqual` returns true if two values are ["deeply equal"](https://golang.org/pkg/reflect/#DeepEqual) + +Works for non-primitive types as well (compared to the built-in `eq`). + +``` +deepEqual (list 1 2 3) (list 1 2 3) +``` + +The above will return `true` + +## Cryptographic and Security Functions + +Sprig provides a couple of advanced cryptographic functions. + +### sha1sum + +The `sha1sum` function receives a string, and computes it's SHA1 digest. + +``` +sha1sum "Hello world!" +``` + +### sha256sum + +The `sha256sum` function receives a string, and computes it's SHA256 digest. + +``` +sha256sum "Hello world!" +``` + +The above will compute the SHA 256 sum in an "ASCII armored" format that is +safe to print. + +### sha512sum + +The `sha512sum` function receives a string, and computes it's SHA512 digest. + +``` +sha512sum "Hello world!" +``` + +The above will compute the SHA 512 sum in an "ASCII armored" format that is +safe to print. + +### adler32sum + +The `adler32sum` function receives a string, and computes its Adler-32 checksum. + +``` +adler32sum "Hello world!" +``` + +## URL Functions + +### urlParse +Parses string for URL and produces dict with URL parts + +``` +urlParse "http://admin:secret@server.com:8080/api?list=false#anchor" +``` + +The above returns a dict, containing URL object: +```yaml +scheme: 'http' +host: 'server.com:8080' +path: '/api' +query: 'list=false' +opaque: nil +fragment: 'anchor' +userinfo: 'admin:secret' +``` + +For more info, check https://golang.org/pkg/net/url/#URL + +### urlJoin +Joins map (produced by `urlParse`) to produce URL string + +``` +urlJoin (dict "fragment" "fragment" "host" "host:80" "path" "/path" "query" "query" "scheme" "http") +``` + +The above returns the following string: +``` +proto://host:80/path?query#fragment +``` diff --git a/docs/releases.md b/docs/releases.md index 0877527e..6171dcff 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -2,6 +2,25 @@ Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases) and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases). +### ntfy server v2.13.0 +Released July 10, 2025 + +This is a relatively small release, mainly to support IPv6 and to add more sophisticated +proxy header support. Quick reminder that if you like ntfy, **please consider sponsoring us** +via [GitHub Sponsors](https://github.com/sponsors/binwiederhier) and [Liberapay](https://en.liberapay.com/ntfy/), or buying a [paid plan via the web app](https://ntfy.sh/app). +ntfy will always remain open source. + +**Features:** + +* Full [IPv6 support](config.md#ipv6-support) for ntfy and the official ntfy.sh server ([#519](https://github.com/binwiederhier/ntfy/issues/519)/[#1380](https://github.com/binwiederhier/ntfy/pull/1380)/[ansible#4](https://github.com/binwiederhier/ntfy-ansible/pull/4)) +* Support `X-Client-IP`, `X-Real-IP`, `Forwarded` headers for [rate limiting](config.md#ip-based-rate-limiting) via `proxy-forwarded-header` and `proxy-trusted-hosts` ([#1360](https://github.com/binwiederhier/ntfy/pull/1360)/[#1252](https://github.com/binwiederhier/ntfy/pull/1252), thanks to [@pixitha](https://github.com/pixitha)) +* Add STDIN support for `ntfy publish` ([#1382](https://github.com/binwiederhier/ntfy/pull/1382), thanks to [@srevn](https://github.com/srevn)) + +**Languages** + +* Update new languages from Weblate. Thanks to all the contributors! +* Added Estonian (Esti), Galician (Galego), Romanian (Română), Slovak (Slovenčina) as new languages to the web app + ### ntfy server v2.12.0 Released May 29, 2025 @@ -1433,18 +1452,12 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet -### ntfy server v2.13.0 (UNRELEASED) +### ntfy server v2.14.0 (UNRELEASED) **Features:** -* Full [IPv6 support](config.md#ipv6-support) for ntfy and the official ntfy.sh server ([#519](https://github.com/binwiederhier/ntfy/issues/519)/[#1380](https://github.com/binwiederhier/ntfy/pull/1380)/[ansible#4](https://github.com/binwiederhier/ntfy-ansible/pull/4)) -* Support `X-Client-IP`, `X-Real-IP`, `Forwarded` headers for [rate limiting](config.md#ip-based-rate-limiting) via `proxy-forwarded-header` and `proxy-trusted-hosts` ([#1360](https://github.com/binwiederhier/ntfy/pull/1360)/[#1252](https://github.com/binwiederhier/ntfy/pull/1252), thanks to [@pixitha](https://github.com/pixitha)) -* Add STDIN support for `ntfy publish` ([#1382](https://github.com/binwiederhier/ntfy/pull/1382), thanks to [@srevn](https://github.com/srevn)) - -**Languages** - -* Update new languages from Weblate. Thanks to all the contributors! -* Added Estonian (Esti), Galician (Galego), Romanian (Română), Slovak (Slovenčina) as new languages to the web app +* Enhanced JSON webhook support via [pre-defined](publish.md#pre-defined-templates) and [custom templates](publish.md#custom-templates) ([#1390](https://github.com/binwiederhier/ntfy/pull/1390)) +* Support of advanced [template functions](publish.md#template-functions) based on the [Sprig](https://github.com/Masterminds/sprig) library ([#1121](https://github.com/binwiederhier/ntfy/issues/1121), thanks to [@davidatkinsondoyle](https://github.com/davidatkinsondoyle) for reporting, to [@wunter8](https://github.com/wunter8) for implementing, and to the Sprig team for their work) ### ntfy Android app v1.16.1 (UNRELEASED) diff --git a/docs/static/img/android-screenshot-template-custom.png b/docs/static/img/android-screenshot-template-custom.png new file mode 100644 index 00000000..8325e9a4 Binary files /dev/null and b/docs/static/img/android-screenshot-template-custom.png differ diff --git a/docs/static/img/android-screenshot-template-predefined.png b/docs/static/img/android-screenshot-template-predefined.png new file mode 100644 index 00000000..ba77c31d Binary files /dev/null and b/docs/static/img/android-screenshot-template-predefined.png differ diff --git a/docs/static/img/screenshot-github-webhook-config.png b/docs/static/img/screenshot-github-webhook-config.png new file mode 100644 index 00000000..46b784b3 Binary files /dev/null and b/docs/static/img/screenshot-github-webhook-config.png differ diff --git a/docs/subscribe/cli.md b/docs/subscribe/cli.md index 78e160c8..36388c71 100644 --- a/docs/subscribe/cli.md +++ b/docs/subscribe/cli.md @@ -156,7 +156,7 @@ environment variables. Here are a few examples: ``` ntfy sub mytopic 'notify-send "$m"' ntfy sub topic1 /my/script.sh -ntfy sub topic1 'echo "Message $m was received. Its title was $t and it had priority $p' +ntfy sub topic1 'echo "Message $m was received. Its title was $t and it had priority $p"' ```
diff --git a/go.mod b/go.mod index 7bddeb07..4ecc680a 100644 --- a/go.mod +++ b/go.mod @@ -16,12 +16,12 @@ require ( github.com/olebedev/when v1.1.0 github.com/stretchr/testify v1.10.0 github.com/urfave/cli/v2 v2.27.7 - golang.org/x/crypto v0.39.0 + golang.org/x/crypto v0.40.0 golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sync v0.15.0 - golang.org/x/term v0.32.0 + golang.org/x/sync v0.16.0 + golang.org/x/term v0.33.0 golang.org/x/time v0.12.0 - google.golang.org/api v0.240.0 + google.golang.org/api v0.242.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -30,17 +30,18 @@ replace github.com/emersion/go-smtp => github.com/emersion/go-smtp v0.17.0 // Pi require github.com/pkg/errors v0.9.1 // indirect require ( - firebase.google.com/go/v4 v4.16.1 + firebase.google.com/go/v4 v4.17.0 github.com/SherClockHolmes/webpush-go v1.4.0 github.com/microcosm-cc/bluemonday v1.0.27 github.com/prometheus/client_golang v1.22.0 github.com/stripe/stripe-go/v74 v74.30.0 + golang.org/x/text v0.27.0 ) require ( cel.dev/expr v0.24.0 // indirect - cloud.google.com/go v0.121.3 // indirect - cloud.google.com/go/auth v0.16.2 // indirect + cloud.google.com/go v0.121.4 // indirect + cloud.google.com/go/auth v0.16.3 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.7.0 // indirect cloud.google.com/go/iam v1.5.2 // indirect @@ -64,12 +65,12 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect - github.com/golang-jwt/jwt/v5 v5.2.2 // indirect + github.com/golang-jwt/jwt/v5 v5.2.3 // indirect 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.6 // indirect - github.com/googleapis/gax-go/v2 v2.14.2 // indirect + github.com/googleapis/gax-go/v2 v2.15.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 @@ -91,13 +92,12 @@ require ( go.opentelemetry.io/otel/sdk v1.37.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect go.opentelemetry.io/otel/trace v1.37.0 // indirect - golang.org/x/net v0.41.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.26.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/sys v0.34.0 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect - google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250715232539-7130f93afb79 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79 // indirect google.golang.org/grpc v1.73.0 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 18815b70..1f98da35 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,12 @@ cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= cloud.google.com/go v0.121.3 h1:84RD+hQXNdY5Sw/MWVAx5O9Aui/rd5VQ9HEcdN19afo= cloud.google.com/go v0.121.3/go.mod h1:6vWF3nJWRrEUv26mMB3FEIU/o1MQNVPG1iHdisa2SJc= +cloud.google.com/go v0.121.4 h1:cVvUiY0sX0xwyxPwdSU2KsF9knOVmtRyAMt8xou0iTs= +cloud.google.com/go v0.121.4/go.mod h1:XEBchUiHFJbz4lKBZwYBDHV/rSyfFktk737TLDU089s= cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4= cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA= +cloud.google.com/go/auth v0.16.3 h1:kabzoQ9/bobUmnseYnBO6qQG7q4a/CffFRlJSxv2wCc= +cloud.google.com/go/auth v0.16.3/go.mod h1:NucRGjaXfzP1ltpcQ7On/VTZ0H4kWB5Jy+Y9Dnm76fA= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= @@ -24,6 +28,8 @@ cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4 cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI= firebase.google.com/go/v4 v4.16.1 h1:Kl5cgXmM0VOWDGT1UAx6b0T2UFWa14ak0CvYqeI7Py4= firebase.google.com/go/v4 v4.16.1/go.mod h1:aAPJq/bOyb23tBlc1K6GR+2E8sOGAeJSc8wIJVgl9SM= +firebase.google.com/go/v4 v4.17.0 h1:Bih69QV/k0YKPA1qUX04ln0aPT9IERrAo2ezibcngzE= +firebase.google.com/go/v4 v4.17.0/go.mod h1:aAPJq/bOyb23tBlc1K6GR+2E8sOGAeJSc8wIJVgl9SM= github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= @@ -83,6 +89,8 @@ github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= +github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= @@ -100,6 +108,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0= github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w= +github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= +github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -186,6 +196,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= 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= @@ -202,6 +214,8 @@ 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.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -213,6 +227,8 @@ golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -227,6 +243,8 @@ 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.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 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= @@ -238,6 +256,8 @@ 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.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= +golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= 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= @@ -251,6 +271,8 @@ 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.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -263,14 +285,23 @@ 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= google.golang.org/api v0.240.0 h1:PxG3AA2UIqT1ofIzWV2COM3j3JagKTKSwy7L6RHNXNU= google.golang.org/api v0.240.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50= +google.golang.org/api v0.242.0 h1:7Lnb1nfnpvbkCiZek6IXKdJ0MFuAZNAJKQfA1ws62xg= +google.golang.org/api v0.242.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= 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-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= +google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 h1:Nt6z9UHqSlIdIGJdz6KhTIs2VRx/iOsA5iE8bmQNcxs= +google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79/go.mod h1:kTmlBHMPqR5uCZPBvwa2B18mvubkjyY3CRLI0c6fj0s= google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= +google.golang.org/genproto/googleapis/api v0.0.0-20250715232539-7130f93afb79 h1:iOye66xuaAK0WnkPuhQPUFy8eJcmwUXqGGP3om6IxX8= +google.golang.org/genproto/googleapis/api v0.0.0-20250715232539-7130f93afb79/go.mod h1:HKJDgKsFUnv5VAGeQjz8kxcgDP0HoE0iZNp0OdZNlhE= google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79 h1:1ZwqphdOdWYXsUHgMpU/101nCtf/kSp9hOrcvFsnl10= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= diff --git a/mkdocs.yml b/mkdocs.yml index ef746518..adaf166b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -94,6 +94,7 @@ nav: - "Integrations + projects": integrations.md - "Release notes": releases.md - "Emojis 🥳 🎉": emojis.md + - "Template functions": publish/template-functions.md - "Troubleshooting": troubleshooting.md - "Known issues": known-issues.md - "Deprecation notices": deprecations.md diff --git a/server/config.go b/server/config.go index c163614f..86971e47 100644 --- a/server/config.go +++ b/server/config.go @@ -11,6 +11,8 @@ import ( // Defines default config settings (excluding limits, see below) const ( DefaultListenHTTP = ":80" + DefaultConfigFile = "/etc/ntfy/server.yml" + DefaultTemplateDir = "/etc/ntfy/templates" DefaultCacheDuration = 12 * time.Hour DefaultCacheBatchTimeout = time.Duration(0) DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!) @@ -101,6 +103,7 @@ type Config struct { AttachmentTotalSizeLimit int64 AttachmentFileSizeLimit int64 AttachmentExpiryDuration time.Duration + TemplateDir string // Directory to load named templates from KeepaliveInterval time.Duration ManagerInterval time.Duration DisallowedTopics []string @@ -174,7 +177,7 @@ type Config struct { // NewConfig instantiates a default new server config func NewConfig() *Config { return &Config{ - File: "", // Only used for testing + File: DefaultConfigFile, // Only used for testing BaseURL: "", ListenHTTP: DefaultListenHTTP, ListenHTTPS: "", @@ -197,6 +200,7 @@ func NewConfig() *Config { AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit, AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit, AttachmentExpiryDuration: DefaultAttachmentExpiryDuration, + TemplateDir: DefaultTemplateDir, KeepaliveInterval: DefaultKeepaliveInterval, ManagerInterval: DefaultManagerInterval, DisallowedTopics: DefaultDisallowedTopics, diff --git a/server/errors.go b/server/errors.go index c6076f3f..c6745779 100644 --- a/server/errors.go +++ b/server/errors.go @@ -123,6 +123,8 @@ var ( errHTTPBadRequestTemplateDisallowedFunctionCalls = &errHTTP{40044, http.StatusBadRequest, "invalid request: template contains disallowed function calls, e.g. template, call, or define", "https://ntfy.sh/docs/publish/#message-templating", nil} errHTTPBadRequestTemplateExecuteFailed = &errHTTP{40045, http.StatusBadRequest, "invalid request: template execution failed", "https://ntfy.sh/docs/publish/#message-templating", nil} errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil} + errHTTPBadRequestTemplateFileNotFound = &errHTTP{40047, http.StatusBadRequest, "invalid request: template file not found", "https://ntfy.sh/docs/publish/#message-templating", nil} + errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil} errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil} diff --git a/server/server.go b/server/server.go index cba9b181..d585faa0 100644 --- a/server/server.go +++ b/server/server.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "fmt" + "gopkg.in/yaml.v2" "io" "net" "net/http" @@ -34,6 +35,7 @@ import ( "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" + "heckel.io/ntfy/v2/util/sprig" ) // Server is the main server, providing the UI and API for ntfy @@ -120,6 +122,15 @@ var ( //go:embed docs docsStaticFs embed.FS docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs} + + //go:embed templates + templatesFs embed.FS // Contains template config files (e.g. grafana.yml, github.yml, ...) + templatesDir = "templates" + + // templateDisallowedRegex tests a template for disallowed expressions. While not really dangerous, they + // are not useful, and seem potentially troublesome. + templateDisallowedRegex = regexp.MustCompile(`(?m)\{\{-?\s*(call|template|define)\b`) + templateNameRegex = regexp.MustCompile(`^[-_A-Za-z0-9]+$`) ) const ( @@ -129,17 +140,13 @@ const ( newMessageBody = "New message" // Used in poll requests as generic message defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment encodingBase64 = "base64" // Used mainly for binary UnifiedPush messages - jsonBodyBytesLimit = 32768 // Max number of bytes for a request bodys (unless MessageLimit is higher) + jsonBodyBytesLimit = 131072 // Max number of bytes for a request bodys (unless MessageLimit is higher) unifiedPushTopicPrefix = "up" // Temporarily, we rate limit all "up*" topics based on the subscriber unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part messagesHistoryMax = 10 // Number of message count values to keep in memory - templateMaxExecutionTime = 100 * time.Millisecond -) - -var ( - // templateDisallowedRegex tests a template for disallowed expressions. While not really dangerous, they - // are not useful, and seem potentially troublesome. - templateDisallowedRegex = regexp.MustCompile(`(?m)\{\{-?\s*(call|template|define)\b`) + templateMaxExecutionTime = 100 * time.Millisecond // Maximum time a template can take to execute, used to prevent DoS attacks + templateMaxOutputBytes = 1024 * 1024 // Maximum number of bytes a template can output, used to prevent DoS attacks + templateFileExtension = ".yml" // Template files must end with this extension ) // WebSocket constants @@ -945,7 +952,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) { } } -func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template bool, unifiedpush bool, err *errHTTP) { +func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template templateMode, unifiedpush bool, err *errHTTP) { cache = readBoolParam(r, true, "x-cache", "cache") firebase = readBoolParam(r, true, "x-firebase", "firebase") m.Title = readParam(r, "x-title", "title", "t") @@ -961,7 +968,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi } if attach != "" { if !urlRegex.MatchString(attach) { - return false, false, "", "", false, false, errHTTPBadRequestAttachmentURLInvalid + return false, false, "", "", "", false, errHTTPBadRequestAttachmentURLInvalid } m.Attachment.URL = attach if m.Attachment.Name == "" { @@ -979,19 +986,19 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi } if icon != "" { if !urlRegex.MatchString(icon) { - return false, false, "", "", false, false, errHTTPBadRequestIconURLInvalid + return false, false, "", "", "", false, errHTTPBadRequestIconURLInvalid } m.Icon = icon } email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e") if s.smtpSender == nil && email != "" { - return false, false, "", "", false, false, errHTTPBadRequestEmailDisabled + return false, false, "", "", "", false, errHTTPBadRequestEmailDisabled } call = readParam(r, "x-call", "call") if call != "" && (s.config.TwilioAccount == "" || s.userManager == nil) { - return false, false, "", "", false, false, errHTTPBadRequestPhoneCallsDisabled + return false, false, "", "", "", false, errHTTPBadRequestPhoneCallsDisabled } else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) { - return false, false, "", "", false, false, errHTTPBadRequestPhoneNumberInvalid + return false, false, "", "", "", false, errHTTPBadRequestPhoneNumberInvalid } messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n") if messageStr != "" { @@ -1000,27 +1007,27 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi var e error m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p")) if e != nil { - return false, false, "", "", false, false, errHTTPBadRequestPriorityInvalid + return false, false, "", "", "", false, errHTTPBadRequestPriorityInvalid } m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta") delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in") if delayStr != "" { if !cache { - return false, false, "", "", false, false, errHTTPBadRequestDelayNoCache + return false, false, "", "", "", false, errHTTPBadRequestDelayNoCache } if email != "" { - return false, false, "", "", false, false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet) + return false, false, "", "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet) } if call != "" { - return false, false, "", "", false, false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet) + return false, false, "", "", "", false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet) } delay, err := util.ParseFutureTime(delayStr, time.Now()) if err != nil { - return false, false, "", "", false, false, errHTTPBadRequestDelayCannotParse + return false, false, "", "", "", false, errHTTPBadRequestDelayCannotParse } else if delay.Unix() < time.Now().Add(s.config.MessageDelayMin).Unix() { - return false, false, "", "", false, false, errHTTPBadRequestDelayTooSmall + return false, false, "", "", "", false, errHTTPBadRequestDelayTooSmall } else if delay.Unix() > time.Now().Add(s.config.MessageDelayMax).Unix() { - return false, false, "", "", false, false, errHTTPBadRequestDelayTooLarge + return false, false, "", "", "", false, errHTTPBadRequestDelayTooLarge } m.Time = delay.Unix() } @@ -1028,14 +1035,14 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi if actionsStr != "" { m.Actions, e = parseActions(actionsStr) if e != nil { - return false, false, "", "", false, false, errHTTPBadRequestActionsInvalid.Wrap("%s", e.Error()) + return false, false, "", "", "", false, errHTTPBadRequestActionsInvalid.Wrap("%s", e.Error()) } } contentType, markdown := readParam(r, "content-type", "content_type"), readBoolParam(r, false, "x-markdown", "markdown", "md") if markdown || strings.ToLower(contentType) == "text/markdown" { m.ContentType = "text/markdown" } - template = readBoolParam(r, false, "x-template", "template", "tpl") + template = templateMode(readParam(r, "x-template", "template", "tpl")) unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too! contentEncoding := readParam(r, "content-encoding") if unifiedpush || contentEncoding == "aes128gcm" { @@ -1067,7 +1074,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi // If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message // 7. curl -T file.txt ntfy.sh/mytopic // In all other cases, mostly if file.txt is > message limit, treat it as an attachment -func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, template, unifiedpush bool) error { +func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, template templateMode, unifiedpush bool) error { if m.Event == pollRequestEvent { // Case 1 return s.handleBodyDiscard(body) } else if unifiedpush { @@ -1076,8 +1083,8 @@ func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body return s.handleBodyAsTextMessage(m, body) // Case 3 } else if m.Attachment != nil && m.Attachment.Name != "" { return s.handleBodyAsAttachment(r, v, m, body) // Case 4 - } else if template { - return s.handleBodyAsTemplatedTextMessage(m, body) // Case 5 + } else if template.Enabled() { + return s.handleBodyAsTemplatedTextMessage(m, template, body) // Case 5 } else if !body.LimitReached && utf8.Valid(body.PeekedBytes) { return s.handleBodyAsTextMessage(m, body) // Case 6 } @@ -1113,7 +1120,7 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser return nil } -func (s *Server) handleBodyAsTemplatedTextMessage(m *message, body *util.PeekedReadCloser) error { +func (s *Server) handleBodyAsTemplatedTextMessage(m *message, template templateMode, body *util.PeekedReadCloser) error { body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit)) if err != nil { return err @@ -1121,19 +1128,69 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *message, body *util.PeekedR return errHTTPEntityTooLargeJSONBody } peekedBody := strings.TrimSpace(string(body.PeekedBytes)) - if m.Message, err = replaceTemplate(m.Message, peekedBody); err != nil { - return err + if templateName := template.Name(); templateName != "" { + if err := s.renderTemplateFromFile(m, templateName, peekedBody); err != nil { + return err + } + } else { + if err := s.renderTemplateFromParams(m, peekedBody); err != nil { + return err + } } - if m.Title, err = replaceTemplate(m.Title, peekedBody); err != nil { - return err - } - if len(m.Message) > s.config.MessageSizeLimit { + if len(m.Title) > s.config.MessageSizeLimit || len(m.Message) > s.config.MessageSizeLimit { return errHTTPBadRequestTemplateMessageTooLarge } return nil } -func replaceTemplate(tpl string, source string) (string, error) { +// renderTemplateFromFile transforms the JSON message body according to a template from the filesystem. +// The template file must be in the templates directory, or in the configured template directory. +func (s *Server) renderTemplateFromFile(m *message, templateName, peekedBody string) error { + if !templateNameRegex.MatchString(templateName) { + return errHTTPBadRequestTemplateFileNotFound + } + templateContent, _ := templatesFs.ReadFile(filepath.Join(templatesDir, templateName+templateFileExtension)) // Read from the embedded filesystem first + if s.config.TemplateDir != "" { + if b, _ := os.ReadFile(filepath.Join(s.config.TemplateDir, templateName+templateFileExtension)); len(b) > 0 { + templateContent = b + } + } + if len(templateContent) == 0 { + return errHTTPBadRequestTemplateFileNotFound + } + var tpl templateFile + if err := yaml.Unmarshal(templateContent, &tpl); err != nil { + return errHTTPBadRequestTemplateFileInvalid + } + var err error + if tpl.Message != nil { + if m.Message, err = s.renderTemplate(*tpl.Message, peekedBody); err != nil { + return err + } + } + if tpl.Title != nil { + if m.Title, err = s.renderTemplate(*tpl.Title, peekedBody); err != nil { + return err + } + } + return nil +} + +// renderTemplateFromParams transforms the JSON message body according to the inline template in the +// message and title parameters. +func (s *Server) renderTemplateFromParams(m *message, peekedBody string) error { + var err error + if m.Message, err = s.renderTemplate(m.Message, peekedBody); err != nil { + return err + } + if m.Title, err = s.renderTemplate(m.Title, peekedBody); err != nil { + return err + } + return nil +} + +// renderTemplate renders a template with the given JSON source data. +func (s *Server) renderTemplate(tpl string, source string) (string, error) { if templateDisallowedRegex.MatchString(tpl) { return "", errHTTPBadRequestTemplateDisallowedFunctionCalls } @@ -1141,15 +1198,16 @@ func replaceTemplate(tpl string, source string) (string, error) { if err := json.Unmarshal([]byte(source), &data); err != nil { return "", errHTTPBadRequestTemplateMessageNotJSON } - t, err := template.New("").Parse(tpl) + t, err := template.New("").Funcs(sprig.TxtFuncMap()).Parse(tpl) if err != nil { - return "", errHTTPBadRequestTemplateInvalid + return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error()) } var buf bytes.Buffer - if err := t.Execute(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), data); err != nil { - return "", errHTTPBadRequestTemplateExecuteFailed + limitWriter := util.NewLimitWriter(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), util.NewFixedLimiter(templateMaxOutputBytes)) + if err := t.Execute(limitWriter, data); err != nil { + return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("%s", err.Error()) } - return buf.String(), nil + return strings.TrimSpace(buf.String()), nil } func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) error { diff --git a/server/server.yml b/server/server.yml index e1a58232..db968498 100644 --- a/server/server.yml +++ b/server/server.yml @@ -126,6 +126,26 @@ # attachment-file-size-limit: "15M" # attachment-expiry-duration: "3h" +# Template directory for message templates. +# +# When "X-Template: " (aliases: "Template: ", "Tpl: ") or "?template=" is set, transform the message +# based on one of the built-in pre-defined templates, or on a template defined in the "template-dir" directory. +# +# Template files must have the ".yml" extension and must be formatted as YAML. They may contain "title" and "message" keys, +# which are interpreted as Go templates. +# +# Example template file (e.g. /etc/ntfy/templates/grafana.yml): +# title: | +# {{- if eq .status "firing" }} +# {{ .title | default "Alert firing" }} +# {{- else if eq .status "resolved" }} +# {{ .title | default "Alert resolved" }} +# {{- end }} +# message: | +# {{ .message | trunc 2000 }} +# +# template-dir: "/etc/ntfy/templates" + # If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set, # messages will additionally be sent out as e-mail using an external SMTP server. # diff --git a/server/server_test.go b/server/server_test.go index e09f67a2..36bbae3f 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "crypto/rand" + _ "embed" "encoding/base64" "encoding/json" "fmt" @@ -2917,7 +2918,7 @@ func TestServer_MessageTemplate_Range(t *testing.T) { require.Equal(t, 200, response.Code) m := toMessage(t, response.Body.String()) - require.Equal(t, "Severe URLs:\n- https://severe1.com\n- https://severe2.com\n", m.Message) + require.Equal(t, "Severe URLs:\n- https://severe1.com\n- https://severe2.com", m.Message) } func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageOK(t *testing.T) { @@ -2970,8 +2971,7 @@ Labels: Annotations: - summary = 15m load average too high Source: localhost:3000/alerting/grafana/NW9oDw-4z/view -Silence: localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter -`, m.Message) +Silence: localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter`, m.Message) } func TestServer_MessageTemplate_GitHub(t *testing.T) { @@ -3024,12 +3024,168 @@ template ""}}`, } } +func TestServer_MessageTemplate_SprigFunctions(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + bodies := []string{ + `{"foo":"bar","nested":{"title":"here"}}`, + `{"topic":"ntfy-test"}`, + `{"topic":"another-topic"}`, + } + templates := []string{ + `{{.foo | upper}} is {{.nested.title | repeat 3}}`, + `{{if hasPrefix "ntfy-" .topic}}Topic: {{trimPrefix "ntfy-" .topic}}{{ else }}Topic: {{.topic}}{{end}}`, + `{{if hasPrefix "ntfy-" .topic}}Topic: {{trimPrefix "ntfy-" .topic}}{{ else }}Topic: {{.topic}}{{end}}`, + } + targets := []string{ + `BAR is hereherehere`, + `Topic: test`, + `Topic: another-topic`, + } + for i, body := range bodies { + template := templates[i] + target := targets[i] + t.Run(template, func(t *testing.T) { + response := request(t, s, "PUT", `/mytopic`, body, map[string]string{ + "Template": "yes", + "Message": template, + }) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, target, m.Message) + }) + } +} + +func TestServer_MessageTemplate_UnsafeSprigFunctions(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{ + "X-Message": `{{ env "PATH" }}`, + "X-Template": "1", + }) + + require.Equal(t, 400, response.Code) + require.Equal(t, 40043, toHTTPError(t, response.Body.String()).Code) +} + +var ( + //go:embed testdata/webhook_github_comment_created.json + githubCommentCreatedJSON string + + //go:embed testdata/webhook_github_issue_opened.json + githubIssueOpenedJSON string +) + +func TestServer_MessageTemplate_FromNamedTemplate_GitHubCommentCreated(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "POST", "/mytopic?template=github", githubCommentCreatedJSON, nil) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "💬 New comment on issue #1389 instant alerts without Pull to refresh", m.Title) + require.Equal(t, `Commenter: https://github.com/wunter8 +Repository: https://github.com/binwiederhier/ntfy +Comment link: https://github.com/binwiederhier/ntfy/issues/1389#issuecomment-3078214289 + +Comment: +These are the things you need to do to get iOS push notifications to work: +1. open a browser to the web app of your ntfy instance and copy the URL (including "http://" or "https://", your domain or IP address, and any ports, and excluding any trailing slashes) +2. put the URL you copied in the ntfy `+"`"+`base-url`+"`"+` config in server.yml or NTFY_BASE_URL in env variables +3. put the URL you copied in the default server URL setting in the iOS ntfy app +4. set `+"`"+`upstream-base-url`+"`"+` in server.yml or NTFY_UPSTREAM_BASE_URL in env variables to "https://ntfy.sh" (without a trailing slash)`, m.Message) +} + +func TestServer_MessageTemplate_FromNamedTemplate_GitHubIssueOpened(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "POST", "/mytopic?template=github", githubIssueOpenedJSON, nil) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "🐛 Issue opened: #1391 http 500 error (ntfy error 50001)", m.Title) + require.Equal(t, `Opened by: https://github.com/TheUser-dev +Repository: https://github.com/binwiederhier/ntfy +Issue link: https://github.com/binwiederhier/ntfy/issues/1391 +Labels: 🪲 bug + +Description: +:lady_beetle: **Describe the bug** +When sending a notification (especially when it happens with multiple requests) this error occurs + +:computer: **Components impacted** +ntfy server 2.13.0 in docker, debian 12 arm64 + +:bulb: **Screenshots and/or logs** +`+"```"+` +closed with HTTP 500 (ntfy error 50001) (error=database table is locked, http_method=POST, http_path=/_matrix/push/v1/notify, tag=http, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=30, visitor_id=ip:, visitor_ip=, visitor_messages=448, visitor_messages_limit=17280, visitor_messages_remaining=16832, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=57.049697891799994, visitor_seen=2025-07-16T15:06:35.429Z) +`+"```"+` + +:crystal_ball: **Additional context** +Looks like this has already been fixed by #498, regression?`, m.Message) +} + +func TestServer_MessageTemplate_FromNamedTemplate_GitHubIssueOpened_OverrideConfigTemplate(t *testing.T) { + t.Parallel() + c := newTestConfig(t) + c.TemplateDir = t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(c.TemplateDir, "github.yml"), []byte(` +title: | + Custom title: action={{ .action }} trunctitle={{ .issue.title | trunc 10 }} +message: | + Custom message {{ .issue.number }} +`), 0644)) + s := newTestServer(t, c) + response := request(t, s, "POST", "/mytopic?template=github", githubIssueOpenedJSON, nil) + fmt.Println(response.Body.String()) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "Custom title: action=opened trunctitle=http 500 e", m.Title) + require.Equal(t, "Custom message 1391", m.Message) +} + +func TestServer_MessageTemplate_Repeat9999_TooLarge(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{ + "X-Message": `{{ repeat 9999 "mystring" }}`, + "X-Template": "1", + }) + require.Equal(t, 400, response.Code) + require.Equal(t, 40041, toHTTPError(t, response.Body.String()).Code) + require.Contains(t, toHTTPError(t, response.Body.String()).Message, "message or title is too large after replacing template") +} + +func TestServer_MessageTemplate_Repeat10001_TooLarge(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{ + "X-Message": `{{ repeat 10001 "mystring" }}`, + "X-Template": "1", + }) + require.Equal(t, 400, response.Code) + require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code) + require.Contains(t, toHTTPError(t, response.Body.String()).Message, "repeat count 10001 exceeds limit of 10000") +} + +func TestServer_MessageTemplate_Until100_000(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{ + "X-Message": `{{ range $i, $e := until 100_000 }}{{end}}`, + "X-Template": "1", + }) + require.Equal(t, 400, response.Code) + require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code) + require.Contains(t, toHTTPError(t, response.Body.String()).Message, "too many iterations") +} + func newTestConfig(t *testing.T) *Config { conf := NewConfig() conf.BaseURL = "http://127.0.0.1:12345" conf.CacheFile = filepath.Join(t.TempDir(), "cache.db") conf.CacheStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;" conf.AttachmentCacheDir = t.TempDir() + conf.TemplateDir = t.TempDir() return conf } diff --git a/server/smtp_server.go b/server/smtp_server.go index 6de42e37..ee28efc2 100644 --- a/server/smtp_server.go +++ b/server/smtp_server.go @@ -192,12 +192,12 @@ func (s *smtpSession) publishMessage(m *message) error { // Call HTTP handler with fake HTTP request url := fmt.Sprintf("%s/%s", s.backend.config.BaseURL, m.Topic) req, err := http.NewRequest("POST", url, strings.NewReader(m.Message)) - req.RequestURI = "/" + m.Topic // just for the logs - req.RemoteAddr = remoteAddr // rate limiting!! - req.Header.Set(s.backend.config.ProxyForwardedHeader, remoteAddr) // Set X-Forwarded-For header if err != nil { return err } + req.RequestURI = "/" + m.Topic // just for the logs + req.RemoteAddr = remoteAddr // rate limiting!! + req.Header.Set(s.backend.config.ProxyForwardedHeader, remoteAddr) // Set X-Forwarded-For header if m.Title != "" { req.Header.Set("Title", m.Title) } diff --git a/server/templates/alertmanager.yml b/server/templates/alertmanager.yml new file mode 100644 index 00000000..a63a756c --- /dev/null +++ b/server/templates/alertmanager.yml @@ -0,0 +1,27 @@ +title: | + {{- if eq .status "firing" }} + 🚨 Alert: {{ (first .alerts).labels.alertname }} + {{- else if eq .status "resolved" }} + ✅ Resolved: {{ (first .alerts).labels.alertname }} + {{- else }} + {{ fail "Unsupported Alertmanager status." }} + {{- end }} +message: | + Status: {{ .status | title }} + Receiver: {{ .receiver }} + + {{- range .alerts }} + Alert: {{ .labels.alertname }} + Instance: {{ .labels.instance }} + Severity: {{ .labels.severity }} + Starts at: {{ .startsAt }} + {{- if .endsAt }}Ends at: {{ .endsAt }}{{ end }} + {{- if .annotations.summary }} + Summary: {{ .annotations.summary }} + {{- end }} + {{- if .annotations.description }} + Description: {{ .annotations.description }} + {{- end }} + Source: {{ .generatorURL }} + + {{ end }} diff --git a/server/templates/github.yml b/server/templates/github.yml new file mode 100644 index 00000000..aee95b42 --- /dev/null +++ b/server/templates/github.yml @@ -0,0 +1,57 @@ +title: | + {{- if and .starred_at (eq .action "created")}} + ⭐ {{ .sender.login }} starred {{ .repository.name }} + + {{- else if and .repository (eq .action "started")}} + 👀 {{ .sender.login }} started watching {{ .repository.name }} + + {{- else if and .comment (eq .action "created") }} + 💬 New comment on issue #{{ .issue.number }} {{ .issue.title }} + + {{- else if .pull_request }} + 🔀 Pull request {{ .action }}: #{{ .pull_request.number }} {{ .pull_request.title }} + + {{- else if .issue }} + 🐛 Issue {{ .action }}: #{{ .issue.number }} {{ .issue.title }} + + {{- else }} + {{ fail "Unsupported GitHub event type or action." }} + {{- end }} +message: | + {{ if and .starred_at (eq .action "created")}} + Stargazer: {{ .sender.html_url }} + Repository: {{ .repository.html_url }} + + {{- else if and .repository (eq .action "started")}} + Watcher: {{ .sender.html_url }} + Repository: {{ .repository.html_url }} + + {{- else if and .comment (eq .action "created") }} + Commenter: {{ .comment.user.html_url }} + Repository: {{ .repository.html_url }} + Comment link: {{ .comment.html_url }} + {{ if .comment.body }} + Comment: + {{ .comment.body | trunc 2000 }}{{ end }} + + {{- else if .pull_request }} + Branch: {{ .pull_request.head.ref }} → {{ .pull_request.base.ref }} + {{ .action | title }} by: {{ .pull_request.user.html_url }} + Repository: {{ .repository.html_url }} + Pull request: {{ .pull_request.html_url }} + {{ if .pull_request.body }} + Description: + {{ .pull_request.body | trunc 2000 }}{{ end }} + + {{- else if .issue }} + {{ .action | title }} by: {{ .issue.user.html_url }} + Repository: {{ .repository.html_url }} + Issue link: {{ .issue.html_url }} + {{ if .issue.labels }}Labels: {{ range .issue.labels }}{{ .name }} {{ end }}{{ end }} + {{ if .issue.body }} + Description: + {{ .issue.body | trunc 2000 }}{{ end }} + + {{- else }} + {{ fail "Unsupported GitHub event type or action." }} + {{- end }} diff --git a/server/templates/grafana.yml b/server/templates/grafana.yml new file mode 100644 index 00000000..bdb64e45 --- /dev/null +++ b/server/templates/grafana.yml @@ -0,0 +1,10 @@ +title: | + {{- if eq .status "firing" }} + 🚨 {{ .title | default "Alert firing" }} + {{- else if eq .status "resolved" }} + ✅ {{ .title | default "Alert resolved" }} + {{- else }} + ⚠️ Unknown alert: {{ .title | default "Alert" }} + {{- end }} +message: | + {{ .message | trunc 2000 }} diff --git a/server/testdata/webhook_alertmanager_firing.json b/server/testdata/webhook_alertmanager_firing.json new file mode 100644 index 00000000..9155bd9e --- /dev/null +++ b/server/testdata/webhook_alertmanager_firing.json @@ -0,0 +1,33 @@ +{ + "version": "4", + "groupKey": "...", + "status": "firing", + "receiver": "webhook-receiver", + "groupLabels": { + "alertname": "HighCPUUsage" + }, + "commonLabels": { + "alertname": "HighCPUUsage", + "instance": "server01", + "severity": "critical" + }, + "commonAnnotations": { + "summary": "High CPU usage detected" + }, + "alerts": [ + { + "status": "firing", + "labels": { + "alertname": "HighCPUUsage", + "instance": "server01", + "severity": "critical" + }, + "annotations": { + "summary": "High CPU usage detected" + }, + "startsAt": "2025-07-17T07:00:00Z", + "endsAt": "0001-01-01T00:00:00Z", + "generatorURL": "http://prometheus.local/graph?g0.expr=..." + } + ] +} diff --git a/server/testdata/webhook_github_comment_created.json b/server/testdata/webhook_github_comment_created.json new file mode 100644 index 00000000..04e7cddb --- /dev/null +++ b/server/testdata/webhook_github_comment_created.json @@ -0,0 +1,261 @@ +{ + "action": "created", + "issue": { + "url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1389", + "repository_url": "https://api.github.com/repos/binwiederhier/ntfy", + "labels_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1389/labels{/name}", + "comments_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1389/comments", + "events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1389/events", + "html_url": "https://github.com/binwiederhier/ntfy/issues/1389", + "id": 3230655753, + "node_id": "I_kwDOGRBhi87Aj-UJ", + "number": 1389, + "title": "instant alerts without Pull to refresh", + "user": { + "login": "edbraunh", + "id": 8795846, + "node_id": "MDQ6VXNlcjg3OTU4NDY=", + "avatar_url": "https://avatars.githubusercontent.com/u/8795846?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/edbraunh", + "html_url": "https://github.com/edbraunh", + "followers_url": "https://api.github.com/users/edbraunh/followers", + "following_url": "https://api.github.com/users/edbraunh/following{/other_user}", + "gists_url": "https://api.github.com/users/edbraunh/gists{/gist_id}", + "starred_url": "https://api.github.com/users/edbraunh/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/edbraunh/subscriptions", + "organizations_url": "https://api.github.com/users/edbraunh/orgs", + "repos_url": "https://api.github.com/users/edbraunh/repos", + "events_url": "https://api.github.com/users/edbraunh/events{/privacy}", + "received_events_url": "https://api.github.com/users/edbraunh/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "labels": [ + { + "id": 3480884105, + "node_id": "LA_kwDOGRBhi87PehOJ", + "url": "https://api.github.com/repos/binwiederhier/ntfy/labels/enhancement", + "name": "enhancement", + "color": "a2eeef", + "default": true, + "description": "New feature or request" + } + ], + "state": "open", + "locked": false, + "assignee": null, + "assignees": [ + ], + "milestone": null, + "comments": 3, + "created_at": "2025-07-15T03:46:30Z", + "updated_at": "2025-07-16T11:45:57Z", + "closed_at": null, + "author_association": "NONE", + "active_lock_reason": null, + "sub_issues_summary": { + "total": 0, + "completed": 0, + "percent_completed": 0 + }, + "body": "Hello ntfy Team,\n\nFirst off, thank you for developing such a powerful and lightweight notification app — it’s been invaluable for receiving timely alerts.\n\nI’m a user who relies heavily on ntfy for real-time trading alerts and have noticed that while push notifications arrive instantly, the in-app alert list does not automatically refresh with new messages. Currently, I need to manually pull-to-refresh the alert list to see the latest alerts.\n\nWould it be possible to add a feature that enables automatic refreshing of the alert list as new notifications arrive? This would greatly enhance usability and streamline the user experience, especially for users monitoring time-sensitive information.\n\nThank you for considering this request. I appreciate your hard work and look forward to future updates!", + "reactions": { + "url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1389/reactions", + "total_count": 0, + "+1": 0, + "-1": 0, + "laugh": 0, + "hooray": 0, + "confused": 0, + "heart": 0, + "rocket": 0, + "eyes": 0 + }, + "timeline_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1389/timeline", + "performed_via_github_app": null, + "state_reason": null + }, + "comment": { + "url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments/3078214289", + "html_url": "https://github.com/binwiederhier/ntfy/issues/1389#issuecomment-3078214289", + "issue_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1389", + "id": 3078214289, + "node_id": "IC_kwDOGRBhi863edKR", + "user": { + "login": "wunter8", + "id": 8421688, + "node_id": "MDQ6VXNlcjg0MjE2ODg=", + "avatar_url": "https://avatars.githubusercontent.com/u/8421688?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/wunter8", + "html_url": "https://github.com/wunter8", + "followers_url": "https://api.github.com/users/wunter8/followers", + "following_url": "https://api.github.com/users/wunter8/following{/other_user}", + "gists_url": "https://api.github.com/users/wunter8/gists{/gist_id}", + "starred_url": "https://api.github.com/users/wunter8/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/wunter8/subscriptions", + "organizations_url": "https://api.github.com/users/wunter8/orgs", + "repos_url": "https://api.github.com/users/wunter8/repos", + "events_url": "https://api.github.com/users/wunter8/events{/privacy}", + "received_events_url": "https://api.github.com/users/wunter8/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "created_at": "2025-07-16T11:45:57Z", + "updated_at": "2025-07-16T11:45:57Z", + "author_association": "CONTRIBUTOR", + "body": "These are the things you need to do to get iOS push notifications to work:\n1. open a browser to the web app of your ntfy instance and copy the URL (including \"http://\" or \"https://\", your domain or IP address, and any ports, and excluding any trailing slashes)\n2. put the URL you copied in the ntfy `base-url` config in server.yml or NTFY_BASE_URL in env variables\n3. put the URL you copied in the default server URL setting in the iOS ntfy app\n4. set `upstream-base-url` in server.yml or NTFY_UPSTREAM_BASE_URL in env variables to \"https://ntfy.sh\" (without a trailing slash)", + "reactions": { + "url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments/3078214289/reactions", + "total_count": 0, + "+1": 0, + "-1": 0, + "laugh": 0, + "hooray": 0, + "confused": 0, + "heart": 0, + "rocket": 0, + "eyes": 0 + }, + "performed_via_github_app": null + }, + "repository": { + "id": 420503947, + "node_id": "R_kgDOGRBhiw", + "name": "ntfy", + "full_name": "binwiederhier/ntfy", + "private": false, + "owner": { + "login": "binwiederhier", + "id": 664597, + "node_id": "MDQ6VXNlcjY2NDU5Nw==", + "avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/binwiederhier", + "html_url": "https://github.com/binwiederhier", + "followers_url": "https://api.github.com/users/binwiederhier/followers", + "following_url": "https://api.github.com/users/binwiederhier/following{/other_user}", + "gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}", + "starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions", + "organizations_url": "https://api.github.com/users/binwiederhier/orgs", + "repos_url": "https://api.github.com/users/binwiederhier/repos", + "events_url": "https://api.github.com/users/binwiederhier/events{/privacy}", + "received_events_url": "https://api.github.com/users/binwiederhier/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "html_url": "https://github.com/binwiederhier/ntfy", + "description": "Send push notifications to your phone or desktop using PUT/POST", + "fork": false, + "url": "https://api.github.com/repos/binwiederhier/ntfy", + "forks_url": "https://api.github.com/repos/binwiederhier/ntfy/forks", + "keys_url": "https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/binwiederhier/ntfy/teams", + "hooks_url": "https://api.github.com/repos/binwiederhier/ntfy/hooks", + "issue_events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}", + "events_url": "https://api.github.com/repos/binwiederhier/ntfy/events", + "assignees_url": "https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}", + "branches_url": "https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}", + "tags_url": "https://api.github.com/repos/binwiederhier/ntfy/tags", + "blobs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}", + "languages_url": "https://api.github.com/repos/binwiederhier/ntfy/languages", + "stargazers_url": "https://api.github.com/repos/binwiederhier/ntfy/stargazers", + "contributors_url": "https://api.github.com/repos/binwiederhier/ntfy/contributors", + "subscribers_url": "https://api.github.com/repos/binwiederhier/ntfy/subscribers", + "subscription_url": "https://api.github.com/repos/binwiederhier/ntfy/subscription", + "commits_url": "https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/binwiederhier/ntfy/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}", + "compare_url": "https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/binwiederhier/ntfy/merges", + "archive_url": "https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/binwiederhier/ntfy/downloads", + "issues_url": "https://api.github.com/repos/binwiederhier/ntfy/issues{/number}", + "pulls_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}", + "milestones_url": "https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}", + "notifications_url": "https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/binwiederhier/ntfy/labels{/name}", + "releases_url": "https://api.github.com/repos/binwiederhier/ntfy/releases{/id}", + "deployments_url": "https://api.github.com/repos/binwiederhier/ntfy/deployments", + "created_at": "2021-10-23T19:25:32Z", + "updated_at": "2025-07-16T10:18:34Z", + "pushed_at": "2025-07-13T13:56:19Z", + "git_url": "git://github.com/binwiederhier/ntfy.git", + "ssh_url": "git@github.com:binwiederhier/ntfy.git", + "clone_url": "https://github.com/binwiederhier/ntfy.git", + "svn_url": "https://github.com/binwiederhier/ntfy", + "homepage": "https://ntfy.sh", + "size": 36740, + "stargazers_count": 25111, + "watchers_count": 25111, + "language": "Go", + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": false, + "has_discussions": false, + "forks_count": 984, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 367, + "license": { + "key": "apache-2.0", + "name": "Apache License 2.0", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/licenses/apache-2.0", + "node_id": "MDc6TGljZW5zZTI=" + }, + "allow_forking": true, + "is_template": false, + "web_commit_signoff_required": false, + "topics": [ + "curl", + "notifications", + "ntfy", + "ntfysh", + "pubsub", + "push-notifications", + "rest-api" + ], + "visibility": "public", + "forks": 984, + "open_issues": 367, + "watchers": 25111, + "default_branch": "main" + }, + "sender": { + "login": "wunter8", + "id": 8421688, + "node_id": "MDQ6VXNlcjg0MjE2ODg=", + "avatar_url": "https://avatars.githubusercontent.com/u/8421688?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/wunter8", + "html_url": "https://github.com/wunter8", + "followers_url": "https://api.github.com/users/wunter8/followers", + "following_url": "https://api.github.com/users/wunter8/following{/other_user}", + "gists_url": "https://api.github.com/users/wunter8/gists{/gist_id}", + "starred_url": "https://api.github.com/users/wunter8/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/wunter8/subscriptions", + "organizations_url": "https://api.github.com/users/wunter8/orgs", + "repos_url": "https://api.github.com/users/wunter8/repos", + "events_url": "https://api.github.com/users/wunter8/events{/privacy}", + "received_events_url": "https://api.github.com/users/wunter8/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + } +} diff --git a/server/testdata/webhook_github_issue_opened.json b/server/testdata/webhook_github_issue_opened.json new file mode 100644 index 00000000..1b3e74c0 --- /dev/null +++ b/server/testdata/webhook_github_issue_opened.json @@ -0,0 +1,216 @@ +{ + "action": "opened", + "issue": { + "url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1391", + "repository_url": "https://api.github.com/repos/binwiederhier/ntfy", + "labels_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1391/labels{/name}", + "comments_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1391/comments", + "events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1391/events", + "html_url": "https://github.com/binwiederhier/ntfy/issues/1391", + "id": 3236389051, + "node_id": "I_kwDOGRBhi87A52C7", + "number": 1391, + "title": "http 500 error (ntfy error 50001)", + "user": { + "login": "TheUser-dev", + "id": 213207407, + "node_id": "U_kgDODLVJbw", + "avatar_url": "https://avatars.githubusercontent.com/u/213207407?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/TheUser-dev", + "html_url": "https://github.com/TheUser-dev", + "followers_url": "https://api.github.com/users/TheUser-dev/followers", + "following_url": "https://api.github.com/users/TheUser-dev/following{/other_user}", + "gists_url": "https://api.github.com/users/TheUser-dev/gists{/gist_id}", + "starred_url": "https://api.github.com/users/TheUser-dev/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/TheUser-dev/subscriptions", + "organizations_url": "https://api.github.com/users/TheUser-dev/orgs", + "repos_url": "https://api.github.com/users/TheUser-dev/repos", + "events_url": "https://api.github.com/users/TheUser-dev/events{/privacy}", + "received_events_url": "https://api.github.com/users/TheUser-dev/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "labels": [ + { + "id": 3480884102, + "node_id": "LA_kwDOGRBhi87PehOG", + "url": "https://api.github.com/repos/binwiederhier/ntfy/labels/%F0%9F%AA%B2%20bug", + "name": "🪲 bug", + "color": "d73a4a", + "default": false, + "description": "Something isn't working" + } + ], + "state": "open", + "locked": false, + "assignee": null, + "assignees": [ + ], + "milestone": null, + "comments": 0, + "created_at": "2025-07-16T15:20:56Z", + "updated_at": "2025-07-16T15:20:56Z", + "closed_at": null, + "author_association": "NONE", + "active_lock_reason": null, + "sub_issues_summary": { + "total": 0, + "completed": 0, + "percent_completed": 0 + }, + "body": ":lady_beetle: **Describe the bug**\nWhen sending a notification (especially when it happens with multiple requests) this error occurs\n\n:computer: **Components impacted**\nntfy server 2.13.0 in docker, debian 12 arm64\n\n:bulb: **Screenshots and/or logs**\n```\nclosed with HTTP 500 (ntfy error 50001) (error=database table is locked, http_method=POST, http_path=/_matrix/push/v1/notify, tag=http, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=30, visitor_id=ip:, visitor_ip=, visitor_messages=448, visitor_messages_limit=17280, visitor_messages_remaining=16832, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=57.049697891799994, visitor_seen=2025-07-16T15:06:35.429Z)\n```\n\n:crystal_ball: **Additional context**\nLooks like this has already been fixed by #498, regression?\n", + "reactions": { + "url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1391/reactions", + "total_count": 0, + "+1": 0, + "-1": 0, + "laugh": 0, + "hooray": 0, + "confused": 0, + "heart": 0, + "rocket": 0, + "eyes": 0 + }, + "timeline_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1391/timeline", + "performed_via_github_app": null, + "state_reason": null + }, + "repository": { + "id": 420503947, + "node_id": "R_kgDOGRBhiw", + "name": "ntfy", + "full_name": "binwiederhier/ntfy", + "private": false, + "owner": { + "login": "binwiederhier", + "id": 664597, + "node_id": "MDQ6VXNlcjY2NDU5Nw==", + "avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/binwiederhier", + "html_url": "https://github.com/binwiederhier", + "followers_url": "https://api.github.com/users/binwiederhier/followers", + "following_url": "https://api.github.com/users/binwiederhier/following{/other_user}", + "gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}", + "starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions", + "organizations_url": "https://api.github.com/users/binwiederhier/orgs", + "repos_url": "https://api.github.com/users/binwiederhier/repos", + "events_url": "https://api.github.com/users/binwiederhier/events{/privacy}", + "received_events_url": "https://api.github.com/users/binwiederhier/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "html_url": "https://github.com/binwiederhier/ntfy", + "description": "Send push notifications to your phone or desktop using PUT/POST", + "fork": false, + "url": "https://api.github.com/repos/binwiederhier/ntfy", + "forks_url": "https://api.github.com/repos/binwiederhier/ntfy/forks", + "keys_url": "https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/binwiederhier/ntfy/teams", + "hooks_url": "https://api.github.com/repos/binwiederhier/ntfy/hooks", + "issue_events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}", + "events_url": "https://api.github.com/repos/binwiederhier/ntfy/events", + "assignees_url": "https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}", + "branches_url": "https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}", + "tags_url": "https://api.github.com/repos/binwiederhier/ntfy/tags", + "blobs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}", + "languages_url": "https://api.github.com/repos/binwiederhier/ntfy/languages", + "stargazers_url": "https://api.github.com/repos/binwiederhier/ntfy/stargazers", + "contributors_url": "https://api.github.com/repos/binwiederhier/ntfy/contributors", + "subscribers_url": "https://api.github.com/repos/binwiederhier/ntfy/subscribers", + "subscription_url": "https://api.github.com/repos/binwiederhier/ntfy/subscription", + "commits_url": "https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/binwiederhier/ntfy/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}", + "compare_url": "https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/binwiederhier/ntfy/merges", + "archive_url": "https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/binwiederhier/ntfy/downloads", + "issues_url": "https://api.github.com/repos/binwiederhier/ntfy/issues{/number}", + "pulls_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}", + "milestones_url": "https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}", + "notifications_url": "https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/binwiederhier/ntfy/labels{/name}", + "releases_url": "https://api.github.com/repos/binwiederhier/ntfy/releases{/id}", + "deployments_url": "https://api.github.com/repos/binwiederhier/ntfy/deployments", + "created_at": "2021-10-23T19:25:32Z", + "updated_at": "2025-07-16T14:54:16Z", + "pushed_at": "2025-07-16T11:49:26Z", + "git_url": "git://github.com/binwiederhier/ntfy.git", + "ssh_url": "git@github.com:binwiederhier/ntfy.git", + "clone_url": "https://github.com/binwiederhier/ntfy.git", + "svn_url": "https://github.com/binwiederhier/ntfy", + "homepage": "https://ntfy.sh", + "size": 36831, + "stargazers_count": 25112, + "watchers_count": 25112, + "language": "Go", + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": false, + "has_discussions": false, + "forks_count": 984, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 369, + "license": { + "key": "apache-2.0", + "name": "Apache License 2.0", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/licenses/apache-2.0", + "node_id": "MDc6TGljZW5zZTI=" + }, + "allow_forking": true, + "is_template": false, + "web_commit_signoff_required": false, + "topics": [ + "curl", + "notifications", + "ntfy", + "ntfysh", + "pubsub", + "push-notifications", + "rest-api" + ], + "visibility": "public", + "forks": 984, + "open_issues": 369, + "watchers": 25112, + "default_branch": "main" + }, + "sender": { + "login": "TheUser-dev", + "id": 213207407, + "node_id": "U_kgDODLVJbw", + "avatar_url": "https://avatars.githubusercontent.com/u/213207407?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/TheUser-dev", + "html_url": "https://github.com/TheUser-dev", + "followers_url": "https://api.github.com/users/TheUser-dev/followers", + "following_url": "https://api.github.com/users/TheUser-dev/following{/other_user}", + "gists_url": "https://api.github.com/users/TheUser-dev/gists{/gist_id}", + "starred_url": "https://api.github.com/users/TheUser-dev/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/TheUser-dev/subscriptions", + "organizations_url": "https://api.github.com/users/TheUser-dev/orgs", + "repos_url": "https://api.github.com/users/TheUser-dev/repos", + "events_url": "https://api.github.com/users/TheUser-dev/events{/privacy}", + "received_events_url": "https://api.github.com/users/TheUser-dev/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + } +} diff --git a/server/testdata/webhook_github_pr_opened.json b/server/testdata/webhook_github_pr_opened.json new file mode 100644 index 00000000..c89d1c3b --- /dev/null +++ b/server/testdata/webhook_github_pr_opened.json @@ -0,0 +1,541 @@ +{ + "action": "opened", + "number": 1390, + "pull_request": { + "url": "https://api.github.com/repos/binwiederhier/ntfy/pulls/1390", + "id": 2670425869, + "node_id": "PR_kwDOGRBhi86fK3cN", + "html_url": "https://github.com/binwiederhier/ntfy/pull/1390", + "diff_url": "https://github.com/binwiederhier/ntfy/pull/1390.diff", + "patch_url": "https://github.com/binwiederhier/ntfy/pull/1390.patch", + "issue_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1390", + "number": 1390, + "state": "open", + "locked": false, + "title": "WIP Template dir", + "user": { + "login": "binwiederhier", + "id": 664597, + "node_id": "MDQ6VXNlcjY2NDU5Nw==", + "avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/binwiederhier", + "html_url": "https://github.com/binwiederhier", + "followers_url": "https://api.github.com/users/binwiederhier/followers", + "following_url": "https://api.github.com/users/binwiederhier/following{/other_user}", + "gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}", + "starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions", + "organizations_url": "https://api.github.com/users/binwiederhier/orgs", + "repos_url": "https://api.github.com/users/binwiederhier/repos", + "events_url": "https://api.github.com/users/binwiederhier/events{/privacy}", + "received_events_url": "https://api.github.com/users/binwiederhier/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "body": null, + "created_at": "2025-07-16T11:49:31Z", + "updated_at": "2025-07-16T11:49:31Z", + "closed_at": null, + "merged_at": null, + "merge_commit_sha": null, + "assignee": null, + "assignees": [ + ], + "requested_reviewers": [ + ], + "requested_teams": [ + ], + "labels": [ + ], + "milestone": null, + "draft": false, + "commits_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls/1390/commits", + "review_comments_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls/1390/comments", + "review_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls/comments{/number}", + "comments_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1390/comments", + "statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/b1e935da45365c5e7e731d544a1ad4c7ea3643cd", + "head": { + "label": "binwiederhier:template-dir", + "ref": "template-dir", + "sha": "b1e935da45365c5e7e731d544a1ad4c7ea3643cd", + "user": { + "login": "binwiederhier", + "id": 664597, + "node_id": "MDQ6VXNlcjY2NDU5Nw==", + "avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/binwiederhier", + "html_url": "https://github.com/binwiederhier", + "followers_url": "https://api.github.com/users/binwiederhier/followers", + "following_url": "https://api.github.com/users/binwiederhier/following{/other_user}", + "gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}", + "starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions", + "organizations_url": "https://api.github.com/users/binwiederhier/orgs", + "repos_url": "https://api.github.com/users/binwiederhier/repos", + "events_url": "https://api.github.com/users/binwiederhier/events{/privacy}", + "received_events_url": "https://api.github.com/users/binwiederhier/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "repo": { + "id": 420503947, + "node_id": "R_kgDOGRBhiw", + "name": "ntfy", + "full_name": "binwiederhier/ntfy", + "private": false, + "owner": { + "login": "binwiederhier", + "id": 664597, + "node_id": "MDQ6VXNlcjY2NDU5Nw==", + "avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/binwiederhier", + "html_url": "https://github.com/binwiederhier", + "followers_url": "https://api.github.com/users/binwiederhier/followers", + "following_url": "https://api.github.com/users/binwiederhier/following{/other_user}", + "gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}", + "starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions", + "organizations_url": "https://api.github.com/users/binwiederhier/orgs", + "repos_url": "https://api.github.com/users/binwiederhier/repos", + "events_url": "https://api.github.com/users/binwiederhier/events{/privacy}", + "received_events_url": "https://api.github.com/users/binwiederhier/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "html_url": "https://github.com/binwiederhier/ntfy", + "description": "Send push notifications to your phone or desktop using PUT/POST", + "fork": false, + "url": "https://api.github.com/repos/binwiederhier/ntfy", + "forks_url": "https://api.github.com/repos/binwiederhier/ntfy/forks", + "keys_url": "https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/binwiederhier/ntfy/teams", + "hooks_url": "https://api.github.com/repos/binwiederhier/ntfy/hooks", + "issue_events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}", + "events_url": "https://api.github.com/repos/binwiederhier/ntfy/events", + "assignees_url": "https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}", + "branches_url": "https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}", + "tags_url": "https://api.github.com/repos/binwiederhier/ntfy/tags", + "blobs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}", + "languages_url": "https://api.github.com/repos/binwiederhier/ntfy/languages", + "stargazers_url": "https://api.github.com/repos/binwiederhier/ntfy/stargazers", + "contributors_url": "https://api.github.com/repos/binwiederhier/ntfy/contributors", + "subscribers_url": "https://api.github.com/repos/binwiederhier/ntfy/subscribers", + "subscription_url": "https://api.github.com/repos/binwiederhier/ntfy/subscription", + "commits_url": "https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/binwiederhier/ntfy/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}", + "compare_url": "https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/binwiederhier/ntfy/merges", + "archive_url": "https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/binwiederhier/ntfy/downloads", + "issues_url": "https://api.github.com/repos/binwiederhier/ntfy/issues{/number}", + "pulls_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}", + "milestones_url": "https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}", + "notifications_url": "https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/binwiederhier/ntfy/labels{/name}", + "releases_url": "https://api.github.com/repos/binwiederhier/ntfy/releases{/id}", + "deployments_url": "https://api.github.com/repos/binwiederhier/ntfy/deployments", + "created_at": "2021-10-23T19:25:32Z", + "updated_at": "2025-07-16T10:18:34Z", + "pushed_at": "2025-07-16T11:49:26Z", + "git_url": "git://github.com/binwiederhier/ntfy.git", + "ssh_url": "git@github.com:binwiederhier/ntfy.git", + "clone_url": "https://github.com/binwiederhier/ntfy.git", + "svn_url": "https://github.com/binwiederhier/ntfy", + "homepage": "https://ntfy.sh", + "size": 36740, + "stargazers_count": 25111, + "watchers_count": 25111, + "language": "Go", + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": false, + "has_discussions": false, + "forks_count": 984, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 368, + "license": { + "key": "apache-2.0", + "name": "Apache License 2.0", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/licenses/apache-2.0", + "node_id": "MDc6TGljZW5zZTI=" + }, + "allow_forking": true, + "is_template": false, + "web_commit_signoff_required": false, + "topics": [ + "curl", + "notifications", + "ntfy", + "ntfysh", + "pubsub", + "push-notifications", + "rest-api" + ], + "visibility": "public", + "forks": 984, + "open_issues": 368, + "watchers": 25111, + "default_branch": "main", + "allow_squash_merge": true, + "allow_merge_commit": true, + "allow_rebase_merge": true, + "allow_auto_merge": true, + "delete_branch_on_merge": false, + "allow_update_branch": false, + "use_squash_pr_title_as_default": false, + "squash_merge_commit_message": "COMMIT_MESSAGES", + "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", + "merge_commit_message": "PR_TITLE", + "merge_commit_title": "MERGE_MESSAGE" + } + }, + "base": { + "label": "binwiederhier:main", + "ref": "main", + "sha": "81a486adc11fe24efcbedefb28ae946028597c2f", + "user": { + "login": "binwiederhier", + "id": 664597, + "node_id": "MDQ6VXNlcjY2NDU5Nw==", + "avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/binwiederhier", + "html_url": "https://github.com/binwiederhier", + "followers_url": "https://api.github.com/users/binwiederhier/followers", + "following_url": "https://api.github.com/users/binwiederhier/following{/other_user}", + "gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}", + "starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions", + "organizations_url": "https://api.github.com/users/binwiederhier/orgs", + "repos_url": "https://api.github.com/users/binwiederhier/repos", + "events_url": "https://api.github.com/users/binwiederhier/events{/privacy}", + "received_events_url": "https://api.github.com/users/binwiederhier/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "repo": { + "id": 420503947, + "node_id": "R_kgDOGRBhiw", + "name": "ntfy", + "full_name": "binwiederhier/ntfy", + "private": false, + "owner": { + "login": "binwiederhier", + "id": 664597, + "node_id": "MDQ6VXNlcjY2NDU5Nw==", + "avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/binwiederhier", + "html_url": "https://github.com/binwiederhier", + "followers_url": "https://api.github.com/users/binwiederhier/followers", + "following_url": "https://api.github.com/users/binwiederhier/following{/other_user}", + "gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}", + "starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions", + "organizations_url": "https://api.github.com/users/binwiederhier/orgs", + "repos_url": "https://api.github.com/users/binwiederhier/repos", + "events_url": "https://api.github.com/users/binwiederhier/events{/privacy}", + "received_events_url": "https://api.github.com/users/binwiederhier/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "html_url": "https://github.com/binwiederhier/ntfy", + "description": "Send push notifications to your phone or desktop using PUT/POST", + "fork": false, + "url": "https://api.github.com/repos/binwiederhier/ntfy", + "forks_url": "https://api.github.com/repos/binwiederhier/ntfy/forks", + "keys_url": "https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/binwiederhier/ntfy/teams", + "hooks_url": "https://api.github.com/repos/binwiederhier/ntfy/hooks", + "issue_events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}", + "events_url": "https://api.github.com/repos/binwiederhier/ntfy/events", + "assignees_url": "https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}", + "branches_url": "https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}", + "tags_url": "https://api.github.com/repos/binwiederhier/ntfy/tags", + "blobs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}", + "languages_url": "https://api.github.com/repos/binwiederhier/ntfy/languages", + "stargazers_url": "https://api.github.com/repos/binwiederhier/ntfy/stargazers", + "contributors_url": "https://api.github.com/repos/binwiederhier/ntfy/contributors", + "subscribers_url": "https://api.github.com/repos/binwiederhier/ntfy/subscribers", + "subscription_url": "https://api.github.com/repos/binwiederhier/ntfy/subscription", + "commits_url": "https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/binwiederhier/ntfy/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}", + "compare_url": "https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/binwiederhier/ntfy/merges", + "archive_url": "https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/binwiederhier/ntfy/downloads", + "issues_url": "https://api.github.com/repos/binwiederhier/ntfy/issues{/number}", + "pulls_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}", + "milestones_url": "https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}", + "notifications_url": "https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/binwiederhier/ntfy/labels{/name}", + "releases_url": "https://api.github.com/repos/binwiederhier/ntfy/releases{/id}", + "deployments_url": "https://api.github.com/repos/binwiederhier/ntfy/deployments", + "created_at": "2021-10-23T19:25:32Z", + "updated_at": "2025-07-16T10:18:34Z", + "pushed_at": "2025-07-16T11:49:26Z", + "git_url": "git://github.com/binwiederhier/ntfy.git", + "ssh_url": "git@github.com:binwiederhier/ntfy.git", + "clone_url": "https://github.com/binwiederhier/ntfy.git", + "svn_url": "https://github.com/binwiederhier/ntfy", + "homepage": "https://ntfy.sh", + "size": 36740, + "stargazers_count": 25111, + "watchers_count": 25111, + "language": "Go", + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": false, + "has_discussions": false, + "forks_count": 984, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 368, + "license": { + "key": "apache-2.0", + "name": "Apache License 2.0", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/licenses/apache-2.0", + "node_id": "MDc6TGljZW5zZTI=" + }, + "allow_forking": true, + "is_template": false, + "web_commit_signoff_required": false, + "topics": [ + "curl", + "notifications", + "ntfy", + "ntfysh", + "pubsub", + "push-notifications", + "rest-api" + ], + "visibility": "public", + "forks": 984, + "open_issues": 368, + "watchers": 25111, + "default_branch": "main", + "allow_squash_merge": true, + "allow_merge_commit": true, + "allow_rebase_merge": true, + "allow_auto_merge": true, + "delete_branch_on_merge": false, + "allow_update_branch": false, + "use_squash_pr_title_as_default": false, + "squash_merge_commit_message": "COMMIT_MESSAGES", + "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", + "merge_commit_message": "PR_TITLE", + "merge_commit_title": "MERGE_MESSAGE" + } + }, + "_links": { + "self": { + "href": "https://api.github.com/repos/binwiederhier/ntfy/pulls/1390" + }, + "html": { + "href": "https://github.com/binwiederhier/ntfy/pull/1390" + }, + "issue": { + "href": "https://api.github.com/repos/binwiederhier/ntfy/issues/1390" + }, + "comments": { + "href": "https://api.github.com/repos/binwiederhier/ntfy/issues/1390/comments" + }, + "review_comments": { + "href": "https://api.github.com/repos/binwiederhier/ntfy/pulls/1390/comments" + }, + "review_comment": { + "href": "https://api.github.com/repos/binwiederhier/ntfy/pulls/comments{/number}" + }, + "commits": { + "href": "https://api.github.com/repos/binwiederhier/ntfy/pulls/1390/commits" + }, + "statuses": { + "href": "https://api.github.com/repos/binwiederhier/ntfy/statuses/b1e935da45365c5e7e731d544a1ad4c7ea3643cd" + } + }, + "author_association": "OWNER", + "auto_merge": null, + "active_lock_reason": null, + "merged": false, + "mergeable": null, + "rebaseable": null, + "mergeable_state": "unknown", + "merged_by": null, + "comments": 0, + "review_comments": 0, + "maintainer_can_modify": false, + "commits": 7, + "additions": 5506, + "deletions": 42, + "changed_files": 58 + }, + "repository": { + "id": 420503947, + "node_id": "R_kgDOGRBhiw", + "name": "ntfy", + "full_name": "binwiederhier/ntfy", + "private": false, + "owner": { + "login": "binwiederhier", + "id": 664597, + "node_id": "MDQ6VXNlcjY2NDU5Nw==", + "avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/binwiederhier", + "html_url": "https://github.com/binwiederhier", + "followers_url": "https://api.github.com/users/binwiederhier/followers", + "following_url": "https://api.github.com/users/binwiederhier/following{/other_user}", + "gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}", + "starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions", + "organizations_url": "https://api.github.com/users/binwiederhier/orgs", + "repos_url": "https://api.github.com/users/binwiederhier/repos", + "events_url": "https://api.github.com/users/binwiederhier/events{/privacy}", + "received_events_url": "https://api.github.com/users/binwiederhier/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "html_url": "https://github.com/binwiederhier/ntfy", + "description": "Send push notifications to your phone or desktop using PUT/POST", + "fork": false, + "url": "https://api.github.com/repos/binwiederhier/ntfy", + "forks_url": "https://api.github.com/repos/binwiederhier/ntfy/forks", + "keys_url": "https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/binwiederhier/ntfy/teams", + "hooks_url": "https://api.github.com/repos/binwiederhier/ntfy/hooks", + "issue_events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}", + "events_url": "https://api.github.com/repos/binwiederhier/ntfy/events", + "assignees_url": "https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}", + "branches_url": "https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}", + "tags_url": "https://api.github.com/repos/binwiederhier/ntfy/tags", + "blobs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}", + "languages_url": "https://api.github.com/repos/binwiederhier/ntfy/languages", + "stargazers_url": "https://api.github.com/repos/binwiederhier/ntfy/stargazers", + "contributors_url": "https://api.github.com/repos/binwiederhier/ntfy/contributors", + "subscribers_url": "https://api.github.com/repos/binwiederhier/ntfy/subscribers", + "subscription_url": "https://api.github.com/repos/binwiederhier/ntfy/subscription", + "commits_url": "https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/binwiederhier/ntfy/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}", + "compare_url": "https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/binwiederhier/ntfy/merges", + "archive_url": "https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/binwiederhier/ntfy/downloads", + "issues_url": "https://api.github.com/repos/binwiederhier/ntfy/issues{/number}", + "pulls_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}", + "milestones_url": "https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}", + "notifications_url": "https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/binwiederhier/ntfy/labels{/name}", + "releases_url": "https://api.github.com/repos/binwiederhier/ntfy/releases{/id}", + "deployments_url": "https://api.github.com/repos/binwiederhier/ntfy/deployments", + "created_at": "2021-10-23T19:25:32Z", + "updated_at": "2025-07-16T10:18:34Z", + "pushed_at": "2025-07-16T11:49:26Z", + "git_url": "git://github.com/binwiederhier/ntfy.git", + "ssh_url": "git@github.com:binwiederhier/ntfy.git", + "clone_url": "https://github.com/binwiederhier/ntfy.git", + "svn_url": "https://github.com/binwiederhier/ntfy", + "homepage": "https://ntfy.sh", + "size": 36740, + "stargazers_count": 25111, + "watchers_count": 25111, + "language": "Go", + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": false, + "has_discussions": false, + "forks_count": 984, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 368, + "license": { + "key": "apache-2.0", + "name": "Apache License 2.0", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/licenses/apache-2.0", + "node_id": "MDc6TGljZW5zZTI=" + }, + "allow_forking": true, + "is_template": false, + "web_commit_signoff_required": false, + "topics": [ + "curl", + "notifications", + "ntfy", + "ntfysh", + "pubsub", + "push-notifications", + "rest-api" + ], + "visibility": "public", + "forks": 984, + "open_issues": 368, + "watchers": 25111, + "default_branch": "main" + }, + "sender": { + "login": "binwiederhier", + "id": 664597, + "node_id": "MDQ6VXNlcjY2NDU5Nw==", + "avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/binwiederhier", + "html_url": "https://github.com/binwiederhier", + "followers_url": "https://api.github.com/users/binwiederhier/followers", + "following_url": "https://api.github.com/users/binwiederhier/following{/other_user}", + "gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}", + "starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions", + "organizations_url": "https://api.github.com/users/binwiederhier/orgs", + "repos_url": "https://api.github.com/users/binwiederhier/repos", + "events_url": "https://api.github.com/users/binwiederhier/events{/privacy}", + "received_events_url": "https://api.github.com/users/binwiederhier/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + } +} diff --git a/server/testdata/webhook_github_star_created.json b/server/testdata/webhook_github_star_created.json new file mode 100644 index 00000000..30099145 --- /dev/null +++ b/server/testdata/webhook_github_star_created.json @@ -0,0 +1,141 @@ +{ + "action": "created", + "starred_at": "2025-07-16T12:57:43Z", + "repository": { + "id": 420503947, + "node_id": "R_kgDOGRBhiw", + "name": "ntfy", + "full_name": "binwiederhier/ntfy", + "private": false, + "owner": { + "login": "binwiederhier", + "id": 664597, + "node_id": "MDQ6VXNlcjY2NDU5Nw==", + "avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/binwiederhier", + "html_url": "https://github.com/binwiederhier", + "followers_url": "https://api.github.com/users/binwiederhier/followers", + "following_url": "https://api.github.com/users/binwiederhier/following{/other_user}", + "gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}", + "starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions", + "organizations_url": "https://api.github.com/users/binwiederhier/orgs", + "repos_url": "https://api.github.com/users/binwiederhier/repos", + "events_url": "https://api.github.com/users/binwiederhier/events{/privacy}", + "received_events_url": "https://api.github.com/users/binwiederhier/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "html_url": "https://github.com/binwiederhier/ntfy", + "description": "Send push notifications to your phone or desktop using PUT/POST", + "fork": false, + "url": "https://api.github.com/repos/binwiederhier/ntfy", + "forks_url": "https://api.github.com/repos/binwiederhier/ntfy/forks", + "keys_url": "https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/binwiederhier/ntfy/teams", + "hooks_url": "https://api.github.com/repos/binwiederhier/ntfy/hooks", + "issue_events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}", + "events_url": "https://api.github.com/repos/binwiederhier/ntfy/events", + "assignees_url": "https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}", + "branches_url": "https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}", + "tags_url": "https://api.github.com/repos/binwiederhier/ntfy/tags", + "blobs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}", + "languages_url": "https://api.github.com/repos/binwiederhier/ntfy/languages", + "stargazers_url": "https://api.github.com/repos/binwiederhier/ntfy/stargazers", + "contributors_url": "https://api.github.com/repos/binwiederhier/ntfy/contributors", + "subscribers_url": "https://api.github.com/repos/binwiederhier/ntfy/subscribers", + "subscription_url": "https://api.github.com/repos/binwiederhier/ntfy/subscription", + "commits_url": "https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/binwiederhier/ntfy/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}", + "compare_url": "https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/binwiederhier/ntfy/merges", + "archive_url": "https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/binwiederhier/ntfy/downloads", + "issues_url": "https://api.github.com/repos/binwiederhier/ntfy/issues{/number}", + "pulls_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}", + "milestones_url": "https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}", + "notifications_url": "https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/binwiederhier/ntfy/labels{/name}", + "releases_url": "https://api.github.com/repos/binwiederhier/ntfy/releases{/id}", + "deployments_url": "https://api.github.com/repos/binwiederhier/ntfy/deployments", + "created_at": "2021-10-23T19:25:32Z", + "updated_at": "2025-07-16T12:57:43Z", + "pushed_at": "2025-07-16T11:49:26Z", + "git_url": "git://github.com/binwiederhier/ntfy.git", + "ssh_url": "git@github.com:binwiederhier/ntfy.git", + "clone_url": "https://github.com/binwiederhier/ntfy.git", + "svn_url": "https://github.com/binwiederhier/ntfy", + "homepage": "https://ntfy.sh", + "size": 36831, + "stargazers_count": 25112, + "watchers_count": 25112, + "language": "Go", + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": false, + "has_discussions": false, + "forks_count": 984, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 368, + "license": { + "key": "apache-2.0", + "name": "Apache License 2.0", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/licenses/apache-2.0", + "node_id": "MDc6TGljZW5zZTI=" + }, + "allow_forking": true, + "is_template": false, + "web_commit_signoff_required": false, + "topics": [ + "curl", + "notifications", + "ntfy", + "ntfysh", + "pubsub", + "push-notifications", + "rest-api" + ], + "visibility": "public", + "forks": 984, + "open_issues": 368, + "watchers": 25112, + "default_branch": "main" + }, + "sender": { + "login": "mbilby", + "id": 51273322, + "node_id": "MDQ6VXNlcjUxMjczMzIy", + "avatar_url": "https://avatars.githubusercontent.com/u/51273322?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/mbilby", + "html_url": "https://github.com/mbilby", + "followers_url": "https://api.github.com/users/mbilby/followers", + "following_url": "https://api.github.com/users/mbilby/following{/other_user}", + "gists_url": "https://api.github.com/users/mbilby/gists{/gist_id}", + "starred_url": "https://api.github.com/users/mbilby/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/mbilby/subscriptions", + "organizations_url": "https://api.github.com/users/mbilby/orgs", + "repos_url": "https://api.github.com/users/mbilby/repos", + "events_url": "https://api.github.com/users/mbilby/events{/privacy}", + "received_events_url": "https://api.github.com/users/mbilby/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + } +} + diff --git a/server/testdata/webhook_github_watch_created.json b/server/testdata/webhook_github_watch_created.json new file mode 100644 index 00000000..47440ebf --- /dev/null +++ b/server/testdata/webhook_github_watch_created.json @@ -0,0 +1,139 @@ +{ + "action": "started", + "repository": { + "id": 420503947, + "node_id": "R_kgDOGRBhiw", + "name": "ntfy", + "full_name": "binwiederhier/ntfy", + "private": false, + "owner": { + "login": "binwiederhier", + "id": 664597, + "node_id": "MDQ6VXNlcjY2NDU5Nw==", + "avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/binwiederhier", + "html_url": "https://github.com/binwiederhier", + "followers_url": "https://api.github.com/users/binwiederhier/followers", + "following_url": "https://api.github.com/users/binwiederhier/following{/other_user}", + "gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}", + "starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions", + "organizations_url": "https://api.github.com/users/binwiederhier/orgs", + "repos_url": "https://api.github.com/users/binwiederhier/repos", + "events_url": "https://api.github.com/users/binwiederhier/events{/privacy}", + "received_events_url": "https://api.github.com/users/binwiederhier/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "html_url": "https://github.com/binwiederhier/ntfy", + "description": "Send push notifications to your phone or desktop using PUT/POST", + "fork": false, + "url": "https://api.github.com/repos/binwiederhier/ntfy", + "forks_url": "https://api.github.com/repos/binwiederhier/ntfy/forks", + "keys_url": "https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/binwiederhier/ntfy/teams", + "hooks_url": "https://api.github.com/repos/binwiederhier/ntfy/hooks", + "issue_events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}", + "events_url": "https://api.github.com/repos/binwiederhier/ntfy/events", + "assignees_url": "https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}", + "branches_url": "https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}", + "tags_url": "https://api.github.com/repos/binwiederhier/ntfy/tags", + "blobs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}", + "languages_url": "https://api.github.com/repos/binwiederhier/ntfy/languages", + "stargazers_url": "https://api.github.com/repos/binwiederhier/ntfy/stargazers", + "contributors_url": "https://api.github.com/repos/binwiederhier/ntfy/contributors", + "subscribers_url": "https://api.github.com/repos/binwiederhier/ntfy/subscribers", + "subscription_url": "https://api.github.com/repos/binwiederhier/ntfy/subscription", + "commits_url": "https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/binwiederhier/ntfy/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}", + "compare_url": "https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/binwiederhier/ntfy/merges", + "archive_url": "https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/binwiederhier/ntfy/downloads", + "issues_url": "https://api.github.com/repos/binwiederhier/ntfy/issues{/number}", + "pulls_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}", + "milestones_url": "https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}", + "notifications_url": "https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/binwiederhier/ntfy/labels{/name}", + "releases_url": "https://api.github.com/repos/binwiederhier/ntfy/releases{/id}", + "deployments_url": "https://api.github.com/repos/binwiederhier/ntfy/deployments", + "created_at": "2021-10-23T19:25:32Z", + "updated_at": "2025-07-16T12:57:43Z", + "pushed_at": "2025-07-16T11:49:26Z", + "git_url": "git://github.com/binwiederhier/ntfy.git", + "ssh_url": "git@github.com:binwiederhier/ntfy.git", + "clone_url": "https://github.com/binwiederhier/ntfy.git", + "svn_url": "https://github.com/binwiederhier/ntfy", + "homepage": "https://ntfy.sh", + "size": 36831, + "stargazers_count": 25112, + "watchers_count": 25112, + "language": "Go", + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": false, + "has_discussions": false, + "forks_count": 984, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 368, + "license": { + "key": "apache-2.0", + "name": "Apache License 2.0", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/licenses/apache-2.0", + "node_id": "MDc6TGljZW5zZTI=" + }, + "allow_forking": true, + "is_template": false, + "web_commit_signoff_required": false, + "topics": [ + "curl", + "notifications", + "ntfy", + "ntfysh", + "pubsub", + "push-notifications", + "rest-api" + ], + "visibility": "public", + "forks": 984, + "open_issues": 368, + "watchers": 25112, + "default_branch": "main" + }, + "sender": { + "login": "mbilby", + "id": 51273322, + "node_id": "MDQ6VXNlcjUxMjczMzIy", + "avatar_url": "https://avatars.githubusercontent.com/u/51273322?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/mbilby", + "html_url": "https://github.com/mbilby", + "followers_url": "https://api.github.com/users/mbilby/followers", + "following_url": "https://api.github.com/users/mbilby/following{/other_user}", + "gists_url": "https://api.github.com/users/mbilby/gists{/gist_id}", + "starred_url": "https://api.github.com/users/mbilby/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/mbilby/subscriptions", + "organizations_url": "https://api.github.com/users/mbilby/orgs", + "repos_url": "https://api.github.com/users/mbilby/repos", + "events_url": "https://api.github.com/users/mbilby/events{/privacy}", + "received_events_url": "https://api.github.com/users/mbilby/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + } +} diff --git a/server/testdata/webhook_grafana_resolved.json b/server/testdata/webhook_grafana_resolved.json new file mode 100644 index 00000000..41494578 --- /dev/null +++ b/server/testdata/webhook_grafana_resolved.json @@ -0,0 +1,51 @@ +{ + "receiver": "ntfy\\.example\\.com/alerts", + "status": "resolved", + "alerts": [ + { + "status": "resolved", + "labels": { + "alertname": "Load avg 15m too high", + "grafana_folder": "Node alerts", + "instance": "10.108.0.2:9100", + "job": "node-exporter" + }, + "annotations": { + "summary": "15m load average too high" + }, + "startsAt": "2024-03-15T02:28:00Z", + "endsAt": "2024-03-15T02:42:00Z", + "generatorURL": "localhost:3000/alerting/grafana/NW9oDw-4z/view", + "fingerprint": "becbfb94bd81ef48", + "silenceURL": "localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter", + "dashboardURL": "", + "panelURL": "", + "values": { + "B": 18.98211314475876, + "C": 0 + }, + "valueString": "[ var='B' labels={__name__=node_load15, instance=10.108.0.2:9100, job=node-exporter} value=18.98211314475876 ], [ var='C' labels={__name__=node_load15, instance=10.108.0.2:9100, job=node-exporter} value=0 ]" + } + ], + "groupLabels": { + "alertname": "Load avg 15m too high", + "grafana_folder": "Node alerts" + }, + "commonLabels": { + "alertname": "Load avg 15m too high", + "grafana_folder": "Node alerts", + "instance": "10.108.0.2:9100", + "job": "node-exporter" + }, + "commonAnnotations": { + "summary": "15m load average too high" + }, + "externalURL": "localhost:3000/", + "version": "1", + "groupKey": "{}:{alertname=\"Load avg 15m too high\", grafana_folder=\"Node alerts\"}", + "truncatedAlerts": 0, + "orgId": 1, + "title": "[RESOLVED] Load avg 15m too high Node alerts (10.108.0.2:9100 node-exporter)", + "state": "ok", + "message": "**Resolved**\n\nValue: B=18.98211314475876, C=0\nLabels:\n - alertname = Load avg 15m too high\n - grafana_folder = Node alerts\n - instance = 10.108.0.2:9100\n - job = node-exporter\n" +} diff --git a/server/types.go b/server/types.go index 30f5c468..ea6b8615 100644 --- a/server/types.go +++ b/server/types.go @@ -7,7 +7,6 @@ import ( "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/user" - "heckel.io/ntfy/v2/util" ) @@ -246,6 +245,24 @@ func (q *queryFilter) Pass(msg *message) bool { return true } +type templateMode string + +func (t templateMode) Enabled() bool { + return t != "" +} + +func (t templateMode) Name() string { + if isBoolValue(string(t)) { + return "" + } + return string(t) +} + +type templateFile struct { + Title *string `yaml:"title"` + Message *string `yaml:"message"` +} + type apiHealthResponse struct { Healthy bool `json:"healthy"` } diff --git a/util/sprig/LICENSE.txt b/util/sprig/LICENSE.txt new file mode 100644 index 00000000..f311b1ea --- /dev/null +++ b/util/sprig/LICENSE.txt @@ -0,0 +1,19 @@ +Copyright (C) 2013-2020 Masterminds + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/util/sprig/crypto.go b/util/sprig/crypto.go new file mode 100644 index 00000000..da4bfc94 --- /dev/null +++ b/util/sprig/crypto.go @@ -0,0 +1,47 @@ +package sprig + +import ( + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "encoding/hex" + "fmt" + "hash/adler32" +) + +// sha512sum computes the SHA-512 hash of the input string and returns it as a hex-encoded string. +// This function can be used in templates to generate secure hashes of sensitive data. +// +// Example usage in templates: {{ "hello world" | sha512sum }} +func sha512sum(input string) string { + hash := sha512.Sum512([]byte(input)) + return hex.EncodeToString(hash[:]) +} + +// sha256sum computes the SHA-256 hash of the input string and returns it as a hex-encoded string. +// This is a commonly used cryptographic hash function that produces a 256-bit (32-byte) hash value. +// +// Example usage in templates: {{ "hello world" | sha256sum }} +func sha256sum(input string) string { + hash := sha256.Sum256([]byte(input)) + return hex.EncodeToString(hash[:]) +} + +// sha1sum computes the SHA-1 hash of the input string and returns it as a hex-encoded string. +// Note: SHA-1 is no longer considered secure against well-funded attackers for cryptographic purposes. +// Consider using sha256sum or sha512sum for security-critical applications. +// +// Example usage in templates: {{ "hello world" | sha1sum }} +func sha1sum(input string) string { + hash := sha1.Sum([]byte(input)) + return hex.EncodeToString(hash[:]) +} + +// adler32sum computes the Adler-32 checksum of the input string and returns it as a decimal string. +// This is a non-cryptographic hash function primarily used for error detection. +// +// Example usage in templates: {{ "hello world" | adler32sum }} +func adler32sum(input string) string { + hash := adler32.Checksum([]byte(input)) + return fmt.Sprintf("%d", hash) +} diff --git a/util/sprig/crypto_test.go b/util/sprig/crypto_test.go new file mode 100644 index 00000000..d6fb1736 --- /dev/null +++ b/util/sprig/crypto_test.go @@ -0,0 +1,33 @@ +package sprig + +import ( + "testing" +) + +func TestSha512Sum(t *testing.T) { + tpl := `{{"abc" | sha512sum}}` + if err := runt(tpl, "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f"); err != nil { + t.Error(err) + } +} + +func TestSha256Sum(t *testing.T) { + tpl := `{{"abc" | sha256sum}}` + if err := runt(tpl, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); err != nil { + t.Error(err) + } +} + +func TestSha1Sum(t *testing.T) { + tpl := `{{"abc" | sha1sum}}` + if err := runt(tpl, "a9993e364706816aba3e25717850c26c9cd0d89d"); err != nil { + t.Error(err) + } +} + +func TestAdler32Sum(t *testing.T) { + tpl := `{{"abc" | adler32sum}}` + if err := runt(tpl, "38600999"); err != nil { + t.Error(err) + } +} diff --git a/util/sprig/date.go b/util/sprig/date.go new file mode 100644 index 00000000..3231e619 --- /dev/null +++ b/util/sprig/date.go @@ -0,0 +1,240 @@ +package sprig + +import ( + "math" + "strconv" + "time" +) + +// date formats a date according to the provided format string. +// +// Parameters: +// - fmt: A Go time format string (e.g., "2006-01-02 15:04:05") +// - date: Can be a time.Time, *time.Time, or int/int32/int64 (seconds since UNIX epoch) +// +// If date is not one of the recognized types, the current time is used. +// +// Example usage in templates: {{ now | date "2006-01-02" }} +func date(fmt string, date any) string { + return dateInZone(fmt, date, "Local") +} + +// htmlDate formats a date in HTML5 date format (YYYY-MM-DD). +// +// Parameters: +// - date: Can be a time.Time, *time.Time, or int/int32/int64 (seconds since UNIX epoch) +// +// If date is not one of the recognized types, the current time is used. +// +// Example usage in templates: {{ now | htmlDate }} +func htmlDate(date any) string { + return dateInZone("2006-01-02", date, "Local") +} + +// htmlDateInZone formats a date in HTML5 date format (YYYY-MM-DD) in the specified timezone. +// +// Parameters: +// - date: Can be a time.Time, *time.Time, or int/int32/int64 (seconds since UNIX epoch) +// - zone: Timezone name (e.g., "UTC", "America/New_York") +// +// If date is not one of the recognized types, the current time is used. +// If the timezone is invalid, UTC is used. +// +// Example usage in templates: {{ now | htmlDateInZone "UTC" }} +func htmlDateInZone(date any, zone string) string { + return dateInZone("2006-01-02", date, zone) +} + +// dateInZone formats a date according to the provided format string in the specified timezone. +// +// Parameters: +// - fmt: A Go time format string (e.g., "2006-01-02 15:04:05") +// - date: Can be a time.Time, *time.Time, or int/int32/int64 (seconds since UNIX epoch) +// - zone: Timezone name (e.g., "UTC", "America/New_York") +// +// If date is not one of the recognized types, the current time is used. +// If the timezone is invalid, UTC is used. +// +// Example usage in templates: {{ now | dateInZone "2006-01-02 15:04:05" "UTC" }} +func dateInZone(fmt string, date any, zone string) string { + var t time.Time + switch date := date.(type) { + default: + t = time.Now() + case time.Time: + t = date + case *time.Time: + t = *date + case int64: + t = time.Unix(date, 0) + case int: + t = time.Unix(int64(date), 0) + case int32: + t = time.Unix(int64(date), 0) + } + loc, err := time.LoadLocation(zone) + if err != nil { + loc, _ = time.LoadLocation("UTC") + } + return t.In(loc).Format(fmt) +} + +// dateModify modifies a date by adding a duration and returns the resulting time. +// +// Parameters: +// - fmt: A duration string (e.g., "24h", "-12h30m", "1h15m30s") +// - date: The time.Time to modify +// +// If the duration string is invalid, the original date is returned. +// +// Example usage in templates: {{ now | dateModify "-24h" }} +func dateModify(fmt string, date time.Time) time.Time { + d, err := time.ParseDuration(fmt) + if err != nil { + return date + } + return date.Add(d) +} + +// mustDateModify modifies a date by adding a duration and returns the resulting time or an error. +// +// Parameters: +// - fmt: A duration string (e.g., "24h", "-12h30m", "1h15m30s") +// - date: The time.Time to modify +// +// Unlike dateModify, this function returns an error if the duration string is invalid. +// +// Example usage in templates: {{ now | mustDateModify "24h" }} +func mustDateModify(fmt string, date time.Time) (time.Time, error) { + d, err := time.ParseDuration(fmt) + if err != nil { + return time.Time{}, err + } + return date.Add(d), nil +} + +// dateAgo returns a string representing the time elapsed since the given date. +// +// Parameters: +// - date: Can be a time.Time, int, or int64 (seconds since UNIX epoch) +// +// If date is not one of the recognized types, the current time is used. +// +// Example usage in templates: {{ "2023-01-01" | toDate "2006-01-02" | dateAgo }} +func dateAgo(date any) string { + var t time.Time + switch date := date.(type) { + default: + t = time.Now() + case time.Time: + t = date + case int64: + t = time.Unix(date, 0) + case int: + t = time.Unix(int64(date), 0) + } + return time.Since(t).Round(time.Second).String() +} + +// duration converts seconds to a duration string. +// +// Parameters: +// - sec: Can be a string (parsed as int64), or int64 representing seconds +// +// Example usage in templates: {{ 3600 | duration }} -> "1h0m0s" +func duration(sec any) string { + var n int64 + switch value := sec.(type) { + default: + n = 0 + case string: + n, _ = strconv.ParseInt(value, 10, 64) + case int64: + n = value + } + return (time.Duration(n) * time.Second).String() +} + +// durationRound formats a duration in a human-readable rounded format. +// +// Parameters: +// - duration: Can be a string (parsed as duration), int64 (nanoseconds), +// or time.Time (time since that moment) +// +// Returns a string with the largest appropriate unit (y, mo, d, h, m, s). +// +// Example usage in templates: {{ 3600 | duration | durationRound }} -> "1h" +func durationRound(duration any) string { + var d time.Duration + switch duration := duration.(type) { + default: + d = 0 + case string: + d, _ = time.ParseDuration(duration) + case int64: + d = time.Duration(duration) + case time.Time: + d = time.Since(duration) + } + u := uint64(math.Abs(float64(d))) + var ( + year = uint64(time.Hour) * 24 * 365 + month = uint64(time.Hour) * 24 * 30 + day = uint64(time.Hour) * 24 + hour = uint64(time.Hour) + minute = uint64(time.Minute) + second = uint64(time.Second) + ) + switch { + case u > year: + return strconv.FormatUint(u/year, 10) + "y" + case u > month: + return strconv.FormatUint(u/month, 10) + "mo" + case u > day: + return strconv.FormatUint(u/day, 10) + "d" + case u > hour: + return strconv.FormatUint(u/hour, 10) + "h" + case u > minute: + return strconv.FormatUint(u/minute, 10) + "m" + case u > second: + return strconv.FormatUint(u/second, 10) + "s" + } + return "0s" +} + +// toDate parses a string into a time.Time using the specified format. +// +// Parameters: +// - fmt: A Go time format string (e.g., "2006-01-02") +// - str: The date string to parse +// +// If parsing fails, returns a zero time.Time. +// +// Example usage in templates: {{ "2023-01-01" | toDate "2006-01-02" }} +func toDate(fmt, str string) time.Time { + t, _ := time.ParseInLocation(fmt, str, time.Local) + return t +} + +// mustToDate parses a string into a time.Time using the specified format or returns an error. +// +// Parameters: +// - fmt: A Go time format string (e.g., "2006-01-02") +// - str: The date string to parse +// +// Unlike toDate, this function returns an error if parsing fails. +// +// Example usage in templates: {{ mustToDate "2006-01-02" "2023-01-01" }} +func mustToDate(fmt, str string) (time.Time, error) { + return time.ParseInLocation(fmt, str, time.Local) +} + +// unixEpoch returns the Unix timestamp (seconds since January 1, 1970 UTC) for the given time. +// +// Parameters: +// - date: A time.Time value +// +// Example usage in templates: {{ now | unixEpoch }} +func unixEpoch(date time.Time) string { + return strconv.FormatInt(date.Unix(), 10) +} diff --git a/util/sprig/date_test.go b/util/sprig/date_test.go new file mode 100644 index 00000000..ee9a9cc6 --- /dev/null +++ b/util/sprig/date_test.go @@ -0,0 +1,123 @@ +package sprig + +import ( + "testing" + "time" +) + +func TestHtmlDate(t *testing.T) { + t.Skip() + tpl := `{{ htmlDate 0}}` + if err := runt(tpl, "1970-01-01"); err != nil { + t.Error(err) + } +} + +func TestAgo(t *testing.T) { + tpl := "{{ ago .Time }}" + if err := runtv(tpl, "2m5s", map[string]any{"Time": time.Now().Add(-125 * time.Second)}); err != nil { + t.Error(err) + } + + if err := runtv(tpl, "2h34m17s", map[string]any{"Time": time.Now().Add(-(2*3600 + 34*60 + 17) * time.Second)}); err != nil { + t.Error(err) + } + + if err := runtv(tpl, "-5s", map[string]any{"Time": time.Now().Add(5 * time.Second)}); err != nil { + t.Error(err) + } +} + +func TestToDate(t *testing.T) { + tpl := `{{toDate "2006-01-02" "2017-12-31" | date "02/01/2006"}}` + if err := runt(tpl, "31/12/2017"); err != nil { + t.Error(err) + } +} + +func TestUnixEpoch(t *testing.T) { + tm, err := time.Parse("02 Jan 06 15:04:05 MST", "13 Jun 19 20:39:39 GMT") + if err != nil { + t.Error(err) + } + tpl := `{{unixEpoch .Time}}` + + if err = runtv(tpl, "1560458379", map[string]any{"Time": tm}); err != nil { + t.Error(err) + } +} + +func TestDateInZone(t *testing.T) { + tm, err := time.Parse("02 Jan 06 15:04:05 MST", "13 Jun 19 20:39:39 GMT") + if err != nil { + t.Error(err) + } + tpl := `{{ dateInZone "02 Jan 06 15:04 -0700" .Time "UTC" }}` + + // Test time.Time input + if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]any{"Time": tm}); err != nil { + t.Error(err) + } + + // Test pointer to time.Time input + if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]any{"Time": &tm}); err != nil { + t.Error(err) + } + + // Test no time input. This should be close enough to time.Now() we can test + loc, _ := time.LoadLocation("UTC") + if err = runtv(tpl, time.Now().In(loc).Format("02 Jan 06 15:04 -0700"), map[string]any{"Time": ""}); err != nil { + t.Error(err) + } + + // Test unix timestamp as int64 + if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]any{"Time": int64(1560458379)}); err != nil { + t.Error(err) + } + + // Test unix timestamp as int32 + if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]any{"Time": int32(1560458379)}); err != nil { + t.Error(err) + } + + // Test unix timestamp as int + if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]any{"Time": int(1560458379)}); err != nil { + t.Error(err) + } + + // Test case of invalid timezone + tpl = `{{ dateInZone "02 Jan 06 15:04 -0700" .Time "foobar" }}` + if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]any{"Time": tm}); err != nil { + t.Error(err) + } +} + +func TestDuration(t *testing.T) { + tpl := "{{ duration .Secs }}" + if err := runtv(tpl, "1m1s", map[string]any{"Secs": "61"}); err != nil { + t.Error(err) + } + if err := runtv(tpl, "1h0m0s", map[string]any{"Secs": "3600"}); err != nil { + t.Error(err) + } + // 1d2h3m4s but go is opinionated + if err := runtv(tpl, "26h3m4s", map[string]any{"Secs": "93784"}); err != nil { + t.Error(err) + } +} + +func TestDurationRound(t *testing.T) { + tpl := "{{ durationRound .Time }}" + if err := runtv(tpl, "2h", map[string]any{"Time": "2h5s"}); err != nil { + t.Error(err) + } + if err := runtv(tpl, "1d", map[string]any{"Time": "24h5s"}); err != nil { + t.Error(err) + } + if err := runtv(tpl, "3mo", map[string]any{"Time": "2400h5s"}); err != nil { + t.Error(err) + } + if err := runtv(tpl, "1m", map[string]any{"Time": "-1m1s"}); err != nil { + t.Error(err) + } +} diff --git a/util/sprig/defaults.go b/util/sprig/defaults.go new file mode 100644 index 00000000..c5c14308 --- /dev/null +++ b/util/sprig/defaults.go @@ -0,0 +1,268 @@ +package sprig + +import ( + "bytes" + "encoding/json" + "reflect" + "strings" +) + +// defaultValue checks whether `given` is set, and returns default if not set. +// +// This returns `d` if `given` appears not to be set, and `given` otherwise. +// +// For numeric types 0 is unset. +// For strings, maps, arrays, and slices, len() = 0 is considered unset. +// For bool, false is unset. +// Structs are never considered unset. +// +// For everything else, including pointers, a nil value is unset. +func defaultValue(d any, given ...any) any { + if empty(given) || empty(given[0]) { + return d + } + return given[0] +} + +// empty returns true if the given value has the zero value for its type. +// This is a helper function used by defaultValue, coalesce, all, and anyNonEmpty. +// +// The following values are considered empty: +// - Invalid values +// - nil values +// - Zero-length arrays, slices, maps, and strings +// - Boolean false +// - Zero for all numeric types +// - Structs are never considered empty +// +// Parameters: +// - given: The value to check for emptiness +// +// Returns: +// - bool: True if the value is considered empty, false otherwise +func empty(given any) bool { + g := reflect.ValueOf(given) + if !g.IsValid() { + return true + } + // Basically adapted from text/template.isTrue + switch g.Kind() { + default: + return g.IsNil() + case reflect.Array, reflect.Slice, reflect.Map, reflect.String: + return g.Len() == 0 + case reflect.Bool: + return !g.Bool() + case reflect.Complex64, reflect.Complex128: + return g.Complex() == 0 + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return g.Int() == 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return g.Uint() == 0 + case reflect.Float32, reflect.Float64: + return g.Float() == 0 + case reflect.Struct: + return false + } +} + +// coalesce returns the first non-empty value from a list of values. +// If all values are empty, it returns nil. +// +// This is useful for providing a series of fallback values. +// +// Parameters: +// - v: A variadic list of values to check +// +// Returns: +// - any: The first non-empty value, or nil if all values are empty +func coalesce(v ...any) any { + for _, val := range v { + if !empty(val) { + return val + } + } + return nil +} + +// all checks if all values in a list are non-empty. +// Returns true if every value in the list is non-empty. +// If the list is empty, returns true (vacuously true). +// +// Parameters: +// - v: A variadic list of values to check +// +// Returns: +// - bool: True if all values are non-empty, false otherwise +func all(v ...any) bool { + for _, val := range v { + if empty(val) { + return false + } + } + return true +} + +// anyNonEmpty checks if at least one value in a list is non-empty. +// Returns true if any value in the list is non-empty. +// If the list is empty, returns false. +// +// Parameters: +// - v: A variadic list of values to check +// +// Returns: +// - bool: True if at least one value is non-empty, false otherwise +func anyNonEmpty(v ...any) bool { + for _, val := range v { + if !empty(val) { + return true + } + } + return false +} + +// fromJSON decodes a JSON string into a structured value. +// This function ignores any errors that occur during decoding. +// If the JSON is invalid, it returns nil. +// +// Parameters: +// - v: The JSON string to decode +// +// Returns: +// - any: The decoded value, or nil if decoding failed +func fromJSON(v string) any { + output, _ := mustFromJSON(v) + return output +} + +// mustFromJSON decodes a JSON string into a structured value. +// Unlike fromJSON, this function returns any errors that occur during decoding. +// +// Parameters: +// - v: The JSON string to decode +// +// Returns: +// - any: The decoded value +// - error: Any error that occurred during decoding +func mustFromJSON(v string) (any, error) { + var output any + err := json.Unmarshal([]byte(v), &output) + return output, err +} + +// toJSON encodes a value into a JSON string. +// This function ignores any errors that occur during encoding. +// If the value cannot be encoded, it returns an empty string. +// +// Parameters: +// - v: The value to encode to JSON +// +// Returns: +// - string: The JSON string representation of the value +func toJSON(v any) string { + output, _ := json.Marshal(v) + return string(output) +} + +// mustToJSON encodes a value into a JSON string. +// Unlike toJSON, this function returns any errors that occur during encoding. +// +// Parameters: +// - v: The value to encode to JSON +// +// Returns: +// - string: The JSON string representation of the value +// - error: Any error that occurred during encoding +func mustToJSON(v any) (string, error) { + output, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(output), nil +} + +// toPrettyJSON encodes a value into a pretty (indented) JSON string. +// This function ignores any errors that occur during encoding. +// If the value cannot be encoded, it returns an empty string. +// +// Parameters: +// - v: The value to encode to JSON +// +// Returns: +// - string: The indented JSON string representation of the value +func toPrettyJSON(v any) string { + output, _ := json.MarshalIndent(v, "", " ") + return string(output) +} + +// mustToPrettyJSON encodes a value into a pretty (indented) JSON string. +// Unlike toPrettyJSON, this function returns any errors that occur during encoding. +// +// Parameters: +// - v: The value to encode to JSON +// +// Returns: +// - string: The indented JSON string representation of the value +// - error: Any error that occurred during encoding +func mustToPrettyJSON(v any) (string, error) { + output, err := json.MarshalIndent(v, "", " ") + if err != nil { + return "", err + } + return string(output), nil +} + +// toRawJSON encodes a value into a JSON string with no escaping of HTML characters. +// This function panics if an error occurs during encoding. +// Unlike toJSON, HTML characters like <, >, and & are not escaped. +// +// Parameters: +// - v: The value to encode to JSON +// +// Returns: +// - string: The JSON string representation of the value without HTML escaping +func toRawJSON(v any) string { + output, err := mustToRawJSON(v) + if err != nil { + panic(err) + } + return output +} + +// mustToRawJSON encodes a value into a JSON string with no escaping of HTML characters. +// Unlike toRawJSON, this function returns any errors that occur during encoding. +// HTML characters like <, >, and & are not escaped in the output. +// +// Parameters: +// - v: The value to encode to JSON +// +// Returns: +// - string: The JSON string representation of the value without HTML escaping +// - error: Any error that occurred during encoding +func mustToRawJSON(v any) (string, error) { + buf := new(bytes.Buffer) + enc := json.NewEncoder(buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(&v); err != nil { + return "", err + } + return strings.TrimSuffix(buf.String(), "\n"), nil +} + +// ternary implements a conditional (ternary) operator. +// It returns the first value if the condition is true, otherwise returns the second value. +// This is similar to the ?: operator in many programming languages. +// +// Parameters: +// - vt: The value to return if the condition is true +// - vf: The value to return if the condition is false +// - v: The boolean condition to evaluate +// +// Returns: +// - any: Either vt or vf depending on the value of v +func ternary(vt any, vf any, v bool) any { + if v { + return vt + } + return vf +} diff --git a/util/sprig/defaults_test.go b/util/sprig/defaults_test.go new file mode 100644 index 00000000..f67c9cd9 --- /dev/null +++ b/util/sprig/defaults_test.go @@ -0,0 +1,196 @@ +package sprig + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDefault(t *testing.T) { + tpl := `{{"" | default "foo"}}` + if err := runt(tpl, "foo"); err != nil { + t.Error(err) + } + tpl = `{{default "foo" 234}}` + if err := runt(tpl, "234"); err != nil { + t.Error(err) + } + tpl = `{{default "foo" 2.34}}` + if err := runt(tpl, "2.34"); err != nil { + t.Error(err) + } + + tpl = `{{ .Nothing | default "123" }}` + if err := runt(tpl, "123"); err != nil { + t.Error(err) + } + tpl = `{{ default "123" }}` + if err := runt(tpl, "123"); err != nil { + t.Error(err) + } +} + +func TestEmpty(t *testing.T) { + tpl := `{{if empty 1}}1{{else}}0{{end}}` + if err := runt(tpl, "0"); err != nil { + t.Error(err) + } + + tpl = `{{if empty 0}}1{{else}}0{{end}}` + if err := runt(tpl, "1"); err != nil { + t.Error(err) + } + tpl = `{{if empty ""}}1{{else}}0{{end}}` + if err := runt(tpl, "1"); err != nil { + t.Error(err) + } + tpl = `{{if empty 0.0}}1{{else}}0{{end}}` + if err := runt(tpl, "1"); err != nil { + t.Error(err) + } + tpl = `{{if empty false}}1{{else}}0{{end}}` + if err := runt(tpl, "1"); err != nil { + t.Error(err) + } + + dict := map[string]any{"top": map[string]any{}} + tpl = `{{if empty .top.NoSuchThing}}1{{else}}0{{end}}` + if err := runtv(tpl, "1", dict); err != nil { + t.Error(err) + } + tpl = `{{if empty .bottom.NoSuchThing}}1{{else}}0{{end}}` + if err := runtv(tpl, "1", dict); err != nil { + t.Error(err) + } +} + +func TestCoalesce(t *testing.T) { + tests := map[string]string{ + `{{ coalesce 1 }}`: "1", + `{{ coalesce "" 0 nil 2 }}`: "2", + `{{ $two := 2 }}{{ coalesce "" 0 nil $two }}`: "2", + `{{ $two := 2 }}{{ coalesce "" $two 0 0 0 }}`: "2", + `{{ $two := 2 }}{{ coalesce "" $two 3 4 5 }}`: "2", + `{{ coalesce }}`: "", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } + + dict := map[string]any{"top": map[string]any{}} + tpl := `{{ coalesce .top.NoSuchThing .bottom .bottom.dollar "airplane"}}` + if err := runtv(tpl, "airplane", dict); err != nil { + t.Error(err) + } +} + +func TestAll(t *testing.T) { + tests := map[string]string{ + `{{ all 1 }}`: "true", + `{{ all "" 0 nil 2 }}`: "false", + `{{ $two := 2 }}{{ all "" 0 nil $two }}`: "false", + `{{ $two := 2 }}{{ all "" $two 0 0 0 }}`: "false", + `{{ $two := 2 }}{{ all "" $two 3 4 5 }}`: "false", + `{{ all }}`: "true", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } + + dict := map[string]any{"top": map[string]any{}} + tpl := `{{ all .top.NoSuchThing .bottom .bottom.dollar "airplane"}}` + if err := runtv(tpl, "false", dict); err != nil { + t.Error(err) + } +} + +func TestAny(t *testing.T) { + tests := map[string]string{ + `{{ any 1 }}`: "true", + `{{ any "" 0 nil 2 }}`: "true", + `{{ $two := 2 }}{{ any "" 0 nil $two }}`: "true", + `{{ $two := 2 }}{{ any "" $two 3 4 5 }}`: "true", + `{{ $zero := 0 }}{{ any "" $zero 0 0 0 }}`: "false", + `{{ any }}`: "false", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } + + dict := map[string]any{"top": map[string]any{}} + tpl := `{{ any .top.NoSuchThing .bottom .bottom.dollar "airplane"}}` + if err := runtv(tpl, "true", dict); err != nil { + t.Error(err) + } +} + +func TestFromJSON(t *testing.T) { + dict := map[string]any{"Input": `{"foo": 55}`} + + tpl := `{{.Input | fromJSON}}` + expected := `map[foo:55]` + if err := runtv(tpl, expected, dict); err != nil { + t.Error(err) + } + + tpl = `{{(.Input | fromJSON).foo}}` + expected = `55` + if err := runtv(tpl, expected, dict); err != nil { + t.Error(err) + } +} + +func TestToJSON(t *testing.T) { + dict := map[string]any{"Top": map[string]any{"bool": true, "string": "test", "number": 42}} + + tpl := `{{.Top | toJSON}}` + expected := `{"bool":true,"number":42,"string":"test"}` + if err := runtv(tpl, expected, dict); err != nil { + t.Error(err) + } +} + +func TestToPrettyJSON(t *testing.T) { + dict := map[string]any{"Top": map[string]any{"bool": true, "string": "test", "number": 42}} + tpl := `{{.Top | toPrettyJSON}}` + expected := `{ + "bool": true, + "number": 42, + "string": "test" +}` + if err := runtv(tpl, expected, dict); err != nil { + t.Error(err) + } +} + +func TestToRawJSON(t *testing.T) { + dict := map[string]any{"Top": map[string]any{"bool": true, "string": "test", "number": 42, "html": ""}} + tpl := `{{.Top | toRawJSON}}` + expected := `{"bool":true,"html":"","number":42,"string":"test"}` + + if err := runtv(tpl, expected, dict); err != nil { + t.Error(err) + } +} + +func TestTernary(t *testing.T) { + tpl := `{{true | ternary "foo" "bar"}}` + if err := runt(tpl, "foo"); err != nil { + t.Error(err) + } + + tpl = `{{ternary "foo" "bar" true}}` + if err := runt(tpl, "foo"); err != nil { + t.Error(err) + } + + tpl = `{{false | ternary "foo" "bar"}}` + if err := runt(tpl, "bar"); err != nil { + t.Error(err) + } + + tpl = `{{ternary "foo" "bar" false}}` + if err := runt(tpl, "bar"); err != nil { + t.Error(err) + } +} diff --git a/util/sprig/dict.go b/util/sprig/dict.go new file mode 100644 index 00000000..4bb16d03 --- /dev/null +++ b/util/sprig/dict.go @@ -0,0 +1,233 @@ +package sprig + +// get retrieves a value from a map by its key. +// If the key exists, returns the corresponding value. +// If the key doesn't exist, returns an empty string. +// +// Parameters: +// - d: The map to retrieve the value from +// - key: The key to look up +// +// Returns: +// - any: The value associated with the key, or an empty string if not found +func get(d map[string]any, key string) any { + if val, ok := d[key]; ok { + return val + } + return "" +} + +// set adds or updates a key-value pair in a map. +// Modifies the map in place and returns the modified map. +// +// Parameters: +// - d: The map to modify +// - key: The key to set +// - value: The value to associate with the key +// +// Returns: +// - map[string]any: The modified map (same instance as the input map) +func set(d map[string]any, key string, value any) map[string]any { + d[key] = value + return d +} + +// unset removes a key-value pair from a map. +// If the key doesn't exist, the map remains unchanged. +// Modifies the map in place and returns the modified map. +// +// Parameters: +// - d: The map to modify +// - key: The key to remove +// +// Returns: +// - map[string]any: The modified map (same instance as the input map) +func unset(d map[string]any, key string) map[string]any { + delete(d, key) + return d +} + +// hasKey checks if a key exists in a map. +// +// Parameters: +// - d: The map to check +// - key: The key to look for +// +// Returns: +// - bool: True if the key exists in the map, false otherwise +func hasKey(d map[string]any, key string) bool { + _, ok := d[key] + return ok +} + +// pluck extracts values for a specific key from multiple maps. +// Only includes values from maps where the key exists. +// +// Parameters: +// - key: The key to extract values for +// - d: A variadic list of maps to extract values from +// +// Returns: +// - []any: A slice containing all values associated with the key across all maps +func pluck(key string, d ...map[string]any) []any { + var res []any + for _, dict := range d { + if val, ok := dict[key]; ok { + res = append(res, val) + } + } + return res +} + +// keys collects all keys from one or more maps. +// The returned slice may contain duplicate keys if multiple maps contain the same key. +// +// Parameters: +// - dicts: A variadic list of maps to collect keys from +// +// Returns: +// - []string: A slice containing all keys from all provided maps +func keys(dicts ...map[string]any) []string { + var k []string + for _, dict := range dicts { + for key := range dict { + k = append(k, key) + } + } + return k +} + +// pick creates a new map containing only the specified keys from the original map. +// If a key doesn't exist in the original map, it won't be included in the result. +// +// Parameters: +// - dict: The source map +// - keys: A variadic list of keys to include in the result +// +// Returns: +// - map[string]any: A new map containing only the specified keys and their values +func pick(dict map[string]any, keys ...string) map[string]any { + res := map[string]any{} + for _, k := range keys { + if v, ok := dict[k]; ok { + res[k] = v + } + } + return res +} + +// omit creates a new map excluding the specified keys from the original map. +// The original map remains unchanged. +// +// Parameters: +// - dict: The source map +// - keys: A variadic list of keys to exclude from the result +// +// Returns: +// - map[string]any: A new map containing all key-value pairs except those specified +func omit(dict map[string]any, keys ...string) map[string]any { + res := map[string]any{} + omit := make(map[string]bool, len(keys)) + for _, k := range keys { + omit[k] = true + } + for k, v := range dict { + if _, ok := omit[k]; !ok { + res[k] = v + } + } + return res +} + +// dict creates a new map from a list of key-value pairs. +// The arguments are treated as key-value pairs, where even-indexed arguments are keys +// and odd-indexed arguments are values. +// If there's an odd number of arguments, the last key will be assigned an empty string value. +// +// Parameters: +// - v: A variadic list of alternating keys and values +// +// Returns: +// - map[string]any: A new map containing the specified key-value pairs +func dict(v ...any) map[string]any { + dict := map[string]any{} + lenv := len(v) + for i := 0; i < lenv; i += 2 { + key := strval(v[i]) + if i+1 >= lenv { + dict[key] = "" + continue + } + dict[key] = v[i+1] + } + return dict +} + +// values collects all values from a map into a slice. +// The order of values in the resulting slice is not guaranteed. +// +// Parameters: +// - dict: The map to collect values from +// +// Returns: +// - []any: A slice containing all values from the map +func values(dict map[string]any) []any { + var values []any + for _, value := range dict { + values = append(values, value) + } + return values +} + +// dig safely accesses nested values in maps using a sequence of keys. +// If any key in the path doesn't exist, it returns the default value. +// The function expects at least 3 arguments: one or more keys, a default value, and a map. +// +// Parameters: +// - ps: A variadic list where: +// - The first N-2 arguments are string keys forming the path +// - The second-to-last argument is the default value to return if the path doesn't exist +// - The last argument is the map to traverse +// +// Returns: +// - any: The value found at the specified path, or the default value if not found +// - error: Any error that occurred during traversal +// +// Panics: +// - If fewer than 3 arguments are provided +func dig(ps ...any) (any, error) { + if len(ps) < 3 { + panic("dig needs at least three arguments") + } + dict := ps[len(ps)-1].(map[string]any) + def := ps[len(ps)-2] + ks := make([]string, len(ps)-2) + for i := 0; i < len(ks); i++ { + ks[i] = ps[i].(string) + } + + return digFromDict(dict, def, ks) +} + +// digFromDict is a helper function for dig that recursively traverses a map using a sequence of keys. +// If any key in the path doesn't exist, it returns the default value. +// +// Parameters: +// - dict: The map to traverse +// - d: The default value to return if the path doesn't exist +// - ks: A slice of string keys forming the path to traverse +// +// Returns: +// - any: The value found at the specified path, or the default value if not found +// - error: Any error that occurred during traversal +func digFromDict(dict map[string]any, d any, ks []string) (any, error) { + k, ns := ks[0], ks[1:] + step, has := dict[k] + if !has { + return d, nil + } + if len(ns) == 0 { + return step, nil + } + return digFromDict(step.(map[string]any), d, ns) +} diff --git a/util/sprig/dict_test.go b/util/sprig/dict_test.go new file mode 100644 index 00000000..0b293140 --- /dev/null +++ b/util/sprig/dict_test.go @@ -0,0 +1,166 @@ +package sprig + +import ( + "strings" + "testing" +) + +func TestDict(t *testing.T) { + tpl := `{{$d := dict 1 2 "three" "four" 5}}{{range $k, $v := $d}}{{$k}}{{$v}}{{end}}` + out, err := runRaw(tpl, nil) + if err != nil { + t.Error(err) + } + if len(out) != 12 { + t.Errorf("Expected length 12, got %d", len(out)) + } + // dict does not guarantee ordering because it is backed by a map. + if !strings.Contains(out, "12") { + t.Error("Expected grouping 12") + } + if !strings.Contains(out, "threefour") { + t.Error("Expected grouping threefour") + } + if !strings.Contains(out, "5") { + t.Error("Expected 5") + } + tpl = `{{$t := dict "I" "shot" "the" "albatross"}}{{$t.the}} {{$t.I}}` + if err := runt(tpl, "albatross shot"); err != nil { + t.Error(err) + } +} + +func TestUnset(t *testing.T) { + tpl := `{{- $d := dict "one" 1 "two" 222222 -}} + {{- $_ := unset $d "two" -}} + {{- range $k, $v := $d}}{{$k}}{{$v}}{{- end -}} + ` + + expect := "one1" + if err := runt(tpl, expect); err != nil { + t.Error(err) + } +} +func TestHasKey(t *testing.T) { + tpl := `{{- $d := dict "one" 1 "two" 222222 -}} + {{- if hasKey $d "one" -}}1{{- end -}} + ` + + expect := "1" + if err := runt(tpl, expect); err != nil { + t.Error(err) + } +} + +func TestPluck(t *testing.T) { + tpl := ` + {{- $d := dict "one" 1 "two" 222222 -}} + {{- $d2 := dict "one" 1 "two" 33333 -}} + {{- $d3 := dict "one" 1 -}} + {{- $d4 := dict "one" 1 "two" 4444 -}} + {{- pluck "two" $d $d2 $d3 $d4 -}} + ` + + expect := "[222222 33333 4444]" + if err := runt(tpl, expect); err != nil { + t.Error(err) + } +} + +func TestKeys(t *testing.T) { + tests := map[string]string{ + `{{ dict "foo" 1 "bar" 2 | keys | sortAlpha }}`: "[bar foo]", + `{{ dict | keys }}`: "[]", + `{{ keys (dict "foo" 1) (dict "bar" 2) (dict "bar" 3) | uniq | sortAlpha }}`: "[bar foo]", + } + for tpl, expect := range tests { + if err := runt(tpl, expect); err != nil { + t.Error(err) + } + } +} + +func TestPick(t *testing.T) { + tests := map[string]string{ + `{{- $d := dict "one" 1 "two" 222222 }}{{ pick $d "two" | len -}}`: "1", + `{{- $d := dict "one" 1 "two" 222222 }}{{ pick $d "two" -}}`: "map[two:222222]", + `{{- $d := dict "one" 1 "two" 222222 }}{{ pick $d "one" "two" | len -}}`: "2", + `{{- $d := dict "one" 1 "two" 222222 }}{{ pick $d "one" "two" "three" | len -}}`: "2", + `{{- $d := dict }}{{ pick $d "two" | len -}}`: "0", + } + for tpl, expect := range tests { + if err := runt(tpl, expect); err != nil { + t.Error(err) + } + } +} +func TestOmit(t *testing.T) { + tests := map[string]string{ + `{{- $d := dict "one" 1 "two" 222222 }}{{ omit $d "one" | len -}}`: "1", + `{{- $d := dict "one" 1 "two" 222222 }}{{ omit $d "one" -}}`: "map[two:222222]", + `{{- $d := dict "one" 1 "two" 222222 }}{{ omit $d "one" "two" | len -}}`: "0", + `{{- $d := dict "one" 1 "two" 222222 }}{{ omit $d "two" "three" | len -}}`: "1", + `{{- $d := dict }}{{ omit $d "two" | len -}}`: "0", + } + for tpl, expect := range tests { + if err := runt(tpl, expect); err != nil { + t.Error(err) + } + } +} + +func TestGet(t *testing.T) { + tests := map[string]string{ + `{{- $d := dict "one" 1 }}{{ get $d "one" -}}`: "1", + `{{- $d := dict "one" 1 "two" "2" }}{{ get $d "two" -}}`: "2", + `{{- $d := dict }}{{ get $d "two" -}}`: "", + } + for tpl, expect := range tests { + if err := runt(tpl, expect); err != nil { + t.Error(err) + } + } +} + +func TestSet(t *testing.T) { + tpl := `{{- $d := dict "one" 1 "two" 222222 -}} + {{- $_ := set $d "two" 2 -}} + {{- $_ := set $d "three" 3 -}} + {{- if hasKey $d "one" -}}{{$d.one}}{{- end -}} + {{- if hasKey $d "two" -}}{{$d.two}}{{- end -}} + {{- if hasKey $d "three" -}}{{$d.three}}{{- end -}} + ` + + expect := "123" + if err := runt(tpl, expect); err != nil { + t.Error(err) + } +} + +func TestValues(t *testing.T) { + tests := map[string]string{ + `{{- $d := dict "a" 1 "b" 2 }}{{ values $d | sortAlpha | join "," }}`: "1,2", + `{{- $d := dict "a" "first" "b" 2 }}{{ values $d | sortAlpha | join "," }}`: "2,first", + } + + for tpl, expect := range tests { + if err := runt(tpl, expect); err != nil { + t.Error(err) + } + } +} + +func TestDig(t *testing.T) { + tests := map[string]string{ + `{{- $d := dict "a" (dict "b" (dict "c" 1)) }}{{ dig "a" "b" "c" "" $d }}`: "1", + `{{- $d := dict "a" (dict "b" (dict "c" 1)) }}{{ dig "a" "b" "z" "2" $d }}`: "2", + `{{ dict "a" 1 | dig "a" "" }}`: "1", + `{{ dict "a" 1 | dig "z" "2" }}`: "2", + } + + for tpl, expect := range tests { + if err := runt(tpl, expect); err != nil { + t.Error(err) + } + } +} diff --git a/util/sprig/doc.go b/util/sprig/doc.go new file mode 100644 index 00000000..91031d6d --- /dev/null +++ b/util/sprig/doc.go @@ -0,0 +1,19 @@ +/* +Package sprig provides template functions for Go. + +This package contains a number of utility functions for working with data +inside of Go `html/template` and `text/template` files. + +To add these functions, use the `template.Funcs()` method: + + t := template.New("foo").Funcs(sprig.FuncMap()) + +Note that you should add the function map before you parse any template files. + + In several cases, Sprig reverses the order of arguments from the way they + appear in the standard library. This is to make it easier to pipe + arguments into functions. + +See http://masterminds.github.io/sprig/ for more detailed documentation on each of the available functions. +*/ +package sprig diff --git a/util/sprig/example_test.go b/util/sprig/example_test.go new file mode 100644 index 00000000..2f1b74c8 --- /dev/null +++ b/util/sprig/example_test.go @@ -0,0 +1,25 @@ +package sprig + +import ( + "fmt" + "os" + "text/template" +) + +func Example() { + // Set up variables and template. + vars := map[string]any{"Name": " John Jacob Jingleheimer Schmidt "} + tpl := `Hello {{.Name | trim | lower}}` + + // Get the Sprig function map. + fmap := TxtFuncMap() + t := template.Must(template.New("test").Funcs(fmap).Parse(tpl)) + + err := t.Execute(os.Stdout, vars) + if err != nil { + fmt.Printf("Error during template execution: %s", err) + return + } + // Output: + // Hello john jacob jingleheimer schmidt +} diff --git a/util/sprig/flow_control.go b/util/sprig/flow_control.go new file mode 100644 index 00000000..cfaa5081 --- /dev/null +++ b/util/sprig/flow_control.go @@ -0,0 +1,8 @@ +package sprig + +import "errors" + +// fail is a function that always returns an error with the given message. +func fail(msg string) (string, error) { + return "", errors.New(msg) +} diff --git a/util/sprig/flow_control_test.go b/util/sprig/flow_control_test.go new file mode 100644 index 00000000..d4e5ebf0 --- /dev/null +++ b/util/sprig/flow_control_test.go @@ -0,0 +1,16 @@ +package sprig + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFail(t *testing.T) { + const msg = "This is an error!" + tpl := fmt.Sprintf(`{{fail "%s"}}`, msg) + _, err := runRaw(tpl, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), msg) +} diff --git a/util/sprig/functions.go b/util/sprig/functions.go new file mode 100644 index 00000000..8dbb23f8 --- /dev/null +++ b/util/sprig/functions.go @@ -0,0 +1,214 @@ +package sprig + +import ( + "path" + "path/filepath" + "reflect" + "strings" + "text/template" + "time" +) + +const ( + loopExecutionLimit = 10_000 // Limit the number of loop executions to prevent execution from taking too long + stringLengthLimit = 100_000 // Limit the length of strings to prevent memory issues + sliceSizeLimit = 10_000 // Limit the size of slices to prevent memory issues +) + +// TxtFuncMap produces the function map. +// +// Use this to pass the functions into the template engine: +// +// tpl := template.New("foo").Funcs(sprig.FuncMap())) +// +// TxtFuncMap returns a 'text/template'.FuncMap +func TxtFuncMap() template.FuncMap { + return map[string]any{ + // Date functions + "ago": dateAgo, + "date": date, + "dateInZone": dateInZone, + "dateModify": dateModify, + "duration": duration, + "durationRound": durationRound, + "htmlDate": htmlDate, + "htmlDateInZone": htmlDateInZone, + "mustDateModify": mustDateModify, + "mustToDate": mustToDate, + "now": time.Now, + "toDate": toDate, + "unixEpoch": unixEpoch, + + // Strings + "trunc": trunc, + "trim": strings.TrimSpace, + "upper": strings.ToUpper, + "lower": strings.ToLower, + "title": title, + "substr": substring, + "repeat": repeat, + "trimAll": trimAll, + "trimPrefix": trimPrefix, + "trimSuffix": trimSuffix, + "contains": contains, + "hasPrefix": hasPrefix, + "hasSuffix": hasSuffix, + "quote": quote, + "squote": squote, + "cat": cat, + "indent": indent, + "nindent": nindent, + "replace": replace, + "plural": plural, + "sha1sum": sha1sum, + "sha256sum": sha256sum, + "sha512sum": sha512sum, + "adler32sum": adler32sum, + "toString": strval, + + // Wrap Atoi to stop errors. + "atoi": atoi, + "seq": seq, + "toDecimal": toDecimal, + "split": split, + "splitList": splitList, + "splitn": splitn, + "toStrings": strslice, + + "until": until, + "untilStep": untilStep, + + // Basic arithmetic + "add1": add1, + "add": add, + "sub": sub, + "div": div, + "mod": mod, + "mul": mul, + "randInt": randInt, + "biggest": maxAsInt64, + "max": maxAsInt64, + "min": minAsInt64, + "maxf": maxAsFloat64, + "minf": minAsFloat64, + "ceil": ceil, + "floor": floor, + "round": round, + + // string slices. Note that we reverse the order b/c that's better + // for template processing. + "join": join, + "sortAlpha": sortAlpha, + + // Defaults + "default": defaultValue, + "empty": empty, + "coalesce": coalesce, + "all": all, + "any": anyNonEmpty, + "compact": compact, + "mustCompact": mustCompact, + "fromJSON": fromJSON, + "toJSON": toJSON, + "toPrettyJSON": toPrettyJSON, + "toRawJSON": toRawJSON, + "mustFromJSON": mustFromJSON, + "mustToJSON": mustToJSON, + "mustToPrettyJSON": mustToPrettyJSON, + "mustToRawJSON": mustToRawJSON, + "ternary": ternary, + + // Reflection + "typeOf": typeOf, + "typeIs": typeIs, + "typeIsLike": typeIsLike, + "kindOf": kindOf, + "kindIs": kindIs, + "deepEqual": reflect.DeepEqual, + + // Paths + "base": path.Base, + "dir": path.Dir, + "clean": path.Clean, + "ext": path.Ext, + "isAbs": path.IsAbs, + + // Filepaths + "osBase": filepath.Base, + "osClean": filepath.Clean, + "osDir": filepath.Dir, + "osExt": filepath.Ext, + "osIsAbs": filepath.IsAbs, + + // Encoding + "b64enc": base64encode, + "b64dec": base64decode, + "b32enc": base32encode, + "b32dec": base32decode, + + // Data Structures + "tuple": list, // FIXME: with the addition of append/prepend these are no longer immutable. + "list": list, + "dict": dict, + "get": get, + "set": set, + "unset": unset, + "hasKey": hasKey, + "pluck": pluck, + "keys": keys, + "pick": pick, + "omit": omit, + "values": values, + + "append": push, + "push": push, + "mustAppend": mustPush, + "mustPush": mustPush, + "prepend": prepend, + "mustPrepend": mustPrepend, + "first": first, + "mustFirst": mustFirst, + "rest": rest, + "mustRest": mustRest, + "last": last, + "mustLast": mustLast, + "initial": initial, + "mustInitial": mustInitial, + "reverse": reverse, + "mustReverse": mustReverse, + "uniq": uniq, + "mustUniq": mustUniq, + "without": without, + "mustWithout": mustWithout, + "has": has, + "mustHas": mustHas, + "slice": slice, + "mustSlice": mustSlice, + "concat": concat, + "dig": dig, + "chunk": chunk, + "mustChunk": mustChunk, + + // Flow Control + "fail": fail, + + // Regex + "regexMatch": regexMatch, + "mustRegexMatch": mustRegexMatch, + "regexFindAll": regexFindAll, + "mustRegexFindAll": mustRegexFindAll, + "regexFind": regexFind, + "mustRegexFind": mustRegexFind, + "regexReplaceAll": regexReplaceAll, + "mustRegexReplaceAll": mustRegexReplaceAll, + "regexReplaceAllLiteral": regexReplaceAllLiteral, + "mustRegexReplaceAllLiteral": mustRegexReplaceAllLiteral, + "regexSplit": regexSplit, + "mustRegexSplit": mustRegexSplit, + "regexQuoteMeta": regexQuoteMeta, + + // URLs + "urlParse": urlParse, + "urlJoin": urlJoin, + } +} diff --git a/util/sprig/functions_linux_test.go b/util/sprig/functions_linux_test.go new file mode 100644 index 00000000..cfbf253a --- /dev/null +++ b/util/sprig/functions_linux_test.go @@ -0,0 +1,28 @@ +package sprig + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestOsBase(t *testing.T) { + assert.NoError(t, runt(`{{ osBase "foo/bar" }}`, "bar")) +} + +func TestOsDir(t *testing.T) { + assert.NoError(t, runt(`{{ osDir "foo/bar/baz" }}`, "foo/bar")) +} + +func TestOsIsAbs(t *testing.T) { + assert.NoError(t, runt(`{{ osIsAbs "/foo" }}`, "true")) + assert.NoError(t, runt(`{{ osIsAbs "foo" }}`, "false")) +} + +func TestOsClean(t *testing.T) { + assert.NoError(t, runt(`{{ osClean "/foo/../foo/../bar" }}`, "/bar")) +} + +func TestOsExt(t *testing.T) { + assert.NoError(t, runt(`{{ osExt "/foo/bar/baz.txt" }}`, ".txt")) +} diff --git a/util/sprig/functions_test.go b/util/sprig/functions_test.go new file mode 100644 index 00000000..4e83e993 --- /dev/null +++ b/util/sprig/functions_test.go @@ -0,0 +1,70 @@ +package sprig + +import ( + "bytes" + "fmt" + "testing" + "text/template" + + "github.com/stretchr/testify/assert" +) + +func TestBase(t *testing.T) { + assert.NoError(t, runt(`{{ base "foo/bar" }}`, "bar")) +} + +func TestDir(t *testing.T) { + assert.NoError(t, runt(`{{ dir "foo/bar/baz" }}`, "foo/bar")) +} + +func TestIsAbs(t *testing.T) { + assert.NoError(t, runt(`{{ isAbs "/foo" }}`, "true")) + assert.NoError(t, runt(`{{ isAbs "foo" }}`, "false")) +} + +func TestClean(t *testing.T) { + assert.NoError(t, runt(`{{ clean "/foo/../foo/../bar" }}`, "/bar")) +} + +func TestExt(t *testing.T) { + assert.NoError(t, runt(`{{ ext "/foo/bar/baz.txt" }}`, ".txt")) +} + +func TestRegex(t *testing.T) { + assert.NoError(t, runt(`{{ regexQuoteMeta "1.2.3" }}`, "1\\.2\\.3")) + assert.NoError(t, runt(`{{ regexQuoteMeta "pretzel" }}`, "pretzel")) +} + +// runt runs a template and checks that the output exactly matches the expected string. +func runt(tpl, expect string) error { + return runtv(tpl, expect, map[string]string{}) +} + +// runtv takes a template, and expected return, and values for substitution. +// +// It runs the template and verifies that the output is an exact match. +func runtv(tpl, expect string, vars any) error { + fmap := TxtFuncMap() + t := template.Must(template.New("test").Funcs(fmap).Parse(tpl)) + var b bytes.Buffer + err := t.Execute(&b, vars) + if err != nil { + return err + } + if expect != b.String() { + return fmt.Errorf("expected '%s', got '%s'", expect, b.String()) + } + return nil +} + +// runRaw runs a template with the given variables and returns the result. +func runRaw(tpl string, vars any) (string, error) { + fmap := TxtFuncMap() + t := template.Must(template.New("test").Funcs(fmap).Parse(tpl)) + var b bytes.Buffer + err := t.Execute(&b, vars) + if err != nil { + return "", err + } + return b.String(), nil +} diff --git a/util/sprig/list.go b/util/sprig/list.go new file mode 100644 index 00000000..fdcbf5e6 --- /dev/null +++ b/util/sprig/list.go @@ -0,0 +1,505 @@ +package sprig + +import ( + "fmt" + "math" + "reflect" + "sort" +) + +// Reflection is used in these functions so that slices and arrays of strings, +// ints, and other types not implementing []any can be worked with. +// For example, this is useful if you need to work on the output of regexs. + +// list creates a new list (slice) containing the provided arguments. +// It accepts any number of arguments of any type and returns them as a slice. +func list(v ...any) []any { + return v +} + +// push appends an element to the end of a list (slice or array). +// It takes a list and a value, and returns a new list with the value appended. +// This function will panic if the first argument is not a slice or array. +func push(list any, v any) []any { + l, err := mustPush(list, v) + if err != nil { + panic(err) + } + return l +} + +// mustPush is the implementation of push that returns an error instead of panicking. +// It converts the input list to a slice of any type, then appends the value. +func mustPush(list any, v any) ([]any, error) { + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + l2 := reflect.ValueOf(list) + l := l2.Len() + nl := make([]any, l) + for i := 0; i < l; i++ { + nl[i] = l2.Index(i).Interface() + } + return append(nl, v), nil + default: + return nil, fmt.Errorf("cannot push on type %s", tp) + } +} + +// prepend adds an element to the beginning of a list (slice or array). +// It takes a list and a value, and returns a new list with the value at the start. +// This function will panic if the first argument is not a slice or array. +func prepend(list any, v any) []any { + l, err := mustPrepend(list, v) + if err != nil { + panic(err) + } + return l +} + +// mustPrepend is the implementation of prepend that returns an error instead of panicking. +// It converts the input list to a slice of any type, then prepends the value. +func mustPrepend(list any, v any) ([]any, error) { + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + l2 := reflect.ValueOf(list) + l := l2.Len() + nl := make([]any, l) + for i := 0; i < l; i++ { + nl[i] = l2.Index(i).Interface() + } + return append([]any{v}, nl...), nil + default: + return nil, fmt.Errorf("cannot prepend on type %s", tp) + } +} + +// chunk divides a list into sub-lists of the specified size. +// It takes a size and a list, and returns a list of lists, each containing +// up to 'size' elements from the original list. +// This function will panic if the second argument is not a slice or array. +func chunk(size int, list any) [][]any { + l, err := mustChunk(size, list) + if err != nil { + panic(err) + } + return l +} + +// mustChunk is the implementation of chunk that returns an error instead of panicking. +// It divides the input list into chunks of the specified size. +func mustChunk(size int, list any) ([][]any, error) { + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + l2 := reflect.ValueOf(list) + l := l2.Len() + numChunks := int(math.Floor(float64(l-1)/float64(size)) + 1) + if numChunks > sliceSizeLimit { + return nil, fmt.Errorf("number of chunks %d exceeds maximum limit of %d", numChunks, sliceSizeLimit) + } + result := make([][]any, numChunks) + for i := 0; i < numChunks; i++ { + clen := size + // Handle the last chunk which might be smaller + if i == numChunks-1 { + clen = int(math.Floor(math.Mod(float64(l), float64(size)))) + if clen == 0 { + clen = size + } + } + result[i] = make([]any, clen) + for j := 0; j < clen; j++ { + ix := i*size + j + result[i][j] = l2.Index(ix).Interface() + } + } + return result, nil + + default: + return nil, fmt.Errorf("cannot chunk type %s", tp) + } +} + +// last returns the last element of a list (slice or array). +// If the list is empty, it returns nil. +// This function will panic if the argument is not a slice or array. +func last(list any) any { + l, err := mustLast(list) + if err != nil { + panic(err) + } + + return l +} + +// mustLast is the implementation of last that returns an error instead of panicking. +// It returns the last element of the list or nil if the list is empty. +func mustLast(list any) (any, error) { + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + l2 := reflect.ValueOf(list) + + l := l2.Len() + if l == 0 { + return nil, nil + } + + return l2.Index(l - 1).Interface(), nil + default: + return nil, fmt.Errorf("cannot find last on type %s", tp) + } +} + +// first returns the first element of a list (slice or array). +// If the list is empty, it returns nil. +// This function will panic if the argument is not a slice or array. +func first(list any) any { + l, err := mustFirst(list) + if err != nil { + panic(err) + } + + return l +} + +// mustFirst is the implementation of first that returns an error instead of panicking. +// It returns the first element of the list or nil if the list is empty. +func mustFirst(list any) (any, error) { + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + l2 := reflect.ValueOf(list) + + l := l2.Len() + if l == 0 { + return nil, nil + } + + return l2.Index(0).Interface(), nil + default: + return nil, fmt.Errorf("cannot find first on type %s", tp) + } +} + +// rest returns all elements of a list except the first one. +// If the list is empty, it returns nil. +// This function will panic if the argument is not a slice or array. +func rest(list any) []any { + l, err := mustRest(list) + if err != nil { + panic(err) + } + + return l +} + +// mustRest is the implementation of rest that returns an error instead of panicking. +// It returns all elements of the list except the first one, or nil if the list is empty. +func mustRest(list any) ([]any, error) { + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + l2 := reflect.ValueOf(list) + l := l2.Len() + if l == 0 { + return nil, nil + } + nl := make([]any, l-1) + for i := 1; i < l; i++ { + nl[i-1] = l2.Index(i).Interface() + } + return nl, nil + default: + return nil, fmt.Errorf("cannot find rest on type %s", tp) + } +} + +// initial returns all elements of a list except the last one. +// If the list is empty, it returns nil. +// This function will panic if the argument is not a slice or array. +func initial(list any) []any { + l, err := mustInitial(list) + if err != nil { + panic(err) + } + + return l +} + +// mustInitial is the implementation of initial that returns an error instead of panicking. +// It returns all elements of the list except the last one, or nil if the list is empty. +func mustInitial(list any) ([]any, error) { + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + l2 := reflect.ValueOf(list) + l := l2.Len() + if l == 0 { + return nil, nil + } + nl := make([]any, l-1) + for i := 0; i < l-1; i++ { + nl[i] = l2.Index(i).Interface() + } + return nl, nil + default: + return nil, fmt.Errorf("cannot find initial on type %s", tp) + } +} + +// sortAlpha sorts a list of strings alphabetically. +// If the input is not a slice or array, it returns a single-element slice +// containing the string representation of the input. +func sortAlpha(list any) []string { + k := reflect.Indirect(reflect.ValueOf(list)).Kind() + switch k { + case reflect.Slice, reflect.Array: + a := strslice(list) + s := sort.StringSlice(a) + s.Sort() + return s + } + return []string{strval(list)} +} + +// reverse returns a new list with the elements in reverse order. +// This function will panic if the argument is not a slice or array. +func reverse(v any) []any { + l, err := mustReverse(v) + if err != nil { + panic(err) + } + + return l +} + +// mustReverse is the implementation of reverse that returns an error instead of panicking. +// It returns a new list with the elements in reverse order. +func mustReverse(v any) ([]any, error) { + tp := reflect.TypeOf(v).Kind() + switch tp { + case reflect.Slice, reflect.Array: + l2 := reflect.ValueOf(v) + l := l2.Len() + // We do not sort in place because the incoming array should not be altered. + nl := make([]any, l) + for i := 0; i < l; i++ { + nl[l-i-1] = l2.Index(i).Interface() + } + return nl, nil + default: + return nil, fmt.Errorf("cannot find reverse on type %s", tp) + } +} + +// compact returns a new list with all "empty" elements removed. +// An element is considered empty if it's nil, zero, an empty string, or an empty collection. +// This function will panic if the argument is not a slice or array. +func compact(list any) []any { + l, err := mustCompact(list) + if err != nil { + panic(err) + } + return l +} + +// mustCompact is the implementation of compact that returns an error instead of panicking. +// It returns a new list with all "empty" elements removed. +func mustCompact(list any) ([]any, error) { + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + l2 := reflect.ValueOf(list) + l := l2.Len() + var nl []any + var item any + for i := 0; i < l; i++ { + item = l2.Index(i).Interface() + if !empty(item) { + nl = append(nl, item) + } + } + return nl, nil + default: + return nil, fmt.Errorf("cannot compact on type %s", tp) + } +} + +// uniq returns a new list with duplicate elements removed. +// The first occurrence of each element is kept. +// This function will panic if the argument is not a slice or array. +func uniq(list any) []any { + l, err := mustUniq(list) + if err != nil { + panic(err) + } + return l +} + +// mustUniq is the implementation of uniq that returns an error instead of panicking. +// It returns a new list with duplicate elements removed. +func mustUniq(list any) ([]any, error) { + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + l2 := reflect.ValueOf(list) + l := l2.Len() + var dest []any + var item any + for i := 0; i < l; i++ { + item = l2.Index(i).Interface() + if !inList(dest, item) { + dest = append(dest, item) + } + } + return dest, nil + default: + return nil, fmt.Errorf("cannot find uniq on type %s", tp) + } +} + +// inList checks if a value is present in a list. +// It uses deep equality comparison to check for matches. +// Returns true if the value is found, false otherwise. +func inList(haystack []any, needle any) bool { + for _, h := range haystack { + if reflect.DeepEqual(needle, h) { + return true + } + } + return false +} + +// without returns a new list with all occurrences of the specified values removed. +// This function will panic if the first argument is not a slice or array. +func without(list any, omit ...any) []any { + l, err := mustWithout(list, omit...) + if err != nil { + panic(err) + } + return l +} + +// mustWithout is the implementation of without that returns an error instead of panicking. +// It returns a new list with all occurrences of the specified values removed. +func mustWithout(list any, omit ...any) ([]any, error) { + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + l2 := reflect.ValueOf(list) + l := l2.Len() + res := []any{} + var item any + for i := 0; i < l; i++ { + item = l2.Index(i).Interface() + if !inList(omit, item) { + res = append(res, item) + } + } + return res, nil + default: + return nil, fmt.Errorf("cannot find without on type %s", tp) + } +} + +// has checks if a value is present in a list. +// Returns true if the value is found, false otherwise. +// This function will panic if the second argument is not a slice or array. +func has(needle any, haystack any) bool { + l, err := mustHas(needle, haystack) + if err != nil { + panic(err) + } + return l +} + +// mustHas is the implementation of has that returns an error instead of panicking. +// It checks if a value is present in a list. +func mustHas(needle any, haystack any) (bool, error) { + if haystack == nil { + return false, nil + } + tp := reflect.TypeOf(haystack).Kind() + switch tp { + case reflect.Slice, reflect.Array: + l2 := reflect.ValueOf(haystack) + var item any + l := l2.Len() + for i := 0; i < l; i++ { + item = l2.Index(i).Interface() + if reflect.DeepEqual(needle, item) { + return true, nil + } + } + return false, nil + default: + return false, fmt.Errorf("cannot find has on type %s", tp) + } +} + +// slice extracts a portion of a list based on the provided indices. +// Usage examples: +// $list := [1, 2, 3, 4, 5] +// slice $list -> list[0:5] = list[:] +// slice $list 0 3 -> list[0:3] = list[:3] +// slice $list 3 5 -> list[3:5] +// slice $list 3 -> list[3:5] = list[3:] +// +// This function will panic if the first argument is not a slice or array. +func slice(list any, indices ...any) any { + l, err := mustSlice(list, indices...) + if err != nil { + panic(err) + } + return l +} + +// mustSlice is the implementation of slice that returns an error instead of panicking. +// It extracts a portion of a list based on the provided indices. +func mustSlice(list any, indices ...any) (any, error) { + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + l2 := reflect.ValueOf(list) + l := l2.Len() + if l == 0 { + return nil, nil + } + // Determine start and end indices + var start, end int + if len(indices) > 0 { + start = toInt(indices[0]) + } + if len(indices) < 2 { + end = l + } else { + end = toInt(indices[1]) + } + return l2.Slice(start, end).Interface(), nil + default: + return nil, fmt.Errorf("list should be type of slice or array but %s", tp) + } +} + +// concat combines multiple lists into a single list. +// It takes any number of lists and returns a new list containing all elements. +// This function will panic if any argument is not a slice or array. +func concat(lists ...any) any { + var res []any + for _, list := range lists { + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + l2 := reflect.ValueOf(list) + for i := 0; i < l2.Len(); i++ { + res = append(res, l2.Index(i).Interface()) + } + default: + panic(fmt.Sprintf("cannot concat type %s as list", tp)) + } + } + return res +} diff --git a/util/sprig/list_test.go b/util/sprig/list_test.go new file mode 100644 index 00000000..e6693b2f --- /dev/null +++ b/util/sprig/list_test.go @@ -0,0 +1,367 @@ +package sprig + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTuple(t *testing.T) { + tpl := `{{$t := tuple 1 "a" "foo"}}{{index $t 2}}{{index $t 0 }}{{index $t 1}}` + if err := runt(tpl, "foo1a"); err != nil { + t.Error(err) + } +} + +func TestList(t *testing.T) { + tpl := `{{$t := list 1 "a" "foo"}}{{index $t 2}}{{index $t 0 }}{{index $t 1}}` + if err := runt(tpl, "foo1a"); err != nil { + t.Error(err) + } +} + +func TestPush(t *testing.T) { + // Named `append` in the function map + tests := map[string]string{ + `{{ $t := tuple 1 2 3 }}{{ append $t 4 | len }}`: "4", + `{{ $t := tuple 1 2 3 4 }}{{ append $t 5 | join "-" }}`: "1-2-3-4-5", + `{{ $t := regexSplit "/" "foo/bar/baz" -1 }}{{ append $t "qux" | join "-" }}`: "foo-bar-baz-qux", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestMustPush(t *testing.T) { + // Named `append` in the function map + tests := map[string]string{ + `{{ $t := tuple 1 2 3 }}{{ mustAppend $t 4 | len }}`: "4", + `{{ $t := tuple 1 2 3 4 }}{{ mustAppend $t 5 | join "-" }}`: "1-2-3-4-5", + `{{ $t := regexSplit "/" "foo/bar/baz" -1 }}{{ mustPush $t "qux" | join "-" }}`: "foo-bar-baz-qux", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestChunk(t *testing.T) { + tests := map[string]string{ + `{{ tuple 1 2 3 4 5 6 7 | chunk 3 | len }}`: "3", + `{{ tuple | chunk 3 | len }}`: "0", + `{{ range ( tuple 1 2 3 4 5 6 7 8 9 | chunk 3 ) }}{{. | join "-"}}|{{end}}`: "1-2-3|4-5-6|7-8-9|", + `{{ range ( tuple 1 2 3 4 5 6 7 8 | chunk 3 ) }}{{. | join "-"}}|{{end}}`: "1-2-3|4-5-6|7-8|", + `{{ range ( tuple 1 2 | chunk 3 ) }}{{. | join "-"}}|{{end}}`: "1-2|", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestMustChunk(t *testing.T) { + tests := map[string]string{ + `{{ tuple 1 2 3 4 5 6 7 | mustChunk 3 | len }}`: "3", + `{{ tuple | mustChunk 3 | len }}`: "0", + `{{ range ( tuple 1 2 3 4 5 6 7 8 9 | mustChunk 3 ) }}{{. | join "-"}}|{{end}}`: "1-2-3|4-5-6|7-8-9|", + `{{ range ( tuple 1 2 3 4 5 6 7 8 | mustChunk 3 ) }}{{. | join "-"}}|{{end}}`: "1-2-3|4-5-6|7-8|", + `{{ range ( tuple 1 2 | mustChunk 3 ) }}{{. | join "-"}}|{{end}}`: "1-2|", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } + err := runt(`{{ tuple `+strings.Repeat(" 0", 10001)+` | mustChunk 1 }}`, "a") + assert.ErrorContains(t, err, "number of chunks 10001 exceeds maximum limit of 10000") +} + +func TestPrepend(t *testing.T) { + tests := map[string]string{ + `{{ $t := tuple 1 2 3 }}{{ prepend $t 0 | len }}`: "4", + `{{ $t := tuple 1 2 3 4 }}{{ prepend $t 0 | join "-" }}`: "0-1-2-3-4", + `{{ $t := regexSplit "/" "foo/bar/baz" -1 }}{{ prepend $t "qux" | join "-" }}`: "qux-foo-bar-baz", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestMustPrepend(t *testing.T) { + tests := map[string]string{ + `{{ $t := tuple 1 2 3 }}{{ mustPrepend $t 0 | len }}`: "4", + `{{ $t := tuple 1 2 3 4 }}{{ mustPrepend $t 0 | join "-" }}`: "0-1-2-3-4", + `{{ $t := regexSplit "/" "foo/bar/baz" -1 }}{{ mustPrepend $t "qux" | join "-" }}`: "qux-foo-bar-baz", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestFirst(t *testing.T) { + tests := map[string]string{ + `{{ list 1 2 3 | first }}`: "1", + `{{ list | first }}`: "", + `{{ regexSplit "/src/" "foo/src/bar" -1 | first }}`: "foo", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestMustFirst(t *testing.T) { + tests := map[string]string{ + `{{ list 1 2 3 | mustFirst }}`: "1", + `{{ list | mustFirst }}`: "", + `{{ regexSplit "/src/" "foo/src/bar" -1 | mustFirst }}`: "foo", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestLast(t *testing.T) { + tests := map[string]string{ + `{{ list 1 2 3 | last }}`: "3", + `{{ list | last }}`: "", + `{{ regexSplit "/src/" "foo/src/bar" -1 | last }}`: "bar", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestMustLast(t *testing.T) { + tests := map[string]string{ + `{{ list 1 2 3 | mustLast }}`: "3", + `{{ list | mustLast }}`: "", + `{{ regexSplit "/src/" "foo/src/bar" -1 | mustLast }}`: "bar", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestInitial(t *testing.T) { + tests := map[string]string{ + `{{ list 1 2 3 | initial | len }}`: "2", + `{{ list 1 2 3 | initial | last }}`: "2", + `{{ list 1 2 3 | initial | first }}`: "1", + `{{ list | initial }}`: "[]", + `{{ regexSplit "/" "foo/bar/baz" -1 | initial }}`: "[foo bar]", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestMustInitial(t *testing.T) { + tests := map[string]string{ + `{{ list 1 2 3 | mustInitial | len }}`: "2", + `{{ list 1 2 3 | mustInitial | last }}`: "2", + `{{ list 1 2 3 | mustInitial | first }}`: "1", + `{{ list | mustInitial }}`: "[]", + `{{ regexSplit "/" "foo/bar/baz" -1 | mustInitial }}`: "[foo bar]", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestRest(t *testing.T) { + tests := map[string]string{ + `{{ list 1 2 3 | rest | len }}`: "2", + `{{ list 1 2 3 | rest | last }}`: "3", + `{{ list 1 2 3 | rest | first }}`: "2", + `{{ list | rest }}`: "[]", + `{{ regexSplit "/" "foo/bar/baz" -1 | rest }}`: "[bar baz]", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestMustRest(t *testing.T) { + tests := map[string]string{ + `{{ list 1 2 3 | mustRest | len }}`: "2", + `{{ list 1 2 3 | mustRest | last }}`: "3", + `{{ list 1 2 3 | mustRest | first }}`: "2", + `{{ list | mustRest }}`: "[]", + `{{ regexSplit "/" "foo/bar/baz" -1 | mustRest }}`: "[bar baz]", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestReverse(t *testing.T) { + tests := map[string]string{ + `{{ list 1 2 3 | reverse | first }}`: "3", + `{{ list 1 2 3 | reverse | rest | first }}`: "2", + `{{ list 1 2 3 | reverse | last }}`: "1", + `{{ list 1 2 3 4 | reverse }}`: "[4 3 2 1]", + `{{ list 1 | reverse }}`: "[1]", + `{{ list | reverse }}`: "[]", + `{{ regexSplit "/" "foo/bar/baz" -1 | reverse }}`: "[baz bar foo]", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestMustReverse(t *testing.T) { + tests := map[string]string{ + `{{ list 1 2 3 | mustReverse | first }}`: "3", + `{{ list 1 2 3 | mustReverse | rest | first }}`: "2", + `{{ list 1 2 3 | mustReverse | last }}`: "1", + `{{ list 1 2 3 4 | mustReverse }}`: "[4 3 2 1]", + `{{ list 1 | mustReverse }}`: "[1]", + `{{ list | mustReverse }}`: "[]", + `{{ regexSplit "/" "foo/bar/baz" -1 | mustReverse }}`: "[baz bar foo]", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestCompact(t *testing.T) { + tests := map[string]string{ + `{{ list 1 0 "" "hello" | compact }}`: `[1 hello]`, + `{{ list "" "" | compact }}`: `[]`, + `{{ list | compact }}`: `[]`, + `{{ regexSplit "/" "foo//bar" -1 | compact }}`: "[foo bar]", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestMustCompact(t *testing.T) { + tests := map[string]string{ + `{{ list 1 0 "" "hello" | mustCompact }}`: `[1 hello]`, + `{{ list "" "" | mustCompact }}`: `[]`, + `{{ list | mustCompact }}`: `[]`, + `{{ regexSplit "/" "foo//bar" -1 | mustCompact }}`: "[foo bar]", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestUniq(t *testing.T) { + tests := map[string]string{ + `{{ list 1 2 3 4 | uniq }}`: `[1 2 3 4]`, + `{{ list "a" "b" "c" "d" | uniq }}`: `[a b c d]`, + `{{ list 1 1 1 1 2 2 2 2 | uniq }}`: `[1 2]`, + `{{ list "foo" 1 1 1 1 "foo" "foo" | uniq }}`: `[foo 1]`, + `{{ list | uniq }}`: `[]`, + `{{ regexSplit "/" "foo/foo/bar" -1 | uniq }}`: "[foo bar]", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestMustUniq(t *testing.T) { + tests := map[string]string{ + `{{ list 1 2 3 4 | mustUniq }}`: `[1 2 3 4]`, + `{{ list "a" "b" "c" "d" | mustUniq }}`: `[a b c d]`, + `{{ list 1 1 1 1 2 2 2 2 | mustUniq }}`: `[1 2]`, + `{{ list "foo" 1 1 1 1 "foo" "foo" | mustUniq }}`: `[foo 1]`, + `{{ list | mustUniq }}`: `[]`, + `{{ regexSplit "/" "foo/foo/bar" -1 | mustUniq }}`: "[foo bar]", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestWithout(t *testing.T) { + tests := map[string]string{ + `{{ without (list 1 2 3 4) 1 }}`: `[2 3 4]`, + `{{ without (list "a" "b" "c" "d") "a" }}`: `[b c d]`, + `{{ without (list 1 1 1 1 2) 1 }}`: `[2]`, + `{{ without (list) 1 }}`: `[]`, + `{{ without (list 1 2 3) }}`: `[1 2 3]`, + `{{ without list }}`: `[]`, + `{{ without (regexSplit "/" "foo/bar/baz" -1 ) "foo" }}`: "[bar baz]", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestMustWithout(t *testing.T) { + tests := map[string]string{ + `{{ mustWithout (list 1 2 3 4) 1 }}`: `[2 3 4]`, + `{{ mustWithout (list "a" "b" "c" "d") "a" }}`: `[b c d]`, + `{{ mustWithout (list 1 1 1 1 2) 1 }}`: `[2]`, + `{{ mustWithout (list) 1 }}`: `[]`, + `{{ mustWithout (list 1 2 3) }}`: `[1 2 3]`, + `{{ mustWithout list }}`: `[]`, + `{{ mustWithout (regexSplit "/" "foo/bar/baz" -1 ) "foo" }}`: "[bar baz]", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestHas(t *testing.T) { + tests := map[string]string{ + `{{ list 1 2 3 | has 1 }}`: `true`, + `{{ list 1 2 3 | has 4 }}`: `false`, + `{{ regexSplit "/" "foo/bar/baz" -1 | has "bar" }}`: `true`, + `{{ has "bar" nil }}`: `false`, + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestMustHas(t *testing.T) { + tests := map[string]string{ + `{{ list 1 2 3 | mustHas 1 }}`: `true`, + `{{ list 1 2 3 | mustHas 4 }}`: `false`, + `{{ regexSplit "/" "foo/bar/baz" -1 | mustHas "bar" }}`: `true`, + `{{ mustHas "bar" nil }}`: `false`, + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestSlice(t *testing.T) { + tests := map[string]string{ + `{{ slice (list 1 2 3) }}`: "[1 2 3]", + `{{ slice (list 1 2 3) 0 1 }}`: "[1]", + `{{ slice (list 1 2 3) 1 3 }}`: "[2 3]", + `{{ slice (list 1 2 3) 1 }}`: "[2 3]", + `{{ slice (regexSplit "/" "foo/bar/baz" -1) 1 2 }}`: "[bar]", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestMustSlice(t *testing.T) { + tests := map[string]string{ + `{{ mustSlice (list 1 2 3) }}`: "[1 2 3]", + `{{ mustSlice (list 1 2 3) 0 1 }}`: "[1]", + `{{ mustSlice (list 1 2 3) 1 3 }}`: "[2 3]", + `{{ mustSlice (list 1 2 3) 1 }}`: "[2 3]", + `{{ mustSlice (regexSplit "/" "foo/bar/baz" -1) 1 2 }}`: "[bar]", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestConcat(t *testing.T) { + tests := map[string]string{ + `{{ concat (list 1 2 3) }}`: "[1 2 3]", + `{{ concat (list 1 2 3) (list 4 5) }}`: "[1 2 3 4 5]", + `{{ concat (list 1 2 3) (list 4 5) (list) }}`: "[1 2 3 4 5]", + `{{ concat (list 1 2 3) (list 4 5) (list nil) }}`: "[1 2 3 4 5 ]", + `{{ concat (list 1 2 3) (list 4 5) (list ( list "foo" ) ) }}`: "[1 2 3 4 5 [foo]]", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} diff --git a/util/sprig/numeric.go b/util/sprig/numeric.go new file mode 100644 index 00000000..7ee3616d --- /dev/null +++ b/util/sprig/numeric.go @@ -0,0 +1,499 @@ +package sprig + +import ( + "fmt" + "math" + "math/rand" + "reflect" + "strconv" + "strings" +) + +// toFloat64 converts a value to a 64-bit float. +// It handles various input types: +// - string: parsed as a float, returns 0 if parsing fails +// - integer types: converted to float64 +// - unsigned integer types: converted to float64 +// - float types: returned as is +// - bool: true becomes 1.0, false becomes 0.0 +// - other types: returns 0.0 +// +// Parameters: +// - v: The value to convert to float64 +// +// Returns: +// - float64: The converted value +func toFloat64(v any) float64 { + if str, ok := v.(string); ok { + iv, err := strconv.ParseFloat(str, 64) + if err != nil { + return 0 + } + return iv + } + + val := reflect.Indirect(reflect.ValueOf(v)) + switch val.Kind() { + case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int: + return float64(val.Int()) + case reflect.Uint8, reflect.Uint16, reflect.Uint32: + return float64(val.Uint()) + case reflect.Uint, reflect.Uint64: + return float64(val.Uint()) + case reflect.Float32, reflect.Float64: + return val.Float() + case reflect.Bool: + if val.Bool() { + return 1 + } + return 0 + default: + return 0 + } +} + +// toInt converts a value to a 32-bit integer. +// This is a wrapper around toInt64 that casts the result to int. +// +// Parameters: +// - v: The value to convert to int +// +// Returns: +// - int: The converted value +func toInt(v any) int { + // It's not optimal. But I don't want duplicate toInt64 code. + return int(toInt64(v)) +} + +// toInt64 converts a value to a 64-bit integer. +// It handles various input types: +// - string: parsed as an integer, returns 0 if parsing fails +// - integer types: converted to int64 +// - unsigned integer types: converted to int64 (values > MaxInt64 become MaxInt64) +// - float types: truncated to int64 +// - bool: true becomes 1, false becomes 0 +// - other types: returns 0 +func toInt64(v any) int64 { + if str, ok := v.(string); ok { + iv, err := strconv.ParseInt(str, 10, 64) + if err != nil { + return 0 + } + return iv + } + val := reflect.Indirect(reflect.ValueOf(v)) + switch val.Kind() { + case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int: + return val.Int() + case reflect.Uint8, reflect.Uint16, reflect.Uint32: + return int64(val.Uint()) + case reflect.Uint, reflect.Uint64: + tv := val.Uint() + if tv <= math.MaxInt64 { + return int64(tv) + } + // TODO: What is the sensible thing to do here? + return math.MaxInt64 + case reflect.Float32, reflect.Float64: + return int64(val.Float()) + case reflect.Bool: + if val.Bool() { + return 1 + } + return 0 + default: + return 0 + } +} + +// add1 increments a value by 1. +// The input is first converted to int64 using toInt64. +// +// Parameters: +// - i: The value to increment +// +// Returns: +// - int64: The incremented value +func add1(i any) int64 { + return toInt64(i) + 1 +} + +// add sums all the provided values. +// All inputs are converted to int64 using toInt64 before addition. +// +// Parameters: +// - i: A variadic list of values to sum +// +// Returns: +// - int64: The sum of all values +func add(i ...any) int64 { + var a int64 + for _, b := range i { + a += toInt64(b) + } + return a +} + +// sub subtracts the second value from the first. +// Both inputs are converted to int64 using toInt64 before subtraction. +// +// Parameters: +// - a: The value to subtract from +// - b: The value to subtract +// +// Returns: +// - int64: The result of a - b +func sub(a, b any) int64 { + return toInt64(a) - toInt64(b) +} + +// div divides the first value by the second. +// Both inputs are converted to int64 using toInt64 before division. +// Note: This performs integer division, so the result is truncated. +// +// Parameters: +// - a: The dividend +// - b: The divisor +// +// Returns: +// - int64: The result of a / b +// +// Panics: +// - If b evaluates to 0 (division by zero) +func div(a, b any) int64 { + return toInt64(a) / toInt64(b) +} + +// mod returns the remainder of dividing the first value by the second. +// Both inputs are converted to int64 using toInt64 before the modulo operation. +// +// Parameters: +// - a: The dividend +// - b: The divisor +// +// Returns: +// - int64: The remainder of a / b +// +// Panics: +// - If b evaluates to 0 (modulo by zero) +func mod(a, b any) int64 { + return toInt64(a) % toInt64(b) +} + +// mul multiplies all the provided values. +// All inputs are converted to int64 using toInt64 before multiplication. +// +// Parameters: +// - a: The first value to multiply +// - v: Additional values to multiply with a +// +// Returns: +// - int64: The product of all values +func mul(a any, v ...any) int64 { + val := toInt64(a) + for _, b := range v { + val = val * toInt64(b) + } + return val +} + +// randInt generates a random integer between min (inclusive) and max (exclusive). +// +// Parameters: +// - min: The lower bound (inclusive) +// - max: The upper bound (exclusive) +// +// Returns: +// - int: A random integer in the range [min, max) +// +// Panics: +// - If max <= min (via rand.Intn) +func randInt(min, max int) int { + return rand.Intn(max-min) + min +} + +// maxAsInt64 returns the maximum value from a list of values as an int64. +// All inputs are converted to int64 using toInt64 before comparison. +// +// Parameters: +// - a: The first value to compare +// - i: Additional values to compare +// +// Returns: +// - int64: The maximum value from all inputs +func maxAsInt64(a any, i ...any) int64 { + aa := toInt64(a) + for _, b := range i { + bb := toInt64(b) + if bb > aa { + aa = bb + } + } + return aa +} + +// maxAsFloat64 returns the maximum value from a list of values as a float64. +// All inputs are converted to float64 using toFloat64 before comparison. +// +// Parameters: +// - a: The first value to compare +// - i: Additional values to compare +// +// Returns: +// - float64: The maximum value from all inputs +func maxAsFloat64(a any, i ...any) float64 { + m := toFloat64(a) + for _, b := range i { + m = math.Max(m, toFloat64(b)) + } + return m +} + +// minAsInt64 returns the minimum value from a list of values as an int64. +// All inputs are converted to int64 using toInt64 before comparison. +// +// Parameters: +// - a: The first value to compare +// - i: Additional values to compare +// +// Returns: +// - int64: The minimum value from all inputs +func minAsInt64(a any, i ...any) int64 { + aa := toInt64(a) + for _, b := range i { + bb := toInt64(b) + if bb < aa { + aa = bb + } + } + return aa +} + +// minAsFloat64 returns the minimum value from a list of values as a float64. +// All inputs are converted to float64 using toFloat64 before comparison. +// +// Parameters: +// - a: The first value to compare +// - i: Additional values to compare +// +// Returns: +// - float64: The minimum value from all inputs +func minAsFloat64(a any, i ...any) float64 { + m := toFloat64(a) + for _, b := range i { + m = math.Min(m, toFloat64(b)) + } + return m +} + +// until generates a sequence of integers from 0 to count (exclusive). +// If count is negative, it generates a sequence from 0 to count (inclusive) with step -1. +// +// Parameters: +// - count: The end value (exclusive if positive, inclusive if negative) +// +// Returns: +// - []int: A slice containing the generated sequence +func until(count int) []int { + step := 1 + if count < 0 { + step = -1 + } + return untilStep(0, count, step) +} + +// untilStep generates a sequence of integers from start to stop with the specified step. +// The sequence is generated as follows: +// - If step is 0, returns an empty slice +// - If stop < start and step < 0, generates a decreasing sequence from start to stop (exclusive) +// - If stop > start and step > 0, generates an increasing sequence from start to stop (exclusive) +// - Otherwise, returns an empty slice +// +// Parameters: +// - start: The starting value (inclusive) +// - stop: The ending value (exclusive) +// - step: The increment between values +// +// Returns: +// - []int: A slice containing the generated sequence +// +// Panics: +// - If the number of iterations would exceed loopExecutionLimit +func untilStep(start, stop, step int) []int { + var v []int + if step == 0 { + return v + } + iterations := math.Abs(float64(stop)-float64(start)) / float64(step) + if iterations > loopExecutionLimit { + panic(fmt.Sprintf("too many iterations in untilStep; max allowed is %d, got %f", loopExecutionLimit, iterations)) + } + if stop < start { + if step >= 0 { + return v + } + for i := start; i > stop; i += step { + v = append(v, i) + } + return v + } + if step <= 0 { + return v + } + for i := start; i < stop; i += step { + v = append(v, i) + } + return v +} + +// floor returns the greatest integer value less than or equal to the input. +// The input is first converted to float64 using toFloat64. +// +// Parameters: +// - a: The value to floor +// +// Returns: +// - float64: The greatest integer value less than or equal to a +func floor(a any) float64 { + return math.Floor(toFloat64(a)) +} + +// ceil returns the least integer value greater than or equal to the input. +// The input is first converted to float64 using toFloat64. +// +// Parameters: +// - a: The value to ceil +// +// Returns: +// - float64: The least integer value greater than or equal to a +func ceil(a any) float64 { + return math.Ceil(toFloat64(a)) +} + +// round rounds a number to a specified number of decimal places. +// The input is first converted to float64 using toFloat64. +// +// Parameters: +// - a: The value to round +// - p: The number of decimal places to round to +// - rOpt: Optional rounding threshold (default is 0.5) +// +// Returns: +// - float64: The rounded value +// +// Examples: +// - round(3.14159, 2) returns 3.14 +// - round(3.14159, 2, 0.6) returns 3.14 (only rounds up if fraction ≥ 0.6) +func round(a any, p int, rOpt ...float64) float64 { + roundOn := .5 + if len(rOpt) > 0 { + roundOn = rOpt[0] + } + val := toFloat64(a) + places := toFloat64(p) + var round float64 + pow := math.Pow(10, places) + digit := pow * val + _, div := math.Modf(digit) + if div >= roundOn { + round = math.Ceil(digit) + } else { + round = math.Floor(digit) + } + return round / pow +} + +// toDecimal converts a value from octal to decimal. +// The input is first converted to a string using fmt.Sprint, then parsed as an octal number. +// If the parsing fails, it returns 0. +// +// Parameters: +// - v: The octal value to convert +// +// Returns: +// - int64: The decimal representation of the octal value +func toDecimal(v any) int64 { + result, err := strconv.ParseInt(fmt.Sprint(v), 8, 64) + if err != nil { + return 0 + } + return result +} + +// atoi converts a string to an integer. +// If the conversion fails, it returns 0. +// +// Parameters: +// - a: The string to convert +// +// Returns: +// - int: The integer value of the string +func atoi(a string) int { + i, _ := strconv.Atoi(a) + return i +} + +// seq generates a sequence of integers and returns them as a space-delimited string. +// The behavior depends on the number of parameters: +// - 0 params: Returns an empty string +// - 1 param: Generates sequence from 1 to param[0] +// - 2 params: Generates sequence from param[0] to param[1] +// - 3 params: Generates sequence from param[0] to param[2] with step param[1] +// +// If the end is less than the start, the sequence will be decreasing unless +// a positive step is explicitly provided (which would result in an empty string). +// +// Parameters: +// - params: Variable number of integers defining the sequence +// +// Returns: +// - string: A space-delimited string of the generated sequence +func seq(params ...int) string { + increment := 1 + switch len(params) { + case 0: + return "" + case 1: + start := 1 + end := params[0] + if end < start { + increment = -1 + } + return intArrayToString(untilStep(start, end+increment, increment), " ") + case 3: + start := params[0] + end := params[2] + step := params[1] + if end < start { + increment = -1 + if step > 0 { + return "" + } + } + return intArrayToString(untilStep(start, end+increment, step), " ") + case 2: + start := params[0] + end := params[1] + step := 1 + if end < start { + step = -1 + } + return intArrayToString(untilStep(start, end+step, step), " ") + default: + return "" + } +} + +// intArrayToString converts a slice of integers to a space-delimited string. +// The function removes the square brackets that would normally appear when +// converting a slice to a string. +// +// Parameters: +// - slice: The slice of integers to convert +// - delimiter: The delimiter to use between elements +// +// Returns: +// - string: A delimited string representation of the integer slice +func intArrayToString(slice []int, delimiter string) string { + return strings.Trim(strings.Join(strings.Fields(fmt.Sprint(slice)), delimiter), "[]") +} diff --git a/util/sprig/numeric_test.go b/util/sprig/numeric_test.go new file mode 100644 index 00000000..63310c52 --- /dev/null +++ b/util/sprig/numeric_test.go @@ -0,0 +1,307 @@ +package sprig + +import ( + "fmt" + "github.com/stretchr/testify/assert" + "strconv" + "testing" +) + +func TestUntil(t *testing.T) { + tests := map[string]string{ + `{{range $i, $e := until 5}}{{$i}}{{$e}}{{end}}`: "0011223344", + `{{range $i, $e := until -5}}{{$i}}{{$e}} {{end}}`: "00 1-1 2-2 3-3 4-4 ", + } + for tpl, expect := range tests { + if err := runt(tpl, expect); err != nil { + t.Error(err) + } + } +} +func TestUntilStep(t *testing.T) { + tests := map[string]string{ + `{{range $i, $e := untilStep 0 5 1}}{{$i}}{{$e}}{{end}}`: "0011223344", + `{{range $i, $e := untilStep 3 6 1}}{{$i}}{{$e}}{{end}}`: "031425", + `{{range $i, $e := untilStep 0 -10 -2}}{{$i}}{{$e}} {{end}}`: "00 1-2 2-4 3-6 4-8 ", + `{{range $i, $e := untilStep 3 0 1}}{{$i}}{{$e}}{{end}}`: "", + `{{range $i, $e := untilStep 3 99 0}}{{$i}}{{$e}}{{end}}`: "", + `{{range $i, $e := untilStep 3 99 -1}}{{$i}}{{$e}}{{end}}`: "", + `{{range $i, $e := untilStep 3 0 0}}{{$i}}{{$e}}{{end}}`: "", + } + for tpl, expect := range tests { + if err := runt(tpl, expect); err != nil { + t.Error(err) + } + } + +} +func TestBiggest(t *testing.T) { + tpl := `{{ biggest 1 2 3 345 5 6 7}}` + if err := runt(tpl, `345`); err != nil { + t.Error(err) + } + + tpl = `{{ max 345}}` + if err := runt(tpl, `345`); err != nil { + t.Error(err) + } +} +func TestMaxf(t *testing.T) { + tpl := `{{ maxf 1 2 3 345.7 5 6 7}}` + if err := runt(tpl, `345.7`); err != nil { + t.Error(err) + } + + tpl = `{{ max 345 }}` + if err := runt(tpl, `345`); err != nil { + t.Error(err) + } +} +func TestMin(t *testing.T) { + tpl := `{{ min 1 2 3 345 5 6 7}}` + if err := runt(tpl, `1`); err != nil { + t.Error(err) + } + + tpl = `{{ min 345}}` + if err := runt(tpl, `345`); err != nil { + t.Error(err) + } +} + +func TestMinf(t *testing.T) { + tpl := `{{ minf 1.4 2 3 345.6 5 6 7}}` + if err := runt(tpl, `1.4`); err != nil { + t.Error(err) + } + + tpl = `{{ minf 345 }}` + if err := runt(tpl, `345`); err != nil { + t.Error(err) + } +} + +func TestToFloat64(t *testing.T) { + target := float64(102) + if target != toFloat64(int8(102)) { + t.Errorf("Expected 102") + } + if target != toFloat64(int(102)) { + t.Errorf("Expected 102") + } + if target != toFloat64(int32(102)) { + t.Errorf("Expected 102") + } + if target != toFloat64(int16(102)) { + t.Errorf("Expected 102") + } + if target != toFloat64(int64(102)) { + t.Errorf("Expected 102") + } + if target != toFloat64("102") { + t.Errorf("Expected 102") + } + if toFloat64("frankie") != 0 { + t.Errorf("Expected 0") + } + if target != toFloat64(uint16(102)) { + t.Errorf("Expected 102") + } + if target != toFloat64(uint64(102)) { + t.Errorf("Expected 102") + } + if toFloat64(float64(102.1234)) != 102.1234 { + t.Errorf("Expected 102.1234") + } + if toFloat64(true) != 1 { + t.Errorf("Expected 102") + } +} +func TestToInt64(t *testing.T) { + target := int64(102) + if target != toInt64(int8(102)) { + t.Errorf("Expected 102") + } + if target != toInt64(int(102)) { + t.Errorf("Expected 102") + } + if target != toInt64(int32(102)) { + t.Errorf("Expected 102") + } + if target != toInt64(int16(102)) { + t.Errorf("Expected 102") + } + if target != toInt64(int64(102)) { + t.Errorf("Expected 102") + } + if target != toInt64("102") { + t.Errorf("Expected 102") + } + if toInt64("frankie") != 0 { + t.Errorf("Expected 0") + } + if target != toInt64(uint16(102)) { + t.Errorf("Expected 102") + } + if target != toInt64(uint64(102)) { + t.Errorf("Expected 102") + } + if target != toInt64(float64(102.1234)) { + t.Errorf("Expected 102") + } + if toInt64(true) != 1 { + t.Errorf("Expected 102") + } +} + +func TestToInt(t *testing.T) { + target := int(102) + if target != toInt(int8(102)) { + t.Errorf("Expected 102") + } + if target != toInt(int(102)) { + t.Errorf("Expected 102") + } + if target != toInt(int32(102)) { + t.Errorf("Expected 102") + } + if target != toInt(int16(102)) { + t.Errorf("Expected 102") + } + if target != toInt(int64(102)) { + t.Errorf("Expected 102") + } + if target != toInt("102") { + t.Errorf("Expected 102") + } + if toInt("frankie") != 0 { + t.Errorf("Expected 0") + } + if target != toInt(uint16(102)) { + t.Errorf("Expected 102") + } + if target != toInt(uint64(102)) { + t.Errorf("Expected 102") + } + if target != toInt(float64(102.1234)) { + t.Errorf("Expected 102") + } + if toInt(true) != 1 { + t.Errorf("Expected 102") + } +} + +func TestToDecimal(t *testing.T) { + tests := map[any]int64{ + "777": 511, + 777: 511, + 770: 504, + 755: 493, + } + + for input, expectedResult := range tests { + result := toDecimal(input) + if result != expectedResult { + t.Errorf("Expected %v but got %v", expectedResult, result) + } + } +} + +func TestAdd1(t *testing.T) { + tpl := `{{ 3 | add1 }}` + if err := runt(tpl, `4`); err != nil { + t.Error(err) + } +} + +func TestAdd(t *testing.T) { + tpl := `{{ 3 | add 1 2}}` + if err := runt(tpl, `6`); err != nil { + t.Error(err) + } +} + +func TestDiv(t *testing.T) { + tpl := `{{ 4 | div 5 }}` + if err := runt(tpl, `1`); err != nil { + t.Error(err) + } +} + +func TestMul(t *testing.T) { + tpl := `{{ 1 | mul "2" 3 "4"}}` + if err := runt(tpl, `24`); err != nil { + t.Error(err) + } +} + +func TestSub(t *testing.T) { + tpl := `{{ 3 | sub 14 }}` + if err := runt(tpl, `11`); err != nil { + t.Error(err) + } +} + +func TestCeil(t *testing.T) { + assert.Equal(t, 123.0, ceil(123)) + assert.Equal(t, 123.0, ceil("123")) + assert.Equal(t, 124.0, ceil(123.01)) + assert.Equal(t, 124.0, ceil("123.01")) +} + +func TestFloor(t *testing.T) { + assert.Equal(t, 123.0, floor(123)) + assert.Equal(t, 123.0, floor("123")) + assert.Equal(t, 123.0, floor(123.9999)) + assert.Equal(t, 123.0, floor("123.9999")) +} + +func TestRound(t *testing.T) { + assert.Equal(t, 123.556, round(123.5555, 3)) + assert.Equal(t, 123.556, round("123.55555", 3)) + assert.Equal(t, 124.0, round(123.500001, 0)) + assert.Equal(t, 123.0, round(123.49999999, 0)) + assert.Equal(t, 123.23, round(123.2329999, 2, .3)) + assert.Equal(t, 123.24, round(123.233, 2, .3)) +} + +func TestRandomInt(t *testing.T) { + var tests = []struct { + min int + max int + }{ + {10, 11}, + {10, 13}, + {0, 1}, + {5, 50}, + } + for _, v := range tests { + x, _ := runRaw(fmt.Sprintf(`{{ randInt %d %d }}`, v.min, v.max), nil) + r, err := strconv.Atoi(x) + assert.NoError(t, err) + assert.True(t, func(min, max, r int) bool { + return r >= v.min && r < v.max + }(v.min, v.max, r)) + } +} + +func TestSeq(t *testing.T) { + tests := map[string]string{ + `{{seq 0 1 3}}`: "0 1 2 3", + `{{seq 0 3 10}}`: "0 3 6 9", + `{{seq 3 3 2}}`: "", + `{{seq 3 -3 2}}`: "3", + `{{seq}}`: "", + `{{seq 0 4}}`: "0 1 2 3 4", + `{{seq 5}}`: "1 2 3 4 5", + `{{seq -5}}`: "1 0 -1 -2 -3 -4 -5", + `{{seq 0}}`: "1 0", + `{{seq 0 1 2 3}}`: "", + `{{seq 0 -4}}`: "0 -1 -2 -3 -4", + } + for tpl, expect := range tests { + if err := runt(tpl, expect); err != nil { + t.Error(err) + } + } +} diff --git a/util/sprig/reflect.go b/util/sprig/reflect.go new file mode 100644 index 00000000..6315a780 --- /dev/null +++ b/util/sprig/reflect.go @@ -0,0 +1,70 @@ +package sprig + +import ( + "fmt" + "reflect" +) + +// typeIs returns true if the src is the type named in target. +// It compares the type name of src with the target string. +// +// Parameters: +// - target: The type name to check against +// - src: The value whose type will be checked +// +// Returns: +// - bool: True if the type name of src matches target, false otherwise +func typeIs(target string, src any) bool { + return target == typeOf(src) +} + +// typeIsLike returns true if the src is the type named in target or a pointer to that type. +// This is useful when you need to check for both a type and a pointer to that type. +// +// Parameters: +// - target: The type name to check against +// - src: The value whose type will be checked +// +// Returns: +// - bool: True if the type of src matches target or "*"+target, false otherwise +func typeIsLike(target string, src any) bool { + t := typeOf(src) + return target == t || "*"+target == t +} + +// typeOf returns the type of a value as a string. +// It uses fmt.Sprintf with the %T format verb to get the type name. +// +// Parameters: +// - src: The value whose type name will be returned +// +// Returns: +// - string: The type name of src +func typeOf(src any) string { + return fmt.Sprintf("%T", src) +} + +// kindIs returns true if the kind of src matches the target kind. +// This checks the underlying kind (e.g., "string", "int", "map") rather than the specific type. +// +// Parameters: +// - target: The kind name to check against +// - src: The value whose kind will be checked +// +// Returns: +// - bool: True if the kind of src matches target, false otherwise +func kindIs(target string, src any) bool { + return target == kindOf(src) +} + +// kindOf returns the kind of a value as a string. +// The kind represents the specific Go type category (e.g., "string", "int", "map", "slice"). +// +// Parameters: +// - src: The value whose kind will be returned +// +// Returns: +// - string: The kind of src as a string +func kindOf(src any) string { + return reflect.ValueOf(src).Kind().String() +} diff --git a/util/sprig/reflect_test.go b/util/sprig/reflect_test.go new file mode 100644 index 00000000..f102907e --- /dev/null +++ b/util/sprig/reflect_test.go @@ -0,0 +1,73 @@ +package sprig + +import ( + "testing" +) + +type fixtureTO struct { + Name, Value string +} + +func TestTypeOf(t *testing.T) { + f := &fixtureTO{"hello", "world"} + tpl := `{{typeOf .}}` + if err := runtv(tpl, "*sprig.fixtureTO", f); err != nil { + t.Error(err) + } +} + +func TestKindOf(t *testing.T) { + tpl := `{{kindOf .}}` + + f := fixtureTO{"hello", "world"} + if err := runtv(tpl, "struct", f); err != nil { + t.Error(err) + } + + f2 := []string{"hello"} + if err := runtv(tpl, "slice", f2); err != nil { + t.Error(err) + } + + var f3 *fixtureTO + if err := runtv(tpl, "ptr", f3); err != nil { + t.Error(err) + } +} + +func TestTypeIs(t *testing.T) { + f := &fixtureTO{"hello", "world"} + tpl := `{{if typeIs "*sprig.fixtureTO" .}}t{{else}}f{{end}}` + if err := runtv(tpl, "t", f); err != nil { + t.Error(err) + } + + f2 := "hello" + if err := runtv(tpl, "f", f2); err != nil { + t.Error(err) + } +} +func TestTypeIsLike(t *testing.T) { + f := "foo" + tpl := `{{if typeIsLike "string" .}}t{{else}}f{{end}}` + if err := runtv(tpl, "t", f); err != nil { + t.Error(err) + } + + // Now make a pointer. Should still match. + f2 := &f + if err := runtv(tpl, "t", f2); err != nil { + t.Error(err) + } +} +func TestKindIs(t *testing.T) { + f := &fixtureTO{"hello", "world"} + tpl := `{{if kindIs "ptr" .}}t{{else}}f{{end}}` + if err := runtv(tpl, "t", f); err != nil { + t.Error(err) + } + f2 := "hello" + if err := runtv(tpl, "f", f2); err != nil { + t.Error(err) + } +} diff --git a/util/sprig/regex.go b/util/sprig/regex.go new file mode 100644 index 00000000..9853d2e1 --- /dev/null +++ b/util/sprig/regex.go @@ -0,0 +1,217 @@ +package sprig + +import ( + "regexp" +) + +// regexMatch checks if a string matches a regular expression pattern. +// It ignores any errors that might occur during regex compilation. +// +// Parameters: +// - regex: The regular expression pattern to match against +// - s: The string to check +// +// Returns: +// - bool: True if the string matches the pattern, false otherwise +func regexMatch(regex string, s string) bool { + match, _ := regexp.MatchString(regex, s) + return match +} + +// mustRegexMatch checks if a string matches a regular expression pattern. +// Unlike regexMatch, this function returns any errors that occur during regex compilation. +// +// Parameters: +// - regex: The regular expression pattern to match against +// - s: The string to check +// +// Returns: +// - bool: True if the string matches the pattern, false otherwise +// - error: Any error that occurred during regex compilation +func mustRegexMatch(regex string, s string) (bool, error) { + return regexp.MatchString(regex, s) +} + +// regexFindAll finds all matches of a regular expression in a string. +// It panics if the regex pattern cannot be compiled. +// +// Parameters: +// - regex: The regular expression pattern to search for +// - s: The string to search within +// - n: The maximum number of matches to return (negative means all matches) +// +// Returns: +// - []string: A slice containing all matched substrings +func regexFindAll(regex string, s string, n int) []string { + r := regexp.MustCompile(regex) + return r.FindAllString(s, n) +} + +// mustRegexFindAll finds all matches of a regular expression in a string. +// Unlike regexFindAll, this function returns any errors that occur during regex compilation. +// +// Parameters: +// - regex: The regular expression pattern to search for +// - s: The string to search within +// - n: The maximum number of matches to return (negative means all matches) +// +// Returns: +// - []string: A slice containing all matched substrings +// - error: Any error that occurred during regex compilation +func mustRegexFindAll(regex string, s string, n int) ([]string, error) { + r, err := regexp.Compile(regex) + if err != nil { + return []string{}, err + } + return r.FindAllString(s, n), nil +} + +// regexFind finds the first match of a regular expression in a string. +// It panics if the regex pattern cannot be compiled. +// +// Parameters: +// - regex: The regular expression pattern to search for +// - s: The string to search within +// +// Returns: +// - string: The first matched substring, or an empty string if no match +func regexFind(regex string, s string) string { + r := regexp.MustCompile(regex) + return r.FindString(s) +} + +// mustRegexFind finds the first match of a regular expression in a string. +// Unlike regexFind, this function returns any errors that occur during regex compilation. +// +// Parameters: +// - regex: The regular expression pattern to search for +// - s: The string to search within +// +// Returns: +// - string: The first matched substring, or an empty string if no match +// - error: Any error that occurred during regex compilation +func mustRegexFind(regex string, s string) (string, error) { + r, err := regexp.Compile(regex) + if err != nil { + return "", err + } + return r.FindString(s), nil +} + +// regexReplaceAll replaces all matches of a regular expression with a replacement string. +// It panics if the regex pattern cannot be compiled. +// The replacement string can contain $1, $2, etc. for submatches. +// +// Parameters: +// - regex: The regular expression pattern to search for +// - s: The string to search within +// - repl: The replacement string (can contain $1, $2, etc. for submatches) +// +// Returns: +// - string: The resulting string after all replacements +func regexReplaceAll(regex string, s string, repl string) string { + r := regexp.MustCompile(regex) + return r.ReplaceAllString(s, repl) +} + +// mustRegexReplaceAll replaces all matches of a regular expression with a replacement string. +// Unlike regexReplaceAll, this function returns any errors that occur during regex compilation. +// The replacement string can contain $1, $2, etc. for submatches. +// +// Parameters: +// - regex: The regular expression pattern to search for +// - s: The string to search within +// - repl: The replacement string (can contain $1, $2, etc. for submatches) +// +// Returns: +// - string: The resulting string after all replacements +// - error: Any error that occurred during regex compilation +func mustRegexReplaceAll(regex string, s string, repl string) (string, error) { + r, err := regexp.Compile(regex) + if err != nil { + return "", err + } + return r.ReplaceAllString(s, repl), nil +} + +// regexReplaceAllLiteral replaces all matches of a regular expression with a literal replacement string. +// It panics if the regex pattern cannot be compiled. +// Unlike regexReplaceAll, the replacement string is used literally (no $1, $2 processing). +// +// Parameters: +// - regex: The regular expression pattern to search for +// - s: The string to search within +// - repl: The literal replacement string +// +// Returns: +// - string: The resulting string after all replacements +func regexReplaceAllLiteral(regex string, s string, repl string) string { + r := regexp.MustCompile(regex) + return r.ReplaceAllLiteralString(s, repl) +} + +// mustRegexReplaceAllLiteral replaces all matches of a regular expression with a literal replacement string. +// Unlike regexReplaceAllLiteral, this function returns any errors that occur during regex compilation. +// The replacement string is used literally (no $1, $2 processing). +// +// Parameters: +// - regex: The regular expression pattern to search for +// - s: The string to search within +// - repl: The literal replacement string +// +// Returns: +// - string: The resulting string after all replacements +// - error: Any error that occurred during regex compilation +func mustRegexReplaceAllLiteral(regex string, s string, repl string) (string, error) { + r, err := regexp.Compile(regex) + if err != nil { + return "", err + } + return r.ReplaceAllLiteralString(s, repl), nil +} + +// regexSplit splits a string by a regular expression pattern. +// It panics if the regex pattern cannot be compiled. +// +// Parameters: +// - regex: The regular expression pattern to split on +// - s: The string to split +// - n: The maximum number of substrings to return (negative means all substrings) +// +// Returns: +// - []string: A slice containing the substrings between regex matches +func regexSplit(regex string, s string, n int) []string { + r := regexp.MustCompile(regex) + return r.Split(s, n) +} + +// mustRegexSplit splits a string by a regular expression pattern. +// Unlike regexSplit, this function returns any errors that occur during regex compilation. +// +// Parameters: +// - regex: The regular expression pattern to split on +// - s: The string to split +// - n: The maximum number of substrings to return (negative means all substrings) +// +// Returns: +// - []string: A slice containing the substrings between regex matches +// - error: Any error that occurred during regex compilation +func mustRegexSplit(regex string, s string, n int) ([]string, error) { + r, err := regexp.Compile(regex) + if err != nil { + return []string{}, err + } + return r.Split(s, n), nil +} + +// regexQuoteMeta escapes all regular expression metacharacters in a string. +// This is useful when you want to use a string as a literal in a regular expression. +// +// Parameters: +// - s: The string to escape +// +// Returns: +// - string: The escaped string with all regex metacharacters quoted +func regexQuoteMeta(s string) string { + return regexp.QuoteMeta(s) +} diff --git a/util/sprig/regex_test.go b/util/sprig/regex_test.go new file mode 100644 index 00000000..60aafc29 --- /dev/null +++ b/util/sprig/regex_test.go @@ -0,0 +1,203 @@ +package sprig + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRegexMatch(t *testing.T) { + regex := "[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}" + + assert.True(t, regexMatch(regex, "test@acme.com")) + assert.True(t, regexMatch(regex, "Test@Acme.Com")) + assert.False(t, regexMatch(regex, "test")) + assert.False(t, regexMatch(regex, "test.com")) + assert.False(t, regexMatch(regex, "test@acme")) +} + +func TestMustRegexMatch(t *testing.T) { + regex := "[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}" + + o, err := mustRegexMatch(regex, "test@acme.com") + assert.True(t, o) + assert.Nil(t, err) + + o, err = mustRegexMatch(regex, "Test@Acme.Com") + assert.True(t, o) + assert.Nil(t, err) + + o, err = mustRegexMatch(regex, "test") + assert.False(t, o) + assert.Nil(t, err) + + o, err = mustRegexMatch(regex, "test.com") + assert.False(t, o) + assert.Nil(t, err) + + o, err = mustRegexMatch(regex, "test@acme") + assert.False(t, o) + assert.Nil(t, err) +} + +func TestRegexFindAll(t *testing.T) { + regex := "a{2}" + assert.Equal(t, 1, len(regexFindAll(regex, "aa", -1))) + assert.Equal(t, 1, len(regexFindAll(regex, "aaaaaaaa", 1))) + assert.Equal(t, 2, len(regexFindAll(regex, "aaaa", -1))) + assert.Equal(t, 0, len(regexFindAll(regex, "none", -1))) +} + +func TestMustRegexFindAll(t *testing.T) { + type args struct { + regex, s string + n int + } + cases := []struct { + expected int + args args + }{ + {1, args{"a{2}", "aa", -1}}, + {1, args{"a{2}", "aaaaaaaa", 1}}, + {2, args{"a{2}", "aaaa", -1}}, + {0, args{"a{2}", "none", -1}}, + } + + for _, c := range cases { + res, err := mustRegexFindAll(c.args.regex, c.args.s, c.args.n) + if err != nil { + t.Errorf("regexFindAll test case %v failed with err %s", c, err) + } + assert.Equal(t, c.expected, len(res), "case %#v", c.args) + } +} + +func TestRegexFindl(t *testing.T) { + regex := "fo.?" + assert.Equal(t, "foo", regexFind(regex, "foorbar")) + assert.Equal(t, "foo", regexFind(regex, "foo foe fome")) + assert.Equal(t, "", regexFind(regex, "none")) +} + +func TestMustRegexFindl(t *testing.T) { + type args struct{ regex, s string } + cases := []struct { + expected string + args args + }{ + {"foo", args{"fo.?", "foorbar"}}, + {"foo", args{"fo.?", "foo foe fome"}}, + {"", args{"fo.?", "none"}}, + } + + for _, c := range cases { + res, err := mustRegexFind(c.args.regex, c.args.s) + if err != nil { + t.Errorf("regexFind test case %v failed with err %s", c, err) + } + assert.Equal(t, c.expected, res, "case %#v", c.args) + } +} + +func TestRegexReplaceAll(t *testing.T) { + regex := "a(x*)b" + assert.Equal(t, "-T-T-", regexReplaceAll(regex, "-ab-axxb-", "T")) + assert.Equal(t, "--xx-", regexReplaceAll(regex, "-ab-axxb-", "$1")) + assert.Equal(t, "---", regexReplaceAll(regex, "-ab-axxb-", "$1W")) + assert.Equal(t, "-W-xxW-", regexReplaceAll(regex, "-ab-axxb-", "${1}W")) +} + +func TestMustRegexReplaceAll(t *testing.T) { + type args struct{ regex, s, repl string } + cases := []struct { + expected string + args args + }{ + {"-T-T-", args{"a(x*)b", "-ab-axxb-", "T"}}, + {"--xx-", args{"a(x*)b", "-ab-axxb-", "$1"}}, + {"---", args{"a(x*)b", "-ab-axxb-", "$1W"}}, + {"-W-xxW-", args{"a(x*)b", "-ab-axxb-", "${1}W"}}, + } + + for _, c := range cases { + res, err := mustRegexReplaceAll(c.args.regex, c.args.s, c.args.repl) + if err != nil { + t.Errorf("regexReplaceAll test case %v failed with err %s", c, err) + } + assert.Equal(t, c.expected, res, "case %#v", c.args) + } +} + +func TestRegexReplaceAllLiteral(t *testing.T) { + regex := "a(x*)b" + assert.Equal(t, "-T-T-", regexReplaceAllLiteral(regex, "-ab-axxb-", "T")) + assert.Equal(t, "-$1-$1-", regexReplaceAllLiteral(regex, "-ab-axxb-", "$1")) + assert.Equal(t, "-${1}-${1}-", regexReplaceAllLiteral(regex, "-ab-axxb-", "${1}")) +} + +func TestMustRegexReplaceAllLiteral(t *testing.T) { + type args struct{ regex, s, repl string } + cases := []struct { + expected string + args args + }{ + {"-T-T-", args{"a(x*)b", "-ab-axxb-", "T"}}, + {"-$1-$1-", args{"a(x*)b", "-ab-axxb-", "$1"}}, + {"-${1}-${1}-", args{"a(x*)b", "-ab-axxb-", "${1}"}}, + } + + for _, c := range cases { + res, err := mustRegexReplaceAllLiteral(c.args.regex, c.args.s, c.args.repl) + if err != nil { + t.Errorf("regexReplaceAllLiteral test case %v failed with err %s", c, err) + } + assert.Equal(t, c.expected, res, "case %#v", c.args) + } +} + +func TestRegexSplit(t *testing.T) { + regex := "a" + assert.Equal(t, 4, len(regexSplit(regex, "banana", -1))) + assert.Equal(t, 0, len(regexSplit(regex, "banana", 0))) + assert.Equal(t, 1, len(regexSplit(regex, "banana", 1))) + assert.Equal(t, 2, len(regexSplit(regex, "banana", 2))) + + regex = "z+" + assert.Equal(t, 2, len(regexSplit(regex, "pizza", -1))) + assert.Equal(t, 0, len(regexSplit(regex, "pizza", 0))) + assert.Equal(t, 1, len(regexSplit(regex, "pizza", 1))) + assert.Equal(t, 2, len(regexSplit(regex, "pizza", 2))) +} + +func TestMustRegexSplit(t *testing.T) { + type args struct { + regex, s string + n int + } + cases := []struct { + expected int + args args + }{ + {4, args{"a", "banana", -1}}, + {0, args{"a", "banana", 0}}, + {1, args{"a", "banana", 1}}, + {2, args{"a", "banana", 2}}, + {2, args{"z+", "pizza", -1}}, + {0, args{"z+", "pizza", 0}}, + {1, args{"z+", "pizza", 1}}, + {2, args{"z+", "pizza", 2}}, + } + + for _, c := range cases { + res, err := mustRegexSplit(c.args.regex, c.args.s, c.args.n) + if err != nil { + t.Errorf("regexSplit test case %v failed with err %s", c, err) + } + assert.Equal(t, c.expected, len(res), "case %#v", c.args) + } +} + +func TestRegexQuoteMeta(t *testing.T) { + assert.Equal(t, "1\\.2\\.3", regexQuoteMeta("1.2.3")) + assert.Equal(t, "pretzel", regexQuoteMeta("pretzel")) +} diff --git a/util/sprig/strings.go b/util/sprig/strings.go new file mode 100644 index 00000000..e64f82d9 --- /dev/null +++ b/util/sprig/strings.go @@ -0,0 +1,487 @@ +package sprig + +import ( + "encoding/base32" + "encoding/base64" + "fmt" + "golang.org/x/text/cases" + "golang.org/x/text/language" + "reflect" + "strconv" + "strings" +) + +// base64encode encodes a string to base64 using standard encoding. +// +// Parameters: +// - v: The string to encode +// +// Returns: +// - string: The base64 encoded string +func base64encode(v string) string { + return base64.StdEncoding.EncodeToString([]byte(v)) +} + +// base64decode decodes a base64 encoded string. +// If the input is not valid base64, it returns the error message as a string. +// +// Parameters: +// - v: The base64 encoded string to decode +// +// Returns: +// - string: The decoded string, or an error message if decoding fails +func base64decode(v string) string { + data, err := base64.StdEncoding.DecodeString(v) + if err != nil { + return err.Error() + } + return string(data) +} + +// base32encode encodes a string to base32 using standard encoding. +// +// Parameters: +// - v: The string to encode +// +// Returns: +// - string: The base32 encoded string +func base32encode(v string) string { + return base32.StdEncoding.EncodeToString([]byte(v)) +} + +// base32decode decodes a base32 encoded string. +// If the input is not valid base32, it returns the error message as a string. +// +// Parameters: +// - v: The base32 encoded string to decode +// +// Returns: +// - string: The decoded string, or an error message if decoding fails +func base32decode(v string) string { + data, err := base32.StdEncoding.DecodeString(v) + if err != nil { + return err.Error() + } + return string(data) +} + +// quote adds double quotes around each non-nil string in the input and joins them with spaces. +// This uses Go's %q formatter which handles escaping special characters. +// +// Parameters: +// - str: A variadic list of values to quote +// +// Returns: +// - string: The quoted strings joined with spaces +func quote(str ...any) string { + out := make([]string, 0, len(str)) + for _, s := range str { + if s != nil { + out = append(out, fmt.Sprintf("%q", strval(s))) + } + } + return strings.Join(out, " ") +} + +// squote adds single quotes around each non-nil value in the input and joins them with spaces. +// Unlike quote, this doesn't escape special characters. +// +// Parameters: +// - str: A variadic list of values to quote +// +// Returns: +// - string: The single-quoted values joined with spaces +func squote(str ...any) string { + out := make([]string, 0, len(str)) + for _, s := range str { + if s != nil { + out = append(out, fmt.Sprintf("'%v'", s)) + } + } + return strings.Join(out, " ") +} + +// cat concatenates all non-nil values into a single string. +// Nil values are removed before concatenation. +// +// Parameters: +// - v: A variadic list of values to concatenate +// +// Returns: +// - string: The concatenated string +func cat(v ...any) string { + v = removeNilElements(v) + r := strings.TrimSpace(strings.Repeat("%v ", len(v))) + return fmt.Sprintf(r, v...) +} + +// indent adds a specified number of spaces at the beginning of each line in a string. +// +// Parameters: +// - spaces: The number of spaces to add +// - v: The string to indent +// +// Returns: +// - string: The indented string +func indent(spaces int, v string) string { + pad := strings.Repeat(" ", spaces) + return pad + strings.Replace(v, "\n", "\n"+pad, -1) +} + +// nindent adds a newline followed by an indented string. +// It's a shorthand for "\n" + indent(spaces, v). +// +// Parameters: +// - spaces: The number of spaces to add +// - v: The string to indent +// +// Returns: +// - string: A newline followed by the indented string +func nindent(spaces int, v string) string { + return "\n" + indent(spaces, v) +} + +// replace replaces all occurrences of a substring with another substring. +// +// Parameters: +// - old: The substring to replace +// - new: The replacement substring +// - src: The source string +// +// Returns: +// - string: The resulting string after all replacements +func replace(old, new, src string) string { + return strings.Replace(src, old, new, -1) +} + +// plural returns the singular or plural form of a word based on the count. +// If count is 1, it returns the singular form, otherwise it returns the plural form. +// +// Parameters: +// - one: The singular form of the word +// - many: The plural form of the word +// - count: The count to determine which form to use +// +// Returns: +// - string: Either the singular or plural form based on the count +func plural(one, many string, count int) string { + if count == 1 { + return one + } + return many +} + +// strslice converts a value to a slice of strings. +// It handles various input types: +// - []string: returned as is +// - []any: converted to []string, skipping nil values +// - arrays and slices: converted to []string, skipping nil values +// - nil: returns an empty slice +// - anything else: returns a single-element slice with the string representation +// +// Parameters: +// - v: The value to convert to a string slice +// +// Returns: +// - []string: A slice of strings +func strslice(v any) []string { + switch v := v.(type) { + case []string: + return v + case []any: + b := make([]string, 0, len(v)) + for _, s := range v { + if s != nil { + b = append(b, strval(s)) + } + } + return b + default: + val := reflect.ValueOf(v) + switch val.Kind() { + case reflect.Array, reflect.Slice: + l := val.Len() + b := make([]string, 0, l) + for i := 0; i < l; i++ { + value := val.Index(i).Interface() + if value != nil { + b = append(b, strval(value)) + } + } + return b + default: + if v == nil { + return []string{} + } + + return []string{strval(v)} + } + } +} + +// removeNilElements creates a new slice with all nil elements removed. +// This is a helper function used by other functions like cat. +// +// Parameters: +// - v: The slice to process +// +// Returns: +// - []any: A new slice with all nil elements removed +func removeNilElements(v []any) []any { + newSlice := make([]any, 0, len(v)) + for _, i := range v { + if i != nil { + newSlice = append(newSlice, i) + } + } + return newSlice +} + +// strval converts any value to a string. +// It handles various types: +// - string: returned as is +// - []byte: converted to string +// - error: returns the error message +// - fmt.Stringer: calls the String() method +// - anything else: uses fmt.Sprintf("%v", v) +// +// Parameters: +// - v: The value to convert to a string +// +// Returns: +// - string: The string representation of the value +func strval(v any) string { + switch v := v.(type) { + case string: + return v + case []byte: + return string(v) + case error: + return v.Error() + case fmt.Stringer: + return v.String() + default: + return fmt.Sprintf("%v", v) + } +} + +// trunc truncates a string to a specified length. +// If c is positive, it returns the first c characters. +// If c is negative, it returns the last |c| characters. +// If the string is shorter than the requested length, it returns the original string. +// +// Parameters: +// - c: The number of characters to keep (positive from start, negative from end) +// - s: The string to truncate +// +// Returns: +// - string: The truncated string +func trunc(c int, s string) string { + if c < 0 && len(s)+c > 0 { + return s[len(s)+c:] + } + if c >= 0 && len(s) > c { + return s[:c] + } + return s +} + +// title converts a string to title case. +// This uses the English language rules for capitalization. +// +// Parameters: +// - s: The string to convert +// +// Returns: +// - string: The string in title case +func title(s string) string { + return cases.Title(language.English).String(s) +} + +// join concatenates the elements of a slice with a separator. +// The input is first converted to a string slice using strslice. +// +// Parameters: +// - sep: The separator to use between elements +// - v: The value to join (will be converted to a string slice) +// +// Returns: +// - string: The joined string +func join(sep string, v any) string { + return strings.Join(strslice(v), sep) +} + +// split splits a string by a separator and returns a map. +// The keys in the map are "_0", "_1", etc., corresponding to the position of each part. +// +// Parameters: +// - sep: The separator to split on +// - orig: The string to split +// +// Returns: +// - map[string]string: A map with keys "_0", "_1", etc. and values being the split parts +func split(sep, orig string) map[string]string { + parts := strings.Split(orig, sep) + res := make(map[string]string, len(parts)) + for i, v := range parts { + res["_"+strconv.Itoa(i)] = v + } + return res +} + +// splitList splits a string by a separator and returns a slice. +// This is a simple wrapper around strings.Split. +// +// Parameters: +// - sep: The separator to split on +// - orig: The string to split +// +// Returns: +// - []string: A slice containing the split parts +func splitList(sep, orig string) []string { + return strings.Split(orig, sep) +} + +// splitn splits a string by a separator with a limit and returns a map. +// The keys in the map are "_0", "_1", etc., corresponding to the position of each part. +// It will split the string into at most n parts. +// +// Parameters: +// - sep: The separator to split on +// - n: The maximum number of parts to return +// - orig: The string to split +// +// Returns: +// - map[string]string: A map with keys "_0", "_1", etc. and values being the split parts +func splitn(sep string, n int, orig string) map[string]string { + parts := strings.SplitN(orig, sep, n) + res := make(map[string]string, len(parts)) + for i, v := range parts { + res["_"+strconv.Itoa(i)] = v + } + return res +} + +// substring creates a substring of the given string. +// It extracts a portion of a string based on start and end indices. +// +// Parameters: +// - start: The starting index (inclusive) +// - end: The ending index (exclusive) +// - s: The source string +// +// Behavior: +// - If start < 0, returns s[:end] +// - If start >= 0 and end < 0 or end > len(s), returns s[start:] +// - Otherwise, returns s[start:end] +// +// Returns: +// - string: The extracted substring +func substring(start, end int, s string) string { + if start < 0 { + return s[:end] + } + if end < 0 || end > len(s) { + return s[start:] + } + return s[start:end] +} + +// repeat creates a new string by repeating the input string a specified number of times. +// It has safety limits to prevent excessive memory usage or infinite loops. +// +// Parameters: +// - count: The number of times to repeat the string +// - str: The string to repeat +// +// Returns: +// - string: The repeated string +// +// Panics: +// - If count exceeds loopExecutionLimit +// - If the resulting string length would exceed stringLengthLimit +func repeat(count int, str string) string { + if count > loopExecutionLimit { + panic(fmt.Sprintf("repeat count %d exceeds limit of %d", count, loopExecutionLimit)) + } else if count*len(str) >= stringLengthLimit { + panic(fmt.Sprintf("repeat count %d with string length %d exceeds limit of %d", count, len(str), stringLengthLimit)) + } + return strings.Repeat(str, count) +} + +// trimAll removes all leading and trailing characters contained in the cutset. +// Note that the parameter order is reversed from the standard strings.Trim function. +// +// Parameters: +// - a: The cutset of characters to remove +// - b: The string to trim +// +// Returns: +// - string: The trimmed string +func trimAll(a, b string) string { + return strings.Trim(b, a) +} + +// trimPrefix removes the specified prefix from a string. +// If the string doesn't start with the prefix, it returns the original string. +// Note that the parameter order is reversed from the standard strings.TrimPrefix function. +// +// Parameters: +// - a: The prefix to remove +// - b: The string to trim +// +// Returns: +// - string: The string with the prefix removed, or the original string if it doesn't start with the prefix +func trimPrefix(a, b string) string { + return strings.TrimPrefix(b, a) +} + +// trimSuffix removes the specified suffix from a string. +// If the string doesn't end with the suffix, it returns the original string. +// Note that the parameter order is reversed from the standard strings.TrimSuffix function. +// +// Parameters: +// - a: The suffix to remove +// - b: The string to trim +// +// Returns: +// - string: The string with the suffix removed, or the original string if it doesn't end with the suffix +func trimSuffix(a, b string) string { + return strings.TrimSuffix(b, a) +} + +// contains checks if a string contains a substring. +// +// Parameters: +// - substr: The substring to search for +// - str: The string to search in +// +// Returns: +// - bool: True if str contains substr, false otherwise +func contains(substr string, str string) bool { + return strings.Contains(str, substr) +} + +// hasPrefix checks if a string starts with a specified prefix. +// +// Parameters: +// - substr: The prefix to check for +// - str: The string to check +// +// Returns: +// - bool: True if str starts with substr, false otherwise +func hasPrefix(substr string, str string) bool { + return strings.HasPrefix(str, substr) +} + +// hasSuffix checks if a string ends with a specified suffix. +// +// Parameters: +// - substr: The suffix to check for +// - str: The string to check +// +// Returns: +// - bool: True if str ends with substr, false otherwise +func hasSuffix(substr string, str string) bool { + return strings.HasSuffix(str, substr) +} diff --git a/util/sprig/strings_test.go b/util/sprig/strings_test.go new file mode 100644 index 00000000..1e91d9b2 --- /dev/null +++ b/util/sprig/strings_test.go @@ -0,0 +1,233 @@ +package sprig + +import ( + "encoding/base32" + "encoding/base64" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSubstr(t *testing.T) { + tpl := `{{"fooo" | substr 0 3 }}` + if err := runt(tpl, "foo"); err != nil { + t.Error(err) + } +} + +func TestSubstr_shorterString(t *testing.T) { + tpl := `{{"foo" | substr 0 10 }}` + if err := runt(tpl, "foo"); err != nil { + t.Error(err) + } +} + +func TestTrunc(t *testing.T) { + tpl := `{{ "foooooo" | trunc 3 }}` + if err := runt(tpl, "foo"); err != nil { + t.Error(err) + } + tpl = `{{ "baaaaaar" | trunc -3 }}` + if err := runt(tpl, "aar"); err != nil { + t.Error(err) + } + tpl = `{{ "baaaaaar" | trunc -999 }}` + if err := runt(tpl, "baaaaaar"); err != nil { + t.Error(err) + } + tpl = `{{ "baaaaaz" | trunc 0 }}` + if err := runt(tpl, ""); err != nil { + t.Error(err) + } +} + +func TestQuote(t *testing.T) { + tpl := `{{quote "a" "b" "c"}}` + if err := runt(tpl, `"a" "b" "c"`); err != nil { + t.Error(err) + } + tpl = `{{quote "\"a\"" "b" "c"}}` + if err := runt(tpl, `"\"a\"" "b" "c"`); err != nil { + t.Error(err) + } + tpl = `{{quote 1 2 3 }}` + if err := runt(tpl, `"1" "2" "3"`); err != nil { + t.Error(err) + } + tpl = `{{ .value | quote }}` + values := map[string]any{"value": nil} + if err := runtv(tpl, ``, values); err != nil { + t.Error(err) + } +} +func TestSquote(t *testing.T) { + tpl := `{{squote "a" "b" "c"}}` + if err := runt(tpl, `'a' 'b' 'c'`); err != nil { + t.Error(err) + } + tpl = `{{squote 1 2 3 }}` + if err := runt(tpl, `'1' '2' '3'`); err != nil { + t.Error(err) + } + tpl = `{{ .value | squote }}` + values := map[string]any{"value": nil} + if err := runtv(tpl, ``, values); err != nil { + t.Error(err) + } +} + +func TestContains(t *testing.T) { + // Mainly, we're just verifying the paramater order swap. + tests := []string{ + `{{if contains "cat" "fair catch"}}1{{end}}`, + `{{if hasPrefix "cat" "catch"}}1{{end}}`, + `{{if hasSuffix "cat" "ducat"}}1{{end}}`, + } + for _, tt := range tests { + if err := runt(tt, "1"); err != nil { + t.Error(err) + } + } +} + +func TestTrim(t *testing.T) { + tests := []string{ + `{{trim " 5.00 "}}`, + `{{trimAll "$" "$5.00$"}}`, + `{{trimPrefix "$" "$5.00"}}`, + `{{trimSuffix "$" "5.00$"}}`, + } + for _, tt := range tests { + if err := runt(tt, "5.00"); err != nil { + t.Error(err) + } + } +} + +func TestSplit(t *testing.T) { + tpl := `{{$v := "foo$bar$baz" | split "$"}}{{$v._0}}` + if err := runt(tpl, "foo"); err != nil { + t.Error(err) + } +} + +func TestSplitn(t *testing.T) { + tpl := `{{$v := "foo$bar$baz" | splitn "$" 2}}{{$v._0}}` + if err := runt(tpl, "foo"); err != nil { + t.Error(err) + } +} + +func TestToString(t *testing.T) { + tpl := `{{ toString 1 | kindOf }}` + assert.NoError(t, runt(tpl, "string")) +} + +func TestToStrings(t *testing.T) { + tpl := `{{ $s := list 1 2 3 | toStrings }}{{ index $s 1 | kindOf }}` + assert.NoError(t, runt(tpl, "string")) + tpl = `{{ list 1 .value 2 | toStrings }}` + values := map[string]any{"value": nil} + if err := runtv(tpl, `[1 2]`, values); err != nil { + t.Error(err) + } +} + +func TestJoin(t *testing.T) { + assert.NoError(t, runt(`{{ tuple "a" "b" "c" | join "-" }}`, "a-b-c")) + assert.NoError(t, runt(`{{ tuple 1 2 3 | join "-" }}`, "1-2-3")) + assert.NoError(t, runtv(`{{ join "-" .V }}`, "a-b-c", map[string]any{"V": []string{"a", "b", "c"}})) + assert.NoError(t, runtv(`{{ join "-" .V }}`, "abc", map[string]any{"V": "abc"})) + assert.NoError(t, runtv(`{{ join "-" .V }}`, "1-2-3", map[string]any{"V": []int{1, 2, 3}})) + assert.NoError(t, runtv(`{{ join "-" .value }}`, "1-2", map[string]any{"value": []any{"1", nil, "2"}})) +} + +func TestSortAlpha(t *testing.T) { + // Named `append` in the function map + tests := map[string]string{ + `{{ list "c" "a" "b" | sortAlpha | join "" }}`: "abc", + `{{ list 2 1 4 3 | sortAlpha | join "" }}`: "1234", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} +func TestBase64EncodeDecode(t *testing.T) { + magicWord := "coffee" + expect := base64.StdEncoding.EncodeToString([]byte(magicWord)) + + if expect == magicWord { + t.Fatal("Encoder doesn't work.") + } + + tpl := `{{b64enc "coffee"}}` + if err := runt(tpl, expect); err != nil { + t.Error(err) + } + tpl = fmt.Sprintf("{{b64dec %q}}", expect) + if err := runt(tpl, magicWord); err != nil { + t.Error(err) + } +} +func TestBase32EncodeDecode(t *testing.T) { + magicWord := "coffee" + expect := base32.StdEncoding.EncodeToString([]byte(magicWord)) + + if expect == magicWord { + t.Fatal("Encoder doesn't work.") + } + + tpl := `{{b32enc "coffee"}}` + if err := runt(tpl, expect); err != nil { + t.Error(err) + } + tpl = fmt.Sprintf("{{b32dec %q}}", expect) + if err := runt(tpl, magicWord); err != nil { + t.Error(err) + } +} + +func TestCat(t *testing.T) { + tpl := `{{$b := "b"}}{{"c" | cat "a" $b}}` + if err := runt(tpl, "a b c"); err != nil { + t.Error(err) + } + tpl = `{{ .value | cat "a" "b"}}` + values := map[string]any{"value": nil} + if err := runtv(tpl, "a b", values); err != nil { + t.Error(err) + } +} + +func TestIndent(t *testing.T) { + tpl := `{{indent 4 "a\nb\nc"}}` + if err := runt(tpl, " a\n b\n c"); err != nil { + t.Error(err) + } +} + +func TestNindent(t *testing.T) { + tpl := `{{nindent 4 "a\nb\nc"}}` + if err := runt(tpl, "\n a\n b\n c"); err != nil { + t.Error(err) + } +} + +func TestReplace(t *testing.T) { + tpl := `{{"I Am Henry VIII" | replace " " "-"}}` + if err := runt(tpl, "I-Am-Henry-VIII"); err != nil { + t.Error(err) + } +} + +func TestPlural(t *testing.T) { + tpl := `{{$num := len "two"}}{{$num}} {{$num | plural "1 char" "chars"}}` + if err := runt(tpl, "3 chars"); err != nil { + t.Error(err) + } + tpl = `{{len "t" | plural "cheese" "%d chars"}}` + if err := runt(tpl, "cheese"); err != nil { + t.Error(err) + } +} diff --git a/util/sprig/url.go b/util/sprig/url.go new file mode 100644 index 00000000..52dac3bb --- /dev/null +++ b/util/sprig/url.go @@ -0,0 +1,65 @@ +package sprig + +import ( + "fmt" + "net/url" + "reflect" +) + +func dictGetOrEmpty(dict map[string]any, key string) string { + value, ok := dict[key] + if !ok { + return "" + } + tp := reflect.TypeOf(value).Kind() + if tp != reflect.String { + panic(fmt.Sprintf("unable to parse %s key, must be of type string, but %s found", key, tp.String())) + } + return reflect.ValueOf(value).String() +} + +// parses given URL to return dict object +func urlParse(v string) map[string]any { + dict := map[string]any{} + parsedURL, err := url.Parse(v) + if err != nil { + panic(fmt.Sprintf("unable to parse url: %s", err)) + } + dict["scheme"] = parsedURL.Scheme + dict["host"] = parsedURL.Host + dict["hostname"] = parsedURL.Hostname() + dict["path"] = parsedURL.Path + dict["query"] = parsedURL.RawQuery + dict["opaque"] = parsedURL.Opaque + dict["fragment"] = parsedURL.Fragment + if parsedURL.User != nil { + dict["userinfo"] = parsedURL.User.String() + } else { + dict["userinfo"] = "" + } + + return dict +} + +// join given dict to URL string +func urlJoin(d map[string]any) string { + resURL := url.URL{ + Scheme: dictGetOrEmpty(d, "scheme"), + Host: dictGetOrEmpty(d, "host"), + Path: dictGetOrEmpty(d, "path"), + RawQuery: dictGetOrEmpty(d, "query"), + Opaque: dictGetOrEmpty(d, "opaque"), + Fragment: dictGetOrEmpty(d, "fragment"), + } + userinfo := dictGetOrEmpty(d, "userinfo") + var user *url.Userinfo + if userinfo != "" { + tempURL, err := url.Parse(fmt.Sprintf("proto://%s@host", userinfo)) + if err != nil { + panic(fmt.Sprintf("unable to parse userinfo in dict: %s", err)) + } + user = tempURL.User + } + resURL.User = user + return resURL.String() +} diff --git a/util/sprig/url_test.go b/util/sprig/url_test.go new file mode 100644 index 00000000..16d457a7 --- /dev/null +++ b/util/sprig/url_test.go @@ -0,0 +1,87 @@ +package sprig + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +var urlTests = map[string]map[string]any{ + "proto://auth@host:80/path?query#fragment": { + "fragment": "fragment", + "host": "host:80", + "hostname": "host", + "opaque": "", + "path": "/path", + "query": "query", + "scheme": "proto", + "userinfo": "auth", + }, + "proto://host:80/path": { + "fragment": "", + "host": "host:80", + "hostname": "host", + "opaque": "", + "path": "/path", + "query": "", + "scheme": "proto", + "userinfo": "", + }, + "something": { + "fragment": "", + "host": "", + "hostname": "", + "opaque": "", + "path": "something", + "query": "", + "scheme": "", + "userinfo": "", + }, + "proto://user:passwor%20d@host:80/path": { + "fragment": "", + "host": "host:80", + "hostname": "host", + "opaque": "", + "path": "/path", + "query": "", + "scheme": "proto", + "userinfo": "user:passwor%20d", + }, + "proto://host:80/pa%20th?key=val%20ue": { + "fragment": "", + "host": "host:80", + "hostname": "host", + "opaque": "", + "path": "/pa th", + "query": "key=val%20ue", + "scheme": "proto", + "userinfo": "", + }, +} + +func TestUrlParse(t *testing.T) { + // testing that function is exported and working properly + assert.NoError(t, runt( + `{{ index ( urlParse "proto://auth@host:80/path?query#fragment" ) "host" }}`, + "host:80")) + + // testing scenarios + for url, expected := range urlTests { + assert.EqualValues(t, expected, urlParse(url)) + } +} + +func TestUrlJoin(t *testing.T) { + tests := map[string]string{ + `{{ urlJoin (dict "fragment" "fragment" "host" "host:80" "path" "/path" "query" "query" "scheme" "proto") }}`: "proto://host:80/path?query#fragment", + `{{ urlJoin (dict "fragment" "fragment" "host" "host:80" "path" "/path" "scheme" "proto" "userinfo" "ASDJKJSD") }}`: "proto://ASDJKJSD@host:80/path#fragment", + } + for tpl, expected := range tests { + assert.NoError(t, runt(tpl, expected)) + } + + for expected, urlMap := range urlTests { + assert.EqualValues(t, expected, urlJoin(urlMap)) + } + +} diff --git a/util/timeout_writer.go b/util/timeout_writer.go index 370068c4..d531916d 100644 --- a/util/timeout_writer.go +++ b/util/timeout_writer.go @@ -7,7 +7,7 @@ import ( ) // ErrWriteTimeout is returned when a write timed out -var ErrWriteTimeout = errors.New("write operation failed due to timeout since creation") +var ErrWriteTimeout = errors.New("write operation failed due to timeout") // TimeoutWriter wraps an io.Writer that will time out after the given timeout type TimeoutWriter struct { @@ -28,7 +28,7 @@ func NewTimeoutWriter(w io.Writer, timeout time.Duration) *TimeoutWriter { // Write implements the io.Writer interface, failing if called after the timeout period from creation. func (tw *TimeoutWriter) Write(p []byte) (n int, err error) { if time.Since(tw.start) > tw.timeout { - return 0, errors.New("write operation failed due to timeout since creation") + return 0, ErrWriteTimeout } return tw.writer.Write(p) } diff --git a/web/package-lock.json b/web/package-lock.json index ea4962a4..28e5b6d3 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1261,9 +1261,9 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.0.tgz", - "integrity": "sha512-LOAozRVbqxEVjSKfhGnuLoE4Kz4Oc5UJzuvFUhSsQzdCdaAQu06mG8zDv2GFSerM62nImUZ7K92vxnQcLSDlCQ==", + "version": "7.28.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.1.tgz", + "integrity": "sha512-P0QiV/taaa3kXpLY+sXla5zec4E+4t4Aqc9ggHlfZ7a2cp8/x/Gv08jfwEtn9gnnYIMvHx6aoOZ8XJL8eU71Dg==", "dev": true, "license": "MIT", "dependencies": { @@ -1599,9 +1599,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz", - "integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==", + "version": "7.28.1", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.1.tgz", + "integrity": "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -1770,9 +1770,9 @@ "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", - "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", + "integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==", "cpu": [ "ppc64" ], @@ -1787,9 +1787,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", - "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz", + "integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==", "cpu": [ "arm" ], @@ -1804,9 +1804,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", - "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz", + "integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==", "cpu": [ "arm64" ], @@ -1821,9 +1821,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", - "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz", + "integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==", "cpu": [ "x64" ], @@ -1838,9 +1838,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", - "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz", + "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==", "cpu": [ "arm64" ], @@ -1855,9 +1855,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", - "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz", + "integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==", "cpu": [ "x64" ], @@ -1872,9 +1872,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", - "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz", + "integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==", "cpu": [ "arm64" ], @@ -1889,9 +1889,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", - "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz", + "integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==", "cpu": [ "x64" ], @@ -1906,9 +1906,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", - "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz", + "integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==", "cpu": [ "arm" ], @@ -1923,9 +1923,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", - "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz", + "integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==", "cpu": [ "arm64" ], @@ -1940,9 +1940,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", - "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz", + "integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==", "cpu": [ "ia32" ], @@ -1957,9 +1957,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", - "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz", + "integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==", "cpu": [ "loong64" ], @@ -1974,9 +1974,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", - "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz", + "integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==", "cpu": [ "mips64el" ], @@ -1991,9 +1991,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", - "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz", + "integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==", "cpu": [ "ppc64" ], @@ -2008,9 +2008,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", - "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz", + "integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==", "cpu": [ "riscv64" ], @@ -2025,9 +2025,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", - "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz", + "integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==", "cpu": [ "s390x" ], @@ -2042,9 +2042,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", - "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz", + "integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==", "cpu": [ "x64" ], @@ -2059,9 +2059,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", - "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz", + "integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==", "cpu": [ "arm64" ], @@ -2076,9 +2076,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", - "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz", + "integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==", "cpu": [ "x64" ], @@ -2093,9 +2093,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", - "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz", + "integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==", "cpu": [ "arm64" ], @@ -2110,9 +2110,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", - "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz", + "integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==", "cpu": [ "x64" ], @@ -2126,10 +2126,27 @@ "node": ">=18" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz", + "integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", - "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz", + "integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==", "cpu": [ "x64" ], @@ -2144,9 +2161,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", - "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz", + "integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==", "cpu": [ "arm64" ], @@ -2161,9 +2178,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", - "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz", + "integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==", "cpu": [ "ia32" ], @@ -2178,9 +2195,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", - "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz", + "integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==", "cpu": [ "x64" ], @@ -2354,9 +2371,9 @@ } }, "node_modules/@mui/core-downloads-tracker": { - "version": "5.17.1", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.17.1.tgz", - "integrity": "sha512-OcZj+cs6EfUD39IoPBOgN61zf1XFVY+imsGoBDwXeSq2UHJZE3N59zzBOVjclck91Ne3e9gudONOeILvHCIhUA==", + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.18.0.tgz", + "integrity": "sha512-jbhwoQ1AY200PSSOrNXmrFCaSDSJWP7qk6urkTmIirvRXDROkqe+QwcLlUiw/PrREwsIF/vm3/dAXvjlMHF0RA==", "license": "MIT", "funding": { "type": "opencollective", @@ -2364,9 +2381,9 @@ } }, "node_modules/@mui/icons-material": { - "version": "5.17.1", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.17.1.tgz", - "integrity": "sha512-CN86LocjkunFGG0yPlO4bgqHkNGgaEOEc3X/jG5Bzm401qYw79/SaLrofA7yAKCCXAGdIGnLoMHohc3+ubs95A==", + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.18.0.tgz", + "integrity": "sha512-1s0vEZj5XFXDMmz3Arl/R7IncFqJ+WQ95LDp1roHWGDE2oCO3IS4/hmiOv1/8SD9r6B7tv9GLiqVZYHo+6PkTg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.9" @@ -2390,14 +2407,14 @@ } }, "node_modules/@mui/material": { - "version": "5.17.1", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.17.1.tgz", - "integrity": "sha512-2B33kQf+GmPnrvXXweWAx+crbiUEsxCdCN979QDYnlH9ox4pd+0/IBriWLV+l6ORoBF60w39cWjFnJYGFdzXcw==", + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.18.0.tgz", + "integrity": "sha512-bbH/HaJZpFtXGvWg3TsBWG4eyt3gah3E7nCNU8GLyRjVoWcA91Vm/T+sjHfUcwgJSw9iLtucfHBoq+qW/T30aA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.9", - "@mui/core-downloads-tracker": "^5.17.1", - "@mui/system": "^5.17.1", + "@mui/core-downloads-tracker": "^5.18.0", + "@mui/system": "^5.18.0", "@mui/types": "~7.2.15", "@mui/utils": "^5.17.1", "@popperjs/core": "^2.11.8", @@ -2462,13 +2479,14 @@ } }, "node_modules/@mui/styled-engine": { - "version": "5.16.14", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.16.14.tgz", - "integrity": "sha512-UAiMPZABZ7p8mUW4akDV6O7N3+4DatStpXMZwPlt+H/dA0lt67qawN021MNND+4QTpjaiMYxbhKZeQcyWCbuKw==", + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.18.0.tgz", + "integrity": "sha512-BN/vKV/O6uaQh2z5rXV+MBlVrEkwoS/TK75rFQ2mjxA7+NBo8qtTAOA4UaM0XeJfn7kh2wZ+xQw2HAx0u+TiBg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.9", "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", "csstype": "^3.1.3", "prop-types": "^15.8.1" }, @@ -2494,14 +2512,14 @@ } }, "node_modules/@mui/system": { - "version": "5.17.1", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.17.1.tgz", - "integrity": "sha512-aJrmGfQpyF0U4D4xYwA6ueVtQcEMebET43CUmKMP7e7iFh3sMIF3sBR0l8Urb4pqx1CBjHAaWgB0ojpND4Q3Jg==", + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.18.0.tgz", + "integrity": "sha512-ojZGVcRWqWhu557cdO3pWHloIGJdzVtxs3rk0F9L+x55LsUjcMUVkEhiF7E4TMxZoF9MmIHGGs0ZX3FDLAf0Xw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.9", "@mui/private-theming": "^5.17.1", - "@mui/styled-engine": "^5.16.14", + "@mui/styled-engine": "^5.18.0", "@mui/types": "~7.2.15", "@mui/utils": "^5.17.1", "clsx": "^2.1.0", @@ -2635,9 +2653,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.19", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz", - "integrity": "sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==", + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", "dev": true, "license": "MIT" }, @@ -2713,9 +2731,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.2.tgz", - "integrity": "sha512-g0dF8P1e2QYPOj1gu7s/3LVP6kze9A7m6x0BZ9iTdXK8N5c2V7cpBKHV3/9A4Zd8xxavdhK0t4PnqjkqVmUc9Q==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.45.1.tgz", + "integrity": "sha512-NEySIFvMY0ZQO+utJkgoMiCAjMrGvnbDLHvcmlA33UXJpYBCvlBEbMMtV837uCkS+plG2umfhn0T5mMAxGrlRA==", "cpu": [ "arm" ], @@ -2727,9 +2745,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.2.tgz", - "integrity": "sha512-Yt5MKrOosSbSaAK5Y4J+vSiID57sOvpBNBR6K7xAaQvk3MkcNVV0f9fE20T+41WYN8hDn6SGFlFrKudtx4EoxA==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.45.1.tgz", + "integrity": "sha512-ujQ+sMXJkg4LRJaYreaVx7Z/VMgBBd89wGS4qMrdtfUFZ+TSY5Rs9asgjitLwzeIbhwdEhyj29zhst3L1lKsRQ==", "cpu": [ "arm64" ], @@ -2741,9 +2759,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.2.tgz", - "integrity": "sha512-EsnFot9ZieM35YNA26nhbLTJBHD0jTwWpPwmRVDzjylQT6gkar+zenfb8mHxWpRrbn+WytRRjE0WKsfaxBkVUA==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.45.1.tgz", + "integrity": "sha512-FSncqHvqTm3lC6Y13xncsdOYfxGSLnP+73k815EfNmpewPs+EyM49haPS105Rh4aF5mJKywk9X0ogzLXZzN9lA==", "cpu": [ "arm64" ], @@ -2755,9 +2773,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.2.tgz", - "integrity": "sha512-dv/t1t1RkCvJdWWxQ2lWOO+b7cMsVw5YFaS04oHpZRWehI1h0fV1gF4wgGCTyQHHjJDfbNpwOi6PXEafRBBezw==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.45.1.tgz", + "integrity": "sha512-2/vVn/husP5XI7Fsf/RlhDaQJ7x9zjvC81anIVbr4b/f0xtSmXQTFcGIQ/B1cXIYM6h2nAhJkdMHTnD7OtQ9Og==", "cpu": [ "x64" ], @@ -2769,9 +2787,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.2.tgz", - "integrity": "sha512-W4tt4BLorKND4qeHElxDoim0+BsprFTwb+vriVQnFFtT/P6v/xO5I99xvYnVzKWrK6j7Hb0yp3x7V5LUbaeOMg==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.45.1.tgz", + "integrity": "sha512-4g1kaDxQItZsrkVTdYQ0bxu4ZIQ32cotoQbmsAnW1jAE4XCMbcBPDirX5fyUzdhVCKgPcrwWuucI8yrVRBw2+g==", "cpu": [ "arm64" ], @@ -2783,9 +2801,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.2.tgz", - "integrity": "sha512-tdT1PHopokkuBVyHjvYehnIe20fxibxFCEhQP/96MDSOcyjM/shlTkZZLOufV3qO6/FQOSiJTBebhVc12JyPTA==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.45.1.tgz", + "integrity": "sha512-L/6JsfiL74i3uK1Ti2ZFSNsp5NMiM4/kbbGEcOCps99aZx3g8SJMO1/9Y0n/qKlWZfn6sScf98lEOUe2mBvW9A==", "cpu": [ "x64" ], @@ -2797,9 +2815,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.2.tgz", - "integrity": "sha512-+xmiDGGaSfIIOXMzkhJ++Oa0Gwvl9oXUeIiwarsdRXSe27HUIvjbSIpPxvnNsRebsNdUo7uAiQVgBD1hVriwSQ==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.45.1.tgz", + "integrity": "sha512-RkdOTu2jK7brlu+ZwjMIZfdV2sSYHK2qR08FUWcIoqJC2eywHbXr0L8T/pONFwkGukQqERDheaGTeedG+rra6Q==", "cpu": [ "arm" ], @@ -2811,9 +2829,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.2.tgz", - "integrity": "sha512-bDHvhzOfORk3wt8yxIra8N4k/N0MnKInCW5OGZaeDYa/hMrdPaJzo7CSkjKZqX4JFUWjUGm88lI6QJLCM7lDrA==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.45.1.tgz", + "integrity": "sha512-3kJ8pgfBt6CIIr1o+HQA7OZ9mp/zDk3ctekGl9qn/pRBgrRgfwiffaUmqioUGN9hv0OHv2gxmvdKOkARCtRb8Q==", "cpu": [ "arm" ], @@ -2825,9 +2843,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.2.tgz", - "integrity": "sha512-NMsDEsDiYghTbeZWEGnNi4F0hSbGnsuOG+VnNvxkKg0IGDvFh7UVpM/14mnMwxRxUf9AdAVJgHPvKXf6FpMB7A==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.45.1.tgz", + "integrity": "sha512-k3dOKCfIVixWjG7OXTCOmDfJj3vbdhN0QYEqB+OuGArOChek22hn7Uy5A/gTDNAcCy5v2YcXRJ/Qcnm4/ma1xw==", "cpu": [ "arm64" ], @@ -2839,9 +2857,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.2.tgz", - "integrity": "sha512-lb5bxXnxXglVq+7imxykIp5xMq+idehfl+wOgiiix0191av84OqbjUED+PRC5OA8eFJYj5xAGcpAZ0pF2MnW+A==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.45.1.tgz", + "integrity": "sha512-PmI1vxQetnM58ZmDFl9/Uk2lpBBby6B6rF4muJc65uZbxCs0EA7hhKCk2PKlmZKuyVSHAyIw3+/SiuMLxKxWog==", "cpu": [ "arm64" ], @@ -2853,9 +2871,9 @@ ] }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.2.tgz", - "integrity": "sha512-Yl5Rdpf9pIc4GW1PmkUGHdMtbx0fBLE1//SxDmuf3X0dUC57+zMepow2LK0V21661cjXdTn8hO2tXDdAWAqE5g==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.45.1.tgz", + "integrity": "sha512-9UmI0VzGmNJ28ibHW2GpE2nF0PBQqsyiS4kcJ5vK+wuwGnV5RlqdczVocDSUfGX/Na7/XINRVoUgJyFIgipoRg==", "cpu": [ "loong64" ], @@ -2867,9 +2885,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.2.tgz", - "integrity": "sha512-03vUDH+w55s680YYryyr78jsO1RWU9ocRMaeV2vMniJJW/6HhoTBwyyiiTPVHNWLnhsnwcQ0oH3S9JSBEKuyqw==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.45.1.tgz", + "integrity": "sha512-7nR2KY8oEOUTD3pBAxIBBbZr0U7U+R9HDTPNy+5nVVHDXI4ikYniH1oxQz9VoB5PbBU1CZuDGHkLJkd3zLMWsg==", "cpu": [ "ppc64" ], @@ -2881,9 +2899,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.2.tgz", - "integrity": "sha512-iYtAqBg5eEMG4dEfVlkqo05xMOk6y/JXIToRca2bAWuqjrJYJlx/I7+Z+4hSrsWU8GdJDFPL4ktV3dy4yBSrzg==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.45.1.tgz", + "integrity": "sha512-nlcl3jgUultKROfZijKjRQLUu9Ma0PeNv/VFHkZiKbXTBQXhpytS8CIj5/NfBeECZtY2FJQubm6ltIxm/ftxpw==", "cpu": [ "riscv64" ], @@ -2895,9 +2913,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.2.tgz", - "integrity": "sha512-e6vEbgaaqz2yEHqtkPXa28fFuBGmUJ0N2dOJK8YUfijejInt9gfCSA7YDdJ4nYlv67JfP3+PSWFX4IVw/xRIPg==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.45.1.tgz", + "integrity": "sha512-HJV65KLS51rW0VY6rvZkiieiBnurSzpzore1bMKAhunQiECPuxsROvyeaot/tcK3A3aGnI+qTHqisrpSgQrpgA==", "cpu": [ "riscv64" ], @@ -2909,9 +2927,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.2.tgz", - "integrity": "sha512-evFOtkmVdY3udE+0QKrV5wBx7bKI0iHz5yEVx5WqDJkxp9YQefy4Mpx3RajIVcM6o7jxTvVd/qpC1IXUhGc1Mw==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.45.1.tgz", + "integrity": "sha512-NITBOCv3Qqc6hhwFt7jLV78VEO/il4YcBzoMGGNxznLgRQf43VQDae0aAzKiBeEPIxnDrACiMgbqjuihx08OOw==", "cpu": [ "s390x" ], @@ -2923,9 +2941,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.2.tgz", - "integrity": "sha512-/bXb0bEsWMyEkIsUL2Yt5nFB5naLAwyOWMEviQfQY1x3l5WsLKgvZf66TM7UTfED6erckUVUJQ/jJ1FSpm3pRQ==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.45.1.tgz", + "integrity": "sha512-+E/lYl6qu1zqgPEnTrs4WysQtvc/Sh4fC2nByfFExqgYrqkKWp1tWIbe+ELhixnenSpBbLXNi6vbEEJ8M7fiHw==", "cpu": [ "x64" ], @@ -2937,9 +2955,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.2.tgz", - "integrity": "sha512-3D3OB1vSSBXmkGEZR27uiMRNiwN08/RVAcBKwhUYPaiZ8bcvdeEwWPvbnXvvXHY+A/7xluzcN+kaiOFNiOZwWg==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.45.1.tgz", + "integrity": "sha512-a6WIAp89p3kpNoYStITT9RbTbTnqarU7D8N8F2CV+4Cl9fwCOZraLVuVFvlpsW0SbIiYtEnhCZBPLoNdRkjQFw==", "cpu": [ "x64" ], @@ -2951,9 +2969,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.2.tgz", - "integrity": "sha512-VfU0fsMK+rwdK8mwODqYeM2hDrF2WiHaSmCBrS7gColkQft95/8tphyzv2EupVxn3iE0FI78wzffoULH1G+dkw==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.45.1.tgz", + "integrity": "sha512-T5Bi/NS3fQiJeYdGvRpTAP5P02kqSOpqiopwhj0uaXB6nzs5JVi2XMJb18JUSKhCOX8+UE1UKQufyD6Or48dJg==", "cpu": [ "arm64" ], @@ -2965,9 +2983,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.2.tgz", - "integrity": "sha512-+qMUrkbUurpE6DVRjiJCNGZBGo9xM4Y0FXU5cjgudWqIBWbcLkjE3XprJUsOFgC6xjBClwVa9k6O3A7K3vxb5Q==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.45.1.tgz", + "integrity": "sha512-lxV2Pako3ujjuUe9jiU3/s7KSrDfH6IgTSQOnDWr9aJ92YsFd7EurmClK0ly/t8dzMkDtd04g60WX6yl0sGfdw==", "cpu": [ "ia32" ], @@ -2979,9 +2997,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.2.tgz", - "integrity": "sha512-3+QZROYfJ25PDcxFF66UEk8jGWigHJeecZILvkPkyQN7oc5BvFo4YEXFkOs154j3FTMp9mn9Ky8RCOwastduEA==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.45.1.tgz", + "integrity": "sha512-M/fKi4sasCdM8i0aWJjCSFm2qEnYRR8AMLG2kxp6wD13+tMGA4Z1tVAuHkNRjud5SW2EM3naLuK35w9twvf6aA==", "cpu": [ "x64" ], @@ -3139,16 +3157,16 @@ "license": "ISC" }, "node_modules/@vitejs/plugin-react": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.6.0.tgz", - "integrity": "sha512-5Kgff+m8e2PB+9j51eGHEpn5kUzRKH2Ry0qGoe8ItJg7pqnkPrYPkDQZGgGmTa0EGarHrkjLvOdU3b1fzI8otQ==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.27.4", + "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.19", + "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, @@ -3156,7 +3174,7 @@ "node": "^14.18.0 || >=16.0.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "node_modules/acorn": { @@ -4094,9 +4112,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.179", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.179.tgz", - "integrity": "sha512-UWKi/EbBopgfFsc5k61wFpV7WrnnSlSzW/e2XcBmS6qKYTivZlLtoll5/rdqRTxGglGHkmkW0j0pFNJG10EUIQ==", + "version": "1.5.187", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.187.tgz", + "integrity": "sha512-cl5Jc9I0KGUoOoSbxvTywTa40uspGJt/BDBoDLoxJRSBpWh4FFXBsjNRHfQrONsV/OoEjDfHUmZQa2d6Ze4YgA==", "dev": true, "license": "ISC" }, @@ -4303,9 +4321,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", - "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", + "integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4316,31 +4334,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.5", - "@esbuild/android-arm": "0.25.5", - "@esbuild/android-arm64": "0.25.5", - "@esbuild/android-x64": "0.25.5", - "@esbuild/darwin-arm64": "0.25.5", - "@esbuild/darwin-x64": "0.25.5", - "@esbuild/freebsd-arm64": "0.25.5", - "@esbuild/freebsd-x64": "0.25.5", - "@esbuild/linux-arm": "0.25.5", - "@esbuild/linux-arm64": "0.25.5", - "@esbuild/linux-ia32": "0.25.5", - "@esbuild/linux-loong64": "0.25.5", - "@esbuild/linux-mips64el": "0.25.5", - "@esbuild/linux-ppc64": "0.25.5", - "@esbuild/linux-riscv64": "0.25.5", - "@esbuild/linux-s390x": "0.25.5", - "@esbuild/linux-x64": "0.25.5", - "@esbuild/netbsd-arm64": "0.25.5", - "@esbuild/netbsd-x64": "0.25.5", - "@esbuild/openbsd-arm64": "0.25.5", - "@esbuild/openbsd-x64": "0.25.5", - "@esbuild/sunos-x64": "0.25.5", - "@esbuild/win32-arm64": "0.25.5", - "@esbuild/win32-ia32": "0.25.5", - "@esbuild/win32-x64": "0.25.5" + "@esbuild/aix-ppc64": "0.25.8", + "@esbuild/android-arm": "0.25.8", + "@esbuild/android-arm64": "0.25.8", + "@esbuild/android-x64": "0.25.8", + "@esbuild/darwin-arm64": "0.25.8", + "@esbuild/darwin-x64": "0.25.8", + "@esbuild/freebsd-arm64": "0.25.8", + "@esbuild/freebsd-x64": "0.25.8", + "@esbuild/linux-arm": "0.25.8", + "@esbuild/linux-arm64": "0.25.8", + "@esbuild/linux-ia32": "0.25.8", + "@esbuild/linux-loong64": "0.25.8", + "@esbuild/linux-mips64el": "0.25.8", + "@esbuild/linux-ppc64": "0.25.8", + "@esbuild/linux-riscv64": "0.25.8", + "@esbuild/linux-s390x": "0.25.8", + "@esbuild/linux-x64": "0.25.8", + "@esbuild/netbsd-arm64": "0.25.8", + "@esbuild/netbsd-x64": "0.25.8", + "@esbuild/openbsd-arm64": "0.25.8", + "@esbuild/openbsd-x64": "0.25.8", + "@esbuild/openharmony-arm64": "0.25.8", + "@esbuild/sunos-x64": "0.25.8", + "@esbuild/win32-arm64": "0.25.8", + "@esbuild/win32-ia32": "0.25.8", + "@esbuild/win32-x64": "0.25.8" } }, "node_modules/escalade": { @@ -4465,9 +4484,9 @@ } }, "node_modules/eslint-config-prettier": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.10.0.tgz", - "integrity": "sha512-SM8AMJdeQqRYT9O9zguiruQZaN7+z+E4eAP9oiLNGKMtomwaB1E9dcgUD6ZAn/eQAb52USbvezbiljfZUhbJcg==", + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.10.2.tgz", + "integrity": "sha512-/IGJ6+Dka158JnP5n5YFMOszjDWrXggGz1LaK/guZq9vZTmniaKlHcsscvkAhn9y4U+BU3JuUdYvtAMcv30y4A==", "dev": true, "license": "MIT", "bin": { @@ -6839,9 +6858,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { @@ -7364,9 +7383,9 @@ } }, "node_modules/rollup": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.2.tgz", - "integrity": "sha512-PVoapzTwSEcelaWGth3uR66u7ZRo6qhPHc0f2uRO9fX6XDVNrIiGYS0Pj9+R8yIIYSD/mCx2b16Ws9itljKSPg==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.45.1.tgz", + "integrity": "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==", "dev": true, "license": "MIT", "dependencies": { @@ -7380,26 +7399,26 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.44.2", - "@rollup/rollup-android-arm64": "4.44.2", - "@rollup/rollup-darwin-arm64": "4.44.2", - "@rollup/rollup-darwin-x64": "4.44.2", - "@rollup/rollup-freebsd-arm64": "4.44.2", - "@rollup/rollup-freebsd-x64": "4.44.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.44.2", - "@rollup/rollup-linux-arm-musleabihf": "4.44.2", - "@rollup/rollup-linux-arm64-gnu": "4.44.2", - "@rollup/rollup-linux-arm64-musl": "4.44.2", - "@rollup/rollup-linux-loongarch64-gnu": "4.44.2", - "@rollup/rollup-linux-powerpc64le-gnu": "4.44.2", - "@rollup/rollup-linux-riscv64-gnu": "4.44.2", - "@rollup/rollup-linux-riscv64-musl": "4.44.2", - "@rollup/rollup-linux-s390x-gnu": "4.44.2", - "@rollup/rollup-linux-x64-gnu": "4.44.2", - "@rollup/rollup-linux-x64-musl": "4.44.2", - "@rollup/rollup-win32-arm64-msvc": "4.44.2", - "@rollup/rollup-win32-ia32-msvc": "4.44.2", - "@rollup/rollup-win32-x64-msvc": "4.44.2", + "@rollup/rollup-android-arm-eabi": "4.45.1", + "@rollup/rollup-android-arm64": "4.45.1", + "@rollup/rollup-darwin-arm64": "4.45.1", + "@rollup/rollup-darwin-x64": "4.45.1", + "@rollup/rollup-freebsd-arm64": "4.45.1", + "@rollup/rollup-freebsd-x64": "4.45.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.45.1", + "@rollup/rollup-linux-arm-musleabihf": "4.45.1", + "@rollup/rollup-linux-arm64-gnu": "4.45.1", + "@rollup/rollup-linux-arm64-musl": "4.45.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.45.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.45.1", + "@rollup/rollup-linux-riscv64-gnu": "4.45.1", + "@rollup/rollup-linux-riscv64-musl": "4.45.1", + "@rollup/rollup-linux-s390x-gnu": "4.45.1", + "@rollup/rollup-linux-x64-gnu": "4.45.1", + "@rollup/rollup-linux-x64-musl": "4.45.1", + "@rollup/rollup-win32-arm64-msvc": "4.45.1", + "@rollup/rollup-win32-ia32-msvc": "4.45.1", + "@rollup/rollup-win32-x64-msvc": "4.45.1", "fsevents": "~2.3.2" } },