Compare commits

..

78 Commits

Author SHA1 Message Date
binwiederhier
f4c285f7ce WIP Busy timeout 2025-07-27 09:33:24 +02:00
binwiederhier
141ddb3a51 Comments 2025-07-26 12:20:11 +02:00
binwiederhier
f99801a2e6 Add "ntfy user hash" 2025-07-26 12:14:21 +02:00
binwiederhier
4457e9e26f Migration 2025-07-26 11:16:33 +02:00
binwiederhier
f59df0f40a Works 2025-07-21 17:44:00 +02:00
binwiederhier
51af114b2e Merge branch 'main' of github.com:binwiederhier/ntfy into predefined-users 2025-07-21 11:57:14 +02:00
Philipp C. Heckel
83bf9d4d6c Merge pull request #1390 from binwiederhier/template-dir
Advanced message templating: Sprig functions, pre-defined templates, custom templates via `template-dir`
2025-07-21 11:56:28 +02:00
binwiederhier
f298d947bd Bump 2025-07-21 11:46:22 +02:00
binwiederhier
d87d8a2db4 fmt 2025-07-21 11:31:38 +02:00
binwiederhier
50c564d8a2 AI docs 2025-07-21 11:24:58 +02:00
binwiederhier
c807b5db21 Merge branch 'template-dir' of github.com:binwiederhier/ntfy into template-dir 2025-07-21 10:28:40 +02:00
binwiederhier
4d1baae6d0 Refine 2025-07-21 10:28:26 +02:00
binwiederhier
34bc551303 Merge branch 'main' of github.com:binwiederhier/ntfy into template-dir 2025-07-21 10:23:56 +02:00
Philipp C. Heckel
0847a6406e Merge pull request #1395 from wunter8/template-dir
doc corrections
2025-07-21 10:23:47 +02:00
Hunter Kehoe
f4a74dac57 doc corrections 2025-07-19 21:51:00 -06:00
binwiederhier
1f34c39eb0 Refactor a little 2025-07-19 22:52:08 +02:00
binwiederhier
8783c86cd6 Fix docs 2025-07-19 22:45:41 +02:00
binwiederhier
892e82ceb8 Remove underscore functions 2025-07-19 22:41:53 +02:00
binwiederhier
8b4834929d Clean code 2025-07-19 22:30:07 +02:00
binwiederhier
f0d5392e9e Self-review 2025-07-19 21:32:05 +02:00
binwiederhier
dde07adbdc Add some limits 2025-07-19 16:46:53 +02:00
binwiederhier
57df16dd62 Remove UUID 2025-07-19 15:44:49 +02:00
binwiederhier
ae62e0d955 Docs docs docs 2025-07-19 15:37:05 +02:00
binwiederhier
4603802f62 WIP 2025-07-16 21:50:29 +02:00
binwiederhier
610792b902 WIP 2025-07-16 20:33:52 +02:00
binwiederhier
b1e935da45 TEmplate dir 2025-07-16 13:49:15 +02:00
binwiederhier
93e14b73bb Tempalte dir 2025-07-16 10:01:59 +02:00
Philipp C. Heckel
81a486adc1 Merge pull request #1388 from KristopherPaulsen/add-missing-quote-on-cli-example
Add missing double-quote to docs so commands work when copy-pasted
2025-07-13 15:56:19 +02:00
Kristopher Paulsen
8bf4727a1c Missing double quote, sneaky little bugger 2025-07-13 09:50:06 -04:00
binwiederhier
2a468493f9 any 2025-07-13 12:45:00 +02:00
binwiederhier
3ac3e2ec7c Merge branch 'main' of github.com:binwiederhier/ntfy into sprig 2025-07-11 13:19:55 +02:00
binwiederhier
fea0f301d2 Sprig funcs 2025-07-11 13:19:31 +02:00
binwiederhier
1ce08a18c0 Bump release notes 2025-07-10 21:17:58 +02:00
binwiederhier
8d6f1eecdf Fix build 2025-07-10 21:06:39 +02:00
binwiederhier
c0b5151bae Predefined users 2025-07-10 20:50:29 +02:00
Hunter Kehoe
650f492d7d make tests happy 2025-07-07 22:47:41 -06:00
Hunter Kehoe
1f2c76e63d copy subset of Sprig template functions 2025-07-07 22:23:32 -06:00
binwiederhier
efef587671 WIP: Predefined users 2025-07-07 22:36:01 +02:00
Philipp C. Heckel
3c8ac4a1e1 Merge pull request #1380 from binwiederhier/ipv6
IPv6 support
2025-07-07 21:25:14 +02:00
binwiederhier
f5247c50f4 Bump 2025-07-07 21:24:43 +02:00
binwiederhier
1edbda4f31 Release notes 2025-07-07 18:34:05 +02:00
binwiederhier
de7b7218e4 Add languages 2025-07-07 18:28:16 +02:00
binwiederhier
19a4e95a3a Docs 2025-07-07 16:49:15 +02:00
binwiederhier
4578835a8f stdin 2025-07-07 11:04:33 +02:00
binwiederhier
aead619dea Merge branch 'main' of github.com:binwiederhier/ntfy into ipv6 2025-07-06 21:52:49 +02:00
Philipp C. Heckel
deeefee8c0 Merge pull request #1382 from srevn/main
Add piping support
2025-07-06 21:52:23 +02:00
Philipp C. Heckel
5e380e147f Merge pull request #1371 from cyb3rko/docs-update
Smaller docs updates
2025-07-06 21:48:52 +02:00
Philipp C. Heckel
ba5c3a164d Merge pull request #1381 from alecthomas/patch-1
docs: add ntfyexec to integrations
2025-07-06 21:48:18 +02:00
srevn
47da3aeea6 fix unbounded read 2025-07-06 17:53:04 +03:00
srevn
9ed96e5d8b Small cosmetic fixes 2025-07-06 16:31:03 +03:00
srevn
04aff72631 Add example and logging 2025-07-06 10:51:28 +03:00
srevn
6fbcd85d17 Add piping support 2025-07-06 10:23:32 +03:00
binwiederhier
8f60294c5b Docs 2025-07-05 22:48:45 +02:00
binwiederhier
677b44ce61 Docs, rename proxy-trusted-(addresses->hosts) 2025-07-05 22:35:26 +02:00
binwiederhier
000248e6aa Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web into ipv6 2025-07-05 21:46:44 +02:00
binwiederhier
359c789c34 Test for visitorID 2025-07-05 13:11:17 +02:00
Alec Thomas
34e9a771ce docs: add ntfyexec to integrations 2025-07-05 17:05:31 +10:00
binwiederhier
60b8588129 Tests 2025-07-04 16:56:35 +02:00
binwiederhier
7eeaeb8398 server.yml update 2025-07-04 16:51:55 +02:00
binwiederhier
c99d8b66c2 Re-order 2025-07-04 10:19:27 +02:00
binwiederhier
960f690dd6 Merge branch 'main' of github.com:binwiederhier/ntfy into ipv6 2025-07-04 10:17:05 +02:00
binwiederhier
54514454bf Works 2025-07-04 10:16:49 +02:00
binwiederhier
d8c8f31846 IPv6 WIP 2025-07-04 07:38:58 +02:00
Kachelkaiser
ae27c3a5ab Translated using Weblate (German)
Currently translated at 100.0% (405 of 405 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/de/
2025-06-30 16:08:38 +02:00
Kachelkaiser
48cb816111 Translated using Weblate (German)
Currently translated at 100.0% (405 of 405 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/de/
2025-06-30 16:08:38 +02:00
Carl Fritze
ff904a5ca6 Translated using Weblate (German)
Currently translated at 100.0% (405 of 405 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/de/
2025-06-30 16:08:38 +02:00
Priit Jõerüüt
8e7de80353 Translated using Weblate (Estonian)
Currently translated at 67.1% (272 of 405 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/et/
2025-06-26 23:01:49 +02:00
huy.phan
9c8a8f8795 Translated using Weblate (Vietnamese)
Currently translated at 20.0% (81 of 405 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/vi/
2025-06-26 23:01:47 +02:00
Priit Jõerüüt
df73c6f655 Translated using Weblate (Estonian)
Currently translated at 52.5% (213 of 405 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/et/
2025-06-24 18:01:52 +02:00
Kachelkaiser
c1e657db8b Translated using Weblate (German)
Currently translated at 100.0% (405 of 405 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/de/
2025-06-24 18:01:50 +02:00
Joan
62c8a13ed4 Translated using Weblate (Catalan)
Currently translated at 1.2% (5 of 405 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ca/
2025-06-21 13:01:46 +02:00
Joan
994266ab04 Added translation using Weblate (Catalan) 2025-06-20 12:07:37 +02:00
Niko Diamadis
a41e3a1e76 Update App Store badges and remove Docker compose versions 2025-06-20 00:45:42 +02:00
lazar
86bec660bf Translated using Weblate (Romanian)
Currently translated at 60.2% (244 of 405 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ro/
2025-06-08 15:50:23 +02:00
Philipp C. Heckel
30301c8a7f Update README.md 2025-06-07 06:49:22 -04:00
binwiederhier
7b470a7f6f Sponsors 2025-06-07 06:45:43 -04:00
Priit Jõerüüt
9d5891963a Translated using Weblate (Estonian)
Currently translated at 44.1% (179 of 405 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/et/
2025-06-03 22:04:33 +02:00
Philipp C. Heckel
de8e3bc2aa Merge pull request #1360 from binwiederhier/client-ip-header
Add custom client IP header
2025-06-01 10:12:34 -04:00
93 changed files with 10399 additions and 986 deletions

View File

@@ -1,76 +1,70 @@
version: 2
before:
hooks:
- go mod download
- go mod tidy
builds:
-
id: ntfy_linux_amd64
- id: ntfy_linux_amd64
binary: ntfy
env:
- CGO_ENABLED=1 # required for go-sqlite3
tags: [sqlite_omit_load_extension,osusergo,netgo]
tags: [ sqlite_omit_load_extension,osusergo,netgo ]
ldflags:
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
goos: [linux]
goarch: [amd64]
-
id: ntfy_linux_armv6
goos: [ linux ]
goarch: [ amd64 ]
- id: ntfy_linux_armv6
binary: ntfy
env:
- CGO_ENABLED=1 # required for go-sqlite3
- CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi
tags: [sqlite_omit_load_extension,osusergo,netgo]
tags: [ sqlite_omit_load_extension,osusergo,netgo ]
ldflags:
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
goos: [linux]
goarch: [arm]
goarm: [6]
-
id: ntfy_linux_armv7
goos: [ linux ]
goarch: [ arm ]
goarm: [ 6 ]
- id: ntfy_linux_armv7
binary: ntfy
env:
- CGO_ENABLED=1 # required for go-sqlite3
- CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi
tags: [sqlite_omit_load_extension,osusergo,netgo]
tags: [ sqlite_omit_load_extension,osusergo,netgo ]
ldflags:
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
goos: [linux]
goarch: [arm]
goarm: [7]
-
id: ntfy_linux_arm64
goos: [ linux ]
goarch: [ arm ]
goarm: [ 7 ]
- id: ntfy_linux_arm64
binary: ntfy
env:
- CGO_ENABLED=1 # required for go-sqlite3
- CC=aarch64-linux-gnu-gcc # apt install gcc-aarch64-linux-gnu
tags: [sqlite_omit_load_extension,osusergo,netgo]
tags: [ sqlite_omit_load_extension,osusergo,netgo ]
ldflags:
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
goos: [linux]
goarch: [arm64]
-
id: ntfy_windows_amd64
goos: [ linux ]
goarch: [ arm64 ]
- id: ntfy_windows_amd64
binary: ntfy
env:
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
tags: [noserver] # don't include server files
tags: [ noserver ] # don't include server files
ldflags:
- "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
goos: [windows]
goarch: [amd64]
-
id: ntfy_darwin_all
goos: [ windows ]
goarch: [ amd64 ]
- id: ntfy_darwin_all
binary: ntfy
env:
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
tags: [noserver] # don't include server files
tags: [ noserver ] # don't include server files
ldflags:
- "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
goos: [darwin]
goarch: [amd64, arm64] # will be combined to "universal binary" (see below)
goos: [ darwin ]
goarch: [ amd64, arm64 ] # will be combined to "universal binary" (see below)
nfpms:
-
package_name: ntfy
- package_name: ntfy
homepage: https://heckel.io/ntfy
maintainer: Philipp C. Heckel <philipp.heckel@gmail.com>
description: Simple pub-sub notification service
@@ -106,9 +100,8 @@ nfpms:
preremove: "scripts/prerm.sh"
postremove: "scripts/postrm.sh"
archives:
-
id: ntfy_linux
builds:
- id: ntfy_linux
ids:
- ntfy_linux_amd64
- ntfy_linux_armv6
- ntfy_linux_armv7
@@ -122,19 +115,17 @@ archives:
- client/client.yml
- client/ntfy-client.service
- client/user/ntfy-client.service
-
id: ntfy_windows
builds:
- id: ntfy_windows
ids:
- ntfy_windows_amd64
format: zip
formats: [ zip ]
wrap_in_directory: true
files:
- LICENSE
- README.md
- client/client.yml
-
id: ntfy_darwin
builds:
- id: ntfy_darwin
ids:
- ntfy_darwin_all
wrap_in_directory: true
files:
@@ -142,14 +133,13 @@ archives:
- README.md
- client/client.yml
universal_binaries:
-
id: ntfy_darwin_all
- id: ntfy_darwin_all
replace: true
name_template: ntfy
checksum:
name_template: 'checksums.txt'
snapshot:
name_template: "{{ .Tag }}-next"
version_template: "{{ .Tag }}-next"
changelog:
sort: asc
filters:

View File

@@ -220,7 +220,7 @@ cli-deps-static-sites:
touch server/docs/index.html server/site/app.html
cli-deps-all:
go install github.com/goreleaser/goreleaser@latest
go install github.com/goreleaser/goreleaser/v2@latest
cli-deps-gcc-armv6-armv7:
which arm-linux-gnueabi-gcc || { echo "ERROR: ARMv6/ARMv7 cross compiler not installed. On Ubuntu, run: apt install gcc-arm-linux-gnueabi"; exit 1; }
@@ -232,7 +232,7 @@ cli-deps-update:
go get -u
go install honnef.co/go/tools/cmd/staticcheck@latest
go install golang.org/x/lint/golint@latest
go install github.com/goreleaser/goreleaser@latest
go install github.com/goreleaser/goreleaser/v2@latest
cli-build-results:
cat dist/config.yaml
@@ -301,7 +301,7 @@ release: clean cli-deps release-checks docs web check
goreleaser release --clean
release-snapshot: clean cli-deps docs web check
goreleaser release --snapshot --skip-publish --clean
goreleaser release --snapshot --clean
release-checks:
$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-))

View File

@@ -56,20 +56,18 @@ For announcements of new releases and cutting-edge beta versions, please subscri
topic. If you'd like to test the iOS app, join [TestFlight](https://testflight.apple.com/join/P1fFnAm9). For Android betas,
join Discord/Matrix (I'll eventually make a testing channel in Google Play).
## Contributing
I welcome any contributions. Just create a PR or an issue. For larger features/ideas, please reach out
on Discord/Matrix first to see if I'd accept them. To contribute code, check out the [build instructions](https://ntfy.sh/docs/develop/)
for the server and the Android app. Or, if you'd like to help translate 🇩🇪 🇺🇸 🇧🇬, you can start immediately in
[Hosted Weblate](https://hosted.weblate.org/projects/ntfy/).
<a href="https://hosted.weblate.org/engage/ntfy/">
<img src="https://hosted.weblate.org/widgets/ntfy/-/multi-blue.svg" alt="Translation status" />
</a>
## Sponsors
I have just very recently started accepting donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier),
and [Liberapay](https://liberapay.com/ntfy). I would be humbled if you helped me carry the server and developer
account costs. Even small donations are very much appreciated. A big fat **Thank You** to the folks who have sponsored ntfy in the past, or are still sponsoring ntfy:
If you'd like to support the ntfy maintainers, please consider donating to [GitHub Sponsors](https://github.com/sponsors/binwiederhier) or
and [Liberapay](https://liberapay.com/ntfy). We would be humbled if you helped carry the server and developer
account costs. Even small donations are very much appreciated.
Thank you to our commercial sponsors, who help keep the service running and the development going:
<a href="https://m.do.co/c/442b929528db"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="201px"></a>
<a href="https://www.magicbell.com/?utm_source=ntfy"><img src="assets/sponsors/magicbell.png" width="180px"></a>
And a big fat **Thank You** to the individuals who have sponsored ntfy in the past, or are still sponsoring ntfy:
<a href="https://github.com/neutralinsomniac"><img src="https://github.com/neutralinsomniac.png" width="40px" /></a>
<a href="https://github.com/aspyct"><img src="https://github.com/aspyct.png" width="40px" /></a>
@@ -210,13 +208,21 @@ account costs. Even small donations are very much appreciated. A big fat **Thank
<a href="https://github.com/user8446"><img src="https://github.com/user8446.png" width="40px" /></a>
<a href="https://github.com/cdf-eagles"><img src="https://github.com/cdf-eagles.png" width="40px" /></a>
I'd also like to thank JetBrains for their awesome [IntelliJ IDEA](https://www.jetbrains.com/idea/),
and [DigitalOcean](https://m.do.co/c/442b929528db) (*referral link*) for supporting the project:
## Contributing
I welcome any contributions. Just create a PR or an issue. For larger features/ideas, please reach out
on Discord/Matrix first to see if I'd accept them. To contribute code, check out the [build instructions](https://ntfy.sh/docs/develop/)
for the server and the Android app. Or, if you'd like to help translate 🇩🇪 🇺🇸 🇧🇬, you can start immediately in
[Hosted Weblate](https://hosted.weblate.org/projects/ntfy/).
<a href="https://m.do.co/c/442b929528db"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="201px"></a>
<a href="https://hosted.weblate.org/engage/ntfy/">
<img src="https://hosted.weblate.org/widgets/ntfy/-/multi-blue.svg" alt="Translation status" />
</a>
## Code of Conduct
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for
everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity
and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste,
color, religion, or sexual identity and orientation.
**We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.**
@@ -247,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

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -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)

View File

@@ -105,8 +105,10 @@ func changeAccess(c *cli.Context, manager *user.Manager, username string, topic
return err
}
u, err := manager.User(username)
if err == user.ErrUserNotFound {
if errors.Is(err, user.ErrUserNotFound) {
return fmt.Errorf("user %s does not exist", username)
} else if err != nil {
return err
} else if u.Role == user.RoleAdmin {
return fmt.Errorf("user %s is an admin user, access control entries have no effect", username)
}
@@ -175,7 +177,7 @@ func showAllAccess(c *cli.Context, manager *user.Manager) error {
func showUserAccess(c *cli.Context, manager *user.Manager, username string) error {
users, err := manager.User(username)
if err == user.ErrUserNotFound {
if errors.Is(err, user.ErrUserNotFound) {
return fmt.Errorf("user %s does not exist", username)
} else if err != nil {
return err
@@ -193,19 +195,27 @@ func showUsers(c *cli.Context, manager *user.Manager, users []*user.User) error
if u.Tier != nil {
tier = u.Tier.Name
}
fmt.Fprintf(c.App.ErrWriter, "user %s (role: %s, tier: %s)\n", u.Name, u.Role, tier)
provisioned := ""
if u.Provisioned {
provisioned = ", provisioned user"
}
fmt.Fprintf(c.App.ErrWriter, "user %s (role: %s, tier: %s%s)\n", u.Name, u.Role, tier, provisioned)
if u.Role == user.RoleAdmin {
fmt.Fprintf(c.App.ErrWriter, "- read-write access to all topics (admin role)\n")
} else if len(grants) > 0 {
for _, grant := range grants {
if grant.Allow.IsReadWrite() {
fmt.Fprintf(c.App.ErrWriter, "- read-write access to topic %s\n", grant.TopicPattern)
} else if grant.Allow.IsRead() {
fmt.Fprintf(c.App.ErrWriter, "- read-only access to topic %s\n", grant.TopicPattern)
} else if grant.Allow.IsWrite() {
fmt.Fprintf(c.App.ErrWriter, "- write-only access to topic %s\n", grant.TopicPattern)
grantProvisioned := ""
if grant.Provisioned {
grantProvisioned = ", provisioned access entry"
}
if grant.Permission.IsReadWrite() {
fmt.Fprintf(c.App.ErrWriter, "- read-write access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
} else if grant.Permission.IsRead() {
fmt.Fprintf(c.App.ErrWriter, "- read-only access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
} else if grant.Permission.IsWrite() {
fmt.Fprintf(c.App.ErrWriter, "- write-only access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
} else {
fmt.Fprintf(c.App.ErrWriter, "- no access to topic %s\n", grant.TopicPattern)
fmt.Fprintf(c.App.ErrWriter, "- no access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
}
}
} else {

View File

@@ -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"},
@@ -69,6 +70,7 @@ Examples:
ntfy pub --icon="http://some.tld/icon.png" 'Icon!' # Send notification with custom icon
ntfy pub --attach="http://some.tld/file.zip" files # Send ZIP archive from URL as attachment
ntfy pub --file=flower.jpg flowers 'Nice!' # Send image.jpg as attachment
echo 'message' | ntfy publish mytopic # Send message from stdin
ntfy pub -u phil:mypass secret Psst # Publish with username/password
ntfy pub --wait-pid 1234 mytopic # Wait for process 1234 to exit before publishing
ntfy pub --wait-cmd mytopic rsync -av ./ /tmp/a # Run command and publish after it completes
@@ -97,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")
@@ -145,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))
}
@@ -254,6 +260,15 @@ func parseTopicMessageCommand(c *cli.Context) (topic string, message string, com
if c.String("message") != "" {
message = c.String("message")
}
if message == "" && isStdinRedirected() {
var data []byte
data, err = io.ReadAll(io.LimitReader(c.App.Reader, 1024*1024))
if err != nil {
log.Debug("Failed to read from stdin: %s", err.Error())
return
}
message = strings.TrimSpace(string(data))
}
return
}
@@ -312,3 +327,12 @@ func runAndWaitForCommand(command []string) (message string, err error) {
log.Debug("Command succeeded after %s: %s", runtime, prettyCmd)
return fmt.Sprintf("Command succeeded after %s: %s", runtime, prettyCmd), nil
}
func isStdinRedirected() bool {
stat, err := os.Stdin.Stat()
if err != nil {
log.Debug("Failed to stat stdin: %s", err.Error())
return false
}
return (stat.Mode() & os.ModeCharDevice) == 0
}

View File

@@ -5,13 +5,6 @@ package cmd
import (
"errors"
"fmt"
"github.com/stripe/stripe-go/v74"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/server"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
"io/fs"
"math"
"net"
@@ -22,19 +15,23 @@ import (
"strings"
"syscall"
"time"
"github.com/stripe/stripe-go/v74"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/server"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
)
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"}),
@@ -51,10 +48,13 @@ var flagsServe = append(
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-startup-queries", Aliases: []string{"auth_startup_queries"}, EnvVars: []string{"NTFY_AUTH_STARTUP_QUERIES"}, Usage: "queries run when the auth database is initialized"}),
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"}),
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-provision-users", Aliases: []string{"auth_provision_users"}, EnvVars: []string{"NTFY_AUTH_PROVISION_USERS"}, Usage: "pre-provisioned declarative users"}),
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-provision-access", Aliases: []string{"auth_provision_access"}, EnvVars: []string{"NTFY_AUTH_PROVISION_ACCESS"}, Usage: "pre-provisioned declarative access control entries"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}),
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"}),
@@ -79,6 +79,7 @@ var flagsServe = append(
altsrc.NewStringFlag(&cli.StringFlag{Name: "message-delay-limit", Aliases: []string{"message_delay_limit"}, EnvVars: []string{"NTFY_MESSAGE_DELAY_LIMIT"}, Value: util.FormatDuration(server.DefaultMessageDelayMax), Usage: "max duration a message can be scheduled into the future"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "visitor-subscriber-rate-limiting", Aliases: []string{"visitor_subscriber_rate_limiting"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING"}, Value: false, Usage: "enables subscriber-based rate limiting"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultVisitorAttachmentTotalSizeLimit), Usage: "total storage limit used for attachments per visitor"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", Aliases: []string{"visitor_attachment_daily_bandwidth_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload bandwidth limit per visitor"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", Aliases: []string{"visitor_request_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}),
@@ -87,10 +88,11 @@ var flagsServe = append(
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorEmailLimitReplenish), Usage: "interval at which burst limit is replenished (one per x)"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "visitor-subscriber-rate-limiting", Aliases: []string{"visitor_subscriber_rate_limiting"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING"}, Value: false, Usage: "enables subscriber-based rate limiting"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv4", Aliases: []string{"visitor_prefix_bits_ipv4"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV4"}, Value: server.DefaultVisitorPrefixBitsIPv4, Usage: "number of bits of the IPv4 address to use for rate limiting (default: 32, full address)"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv6", Aliases: []string{"visitor_prefix_bits_ipv6"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV6"}, Value: server.DefaultVisitorPrefixBitsIPv6, Usage: "number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-forwarded-header", Aliases: []string{"proxy_forwarded_header"}, EnvVars: []string{"NTFY_PROXY_FORWARDED_HEADER"}, Value: "X-Forwarded-For", Usage: "use specified header to determine visitor IP address (for rate limiting)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-trusted-addresses", Aliases: []string{"proxy_trusted_addresses"}, EnvVars: []string{"NTFY_PROXY_TRUSTED_ADDRESSES"}, Value: "", Usage: "comma-separated list of trusted IP addresses to remove from forwarded header"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-trusted-hosts", Aliases: []string{"proxy_trusted_hosts"}, EnvVars: []string{"NTFY_PROXY_TRUSTED_HOSTS"}, Value: "", Usage: "comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-secret-key", Aliases: []string{"stripe_secret_key"}, EnvVars: []string{"NTFY_STRIPE_SECRET_KEY"}, Value: "", Usage: "key used for the Stripe API communication, this enables payments"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-webhook-key", Aliases: []string{"stripe_webhook_key"}, EnvVars: []string{"NTFY_STRIPE_WEBHOOK_KEY"}, Value: "", Usage: "key required to validate the authenticity of incoming webhooks from Stripe"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "billing-contact", Aliases: []string{"billing_contact"}, EnvVars: []string{"NTFY_BILLING_CONTACT"}, Value: "", Usage: "e-mail or website to display in upgrade dialog (only if payments are enabled)"}),
@@ -154,10 +156,13 @@ func execServe(c *cli.Context) error {
authFile := c.String("auth-file")
authStartupQueries := c.String("auth-startup-queries")
authDefaultAccess := c.String("auth-default-access")
authProvisionUsersRaw := c.StringSlice("auth-provision-users")
authProvisionAccessRaw := c.StringSlice("auth-provision-access")
attachmentCacheDir := c.String("attachment-cache-dir")
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")
@@ -191,9 +196,11 @@ func execServe(c *cli.Context) error {
visitorMessageDailyLimit := c.Int("visitor-message-daily-limit")
visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
visitorEmailLimitReplenishStr := c.String("visitor-email-limit-replenish")
visitorPrefixBitsIPv4 := c.Int("visitor-prefix-bits-ipv4")
visitorPrefixBitsIPv6 := c.Int("visitor-prefix-bits-ipv6")
behindProxy := c.Bool("behind-proxy")
proxyForwardedHeader := c.String("proxy-forwarded-header")
proxyTrustedAddresses := util.SplitNoEmpty(c.String("proxy-trusted-addresses"), ",")
proxyTrustedHosts := util.SplitNoEmpty(c.String("proxy-trusted-hosts"), ",")
stripeSecretKey := c.String("stripe-secret-key")
stripeWebhookKey := c.String("stripe-webhook-key")
billingContact := c.String("billing-contact")
@@ -324,6 +331,10 @@ func execServe(c *cli.Context) error {
return errors.New("web push expiry warning duration cannot be higher than web push expiry duration")
} else if behindProxy && proxyForwardedHeader == "" {
return errors.New("if behind-proxy is set, proxy-forwarded-header must also be set")
} else if visitorPrefixBitsIPv4 < 1 || visitorPrefixBitsIPv4 > 32 {
return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32")
} else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 {
return errors.New("visitor-prefix-bits-ipv6 must be between 1 and 128")
}
// Backwards compatibility
@@ -337,11 +348,19 @@ func execServe(c *cli.Context) error {
webRoot = "/" + webRoot
}
// Default auth permissions
// Convert default auth permission, read provisioned users
authDefault, err := user.ParsePermission(authDefaultAccess)
if err != nil {
return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
}
authProvisionUsers, err := parseProvisionUsers(authProvisionUsersRaw)
if err != nil {
return err
}
authProvisionAccess, err := parseProvisionAccess(authProvisionUsers, authProvisionAccessRaw)
if err != nil {
return err
}
// Special case: Unset default
if listenHTTP == "-" {
@@ -349,14 +368,24 @@ func execServe(c *cli.Context) error {
}
// Resolve hosts
visitorRequestLimitExemptIPs := make([]netip.Prefix, 0)
visitorRequestLimitExemptPrefixes := make([]netip.Prefix, 0)
for _, host := range visitorRequestLimitExemptHosts {
ips, err := parseIPHostPrefix(host)
prefixes, err := parseIPHostPrefix(host)
if err != nil {
log.Warn("cannot resolve host %s: %s, ignoring visitor request exemption", host, err.Error())
continue
}
visitorRequestLimitExemptIPs = append(visitorRequestLimitExemptIPs, ips...)
visitorRequestLimitExemptPrefixes = append(visitorRequestLimitExemptPrefixes, prefixes...)
}
// Parse trusted prefixes
trustedProxyPrefixes := make([]netip.Prefix, 0)
for _, host := range proxyTrustedHosts {
prefixes, err := parseIPHostPrefix(host)
if err != nil {
return fmt.Errorf("cannot resolve trusted proxy host %s: %s", host, err.Error())
}
trustedProxyPrefixes = append(trustedProxyPrefixes, prefixes...)
}
// Stripe things
@@ -387,10 +416,13 @@ func execServe(c *cli.Context) error {
conf.AuthFile = authFile
conf.AuthStartupQueries = authStartupQueries
conf.AuthDefault = authDefault
conf.AuthProvisionedUsers = authProvisionUsers
conf.AuthProvisionedAccess = authProvisionAccess
conf.AttachmentCacheDir = attachmentCacheDir
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
conf.AttachmentExpiryDuration = attachmentExpiryDuration
conf.TemplateDir = templateDir
conf.KeepaliveInterval = keepaliveInterval
conf.ManagerInterval = managerInterval
conf.DisallowedTopics = disallowedTopics
@@ -412,18 +444,20 @@ func execServe(c *cli.Context) error {
conf.MessageDelayMax = messageDelayLimit
conf.TotalTopicLimit = totalTopicLimit
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
conf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting
conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit
conf.VisitorAttachmentDailyBandwidthLimit = visitorAttachmentDailyBandwidthLimit
conf.VisitorRequestLimitBurst = visitorRequestLimitBurst
conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish
conf.VisitorRequestExemptIPAddrs = visitorRequestLimitExemptIPs
conf.VisitorRequestExemptPrefixes = visitorRequestLimitExemptPrefixes
conf.VisitorMessageDailyLimit = visitorMessageDailyLimit
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
conf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting
conf.VisitorPrefixBitsIPv4 = visitorPrefixBitsIPv4
conf.VisitorPrefixBitsIPv6 = visitorPrefixBitsIPv6
conf.BehindProxy = behindProxy
conf.ProxyForwardedHeader = proxyForwardedHeader
conf.ProxyTrustedAddresses = proxyTrustedAddresses
conf.ProxyTrustedPrefixes = trustedProxyPrefixes
conf.StripeSecretKey = stripeSecretKey
conf.StripeWebhookKey = stripeWebhookKey
conf.BillingContact = billingContact
@@ -433,7 +467,6 @@ func execServe(c *cli.Context) error {
conf.EnableMetrics = enableMetrics
conf.MetricsListenHTTP = metricsListenHTTP
conf.ProfileListenHTTP = profileListenHTTP
conf.Version = c.App.Version
conf.WebPushPrivateKey = webPushPrivateKey
conf.WebPushPublicKey = webPushPublicKey
conf.WebPushFile = webPushFile
@@ -441,6 +474,7 @@ func execServe(c *cli.Context) error {
conf.WebPushStartupQueries = webPushStartupQueries
conf.WebPushExpiryDuration = webPushExpiryDuration
conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration
conf.Version = c.App.Version
// Set up hot-reloading of config
go sigHandlerConfigReload(config)
@@ -473,7 +507,7 @@ func sigHandlerConfigReload(config string) {
}
func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) {
// Try parsing as prefix, e.g. 10.0.1.0/24
// Try parsing as prefix, e.g. 10.0.1.0/24 or 2001:db8::/32
prefix, err := netip.ParsePrefix(host)
if err == nil {
prefixes = append(prefixes, prefix.Masked())
@@ -497,6 +531,76 @@ func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) {
return
}
func parseProvisionUsers(usersRaw []string) ([]*user.User, error) {
provisionUsers := make([]*user.User, 0)
for _, userLine := range usersRaw {
parts := strings.Split(userLine, ":")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid auth-provision-users: %s, expected format: 'name:hash:role'", userLine)
}
username := strings.TrimSpace(parts[0])
passwordHash := strings.TrimSpace(parts[1])
role := user.Role(strings.TrimSpace(parts[2]))
if !user.AllowedUsername(username) {
return nil, fmt.Errorf("invalid auth-provision-users: %s, username invalid", userLine)
} else if err := user.AllowedPasswordHash(passwordHash); err != nil {
return nil, fmt.Errorf("invalid auth-provision-users: %s, %s", userLine, err.Error())
} else if !user.AllowedRole(role) {
return nil, fmt.Errorf("invalid auth-provision-users: %s, role %s is not allowed, allowed roles are 'admin' or 'user'", userLine, role)
}
provisionUsers = append(provisionUsers, &user.User{
Name: username,
Hash: passwordHash,
Role: role,
Provisioned: true,
})
}
return provisionUsers, nil
}
func parseProvisionAccess(provisionUsers []*user.User, provisionAccessRaw []string) (map[string][]*user.Grant, error) {
access := make(map[string][]*user.Grant)
for _, accessLine := range provisionAccessRaw {
parts := strings.Split(accessLine, ":")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid auth-provision-access: %s, expected format: 'user:topic:permission'", accessLine)
}
username := strings.TrimSpace(parts[0])
if username == userEveryone {
username = user.Everyone
}
provisionUser, exists := util.Find(provisionUsers, func(u *user.User) bool {
return u.Name == username
})
if username != user.Everyone {
if !exists {
return nil, fmt.Errorf("invalid auth-provision-access: %s, user %s is not provisioned", accessLine, username)
} else if !user.AllowedUsername(username) {
return nil, fmt.Errorf("invalid auth-provision-access: %s, username %s invalid", accessLine, username)
} else if provisionUser.Role != user.RoleUser {
return nil, fmt.Errorf("invalid auth-provision-access: %s, user %s is not a regular user, only regular users can have ACL entries", accessLine, username)
}
}
topic := strings.TrimSpace(parts[1])
if !user.AllowedTopicPattern(topic) {
return nil, fmt.Errorf("invalid auth-provision-access: %s, topic pattern %s invalid", accessLine, topic)
}
permission, err := user.ParsePermission(strings.TrimSpace(parts[2]))
if err != nil {
return nil, fmt.Errorf("invalid auth-provision-access: %s, permission %s invalid, %s", accessLine, parts[2], err.Error())
}
if _, exists := access[username]; !exists {
access[username] = make([]*user.Grant, 0)
}
access[username] = append(access[username], &user.Grant{
TopicPattern: topic,
Permission: permission,
Provisioned: true,
})
}
return access, nil
}
func reloadLogLevel(inputSource altsrc.InputSourceContext) error {
newLevelStr, err := inputSource.String("log-level")
if err != nil {

View File

@@ -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"}),
)
@@ -94,7 +95,6 @@ Example:
You may set the NTFY_PASSWORD environment variable to pass the new password or NTFY_PASSWORD_HASH to pass
directly the bcrypt hash. This is useful if you are updating users via scripts.
`,
},
{
@@ -133,6 +133,22 @@ as messages per day, attachment file sizes, etc.
Example:
ntfy user change-tier phil pro # Change tier to "pro" for user "phil"
ntfy user change-tier phil - # Remove tier from user "phil" entirely
`,
},
{
Name: "hash",
Usage: "Create password hash for a predefined user",
UsageText: "ntfy user hash",
Action: execUserHash,
Description: `Asks for a password and creates a bcrypt password hash.
This command is useful to create a password hash for a user, which can then be used
for predefined users in the server config file, in auth-provision-users.
Example:
$ ntfy user hash
(asks for password and confirmation)
$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C
`,
},
{
@@ -225,7 +241,7 @@ func execUserDel(c *cli.Context) error {
if err != nil {
return err
}
if _, err := manager.User(username); err == user.ErrUserNotFound {
if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) {
return fmt.Errorf("user %s does not exist", username)
}
if err := manager.RemoveUser(username); err != nil {
@@ -251,7 +267,7 @@ func execUserChangePass(c *cli.Context) error {
if err != nil {
return err
}
if _, err := manager.User(username); err == user.ErrUserNotFound {
if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) {
return fmt.Errorf("user %s does not exist", username)
}
if password == "" {
@@ -279,7 +295,7 @@ func execUserChangeRole(c *cli.Context) error {
if err != nil {
return err
}
if _, err := manager.User(username); err == user.ErrUserNotFound {
if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) {
return fmt.Errorf("user %s does not exist", username)
}
if err := manager.ChangeRole(username, role); err != nil {
@@ -289,6 +305,23 @@ func execUserChangeRole(c *cli.Context) error {
return nil
}
func execUserHash(c *cli.Context) error {
manager, err := createUserManager(c)
if err != nil {
return err
}
password, err := readPasswordAndConfirm(c)
if err != nil {
return err
}
hash, err := manager.HashPassword(password)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
fmt.Fprintf(c.App.Writer, "%s\n", string(hash))
return nil
}
func execUserChangeTier(c *cli.Context) error {
username := c.Args().Get(0)
tier := c.Args().Get(1)
@@ -303,7 +336,7 @@ func execUserChangeTier(c *cli.Context) error {
if err != nil {
return err
}
if _, err := manager.User(username); err == user.ErrUserNotFound {
if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) {
return fmt.Errorf("user %s does not exist", username)
}
if tier == tierReset {
@@ -345,7 +378,15 @@ func createUserManager(c *cli.Context) (*user.Manager, error) {
if err != nil {
return nil, errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
}
return user.NewManager(authFile, authStartupQueries, authDefault, user.DefaultUserPasswordBcryptCost, user.DefaultUserStatsQueueWriterInterval)
authConfig := &user.Config{
Filename: authFile,
StartupQueries: authStartupQueries,
DefaultAccess: authDefault,
ProvisionEnabled: false, // Do not re-provision users on manager initialization
BcryptCost: user.DefaultUserPasswordBcryptCost,
QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval,
}
return user.NewManager(authConfig)
}
func readPasswordAndConfirm(c *cli.Context) (string, error) {

View File

@@ -1,4 +1,3 @@
version: "2.1"
services:
ntfy:
image: binwiederhier/ntfy
@@ -14,4 +13,3 @@ services:
ports:
- 80:80
restart: unless-stopped

View File

@@ -18,8 +18,8 @@ get a list of [command line options](#command-line-options).
## Example config
!!! info
Definitely check out the **[server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml)** file.
It contains examples and detailed descriptions of all the settings.
Definitely check out the **[server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml)** file. It contains examples and detailed descriptions of all the settings.
You may also want to look at how ntfy.sh is configured in the [ntfy-ansible](https://github.com/binwiederhier/ntfy-ansible) repository.
The most basic settings are `base-url` (the external URL of the ntfy server), the HTTP/HTTPS listen address (`listen-http`
and `listen-https`), and socket path (`listen-unix`). All the other things are additional features.
@@ -79,7 +79,6 @@ using Docker Compose (i.e. `docker-compose.yml`):
=== "Docker Compose (w/ auth, cache, attachments)"
``` yaml
version: '3'
services:
ntfy:
image: binwiederhier/ntfy
@@ -101,7 +100,6 @@ using Docker Compose (i.e. `docker-compose.yml`):
=== "Docker Compose (w/ auth, cache, web push, iOS)"
``` yaml
version: '3'
services:
ntfy:
image: binwiederhier/ntfy
@@ -559,7 +557,7 @@ If you are running ntfy behind a proxy, you should set the `behind-proxy` flag.
as the primary identifier for a visitor, as opposed to the remote IP address.
If the `behind-proxy` flag is not set, all visitors will be counted as one, because from the perspective of the
ntfy server, they all share the proxy's IP address.
ntfy server, they all share the proxy's IP address.
Relevant flags to consider:
@@ -568,9 +566,17 @@ Relevant flags to consider:
* `proxy-forwarded-header` is the header to use to identify visitors (default: `X-Forwarded-For`). It may be a single IP address (e.g. `1.2.3.4`),
a comma-separated list of IP addresses (e.g. `1.2.3.4, 5.6.7.8`), or an [RFC 7239](https://datatracker.ietf.org/doc/html/rfc7239)-style
header (e.g. `for=1.2.3.4;by=proxy.example.com, for=5.6.7.8`).
* `proxy-trusted-addresses` is a comma-separated list of IP addresses that are removed from the forwarded header
* `proxy-trusted-hosts` is a comma-separated list of IP addresses, hosts or CIDRs that are removed from the forwarded header
to determine the real IP address. This is only useful if there are multiple proxies involved that add themselves to
the forwarded header (default: empty).
* `visitor-prefix-bits-ipv4` is the number of bits of the IPv4 address to use for rate limiting (default is `32`, which is the entire
IP address). In IPv4 environments, by default, a visitor's **full IPv4 address** is used as-is for rate limiting. This means that
if someone publishes messages from multiple IP addresses, they will be counted as separate visitors. You can adjust this by setting the `visitor-prefix-bits-ipv4` config option. To group visitors in a /24 subnet and count them as one, for instance,
set it to `24`. In that case, `1.2.3.4` and `1.2.3.99` are treated as the same visitor.
* `visitor-prefix-bits-ipv6` is the number of bits of the IPv6 address to use for rate limiting (default is `64`, which is a /64 subnet).
In IPv6 environments, by default, a visitor's IP address is **truncated to the /64 subnet**, meaning that `2001:db8:25:86:1::1` and
`2001:db8:25:86:2::1` are treated as the same visitor. Use the `visitor-prefix-bits-ipv6` config option to adjust this behavior.
See [IPv6 considerations](#ipv6-considerations) for more details.
=== "/etc/ntfy/server.yml (behind a proxy)"
``` yaml
@@ -613,7 +619,21 @@ Relevant flags to consider:
# the visitor IP will be 9.9.9.9 (right-most unknown address).
#
behind-proxy: true
proxy-trusted-addresses: "1.2.3.4, 1.2.3.5"
proxy-trusted-hosts: "1.2.3.0/24, 1.2.2.2, 2001:db8::/64"
```
=== "/etc/ntfy/server.yml (adjusted IPv4/IPv6 prefixes proxies)"
``` yaml
# Tell ntfy to treat visitors as being in a /24 subnet (IPv4) or /48 subnet (IPv6)
# as one visitor, so that they are counted as one for rate limiting.
#
# Example 1: If 1.2.3.4 and 1.2.3.5 publish a message, the visitor 1.2.3.0 will have
# used 2 messages.
# Example 2: If 2001:db8:2500:1::1 and 2001:db8:2500:2::1 publish a message, the visitor
# 2001:db8:2500:: will have used 2 messages.
#
visitor-prefix-bits-ipv4: 24
visitor-prefix-bits-ipv6: 48
```
### TLS/SSL
@@ -1138,6 +1158,18 @@ If this ever happens, there will be a log message that looks something like this
WARN Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor
```
### IPv6 considerations
By default, rate limiting for IPv6 is done using the `/64` subnet of the visitor's IPv6 address. This means that all visitors
in the same `/64` subnet are treated as one visitor. This is done to prevent abuse, as IPv6 subnet assignments are typically
much larger than IPv4 subnets (and much cheaper), and it is common for ISPs to assign large subnets to their customers.
Other than that, rate limiting for IPv6 is done the same way as for IPv4, using the visitor's IP address or subnet to identify them.
There are two options to configure the number of bits used for rate limiting (for IPv4 and IPv6):
- `visitor-prefix-bits-ipv4` is number of bits of the IPv4 address to use for rate limiting (default: 32, full address)
- `visitor-prefix-bits-ipv6` is number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)
### Subscriber-based rate limiting
By default, ntfy puts almost all rate limits on the message publisher, e.g. number of messages, requests, and attachment
size are all based on the visitor who publishes a message. **Subscriber-based rate limiting is a way to use the rate limits
@@ -1302,6 +1334,25 @@ Note that if you run nginx in a container, append `, chain=DOCKER-USER` to the j
is `INPUT`, but `FORWARD` is used when using docker networks. `DOCKER-USER`, available when using docker, is part of the `FORWARD`
chain.
The official ntfy.sh server uses fail2ban to ban IPs. Check out ntfy.sh's [Ansible fail2ban role](https://github.com/binwiederhier/ntfy-ansible/tree/main/roles/fail2ban) for details. Ban actors are banned for 1 hour initially, and up to
4 hours at a time for repeated offenses. IPv4 addresses are banned individually, while IPv6 addresses are banned by their `/56` prefix.
## IPv6 support
ntfy fully supports IPv6, though there are a few things to keep in mind.
- **Listening on an IPv6 address**: By default, ntfy listens on `:80` (IPv4-only). If you want to listen on an IPv6 address, you need to
explicitly set the `listen-http` and/or `listen-https` options in your `server.yml` file to an IPv6 address, e.g. `[::]:80`. To listen on
IPv4 and IPv6, you must run ntfy behind a reverse proxy, e.g. `listen :80; listen [::]:80;` in nginx.
- **Rate limiting:** By default, ntfy uses the `/64` subnet of the visitor's IPv6 address for rate limiting. This means that all visitors in the same `/64`
subnet are treated as one visitor. If you want to change this, you can set the `visitor-prefix-bits-ipv6` option in your `server.yml` file to a different
value (e.g. `48` for `/48` subnets). See [IPv6 considerations](#ipv6-considerations) and [IP-based rate limiting](#ip-based-rate-limiting) for more details.
- **Banning IPs with fail2ban:** By default, if you're using the `iptables-multiport` action, fail2ban bans individual IPv4 and IPv6 addresses via `iptables` and `ip6tables`. While this behavior is fine for IPv4, it is not for IPv6, because every host can technically have up to 2^64 addresses. Please ensure that your `actionban` and `actionunban` commands
support IPv6 and also ban the entire prefix (e.g. `/48`). See [Banning bad actors](#banning-bad-actors-fail2ban) for details.
!!! info
The official ntfy.sh server supports IPv6. Check out ntfy.sh's [Ansible repository](https://github.com/binwiederhier/ntfy-ansible) for examples of how to
configure [ntfy](https://github.com/binwiederhier/ntfy-ansible/tree/main/roles/ntfy), [nginx](https://github.com/binwiederhier/ntfy-ansible/tree/main/roles/nginx) and [fail2ban](https://github.com/binwiederhier/ntfy-ansible/tree/main/roles/fail2ban).
## Health checks
A preliminary health check API endpoint is exposed at `/v1/health`. The endpoint returns a `json` response in the format shown below.
If a non-200 HTTP status code is returned or if the returned `healthy` field is `false` the ntfy service should be considered as unhealthy.
@@ -1444,7 +1495,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `auth-default-access` | `NTFY_AUTH_DEFAULT_ACCESS` | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write` | Default permissions if no matching entries in the auth database are found. Default is `read-write`. |
| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting) |
| `proxy-forwarded-header` | `NTFY_PROXY_FORWARDED_HEADER` | *string* | `X-Forwarded-For` | Use specified header to determine visitor IP address (for rate limiting) |
| `proxy-trusted-addresses` | `NTFY_PROXY_TRUSTED_ADDRESSES` | *comma-separated list of IPs* | - | Comma-separated list of trusted IP addresses to remove from forwarded header |
| `proxy-trusted-hosts` | `NTFY_PROXY_TRUSTED_HOSTS` | *comma-separated host/IP/CIDR list* | - | Comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header |
| `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory* | - | Cache directory for attached files. To enable attachments, this has to be set. |
| `attachment-total-size-limit` | `NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 5G | Limit of the on-disk attachment cache directory. If the limits is exceeded, new attachments will be rejected. |
| `attachment-file-size-limit` | `NTFY_ATTACHMENT_FILE_SIZE_LIMIT` | *size* | 15M | Per-file attachment size limit (e.g. 300k, 2M, 100M). Larger attachment will be rejected. |
@@ -1474,9 +1525,11 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `visitor-message-daily-limit` | `NTFY_VISITOR_MESSAGE_DAILY_LIMIT` | *number* | - | Rate limiting: Allowed number of messages per day per visitor, reset every day at midnight (UTC). By default, this value is unset. |
| `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has |
| `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 5s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled |
| `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting |
| `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP/CIDR list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting |
| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
| `visitor-subscriber-rate-limiting` | `NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING` | *bool* | `false` | Rate limiting: Enables subscriber-based rate limiting |
| `visitor-prefix-bits-ipv4` | `NTFY_VISITOR_PREFIX_BITS_IPV4` | *number* | 32 | Rate limiting: Number of bits to use for IPv4 visitor prefix, e.g. 24 for /24 |
| `visitor-prefix-bits-ipv6` | `NTFY_VISITOR_PREFIX_BITS_IPV6` | *number* | 64 | Rate limiting: Number of bits to use for IPv6 visitor prefix, e.g. 48 for /48 |
| `web-root` | `NTFY_WEB_ROOT` | *path*, e.g. `/` or `/app`, or `disable` | `/` | Sets root of the web app (e.g. /, or /app), or disables it entirely (disable) |
| `enable-signup` | `NTFY_ENABLE_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API |
| `enable-login` | `NTFY_ENABLE_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API |
@@ -1572,6 +1625,7 @@ OPTIONS:
--message-delay-limit value, --message_delay_limit value max duration a message can be scheduled into the future (default: "3d") [$NTFY_MESSAGE_DELAY_LIMIT]
--global-topic-limit value, --global_topic_limit value, -T value total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT]
--visitor-subscription-limit value, --visitor_subscription_limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
--visitor-subscriber-rate-limiting, --visitor_subscriber_rate_limiting enables subscriber-based rate limiting (default: false) [$NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING]
--visitor-attachment-total-size-limit value, --visitor_attachment_total_size_limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]
--visitor-attachment-daily-bandwidth-limit value, --visitor_attachment_daily_bandwidth_limit value total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT]
--visitor-request-limit-burst value, --visitor_request_limit_burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
@@ -1580,8 +1634,11 @@ OPTIONS:
--visitor-message-daily-limit value, --visitor_message_daily_limit value max messages per visitor per day, derived from request limit if unset (default: 0) [$NTFY_VISITOR_MESSAGE_DAILY_LIMIT]
--visitor-email-limit-burst value, --visitor_email_limit_burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]
--visitor-email-limit-replenish value, --visitor_email_limit_replenish value interval at which burst limit is replenished (one per x) (default: "1h") [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
--visitor-subscriber-rate-limiting, --visitor_subscriber_rate_limiting enables subscriber-based rate limiting (default: false) [$NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING]
--behind-proxy, --behind_proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
--visitor-prefix-bits-ipv4 value, --visitor_prefix_bits_ipv4 value number of bits of the IPv4 address to use for rate limiting (default: 32, full address) (default: 32) [$NTFY_VISITOR_PREFIX_BITS_IPV4]
--visitor-prefix-bits-ipv6 value, --visitor_prefix_bits_ipv6 value number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet) (default: 64) [$NTFY_VISITOR_PREFIX_BITS_IPV6]
--behind-proxy, --behind_proxy, -P if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
--proxy-forwarded-header value, --proxy_forwarded_header value use specified header to determine visitor IP address (for rate limiting) (default: "X-Forwarded-For") [$NTFY_PROXY_FORWARDED_HEADER]
--proxy-trusted-hosts value, --proxy_trusted_hosts value comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header [$NTFY_PROXY_TRUSTED_HOSTS]
--stripe-secret-key value, --stripe_secret_key value key used for the Stripe API communication, this enables payments [$NTFY_STRIPE_SECRET_KEY]
--stripe-webhook-key value, --stripe_webhook_key value key required to validate the authenticity of incoming webhooks from Stripe [$NTFY_STRIPE_WEBHOOK_KEY]
--billing-contact value, --billing_contact value e-mail or website to display in upgrade dialog (only if payments are enabled) [$NTFY_BILLING_CONTACT]
@@ -1595,5 +1652,5 @@ OPTIONS:
--web-push-startup-queries value, --web_push_startup_queries value queries run when the web push database is initialized [$NTFY_WEB_PUSH_STARTUP_QUERIES]
--web-push-expiry-duration value, --web_push_expiry_duration value automatically expire unused subscriptions after this time (default: "60d") [$NTFY_WEB_PUSH_EXPIRY_DURATION]
--web-push-expiry-warning-duration value, --web_push_expiry_warning_duration value send web push warning notification after this time before expiring unused subscriptions (default: "55d") [$NTFY_WEB_PUSH_EXPIRY_WARNING_DURATION]
--help, -h show help
--help, -h
```

View File

@@ -3,9 +3,9 @@ ntfy lets you **send push notifications to your phone or desktop via scripts fro
or POST requests. I use it to notify myself when scripts fail, or long-running commands complete.
## Step 1: Get the app
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="static/img/badge-googleplay.png"></a>
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="static/img/badge-fdroid.png"></a>
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img src="static/img/badge-appstore.png"></a>
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img width="170" src="static/img/badge-googleplay.png"></a>
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img width="170" src="static/img/badge-fdroid.png"></a>
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img width="150" src="static/img/badge-appstore.png"></a>
To [receive notifications on your phone](subscribe/phone.md), install the app, either via Google Play, App Store or F-Droid.
Once installed, open it and subscribe to a topic of your choosing. Topics don't have to explicitly be created, so just

View File

@@ -30,37 +30,37 @@ deb/rpm packages.
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_amd64.tar.gz
tar zxvf ntfy_2.12.0_linux_amd64.tar.gz
sudo cp -a ntfy_2.12.0_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.12.0_linux_amd64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_amd64.tar.gz
tar zxvf ntfy_2.13.0_linux_amd64.tar.gz
sudo cp -a ntfy_2.13.0_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.0_linux_amd64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv6.tar.gz
tar zxvf ntfy_2.12.0_linux_armv6.tar.gz
sudo cp -a ntfy_2.12.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.12.0_linux_armv6/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv6.tar.gz
tar zxvf ntfy_2.13.0_linux_armv6.tar.gz
sudo cp -a ntfy_2.13.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.0_linux_armv6/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv7.tar.gz
tar zxvf ntfy_2.12.0_linux_armv7.tar.gz
sudo cp -a ntfy_2.12.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.12.0_linux_armv7/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv7.tar.gz
tar zxvf ntfy_2.13.0_linux_armv7.tar.gz
sudo cp -a ntfy_2.13.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.0_linux_armv7/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_arm64.tar.gz
tar zxvf ntfy_2.12.0_linux_arm64.tar.gz
sudo cp -a ntfy_2.12.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.12.0_linux_arm64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_arm64.tar.gz
tar zxvf ntfy_2.13.0_linux_arm64.tar.gz
sudo cp -a ntfy_2.13.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.0_linux_arm64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
@@ -110,7 +110,7 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_amd64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_amd64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -118,7 +118,7 @@ Manually installing the .deb file:
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv6.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv6.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -126,7 +126,7 @@ Manually installing the .deb file:
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv7.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv7.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -134,7 +134,7 @@ Manually installing the .deb file:
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_arm64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_arm64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -144,28 +144,28 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_amd64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_amd64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv6"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv6.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv6.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv7/armhf"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv7.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv7.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "arm64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_arm64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_arm64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
@@ -195,18 +195,18 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos.
## macOS
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_darwin_all.tar.gz),
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_darwin_all.tar.gz),
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
```bash
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_darwin_all.tar.gz > ntfy_2.12.0_darwin_all.tar.gz
tar zxvf ntfy_2.12.0_darwin_all.tar.gz
sudo cp -a ntfy_2.12.0_darwin_all/ntfy /usr/local/bin/ntfy
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_darwin_all.tar.gz > ntfy_2.13.0_darwin_all.tar.gz
tar zxvf ntfy_2.13.0_darwin_all.tar.gz
sudo cp -a ntfy_2.13.0_darwin_all/ntfy /usr/local/bin/ntfy
mkdir ~/Library/Application\ Support/ntfy
cp ntfy_2.12.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
cp ntfy_2.13.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
ntfy --help
```
@@ -224,7 +224,7 @@ brew install ntfy
## Windows
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_windows_amd64.zip),
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_windows_amd64.zip),
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
@@ -280,8 +280,6 @@ docker run \
Using docker-compose with non-root user and healthchecks enabled:
```yaml
version: "2.3"
services:
ntfy:
image: binwiederhier/ntfy

View File

@@ -95,6 +95,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [wlzntfy](https://github.com/Walzen-Group/ntfy-toaster) - A minimalistic, receive-only toast notification client for Windows 11
- [Ntfy_CSV_Reminders](https://github.com/thiswillbeyourgithub/Ntfy_CSV_Reminders) - A Python tool that sends random-timing phone notifications for recurring tasks by using daily probability checks based on CSV-defined frequencies.
- [Daily Fact Ntfy](https://github.com/thiswillbeyourgithub/Daily_Fact_Ntfy) - Generate [llm](https://github.com/simonw/llm) generated fact every day about any topic you're interested in.
- [ntfyexec](https://github.com/alecthomas/ntfyexec) - Send a notification through ntfy.sh if a command fails
## Projects + scripts

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,25 @@
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
### ntfy server v2.13.0
Released July 10, 2025
This is a relatively small release, mainly to support IPv6 and to add more sophisticated
proxy header support. Quick reminder that if you like ntfy, **please consider sponsoring us**
via [GitHub Sponsors](https://github.com/sponsors/binwiederhier) and [Liberapay](https://en.liberapay.com/ntfy/), or buying a [paid plan via the web app](https://ntfy.sh/app).
ntfy will always remain open source.
**Features:**
* Full [IPv6 support](config.md#ipv6-support) for ntfy and the official ntfy.sh server ([#519](https://github.com/binwiederhier/ntfy/issues/519)/[#1380](https://github.com/binwiederhier/ntfy/pull/1380)/[ansible#4](https://github.com/binwiederhier/ntfy-ansible/pull/4))
* Support `X-Client-IP`, `X-Real-IP`, `Forwarded` headers for [rate limiting](config.md#ip-based-rate-limiting) via `proxy-forwarded-header` and `proxy-trusted-hosts` ([#1360](https://github.com/binwiederhier/ntfy/pull/1360)/[#1252](https://github.com/binwiederhier/ntfy/pull/1252), thanks to [@pixitha](https://github.com/pixitha))
* Add STDIN support for `ntfy publish` ([#1382](https://github.com/binwiederhier/ntfy/pull/1382), thanks to [@srevn](https://github.com/srevn))
**Languages**
* Update new languages from Weblate. Thanks to all the contributors!
* Added Estonian (Esti), Galician (Galego), Romanian (Română), Slovak (Slovenčina) as new languages to the web app
### ntfy server v2.12.0
Released May 29, 2025
@@ -1433,11 +1452,12 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet
### ntfy server v2.13.0 (UNRELEASED)
### ntfy server v2.14.0 (UNRELEASED)
**Features:**
* Support `X-Client-IP`, `X-Real-IP`, `Forwarded` headers for [rate limiting](config.md#ip-based-rate-limiting) via `proxy-forwarded-header` and `proxy-trusted-addresses` ([#1360](https://github.com/binwiederhier/ntfy/pull/1360)/[#1252](https://github.com/binwiederhier/ntfy/pull/1252), thanks to [@pixitha](https://github.com/pixitha))
* 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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -156,7 +156,7 @@ environment variables. Here are a few examples:
```
ntfy sub mytopic 'notify-send "$m"'
ntfy sub topic1 /my/script.sh
ntfy sub topic1 'echo "Message $m was received. Its title was $t and it had priority $p'
ntfy sub topic1 'echo "Message $m was received. Its title was $t and it had priority $p"'
```
<figure>

View File

@@ -4,9 +4,9 @@ to receive notifications directly on your phone. Just like the server, this app
on GitHub ([Android](https://github.com/binwiederhier/ntfy-android), [iOS](https://github.com/binwiederhier/ntfy-ios)). Feel free to
contribute, or [build your own](../develop.md).
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="../../static/img/badge-googleplay.png"></a>
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="../../static/img/badge-fdroid.png"></a>
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img src="../../static/img/badge-appstore.png"></a>
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img width="170" src="../../static/img/badge-googleplay.png"></a>
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img width="170" src="../../static/img/badge-fdroid.png"></a>
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img width="150" src="../../static/img/badge-appstore.png"></a>
You can get the Android app from both [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) and
from [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/). Both are largely identical, with the one exception that

66
go.mod
View File

@@ -15,13 +15,13 @@ require (
github.com/mattn/go-sqlite3 v1.14.28
github.com/olebedev/when v1.1.0
github.com/stretchr/testify v1.10.0
github.com/urfave/cli/v2 v2.27.6
golang.org/x/crypto v0.38.0
github.com/urfave/cli/v2 v2.27.7
golang.org/x/crypto v0.40.0
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.14.0
golang.org/x/term v0.32.0
golang.org/x/time v0.11.0
google.golang.org/api v0.235.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.242.0
gopkg.in/yaml.v2 v2.4.0
)
@@ -30,26 +30,27 @@ 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.15.2
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.2 // indirect
cloud.google.com/go/auth v0.16.1 // 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
cloud.google.com/go/longrunning v0.6.7 // indirect
cloud.google.com/go/monitoring v1.24.2 // indirect
github.com/AlekSi/pointer v1.2.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.28.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.52.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.52.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect
github.com/MicahParks/keyfunc v1.9.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
@@ -60,45 +61,44 @@ require (
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-jose/go-jose/v4 v4.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.1 // indirect
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
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.64.0 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/prometheus/common v0.65.0 // indirect
github.com/prometheus/procfs v0.17.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
github.com/zeebo/errs v1.4.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.36.0 // indirect
go.opentelemetry.io/otel/metric v1.36.0 // indirect
go.opentelemetry.io/otel/sdk v1.36.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.36.0 // indirect
go.opentelemetry.io/otel/trace v1.36.0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.37.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect
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.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-20250528174236-200df99c418a // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect
google.golang.org/grpc v1.72.2 // 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
)

138
go.sum
View File

@@ -1,9 +1,9 @@
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.2 h1:v2qQpN6Dx9x2NmwrqlesOt3Ys4ol5/lFZ6Mg1B7OJCg=
cloud.google.com/go v0.121.2/go.mod h1:nRFlrHq39MNVWu+zESP2PosMWA0ryJw8KUBZ2iZpxbw=
cloud.google.com/go/auth v0.16.1 h1:XrXauHMd30LhQYVRHLGvJiYeczweKQXZxsTbV9TiguU=
cloud.google.com/go/auth v0.16.1/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI=
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.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=
@@ -22,20 +22,20 @@ cloud.google.com/go/storage v1.55.0 h1:NESjdAToN9u1tmhVqhXCaCwYBuvEhZLLv0gBr+2zn
cloud.google.com/go/storage v1.55.0/go.mod h1:ztSmTTwzsdXe5syLVS0YsbFxXuvEmEyZj7v7zChEmuY=
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.15.2 h1:KJtV4rAfO2CVCp40hBfVk+mqUqg7+jQKx7yOgFDnXBg=
firebase.google.com/go/v4 v4.15.2/go.mod h1:qkD/HtSumrPMTLs0ahQrje5gTw2WKFKrzVFoqy4SbKA=
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=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.28.0 h1:VaFXBL0NJpiFBtw4aVJpKHeKULVTcHpD+/G0ibZkcBw=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.28.0/go.mod h1:JXkPazkEc/dZTHzOlzv2vT1DlpWSTbSLmu/1KY6Ly0I=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.52.0 h1:QFgWzcdmJlgEAwJz/zePYVJQxfoJGRtgIqZfIUFg5oQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.52.0/go.mod h1:ayYHuYU7iNcNtEs1K9k6D/Bju7u1VEHMQm5qQ1n3GtM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.52.0 h1:0l8ynskVvq1dvIn5vJbFMf/a/3TqFpRmCMrruFbzlvk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.52.0/go.mod h1:f/ad5NuHnYz8AOZGuR0cY+l36oSCstdxD73YlIchr6I=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.52.0 h1:wbMd4eG/fOhsCa6+IP8uEDvWF5vl7rNoUWmP5f72Tbs=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.52.0/go.mod h1:gdIm9TxRk5soClCwuB0FtdXsbqtw0aqPwBEurK9tPkw=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 h1:UQUsRi8WTzhZntp5313l+CHIAT95ojUI2lpP/ExlZa4=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0 h1:4LP6hvB4I5ouTbGgWtixJhgED6xdf67twf9PoY96Tbg=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0/go.mod h1:jUZ5LYlw40WMd07qxcQJD5M40aUxrfwqQX1g7zxYnrQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo=
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
@@ -70,8 +70,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY=
github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw=
github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI=
github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -81,8 +81,8 @@ github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
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=
@@ -98,8 +98,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
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=
@@ -131,10 +131,10 @@ github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4=
github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
@@ -149,41 +149,43 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stripe/stripe-go/v74 v74.30.0 h1:0Kf0KkeFnY7iRhOwvTerX0Ia1BRw+eV1CVJ51mGYAUY=
github.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g=
github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/detectors/gcp v1.36.0 h1:F7q2tNlCaHY9nMKHR6XH9/qkp8FktLnIcy6jJNyOCQw=
go.opentelemetry.io/contrib/detectors/gcp v1.36.0/go.mod h1:IbBN8uAIIx734PTonTPxAxnjc2pQTxWNkwfstZ+6H2k=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
go.opentelemetry.io/contrib/detectors/gcp v1.37.0 h1:B+WbN9RPsvobe6q4vP6KgM8/9plR/HNjgGBrfcOlweA=
go.opentelemetry.io/contrib/detectors/gcp v1.37.0/go.mod h1:K5zQ3TT7p2ru9Qkzk0bKtCql0RGkPj9pRjpXgZJZ+rU=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 h1:rbRJ8BBoVMsQShESYZ0FkvcITu8X8QNwJogcLUmDNNw=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0/go.mod h1:ru6KHrNtNHxM4nD/vd6QrLVWgKhxPYgblq4VAtNawTQ=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw=
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
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=
@@ -198,8 +200,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
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=
@@ -209,8 +211,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
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.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.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=
@@ -223,8 +225,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.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=
@@ -234,8 +236,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.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=
@@ -247,10 +249,10 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
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=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -259,18 +261,18 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.235.0 h1:C3MkpQSRxS1Jy6AkzTGKKrpSCOd2WOGrezZ+icKSkKo=
google.golang.org/api v0.235.0/go.mod h1:QpeJkemzkFKe5VCE/PMv7GsUfn9ZF+u+q1Q7w6ckxTg=
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/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-20250528174236-200df99c418a h1:KXuwdBmgjb4T3l4ZzXhP6HxxFKXD9FcK5/8qfJI4WwU=
google.golang.org/genproto v0.0.0-20250528174236-200df99c418a/go.mod h1:Nlk93rrS2X7rV8hiC2gh2A/AJspZhElz9Oh2KGsjLEY=
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a h1:SGktgSolFCo75dnHJF2yMvnns6jCmHFJ0vE4Vn2JKvQ=
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8=
google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
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-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-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=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=

View File

@@ -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

View File

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

View File

@@ -123,6 +123,8 @@ var (
errHTTPBadRequestTemplateDisallowedFunctionCalls = &errHTTP{40044, http.StatusBadRequest, "invalid request: template contains disallowed function calls, e.g. template, call, or define", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestTemplateExecuteFailed = &errHTTP{40045, http.StatusBadRequest, "invalid request: template execution failed", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil}
errHTTPBadRequestTemplateFileNotFound = &errHTTP{40047, http.StatusBadRequest, "invalid request: template file not found", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}

View File

@@ -6,6 +6,8 @@ import (
"errors"
"fmt"
"net/netip"
"net/url"
"path/filepath"
"strings"
"time"
@@ -63,6 +65,10 @@ const (
INSERT INTO stats (key, value) VALUES ('messages', 0);
COMMIT;
`
builtinMessageCacheStartupQueries = `
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 50000; -- Wait up to 5 seconds for a lock to be released
`
insertMessageQuery = `
INSERT INTO messages (mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -286,7 +292,18 @@ type messageCache struct {
// newSqliteCache creates a SQLite file-backed cache
func newSqliteCache(filename, startupQueries string, cacheDuration time.Duration, batchSize int, batchTimeout time.Duration, nop bool) (*messageCache, error) {
db, err := sql.Open("sqlite3", filename)
// Parse the filename
file, datasource, err := parseSqliteFile(filename)
if err != nil {
return nil, fmt.Errorf("cannot parse cache database filename %s: %w", filename, err)
}
// Check the parent directory of the database file (makes for friendly error messages)
parentDir := filepath.Dir(filename)
if !util.FileExists(parentDir) {
return nil, fmt.Errorf("cache database directory %s does not exist or is not accessible", parentDir)
}
// Open database
db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?_busy_timeout=50000", filename))
if err != nil {
return nil, err
}
@@ -782,8 +799,21 @@ func (c *messageCache) Close() error {
return c.db.Close()
}
func parseSqliteFile(filename string) (file string, datasource string, err error) {
f, err := url.Parse(filename)
if err != nil {
return "", "", fmt.Errorf("cannot parse cache database filename %s: %w", filename, err)
} else if f.Scheme != "file" {
return f.Path, filename, nil
}
return filename, filename, nil
}
func setupMessagesDB(db *sql.DB, startupQueries string, cacheDuration time.Duration) error {
// Run startup queries
if _, err := db.Exec(builtinMessageCacheStartupQueries); err != nil {
return err
}
if startupQueries != "" {
if _, err := db.Exec(startupQueries); err != nil {
return err

View File

@@ -3,8 +3,11 @@ package server
import (
"database/sql"
"fmt"
"github.com/stretchr/testify/assert"
"net/netip"
"net/url"
"path/filepath"
"sync"
"testing"
"time"
@@ -90,6 +93,26 @@ func testCacheMessages(t *testing.T, c *messageCache) {
require.Empty(t, messages)
}
func TestSqliteCache_MessagesLock(t *testing.T) {
testCacheMessagesLock(t, newSqliteTestCache(t))
}
func TestMemCache_MessagesLock(t *testing.T) {
testCacheMessagesLock(t, newMemTestCache(t))
}
func testCacheMessagesLock(t *testing.T, c *messageCache) {
var wg sync.WaitGroup
for i := 0; i < 3000; i++ {
wg.Add(1)
go func() {
assert.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "test message")))
wg.Done()
}()
}
wg.Wait()
}
func TestSqliteCache_MessagesScheduled(t *testing.T) {
testCacheMessagesScheduled(t, newSqliteTestCache(t))
}
@@ -685,6 +708,35 @@ func checkSchemaVersion(t *testing.T, db *sql.DB) {
require.Nil(t, rows.Close())
}
func TestURL(t *testing.T) {
u, _ := url.Parse("file:mem?_busy_timeout=1000&_journal_mode=WAL&_synchronous=normal&_temp_store=memory")
fmt.Printf("opaque: %+v\n", u.Opaque)
fmt.Printf("scheme: %+v\n", u.Scheme)
fmt.Printf("host: %+v\n", u.Host)
fmt.Printf("path: %+v\n", u.Path)
fmt.Printf("raw path: %+v\n", u.RawPath)
fmt.Printf("raw query: %+v\n", u.RawQuery)
fmt.Printf("query: %+v\n", u.Query())
fmt.Println("----------")
u, _ = url.Parse("myfile.db")
fmt.Printf("opaque: %+v\n", u.Opaque)
fmt.Printf("scheme: %+v\n", u.Scheme)
fmt.Printf("host: %+v\n", u.Host)
fmt.Printf("path: %+v\n", u.Path)
fmt.Printf("raw path: %+v\n", u.RawPath)
fmt.Printf("raw query: %+v\n", u.RawQuery)
fmt.Printf("query: %+v\n", u.Query())
fmt.Println("----------")
u, _ = url.Parse("htttps://abc.com/myfile.db")
fmt.Printf("opaque: %+v\n", u.Opaque)
fmt.Printf("scheme: %+v\n", u.Scheme)
fmt.Printf("host: %+v\n", u.Host)
fmt.Printf("path: %+v\n", u.Path)
fmt.Printf("raw path: %+v\n", u.RawPath)
fmt.Printf("raw query: %+v\n", u.RawQuery)
fmt.Printf("query: %+v\n", u.Query())
}
func TestMemCache_NopCache(t *testing.T) {
c, _ := newNopCache()
require.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "my message")))

View File

@@ -9,6 +9,7 @@ import (
"encoding/json"
"errors"
"fmt"
"gopkg.in/yaml.v2"
"io"
"net"
"net/http"
@@ -34,6 +35,7 @@ import (
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
"heckel.io/ntfy/v2/util/sprig"
)
// Server is the main server, providing the UI and API for ntfy
@@ -120,6 +122,15 @@ var (
//go:embed docs
docsStaticFs embed.FS
docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs}
//go:embed templates
templatesFs embed.FS // Contains template config files (e.g. grafana.yml, github.yml, ...)
templatesDir = "templates"
// templateDisallowedRegex tests a template for disallowed expressions. While not really dangerous, they
// are not useful, and seem potentially troublesome.
templateDisallowedRegex = regexp.MustCompile(`(?m)\{\{-?\s*(call|template|define)\b`)
templateNameRegex = regexp.MustCompile(`^[-_A-Za-z0-9]+$`)
)
const (
@@ -129,17 +140,13 @@ const (
newMessageBody = "New message" // Used in poll requests as generic message
defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
encodingBase64 = "base64" // Used mainly for binary UnifiedPush messages
jsonBodyBytesLimit = 32768 // Max number of bytes for a request bodys (unless MessageLimit is higher)
jsonBodyBytesLimit = 131072 // Max number of bytes for a request bodys (unless MessageLimit is higher)
unifiedPushTopicPrefix = "up" // Temporarily, we rate limit all "up*" topics based on the subscriber
unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part
messagesHistoryMax = 10 // Number of message count values to keep in memory
templateMaxExecutionTime = 100 * time.Millisecond
)
var (
// templateDisallowedRegex tests a template for disallowed expressions. While not really dangerous, they
// are not useful, and seem potentially troublesome.
templateDisallowedRegex = regexp.MustCompile(`(?m)\{\{-?\s*(call|template|define)\b`)
templateMaxExecutionTime = 100 * time.Millisecond // Maximum time a template can take to execute, used to prevent DoS attacks
templateMaxOutputBytes = 1024 * 1024 // Maximum number of bytes a template can output, used to prevent DoS attacks
templateFileExtension = ".yml" // Template files must end with this extension
)
// WebSocket constants
@@ -189,7 +196,17 @@ func New(conf *Config) (*Server, error) {
}
var userManager *user.Manager
if conf.AuthFile != "" {
userManager, err = user.NewManager(conf.AuthFile, conf.AuthStartupQueries, conf.AuthDefault, conf.AuthBcryptCost, conf.AuthStatsQueueWriterInterval)
authConfig := &user.Config{
Filename: conf.AuthFile,
StartupQueries: conf.AuthStartupQueries,
DefaultAccess: conf.AuthDefault,
ProvisionEnabled: true, // Enable provisioning of users and access
ProvisionUsers: conf.AuthProvisionedUsers,
ProvisionAccess: conf.AuthProvisionedAccess,
BcryptCost: conf.AuthBcryptCost,
QueueWriterInterval: conf.AuthStatsQueueWriterInterval,
}
userManager, err = user.NewManager(authConfig)
if err != nil {
return nil, err
}
@@ -760,7 +777,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
// the subscription as invalid if any 400-499 code (except 429/408) is returned.
// See https://github.com/mastodon/mastodon/blob/730bb3e211a84a2f30e3e2bbeae3f77149824a68/app/workers/web/push_notification_worker.rb#L35-L46
return nil, errHTTPInsufficientStorageUnifiedPush.With(t)
} else if !util.ContainsIP(s.config.VisitorRequestExemptIPAddrs, v.ip) && !vrate.MessageAllowed() {
} else if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() {
return nil, errHTTPTooManyRequestsLimitMessages.With(t)
} else if email != "" && !vrate.EmailAllowed() {
return nil, errHTTPTooManyRequestsLimitEmails.With(t)
@@ -936,7 +953,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 +969,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 +987,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 +1008,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 +1036,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 +1075,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 +1084,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 +1121,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 +1129,69 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *message, body *util.PeekedR
return errHTTPEntityTooLargeJSONBody
}
peekedBody := strings.TrimSpace(string(body.PeekedBytes))
if m.Message, err = replaceTemplate(m.Message, peekedBody); err != nil {
return err
if templateName := template.Name(); templateName != "" {
if err := s.renderTemplateFromFile(m, templateName, peekedBody); err != nil {
return err
}
} else {
if err := s.renderTemplateFromParams(m, peekedBody); err != nil {
return err
}
}
if m.Title, err = replaceTemplate(m.Title, peekedBody); err != nil {
return err
}
if len(m.Message) > s.config.MessageSizeLimit {
if len(m.Title) > s.config.MessageSizeLimit || len(m.Message) > s.config.MessageSizeLimit {
return errHTTPBadRequestTemplateMessageTooLarge
}
return nil
}
func replaceTemplate(tpl string, source string) (string, error) {
// renderTemplateFromFile transforms the JSON message body according to a template from the filesystem.
// The template file must be in the templates directory, or in the configured template directory.
func (s *Server) renderTemplateFromFile(m *message, templateName, peekedBody string) error {
if !templateNameRegex.MatchString(templateName) {
return errHTTPBadRequestTemplateFileNotFound
}
templateContent, _ := templatesFs.ReadFile(filepath.Join(templatesDir, templateName+templateFileExtension)) // Read from the embedded filesystem first
if s.config.TemplateDir != "" {
if b, _ := os.ReadFile(filepath.Join(s.config.TemplateDir, templateName+templateFileExtension)); len(b) > 0 {
templateContent = b
}
}
if len(templateContent) == 0 {
return errHTTPBadRequestTemplateFileNotFound
}
var tpl templateFile
if err := yaml.Unmarshal(templateContent, &tpl); err != nil {
return errHTTPBadRequestTemplateFileInvalid
}
var err error
if tpl.Message != nil {
if m.Message, err = s.renderTemplate(*tpl.Message, peekedBody); err != nil {
return err
}
}
if tpl.Title != nil {
if m.Title, err = s.renderTemplate(*tpl.Title, peekedBody); err != nil {
return err
}
}
return nil
}
// renderTemplateFromParams transforms the JSON message body according to the inline template in the
// message and title parameters.
func (s *Server) renderTemplateFromParams(m *message, peekedBody string) error {
var err error
if m.Message, err = s.renderTemplate(m.Message, peekedBody); err != nil {
return err
}
if m.Title, err = s.renderTemplate(m.Title, peekedBody); err != nil {
return err
}
return nil
}
// renderTemplate renders a template with the given JSON source data.
func (s *Server) renderTemplate(tpl string, source string) (string, error) {
if templateDisallowedRegex.MatchString(tpl) {
return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
}
@@ -1132,15 +1199,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 {
@@ -1937,7 +2005,7 @@ func (s *Server) authorizeTopic(next handleFunc, perm user.Permission) handleFun
// that subsequent logging calls still have a visitor context.
func (s *Server) maybeAuthenticate(r *http.Request) (*visitor, error) {
// Read the "Authorization" header value and exit out early if it's not set
ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedAddresses)
ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes)
vip := s.visitor(ip, nil)
if s.userManager == nil {
return vip, nil
@@ -2012,7 +2080,7 @@ func (s *Server) authenticateBearerAuth(r *http.Request, token string) (*user.Us
if err != nil {
return nil, err
}
ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedAddresses)
ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes)
go s.userManager.EnqueueTokenUpdate(token, &user.TokenUpdate{
LastAccess: time.Now(),
LastOrigin: ip,
@@ -2023,7 +2091,7 @@ func (s *Server) authenticateBearerAuth(r *http.Request, token string) (*user.Us
func (s *Server) visitor(ip netip.Addr, user *user.User) *visitor {
s.mu.Lock()
defer s.mu.Unlock()
id := visitorID(ip, user)
id := visitorID(ip, user, s.config)
v, exists := s.visitors[id]
if !exists {
s.visitors[id] = newVisitor(s.config, s.messageCache, s.userManager, ip, user)

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import (
"bufio"
"context"
"crypto/rand"
_ "embed"
"encoding/base64"
"encoding/json"
"fmt"
@@ -1169,7 +1170,7 @@ func (t *testMailer) Count() int {
return t.count
}
func TestServer_PublishTooRequests_Defaults(t *testing.T) {
func TestServer_PublishTooManyRequests_Defaults(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
for i := 0; i < 60; i++ {
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil)
@@ -1179,10 +1180,53 @@ func TestServer_PublishTooRequests_Defaults(t *testing.T) {
require.Equal(t, 429, response.Code)
}
func TestServer_PublishTooRequests_Defaults_ExemptHosts(t *testing.T) {
func TestServer_PublishTooManyRequests_Defaults_IPv6(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
overrideRemoteAddr1 := func(r *http.Request) {
r.RemoteAddr = "[2001:db8:9999:8888:1::1]:1234"
}
overrideRemoteAddr2 := func(r *http.Request) {
r.RemoteAddr = "[2001:db8:9999:8888:2::1]:1234" // Same /64
}
for i := 0; i < 30; i++ {
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr1)
require.Equal(t, 200, response.Code)
}
for i := 0; i < 30; i++ {
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr2)
require.Equal(t, 200, response.Code)
}
response := request(t, s, "PUT", "/mytopic", "message", nil, overrideRemoteAddr1)
require.Equal(t, 429, response.Code)
}
func TestServer_PublishTooManyRequests_IPv6_Slash48(t *testing.T) {
c := newTestConfig(t)
c.VisitorRequestLimitBurst = 6
c.VisitorPrefixBitsIPv6 = 48 // Use /48 for IPv6 prefixes
s := newTestServer(t, c)
overrideRemoteAddr1 := func(r *http.Request) {
r.RemoteAddr = "[2001:db8:9999::1]:1234"
}
overrideRemoteAddr2 := func(r *http.Request) {
r.RemoteAddr = "[2001:db8:9999::2]:1234" // Same /48
}
for i := 0; i < 3; i++ {
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr1)
require.Equal(t, 200, response.Code)
}
for i := 0; i < 3; i++ {
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr2)
require.Equal(t, 200, response.Code)
}
response := request(t, s, "PUT", "/mytopic", "message", nil, overrideRemoteAddr1)
require.Equal(t, 429, response.Code)
}
func TestServer_PublishTooManyRequests_Defaults_ExemptHosts(t *testing.T) {
c := newTestConfig(t)
c.VisitorRequestLimitBurst = 3
c.VisitorRequestExemptIPAddrs = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request()
c.VisitorRequestExemptPrefixes = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request()
s := newTestServer(t, c)
for i := 0; i < 5; i++ { // > 3
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil)
@@ -1190,11 +1234,25 @@ func TestServer_PublishTooRequests_Defaults_ExemptHosts(t *testing.T) {
}
}
func TestServer_PublishTooRequests_Defaults_ExemptHosts_MessageDailyLimit(t *testing.T) {
func TestServer_PublishTooManyRequests_Defaults_ExemptHosts_IPv6(t *testing.T) {
c := newTestConfig(t)
c.VisitorRequestLimitBurst = 3
c.VisitorRequestExemptPrefixes = []netip.Prefix{netip.MustParsePrefix("2001:db8:9999::/48")}
s := newTestServer(t, c)
overrideRemoteAddr := func(r *http.Request) {
r.RemoteAddr = "[2001:db8:9999::1]:1234"
}
for i := 0; i < 5; i++ { // > 3
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr)
require.Equal(t, 200, response.Code)
}
}
func TestServer_PublishTooManyRequests_Defaults_ExemptHosts_MessageDailyLimit(t *testing.T) {
c := newTestConfig(t)
c.VisitorRequestLimitBurst = 10
c.VisitorMessageDailyLimit = 4
c.VisitorRequestExemptIPAddrs = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request()
c.VisitorRequestExemptPrefixes = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request()
s := newTestServer(t, c)
for i := 0; i < 8; i++ { // 4
response := request(t, s, "PUT", "/mytopic", "message", nil)
@@ -1202,7 +1260,7 @@ func TestServer_PublishTooRequests_Defaults_ExemptHosts_MessageDailyLimit(t *tes
}
}
func TestServer_PublishTooRequests_ShortReplenish(t *testing.T) {
func TestServer_PublishTooManyRequests_ShortReplenish(t *testing.T) {
t.Parallel()
c := newTestConfig(t)
c.VisitorRequestLimitBurst = 60
@@ -2244,11 +2302,24 @@ func TestServer_Visitor_Custom_ClientIP_Header(t *testing.T) {
require.Equal(t, "1.2.3.4", v.ip.String())
}
func TestServer_Visitor_Custom_ClientIP_Header_IPv6(t *testing.T) {
c := newTestConfig(t)
c.BehindProxy = true
c.ProxyForwardedHeader = "X-Client-IP"
s := newTestServer(t, c)
r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "[2001:db8:9999::1]:1234"
r.Header.Set("X-Client-IP", "2001:db8:7777::1")
v, err := s.maybeAuthenticate(r)
require.Nil(t, err)
require.Equal(t, "2001:db8:7777::1", v.ip.String())
}
func TestServer_Visitor_Custom_Forwarded_Header(t *testing.T) {
c := newTestConfig(t)
c.BehindProxy = true
c.ProxyForwardedHeader = "Forwarded"
c.ProxyTrustedAddresses = []string{"1.2.3.4"}
c.ProxyTrustedPrefixes = []netip.Prefix{netip.MustParsePrefix("1.2.3.0/24")}
s := newTestServer(t, c)
r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "8.9.10.11:1234"
@@ -2258,6 +2329,20 @@ func TestServer_Visitor_Custom_Forwarded_Header(t *testing.T) {
require.Equal(t, "5.6.7.8", v.ip.String())
}
func TestServer_Visitor_Custom_Forwarded_Header_IPv6(t *testing.T) {
c := newTestConfig(t)
c.BehindProxy = true
c.ProxyForwardedHeader = "Forwarded"
c.ProxyTrustedPrefixes = []netip.Prefix{netip.MustParsePrefix("2001:db8:1111::/64")}
s := newTestServer(t, c)
r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "[2001:db8:2222::1]:1234"
r.Header.Set("Forwarded", " for=[2001:db8:1111::1], by=example.com;for=[2001:db8:3333::1]")
v, err := s.maybeAuthenticate(r)
require.Nil(t, err)
require.Equal(t, "2001:db8:3333::1", v.ip.String())
}
func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) {
t.Parallel()
count := 50000
@@ -2833,7 +2918,7 @@ func TestServer_MessageTemplate_Range(t *testing.T) {
require.Equal(t, 200, response.Code)
m := toMessage(t, response.Body.String())
require.Equal(t, "Severe URLs:\n- https://severe1.com\n- https://severe2.com\n", m.Message)
require.Equal(t, "Severe URLs:\n- https://severe1.com\n- https://severe2.com", m.Message)
}
func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageOK(t *testing.T) {
@@ -2886,8 +2971,7 @@ Labels:
Annotations:
- summary = 15m load average too high
Source: localhost:3000/alerting/grafana/NW9oDw-4z/view
Silence: localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter
`, m.Message)
Silence: localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter`, m.Message)
}
func TestServer_MessageTemplate_GitHub(t *testing.T) {
@@ -2940,12 +3024,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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,6 @@ import (
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
)
@@ -246,6 +245,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"`
}

View File

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

View File

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

View File

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

View File

@@ -50,7 +50,7 @@ const (
);
COMMIT;
`
builtinStartupQueries = `
builtinWebPushStartupQueries = `
PRAGMA foreign_keys = ON;
`
@@ -134,7 +134,7 @@ func runWebPushStartupQueries(db *sql.DB, startupQueries string) error {
if _, err := db.Exec(startupQueries); err != nil {
return err
}
if _, err := db.Exec(builtinStartupQueries); err != nil {
if _, err := db.Exec(builtinWebPushStartupQueries); err != nil {
return err
}
return nil

View File

@@ -12,6 +12,7 @@ import (
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/util"
"net/netip"
"path/filepath"
"strings"
"sync"
"time"
@@ -75,6 +76,7 @@ const (
role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL,
prefs JSON NOT NULL DEFAULT '{}',
sync_topic TEXT NOT NULL,
provisioned INT NOT NULL,
stats_messages INT NOT NULL DEFAULT (0),
stats_emails INT NOT NULL DEFAULT (0),
stats_calls INT NOT NULL DEFAULT (0),
@@ -97,6 +99,7 @@ const (
read INT NOT NULL,
write INT NOT NULL,
owner_user_id INT,
provisioned INT NOT NULL,
PRIMARY KEY (user_id, topic),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE
@@ -121,8 +124,8 @@ const (
id INT PRIMARY KEY,
version INT NOT NULL
);
INSERT INTO user (id, user, pass, role, sync_topic, created)
VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', UNIXEPOCH())
INSERT INTO user (id, user, pass, role, sync_topic, provisioned, created)
VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', false, UNIXEPOCH())
ON CONFLICT (id) DO NOTHING;
COMMIT;
`
@@ -132,26 +135,26 @@ const (
`
selectUserByIDQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u
LEFT JOIN tier t on t.id = u.tier_id
WHERE u.id = ?
`
selectUserByNameQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u
LEFT JOIN tier t on t.id = u.tier_id
WHERE user = ?
`
selectUserByTokenQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u
JOIN user_token tk on u.id = tk.user_id
LEFT JOIN tier t on t.id = u.tier_id
WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?)
`
selectUserByStripeCustomerIDQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u
LEFT JOIN tier t on t.id = u.tier_id
WHERE u.stripe_customer_id = ?
@@ -165,8 +168,8 @@ const (
`
insertUserQuery = `
INSERT INTO user (id, user, pass, role, sync_topic, created)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO user (id, user, pass, role, sync_topic, provisioned, created)
VALUES (?, ?, ?, ?, ?, ?, ?)
`
selectUsernamesQuery = `
SELECT user
@@ -189,18 +192,18 @@ const (
deleteUserQuery = `DELETE FROM user WHERE user = ?`
upsertUserAccessQuery = `
INSERT INTO user_access (user_id, topic, read, write, owner_user_id)
VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?, (SELECT IIF(?='',NULL,(SELECT id FROM user WHERE user=?))))
INSERT INTO user_access (user_id, topic, read, write, owner_user_id, provisioned)
VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?, (SELECT IIF(?='',NULL,(SELECT id FROM user WHERE user=?))), ?)
ON CONFLICT (user_id, topic)
DO UPDATE SET read=excluded.read, write=excluded.write, owner_user_id=excluded.owner_user_id
DO UPDATE SET read=excluded.read, write=excluded.write, owner_user_id=excluded.owner_user_id, provisioned=excluded.provisioned
`
selectUserAllAccessQuery = `
SELECT user_id, topic, read, write
SELECT user_id, topic, read, write, provisioned
FROM user_access
ORDER BY LENGTH(topic) DESC, write DESC, read DESC, topic
`
selectUserAccessQuery = `
SELECT topic, read, write
SELECT topic, read, write, provisioned
FROM user_access
WHERE user_id = (SELECT id FROM user WHERE user = ?)
ORDER BY LENGTH(topic) DESC, write DESC, read DESC, topic
@@ -244,7 +247,8 @@ const (
WHERE user_id = (SELECT id FROM user WHERE user = ?)
OR owner_user_id = (SELECT id FROM user WHERE user = ?)
`
deleteTopicAccessQuery = `
deleteUserAccessProvisionedQuery = `DELETE FROM user_access WHERE provisioned = 1`
deleteTopicAccessQuery = `
DELETE FROM user_access
WHERE (user_id = (SELECT id FROM user WHERE user = ?) OR owner_user_id = (SELECT id FROM user WHERE user = ?))
AND topic = ?
@@ -312,7 +316,7 @@ const (
// Schema management queries
const (
currentSchemaVersion = 5
currentSchemaVersion = 6
insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1`
selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
@@ -427,6 +431,82 @@ const (
migrate4To5UpdateQueries = `
UPDATE user_access SET topic = REPLACE(topic, '_', '\_');
`
// 5 -> 6
migrate5To6UpdateQueries = `
PRAGMA foreign_keys=off;
-- Alter user table: Add provisioned column
ALTER TABLE user RENAME TO user_old;
CREATE TABLE IF NOT EXISTS user (
id TEXT PRIMARY KEY,
tier_id TEXT,
user TEXT NOT NULL,
pass TEXT NOT NULL,
role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL,
prefs JSON NOT NULL DEFAULT '{}',
sync_topic TEXT NOT NULL,
provisioned INT NOT NULL,
stats_messages INT NOT NULL DEFAULT (0),
stats_emails INT NOT NULL DEFAULT (0),
stats_calls INT NOT NULL DEFAULT (0),
stripe_customer_id TEXT,
stripe_subscription_id TEXT,
stripe_subscription_status TEXT,
stripe_subscription_interval TEXT,
stripe_subscription_paid_until INT,
stripe_subscription_cancel_at INT,
created INT NOT NULL,
deleted INT,
FOREIGN KEY (tier_id) REFERENCES tier (id)
);
INSERT INTO user
SELECT
id,
tier_id,
user,
pass,
role,
prefs,
sync_topic,
0,
stats_messages,
stats_emails,
stats_calls,
stripe_customer_id,
stripe_subscription_id,
stripe_subscription_status,
stripe_subscription_interval,
stripe_subscription_paid_until,
stripe_subscription_cancel_at,
created, deleted
FROM user_old;
DROP TABLE user_old;
-- Alter user_access table: Add provisioned column
ALTER TABLE user_access RENAME TO user_access_old;
CREATE TABLE user_access (
user_id TEXT NOT NULL,
topic TEXT NOT NULL,
read INT NOT NULL,
write INT NOT NULL,
owner_user_id INT,
provisioned INTEGER NOT NULL,
PRIMARY KEY (user_id, topic),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE
);
INSERT INTO user_access SELECT *, 0 FROM user_access_old;
DROP TABLE user_access_old;
-- Recreate indices
CREATE UNIQUE INDEX idx_user ON user (user);
CREATE UNIQUE INDEX idx_user_stripe_customer_id ON user (stripe_customer_id);
CREATE UNIQUE INDEX idx_user_stripe_subscription_id ON user (stripe_subscription_id);
-- Re-enable foreign keys
PRAGMA foreign_keys=on;
`
)
var (
@@ -435,42 +515,69 @@ var (
2: migrateFrom2,
3: migrateFrom3,
4: migrateFrom4,
5: migrateFrom5,
}
)
// Manager is an implementation of Manager. It stores users and access control list
// in a SQLite database.
type Manager struct {
db *sql.DB
defaultAccess Permission // Default permission if no ACL matches
statsQueue map[string]*Stats // "Queue" to asynchronously write user stats to the database (UserID -> Stats)
tokenQueue map[string]*TokenUpdate // "Queue" to asynchronously write token access stats to the database (Token ID -> TokenUpdate)
bcryptCost int // Makes testing easier
mu sync.Mutex
config *Config
db *sql.DB
statsQueue map[string]*Stats // "Queue" to asynchronously write user stats to the database (UserID -> Stats)
tokenQueue map[string]*TokenUpdate // "Queue" to asynchronously write token access stats to the database (Token ID -> TokenUpdate)
mu sync.Mutex
}
// Config holds the configuration for the user Manager
type Config struct {
Filename string // Database filename, e.g. "/var/lib/ntfy/user.db"
StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers
DefaultAccess Permission // Default permission if no ACL matches
ProvisionEnabled bool // Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands
ProvisionUsers []*User // Predefined users to create on startup
ProvisionAccess map[string][]*Grant // Predefined access grants to create on startup
QueueWriterInterval time.Duration // Interval for the async queue writer to flush stats and token updates to the database
BcryptCost int // Cost of generated passwords; lowering makes testing faster
}
var _ Auther = (*Manager)(nil)
// NewManager creates a new Manager instance
func NewManager(filename, startupQueries string, defaultAccess Permission, bcryptCost int, queueWriterInterval time.Duration) (*Manager, error) {
db, err := sql.Open("sqlite3", filename)
func NewManager(config *Config) (*Manager, error) {
// Set defaults
if config.BcryptCost <= 0 {
config.BcryptCost = DefaultUserPasswordBcryptCost
}
if config.QueueWriterInterval.Seconds() <= 0 {
config.QueueWriterInterval = DefaultUserStatsQueueWriterInterval
}
// Check the parent directory of the database file (makes for friendly error messages)
parentDir := filepath.Dir(config.Filename)
if !util.FileExists(parentDir) {
return nil, fmt.Errorf("user database directory %s does not exist or is not accessible", parentDir)
}
// Open DB and run setup queries
db, err := sql.Open("sqlite3", config.Filename)
if err != nil {
return nil, err
}
if err := setupDB(db); err != nil {
return nil, err
}
if err := runStartupQueries(db, startupQueries); err != nil {
if err := runStartupQueries(db, config.StartupQueries); err != nil {
return nil, err
}
manager := &Manager{
db: db,
defaultAccess: defaultAccess,
statsQueue: make(map[string]*Stats),
tokenQueue: make(map[string]*TokenUpdate),
bcryptCost: bcryptCost,
db: db,
config: config,
statsQueue: make(map[string]*Stats),
tokenQueue: make(map[string]*TokenUpdate),
}
go manager.asyncQueueWriter(queueWriterInterval)
if err := manager.maybeProvisionUsersAndAccess(); err != nil {
return nil, err
}
go manager.asyncQueueWriter(config.QueueWriterInterval)
return manager, nil
}
@@ -567,7 +674,7 @@ func (a *Manager) Tokens(userID string) ([]*Token, error) {
tokens := make([]*Token, 0)
for {
token, err := a.readToken(rows)
if err == ErrTokenNotFound {
if errors.Is(err, ErrTokenNotFound) {
break
} else if err != nil {
return nil, err
@@ -843,7 +950,7 @@ func (a *Manager) Authorize(user *User, topic string, perm Permission) error {
}
defer rows.Close()
if !rows.Next() {
return a.resolvePerms(a.defaultAccess, perm)
return a.resolvePerms(a.config.DefaultAccess, perm)
}
var read, write bool
if err := rows.Scan(&read, &write); err != nil {
@@ -865,23 +972,33 @@ func (a *Manager) resolvePerms(base, perm Permission) error {
// AddUser adds a user with the given username, password and role
func (a *Manager) AddUser(username, password string, role Role, hashed bool) error {
return execTx(a.db, func(tx *sql.Tx) error {
return a.addUserTx(tx, username, password, role, hashed, false)
})
}
// AddUser adds a user with the given username, password and role
func (a *Manager) addUserTx(tx *sql.Tx, username, password string, role Role, hashed, provisioned bool) error {
if !AllowedUsername(username) || !AllowedRole(role) {
return ErrInvalidArgument
}
var hash []byte
var hash string
var err error = nil
if hashed {
hash = []byte(password)
hash = password
if err := AllowedPasswordHash(hash); err != nil {
return err
}
} else {
hash, err = bcrypt.GenerateFromPassword([]byte(password), a.bcryptCost)
hash, err = a.HashPassword(password)
if err != nil {
return err
}
}
userID := util.RandomStringPrefix(userIDPrefix, userIDLength)
syncTopic, now := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength), time.Now().Unix()
if _, err = a.db.Exec(insertUserQuery, userID, username, hash, role, syncTopic, now); err != nil {
if sqliteErr, ok := err.(sqlite3.Error); ok && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique {
if _, err = tx.Exec(insertUserQuery, userID, username, hash, role, syncTopic, provisioned, now); err != nil {
if errors.Is(err, sqlite3.ErrConstraintUnique) {
return ErrUserExists
}
return err
@@ -892,11 +1009,17 @@ func (a *Manager) AddUser(username, password string, role Role, hashed bool) err
// RemoveUser deletes the user with the given username. The function returns nil on success, even
// if the user did not exist in the first place.
func (a *Manager) RemoveUser(username string) error {
return execTx(a.db, func(tx *sql.Tx) error {
return a.removeUserTx(tx, username)
})
}
func (a *Manager) removeUserTx(tx *sql.Tx, username string) error {
if !AllowedUsername(username) {
return ErrInvalidArgument
}
// Rows in user_access, user_token, etc. are deleted via foreign keys
if _, err := a.db.Exec(deleteUserQuery, username); err != nil {
if _, err := tx.Exec(deleteUserQuery, username); err != nil {
return err
}
return nil
@@ -1010,24 +1133,26 @@ func (a *Manager) userByToken(token string) (*User, error) {
func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
defer rows.Close()
var id, username, hash, role, prefs, syncTopic string
var provisioned bool
var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString
var messages, emails, calls int64
var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64
if !rows.Next() {
return nil, ErrUserNotFound
}
if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &provisioned, &messages, &emails, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
return nil, err
} else if err := rows.Err(); err != nil {
return nil, err
}
user := &User{
ID: id,
Name: username,
Hash: hash,
Role: Role(role),
Prefs: &Prefs{},
SyncTopic: syncTopic,
ID: id,
Name: username,
Hash: hash,
Role: Role(role),
Prefs: &Prefs{},
SyncTopic: syncTopic,
Provisioned: provisioned,
Stats: &Stats{
Messages: messages,
Emails: emails,
@@ -1078,8 +1203,8 @@ func (a *Manager) AllGrants() (map[string][]Grant, error) {
grants := make(map[string][]Grant, 0)
for rows.Next() {
var userID, topic string
var read, write bool
if err := rows.Scan(&userID, &topic, &read, &write); err != nil {
var read, write, provisioned bool
if err := rows.Scan(&userID, &topic, &read, &write, &provisioned); err != nil {
return nil, err
} else if err := rows.Err(); err != nil {
return nil, err
@@ -1089,7 +1214,8 @@ func (a *Manager) AllGrants() (map[string][]Grant, error) {
}
grants[userID] = append(grants[userID], Grant{
TopicPattern: fromSQLWildcard(topic),
Allow: NewPermission(read, write),
Permission: NewPermission(read, write),
Provisioned: provisioned,
})
}
return grants, nil
@@ -1105,15 +1231,16 @@ func (a *Manager) Grants(username string) ([]Grant, error) {
grants := make([]Grant, 0)
for rows.Next() {
var topic string
var read, write bool
if err := rows.Scan(&topic, &read, &write); err != nil {
var read, write, provisioned bool
if err := rows.Scan(&topic, &read, &write, &provisioned); err != nil {
return nil, err
} else if err := rows.Err(); err != nil {
return nil, err
}
grants = append(grants, Grant{
TopicPattern: fromSQLWildcard(topic),
Allow: NewPermission(read, write),
Permission: NewPermission(read, write),
Provisioned: provisioned,
})
}
return grants, nil
@@ -1199,18 +1326,26 @@ func (a *Manager) ReservationOwner(topic string) (string, error) {
// ChangePassword changes a user's password
func (a *Manager) ChangePassword(username, password string, hashed bool) error {
var hash []byte
var err error
return execTx(a.db, func(tx *sql.Tx) error {
return a.changePasswordTx(tx, username, password, hashed)
})
}
func (a *Manager) changePasswordTx(tx *sql.Tx, username, password string, hashed bool) error {
var hash string
var err error
if hashed {
hash = []byte(password)
hash = password
if err := AllowedPasswordHash(hash); err != nil {
return err
}
} else {
hash, err = bcrypt.GenerateFromPassword([]byte(password), a.bcryptCost)
hash, err = a.HashPassword(password)
if err != nil {
return err
}
}
if _, err := a.db.Exec(updateUserPassQuery, hash, username); err != nil {
if _, err := tx.Exec(updateUserPassQuery, hash, username); err != nil {
return err
}
return nil
@@ -1219,14 +1354,20 @@ func (a *Manager) ChangePassword(username, password string, hashed bool) error {
// ChangeRole changes a user's role. When a role is changed from RoleUser to RoleAdmin,
// all existing access control entries (Grant) are removed, since they are no longer needed.
func (a *Manager) ChangeRole(username string, role Role) error {
return execTx(a.db, func(tx *sql.Tx) error {
return a.changeRoleTx(tx, username, role)
})
}
func (a *Manager) changeRoleTx(tx *sql.Tx, username string, role Role) error {
if !AllowedUsername(username) || !AllowedRole(role) {
return ErrInvalidArgument
}
if _, err := a.db.Exec(updateUserRoleQuery, string(role), username); err != nil {
if _, err := tx.Exec(updateUserRoleQuery, string(role), username); err != nil {
return err
}
if role == RoleAdmin {
if _, err := a.db.Exec(deleteUserAccessQuery, username, username); err != nil {
if _, err := tx.Exec(deleteUserAccessQuery, username, username); err != nil {
return err
}
}
@@ -1306,13 +1447,19 @@ func (a *Manager) AllowReservation(username string, topic string) error {
// read/write access to a topic. The parameter topicPattern may include wildcards (*). The ACL entry
// owner may either be a user (username), or the system (empty).
func (a *Manager) AllowAccess(username string, topicPattern string, permission Permission) error {
return execTx(a.db, func(tx *sql.Tx) error {
return a.allowAccessTx(tx, username, topicPattern, permission, false)
})
}
func (a *Manager) allowAccessTx(tx *sql.Tx, username string, topicPattern string, permission Permission, provisioned bool) error {
if !AllowedUsername(username) && username != Everyone {
return ErrInvalidArgument
} else if !AllowedTopicPattern(topicPattern) {
return ErrInvalidArgument
}
owner := ""
if _, err := a.db.Exec(upsertUserAccessQuery, username, toSQLWildcard(topicPattern), permission.IsRead(), permission.IsWrite(), owner, owner); err != nil {
if _, err := tx.Exec(upsertUserAccessQuery, username, toSQLWildcard(topicPattern), permission.IsRead(), permission.IsWrite(), owner, owner, provisioned); err != nil {
return err
}
return nil
@@ -1349,10 +1496,10 @@ func (a *Manager) AddReservation(username string, topic string, everyone Permiss
return err
}
defer tx.Rollback()
if _, err := tx.Exec(upsertUserAccessQuery, username, escapeUnderscore(topic), true, true, username, username); err != nil {
if _, err := tx.Exec(upsertUserAccessQuery, username, escapeUnderscore(topic), true, true, username, username, false); err != nil {
return err
}
if _, err := tx.Exec(upsertUserAccessQuery, Everyone, escapeUnderscore(topic), everyone.IsRead(), everyone.IsWrite(), username, username); err != nil {
if _, err := tx.Exec(upsertUserAccessQuery, Everyone, escapeUnderscore(topic), everyone.IsRead(), everyone.IsWrite(), username, username, false); err != nil {
return err
}
return tx.Commit()
@@ -1387,7 +1534,7 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error {
// DefaultAccess returns the default read/write access if no access control entry matches
func (a *Manager) DefaultAccess() Permission {
return a.defaultAccess
return a.config.DefaultAccess
}
// AddTier creates a new tier in the database
@@ -1500,11 +1647,81 @@ func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) {
}, nil
}
// HashPassword hashes the given password using bcrypt with the configured cost
func (a *Manager) HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), a.config.BcryptCost)
if err != nil {
return "", err
}
return string(hash), nil
}
// Close closes the underlying database
func (a *Manager) Close() error {
return a.db.Close()
}
func (a *Manager) maybeProvisionUsersAndAccess() error {
if !a.config.ProvisionEnabled {
return nil
}
users, err := a.Users()
if err != nil {
return err
}
provisionUsernames := util.Map(a.config.ProvisionUsers, func(u *User) string {
return u.Name
})
return execTx(a.db, func(tx *sql.Tx) error {
// Remove users that are provisioned, but not in the config anymore
for _, user := range users {
if user.Name == Everyone {
continue
} else if user.Provisioned && !util.Contains(provisionUsernames, user.Name) {
log.Tag(tag).Info("Removing previously provisioned user %s", user.Name)
if err := a.removeUserTx(tx, user.Name); err != nil {
return fmt.Errorf("failed to remove provisioned user %s: %v", user.Name, err)
}
}
}
// Add or update provisioned users
for _, user := range a.config.ProvisionUsers {
if user.Name == Everyone {
continue
}
existingUser, exists := util.Find(users, func(u *User) bool {
return u.Name == user.Name
})
if !exists {
log.Tag(tag).Info("Adding provisioned user %s", user.Name)
if err := a.addUserTx(tx, user.Name, user.Hash, user.Role, true, true); err != nil && !errors.Is(err, ErrUserExists) {
return fmt.Errorf("failed to add provisioned user %s: %v", user.Name, err)
}
} else if existingUser.Provisioned && (existingUser.Hash != user.Hash || existingUser.Role != user.Role) {
log.Tag(tag).Info("Updating provisioned user %s", user.Name)
if err := a.changePasswordTx(tx, user.Name, user.Hash, true); err != nil {
return fmt.Errorf("failed to change password for provisioned user %s: %v", user.Name, err)
}
if err := a.changeRoleTx(tx, user.Name, user.Role); err != nil {
return fmt.Errorf("failed to change role for provisioned user %s: %v", user.Name, err)
}
}
}
// Remove and (re-)add provisioned grants
if _, err := tx.Exec(deleteUserAccessProvisionedQuery); err != nil {
return err
}
for username, grants := range a.config.ProvisionAccess {
for _, grant := range grants {
if err := a.allowAccessTx(tx, username, grant.TopicPattern, grant.Permission, true); err != nil {
return err
}
}
}
return nil
})
}
// toSQLWildcard converts a wildcard string to a SQL wildcard string. It only allows '*' as wildcards,
// and escapes '_', assuming '\' as escape character.
func toSQLWildcard(s string) string {
@@ -1676,6 +1893,22 @@ func migrateFrom4(db *sql.DB) error {
return tx.Commit()
}
func migrateFrom5(db *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 5 to 6")
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(migrate5To6UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(updateSchemaVersion, 6); err != nil {
return err
}
return tx.Commit()
}
func nullString(s string) sql.NullString {
if s == "" {
return sql.NullString{}
@@ -1689,3 +1922,18 @@ func nullInt64(v int64) sql.NullInt64 {
}
return sql.NullInt64{Int64: v, Valid: true}
}
// execTx executes a function in a transaction. If the function returns an error, the transaction is rolled back.
func execTx(db *sql.DB, f func(tx *sql.Tx) error) error {
tx, err := db.Begin()
if err != nil {
return err
}
if err := f(tx); err != nil {
if e := tx.Rollback(); e != nil {
return err
}
return err
}
return tx.Commit()
}

View File

@@ -52,10 +52,10 @@ func TestManager_FullScenario_Default_DenyAll(t *testing.T) {
benGrants, err := a.Grants("ben")
require.Nil(t, err)
require.Equal(t, []Grant{
{"everyonewrite", PermissionDenyAll},
{"mytopic", PermissionReadWrite},
{"writeme", PermissionWrite},
{"readme", PermissionRead},
{"everyonewrite", PermissionDenyAll, false},
{"mytopic", PermissionReadWrite, false},
{"writeme", PermissionWrite, false},
{"readme", PermissionRead, false},
}, benGrants)
john, err := a.Authenticate("john", "john")
@@ -67,10 +67,10 @@ func TestManager_FullScenario_Default_DenyAll(t *testing.T) {
johnGrants, err := a.Grants("john")
require.Nil(t, err)
require.Equal(t, []Grant{
{"mytopic_deny*", PermissionDenyAll},
{"mytopic_ro*", PermissionRead},
{"mytopic*", PermissionReadWrite},
{"*", PermissionRead},
{"mytopic_deny*", PermissionDenyAll, false},
{"mytopic_ro*", PermissionRead, false},
{"mytopic*", PermissionReadWrite, false},
{"*", PermissionRead, false},
}, johnGrants)
notben, err := a.Authenticate("ben", "this is wrong")
@@ -277,10 +277,10 @@ func TestManager_UserManagement(t *testing.T) {
benGrants, err := a.Grants("ben")
require.Nil(t, err)
require.Equal(t, []Grant{
{"everyonewrite", PermissionDenyAll},
{"mytopic", PermissionReadWrite},
{"writeme", PermissionWrite},
{"readme", PermissionRead},
{"everyonewrite", PermissionDenyAll, false},
{"mytopic", PermissionReadWrite, false},
{"writeme", PermissionWrite, false},
{"readme", PermissionRead, false},
}, benGrants)
everyone, err := a.User(Everyone)
@@ -292,8 +292,8 @@ func TestManager_UserManagement(t *testing.T) {
everyoneGrants, err := a.Grants(Everyone)
require.Nil(t, err)
require.Equal(t, []Grant{
{"everyonewrite", PermissionReadWrite},
{"announcements", PermissionRead},
{"everyonewrite", PermissionReadWrite, false},
{"announcements", PermissionRead, false},
}, everyoneGrants)
// Ben: Before revoking
@@ -340,7 +340,7 @@ func TestManager_UserManagement(t *testing.T) {
func TestManager_ChangePassword(t *testing.T) {
a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, false))
require.Nil(t, a.AddUser("jane", "$2b$10$OyqU72muEy7VMd1SAU2Iru5IbeSMgrtCGHu/fWLmxL1MwlijQXWbG", RoleUser, true))
require.Nil(t, a.AddUser("jane", "$2a$10$OyqU72muEy7VMd1SAU2Iru5IbeSMgrtCGHu/fWLmxL1MwlijQXWbG", RoleUser, true))
_, err := a.Authenticate("phil", "phil")
require.Nil(t, err)
@@ -354,7 +354,7 @@ func TestManager_ChangePassword(t *testing.T) {
_, err = a.Authenticate("phil", "newpass")
require.Nil(t, err)
require.Nil(t, a.ChangePassword("jane", "$2b$10$CNaCW.q1R431urlbQ5Drh.zl48TiiOeJSmZgfcswkZiPbJGQ1ApSS", true))
require.Nil(t, a.ChangePassword("jane", "$2a$10$CNaCW.q1R431urlbQ5Drh.zl48TiiOeJSmZgfcswkZiPbJGQ1ApSS", true))
_, err = a.Authenticate("jane", "jane")
require.Equal(t, ErrUnauthenticated, err)
_, err = a.Authenticate("jane", "newpass")
@@ -489,12 +489,12 @@ func TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) {
benGrants, err := a.Grants("ben")
require.Nil(t, err)
require.Equal(t, 1, len(benGrants))
require.Equal(t, PermissionReadWrite, benGrants[0].Allow)
require.Equal(t, PermissionReadWrite, benGrants[0].Permission)
everyoneGrants, err := a.Grants(Everyone)
require.Nil(t, err)
require.Equal(t, 1, len(everyoneGrants))
require.Equal(t, PermissionDenyAll, everyoneGrants[0].Allow)
require.Equal(t, PermissionDenyAll, everyoneGrants[0].Permission)
benReservations, err := a.Reservations("ben")
require.Nil(t, err)
@@ -731,7 +731,14 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
}
func TestManager_EnqueueStats_ResetStats(t *testing.T) {
a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 1500*time.Millisecond)
conf := &Config{
Filename: filepath.Join(t.TempDir(), "db"),
StartupQueries: "",
DefaultAccess: PermissionReadWrite,
BcryptCost: bcrypt.MinCost,
QueueWriterInterval: 1500 * time.Millisecond,
}
a, err := NewManager(conf)
require.Nil(t, err)
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
@@ -773,7 +780,14 @@ func TestManager_EnqueueStats_ResetStats(t *testing.T) {
}
func TestManager_EnqueueTokenUpdate(t *testing.T) {
a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 500*time.Millisecond)
conf := &Config{
Filename: filepath.Join(t.TempDir(), "db"),
StartupQueries: "",
DefaultAccess: PermissionReadWrite,
BcryptCost: bcrypt.MinCost,
QueueWriterInterval: 500 * time.Millisecond,
}
a, err := NewManager(conf)
require.Nil(t, err)
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
@@ -806,7 +820,14 @@ func TestManager_EnqueueTokenUpdate(t *testing.T) {
}
func TestManager_ChangeSettings(t *testing.T) {
a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 1500*time.Millisecond)
conf := &Config{
Filename: filepath.Join(t.TempDir(), "db"),
StartupQueries: "",
DefaultAccess: PermissionReadWrite,
BcryptCost: bcrypt.MinCost,
QueueWriterInterval: 1500 * time.Millisecond,
}
a, err := NewManager(conf)
require.Nil(t, err)
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
@@ -1075,6 +1096,103 @@ func TestManager_Topic_Wildcard_With_Underscore(t *testing.T) {
require.Equal(t, ErrUnauthorized, a.Authorize(nil, "mytopicX", PermissionWrite))
}
func TestManager_WithProvisionedUsers(t *testing.T) {
f := filepath.Join(t.TempDir(), "user.db")
conf := &Config{
Filename: f,
DefaultAccess: PermissionReadWrite,
ProvisionEnabled: true,
ProvisionUsers: []*User{
{Name: "philuser", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
{Name: "philadmin", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleAdmin},
},
ProvisionAccess: map[string][]*Grant{
"philuser": {
{TopicPattern: "stats", Permission: PermissionReadWrite},
{TopicPattern: "secret", Permission: PermissionRead},
},
},
}
a, err := NewManager(conf)
require.Nil(t, err)
// Manually add user
require.Nil(t, a.AddUser("philmanual", "manual", RoleUser, false))
// Check that the provisioned users are there
users, err := a.Users()
require.Nil(t, err)
require.Len(t, users, 4)
require.Equal(t, "philadmin", users[0].Name)
require.Equal(t, RoleAdmin, users[0].Role)
require.Equal(t, "philmanual", users[1].Name)
require.Equal(t, RoleUser, users[1].Role)
grants, err := a.Grants("philuser")
require.Nil(t, err)
require.Equal(t, "philuser", users[2].Name)
require.Equal(t, RoleUser, users[2].Role)
require.Equal(t, 2, len(grants))
require.Equal(t, "secret", grants[0].TopicPattern)
require.Equal(t, PermissionRead, grants[0].Permission)
require.Equal(t, "stats", grants[1].TopicPattern)
require.Equal(t, PermissionReadWrite, grants[1].Permission)
require.Equal(t, "*", users[3].Name)
// Re-open the DB (second app start)
require.Nil(t, a.db.Close())
conf.ProvisionUsers = []*User{
{Name: "philuser", Hash: "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
}
conf.ProvisionAccess = map[string][]*Grant{
"philuser": {
{TopicPattern: "stats12", Permission: PermissionReadWrite},
{TopicPattern: "secret12", Permission: PermissionRead},
},
}
a, err = NewManager(conf)
require.Nil(t, err)
// Check that the provisioned users are there
users, err = a.Users()
require.Nil(t, err)
require.Len(t, users, 3)
require.Equal(t, "philmanual", users[0].Name)
require.Equal(t, RoleUser, users[0].Role)
grants, err = a.Grants("philuser")
require.Nil(t, err)
require.Equal(t, "philuser", users[1].Name)
require.Equal(t, RoleUser, users[1].Role)
require.Equal(t, 2, len(grants))
require.Equal(t, "secret12", grants[0].TopicPattern)
require.Equal(t, PermissionRead, grants[0].Permission)
require.Equal(t, "stats12", grants[1].TopicPattern)
require.Equal(t, PermissionReadWrite, grants[1].Permission)
require.Equal(t, "*", users[2].Name)
// Re-open the DB again (third app start)
require.Nil(t, a.db.Close())
conf.ProvisionUsers = []*User{}
conf.ProvisionAccess = map[string][]*Grant{}
a, err = NewManager(conf)
require.Nil(t, err)
// Check that the provisioned users are there
users, err = a.Users()
require.Nil(t, err)
require.Len(t, users, 2)
require.Equal(t, "philmanual", users[0].Name)
require.Equal(t, RoleUser, users[0].Role)
require.Equal(t, "*", users[1].Name)
}
func TestToFromSQLWildcard(t *testing.T) {
require.Equal(t, "up%", toSQLWildcard("up*"))
require.Equal(t, "up\\_%", toSQLWildcard("up_*"))
@@ -1162,16 +1280,16 @@ func TestMigrationFrom1(t *testing.T) {
require.NotEqual(t, ben.SyncTopic, phil.SyncTopic)
require.Equal(t, 2, len(benGrants))
require.Equal(t, "secret", benGrants[0].TopicPattern)
require.Equal(t, PermissionRead, benGrants[0].Allow)
require.Equal(t, PermissionRead, benGrants[0].Permission)
require.Equal(t, "stats", benGrants[1].TopicPattern)
require.Equal(t, PermissionReadWrite, benGrants[1].Allow)
require.Equal(t, PermissionReadWrite, benGrants[1].Permission)
require.Equal(t, "u_everyone", everyone.ID)
require.Equal(t, Everyone, everyone.Name)
require.Equal(t, RoleAnonymous, everyone.Role)
require.Equal(t, 1, len(everyoneGrants))
require.Equal(t, "stats", everyoneGrants[0].TopicPattern)
require.Equal(t, PermissionRead, everyoneGrants[0].Allow)
require.Equal(t, PermissionRead, everyoneGrants[0].Permission)
}
func TestMigrationFrom4(t *testing.T) {
@@ -1336,7 +1454,14 @@ func newTestManager(t *testing.T, defaultAccess Permission) *Manager {
}
func newTestManagerFromFile(t *testing.T, filename, startupQueries string, defaultAccess Permission, bcryptCost int, statsWriterInterval time.Duration) *Manager {
a, err := NewManager(filename, startupQueries, defaultAccess, bcryptCost, statsWriterInterval)
conf := &Config{
Filename: filename,
StartupQueries: startupQueries,
DefaultAccess: defaultAccess,
BcryptCost: bcryptCost,
QueueWriterInterval: statsWriterInterval,
}
a, err := NewManager(conf)
require.Nil(t, err)
return a
}

View File

@@ -12,17 +12,18 @@ import (
// User is a struct that represents a user
type User struct {
ID string
Name string
Hash string // password hash (bcrypt)
Token string // Only set if token was used to log in
Role Role
Prefs *Prefs
Tier *Tier
Stats *Stats
Billing *Billing
SyncTopic string
Deleted bool
ID string
Name string
Hash string // Password hash (bcrypt)
Token string // Only set if token was used to log in
Role Role
Prefs *Prefs
Tier *Tier
Stats *Stats
Billing *Billing
SyncTopic string
Provisioned bool // Whether the user was provisioned by the config file
Deleted bool // Whether the user was soft-deleted
}
// TierID returns the ID of the User.Tier, or an empty string if the user has no tier,
@@ -148,7 +149,8 @@ type Billing struct {
// Grant is a struct that represents an access control entry to a topic by a user
type Grant struct {
TopicPattern string // May include wildcard (*)
Allow Permission
Permission Permission
Provisioned bool // Whether the grant was provisioned by the config file
}
// Reservation is a struct that represents the ownership over a topic by a user
@@ -272,6 +274,14 @@ func AllowedTier(tier string) bool {
return allowedTierRegex.MatchString(tier)
}
// AllowedPasswordHash checks if the given password hash is a valid bcrypt hash
func AllowedPasswordHash(hash string) error {
if !strings.HasPrefix(hash, "$2a$") && !strings.HasPrefix(hash, "$2b$") && !strings.HasPrefix(hash, "$2y$") {
return ErrPasswordHashInvalid
}
return nil
}
// Error constants used by the package
var (
ErrUnauthenticated = errors.New("unauthenticated")
@@ -279,6 +289,7 @@ var (
ErrInvalidArgument = errors.New("invalid argument")
ErrUserNotFound = errors.New("user not found")
ErrUserExists = errors.New("user already exists")
ErrPasswordHashInvalid = errors.New("password hash but be a bcrypt hash, use 'ntfy user hash' to generate")
ErrTierNotFound = errors.New("tier not found")
ErrTokenNotFound = errors.New("token not found")
ErrPhoneNumberNotFound = errors.New("phone number not found")

19
util/sprig/LICENSE.txt Normal file
View File

@@ -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
util/sprig/crypto.go Normal file
View File

@@ -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
util/sprig/crypto_test.go Normal file
View File

@@ -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
util/sprig/date.go Normal file
View File

@@ -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
util/sprig/date_test.go Normal file
View File

@@ -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
util/sprig/defaults.go Normal file
View File

@@ -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
util/sprig/defaults_test.go Normal file
View File

@@ -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
util/sprig/dict.go Normal file
View File

@@ -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
util/sprig/dict_test.go Normal file
View File

@@ -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
util/sprig/doc.go Normal file
View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
util/sprig/functions.go Normal file
View File

@@ -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,
}
}

View File

@@ -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"))
}

View File

@@ -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
util/sprig/list.go Normal file
View File

@@ -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
util/sprig/list_test.go Normal file
View File

@@ -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
util/sprig/numeric.go Normal file
View File

@@ -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
util/sprig/numeric_test.go Normal file
View File

@@ -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
util/sprig/reflect.go Normal file
View File

@@ -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()
}

View File

@@ -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
util/sprig/regex.go Normal file
View File

@@ -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
util/sprig/regex_test.go Normal file
View File

@@ -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
util/sprig/strings.go Normal file
View File

@@ -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
util/sprig/strings_test.go Normal file
View File

@@ -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
util/sprig/url.go Normal file
View File

@@ -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
util/sprig/url_test.go Normal file
View File

@@ -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))
}
}

View File

@@ -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)
}

View File

@@ -120,6 +120,18 @@ func Filter[T any](slice []T, f func(T) bool) []T {
return result
}
// Find returns the first element in the slice that satisfies the given function, and a boolean indicating
// whether such an element was found. If no element is found, it returns the zero value of T and false.
func Find[T any](slice []T, f func(T) bool) (T, bool) {
for _, v := range slice {
if f(v) {
return v, true
}
}
var zero T
return zero, false
}
// RandomString returns a random string with a given length
func RandomString(length int) string {
return RandomStringPrefix("", length)

989
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
{
"nav_button_documentation": "Documentació",
"action_bar_profile_title": "Perfil",
"action_bar_settings": "Configuració",
"action_bar_account": "Compte",
"common_add": "Afegir"
}

View File

@@ -31,12 +31,12 @@
"notifications_attachment_open_title": "Gehe zu {{url}}",
"notifications_none_for_any_title": "Du hast keine Benachrichtigungen empfangen.",
"action_bar_send_test_notification": "Test-Benachrichtigung senden",
"alert_notification_permission_required_description": "Dem Browser erlauben, Desktop-Benachrichtigungen anzuzeigen.",
"alert_notification_permission_required_description": "Browser erlauben, Desktop-Benachrichtigungen anzuzeigen",
"notifications_tags": "Tags",
"message_bar_type_message": "Gib hier eine Nachricht ein",
"message_bar_error_publishing": "Fehler beim Senden der Benachrichtigung",
"alert_not_supported_title": "Benachrichtigungen werden nicht unterstützt",
"alert_not_supported_description": "Benachrichtigungen werden von Deinem Browser nicht unterstützt",
"alert_not_supported_description": "Benachrichtigungen werden von deinem Browser nicht unterstützt",
"action_bar_settings": "Einstellungen",
"action_bar_clear_notifications": "Alle Benachrichtigungen löschen",
"alert_notification_permission_required_button": "Jetzt erlauben",
@@ -208,11 +208,11 @@
"action_bar_change_display_name": "Anzeigenamen ändern",
"action_bar_reservation_add": "Thema reservieren",
"action_bar_reservation_edit": "Reservierung ändern",
"action_bar_reservation_delete": "Reservierung löschen",
"action_bar_reservation_delete": "Reservierung entfernen",
"action_bar_reservation_limit_reached": "Grenze erreicht",
"action_bar_profile_title": "Profil",
"action_bar_profile_settings": "Einstellungen",
"action_bar_profile_logout": "Abmelden",
"action_bar_profile_logout": "Ausloggen",
"action_bar_sign_in": "Anmelden",
"signup_form_password": "Kennwort",
"signup_form_toggle_password_visibility": "Kennwort-Sichtbarkeit umschalten",
@@ -382,7 +382,7 @@
"account_usage_calls_none": "Noch keine Anrufe mit diesem Account getätigt",
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} Telefonanrufe pro Tag",
"action_bar_mute_notifications": "Benachrichtigungen stummschalten",
"action_bar_unmute_notifications": "Benachrichtigungen laut schalten",
"action_bar_unmute_notifications": "Stummschaltung von Benachrichtigungen aufheben",
"alert_notification_permission_denied_title": "Benachrichtigungen sind blockiert",
"alert_notification_permission_denied_description": "Bitte reaktiviere diese in deinem Browser",
"notifications_actions_failed_notification": "Aktion nicht erfolgreich",
@@ -390,8 +390,8 @@
"alert_notification_ios_install_required_description": "Klicke auf das Teilen-Symbol und “Zum Home-Bildschirm” um auf iOS Benachrichtigungen zu aktivieren",
"subscribe_dialog_subscribe_use_another_background_info": "Benachrichtigungen von anderen Servern werden nicht empfangen, wenn die Web App nicht geöffnet ist",
"publish_dialog_checkbox_markdown": "Als Markdown formatieren",
"prefs_notifications_web_push_title": "Hintergrund-Benachrichtigungen",
"prefs_notifications_web_push_disabled_description": "Benachrichtigungen werden empfangen wenn die Web App läuft (über WebSocket)",
"prefs_notifications_web_push_title": "Hintergrundbenachrichtigungen",
"prefs_notifications_web_push_disabled_description": "Benachrichtigungen werden empfangen, wenn die Web App geöffnet ist (via WebSocket)",
"prefs_notifications_web_push_enabled": "Aktiviert für {{server}}",
"prefs_notifications_web_push_disabled": "Deaktiviert",
"prefs_appearance_theme_title": "Thema",
@@ -401,7 +401,7 @@
"error_boundary_button_reload_ntfy": "ntfy neu laden",
"web_push_subscription_expiring_title": "Benachrichtigungen werden pausiert",
"web_push_subscription_expiring_body": "Öffne ntfy um weiterhin Benachrichtigungen zu erhalten",
"web_push_unknown_notification_title": "Unbekannte Benachrichtigung vom server empfangen",
"web_push_unknown_notification_body": "Du musst möglicherweise ntfy aktualisieren, indem du die Web App öffnest.",
"prefs_notifications_web_push_enabled_description": "Benachrichtigungen werden empfangen, auch wenn die Web App nicht läuft (über Web Push)"
"web_push_unknown_notification_title": "Unbekannte Benachrichtigung vom Server empfangen",
"web_push_unknown_notification_body": "Du musst möglicherweise ntfy aktualisieren, indem du die Web App öffnest",
"prefs_notifications_web_push_enabled_description": "Benachrichtigungen werden empfangen, auch wenn die Web App nicht geöffnet ist (via Web Push)"
}

View File

@@ -99,5 +99,176 @@
"account_tokens_table_create_token_button": "Loo ligipääsuks vajalik tunnusluba",
"account_tokens_dialog_title_create": "Loo ligipääsuks vajalik tunnusluba",
"account_tokens_dialog_title_edit": "Muuda ligipääsuks vajalikku tunnusluba",
"account_tokens_dialog_title_delete": "Kustuta ligipääsuks vajalik tunnusluba"
"account_tokens_dialog_title_delete": "Kustuta ligipääsuks vajalik tunnusluba",
"subscribe_dialog_login_password_label": "Salasõna",
"publish_dialog_filename_label": "Failinimi",
"prefs_reservations_table_access_header": "Ligipääs",
"publish_dialog_chip_click_label": "Klõpsi võrguaadressi",
"subscribe_dialog_subscribe_button_cancel": "Katkesta",
"publish_dialog_delay_label": "Viivitus",
"account_basics_password_title": "Salasõna",
"account_upgrade_dialog_button_cancel": "Katkesta",
"notifications_example": "Näide",
"account_usage_title": "Kasutus",
"account_basics_title": "Kasutajakonto",
"prefs_reservations_table_topic_header": "Teema",
"account_delete_dialog_button_cancel": "Katkesta",
"account_delete_dialog_label": "Salasõna",
"publish_dialog_message_label": "Sõnum",
"account_basics_phone_numbers_dialog_channel_call": "Kõne",
"prefs_users_dialog_password_label": "Salasõna",
"subscribe_dialog_subscribe_button_subscribe": "Telli",
"publish_dialog_priority_label": "Prioriteet",
"subscribe_dialog_login_button_login": "Logi sisse",
"subscribe_dialog_error_user_anonymous": "anonüümne",
"prefs_appearance_theme_title": "Kujundus",
"publish_dialog_button_cancel": "Katkesta",
"account_usage_unlimited": "Piiramatu",
"prefs_notifications_delete_after_never": "Mitte kunagi",
"account_upgrade_dialog_interval_monthly": "Iga kuu",
"account_upgrade_dialog_tier_price_per_month": "kuu",
"prefs_notifications_web_push_disabled": "Pole kasutusel",
"prefs_appearance_title": "Välimus",
"prefs_appearance_language_title": "Keel",
"prefs_reservations_dialog_topic_label": "Teema",
"publish_dialog_priority_min": "Väikseim tähtsus",
"notifications_actions_failed_notification": "Ebaõnnestunud toiming",
"publish_dialog_title_label": "Pealkiri",
"publish_dialog_tags_label": "Sildid",
"publish_dialog_email_label": "E-post",
"display_name_dialog_placeholder": "Kuvatav nimi",
"publish_dialog_title_no_topic": "Avalda teavitus",
"publish_dialog_progress_uploading": "Laadin üles…",
"publish_dialog_message_published": "Teavitus on saadetud",
"publish_dialog_emoji_picker_show": "Vali emoji",
"publish_dialog_priority_low": "Vähetähtis",
"publish_dialog_priority_default": "Vaikimisi tähtsus",
"publish_dialog_priority_high": "Oluline",
"publish_dialog_priority_max": "Väga oluline",
"publish_dialog_base_url_label": "Teenuse võrguaadress",
"publish_dialog_topic_label": "Teema nimi",
"publish_dialog_topic_reset": "Lähtesta teema",
"publish_dialog_click_label": "Klõpsi võrguaadressi",
"publish_dialog_call_label": "Telefonikõne",
"publish_dialog_button_send": "Saada",
"publish_dialog_attach_label": "Manuse võrguaadress",
"publish_dialog_filename_placeholder": "Manuse failinimi",
"publish_dialog_other_features": "Lisavõimalused:",
"publish_dialog_chip_call_label": "Telefonikõne",
"publish_dialog_chip_delay_label": "Viivita saatmisega",
"publish_dialog_chip_topic_label": "Muuda teemat",
"publish_dialog_button_cancel_sending": "Katkesta saatmine",
"account_basics_username_title": "Kasutajanimi",
"account_basics_phone_numbers_dialog_channel_sms": "Tekstisõnum",
"account_basics_tier_admin": "Peakasutaja",
"account_basics_tier_basic": "Baasteenus",
"account_basics_tier_free": "Tasuta",
"account_basics_tier_interval_monthly": "kord kuus",
"account_basics_tier_interval_yearly": "kord aastas",
"account_basics_tier_change_button": "Muuda",
"account_upgrade_dialog_interval_yearly": "Kord aastas",
"account_upgrade_dialog_tier_selected_label": "Valitud",
"account_upgrade_dialog_tier_current_label": "Praegune",
"account_tokens_dialog_button_cancel": "Katkesta",
"prefs_notifications_title": "Teavitused",
"prefs_users_table_user_header": "Kasutaja",
"prefs_reservations_dialog_access_label": "Ligipääs",
"priority_min": "min",
"priority_low": "madal",
"priority_default": "vaikimisi",
"priority_high": "kõrge",
"priority_max": "kõrgeim",
"alert_notification_ios_install_required_description": "Teavituste lubamiseks iOS-is klõpsi „Jaga“ ikooni ja vali „Lisa avaekraanile“",
"notifications_none_for_topic_title": "Sul pole selles teemas veel ühtegi teavitust.",
"notifications_none_for_topic_description": "Selles teemas teavituste saatmiseks tee PUT või POST meetodiga päring teema võrguaadressile.",
"publish_dialog_base_url_placeholder": "Teenuse võrguaadress, nt. https://toresait.com",
"notifications_loading": "Laadin teavitusi…",
"publish_dialog_title_topic": "Avalda teemas {{topic}}",
"publish_dialog_progress_uploading_detail": "Üleslaadimisel {{loaded}}/{{total}} ({{percent}}%) …",
"publish_dialog_topic_placeholder": "Teema nimi, nt. kati_teavitused",
"publish_dialog_title_placeholder": "Teavituse pealkiri, nt. Andmeruumi teavitus",
"publish_dialog_message_placeholder": "Siia sisesta sõnum",
"notifications_none_for_any_title": "Sa pole veel saanud ühtegi teavitust.",
"publish_dialog_chip_attach_file_label": "Lisa kohalik fail",
"publish_dialog_chip_attach_url_label": "Lisa fail võrguaadressilt",
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Kinnitatud telefoninumbreid ei leidu",
"publish_dialog_chip_email_label": "Edasta e-posti aadressile",
"subscribe_dialog_subscribe_base_url_label": "Teenuse võrguaadress",
"subscribe_dialog_subscribe_button_generate_topic_name": "Loo nimi",
"publish_dialog_checkbox_markdown": "Kasuta Markdown-vormingut",
"subscribe_dialog_login_title": "Vajalik on sisselogimine",
"subscribe_dialog_login_username_label": "Kasutajanimi, nt. kadri",
"account_basics_phone_numbers_dialog_verify_button_sms": "Saada SMS",
"account_basics_username_description": "Hei, see oled sina ❤",
"account_basics_username_admin_tooltip": "Sina oled peakasutaja",
"account_basics_phone_numbers_dialog_verify_button_call": "Helista mulle",
"account_basics_phone_numbers_dialog_code_label": "Kinnituskood",
"account_basics_phone_numbers_dialog_code_placeholder": "nt. 123456",
"account_basics_phone_numbers_dialog_check_verification_button": "Korda koodi",
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} sõnum päevas",
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} sõnumit päevas",
"account_upgrade_dialog_button_redirect_signup": "Liitu kohe",
"notifications_actions_http_request_title": "Tee päring HTTP {{method}}-meetodiga võrguaadressile {{url}}",
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} e-kirja päevas",
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} e-kiri päevas",
"alert_not_supported_context_description": "Teavitused võivad kasutada vaid HTTPS-ühendust. See on <mdnLink>Teavituste API</mdnLink> piirang.",
"publish_dialog_tags_placeholder": "Komadega eraldatud siltide loend, nt. hoiatus, srv1-varundus",
"display_name_dialog_title": "Muuda kuvatavat nime",
"display_name_dialog_description": "Lisa teemale alternatiivne nimi, mida kuvatakse tellimuste loendis. See on näiteks abiks keerukate nimedega teemade tuvastamiseks.",
"reserve_dialog_checkbox_label": "Reserveeri teema ja seadista ligipääs",
"publish_dialog_attachment_limits_file_reached": "ületab failisuuruse piiri: {{fileSizeLimit}}",
"publish_dialog_attachment_limits_quota_reached": "ületab kvooti, jäänud on {{remainingBytes}}",
"publish_dialog_attachment_limits_file_and_quota_reached": "ületab failisuuruse ülempiiri ({{fileSizeLimit}}) ja kvooti, jäänud on {{remainingBytes}}",
"publish_dialog_click_placeholder": "Teavituse klõpsimisel avatav võrguaadress",
"publish_dialog_click_reset": "Eemalda klikatav võrguaadress",
"publish_dialog_email_placeholder": "Aadress, kuhu teavitus edastatakse, nt. kadri@torefirma.com",
"publish_dialog_email_reset": "Eemalda edastamiseks kasutatav e-posti aadress",
"publish_dialog_call_item": "Helista telefoninumbrile {{number}}",
"publish_dialog_call_reset": "Eemalda helistamine",
"publish_dialog_attach_placeholder": "Lisa fail võrguaadressilt, nt. https://f-droid.org/F-Droid.apk",
"publish_dialog_attach_reset": "Eemalda manuse lisamisel kasutatav võrguaadress",
"publish_dialog_delay_reset": "Eemalda viivitus teavituse edastamisel",
"account_basics_password_description": "Muuda oma kasutajakonto salasõna",
"account_basics_password_dialog_title": "Salasõna muutmine",
"account_basics_password_dialog_current_password_label": "Senine salasõna",
"account_basics_password_dialog_button_submit": "Muuda salasõna",
"account_basics_password_dialog_current_password_incorrect": "Salasõna pole korrektne",
"account_basics_phone_numbers_title": "Telefoninumbrid",
"account_basics_phone_numbers_description": "Kõneteavituste jaoks",
"account_basics_tier_title": "Kasutajakonto tüüp",
"account_basics_tier_description": "Sinu kasutajakonto õigused",
"account_delete_dialog_button_submit": "Kustuta kasutajakonto jäädavalt",
"prefs_appearance_theme_system": "Süsteemi kujundus",
"prefs_appearance_theme_dark": "Tume kujundus",
"prefs_appearance_theme_light": "Hele kujundus",
"prefs_reservations_title": "Reserveeritud teemad",
"prefs_users_table": "Kasutajate loend",
"prefs_users_add_button": "Lisa kasutaja",
"prefs_users_edit_button": "Muuda kasutajat",
"prefs_users_delete_button": "Kustuta kasutaja",
"prefs_users_table_cannot_delete_or_edit": "Sisselogitud kasutajat ei saa kustutada ega muuta",
"prefs_users_table_base_url_header": "Teenuse võrguaadress",
"prefs_users_dialog_title_add": "Lisa kasutaja",
"prefs_users_dialog_title_edit": "Muuda kasutajat",
"prefs_users_dialog_base_url_label": "Teenuse võrguaadress, nt. https://ntfy.sh",
"prefs_users_dialog_username_label": "Kasutajanimi, nt. kadri",
"prefs_notifications_delete_after_three_hours": "Kolme tunni möödumisel",
"prefs_notifications_delete_after_three_hours_description": "Teavitused kustutatakse automaatselt kolme tunni möödumisel",
"prefs_notifications_delete_after_one_day_description": "Teavitused kustutatakse automaatselt ühe päeva möödumisel",
"prefs_notifications_delete_after_one_week_description": "Teavitused kustutatakse automaatselt ühe nädala möödumisel",
"prefs_notifications_delete_after_one_month_description": "Teavitused kustutatakse automaatselt ühe kuu möödumisel",
"prefs_notifications_delete_after_never_description": "Mitte kunagi ei kustutata teavitusi automaatselt",
"prefs_notifications_delete_after_title": "Kustuta teavitused",
"publish_dialog_delay_placeholder": "Viivitus teavituse edastamisel, nt. {{unixTimestamp}}, {{relativeTime}} või „{{naturalLanguage}}“ (vaid inglise keeles)",
"account_basics_password_dialog_new_password_label": "Uus salasõna",
"account_basics_password_dialog_confirm_password_label": "Korda salasõna",
"account_basics_phone_numbers_dialog_description": "Kõneteavituse kasutamiseks pead lisama ja kinnitama vähemalt ühe telefoninumbri. Kinnitamist saad teha SMS-i või kõne abil.",
"account_basics_phone_numbers_dialog_number_placeholder": "nt. +37256123456",
"account_basics_phone_numbers_no_phone_numbers_yet": "Telefoninumbreid veel pole",
"account_basics_phone_numbers_copied_to_clipboard": "Telefoninumber on kopeeritud lõikelauale",
"account_basics_phone_numbers_dialog_title": "Lisa telefoninumber",
"account_basics_phone_numbers_dialog_number_label": "Telefoninumber",
"prefs_notifications_delete_after_one_week": "Ühe nädala möödumisel",
"prefs_notifications_delete_after_one_day": "Ühe päeva möödumisel",
"prefs_notifications_delete_after_one_month": "Ühe kuu möödumisel"
}

View File

@@ -228,5 +228,19 @@
"account_basics_password_dialog_new_password_label": "Parola nouă",
"account_basics_password_title": "Parolă",
"account_basics_tier_description": "Nivelul de putere al contului",
"account_basics_tier_free": "Gratuit"
"account_basics_tier_free": "Gratuit",
"account_delete_description": "Șterge definitiv contul tău",
"account_usage_messages_title": "Mesaje publicate",
"account_basics_tier_manage_billing_button": "Gestionare facturare",
"account_usage_emails_title": "Emailuri trimise",
"account_usage_calls_title": "Apeluri telefonice efectuate",
"account_usage_calls_none": "Nu se pot efectua apeluri telefonice cu acest cont",
"account_usage_reservations_title": "Subiecte rezervate",
"account_usage_cannot_create_portal_session": "Nu s-a putut deschide portalul de facturare",
"account_delete_title": "Șterge contul",
"account_usage_attachment_storage_description": "{{filesize}} per fișier, șters după {{expiry}}",
"account_usage_attachment_storage_title": "Stocare atașamente",
"account_usage_basis_ip_description": "Statistica și limitele de utilizare pentru acest cont se bazează pe adresa ta IP, așadar pot fi partajate cu alți utilizatori. Limitele afișate mai sus sunt aproximative, bazate pe limitele de viteză existente.",
"account_usage_reservations_none": "Nu există subiecte rezervate pentru acest cont",
"account_basics_tier_canceled_subscription": "Abonamentul tău a fost anulat și va fi retrogradat la un cont gratuit în data de {{date}}."
}

View File

@@ -5,10 +5,10 @@
"signup_form_toggle_password_visibility": "Hiện mật khẩu",
"login_form_button_submit": "Đăng nhập",
"common_copy_to_clipboard": "Lưu vào clipboard",
"signup_form_username": "Tên user",
"signup_form_username": "Tên đăng nhập",
"signup_already_have_account": "Đã có tài khoản? Đăng nhập!",
"signup_disabled": "Đăng kí bị đóng",
"signup_error_username_taken": "Tên {{username}} đã được sử dụng",
"signup_disabled": "Đăng kí bị khoá",
"signup_error_username_taken": "Tên đăng nhập {{username}} đã được sử dụng",
"signup_error_creation_limit_reached": "Đã đạt giới hạn tạo tài khoản",
"login_title": "Đăng nhập vào tài khoản ntfy",
"login_link_signup": "Đăng kí",
@@ -27,5 +27,57 @@
"action_bar_unsubscribe": "Hủy đăng kí",
"action_bar_unmute_notifications": "Bật thông báo",
"action_bar_toggle_mute": "Bật/tắt thông báo",
"action_bar_mute_notifications": "Tắt thông báo"
"action_bar_mute_notifications": "Tắt thông báo",
"common_save": "Lưu",
"common_cancel": "Hủy",
"nav_button_all_notifications": "Tất cả thông báo",
"nav_button_connecting": "đang kết nối",
"nav_upgrade_banner_label": "Nâng cấp tài khoản ntfy Pro",
"alert_not_supported_title": "Thông báo không được hỗ trợ",
"alert_not_supported_description": "Thông báo không được hỗ trợ trên trình duyệt của bạn",
"notifications_list": "Danh sách thông báo",
"notifications_list_item": "Thông báo",
"notifications_mark_read": "Đánh dấu đã đọc",
"notifications_delete": "Xoá",
"notifications_attachment_copy_url_title": "Sao chép URL đính kèm vào clipboard",
"notifications_attachment_copy_url_button": "Sao chép URL",
"notifications_attachment_open_title": "Truy cập {{url}}",
"notifications_click_copy_url_button": "Sao chép liên kết",
"notifications_click_open_button": "Mở liên kết",
"notifications_actions_not_supported": "Không được hỗ trợ trên nên tảng web",
"notifications_actions_http_request_title": "Gởi HTTP {{method}} tới {{url}}",
"action_bar_profile_settings": "Cài đặt",
"message_bar_type_message": "Gõ nội dung tại đây",
"nav_button_account": "Tài khoản",
"nav_button_settings": "Cài đặt",
"nav_button_documentation": "Tài liệu",
"alert_notification_permission_required_title": "Thông báo đã bị khoá",
"alert_notification_permission_required_button": "Cấp quyền ngay",
"alert_notification_permission_denied_title": "Thông báo đã bị chặn",
"alert_notification_ios_install_required_title": "Yêu cầu cài đặt iOS",
"alert_notification_ios_install_required_description": "Nhấn vào biểu tượng Chia sẻ và Thêm vào màn hình chính để kích hoạt thông báo trên iOS",
"alert_notification_permission_required_description": "Cấp quyền để trình duyệt hiển thị thông báo trên màn hình",
"alert_notification_permission_denied_description": "Hãy kích hoạt lại trên trình duyệt của bạn",
"notifications_copied_to_clipboard": "Đã lưu vào clipboard",
"notifications_attachment_file_video": "tập tin video",
"notifications_attachment_file_audio": "tập tin âm thanh",
"notifications_actions_failed_notification": "Thực thi thất bại",
"notifications_new_indicator": "Thông báo mới",
"notifications_click_copy_url_title": "Sao liên kết URL vào clipboard",
"notifications_actions_open_url_title": "Truy cập {{url}}",
"notifications_priority_x": "Độ ưu tiên {{priority}}",
"notifications_attachment_link_expired": "liên kết tải đã hết hạn",
"notifications_attachment_file_image": "tập tin hình ảnh",
"notifications_tags": "Thẻ",
"notifications_attachment_file_document": "tập tin khác",
"action_bar_sign_in": "Đăng nhập",
"notifications_attachment_image": "Hình ảnh đính kèm",
"action_bar_sign_up": "Đăng ký",
"action_bar_profile_title": "Hồ sơ",
"action_bar_toggle_action_menu": "Mở/Đóng bảng điều khiển",
"action_bar_profile_logout": "Đăng xuất",
"notifications_attachment_file_app": "tập tin Android",
"notifications_attachment_link_expires": "liên kết đã hết hạn {{date}}",
"alert_not_supported_context_description": "Thông báo chỉ được hỗ trợ qua giao thức HTTPS. Đây là hạn chế của <mdnLink>API thông báo</mdnLink>.",
"notifications_attachment_open_button": "Mở đính kèm"
}

View File

@@ -576,8 +576,10 @@ const Language = () => {
<MenuItem value="zh_Hans">简体中文</MenuItem>
<MenuItem value="da">Dansk</MenuItem>
<MenuItem value="de">Deutsch</MenuItem>
<MenuItem value="et">Eesti</MenuItem>
<MenuItem value="es">Español</MenuItem>
<MenuItem value="fr">Français</MenuItem>
<MenuItem value="gl">Galego</MenuItem>
<MenuItem value="it">Italiano</MenuItem>
<MenuItem value="hu">Magyar</MenuItem>
<MenuItem value="ko">한국어</MenuItem>
@@ -589,6 +591,8 @@ const Language = () => {
<MenuItem value="pt_BR">Português (Brasil)</MenuItem>
<MenuItem value="pl">Polski</MenuItem>
<MenuItem value="ru">Русский</MenuItem>
<MenuItem value="ro">Română</MenuItem>
<MenuItem value="sk">Slovenčina</MenuItem>
<MenuItem value="fi">Suomi</MenuItem>
<MenuItem value="sv">Svenska</MenuItem>
<MenuItem value="tr">Türkçe</MenuItem>