Kaynağa Gözat

Merge branch 'binwiederhier:main' into main

timof 7 ay önce
ebeveyn
işleme
214f70e62f
61 değiştirilmiş dosya ile 8429 ekleme ve 301 silme
  1. 1 0
      README.md
  2. 6 0
      client/options.go
  3. 5 0
      cmd/publish.go
  4. 4 5
      cmd/serve.go
  5. 2 1
      cmd/user.go
  6. 4 4
      docs/publish.md
  7. 1507 0
      docs/publish/template-functions.md
  8. 7 0
      docs/releases.md
  9. BIN
      docs/static/img/android-screenshot-template-custom.png
  10. BIN
      docs/static/img/android-screenshot-template-predefined.png
  11. BIN
      docs/static/img/screenshot-github-webhook-config.png
  12. 15 15
      go.mod
  13. 31 0
      go.sum
  14. 1 0
      mkdocs.yml
  15. 5 1
      server/config.go
  16. 2 0
      server/errors.go
  17. 96 38
      server/server.go
  18. 20 0
      server/server.yml
  19. 159 3
      server/server_test.go
  20. 3 3
      server/smtp_server.go
  21. 27 0
      server/templates/alertmanager.yml
  22. 57 0
      server/templates/github.yml
  23. 10 0
      server/templates/grafana.yml
  24. 33 0
      server/testdata/webhook_alertmanager_firing.json
  25. 261 0
      server/testdata/webhook_github_comment_created.json
  26. 216 0
      server/testdata/webhook_github_issue_opened.json
  27. 541 0
      server/testdata/webhook_github_pr_opened.json
  28. 141 0
      server/testdata/webhook_github_star_created.json
  29. 139 0
      server/testdata/webhook_github_watch_created.json
  30. 51 0
      server/testdata/webhook_grafana_resolved.json
  31. 18 1
      server/types.go
  32. 19 0
      util/sprig/LICENSE.txt
  33. 47 0
      util/sprig/crypto.go
  34. 33 0
      util/sprig/crypto_test.go
  35. 240 0
      util/sprig/date.go
  36. 123 0
      util/sprig/date_test.go
  37. 268 0
      util/sprig/defaults.go
  38. 196 0
      util/sprig/defaults_test.go
  39. 233 0
      util/sprig/dict.go
  40. 166 0
      util/sprig/dict_test.go
  41. 19 0
      util/sprig/doc.go
  42. 25 0
      util/sprig/example_test.go
  43. 8 0
      util/sprig/flow_control.go
  44. 16 0
      util/sprig/flow_control_test.go
  45. 214 0
      util/sprig/functions.go
  46. 28 0
      util/sprig/functions_linux_test.go
  47. 70 0
      util/sprig/functions_test.go
  48. 505 0
      util/sprig/list.go
  49. 367 0
      util/sprig/list_test.go
  50. 499 0
      util/sprig/numeric.go
  51. 307 0
      util/sprig/numeric_test.go
  52. 70 0
      util/sprig/reflect.go
  53. 73 0
      util/sprig/reflect_test.go
  54. 217 0
      util/sprig/regex.go
  55. 203 0
      util/sprig/regex_test.go
  56. 487 0
      util/sprig/strings.go
  57. 233 0
      util/sprig/strings_test.go
  58. 65 0
      util/sprig/url.go
  59. 87 0
      util/sprig/url_test.go
  60. 2 2
      util/timeout_writer.go
  61. 247 228
      web/package-lock.json

+ 1 - 0
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

+ 6 - 0
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)

+ 5 - 0
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))
 	}

+ 4 - 5
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"}),
@@ -56,6 +52,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"}),
@@ -161,6 +158,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")
@@ -410,6 +408,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

+ 2 - 1
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"}),
 )

Dosya farkı çok büyük olduğundan ihmal edildi
+ 4 - 4
docs/publish.md


+ 1507 - 0
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
+```

+ 7 - 0
docs/releases.md

@@ -1452,6 +1452,13 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
 
 ## Not released yet
 
+### ntfy server v2.14.0 (UNRELEASED)
+
+**Features:**
+
+* 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)
 
 **Features:**

BIN
docs/static/img/android-screenshot-template-custom.png


BIN
docs/static/img/android-screenshot-template-predefined.png


BIN
docs/static/img/screenshot-github-webhook-config.png


+ 15 - 15
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

+ 31 - 0
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=

+ 1 - 0
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

+ 5 - 1
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!)
@@ -99,6 +101,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
@@ -172,7 +175,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:                          "",
@@ -195,6 +198,7 @@ func NewConfig() *Config {
 		AttachmentTotalSizeLimit:             DefaultAttachmentTotalSizeLimit,
 		AttachmentFileSizeLimit:              DefaultAttachmentFileSizeLimit,
 		AttachmentExpiryDuration:             DefaultAttachmentExpiryDuration,
+		TemplateDir:                          DefaultTemplateDir,
 		KeepaliveInterval:                    DefaultKeepaliveInterval,
 		ManagerInterval:                      DefaultManagerInterval,
 		DisallowedTopics:                     DefaultDisallowedTopics,

+ 2 - 0
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}

+ 96 - 38
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
@@ -936,7 +943,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
 	}
 }
 
-func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template bool, unifiedpush bool, err *errHTTP) {
+func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template templateMode, unifiedpush bool, err *errHTTP) {
 	cache = readBoolParam(r, true, "x-cache", "cache")
 	firebase = readBoolParam(r, true, "x-firebase", "firebase")
 	m.Title = readParam(r, "x-title", "title", "t")
@@ -952,7 +959,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
 	}
 	if attach != "" {
 		if !urlRegex.MatchString(attach) {
-			return false, false, "", "", false, false, errHTTPBadRequestAttachmentURLInvalid
+			return false, false, "", "", "", false, errHTTPBadRequestAttachmentURLInvalid
 		}
 		m.Attachment.URL = attach
 		if m.Attachment.Name == "" {
@@ -970,19 +977,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 != "" {
@@ -991,27 +998,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()
 	}
@@ -1019,14 +1026,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" {
@@ -1058,7 +1065,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
 //     If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
 //  7. curl -T file.txt ntfy.sh/mytopic
 //     In all other cases, mostly if file.txt is > message limit, treat it as an attachment
-func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, template, unifiedpush bool) error {
+func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, template templateMode, unifiedpush bool) error {
 	if m.Event == pollRequestEvent { // Case 1
 		return s.handleBodyDiscard(body)
 	} else if unifiedpush {
@@ -1067,8 +1074,8 @@ func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body
 		return s.handleBodyAsTextMessage(m, body) // Case 3
 	} else if m.Attachment != nil && m.Attachment.Name != "" {
 		return s.handleBodyAsAttachment(r, v, m, body) // Case 4
-	} else if template {
-		return s.handleBodyAsTemplatedTextMessage(m, body) // Case 5
+	} else if template.Enabled() {
+		return s.handleBodyAsTemplatedTextMessage(m, template, body) // Case 5
 	} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
 		return s.handleBodyAsTextMessage(m, body) // Case 6
 	}
@@ -1104,7 +1111,7 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser
 	return nil
 }
 
-func (s *Server) handleBodyAsTemplatedTextMessage(m *message, body *util.PeekedReadCloser) error {
+func (s *Server) handleBodyAsTemplatedTextMessage(m *message, template templateMode, body *util.PeekedReadCloser) error {
 	body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit))
 	if err != nil {
 		return err
@@ -1112,19 +1119,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 len(m.Title) > s.config.MessageSizeLimit || len(m.Message) > s.config.MessageSizeLimit {
+		return errHTTPBadRequestTemplateMessageTooLarge
 	}
-	if m.Title, err = replaceTemplate(m.Title, peekedBody); err != nil {
+	return nil
+}
+
+// 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 len(m.Message) > s.config.MessageSizeLimit {
-		return errHTTPBadRequestTemplateMessageTooLarge
+	if m.Title, err = s.renderTemplate(m.Title, peekedBody); err != nil {
+		return err
 	}
 	return nil
 }
 
-func replaceTemplate(tpl string, source string) (string, error) {
+// renderTemplate renders a template with the given JSON source data.
+func (s *Server) renderTemplate(tpl string, source string) (string, error) {
 	if templateDisallowedRegex.MatchString(tpl) {
 		return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
 	}
@@ -1132,15 +1189,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 {

+ 20 - 0
server/server.yml

@@ -126,6 +126,26 @@
 # attachment-file-size-limit: "15M"
 # attachment-expiry-duration: "3h"
 
+# Template directory for message templates.
+#
+# When "X-Template: <name>" (aliases: "Template: <name>", "Tpl: <name>") or "?template=<name>" is set, transform the message
+# based on one of the built-in pre-defined templates, or on a template defined in the "template-dir" directory.
+#
+# Template files must have the ".yml" extension and must be formatted as YAML. They may contain "title" and "message" keys,
+# which are interpreted as Go templates.
+#
+# Example template file (e.g. /etc/ntfy/templates/grafana.yml):
+#   title: |
+#     {{- if eq .status "firing" }}
+#     {{ .title | default "Alert firing" }}
+#     {{- else if eq .status "resolved" }}
+#     {{ .title | default "Alert resolved" }}
+#     {{- end }}
+#   message: |
+#     {{ .message | trunc 2000 }}
+#
+# template-dir: "/etc/ntfy/templates"
+
 # If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set,
 # messages will additionally be sent out as e-mail using an external SMTP server.
 #

+ 159 - 3
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:<edited>, visitor_ip=<edited>, visitor_messages=448, visitor_messages_limit=17280, visitor_messages_remaining=16832, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=57.049697891799994, visitor_seen=2025-07-16T15:06:35.429Z)
+`+"```"+`
+
+:crystal_ball: **Additional context**
+Looks like this has already been fixed by #498, regression?`, m.Message)
+}
+
+func TestServer_MessageTemplate_FromNamedTemplate_GitHubIssueOpened_OverrideConfigTemplate(t *testing.T) {
+	t.Parallel()
+	c := newTestConfig(t)
+	c.TemplateDir = t.TempDir()
+	require.NoError(t, os.WriteFile(filepath.Join(c.TemplateDir, "github.yml"), []byte(`
+title: |
+  Custom title: action={{ .action }} trunctitle={{ .issue.title | trunc 10 }}
+message: |
+  Custom message {{ .issue.number }}
+`), 0644))
+	s := newTestServer(t, c)
+	response := request(t, s, "POST", "/mytopic?template=github", githubIssueOpenedJSON, nil)
+	fmt.Println(response.Body.String())
+	require.Equal(t, 200, response.Code)
+	m := toMessage(t, response.Body.String())
+	require.Equal(t, "Custom title: action=opened trunctitle=http 500 e", m.Title)
+	require.Equal(t, "Custom message 1391", m.Message)
+}
+
+func TestServer_MessageTemplate_Repeat9999_TooLarge(t *testing.T) {
+	t.Parallel()
+	s := newTestServer(t, newTestConfig(t))
+	response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
+		"X-Message":  `{{ repeat 9999 "mystring" }}`,
+		"X-Template": "1",
+	})
+	require.Equal(t, 400, response.Code)
+	require.Equal(t, 40041, toHTTPError(t, response.Body.String()).Code)
+	require.Contains(t, toHTTPError(t, response.Body.String()).Message, "message or title is too large after replacing template")
+}
+
+func TestServer_MessageTemplate_Repeat10001_TooLarge(t *testing.T) {
+	t.Parallel()
+	s := newTestServer(t, newTestConfig(t))
+	response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
+		"X-Message":  `{{ repeat 10001 "mystring" }}`,
+		"X-Template": "1",
+	})
+	require.Equal(t, 400, response.Code)
+	require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code)
+	require.Contains(t, toHTTPError(t, response.Body.String()).Message, "repeat count 10001 exceeds limit of 10000")
+}
+
+func TestServer_MessageTemplate_Until100_000(t *testing.T) {
+	t.Parallel()
+	s := newTestServer(t, newTestConfig(t))
+	response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
+		"X-Message":  `{{ range $i, $e := until 100_000 }}{{end}}`,
+		"X-Template": "1",
+	})
+	require.Equal(t, 400, response.Code)
+	require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code)
+	require.Contains(t, toHTTPError(t, response.Body.String()).Message, "too many iterations")
+}
+
 func newTestConfig(t *testing.T) *Config {
 	conf := NewConfig()
 	conf.BaseURL = "http://127.0.0.1:12345"
 	conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")
 	conf.CacheStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;"
 	conf.AttachmentCacheDir = t.TempDir()
+	conf.TemplateDir = t.TempDir()
 	return conf
 }
 

+ 3 - 3
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)
 	}

+ 27 - 0
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 }}

+ 57 - 0
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 }}

+ 10 - 0
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 }}

+ 33 - 0
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=..."
+    }
+  ]
+}

+ 261 - 0
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
+  }
+}

+ 216 - 0
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:<edited>, visitor_ip=<edited>, visitor_messages=448, visitor_messages_limit=17280, visitor_messages_remaining=16832, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=57.049697891799994, visitor_seen=2025-07-16T15:06:35.429Z)\n```\n\n:crystal_ball: **Additional context**\nLooks like this has already been fixed by #498, regression?\n",
+    "reactions": {
+      "url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1391/reactions",
+      "total_count": 0,
+      "+1": 0,
+      "-1": 0,
+      "laugh": 0,
+      "hooray": 0,
+      "confused": 0,
+      "heart": 0,
+      "rocket": 0,
+      "eyes": 0
+    },
+    "timeline_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1391/timeline",
+    "performed_via_github_app": null,
+    "state_reason": null
+  },
+  "repository": {
+    "id": 420503947,
+    "node_id": "R_kgDOGRBhiw",
+    "name": "ntfy",
+    "full_name": "binwiederhier/ntfy",
+    "private": false,
+    "owner": {
+      "login": "binwiederhier",
+      "id": 664597,
+      "node_id": "MDQ6VXNlcjY2NDU5Nw==",
+      "avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4",
+      "gravatar_id": "",
+      "url": "https://api.github.com/users/binwiederhier",
+      "html_url": "https://github.com/binwiederhier",
+      "followers_url": "https://api.github.com/users/binwiederhier/followers",
+      "following_url": "https://api.github.com/users/binwiederhier/following{/other_user}",
+      "gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}",
+      "starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}",
+      "subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions",
+      "organizations_url": "https://api.github.com/users/binwiederhier/orgs",
+      "repos_url": "https://api.github.com/users/binwiederhier/repos",
+      "events_url": "https://api.github.com/users/binwiederhier/events{/privacy}",
+      "received_events_url": "https://api.github.com/users/binwiederhier/received_events",
+      "type": "User",
+      "user_view_type": "public",
+      "site_admin": false
+    },
+    "html_url": "https://github.com/binwiederhier/ntfy",
+    "description": "Send push notifications to your phone or desktop using PUT/POST",
+    "fork": false,
+    "url": "https://api.github.com/repos/binwiederhier/ntfy",
+    "forks_url": "https://api.github.com/repos/binwiederhier/ntfy/forks",
+    "keys_url": "https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}",
+    "collaborators_url": "https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}",
+    "teams_url": "https://api.github.com/repos/binwiederhier/ntfy/teams",
+    "hooks_url": "https://api.github.com/repos/binwiederhier/ntfy/hooks",
+    "issue_events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}",
+    "events_url": "https://api.github.com/repos/binwiederhier/ntfy/events",
+    "assignees_url": "https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}",
+    "branches_url": "https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}",
+    "tags_url": "https://api.github.com/repos/binwiederhier/ntfy/tags",
+    "blobs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}",
+    "git_tags_url": "https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}",
+    "git_refs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}",
+    "trees_url": "https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}",
+    "statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}",
+    "languages_url": "https://api.github.com/repos/binwiederhier/ntfy/languages",
+    "stargazers_url": "https://api.github.com/repos/binwiederhier/ntfy/stargazers",
+    "contributors_url": "https://api.github.com/repos/binwiederhier/ntfy/contributors",
+    "subscribers_url": "https://api.github.com/repos/binwiederhier/ntfy/subscribers",
+    "subscription_url": "https://api.github.com/repos/binwiederhier/ntfy/subscription",
+    "commits_url": "https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}",
+    "git_commits_url": "https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}",
+    "comments_url": "https://api.github.com/repos/binwiederhier/ntfy/comments{/number}",
+    "issue_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}",
+    "contents_url": "https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}",
+    "compare_url": "https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}",
+    "merges_url": "https://api.github.com/repos/binwiederhier/ntfy/merges",
+    "archive_url": "https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}",
+    "downloads_url": "https://api.github.com/repos/binwiederhier/ntfy/downloads",
+    "issues_url": "https://api.github.com/repos/binwiederhier/ntfy/issues{/number}",
+    "pulls_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}",
+    "milestones_url": "https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}",
+    "notifications_url": "https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}",
+    "labels_url": "https://api.github.com/repos/binwiederhier/ntfy/labels{/name}",
+    "releases_url": "https://api.github.com/repos/binwiederhier/ntfy/releases{/id}",
+    "deployments_url": "https://api.github.com/repos/binwiederhier/ntfy/deployments",
+    "created_at": "2021-10-23T19:25:32Z",
+    "updated_at": "2025-07-16T14:54:16Z",
+    "pushed_at": "2025-07-16T11:49:26Z",
+    "git_url": "git://github.com/binwiederhier/ntfy.git",
+    "ssh_url": "git@github.com:binwiederhier/ntfy.git",
+    "clone_url": "https://github.com/binwiederhier/ntfy.git",
+    "svn_url": "https://github.com/binwiederhier/ntfy",
+    "homepage": "https://ntfy.sh",
+    "size": 36831,
+    "stargazers_count": 25112,
+    "watchers_count": 25112,
+    "language": "Go",
+    "has_issues": true,
+    "has_projects": true,
+    "has_downloads": true,
+    "has_wiki": true,
+    "has_pages": false,
+    "has_discussions": false,
+    "forks_count": 984,
+    "mirror_url": null,
+    "archived": false,
+    "disabled": false,
+    "open_issues_count": 369,
+    "license": {
+      "key": "apache-2.0",
+      "name": "Apache License 2.0",
+      "spdx_id": "Apache-2.0",
+      "url": "https://api.github.com/licenses/apache-2.0",
+      "node_id": "MDc6TGljZW5zZTI="
+    },
+    "allow_forking": true,
+    "is_template": false,
+    "web_commit_signoff_required": false,
+    "topics": [
+      "curl",
+      "notifications",
+      "ntfy",
+      "ntfysh",
+      "pubsub",
+      "push-notifications",
+      "rest-api"
+    ],
+    "visibility": "public",
+    "forks": 984,
+    "open_issues": 369,
+    "watchers": 25112,
+    "default_branch": "main"
+  },
+  "sender": {
+    "login": "TheUser-dev",
+    "id": 213207407,
+    "node_id": "U_kgDODLVJbw",
+    "avatar_url": "https://avatars.githubusercontent.com/u/213207407?v=4",
+    "gravatar_id": "",
+    "url": "https://api.github.com/users/TheUser-dev",
+    "html_url": "https://github.com/TheUser-dev",
+    "followers_url": "https://api.github.com/users/TheUser-dev/followers",
+    "following_url": "https://api.github.com/users/TheUser-dev/following{/other_user}",
+    "gists_url": "https://api.github.com/users/TheUser-dev/gists{/gist_id}",
+    "starred_url": "https://api.github.com/users/TheUser-dev/starred{/owner}{/repo}",
+    "subscriptions_url": "https://api.github.com/users/TheUser-dev/subscriptions",
+    "organizations_url": "https://api.github.com/users/TheUser-dev/orgs",
+    "repos_url": "https://api.github.com/users/TheUser-dev/repos",
+    "events_url": "https://api.github.com/users/TheUser-dev/events{/privacy}",
+    "received_events_url": "https://api.github.com/users/TheUser-dev/received_events",
+    "type": "User",
+    "user_view_type": "public",
+    "site_admin": false
+  }
+}

+ 541 - 0
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
+  }
+}

+ 141 - 0
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
+  }
+}
+

+ 139 - 0
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
+  }
+}

+ 51 - 0
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"
+}

+ 18 - 1
server/types.go

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

+ 19 - 0
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.

+ 47 - 0
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)
+}

+ 33 - 0
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)
+	}
+}

+ 240 - 0
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)
+}

+ 123 - 0
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)
+	}
+}

+ 268 - 0
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
+}

+ 196 - 0
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 }}`:                              "<no value>",
+	}
+	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": "<HEAD>"}}
+	tpl := `{{.Top | toRawJSON}}`
+	expected := `{"bool":true,"html":"<HEAD>","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)
+	}
+}

+ 233 - 0
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)
+}

+ 166 - 0
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)
+		}
+	}
+}

+ 19 - 0
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

+ 25 - 0
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
+}

+ 8 - 0
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)
+}

+ 16 - 0
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)
+}

+ 214 - 0
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,
+	}
+}

+ 28 - 0
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"))
+}

+ 70 - 0
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
+}

+ 505 - 0
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
+}

+ 367 - 0
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 }}`:                                "<no value>",
+		`{{ 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 }}`:                                "<no value>",
+		`{{ 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 }}`:                                "<no value>",
+		`{{ 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 }}`:                                "<no value>",
+		`{{ 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 <nil>]",
+		`{{ 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))
+	}
+}

+ 499 - 0
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), "[]")
+}

+ 307 - 0
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)
+		}
+	}
+}

+ 70 - 0
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()
+}

+ 73 - 0
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)
+	}
+}

+ 217 - 0
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)
+}

+ 203 - 0
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"))
+}

+ 487 - 0
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)
+}

+ 233 - 0
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)
+	}
+}

+ 65 - 0
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()
+}

+ 87 - 0
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))
+	}
+
+}

+ 2 - 2
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)
 }

+ 247 - 228
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"
       }
     },

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor