Compare commits
272 Commits
client-ip-
...
898a52ec63
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
898a52ec63 | ||
|
|
7066ba92a6 | ||
|
|
8960459520 | ||
|
|
f581af6d27 | ||
|
|
88016a2549 | ||
|
|
a7603d1dfb | ||
|
|
65e2af9efb | ||
|
|
090cd720c1 | ||
|
|
7bc8edf7c3 | ||
|
|
13768d4289 | ||
|
|
b3721c1b71 | ||
|
|
544f7ef1cb | ||
|
|
16bec00b38 | ||
|
|
e18d64933f | ||
|
|
20ccee5d7d | ||
|
|
3575abcde3 | ||
|
|
2cd444ece0 | ||
|
|
eca1ed4d8d | ||
|
|
4ce9508fd3 | ||
|
|
4f9f1292f1 | ||
|
|
d64376c5ac | ||
|
|
275487b3fd | ||
|
|
9301546e6d | ||
|
|
49b3c724cf | ||
|
|
7091775234 | ||
|
|
90667e7176 | ||
|
|
414bbb0171 | ||
|
|
6e6d35157d | ||
|
|
c0f57a448d | ||
|
|
b348bce06e | ||
|
|
06577c99f2 | ||
|
|
f48a9aef2c | ||
|
|
293dda3e79 | ||
|
|
f52b29931f | ||
|
|
3dea1b12cf | ||
|
|
d83be77727 | ||
|
|
6215113c91 | ||
|
|
954ac89b51 | ||
|
|
76fbe19fd1 | ||
|
|
b27c4cf95d | ||
|
|
d915552788 | ||
|
|
c66ad4d7e3 | ||
|
|
152a6b96d1 | ||
|
|
f642b09ebf | ||
|
|
ec414d29be | ||
|
|
0fbd3e33f3 | ||
|
|
17870cb471 | ||
|
|
4e03c96108 | ||
|
|
6cacdd47f2 | ||
|
|
a380860cab | ||
|
|
7f842eaeb1 | ||
|
|
3e019bfe88 | ||
|
|
430135606b | ||
|
|
62a79d9802 | ||
|
|
d9d02dbbc1 | ||
|
|
e0a9e3aa56 | ||
|
|
ce878a5d03 | ||
|
|
43fe1b9ad8 | ||
|
|
b59f451c6a | ||
|
|
693d2d630f | ||
|
|
025c2963a0 | ||
|
|
76bf4a3de7 | ||
|
|
b7ae47d61c | ||
|
|
4fa265ed28 | ||
|
|
b531bc95ea | ||
|
|
da6f6f528c | ||
|
|
926d8b981b | ||
|
|
997923dd98 | ||
|
|
8131d0d883 | ||
|
|
a326dc034f | ||
|
|
736905c1e8 | ||
|
|
857d56c281 | ||
|
|
cef61b2c48 | ||
|
|
b483891bcb | ||
|
|
bb9bbdf736 | ||
|
|
9e1636a3f7 | ||
|
|
315326ffc0 | ||
|
|
65188b1f07 | ||
|
|
5fefcd1296 | ||
|
|
4c4a5725d8 | ||
|
|
ab2a686937 | ||
|
|
01bb0eeccd | ||
|
|
7cabc8bcec | ||
|
|
95f4e58ca0 | ||
|
|
692a1fa532 | ||
|
|
54ded9db9a | ||
|
|
db2b3a0dd8 | ||
|
|
0b4bcf573e | ||
|
|
f9a88f841e | ||
|
|
c4291cc23e | ||
|
|
fd0595b547 | ||
|
|
b59e18bed8 | ||
|
|
1956ffbf02 | ||
|
|
e647c68cb9 | ||
|
|
53dce3013d | ||
|
|
7615aa86ad | ||
|
|
07ef3e5656 | ||
|
|
2804acf0f5 | ||
|
|
498b632796 | ||
|
|
61e496fc4c | ||
|
|
83e74b014e | ||
|
|
364696e059 | ||
|
|
546c94ba98 | ||
|
|
203d739d38 | ||
|
|
89fd37bc2c | ||
|
|
7856ab5dfb | ||
|
|
773be05bb1 | ||
|
|
c1a6e9a429 | ||
|
|
c9a0a40805 | ||
|
|
439049624c | ||
|
|
a6078037c0 | ||
|
|
9c5c17441f | ||
|
|
b56d250708 | ||
|
|
a714fe2618 | ||
|
|
e863872d0e | ||
|
|
77406f3496 | ||
|
|
157add4835 | ||
|
|
50f3563477 | ||
|
|
e08f3670d1 | ||
|
|
4f6f45a9c0 | ||
|
|
3de04b27ab | ||
|
|
ec1f97b726 | ||
|
|
569d89e8f8 | ||
|
|
f2f146e39b | ||
|
|
18d08298cc | ||
|
|
ebb386af58 | ||
|
|
b105ed6727 | ||
|
|
1916376f8d | ||
|
|
965110b2c3 | ||
|
|
c8ac104043 | ||
|
|
f6bd0a8d51 | ||
|
|
e39498702d | ||
|
|
9b97067b10 | ||
|
|
f72f0d800f | ||
|
|
5244e0be14 | ||
|
|
6eb25f68ac | ||
|
|
efe7c3fa70 | ||
|
|
ce4b2ae9a0 | ||
|
|
ba86e08ffe | ||
|
|
2d9e2356b1 | ||
|
|
fe5c844a21 | ||
|
|
97410db301 | ||
|
|
887751cd5d | ||
|
|
044326068c | ||
|
|
57a51ab2da | ||
|
|
998dbd9054 | ||
|
|
a5a55bd43a | ||
|
|
00409d834b | ||
|
|
d9ab7cc78d | ||
|
|
99a2ca8802 | ||
|
|
ea338ae4fa | ||
|
|
32fa8d43c1 | ||
|
|
0f166e0a1d | ||
|
|
46e423fc40 | ||
|
|
eac523dcf9 | ||
|
|
4225ce2f42 | ||
|
|
d35dfc14d1 | ||
|
|
cef228f880 | ||
|
|
bcfb50b35a | ||
|
|
c4c4916bc8 | ||
|
|
81463614c9 | ||
|
|
15a7f86344 | ||
|
|
3c1da90f47 | ||
|
|
27151d1cac | ||
|
|
a1c6dd2085 | ||
|
|
8f930acfb8 | ||
|
|
08d44703c3 | ||
|
|
82282419fe | ||
|
|
e290d1307f | ||
|
|
747c5c9fff | ||
|
|
9f987e66fa | ||
|
|
b91ff5f0b5 | ||
|
|
23ec7702fc | ||
|
|
f8082d9481 | ||
|
|
d9ecee7200 | ||
|
|
149c13e9d8 | ||
|
|
07e9670a09 | ||
|
|
0e67228605 | ||
|
|
2578236d8d | ||
|
|
fe545423c5 | ||
|
|
f3c67f1d71 | ||
|
|
27b3a89247 | ||
|
|
1470afb715 | ||
|
|
b495a744c9 | ||
|
|
d2b5917e2b | ||
|
|
52ca98611c | ||
|
|
1b394e9bb8 | ||
|
|
0d36ab8af3 | ||
|
|
141ddb3a51 | ||
|
|
f99801a2e6 | ||
|
|
4457e9e26f | ||
|
|
4eb7dc563c | ||
|
|
269373d75d | ||
|
|
ef275ac0c1 | ||
|
|
f59df0f40a | ||
|
|
214f70e62f | ||
|
|
51af114b2e | ||
|
|
83bf9d4d6c | ||
|
|
f298d947bd | ||
|
|
d87d8a2db4 | ||
|
|
50c564d8a2 | ||
|
|
c807b5db21 | ||
|
|
4d1baae6d0 | ||
|
|
34bc551303 | ||
|
|
0847a6406e | ||
|
|
006f73af7d | ||
|
|
f4a74dac57 | ||
|
|
1f34c39eb0 | ||
|
|
8783c86cd6 | ||
|
|
892e82ceb8 | ||
|
|
8b4834929d | ||
|
|
f0d5392e9e | ||
|
|
dde07adbdc | ||
|
|
57df16dd62 | ||
|
|
ae62e0d955 | ||
|
|
4603802f62 | ||
|
|
610792b902 | ||
|
|
b1e935da45 | ||
|
|
93e14b73bb | ||
|
|
81a486adc1 | ||
|
|
8bf4727a1c | ||
|
|
2a468493f9 | ||
|
|
3ac3e2ec7c | ||
|
|
fea0f301d2 | ||
|
|
1ce08a18c0 | ||
|
|
8d6f1eecdf | ||
|
|
c0b5151bae | ||
|
|
650f492d7d | ||
|
|
1f2c76e63d | ||
|
|
efef587671 | ||
|
|
3c8ac4a1e1 | ||
|
|
f5247c50f4 | ||
|
|
1edbda4f31 | ||
|
|
de7b7218e4 | ||
|
|
19a4e95a3a | ||
|
|
4578835a8f | ||
|
|
aead619dea | ||
|
|
deeefee8c0 | ||
|
|
5e380e147f | ||
|
|
ba5c3a164d | ||
|
|
47da3aeea6 | ||
|
|
9ed96e5d8b | ||
|
|
04aff72631 | ||
|
|
6fbcd85d17 | ||
|
|
8f60294c5b | ||
|
|
677b44ce61 | ||
|
|
000248e6aa | ||
|
|
359c789c34 | ||
|
|
34e9a771ce | ||
|
|
60b8588129 | ||
|
|
7eeaeb8398 | ||
|
|
c99d8b66c2 | ||
|
|
960f690dd6 | ||
|
|
54514454bf | ||
|
|
d8c8f31846 | ||
|
|
5ccc131e73 | ||
|
|
ae27c3a5ab | ||
|
|
48cb816111 | ||
|
|
ff904a5ca6 | ||
|
|
8e7de80353 | ||
|
|
9c8a8f8795 | ||
|
|
df73c6f655 | ||
|
|
c1e657db8b | ||
|
|
62c8a13ed4 | ||
|
|
994266ab04 | ||
|
|
a41e3a1e76 | ||
|
|
86bec660bf | ||
|
|
30301c8a7f | ||
|
|
7b470a7f6f | ||
|
|
9d5891963a | ||
|
|
de8e3bc2aa | ||
|
|
03aeb707f2 |
@@ -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:
|
||||
|
||||
6
Makefile
@@ -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-))
|
||||
|
||||
56
README.md
@@ -1,3 +1,16 @@
|
||||
<div align="center" markdown="1">
|
||||
<sup>Special thanks to:</sup>
|
||||
<br>
|
||||
<br>
|
||||
<a href="https://go.warp.dev/ntfy">
|
||||
<img alt="Warp sponsorship" width="400" src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Github/Sponsor/Warp-Github-LG-02.png">
|
||||
</a>
|
||||
|
||||
### [Warp, built for coding with multiple AI agents.](https://go.warp.dev/ntfy)
|
||||
[Available for MacOS, Linux, & Windows](https://go.warp.dev/ntfy)<br>
|
||||
</div>
|
||||
<hr>
|
||||
|
||||

|
||||
|
||||
# ntfy.sh | Send push notifications to your phone or desktop via PUT/POST
|
||||
@@ -56,20 +69,20 @@ 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>
|
||||
|
||||
<a href="https://go.warp.dev/ntfy"><img src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Logos/Warp-Wordmark-Black.png" width="160px"></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 +223,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 +268,4 @@ Third-party libraries and resources:
|
||||
* [Statically linking go-sqlite3](https://www.arp242.net/static-go.html)
|
||||
* [Linked tabs in mkdocs](https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs)
|
||||
* [webpush-go](https://github.com/SherClockHolmes/webpush-go) (MIT) is used to send web push notifications
|
||||
* [Sprig](https://github.com/Masterminds/sprig) (MIT) is used to add template parsing functions
|
||||
|
||||
@@ -6,5 +6,7 @@ As of today, I only support the latest version of ntfy. Please make sure you sta
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report severe security issues privately via ntfy@heckel.io, [Discord](https://discord.gg/cT7ECsZj9w),
|
||||
or [Matrix](https://matrix.to/#/#ntfy:matrix.org) (my username is `binwiederhier`).
|
||||
Please report security vulnerabilities privately via email to [security@mail.ntfy.sh](mailto:security@mail.ntfy.sh).
|
||||
|
||||
You can also reach me on [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org)
|
||||
(my username is `binwiederhier`).
|
||||
|
||||
BIN
assets/sponsors/magicbell.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
@@ -21,7 +21,7 @@
|
||||
# default-command:
|
||||
|
||||
# Subscriptions to topics and their actions. This option is primarily used by the systemd service,
|
||||
# or if you cann "ntfy subscribe --from-config" directly.
|
||||
# or if you can "ntfy subscribe --from-config" directly.
|
||||
#
|
||||
# Example:
|
||||
# subscribe:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -114,13 +116,13 @@ func changeAccess(c *cli.Context, manager *user.Manager, username string, topic
|
||||
return err
|
||||
}
|
||||
if permission.IsReadWrite() {
|
||||
fmt.Fprintf(c.App.ErrWriter, "granted read-write access to topic %s\n\n", topic)
|
||||
fmt.Fprintf(c.App.Writer, "granted read-write access to topic %s\n\n", topic)
|
||||
} else if permission.IsRead() {
|
||||
fmt.Fprintf(c.App.ErrWriter, "granted read-only access to topic %s\n\n", topic)
|
||||
fmt.Fprintf(c.App.Writer, "granted read-only access to topic %s\n\n", topic)
|
||||
} else if permission.IsWrite() {
|
||||
fmt.Fprintf(c.App.ErrWriter, "granted write-only access to topic %s\n\n", topic)
|
||||
fmt.Fprintf(c.App.Writer, "granted write-only access to topic %s\n\n", topic)
|
||||
} else {
|
||||
fmt.Fprintf(c.App.ErrWriter, "revoked all access to topic %s\n\n", topic)
|
||||
fmt.Fprintf(c.App.Writer, "revoked all access to topic %s\n\n", topic)
|
||||
}
|
||||
return showUserAccess(c, manager, username)
|
||||
}
|
||||
@@ -138,7 +140,7 @@ func resetAllAccess(c *cli.Context, manager *user.Manager) error {
|
||||
if err := manager.ResetAccess("", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(c.App.ErrWriter, "reset access for all users")
|
||||
fmt.Fprintln(c.App.Writer, "reset access for all users")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -146,7 +148,7 @@ func resetUserAccess(c *cli.Context, manager *user.Manager, username string) err
|
||||
if err := manager.ResetAccess(username, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "reset access for user %s\n\n", username)
|
||||
fmt.Fprintf(c.App.Writer, "reset access for user %s\n\n", username)
|
||||
return showUserAccess(c, manager, username)
|
||||
}
|
||||
|
||||
@@ -154,7 +156,7 @@ func resetUserTopicAccess(c *cli.Context, manager *user.Manager, username string
|
||||
if err := manager.ResetAccess(username, topic); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "reset access for user %s and topic %s\n\n", username, topic)
|
||||
fmt.Fprintf(c.App.Writer, "reset access for user %s and topic %s\n\n", username, topic)
|
||||
return showUserAccess(c, manager, 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,34 +195,42 @@ 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 = ", server config"
|
||||
}
|
||||
fmt.Fprintf(c.App.Writer, "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")
|
||||
fmt.Fprintf(c.App.Writer, "- 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 = " (server config)"
|
||||
}
|
||||
if grant.Permission.IsReadWrite() {
|
||||
fmt.Fprintf(c.App.Writer, "- read-write access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
|
||||
} else if grant.Permission.IsRead() {
|
||||
fmt.Fprintf(c.App.Writer, "- read-only access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
|
||||
} else if grant.Permission.IsWrite() {
|
||||
fmt.Fprintf(c.App.Writer, "- 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.Writer, "- no access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(c.App.ErrWriter, "- no topic-specific permissions\n")
|
||||
fmt.Fprintf(c.App.Writer, "- no topic-specific permissions\n")
|
||||
}
|
||||
if u.Name == user.Everyone {
|
||||
access := manager.DefaultAccess()
|
||||
if access.IsReadWrite() {
|
||||
fmt.Fprintln(c.App.ErrWriter, "- read-write access to all (other) topics (server config)")
|
||||
fmt.Fprintln(c.App.Writer, "- read-write access to all (other) topics (server config)")
|
||||
} else if access.IsRead() {
|
||||
fmt.Fprintln(c.App.ErrWriter, "- read-only access to all (other) topics (server config)")
|
||||
fmt.Fprintln(c.App.Writer, "- read-only access to all (other) topics (server config)")
|
||||
} else if access.IsWrite() {
|
||||
fmt.Fprintln(c.App.ErrWriter, "- write-only access to all (other) topics (server config)")
|
||||
fmt.Fprintln(c.App.Writer, "- write-only access to all (other) topics (server config)")
|
||||
} else {
|
||||
fmt.Fprintln(c.App.ErrWriter, "- no access to any (other) topics (server config)")
|
||||
fmt.Fprintln(c.App.Writer, "- no access to any (other) topics (server config)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ func TestCLI_Access_Show(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, _, _, stderr := newTestApp()
|
||||
app, _, stdout, _ := newTestApp()
|
||||
require.Nil(t, runAccessCommand(app, conf))
|
||||
require.Contains(t, stderr.String(), "user * (role: anonymous, tier: none)\n- no topic-specific permissions\n- no access to any (other) topics (server config)")
|
||||
require.Contains(t, stdout.String(), "user * (role: anonymous, tier: none)\n- no topic-specific permissions\n- no access to any (other) topics (server config)")
|
||||
}
|
||||
|
||||
func TestCLI_Access_Grant_And_Publish(t *testing.T) {
|
||||
@@ -30,7 +30,7 @@ func TestCLI_Access_Grant_And_Publish(t *testing.T) {
|
||||
require.Nil(t, runAccessCommand(app, conf, "ben", "sometopic", "read"))
|
||||
require.Nil(t, runAccessCommand(app, conf, "everyone", "announcements", "read"))
|
||||
|
||||
app, _, _, stderr := newTestApp()
|
||||
app, _, stdout, _ := newTestApp()
|
||||
require.Nil(t, runAccessCommand(app, conf))
|
||||
expected := `user phil (role: admin, tier: none)
|
||||
- read-write access to all topics (admin role)
|
||||
@@ -41,7 +41,7 @@ user * (role: anonymous, tier: none)
|
||||
- read-only access to topic announcements
|
||||
- no access to any (other) topics (server config)
|
||||
`
|
||||
require.Equal(t, expected, stderr.String())
|
||||
require.Equal(t, expected, stdout.String())
|
||||
|
||||
// See if access permissions match
|
||||
app, _, _, _ = newTestApp()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//go:build darwin || linux || dragonfly || freebsd || netbsd || openbsd
|
||||
// +build darwin linux dragonfly freebsd netbsd openbsd
|
||||
|
||||
package cmd
|
||||
|
||||
|
||||
213
cmd/serve.go
@@ -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/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/payments"
|
||||
"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,14 @@ 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-users", Aliases: []string{"auth_users"}, EnvVars: []string{"NTFY_AUTH_USERS"}, Usage: "pre-provisioned declarative users"}),
|
||||
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-access", Aliases: []string{"auth_access"}, EnvVars: []string{"NTFY_AUTH_ACCESS"}, Usage: "pre-provisioned declarative access control entries"}),
|
||||
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-tokens", Aliases: []string{"auth_tokens"}, EnvVars: []string{"NTFY_AUTH_TOKENS"}, Usage: "pre-provisioned declarative access tokens"}),
|
||||
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"}),
|
||||
@@ -62,6 +63,7 @@ var flagsServe = append(
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-signup", Aliases: []string{"enable_signup"}, EnvVars: []string{"NTFY_ENABLE_SIGNUP"}, Value: false, Usage: "allows users to sign up via the web app, or API"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-login", Aliases: []string{"enable_login"}, EnvVars: []string{"NTFY_ENABLE_LOGIN"}, Value: false, Usage: "allows users to log in via the web app, or API"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-reservations", Aliases: []string{"enable_reservations"}, EnvVars: []string{"NTFY_ENABLE_RESERVATIONS"}, Value: false, Usage: "allows users to reserve topics (if their tier allows it)"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "require-login", Aliases: []string{"require_login"}, EnvVars: []string{"NTFY_REQUIRE_LOGIN"}, Value: false, Usage: "all actions via the web app requires a login"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-base-url", Aliases: []string{"upstream_base_url"}, EnvVars: []string{"NTFY_UPSTREAM_BASE_URL"}, Value: "", Usage: "forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-access-token", Aliases: []string{"upstream_access_token"}, EnvVars: []string{"NTFY_UPSTREAM_ACCESS_TOKEN"}, Value: "", Usage: "access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", Aliases: []string{"smtp_sender_addr"}, EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}),
|
||||
@@ -79,6 +81,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 +90,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,16 +158,21 @@ func execServe(c *cli.Context) error {
|
||||
authFile := c.String("auth-file")
|
||||
authStartupQueries := c.String("auth-startup-queries")
|
||||
authDefaultAccess := c.String("auth-default-access")
|
||||
authUsersRaw := c.StringSlice("auth-users")
|
||||
authAccessRaw := c.StringSlice("auth-access")
|
||||
authTokensRaw := c.StringSlice("auth-tokens")
|
||||
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")
|
||||
webRoot := c.String("web-root")
|
||||
enableSignup := c.Bool("enable-signup")
|
||||
enableLogin := c.Bool("enable-login")
|
||||
requireLogin := c.Bool("require-login")
|
||||
enableReservations := c.Bool("enable-reservations")
|
||||
upstreamBaseURL := c.String("upstream-base-url")
|
||||
upstreamAccessToken := c.String("upstream-access-token")
|
||||
@@ -191,9 +200,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")
|
||||
@@ -270,6 +281,8 @@ func execServe(c *cli.Context) error {
|
||||
// Check values
|
||||
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
|
||||
return errors.New("if set, FCM key file must exist")
|
||||
} else if firebaseKeyFile != "" && !server.FirebaseAvailable {
|
||||
return errors.New("cannot set firebase-key-file, support for Firebase is not available (nofirebase)")
|
||||
} else if webPushPublicKey != "" && (webPushPrivateKey == "" || webPushFile == "" || webPushEmailAddress == "" || baseURL == "") {
|
||||
return errors.New("if web push is enabled, web-push-private-key, web-push-public-key, web-push-file, web-push-email-address, and base-url should be set. run 'ntfy webpush keys' to generate keys")
|
||||
} else if keepaliveInterval < 5*time.Second {
|
||||
@@ -307,10 +320,14 @@ func execServe(c *cli.Context) error {
|
||||
return errors.New("if upstream-base-url is set, base-url must also be set")
|
||||
} else if upstreamBaseURL != "" && baseURL != "" && baseURL == upstreamBaseURL {
|
||||
return errors.New("base-url and upstream-base-url cannot be identical, you'll likely want to set upstream-base-url to https://ntfy.sh, see https://ntfy.sh/docs/config/#ios-instant-notifications")
|
||||
} else if authFile == "" && (enableSignup || enableLogin || enableReservations || stripeSecretKey != "") {
|
||||
return errors.New("cannot set enable-signup, enable-login, enable-reserve-topics, or stripe-secret-key if auth-file is not set")
|
||||
} else if authFile == "" && (enableSignup || enableLogin || requireLogin || enableReservations || stripeSecretKey != "") {
|
||||
return errors.New("cannot set enable-signup, enable-login, require-login, enable-reserve-topics, or stripe-secret-key if auth-file is not set")
|
||||
} else if enableSignup && !enableLogin {
|
||||
return errors.New("cannot set enable-signup without also setting enable-login")
|
||||
} else if requireLogin && !enableLogin {
|
||||
return errors.New("cannot set require-login without also setting enable-login")
|
||||
} else if !payments.Available && (stripeSecretKey != "" || stripeWebhookKey != "") {
|
||||
return errors.New("cannot set stripe-secret-key or stripe-webhook-key, support for payments is not available in this build (nopayments)")
|
||||
} else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") {
|
||||
return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set")
|
||||
} else if twilioAccount != "" && (twilioAuthToken == "" || twilioPhoneNumber == "" || twilioVerifyService == "" || baseURL == "" || authFile == "") {
|
||||
@@ -320,10 +337,16 @@ func execServe(c *cli.Context) error {
|
||||
if messageSizeLimit > 5*1024*1024 {
|
||||
return errors.New("message-size-limit cannot be higher than 5M")
|
||||
}
|
||||
} else if !server.WebPushAvailable && (webPushPrivateKey != "" || webPushPublicKey != "" || webPushFile != "") {
|
||||
return errors.New("cannot enable WebPush, support is not available in this build (nowebpush)")
|
||||
} else if webPushExpiryWarningDuration > 0 && webPushExpiryWarningDuration > webPushExpiryDuration {
|
||||
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 +360,23 @@ 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'")
|
||||
}
|
||||
authUsers, err := parseUsers(authUsersRaw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
authAccess, err := parseAccess(authUsers, authAccessRaw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
authTokens, err := parseTokens(authUsers, authTokensRaw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Special case: Unset default
|
||||
if listenHTTP == "-" {
|
||||
@@ -349,20 +384,29 @@ 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
|
||||
if stripeSecretKey != "" {
|
||||
stripe.EnableTelemetry = false // Whoa!
|
||||
stripe.Key = stripeSecretKey
|
||||
payments.Setup(stripeSecretKey)
|
||||
}
|
||||
|
||||
// Add default forbidden topics
|
||||
@@ -387,10 +431,14 @@ func execServe(c *cli.Context) error {
|
||||
conf.AuthFile = authFile
|
||||
conf.AuthStartupQueries = authStartupQueries
|
||||
conf.AuthDefault = authDefault
|
||||
conf.AuthUsers = authUsers
|
||||
conf.AuthAccess = authAccess
|
||||
conf.AuthTokens = authTokens
|
||||
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,28 +460,30 @@ 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
|
||||
conf.EnableSignup = enableSignup
|
||||
conf.EnableLogin = enableLogin
|
||||
conf.RequireLogin = requireLogin
|
||||
conf.EnableReservations = enableReservations
|
||||
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 +491,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 +524,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 +548,112 @@ func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
func parseUsers(usersRaw []string) ([]*user.User, error) {
|
||||
users := make([]*user.User, 0)
|
||||
for _, userLine := range usersRaw {
|
||||
parts := strings.Split(userLine, ":")
|
||||
if len(parts) != 3 {
|
||||
return nil, fmt.Errorf("invalid auth-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-users: %s, username invalid", userLine)
|
||||
} else if err := user.ValidPasswordHash(passwordHash, user.DefaultUserPasswordBcryptCost); err != nil {
|
||||
return nil, fmt.Errorf("invalid auth-users: %s, password hash invalid, %s", userLine, err.Error())
|
||||
} else if !user.AllowedRole(role) {
|
||||
return nil, fmt.Errorf("invalid auth-users: %s, role %s is not allowed, allowed roles are 'admin' or 'user'", userLine, role)
|
||||
}
|
||||
users = append(users, &user.User{
|
||||
Name: username,
|
||||
Hash: passwordHash,
|
||||
Role: role,
|
||||
Provisioned: true,
|
||||
})
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func parseAccess(users []*user.User, accessRaw []string) (map[string][]*user.Grant, error) {
|
||||
access := make(map[string][]*user.Grant)
|
||||
for _, accessLine := range accessRaw {
|
||||
parts := strings.Split(accessLine, ":")
|
||||
if len(parts) != 3 {
|
||||
return nil, fmt.Errorf("invalid auth-access: %s, expected format: 'user:topic:permission'", accessLine)
|
||||
}
|
||||
username := strings.TrimSpace(parts[0])
|
||||
if username == userEveryone {
|
||||
username = user.Everyone
|
||||
}
|
||||
u, exists := util.Find(users, func(u *user.User) bool {
|
||||
return u.Name == username
|
||||
})
|
||||
if username != user.Everyone {
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("invalid auth-access: %s, user %s is not provisioned", accessLine, username)
|
||||
} else if !user.AllowedUsername(username) {
|
||||
return nil, fmt.Errorf("invalid auth-access: %s, username %s invalid", accessLine, username)
|
||||
} else if u.Role != user.RoleUser {
|
||||
return nil, fmt.Errorf("invalid auth-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-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-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 parseTokens(users []*user.User, tokensRaw []string) (map[string][]*user.Token, error) {
|
||||
tokens := make(map[string][]*user.Token)
|
||||
for _, tokenLine := range tokensRaw {
|
||||
parts := strings.Split(tokenLine, ":")
|
||||
if len(parts) < 2 || len(parts) > 3 {
|
||||
return nil, fmt.Errorf("invalid auth-tokens: %s, expected format: 'user:token[:label]'", tokenLine)
|
||||
}
|
||||
username := strings.TrimSpace(parts[0])
|
||||
_, exists := util.Find(users, func(u *user.User) bool {
|
||||
return u.Name == username
|
||||
})
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("invalid auth-tokens: %s, user %s is not provisioned", tokenLine, username)
|
||||
} else if !user.AllowedUsername(username) {
|
||||
return nil, fmt.Errorf("invalid auth-tokens: %s, username %s invalid", tokenLine, username)
|
||||
}
|
||||
token := strings.TrimSpace(parts[1])
|
||||
if !user.ValidToken(token) {
|
||||
return nil, fmt.Errorf("invalid auth-tokens: %s, token %s invalid, use 'ntfy token generate' to generate a random token", tokenLine, token)
|
||||
}
|
||||
var label string
|
||||
if len(parts) > 2 {
|
||||
label = parts[2]
|
||||
}
|
||||
if _, exists := tokens[username]; !exists {
|
||||
tokens[username] = make([]*user.Token, 0)
|
||||
}
|
||||
tokens[username] = append(tokens[username], &user.Token{
|
||||
Value: token,
|
||||
Label: label,
|
||||
Provisioned: true,
|
||||
})
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
func reloadLogLevel(inputSource altsrc.InputSourceContext) error {
|
||||
newLevelStr, err := inputSource.String("log-level")
|
||||
if err != nil {
|
||||
|
||||
@@ -14,9 +14,461 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/client"
|
||||
"heckel.io/ntfy/v2/test"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
func TestParseUsers_Success(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []string
|
||||
expected []*user.User
|
||||
}{
|
||||
{
|
||||
name: "single user",
|
||||
input: []string{"alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"},
|
||||
expected: []*user.User{
|
||||
{
|
||||
Name: "alice",
|
||||
Hash: "$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S",
|
||||
Role: user.RoleUser,
|
||||
Provisioned: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple users with different roles",
|
||||
input: []string{
|
||||
"alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user",
|
||||
"bob:$2a$10$jIcuBWcbxd6oW1aPvoJ5iOShzu3/UJ2kSxKbTZtDypG06nBflQagq:admin",
|
||||
},
|
||||
expected: []*user.User{
|
||||
{
|
||||
Name: "alice",
|
||||
Hash: "$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S",
|
||||
Role: user.RoleUser,
|
||||
Provisioned: true,
|
||||
},
|
||||
{
|
||||
Name: "bob",
|
||||
Hash: "$2a$10$jIcuBWcbxd6oW1aPvoJ5iOShzu3/UJ2kSxKbTZtDypG06nBflQagq",
|
||||
Role: user.RoleAdmin,
|
||||
Provisioned: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
input: []string{},
|
||||
expected: []*user.User{},
|
||||
},
|
||||
{
|
||||
name: "user with special characters in name",
|
||||
input: []string{"alice.test+123@example.com:$2a$10$RYUYAsl5zOnAIp6fH7BPX.Eug0rUfEUk92r8WiVusb0VK.vGojWBe:user"},
|
||||
expected: []*user.User{
|
||||
{
|
||||
Name: "alice.test+123@example.com",
|
||||
Hash: "$2a$10$RYUYAsl5zOnAIp6fH7BPX.Eug0rUfEUk92r8WiVusb0VK.vGojWBe",
|
||||
Role: user.RoleUser,
|
||||
Provisioned: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := parseUsers(tt.input)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result, len(tt.expected))
|
||||
|
||||
for i, expectedUser := range tt.expected {
|
||||
assert.Equal(t, expectedUser.Name, result[i].Name)
|
||||
assert.Equal(t, expectedUser.Hash, result[i].Hash)
|
||||
assert.Equal(t, expectedUser.Role, result[i].Role)
|
||||
assert.Equal(t, expectedUser.Provisioned, result[i].Provisioned)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUsers_Errors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []string
|
||||
error string
|
||||
}{
|
||||
{
|
||||
name: "invalid format - too few parts",
|
||||
input: []string{"alice:hash"},
|
||||
error: "invalid auth-users: alice:hash, expected format: 'name:hash:role'",
|
||||
},
|
||||
{
|
||||
name: "invalid format - too many parts",
|
||||
input: []string{"alice:hash:role:extra"},
|
||||
error: "invalid auth-users: alice:hash:role:extra, expected format: 'name:hash:role'",
|
||||
},
|
||||
{
|
||||
name: "invalid username",
|
||||
input: []string{"alice@#$%:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"},
|
||||
error: "invalid auth-users: alice@#$%:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user, username invalid",
|
||||
},
|
||||
{
|
||||
name: "invalid password hash - wrong prefix",
|
||||
input: []string{"alice:plaintext:user"},
|
||||
error: "invalid auth-users: alice:plaintext:user, password hash invalid, password hash must be a bcrypt hash, use 'ntfy user hash' to generate",
|
||||
},
|
||||
{
|
||||
name: "invalid role",
|
||||
input: []string{"alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:invalid"},
|
||||
error: "invalid auth-users: alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:invalid, role invalid is not allowed, allowed roles are 'admin' or 'user'",
|
||||
},
|
||||
{
|
||||
name: "empty username",
|
||||
input: []string{":$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"},
|
||||
error: "invalid auth-users: :$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user, username invalid",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := parseUsers(tt.input)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), tt.error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAccess_Success(t *testing.T) {
|
||||
users := []*user.User{
|
||||
{Name: "alice", Role: user.RoleUser},
|
||||
{Name: "bob", Role: user.RoleUser},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
users []*user.User
|
||||
input []string
|
||||
expected map[string][]*user.Grant
|
||||
}{
|
||||
{
|
||||
name: "single access entry",
|
||||
users: users,
|
||||
input: []string{"alice:mytopic:read-write"},
|
||||
expected: map[string][]*user.Grant{
|
||||
"alice": {
|
||||
{
|
||||
TopicPattern: "mytopic",
|
||||
Permission: user.PermissionReadWrite,
|
||||
Provisioned: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple access entries for same user",
|
||||
users: users,
|
||||
input: []string{
|
||||
"alice:topic1:read-only",
|
||||
"alice:topic2:write-only",
|
||||
},
|
||||
expected: map[string][]*user.Grant{
|
||||
"alice": {
|
||||
{
|
||||
TopicPattern: "topic1",
|
||||
Permission: user.PermissionRead,
|
||||
Provisioned: true,
|
||||
},
|
||||
{
|
||||
TopicPattern: "topic2",
|
||||
Permission: user.PermissionWrite,
|
||||
Provisioned: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "access for everyone",
|
||||
users: users,
|
||||
input: []string{"everyone:publictopic:read-only"},
|
||||
expected: map[string][]*user.Grant{
|
||||
user.Everyone: {
|
||||
{
|
||||
TopicPattern: "publictopic",
|
||||
Permission: user.PermissionRead,
|
||||
Provisioned: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wildcard topic pattern",
|
||||
users: users,
|
||||
input: []string{"alice:topic*:read-write"},
|
||||
expected: map[string][]*user.Grant{
|
||||
"alice": {
|
||||
{
|
||||
TopicPattern: "topic*",
|
||||
Permission: user.PermissionReadWrite,
|
||||
Provisioned: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
users: users,
|
||||
input: []string{},
|
||||
expected: map[string][]*user.Grant{},
|
||||
},
|
||||
{
|
||||
name: "deny-all permission",
|
||||
users: users,
|
||||
input: []string{"alice:secretopic:deny-all"},
|
||||
expected: map[string][]*user.Grant{
|
||||
"alice": {
|
||||
{
|
||||
TopicPattern: "secretopic",
|
||||
Permission: user.PermissionDenyAll,
|
||||
Provisioned: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := parseAccess(tt.users, tt.input)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAccess_Errors(t *testing.T) {
|
||||
users := []*user.User{
|
||||
{Name: "alice", Role: user.RoleUser},
|
||||
{Name: "admin", Role: user.RoleAdmin},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
users []*user.User
|
||||
input []string
|
||||
error string
|
||||
}{
|
||||
{
|
||||
name: "invalid format - too few parts",
|
||||
users: users,
|
||||
input: []string{"alice:topic"},
|
||||
error: "invalid auth-access: alice:topic, expected format: 'user:topic:permission'",
|
||||
},
|
||||
{
|
||||
name: "invalid format - too many parts",
|
||||
users: users,
|
||||
input: []string{"alice:topic:read:extra"},
|
||||
error: "invalid auth-access: alice:topic:read:extra, expected format: 'user:topic:permission'",
|
||||
},
|
||||
{
|
||||
name: "user not provisioned",
|
||||
users: users,
|
||||
input: []string{"charlie:topic:read"},
|
||||
error: "invalid auth-access: charlie:topic:read, user charlie is not provisioned",
|
||||
},
|
||||
{
|
||||
name: "admin user cannot have ACL entries",
|
||||
users: users,
|
||||
input: []string{"admin:topic:read"},
|
||||
error: "invalid auth-access: admin:topic:read, user admin is not a regular user, only regular users can have ACL entries",
|
||||
},
|
||||
{
|
||||
name: "invalid topic pattern",
|
||||
users: users,
|
||||
input: []string{"alice:topic-with-invalid-chars!:read"},
|
||||
error: "invalid auth-access: alice:topic-with-invalid-chars!:read, topic pattern topic-with-invalid-chars! invalid",
|
||||
},
|
||||
{
|
||||
name: "invalid permission",
|
||||
users: users,
|
||||
input: []string{"alice:topic:invalid-permission"},
|
||||
error: "invalid auth-access: alice:topic:invalid-permission, permission invalid-permission invalid",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := parseAccess(tt.users, tt.input)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), tt.error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTokens_Success(t *testing.T) {
|
||||
users := []*user.User{
|
||||
{Name: "alice"},
|
||||
{Name: "bob"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
users []*user.User
|
||||
input []string
|
||||
expected map[string][]*user.Token
|
||||
}{
|
||||
{
|
||||
name: "single token without label",
|
||||
users: users,
|
||||
input: []string{"alice:tk_abcdefghijklmnopqrstuvwxyz123"},
|
||||
expected: map[string][]*user.Token{
|
||||
"alice": {
|
||||
{
|
||||
Value: "tk_abcdefghijklmnopqrstuvwxyz123",
|
||||
Label: "",
|
||||
Provisioned: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "single token with label",
|
||||
users: users,
|
||||
input: []string{"alice:tk_abcdefghijklmnopqrstuvwxyz123:My Phone"},
|
||||
expected: map[string][]*user.Token{
|
||||
"alice": {
|
||||
{
|
||||
Value: "tk_abcdefghijklmnopqrstuvwxyz123",
|
||||
Label: "My Phone",
|
||||
Provisioned: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple tokens for same user",
|
||||
users: users,
|
||||
input: []string{
|
||||
"alice:tk_abcdefghijklmnopqrstuvwxyz123:Phone",
|
||||
"alice:tk_zyxwvutsrqponmlkjihgfedcba987:Laptop",
|
||||
},
|
||||
expected: map[string][]*user.Token{
|
||||
"alice": {
|
||||
{
|
||||
Value: "tk_abcdefghijklmnopqrstuvwxyz123",
|
||||
Label: "Phone",
|
||||
Provisioned: true,
|
||||
},
|
||||
{
|
||||
Value: "tk_zyxwvutsrqponmlkjihgfedcba987",
|
||||
Label: "Laptop",
|
||||
Provisioned: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tokens for multiple users",
|
||||
users: users,
|
||||
input: []string{
|
||||
"alice:tk_abcdefghijklmnopqrstuvwxyz123:Phone",
|
||||
"bob:tk_zyxwvutsrqponmlkjihgfedcba987:Tablet",
|
||||
},
|
||||
expected: map[string][]*user.Token{
|
||||
"alice": {
|
||||
{
|
||||
Value: "tk_abcdefghijklmnopqrstuvwxyz123",
|
||||
Label: "Phone",
|
||||
Provisioned: true,
|
||||
},
|
||||
},
|
||||
"bob": {
|
||||
{
|
||||
Value: "tk_zyxwvutsrqponmlkjihgfedcba987",
|
||||
Label: "Tablet",
|
||||
Provisioned: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
users: users,
|
||||
input: []string{},
|
||||
expected: map[string][]*user.Token{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := parseTokens(tt.users, tt.input)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTokens_Errors(t *testing.T) {
|
||||
users := []*user.User{
|
||||
{Name: "alice"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
users []*user.User
|
||||
input []string
|
||||
error string
|
||||
}{
|
||||
{
|
||||
name: "invalid format - too few parts",
|
||||
users: users,
|
||||
input: []string{"alice"},
|
||||
error: "invalid auth-tokens: alice, expected format: 'user:token[:label]'",
|
||||
},
|
||||
{
|
||||
name: "invalid format - too many parts",
|
||||
users: users,
|
||||
input: []string{"alice:token:label:extra:parts"},
|
||||
error: "invalid auth-tokens: alice:token:label:extra:parts, expected format: 'user:token[:label]'",
|
||||
},
|
||||
{
|
||||
name: "user not provisioned",
|
||||
users: users,
|
||||
input: []string{"charlie:tk_abcdefghijklmnopqrstuvwxyz123"},
|
||||
error: "invalid auth-tokens: charlie:tk_abcdefghijklmnopqrstuvwxyz123, user charlie is not provisioned",
|
||||
},
|
||||
{
|
||||
name: "invalid token format",
|
||||
users: users,
|
||||
input: []string{"alice:invalid-token"},
|
||||
error: "invalid auth-tokens: alice:invalid-token, token invalid-token invalid, use 'ntfy token generate' to generate a random token",
|
||||
},
|
||||
{
|
||||
name: "token too short",
|
||||
users: users,
|
||||
input: []string{"alice:tk_short"},
|
||||
error: "invalid auth-tokens: alice:tk_short, token tk_short invalid, use 'ntfy token generate' to generate a random token",
|
||||
},
|
||||
{
|
||||
name: "token without prefix",
|
||||
users: users,
|
||||
input: []string{"alice:abcdefghijklmnopqrstuvwxyz12345"},
|
||||
error: "invalid auth-tokens: alice:abcdefghijklmnopqrstuvwxyz12345, token abcdefghijklmnopqrstuvwxyz12345 invalid, use 'ntfy token generate' to generate a random token",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := parseTokens(tt.users, tt.input)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), tt.error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLI_Serve_Unix_Curl(t *testing.T) {
|
||||
sockFile := filepath.Join(t.TempDir(), "ntfy.sock")
|
||||
configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system
|
||||
|
||||
32
cmd/tier.go
@@ -182,7 +182,7 @@ func execTierAdd(c *cli.Context) error {
|
||||
}
|
||||
if tier, _ := manager.Tier(code); tier != nil {
|
||||
if c.Bool("ignore-exists") {
|
||||
fmt.Fprintf(c.App.ErrWriter, "tier %s already exists (exited successfully)\n", code)
|
||||
fmt.Fprintf(c.App.Writer, "tier %s already exists (exited successfully)\n", code)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("tier %s already exists", code)
|
||||
@@ -234,7 +234,7 @@ func execTierAdd(c *cli.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "tier added\n\n")
|
||||
fmt.Fprintf(c.App.Writer, "tier added\n\n")
|
||||
printTier(c, tier)
|
||||
return nil
|
||||
}
|
||||
@@ -315,7 +315,7 @@ func execTierChange(c *cli.Context) error {
|
||||
if err := manager.UpdateTier(tier); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "tier updated\n\n")
|
||||
fmt.Fprintf(c.App.Writer, "tier updated\n\n")
|
||||
printTier(c, tier)
|
||||
return nil
|
||||
}
|
||||
@@ -335,7 +335,7 @@ func execTierDel(c *cli.Context) error {
|
||||
if err := manager.RemoveTier(code); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "tier %s removed\n", code)
|
||||
fmt.Fprintf(c.App.Writer, "tier %s removed\n", code)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -359,16 +359,16 @@ func printTier(c *cli.Context, tier *user.Tier) {
|
||||
if tier.StripeMonthlyPriceID != "" && tier.StripeYearlyPriceID != "" {
|
||||
prices = fmt.Sprintf("%s / %s", tier.StripeMonthlyPriceID, tier.StripeYearlyPriceID)
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "tier %s (id: %s)\n", tier.Code, tier.ID)
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Name: %s\n", tier.Name)
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Message limit: %d\n", tier.MessageLimit)
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Message expiry duration: %s (%d seconds)\n", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds()))
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Email limit: %d\n", tier.EmailLimit)
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Phone call limit: %d\n", tier.CallLimit)
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit)
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSizeHuman(tier.AttachmentFileSizeLimit))
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSizeHuman(tier.AttachmentTotalSizeLimit))
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment expiry duration: %s (%d seconds)\n", tier.AttachmentExpiryDuration.String(), int64(tier.AttachmentExpiryDuration.Seconds()))
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment daily bandwidth limit: %s\n", util.FormatSizeHuman(tier.AttachmentBandwidthLimit))
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Stripe prices (monthly/yearly): %s\n", prices)
|
||||
fmt.Fprintf(c.App.Writer, "tier %s (id: %s)\n", tier.Code, tier.ID)
|
||||
fmt.Fprintf(c.App.Writer, "- Name: %s\n", tier.Name)
|
||||
fmt.Fprintf(c.App.Writer, "- Message limit: %d\n", tier.MessageLimit)
|
||||
fmt.Fprintf(c.App.Writer, "- Message expiry duration: %s (%d seconds)\n", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds()))
|
||||
fmt.Fprintf(c.App.Writer, "- Email limit: %d\n", tier.EmailLimit)
|
||||
fmt.Fprintf(c.App.Writer, "- Phone call limit: %d\n", tier.CallLimit)
|
||||
fmt.Fprintf(c.App.Writer, "- Reservation limit: %d\n", tier.ReservationLimit)
|
||||
fmt.Fprintf(c.App.Writer, "- Attachment file size limit: %s\n", util.FormatSizeHuman(tier.AttachmentFileSizeLimit))
|
||||
fmt.Fprintf(c.App.Writer, "- Attachment total size limit: %s\n", util.FormatSizeHuman(tier.AttachmentTotalSizeLimit))
|
||||
fmt.Fprintf(c.App.Writer, "- Attachment expiry duration: %s (%d seconds)\n", tier.AttachmentExpiryDuration.String(), int64(tier.AttachmentExpiryDuration.Seconds()))
|
||||
fmt.Fprintf(c.App.Writer, "- Attachment daily bandwidth limit: %s\n", util.FormatSizeHuman(tier.AttachmentBandwidthLimit))
|
||||
fmt.Fprintf(c.App.Writer, "- Stripe prices (monthly/yearly): %s\n", prices)
|
||||
}
|
||||
|
||||
@@ -12,21 +12,21 @@ func TestCLI_Tier_AddListChangeDelete(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, _, _, stderr := newTestApp()
|
||||
app, _, stdout, _ := newTestApp()
|
||||
require.Nil(t, runTierCommand(app, conf, "add", "--name", "Pro", "--message-limit", "1234", "pro"))
|
||||
require.Contains(t, stderr.String(), "tier added\n\ntier pro (id: ti_")
|
||||
require.Contains(t, stdout.String(), "tier added\n\ntier pro (id: ti_")
|
||||
|
||||
err := runTierCommand(app, conf, "add", "pro")
|
||||
require.NotNil(t, err)
|
||||
require.Equal(t, "tier pro already exists", err.Error())
|
||||
|
||||
app, _, _, stderr = newTestApp()
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, runTierCommand(app, conf, "list"))
|
||||
require.Contains(t, stderr.String(), "tier pro (id: ti_")
|
||||
require.Contains(t, stderr.String(), "- Name: Pro")
|
||||
require.Contains(t, stderr.String(), "- Message limit: 1234")
|
||||
require.Contains(t, stdout.String(), "tier pro (id: ti_")
|
||||
require.Contains(t, stdout.String(), "- Name: Pro")
|
||||
require.Contains(t, stdout.String(), "- Message limit: 1234")
|
||||
|
||||
app, _, _, stderr = newTestApp()
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, runTierCommand(app, conf, "change",
|
||||
"--message-limit=999",
|
||||
"--message-expiry-duration=2d",
|
||||
@@ -40,18 +40,18 @@ func TestCLI_Tier_AddListChangeDelete(t *testing.T) {
|
||||
"--stripe-yearly-price-id=price_992",
|
||||
"pro",
|
||||
))
|
||||
require.Contains(t, stderr.String(), "- Message limit: 999")
|
||||
require.Contains(t, stderr.String(), "- Message expiry duration: 48h")
|
||||
require.Contains(t, stderr.String(), "- Email limit: 91")
|
||||
require.Contains(t, stderr.String(), "- Reservation limit: 98")
|
||||
require.Contains(t, stderr.String(), "- Attachment file size limit: 100.0 MB")
|
||||
require.Contains(t, stderr.String(), "- Attachment expiry duration: 24h")
|
||||
require.Contains(t, stderr.String(), "- Attachment total size limit: 10.0 GB")
|
||||
require.Contains(t, stderr.String(), "- Stripe prices (monthly/yearly): price_991 / price_992")
|
||||
require.Contains(t, stdout.String(), "- Message limit: 999")
|
||||
require.Contains(t, stdout.String(), "- Message expiry duration: 48h")
|
||||
require.Contains(t, stdout.String(), "- Email limit: 91")
|
||||
require.Contains(t, stdout.String(), "- Reservation limit: 98")
|
||||
require.Contains(t, stdout.String(), "- Attachment file size limit: 100.0 MB")
|
||||
require.Contains(t, stdout.String(), "- Attachment expiry duration: 24h")
|
||||
require.Contains(t, stdout.String(), "- Attachment total size limit: 10.0 GB")
|
||||
require.Contains(t, stdout.String(), "- Stripe prices (monthly/yearly): price_991 / price_992")
|
||||
|
||||
app, _, _, stderr = newTestApp()
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, runTierCommand(app, conf, "remove", "pro"))
|
||||
require.Contains(t, stderr.String(), "tier pro removed")
|
||||
require.Contains(t, stdout.String(), "tier pro removed")
|
||||
}
|
||||
|
||||
func runTierCommand(app *cli.App, conf *server.Config, args ...string) error {
|
||||
|
||||
41
cmd/token.go
@@ -72,6 +72,15 @@ Example:
|
||||
This is a server-only command. It directly reads from user.db as defined in the server config
|
||||
file server.yml. The command only works if 'auth-file' is properly defined.`,
|
||||
},
|
||||
{
|
||||
Name: "generate",
|
||||
Usage: "Generates a random token",
|
||||
Action: execTokenGenerate,
|
||||
Description: `Randomly generate a token to be used in provisioned tokens.
|
||||
|
||||
This command only generates the token value, but does not persist it anywhere.
|
||||
The output can be used in the 'auth-tokens' config option.`,
|
||||
},
|
||||
},
|
||||
Description: `Manage access tokens for individual users.
|
||||
|
||||
@@ -112,19 +121,19 @@ func execTokenAdd(c *cli.Context) error {
|
||||
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
|
||||
}
|
||||
token, err := manager.CreateToken(u.ID, label, expires, netip.IPv4Unspecified())
|
||||
token, err := manager.CreateToken(u.ID, label, expires, netip.IPv4Unspecified(), false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if expires.Unix() == 0 {
|
||||
fmt.Fprintf(c.App.ErrWriter, "token %s created for user %s, never expires\n", token.Value, u.Name)
|
||||
fmt.Fprintf(c.App.Writer, "token %s created for user %s, never expires\n", token.Value, u.Name)
|
||||
} else {
|
||||
fmt.Fprintf(c.App.ErrWriter, "token %s created for user %s, expires %v\n", token.Value, u.Name, expires.Format(time.UnixDate))
|
||||
fmt.Fprintf(c.App.Writer, "token %s created for user %s, expires %v\n", token.Value, u.Name, expires.Format(time.UnixDate))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -141,7 +150,7 @@ func execTokenDel(c *cli.Context) error {
|
||||
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
|
||||
@@ -149,7 +158,7 @@ func execTokenDel(c *cli.Context) error {
|
||||
if err := manager.RemoveToken(u.ID, token); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "token %s for user %s removed\n", token, username)
|
||||
fmt.Fprintf(c.App.Writer, "token %s for user %s removed\n", token, username)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -165,7 +174,7 @@ func execTokenList(c *cli.Context) error {
|
||||
var users []*user.User
|
||||
if username != "" {
|
||||
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
|
||||
@@ -183,15 +192,15 @@ func execTokenList(c *cli.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
} else if len(tokens) == 0 && username != "" {
|
||||
fmt.Fprintf(c.App.ErrWriter, "user %s has no access tokens\n", username)
|
||||
fmt.Fprintf(c.App.Writer, "user %s has no access tokens\n", username)
|
||||
return nil
|
||||
} else if len(tokens) == 0 {
|
||||
continue
|
||||
}
|
||||
usersWithTokens++
|
||||
fmt.Fprintf(c.App.ErrWriter, "user %s\n", u.Name)
|
||||
fmt.Fprintf(c.App.Writer, "user %s\n", u.Name)
|
||||
for _, t := range tokens {
|
||||
var label, expires string
|
||||
var label, expires, provisioned string
|
||||
if t.Label != "" {
|
||||
label = fmt.Sprintf(" (%s)", t.Label)
|
||||
}
|
||||
@@ -200,11 +209,19 @@ func execTokenList(c *cli.Context) error {
|
||||
} else {
|
||||
expires = fmt.Sprintf("expires %s", t.Expires.Format(time.RFC822))
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "- %s%s, %s, accessed from %s at %s\n", t.Value, label, expires, t.LastOrigin.String(), t.LastAccess.Format(time.RFC822))
|
||||
if t.Provisioned {
|
||||
provisioned = " (server config)"
|
||||
}
|
||||
fmt.Fprintf(c.App.Writer, "- %s%s, %s, accessed from %s at %s%s\n", t.Value, label, expires, t.LastOrigin.String(), t.LastAccess.Format(time.RFC822), provisioned)
|
||||
}
|
||||
}
|
||||
if usersWithTokens == 0 {
|
||||
fmt.Fprintf(c.App.ErrWriter, "no users with tokens\n")
|
||||
fmt.Fprintf(c.App.Writer, "no users with tokens\n")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func execTokenGenerate(c *cli.Context) error {
|
||||
fmt.Fprintln(c.App.Writer, user.GenerateToken())
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -14,28 +14,28 @@ func TestCLI_Token_AddListRemove(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
app, stdin, stdout, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||
require.Contains(t, stdout.String(), "user phil added with role user")
|
||||
|
||||
app, _, _, stderr = newTestApp()
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, runTokenCommand(app, conf, "add", "phil"))
|
||||
require.Regexp(t, `token tk_.+ created for user phil, never expires`, stderr.String())
|
||||
require.Regexp(t, `token tk_.+ created for user phil, never expires`, stdout.String())
|
||||
|
||||
app, _, _, stderr = newTestApp()
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, runTokenCommand(app, conf, "list", "phil"))
|
||||
require.Regexp(t, `user phil\n- tk_.+, never expires, accessed from 0.0.0.0 at .+`, stderr.String())
|
||||
require.Regexp(t, `user phil\n- tk_.+, never expires, accessed from 0.0.0.0 at .+`, stdout.String())
|
||||
re := regexp.MustCompile(`tk_\w+`)
|
||||
token := re.FindString(stderr.String())
|
||||
token := re.FindString(stdout.String())
|
||||
|
||||
app, _, _, stderr = newTestApp()
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, runTokenCommand(app, conf, "remove", "phil", token))
|
||||
require.Regexp(t, fmt.Sprintf("token %s for user phil removed", token), stderr.String())
|
||||
require.Regexp(t, fmt.Sprintf("token %s for user phil removed", token), stdout.String())
|
||||
|
||||
app, _, _, stderr = newTestApp()
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, runTokenCommand(app, conf, "list"))
|
||||
require.Equal(t, "no users with tokens\n", stderr.String())
|
||||
require.Equal(t, "no users with tokens\n", stdout.String())
|
||||
}
|
||||
|
||||
func runTokenCommand(app *cli.App, conf *server.Config, args ...string) error {
|
||||
|
||||
65
cmd/user.go
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/v2/server"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -25,7 +26,7 @@ func init() {
|
||||
|
||||
var flagsUser = append(
|
||||
append([]cli.Flag{}, flagsDefault...),
|
||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, Usage: "config file"},
|
||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: server.DefaultConfigFile, DefaultText: server.DefaultConfigFile, Usage: "config file"},
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
|
||||
)
|
||||
@@ -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-users.
|
||||
|
||||
Example:
|
||||
$ ntfy user hash
|
||||
(asks for password and confirmation)
|
||||
$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C
|
||||
`,
|
||||
},
|
||||
{
|
||||
@@ -195,7 +211,7 @@ func execUserAdd(c *cli.Context) error {
|
||||
}
|
||||
if user, _ := manager.User(username); user != nil {
|
||||
if c.Bool("ignore-exists") {
|
||||
fmt.Fprintf(c.App.ErrWriter, "user %s already exists (exited successfully)\n", username)
|
||||
fmt.Fprintf(c.App.Writer, "user %s already exists (exited successfully)\n", username)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("user %s already exists", username)
|
||||
@@ -210,7 +226,7 @@ func execUserAdd(c *cli.Context) error {
|
||||
if err := manager.AddUser(username, password, role, hashed); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "user %s added with role %s\n", username, role)
|
||||
fmt.Fprintf(c.App.Writer, "user %s added with role %s\n", username, role)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -225,13 +241,13 @@ 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 {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "user %s removed\n", username)
|
||||
fmt.Fprintf(c.App.Writer, "user %s removed\n", username)
|
||||
return 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 == "" {
|
||||
@@ -263,7 +279,7 @@ func execUserChangePass(c *cli.Context) error {
|
||||
if err := manager.ChangePassword(username, password, hashed); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "changed password for user %s\n", username)
|
||||
fmt.Fprintf(c.App.Writer, "changed password for user %s\n", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -279,13 +295,26 @@ 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 {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "changed role for user %s to %s\n", username, role)
|
||||
fmt.Fprintf(c.App.Writer, "changed role for user %s to %s\n", username, role)
|
||||
return nil
|
||||
}
|
||||
|
||||
func execUserHash(c *cli.Context) error {
|
||||
password, err := readPasswordAndConfirm(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hash, err := user.HashPassword(password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
fmt.Fprintln(c.App.Writer, hash)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -303,19 +332,19 @@ 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 {
|
||||
if err := manager.ResetTier(username); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "removed tier from user %s\n", username)
|
||||
fmt.Fprintf(c.App.Writer, "removed tier from user %s\n", username)
|
||||
} else {
|
||||
if err := manager.ChangeTier(username, tier); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "changed tier for user %s to %s\n", username, tier)
|
||||
fmt.Fprintf(c.App.Writer, "changed tier for user %s to %s\n", username, tier)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -345,7 +374,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, // Hack: 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) {
|
||||
|
||||
@@ -15,20 +15,20 @@ func TestCLI_User_Add(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
app, stdin, stdout, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||
require.Contains(t, stdout.String(), "user phil added with role user")
|
||||
}
|
||||
|
||||
func TestCLI_User_Add_Exists(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
app, stdin, stdout, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||
require.Contains(t, stdout.String(), "user phil added with role user")
|
||||
|
||||
app, stdin, _, _ = newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
@@ -41,10 +41,10 @@ func TestCLI_User_Add_Admin(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
app, stdin, stdout, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "--role=admin", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil added with role admin")
|
||||
require.Contains(t, stdout.String(), "user phil added with role admin")
|
||||
}
|
||||
|
||||
func TestCLI_User_Add_Password_Mismatch(t *testing.T) {
|
||||
@@ -60,19 +60,27 @@ func TestCLI_User_Add_Password_Mismatch(t *testing.T) {
|
||||
|
||||
func TestCLI_User_ChangePass(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
conf.AuthUsers = []*user.User{
|
||||
{Name: "philuser", Hash: "$2a$10$U4WSIYY6evyGmZaraavM2e2JeVG6EMGUKN1uUwufUeeRd4Jpg6cGC", Role: user.RoleUser}, // philuser:philpass
|
||||
}
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
// Add user
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
app, stdin, stdout, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||
require.Contains(t, stdout.String(), "user phil added with role user")
|
||||
|
||||
// Change pass
|
||||
app, stdin, _, stderr = newTestApp()
|
||||
app, stdin, stdout, _ = newTestApp()
|
||||
stdin.WriteString("newpass\nnewpass")
|
||||
require.Nil(t, runUserCommand(app, conf, "change-pass", "phil"))
|
||||
require.Contains(t, stderr.String(), "changed password for user phil")
|
||||
require.Contains(t, stdout.String(), "changed password for user phil")
|
||||
|
||||
// Cannot change provisioned user's pass
|
||||
app, stdin, _, _ = newTestApp()
|
||||
stdin.WriteString("newpass\nnewpass")
|
||||
require.Error(t, runUserCommand(app, conf, "change-pass", "philuser"))
|
||||
}
|
||||
|
||||
func TestCLI_User_ChangeRole(t *testing.T) {
|
||||
@@ -80,15 +88,15 @@ func TestCLI_User_ChangeRole(t *testing.T) {
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
// Add user
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
app, stdin, stdout, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||
require.Contains(t, stdout.String(), "user phil added with role user")
|
||||
|
||||
// Change role
|
||||
app, _, _, stderr = newTestApp()
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, runUserCommand(app, conf, "change-role", "phil", "admin"))
|
||||
require.Contains(t, stderr.String(), "changed role for user phil to admin")
|
||||
require.Contains(t, stdout.String(), "changed role for user phil to admin")
|
||||
}
|
||||
|
||||
func TestCLI_User_Delete(t *testing.T) {
|
||||
@@ -96,15 +104,15 @@ func TestCLI_User_Delete(t *testing.T) {
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
// Add user
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
app, stdin, stdout, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||
require.Contains(t, stdout.String(), "user phil added with role user")
|
||||
|
||||
// Delete user
|
||||
app, _, _, stderr = newTestApp()
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, runUserCommand(app, conf, "del", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil removed")
|
||||
require.Contains(t, stdout.String(), "user phil removed")
|
||||
|
||||
// Delete user again (does not exist)
|
||||
app, _, _, _ = newTestApp()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !noserver
|
||||
//go:build !noserver && !nowebpush
|
||||
|
||||
package cmd
|
||||
|
||||
@@ -53,9 +53,9 @@ web-push-private-key: %s
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintf(c.App.ErrWriter, "Web Push keys written to %s.\n", outputFile)
|
||||
_, err = fmt.Fprintf(c.App.Writer, "Web Push keys written to %s.\n", outputFile)
|
||||
} else {
|
||||
_, err = fmt.Fprintf(c.App.ErrWriter, `Web Push keys generated. Add the following lines to your config file:
|
||||
_, err = fmt.Fprintf(c.App.Writer, `Web Push keys generated. Add the following lines to your config file:
|
||||
|
||||
web-push-public-key: %s
|
||||
web-push-private-key: %s
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -9,16 +10,18 @@ import (
|
||||
)
|
||||
|
||||
func TestCLI_WebPush_GenerateKeys(t *testing.T) {
|
||||
app, _, _, stderr := newTestApp()
|
||||
app, _, stdout, _ := newTestApp()
|
||||
require.Nil(t, runWebPushCommand(app, server.NewConfig(), "keys"))
|
||||
require.Contains(t, stderr.String(), "Web Push keys generated.")
|
||||
require.Contains(t, stdout.String(), "Web Push keys generated.")
|
||||
}
|
||||
|
||||
func TestCLI_WebPush_WriteKeysToFile(t *testing.T) {
|
||||
app, _, _, stderr := newTestApp()
|
||||
tempDir := t.TempDir()
|
||||
t.Chdir(tempDir)
|
||||
app, _, stdout, _ := newTestApp()
|
||||
require.Nil(t, runWebPushCommand(app, server.NewConfig(), "keys", "--output-file=key-file.yaml"))
|
||||
require.Contains(t, stderr.String(), "Web Push keys written to key-file.yaml")
|
||||
require.FileExists(t, "key-file.yaml")
|
||||
require.Contains(t, stdout.String(), "Web Push keys written to key-file.yaml")
|
||||
require.FileExists(t, filepath.Join(tempDir, "key-file.yaml"))
|
||||
}
|
||||
|
||||
func runWebPushCommand(app *cli.App, conf *server.Config, args ...string) error {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
version: "2.1"
|
||||
services:
|
||||
ntfy:
|
||||
image: binwiederhier/ntfy
|
||||
@@ -14,4 +13,3 @@ services:
|
||||
ports:
|
||||
- 80:80
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
320
docs/config.md
@@ -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
|
||||
@@ -89,6 +88,7 @@ using Docker Compose (i.e. `docker-compose.yml`):
|
||||
NTFY_CACHE_FILE: /var/lib/ntfy/cache.db
|
||||
NTFY_AUTH_FILE: /var/lib/ntfy/auth.db
|
||||
NTFY_AUTH_DEFAULT_ACCESS: deny-all
|
||||
NTFY_AUTH_USERS: 'phil:$$2a$$10$$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin' # Must escape '$' as '$$'
|
||||
NTFY_BEHIND_PROXY: true
|
||||
NTFY_ATTACHMENT_CACHE_DIR: /var/lib/ntfy/attachments
|
||||
NTFY_ENABLE_LOGIN: true
|
||||
@@ -101,7 +101,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
|
||||
@@ -190,19 +189,31 @@ ntfy's auth is implemented with a simple [SQLite](https://www.sqlite.org/)-based
|
||||
(`user` and `admin`) and per-topic `read` and `write` permissions using an [access control list (ACL)](https://en.wikipedia.org/wiki/Access-control_list).
|
||||
Access control entries can be applied to users as well as the special everyone user (`*`), which represents anonymous API access.
|
||||
|
||||
To set up auth, simply **configure the following two options**:
|
||||
To set up auth, **configure the following options**:
|
||||
|
||||
* `auth-file` is the user/access database; it is created automatically if it doesn't already exist; suggested
|
||||
location `/var/lib/ntfy/user.db` (easiest if deb/rpm package is used)
|
||||
* `auth-default-access` defines the default/fallback access if no access control entry is found; it can be
|
||||
set to `read-write` (default), `read-only`, `write-only` or `deny-all`.
|
||||
set to `read-write` (default), `read-only`, `write-only` or `deny-all`. **If you are setting up a private instance,
|
||||
you'll want to set this to `deny-all`** (see [private instance example](#example-private-instance)).
|
||||
|
||||
Once configured, you can use the `ntfy user` command to [add or modify users](#users-and-roles), and the `ntfy access` command
|
||||
lets you [modify the access control list](#access-control-list-acl) for specific users and topic patterns. Both of these
|
||||
commands **directly edit the auth database** (as defined in `auth-file`), so they only work on the server, and only if the user
|
||||
accessing them has the right permissions.
|
||||
Once configured, you can use
|
||||
|
||||
- the `ntfy user` command and the `auth-users` config option to [add or modify users](#users-and-roles)
|
||||
- the `ntfy access` command and the `auth-access` option to [modify the access control list](#access-control-list-acl)
|
||||
and topic patterns, and
|
||||
- the `ntfy token` command and the `auth-tokens` config option to [manage access tokens](#access-tokens) for users.
|
||||
|
||||
These commands **directly edit the auth database** (as defined in `auth-file`), so they only work on the server,
|
||||
and only if the user accessing them has the right permissions.
|
||||
|
||||
### Users and roles
|
||||
Users can be added to the ntfy user database in two different ways
|
||||
|
||||
* [Using the CLI](#users-via-the-cli): Using the `ntfy user` command, you can manually add/update/remove users.
|
||||
* [In the config](#users-via-the-config): You can provision users in the `server.yml` file via `auth-users` key.
|
||||
|
||||
#### Users via the CLI
|
||||
The `ntfy user` command allows you to add/remove/change users in the ntfy user database, as well as change
|
||||
passwords or roles (`user` or `admin`). In practice, you'll often just create one admin
|
||||
user with `ntfy user add --role=admin ...` and be done with all this (see [example below](#example-private-instance)).
|
||||
@@ -223,12 +234,54 @@ ntfy user del phil # Delete user phil
|
||||
ntfy user change-pass phil # Change password for user phil
|
||||
ntfy user change-role phil admin # Make user phil an admin
|
||||
ntfy user change-tier phil pro # Change phil's tier to "pro"
|
||||
ntfy user hash # Generate password hash, use with auth-users config option
|
||||
```
|
||||
|
||||
#### Users via the config
|
||||
As an alternative to manually creating users via the `ntfy user` CLI command, you can provision users declaratively in
|
||||
the `server.yml` file by adding them to the `auth-users` array. This is useful for general admins, or if you'd like to
|
||||
deploy your ntfy server via Docker/Ansible without manually editing the database.
|
||||
|
||||
The `auth-users` option is a list of users that are automatically created/updated when the server starts. Users
|
||||
previously defined in the config but later removed will be deleted. Each entry is defined in the format `<username>:<password-hash>:<role>`.
|
||||
|
||||
Here's an example with two users: `phil` is an admin, `ben` is a regular user.
|
||||
|
||||
=== "Declarative users in /etc/ntfy/server.yml"
|
||||
``` yaml
|
||||
auth-file: "/var/lib/ntfy/user.db"
|
||||
auth-users:
|
||||
- "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin"
|
||||
- "ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user"
|
||||
```
|
||||
|
||||
=== "Declarative users via env variables"
|
||||
```
|
||||
# Comma-separated list, use single quotes to avoid issues with the bcrypt hash
|
||||
NTFY_AUTH_FILE='/var/lib/ntfy/user.db'
|
||||
NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin,ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user'
|
||||
```
|
||||
|
||||
The password hash can be created using `ntfy user hash` or an [online bcrypt generator](https://bcrypt-generator.com/) (though
|
||||
note that you're putting your password in an untrusted website).
|
||||
|
||||
!!! important
|
||||
Users added declaratively via the config file are marked in the database as "provisioned users". Removing users
|
||||
from the config file will **delete them from the database** the next time ntfy is restarted.
|
||||
|
||||
Also, users that were originally manually created will be "upgraded" to be provisioned users if they are added to
|
||||
the config. Adding a user manually, then adding it to the config, and then removing it from the config will hence
|
||||
lead to the **deletion of that user**.
|
||||
|
||||
### Access control list (ACL)
|
||||
The access control list (ACL) **manages access to topics for non-admin users, and for anonymous access (`everyone`/`*`)**.
|
||||
Each entry represents the access permissions for a user to a specific topic or topic pattern.
|
||||
Each entry represents the access permissions for a user to a specific topic or topic pattern. Entries can be created in
|
||||
two different ways:
|
||||
|
||||
* [Using the CLI](#acl-entries-via-the-cli): Using the `ntfy access` command, you can manually edit the access control list.
|
||||
* [In the config](#acl-entries-via-the-config): You can provision ACL entries in the `server.yml` file via `auth-access` key.
|
||||
|
||||
#### ACL entries via the CLI
|
||||
The ACL can be displayed or modified with the `ntfy access` command:
|
||||
|
||||
```
|
||||
@@ -284,6 +337,51 @@ User `ben` has three topic-specific entries. He can read, but not write to topic
|
||||
to topic `garagedoor` and all topics starting with the word `alerts` (wildcards). Clients that are not authenticated
|
||||
(called `*`/`everyone`) only have read access to the `announcements` and `server-stats` topics.
|
||||
|
||||
#### ACL entries via the config
|
||||
As an alternative to manually creating ACL entries via the `ntfy access` CLI command, you can provision access control
|
||||
entries declaratively in the `server.yml` file by adding them to the `auth-access` array, similar to the `auth-users`
|
||||
option (see [users via the config](#users-via-the-config).
|
||||
|
||||
The `auth-access` option is a list of access control entries that are automatically created/updated when the server starts.
|
||||
When entries are removed, they are deleted from the database. Each entry is defined in the format `<username>:<topic-pattern>:<access>`.
|
||||
|
||||
The `<username>` can be any existing, provisioned user as defined in the `auth-users` section (see [users via the config](#users-via-the-config)),
|
||||
or `everyone`/`*` for anonymous access. The `<topic-pattern>` can be a specific topic name or a pattern with wildcards (`*`). The
|
||||
`<access>` can be one of the following:
|
||||
|
||||
* `read-write` or `rw`: Allows both publishing to and subscribing to the topic
|
||||
* `read-only`, `read`, or `ro`: Allows only subscribing to the topic
|
||||
* `write-only`, `write`, or `wo`: Allows only publishing to the topic
|
||||
* `deny-all`, `deny`, or `none`: Denies all access to the topic
|
||||
|
||||
Here's an example with several ACL entries:
|
||||
|
||||
=== "Declarative ACL entries in /etc/ntfy/server.yml"
|
||||
``` yaml
|
||||
auth-file: "/var/lib/ntfy/user.db"
|
||||
auth-users:
|
||||
- "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:user"
|
||||
- "ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user"
|
||||
auth-access:
|
||||
- "phil:mytopic:rw"
|
||||
- "ben:alerts-*:rw"
|
||||
- "ben:system-logs:ro"
|
||||
- "*:announcements:ro" # or: "everyone:announcements,ro"
|
||||
```
|
||||
|
||||
=== "Declarative ACL entries via env variables"
|
||||
```
|
||||
# Comma-separated list
|
||||
NTFY_AUTH_FILE='/var/lib/ntfy/user.db'
|
||||
NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:user,ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user'
|
||||
NTFY_AUTH_ACCESS='phil:mytopic:rw,ben:alerts-*:rw,ben:system-logs:ro,*:announcements:ro'
|
||||
```
|
||||
|
||||
In this example, the `auth-users` section defines two users, `phil` and `ben`. The `auth-access` section defines
|
||||
access control entries for these users. `phil` has read-write access to the topic `mytopic`, while `ben` has read-write
|
||||
access to all topics starting with `alerts-` and read-only access to the topic `system-logs`. The last entry allows
|
||||
anonymous users (i.e. clients that do not authenticate) to read the `announcements` topic.
|
||||
|
||||
### Access tokens
|
||||
In addition to username/password auth, ntfy also provides authentication via access tokens. Access tokens are useful
|
||||
to avoid having to configure your password across multiple publishing/subscribing applications. For instance, you may
|
||||
@@ -294,6 +392,12 @@ want to use a dedicated token to publish from your backup host, and one from you
|
||||
and deleting the account, every action can be performed with a token. Granular access tokens are on the roadmap,
|
||||
but not yet implemented.
|
||||
|
||||
You can create access tokens in two different ways:
|
||||
|
||||
* [Using the CLI](#tokens-via-the-cli): Using the `ntfy token` command, you can manually add/update/remove tokens.
|
||||
* [In the config](#tokens-via-the-config): You can provision access tokens in the `server.yml` file via `auth-tokens` key.
|
||||
|
||||
#### Tokens via the CLI
|
||||
The `ntfy token` command can be used to manage access tokens for users. Tokens can have labels, and they can expire
|
||||
automatically (or never expire). Each user can have up to 60 tokens (hardcoded).
|
||||
|
||||
@@ -304,6 +408,7 @@ ntfy token list phil # Shows list of tokens for user phil
|
||||
ntfy token add phil # Create token for user phil which never expires
|
||||
ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days
|
||||
ntfy token remove phil tk_th2sxr... # Delete token
|
||||
ntfy token generate # Generate random token, can be used in auth-tokens config option
|
||||
```
|
||||
|
||||
**Creating an access token:**
|
||||
@@ -311,32 +416,89 @@ ntfy token remove phil tk_th2sxr... # Delete token
|
||||
$ ntfy token add --expires=30d --label="backups" phil
|
||||
$ ntfy token list
|
||||
user phil
|
||||
- tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 (backups), expires 15 Mar 23 14:33 EDT, accessed from 0.0.0.0 at 13 Feb 23 13:33 EST
|
||||
- tk_7eevizlsiwf9yi4uxsrs83r4352o0 (backups), expires 15 Mar 23 14:33 EDT, accessed from 0.0.0.0 at 13 Feb 23 13:33 EST
|
||||
```
|
||||
|
||||
Once an access token is created, you can **use it to authenticate against the ntfy server, e.g. when you publish or
|
||||
subscribe to topics**. To learn how, check out [authenticate via access tokens](publish.md#access-tokens).
|
||||
|
||||
### Example: Private instance
|
||||
The easiest way to configure a private instance is to set `auth-default-access` to `deny-all` in the `server.yml`:
|
||||
#### Tokens via the config
|
||||
Access tokens can be pre-provisioned in the `server.yml` configuration file using the `auth-tokens` config option.
|
||||
This is useful for automated setups, Docker environments, or when you want to define tokens declaratively.
|
||||
|
||||
=== "/etc/ntfy/server.yml"
|
||||
The `auth-tokens` option is a list of access tokens that are automatically created/updated when the server starts.
|
||||
When entries are removed, they are deleted from the database. Each entry is defined in the format `<username>:<token>[:<label>]`.
|
||||
|
||||
The `<username>` must be an existing, provisioned user, as defined in the `auth-users` section (see [users via the config](#users-via-the-config)).
|
||||
The `<token>` is a valid access token, which must start with `tk_` and be 32 characters long (including the prefix). You can generate
|
||||
random tokens using the `ntfy token generate` command. The optional `<label>` is a human-readable label for the token,
|
||||
which can be used to identify it later.
|
||||
|
||||
Once configured, these tokens can be used to authenticate API requests just like tokens created via the CLI.
|
||||
For usage examples, see [authenticate via access tokens](publish.md#access-tokens).
|
||||
|
||||
Here's an example:
|
||||
|
||||
=== "Declarative tokens in /etc/ntfy/server.yml"
|
||||
``` yaml
|
||||
auth-file: "/var/lib/ntfy/user.db"
|
||||
auth-users:
|
||||
- "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin"
|
||||
- "backup-service:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user"
|
||||
auth-tokens:
|
||||
- "phil:tk_3gd7d2yftt4b8ixyfe9mnmro88o76"
|
||||
- "backup-service:tk_f099we8uzj7xi5qshzajwp6jffvkz:Backup script"
|
||||
```
|
||||
|
||||
=== "Declarative tokens via env variables"
|
||||
```
|
||||
# Comma-separated list
|
||||
NTFY_AUTH_FILE='/var/lib/ntfy/user.db'
|
||||
NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin,ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user'
|
||||
NTFY_AUTH_TOKENS='phil:tk_3gd7d2yftt4b8ixyfe9mnmro88o76,backup-service:tk_f099we8uzj7xi5qshzajwp6jffvkz:Backup script'
|
||||
```
|
||||
|
||||
In this example, the `auth-users` section defines two users, `phil` and `backup-service`. The `auth-tokens` section
|
||||
defines access tokens for these users. `phil` has a token `tk_3gd7d2yftt4b8ixyfe9mnmro88o76`, while `backup-service`
|
||||
has a token `tk_f099we8uzj7xi5qshzajwp6jffvkz` with the label "Backup script".
|
||||
|
||||
### Example: Private instance
|
||||
The easiest way to configure a private instance is to set `auth-default-access` to `deny-all` in the `server.yml`,
|
||||
and to configure users in the `auth-users` section (see [users via the config](#users-via-the-config)),
|
||||
access control entries in the `auth-access` section (see [ACL entries via the config](#acl-entries-via-the-config)),
|
||||
and access tokens in the `auth-tokens` section (see [access tokens via the config](#tokens-via-the-config)).
|
||||
|
||||
Here's an example that defines a single admin user `phil` with the password `mypass`, and a regular user `backup-script`
|
||||
with the password `backup-script`. The admin user has full access to all topics, while regular user can only
|
||||
access the `backups` topic with read-write permissions. The `auth-default-access` is set to `deny-all`, which means
|
||||
that all other users and anonymous access are denied by default.
|
||||
|
||||
=== "Config via /etc/ntfy/server.yml"
|
||||
``` yaml
|
||||
auth-file: "/var/lib/ntfy/user.db"
|
||||
auth-default-access: "deny-all"
|
||||
auth-users:
|
||||
- "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin"
|
||||
- "backup-script:$2a$10$/ehiQt.w7lhTmHXq.RNsOOkIwiPPeWFIzWYO3DRxNixnWKLX8.uj.:user"
|
||||
auth-access:
|
||||
- "backup-service:backups:rw"
|
||||
auth-tokens:
|
||||
- "phil:tk_3gd7d2yftt4b8ixyfe9mnmro88o76:My personal token"
|
||||
```
|
||||
|
||||
After that, simply create an `admin` user:
|
||||
|
||||
```
|
||||
$ ntfy user add --role=admin phil
|
||||
password: mypass
|
||||
confirm: mypass
|
||||
user phil added with role admin
|
||||
```
|
||||
=== "Config via env variables"
|
||||
``` yaml
|
||||
NTFY_AUTH_FILE='/var/lib/ntfy/user.db'
|
||||
NTFY_AUTH_DEFAULT_ACCESS='deny-all'
|
||||
NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin,backup-script:$2a$10$/ehiQt.w7lhTmHXq.RNsOOkIwiPPeWFIzWYO3DRxNixnWKLX8.uj.:user'
|
||||
NTFY_AUTH_ACCESS='backup-service:backups:rw'
|
||||
NTFY_AUTH_TOKENS='phil:tk_3gd7d2yftt4b8ixyfe9mnmro88o76:My personal token'
|
||||
```
|
||||
|
||||
Once you've done that, you can publish and subscribe using [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication)
|
||||
with the given username/password. Be sure to use HTTPS to avoid eavesdropping and exposing your password. Here's a simple example:
|
||||
with the given username/password. Be sure to use HTTPS to avoid eavesdropping and exposing your password.
|
||||
|
||||
Here's a simple example (using the credentials of the `phil` user):
|
||||
|
||||
=== "Command line (curl)"
|
||||
```
|
||||
@@ -559,7 +721,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 +730,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 +783,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
|
||||
@@ -829,7 +1013,7 @@ or the root domain:
|
||||
=== "caddy"
|
||||
```
|
||||
# Note that this config is most certainly incomplete. Please help out and let me know what's missing
|
||||
# via Discord/Matrix or in a GitHub issue.
|
||||
# via the contact page (https://ntfy.sh/docs/contact/) or in a GitHub issue.
|
||||
# Note: Caddy automatically handles both HTTP and WebSockets with reverse_proxy
|
||||
|
||||
ntfy.sh, http://nfty.sh {
|
||||
@@ -845,6 +1029,36 @@ or the root domain:
|
||||
redir @httpget https://{host}{uri}
|
||||
}
|
||||
```
|
||||
|
||||
=== "ferron"
|
||||
``` kdl
|
||||
// /etc/ferron.kdl
|
||||
// Note that this config is most certainly incomplete. Please help out and let me know what's missing
|
||||
// via the contact page (https://ntfy.sh/docs/contact/) or in a GitHub issue.
|
||||
// Note: Ferron automatically handles both HTTP and WebSockets with proxy
|
||||
|
||||
ntfy.sh {
|
||||
auto_tls
|
||||
auto_tls_letsencrypt_production
|
||||
protocols "h1" "h2" "h3"
|
||||
|
||||
proxy "http://127.0.0.1:2586"
|
||||
|
||||
// Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
|
||||
// it to work with curl without the annoying https:// prefix
|
||||
|
||||
no_redirect_to_https #true
|
||||
|
||||
condition "is_get_topic" {
|
||||
is_equal "{method}" "GET"
|
||||
is_regex "{path}" "^/([-_a-z0-9]{0,64}$|docs/|static/)"
|
||||
}
|
||||
|
||||
if "is_get_topic" {
|
||||
no_redirect_to_https #false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Firebase (FCM)
|
||||
!!! info
|
||||
@@ -1138,6 +1352,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 +1528,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.
|
||||
@@ -1316,7 +1561,7 @@ See [Installation for Docker](install.md#docker) for an example of how this coul
|
||||
If configured, ntfy can expose a `/metrics` endpoint for [Prometheus](https://prometheus.io/), which can then be used to
|
||||
create dashboards and alerts (e.g. via [Grafana](https://grafana.com/)).
|
||||
|
||||
To configure the metrics endpoint, either set `enable-metrics` and/or set the `listen-metrics-http` option to a dedicated
|
||||
To configure the metrics endpoint, either set `enable-metrics` and/or set the `metrics-listen-http` option to a dedicated
|
||||
listen address. Metrics may be considered sensitive information, so before you enable them, be sure you know what you are
|
||||
doing, and/or secure access to the endpoint in your reverse proxy.
|
||||
|
||||
@@ -1444,7 +1689,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,13 +1719,16 @@ 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 |
|
||||
| `enable-reservations` | `NTFY_ENABLE_RESERVATIONS` | *boolean* (`true` or `false`) | `false` | Allows users to reserve topics (if their tier allows it) |
|
||||
| `require-login` | `NTFY_REQUIRE_LOGIN` | *boolean* (`true` or `false`) | `false` | All actions via the web app require a login |
|
||||
| `stripe-secret-key` | `NTFY_STRIPE_SECRET_KEY` | *string* | - | Payments: Key used for the Stripe API communication, this enables payments |
|
||||
| `stripe-webhook-key` | `NTFY_STRIPE_WEBHOOK_KEY` | *string* | - | Payments: Key required to validate the authenticity of incoming webhooks from Stripe |
|
||||
| `billing-contact` | `NTFY_BILLING_CONTACT` | *email address* or *website* | - | Payments: Email or website displayed in Upgrade dialog as a billing contact |
|
||||
@@ -1572,6 +1820,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 +1829,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 +1847,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
|
||||
```
|
||||
|
||||
46
docs/contact.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Contact
|
||||
|
||||
This service is run by [Philipp C. Heckel](https://heckel.io). There are several ways to get in touch with me and the
|
||||
ntfy community. Please choose the appropriate channel based on your needs.
|
||||
|
||||
## Support
|
||||
|
||||
### Community support
|
||||
|
||||
For general questions, feature discussions, and community help, please use one of these public channels:
|
||||
|
||||
| Channel | Link | Description |
|
||||
|-------------------|--------------------------------------------------------------------------------------|------------------------------------------------------------|
|
||||
| **Discord** | [discord.gg/cT7ECsZj9w](https://discord.gg/cT7ECsZj9w) | Real-time chat with the community (I'm `binwiederhier`) |
|
||||
| **Matrix** | [#ntfy:matrix.org](https://matrix.to/#/#ntfy:matrix.org) | Bridged from Discord, same community (I'm `binwiederhier`) |
|
||||
| **Matrix Space** | [#ntfy-space:matrix.org](https://matrix.to/#/#ntfy-space:matrix.org) | Matrix space with all ntfy rooms |
|
||||
| **GitHub Issues** | [github.com/binwiederhier/ntfy/issues](https://github.com/binwiederhier/ntfy/issues) | Bug reports and feature requests |
|
||||
|
||||
!!! info "Why public channels?"
|
||||
Answering questions in public channels benefits the entire community. Other users can learn from the
|
||||
discussion, and answers can be referenced later. This is much more scalable than 1-on-1 support.
|
||||
|
||||
### Paid support
|
||||
|
||||
If you are subscribed to a [ntfy Pro](https://ntfy.sh/#pricing) plan, you are entitled to priority support
|
||||
via the following channels:
|
||||
|
||||
| Channel | Contact | Description |
|
||||
|-----------------------|-----------------------------------------------------|------------------------------------------|
|
||||
| **General Support** | [support@mail.ntfy.sh](mailto:support@mail.ntfy.sh) | Direct email support for Pro subscribers |
|
||||
| **Billing Inquiries** | [billing@mail.ntfy.sh](mailto:support@mail.ntfy.sh) | Inquire about billing issues |
|
||||
| **Discord/Matrix** | Mention your Pro status | Priority responses in community channels |
|
||||
|
||||
Please include your ntfy.sh username when contacting support so we can verify your subscription status.
|
||||
|
||||
## Security issues
|
||||
|
||||
If you discover a security vulnerability, please report it responsibly via [security@mail.ntfy.sh](mailto:security@mail.ntfy.sh). See also: [SECURITY.md](https://github.com/binwiederhier/ntfy/blob/main/SECURITY.md).
|
||||
|
||||
## Other inquiries
|
||||
|
||||
For questions about our [privacy policy](privacy.md), data handling, or to exercise your data rights
|
||||
(access, deletion, etc.), please email [privacy@mail.ntfy.sh](mailto:privacy@mail.ntfy.sh).
|
||||
|
||||
For business inquiries, partnerships, press, or other general questions that don't fit the categories above, please
|
||||
use [contact@mail.ntfy.sh](mailto:contact@mail.ntfy.sh).
|
||||
43
docs/contributing.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Contributing
|
||||
|
||||
Thank you for your interest in contributing to ntfy! There are many ways to help, whether you're a developer,
|
||||
translator, or just an enthusiastic user.
|
||||
|
||||
## Code contributions
|
||||
|
||||
If you'd like to contribute code to ntfy:
|
||||
|
||||
1. Check out the [development guide](develop.md) to set up your environment
|
||||
2. Look at [open issues](https://github.com/binwiederhier/ntfy/issues) for ideas, or propose your own
|
||||
3. For larger features or architectural changes, please reach out on [Discord/Matrix](contact.md) first to discuss
|
||||
before investing significant time
|
||||
4. Submit a pull request on GitHub
|
||||
|
||||
All contributions are welcome, from small bug fixes to major features.
|
||||
|
||||
## Translations
|
||||
|
||||
Help make ntfy accessible to users around the world! We use Hosted Weblate for translations:
|
||||
|
||||
- **Weblate**: [hosted.weblate.org/projects/ntfy](https://hosted.weblate.org/projects/ntfy/)
|
||||
|
||||
You can start translating immediately without any coding knowledge.
|
||||
|
||||
## Documentation
|
||||
|
||||
Found a typo? Want to improve the docs? Documentation contributions are very welcome:
|
||||
|
||||
- Edit any page directly on GitHub using the edit button
|
||||
- Submit a pull request with your improvements
|
||||
|
||||
## Bug reports and feature requests
|
||||
|
||||
- **GitHub Issues**: [github.com/binwiederhier/ntfy/issues](https://github.com/binwiederhier/ntfy/issues)
|
||||
|
||||
Please search existing issues before creating a new one to avoid duplicates.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
Please be respectful and constructive in all interactions. See the
|
||||
[Code of Conduct](https://github.com/binwiederhier/ntfy/blob/main/CODE_OF_CONDUCT.md) for details.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Hurray 🥳 🎉, you are interested in writing code for ntfy! **That's awesome.** 😎
|
||||
|
||||
I tried my very best to write up detailed instructions, but if at any point in time you run into issues, don't
|
||||
hesitate to **contact me on [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org)**.
|
||||
hesitate to reach out via one of the channels listed on the [contact page](contact.md).
|
||||
|
||||
## ntfy server
|
||||
The ntfy server source code is available [on GitHub](https://github.com/binwiederhier/ntfy). The codebase for the
|
||||
@@ -441,6 +441,6 @@ To have instant notifications/better notification delivery when using firebase,
|
||||
1. In XCode, find the NTFY app target. **Not** the NSE app target.
|
||||
1. Find the Asset/ folder in the project navigator
|
||||
1. Drag the `GoogleService-Info.plist` file into the Asset/ folder that you get from the firebase console. It can be
|
||||
found in the "Project settings" > "General" > "Your apps" with a button labled "GoogleService-Info.plist"
|
||||
found in the "Project settings" > "General" > "Your apps" with a button labeled "GoogleService-Info.plist"
|
||||
|
||||
After that, you should be all set!
|
||||
|
||||
10
docs/faq.md
@@ -94,11 +94,11 @@ I would be humbled if you helped me carry the server and developer account costs
|
||||
appreciated.
|
||||
|
||||
## Can I email you? Can I DM you on Discord/Matrix?
|
||||
While I love chatting on [Discord](https://discord.gg/cT7ECsZj9w), [Matrix](https://matrix.to/#/#ntfy-space:matrix.org),
|
||||
[Lemmy](https://discuss.ntfy.sh/c/ntfy), or [GitHub](https://github.com/binwiederhier/ntfy/issues), I generally
|
||||
**do not respond to emails about ntfy or direct messages** about ntfy, unless you are paying for a
|
||||
[ntfy Pro](https://ntfy.sh/#pricing) plan, or you are inquiring about business opportunities.
|
||||
For community support, please use the public channels listed on the [contact page](contact.md). I generally
|
||||
**do not respond to direct messages** about ntfy, unless you are paying for a [ntfy Pro](https://ntfy.sh/#pricing)
|
||||
plan (see [paid support](contact.md#paid-support-ntfy-pro-subscribers)), or you are inquiring about business
|
||||
opportunities (see [general inquiries](contact.md#general-inquiries)).
|
||||
|
||||
I am sorry, but answering individual questions about ntfy on a 1-on-1 basis is not scalable. Answering your questions
|
||||
in the above-mentioned forums benefits others, since I can link to the discussion at a later point in time, or other users
|
||||
in public forums benefits others, since I can link to the discussion at a later point in time, or other users
|
||||
may be able to help out. I hope you understand.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,50 +30,56 @@ 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.15.0/ntfy_2.15.0_linux_amd64.tar.gz
|
||||
tar zxvf ntfy_2.15.0_linux_amd64.tar.gz
|
||||
sudo cp -a ntfy_2.15.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||
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.15.0/ntfy_2.15.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.15.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.15.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
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.15.0/ntfy_2.15.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.15.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.15.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
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.15.0/ntfy_2.15.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.15.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.15.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
## Debian/Ubuntu repository
|
||||
Installation via Debian repository:
|
||||
|
||||
!!! info
|
||||
As of September 2025, **the official ntfy.sh Debian/Ubuntu repository has moved to [archive.ntfy.sh](https://archive.ntfy.sh/apt)**.
|
||||
The old repository [archive.heckel.io](https://archive.heckel.io/apt) is still available for now, but will likely
|
||||
go away soon. I suspect I will phase it out some time in early 2026.
|
||||
|
||||
Installation via Debian/Ubuntu repository (fingerprint `55BA 774A 6F5E E674 31E4 6B7C CFDB 962D 4F1E C4AF`):
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://archive.heckel.io/apt/pubkey.txt | sudo gpg --dearmor -o /etc/apt/keyrings/archive.heckel.io.gpg
|
||||
sudo curl -L -o /etc/apt/keyrings/ntfy.gpg https://archive.ntfy.sh/apt/keyring.gpg
|
||||
sudo apt install apt-transport-https
|
||||
sudo sh -c "echo 'deb [arch=amd64 signed-by=/etc/apt/keyrings/archive.heckel.io.gpg] https://archive.heckel.io/apt debian main' \
|
||||
> /etc/apt/sources.list.d/archive.heckel.io.list"
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/ntfy.gpg] https://archive.ntfy.sh/apt stable main" \
|
||||
| sudo tee /etc/apt/sources.list.d/ntfy.list
|
||||
sudo apt update
|
||||
sudo apt install ntfy
|
||||
sudo systemctl enable ntfy
|
||||
@@ -83,10 +89,10 @@ Installation via Debian repository:
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://archive.heckel.io/apt/pubkey.txt | sudo gpg --dearmor -o /etc/apt/keyrings/archive.heckel.io.gpg
|
||||
sudo curl -L -o /etc/apt/keyrings/ntfy.gpg https://archive.ntfy.sh/apt/keyring.gpg
|
||||
sudo apt install apt-transport-https
|
||||
sudo sh -c "echo 'deb [arch=armhf signed-by=/etc/apt/keyrings/archive.heckel.io.gpg] https://archive.heckel.io/apt debian main' \
|
||||
> /etc/apt/sources.list.d/archive.heckel.io.list"
|
||||
echo "deb [arch=armhf signed-by=/etc/apt/keyrings/ntfy.gpg] https://archive.ntfy.sh/apt stable main" \
|
||||
| sudo tee /etc/apt/sources.list.d/ntfy.list
|
||||
sudo apt update
|
||||
sudo apt install ntfy
|
||||
sudo systemctl enable ntfy
|
||||
@@ -96,10 +102,10 @@ Installation via Debian repository:
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://archive.heckel.io/apt/pubkey.txt | sudo gpg --dearmor -o /etc/apt/keyrings/archive.heckel.io.gpg
|
||||
sudo curl -L -o /etc/apt/keyrings/ntfy.gpg https://archive.ntfy.sh/apt/keyring.gpg
|
||||
sudo apt install apt-transport-https
|
||||
sudo sh -c "echo 'deb [arch=arm64 signed-by=/etc/apt/keyrings/archive.heckel.io.gpg] https://archive.heckel.io/apt debian main' \
|
||||
> /etc/apt/sources.list.d/archive.heckel.io.list"
|
||||
echo "deb [arch=arm64 signed-by=/etc/apt/keyrings/ntfy.gpg] https://archive.ntfy.sh/apt stable main" \
|
||||
| sudo tee /etc/apt/sources.list.d/ntfy.list
|
||||
sudo apt update
|
||||
sudo apt install ntfy
|
||||
sudo systemctl enable ntfy
|
||||
@@ -110,7 +116,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.15.0/ntfy_2.15.0_linux_amd64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -118,7 +124,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.15.0/ntfy_2.15.0_linux_armv6.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -126,7 +132,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.15.0/ntfy_2.15.0_linux_armv7.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -134,7 +140,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.15.0/ntfy_2.15.0_linux_arm64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -144,28 +150,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.15.0/ntfy_2.15.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.15.0/ntfy_2.15.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.15.0/ntfy_2.15.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.15.0/ntfy_2.15.0_linux_arm64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
@@ -195,18 +201,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.15.0/ntfy_2.15.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.15.0/ntfy_2.15.0_darwin_all.tar.gz > ntfy_2.15.0_darwin_all.tar.gz
|
||||
tar zxvf ntfy_2.15.0_darwin_all.tar.gz
|
||||
sudo cp -a ntfy_2.15.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||
mkdir ~/Library/Application\ Support/ntfy
|
||||
cp ntfy_2.12.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
cp ntfy_2.15.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
ntfy --help
|
||||
```
|
||||
|
||||
@@ -221,10 +227,9 @@ simply run:
|
||||
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.15.0/ntfy_2.15.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 +285,6 @@ docker run \
|
||||
|
||||
Using docker-compose with non-root user and healthchecks enabled:
|
||||
```yaml
|
||||
version: "2.3"
|
||||
|
||||
services:
|
||||
ntfy:
|
||||
image: binwiederhier/ntfy
|
||||
@@ -303,6 +306,7 @@ services:
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
restart: unless-stopped
|
||||
init: true # needed, if healthcheck is used. Prevents zombie processes
|
||||
```
|
||||
|
||||
If using a non-root user when running the docker version, be sure to chown the server.yml, user.db, and cache.db files and attachments directory to the same uid/gid.
|
||||
@@ -321,7 +325,6 @@ The setup for Kubernetes is very similar to that for Docker, and requires a fair
|
||||
are a few options to mix and match, including a deployment without a cache file, a stateful set with a persistent cache, and a standalone
|
||||
unmanned pod.
|
||||
|
||||
|
||||
=== "deployment"
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
|
||||
@@ -42,6 +42,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
- [Monibot](https://monibot.io/) - Monibot monitors your websites, servers and applications and notifies you if something goes wrong.
|
||||
- [Miniflux](https://miniflux.app/docs/ntfy.html) - Minimalist and opinionated feed reader
|
||||
- [Beszel](https://beszel.dev/guide/notifications/ntfy) - Server monitoring platform
|
||||
- [Simple Observability](https://simpleobservability.com/docs/alerts/ntfy) - Server monitoring and observability platform
|
||||
|
||||
## Integration via HTTP/SMTP/etc.
|
||||
|
||||
@@ -95,6 +96,8 @@ 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
|
||||
- [Ntfy Desktop](https://github.com/emmaexe/ntfyDesktop) - Fully featured desktop client for Linux, built with Qt and C++.
|
||||
|
||||
## Projects + scripts
|
||||
|
||||
@@ -173,7 +176,12 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
- [ntfy-me-mcp](https://github.com/gitmotion/ntfy-me-mcp) - An ntfy MCP server for sending/fetching ntfy notifications to your self-hosted ntfy server from AI Agents (supports secure token auth & more - use with npx or docker!) (Node/Typescript)
|
||||
- [InvaderInformant](https://github.com/patricksthannon/InvaderInformant) - Script for Mac OS systems that monitors new or dropped connections to your network using ntfy (Shell)
|
||||
- [NtfyPwsh](https://github.com/ptmorris1/NtfyPwsh) - PowerShell module to help send messages to ntfy (PowerShell)
|
||||
- [ntfyrr](https://github.com/leukosaima/ntfyrr) - Currently an Overseerr webhook notification to ntfy helper service.
|
||||
- [ntfyrr](https://github.com/leukosaima/ntfyrr) - Overseerr and Maintainerr webhook notification to ntfy helper service (C#)
|
||||
- [ntfy for Sandstorm](https://apps.sandstorm.io/app/c6rk81r4qk6dm3k04x1kxmyccqewhh4npuxeyg1xrpfypn2ddy0h) - ntfy app for the Sandstorm platform
|
||||
- [ntfy-heartbeat-monitor](https://codeberg.org/RockWolf/ntfy-heartbeat-monitor) - Application for implementing heartbeat monitoring/alerting by utilizing ntfy
|
||||
- [ntfy-bridge](https://github.com/AlexGaudon/ntfy-bridge) - An application to bridge Discord messages (or webhooks) to ntfy.
|
||||
- [ntailfy](https://github.com/leukosaima/ntailfy) - ntfy notifications when Tailscale devices connect/disconnect (Go)
|
||||
- [BRun](https://github.com/cbrake/brun) - Native Linux automation platform connecting triggers to actions without containers (Go)
|
||||
|
||||
## Blog + forum posts
|
||||
|
||||
|
||||
198
docs/privacy.md
@@ -1,12 +1,194 @@
|
||||
# Privacy policy
|
||||
|
||||
I love free software, and I'm doing this because it's fun. I have no bad intentions, and **I will
|
||||
never monetize or sell your information, and this service and software will always stay free and open.**
|
||||
**Last updated:** January 2, 2026
|
||||
|
||||
Neither the server nor the app record any personal information, or share any of the messages and topics with
|
||||
any outside service. All data is exclusively used to make the service function properly. The only external service
|
||||
I use is Firebase Cloud Messaging (FCM) service, which is required to provide instant Android notifications (see
|
||||
[FAQ](faq.md) for details). To avoid FCM altogether, download the F-Droid version.
|
||||
This privacy policy describes how ntfy ("we", "us", or "our") collects, uses, and handles your information
|
||||
when you use the ntfy.sh service, web app, and mobile applications (Android and iOS).
|
||||
|
||||
For debugging purposes, the ntfy server may temporarily log request paths, remote IP addresses or even topics
|
||||
or messages, though typically this is turned off.
|
||||
## Our commitment to privacy
|
||||
|
||||
We love free software, and we're doing this because it's fun. We have no bad intentions, and **we will
|
||||
never monetize or sell your information**. The ntfy service and software will always stay free and open source.
|
||||
If you don't trust us or your messages are sensitive, you can [self-host your own ntfy server](install.md).
|
||||
|
||||
## Information we collect
|
||||
|
||||
### Account information (optional)
|
||||
|
||||
If you create an account on ntfy.sh, we collect:
|
||||
|
||||
- **Username** - A unique identifier you choose
|
||||
- **Password** - Stored as a secure bcrypt hash (we never store your plaintext password)
|
||||
- **Email address** - Only if you subscribe to a paid plan (for billing purposes)
|
||||
- **Phone number** - Only if you enable the phone call notification feature (verified via SMS/call)
|
||||
|
||||
You can use ntfy without creating an account. Anonymous usage is fully supported.
|
||||
|
||||
### Messages and notifications
|
||||
|
||||
- **Message content** - Messages you publish are temporarily cached on our servers (default: 12 hours) to support
|
||||
message polling and to overcome client network disruptions. Messages are deleted after the cache duration expires.
|
||||
- **Attachments** - File attachments are temporarily stored (default: 3 hours) and then automatically deleted.
|
||||
- **Topic names** - The topic names you publish to or subscribe to are processed by our servers.
|
||||
|
||||
### Technical information
|
||||
|
||||
- **IP addresses** - Used for rate limiting to prevent abuse. May be temporarily logged for debugging purposes,
|
||||
though this is typically turned off.
|
||||
- **Access tokens** - If you create access tokens, we store the token value, an optional label, last access time,
|
||||
and the IP address of the last access.
|
||||
- **Web push subscriptions** - If you enable browser notifications, we store your browser's push subscription
|
||||
endpoint to deliver notifications.
|
||||
|
||||
### Billing information (paid plans only)
|
||||
|
||||
If you subscribe to a paid plan, payment processing is handled by Stripe. We store:
|
||||
|
||||
- Stripe customer ID
|
||||
- Subscription status and billing period
|
||||
|
||||
We do not store your credit card numbers or payment details directly. These are handled entirely by Stripe.
|
||||
|
||||
## Third-party services
|
||||
|
||||
To provide the ntfy.sh service, we use the following third-party services:
|
||||
|
||||
### Firebase Cloud Messaging (FCM)
|
||||
|
||||
We use Google's Firebase Cloud Messaging to deliver push notifications to Android and iOS devices. When you
|
||||
receive a notification through the mobile apps (Google Play or App Store versions):
|
||||
|
||||
- Message metadata and content may be transmitted through Google's FCM infrastructure
|
||||
- Google's [privacy policy](https://policies.google.com/privacy) applies to their handling of this data
|
||||
|
||||
**To avoid FCM entirely:** Download the [F-Droid version](https://f-droid.org/en/packages/io.heckel.ntfy/) of
|
||||
the Android app and use a self-hosted server, or use the instant delivery feature with your own server.
|
||||
|
||||
### Twilio (phone calls)
|
||||
|
||||
If you use the phone call notification feature (`X-Call` header), we use Twilio to:
|
||||
|
||||
- Make voice calls to your verified phone number
|
||||
- Send SMS or voice calls for phone number verification
|
||||
|
||||
Your phone number is shared with Twilio to deliver these services. Twilio's
|
||||
[privacy policy](https://www.twilio.com/legal/privacy) applies.
|
||||
|
||||
### Amazon SES (email delivery)
|
||||
|
||||
If you use the email notification feature (`X-Email` header), we use Amazon Simple Email Service (SES) to
|
||||
deliver emails. The recipient email address and message content are transmitted through Amazon's infrastructure.
|
||||
Amazon's [privacy policy](https://aws.amazon.com/privacy/) applies.
|
||||
|
||||
### Stripe (payments)
|
||||
|
||||
If you subscribe to a paid plan, payments are processed by Stripe. Your payment information is handled directly
|
||||
by Stripe and is subject to Stripe's [privacy policy](https://stripe.com/privacy).
|
||||
|
||||
Note: We have explicitly disabled Stripe's telemetry features in our integration.
|
||||
|
||||
### Web push providers
|
||||
|
||||
If you enable browser notifications in the ntfy web app, push messages are delivered through your browser
|
||||
vendor's push service:
|
||||
|
||||
- Google (Chrome)
|
||||
- Mozilla (Firefox)
|
||||
- Apple (Safari)
|
||||
- Microsoft (Edge)
|
||||
|
||||
Your browser's push subscription endpoint is shared with these providers to deliver notifications.
|
||||
|
||||
## Mobile applications
|
||||
|
||||
### Android app
|
||||
|
||||
The Android app is available from two sources:
|
||||
|
||||
- **Google Play Store** - Uses Firebase Cloud Messaging for push notifications. Firebase Analytics is
|
||||
**explicitly disabled** in our app.
|
||||
- **F-Droid** - Does not include any Google services or Firebase. Uses a foreground service to maintain
|
||||
a direct connection to the server.
|
||||
|
||||
The Android app stores the following data locally on your device:
|
||||
|
||||
- Subscribed topics and their settings
|
||||
- Cached notifications
|
||||
- User credentials (if you add a server with authentication)
|
||||
- Application logs (for debugging, stored locally only)
|
||||
|
||||
### iOS app
|
||||
|
||||
The iOS app uses Firebase Cloud Messaging (via Apple Push Notification service) to deliver notifications.
|
||||
The app stores the following data locally on your device:
|
||||
|
||||
- Subscribed topics
|
||||
- Cached notifications
|
||||
- User credentials (if configured)
|
||||
|
||||
## Web application
|
||||
|
||||
The ntfy web app is a static website that stores all data locally in your browser:
|
||||
|
||||
- **IndexedDB** - Stores your subscriptions and cached notifications
|
||||
- **Local Storage** - Stores your preferences and session information
|
||||
|
||||
No cookies are used for tracking. The web app does not have a backend beyond the ntfy API.
|
||||
|
||||
## Data retention
|
||||
|
||||
| Data type | Retention period |
|
||||
|------------------------|---------------------------------------------------|
|
||||
| Messages | 12 hours (configurable by server operators) |
|
||||
| Attachments | 3 hours (configurable by server operators) |
|
||||
| User accounts | Until you delete your account |
|
||||
| Access tokens | Until you revoke them or delete your account |
|
||||
| Phone numbers | Until you remove them or delete your account |
|
||||
| Web push subscriptions | 60 days of inactivity, then automatically removed |
|
||||
| Server logs | Varies; debugging logs are typically temporary |
|
||||
|
||||
## Self-hosting
|
||||
|
||||
If you prefer complete control over your data, you can [self-host your own ntfy server](install.md).
|
||||
When self-hosting:
|
||||
|
||||
- You control all data storage and retention
|
||||
- You can choose whether to use Firebase, Twilio, email delivery, or any other integrations
|
||||
- No data is shared with ntfy.sh or any third party (unless you configure those integrations)
|
||||
|
||||
The server and all apps are fully open source:
|
||||
|
||||
- Server: [github.com/binwiederhier/ntfy](https://github.com/binwiederhier/ntfy)
|
||||
- Android app: [github.com/binwiederhier/ntfy-android](https://github.com/binwiederhier/ntfy-android)
|
||||
- iOS app: [github.com/binwiederhier/ntfy-ios](https://github.com/binwiederhier/ntfy-ios)
|
||||
|
||||
## Data security
|
||||
|
||||
- All connections to ntfy.sh are encrypted using TLS/HTTPS
|
||||
- Passwords are hashed using bcrypt before storage
|
||||
- Access tokens are generated using cryptographically secure random values
|
||||
- The server does not log message content by default
|
||||
|
||||
## Your rights
|
||||
|
||||
You have the right to:
|
||||
|
||||
- **Access** - View your account information and data
|
||||
- **Delete** - Delete your account and associated data via the web app
|
||||
- **Export** - Your messages are available via the API while cached
|
||||
|
||||
To delete your account, use the account settings in the web app or contact us.
|
||||
|
||||
## Changes to this policy
|
||||
|
||||
We may update this privacy policy from time to time. Changes will be posted on this page with an updated
|
||||
"Last updated" date. You may also review all changes in the [Git history](https://github.com/binwiederhier/ntfy/commits/main/docs/privacy.md).
|
||||
|
||||
For significant changes, we may provide additional notice on Discord/Matrix or through the
|
||||
[announcements](https://ntfy.sh/announcements) ntfy topic.
|
||||
|
||||
## Contact
|
||||
|
||||
For privacy-related inquiries, please email [privacy@mail.ntfy.sh](mailto:privacy@mail.ntfy.sh).
|
||||
|
||||
For all other contact options, see the [contact page](contact.md).
|
||||
|
||||
216
docs/publish.md
1507
docs/publish/template-functions.md
Normal file
195
docs/releases.md
@@ -2,7 +2,173 @@
|
||||
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.12.0
|
||||
## Current stable releases
|
||||
|
||||
| Component | Version | Release date |
|
||||
|------------------|---------|--------------|
|
||||
| ntfy server | v2.15.0 | Nov 16, 2025 |
|
||||
| ntfy Android app | v1.21.1 | Jan 6, 2025 |
|
||||
| ntfy iOS app | v1.3 | Nov 26, 2023 |
|
||||
|
||||
Please check out the release notes for [upcoming releases](#not-released-yet) below.
|
||||
|
||||
## ntfy Android app v1.21.1
|
||||
Released January 6, 2026
|
||||
|
||||
This is the first feature release in a long time. After all the SDK updates, fixes to comply with the Google Play policies
|
||||
and the framework updates, this release ships a lot of highly requested features: Sending messages through the app (WhatsApp-style),
|
||||
support for passing headers to your proxy, an in-app language switcher, and more.
|
||||
|
||||
If you are waiting for a feature, please 👍 the corresponding [GitHub issue](https://github.com/binwiederhier/ntfy/issues?q=is%3Aissue%20state%3Aopen%20sort%3Areactions-%2B1-desc).
|
||||
If you like ntfy, please consider purchasing [ntfy Pro](https://ntfy.sh/app) to support us.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Allow publishing messages through the message bar and publish dialog ([#98](https://github.com/binwiederhier/ntfy/issues/98), [ntfy-android#144](https://github.com/binwiederhier/ntfy-android/pull/144))
|
||||
* Define custom HTTP headers to support authenticated proxies, tunnels and SSO ([ntfy-android#116](https://github.com/binwiederhier/ntfy-android/issues/116), [#1018](https://github.com/binwiederhier/ntfy/issues/1018), [ntfy-android#132](https://github.com/binwiederhier/ntfy-android/pull/132), [ntfy-android#146](https://github.com/binwiederhier/ntfy-android/pull/146), thanks to [@CrazyWolf13](https://github.com/CrazyWolf13))
|
||||
* Implement UnifiedPush "raise to foreground" requirement ([ntfy-android#98](https://github.com/binwiederhier/ntfy-android/pull/98), [ntfy-android#148](https://github.com/binwiederhier/ntfy-android/pull/148), thanks to [@p1gp1g](https://github.com/p1gp1g))
|
||||
* Language selector to allow overriding the system language ([#1508](https://github.com/binwiederhier/ntfy/issues/1508), [ntfy-android#145](https://github.com/binwiederhier/ntfy-android/pull/145), thanks to [@hudsonm62](https://github.com/hudsonm62) for reporting)
|
||||
* Highlight phone numbers and email addresses in notifications ([#957](https://github.com/binwiederhier/ntfy/issues/957), [ntfy-android#71](https://github.com/binwiederhier/ntfy-android/pull/71), thanks to [@brennenputh](https://github.com/brennenputh), and [@XylenSky](https://github.com/XylenSky) for reporting)
|
||||
* Support for port and display name in [ntfy://](subscribe/phone.md#ntfy-links) links ([ntfy-android#130](https://github.com/binwiederhier/ntfy-android/pull/130), thanks to [@godovski](https://github.com/godovski))
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Add support for (technically incorrect) 'image/jpg' MIME type ([ntfy-android#142](https://github.com/binwiederhier/ntfy-android/pull/142), thanks to [@Murilobeluco](https://github.com/Murilobeluco))
|
||||
* Unify "copy to clipboard" notifications, use Android 13 style ([ntfy-android#61](https://github.com/binwiederhier/ntfy-android/pull/61), thanks to [@thgoebel](https://github.com/thgoebel))
|
||||
* Fix crash in user add dialog (onAddUser)
|
||||
* Fix ForegroundServiceDidNotStartInTimeException (attempt 2, see [#1520](https://github.com/binwiederhier/ntfy/issues/1520))
|
||||
* Hide "Exact alarms" setting if battery optimization exemption has been granted ([#1456](https://github.com/binwiederhier/ntfy/issues/1456), thanks for reporting [@HappyLer](https://github.com/HappyLer))
|
||||
|
||||
## ntfy Android app v1.20.0
|
||||
Released December 28, 2025
|
||||
|
||||
This is the last pure maintenance release for now. It'll bring all dependencies and library version to the latest version,
|
||||
and fixes some crashes. I had to drop support for about 4,000 devices (only ~200 installations), because the libraries
|
||||
themselves do not support SDK 21 anymore, which was the previous minimum SDK version (Android 5, 2014). Now the minimum
|
||||
SDK version is 26 (Android 8, 2017).
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Updated dependencies, minimum SDK version to 26, clean up legacy code, upgrade Gradle ([ntfy-android#140](https://github.com/binwiederhier/ntfy-android/pull/140),
|
||||
thanks to [@cyb3rko](https://github.com/cyb3rko) for the implementation)
|
||||
* Updated target SDK version to 36 (Android 8, 2017)
|
||||
* Fixed ForegroundServiceDidNotStartInTimeException ([#1520](https://github.com/binwiederhier/ntfy/issues/1520))
|
||||
* Fixed crashes with redrawing the list when temporarily muted topics expire
|
||||
|
||||
## ntfy Android app v1.19.4
|
||||
Released December 21, 2025
|
||||
|
||||
This release upgrades the Android app to use [Material 3](https://m3.material.io/) design components and adds the
|
||||
ability to use [dynamic colors](https://developer.android.com/develop/ui/views/theming/dynamic-colors).
|
||||
**This was a lot of work** and I want to thank [@Bnyro](https://github.com/Bnyro) and [@cyb3rko](https://github.com/cyb3rko) for implementing this. You guys rock!
|
||||
|
||||
**Features:**
|
||||
|
||||
* Moved the user interface to Material 3 and added dynamic color support ([#580](https://github.com/binwiederhier/ntfy/issues/580),
|
||||
[ntfy-android#56](https://github.com/binwiederhier/ntfy-android/pull/56), [ntfy-android#126](https://github.com/binwiederhier/ntfy-android/pull/126),
|
||||
[ntfy-android#135](https://github.com/binwiederhier/ntfy-android/pull/135), thanks to [@Bnyro](https://github.com/Bnyro)
|
||||
and [@cyb3rko](https://github.com/cyb3rko) for the implementation, and to [@RokeJulianLockhart](https://github.com/RokeJulianLockhart) for reporting)
|
||||
|
||||
## ntfy Android app v1.18.0
|
||||
Released December 4, 2025
|
||||
|
||||
**Features:**
|
||||
|
||||
* Added GIF support for preview images ([ntfy-android#76](https://github.com/binwiederhier/ntfy-android/pull/76)/[#532](https://github.com/binwiederhier/ntfy/issues/532), thanks to [@MichaelArkh](https://github.com/MichaelArkh) and [@dimatx](https://github.com/dimatx) for reporting)
|
||||
* Added WebP support for preview images ([ntfy-android#81](https://github.com/binwiederhier/ntfy-android/pull/81)/[ntfy-android#80](https://github.com/binwiederhier/ntfy-android/issues/80), thanks to [@jokakilla](https://github.com/jokakilla))
|
||||
* Added UnifiedPush distributor selection support ([#137](https://github.com/binwiederhier/ntfy-android/pull/137), thanks to [@p1gp1g](https://github.com/p1gp1g))
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Remove REQUEST_INSTALL_PACKAGES permission ([#684](https://github.com/binwiederhier/ntfy/issues/684))
|
||||
* Request to ignore battery optimizations before receiving subscription ([ntfy-android#97](https://github.com/binwiederhier/ntfy-android/pull/97), thanks to [@p1gp1g](https://github.com/p1gp1g))
|
||||
|
||||
## ntfy server v2.15.0
|
||||
Released Nov 16, 2025
|
||||
|
||||
This release adds a `require-login` flag to topics, which forces users to log in before they can
|
||||
use the web app. This is useful for self-hosters and will obviously not be enabled on ntfy.sh.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Add `require-login` flag to redirect to login page if not logged in ([#1434](https://github.com/binwiederhier/ntfy/pull/1434)/[#238](https://github.com/binwiederhier/ntfy/issues/238)/[#1329](https://github.com/binwiederhier/ntfy/pull/1329), thanks to [@theatischbein](https://github.com/theatischbein) for implementing most of this)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* The official ntfy.sh Debian/Ubuntu repository has moved to [archive.ntfy.sh](https://archive.ntfy.sh) ([#1357](https://github.com/binwiederhier/ntfy/issues/1357)/[#1401](https://github.com/binwiederhier/ntfy/issues/1401), thanks to [@skibbipl](https://github.com/skibbipl) and [@lduesing](https://github.com/lduesing) for reporting)
|
||||
* Add mutex around message cache writes to avoid `database locked` errors ([#1397](https://github.com/binwiederhier/ntfy/pull/1397), [#1391](https://github.com/binwiederhier/ntfy/issues/1391), thanks to [@timofej673](https://github.com/timofej673))
|
||||
* Add build tags `nopayments`, `nofirebase` and `nowebpush` to allow excluding external dependencies, useful for
|
||||
packaging in Debian ([#1420](https://github.com/binwiederhier/ntfy/pull/1420), discussion in [#1258](https://github.com/binwiederhier/ntfy/issues/1258), thanks to [@thekhalifa](https://github.com/thekhalifa) for packaging ntfy for Debian/Ubuntu)
|
||||
* Make copying tokens, phone numbers, etc. possible on HTTP ([#1432](https://github.com/binwiederhier/ntfy/pull/1432)/[#1408](https://github.com/binwiederhier/ntfy/issues/1408)/[#1295](https://github.com/binwiederhier/ntfy/issues/1295), thanks to [@EdwinKM](https://github.com/EdwinKM), [@xxl6097](https://github.com/xxl6097) for reporting)
|
||||
|
||||
## ntfy Android app v1.17.13
|
||||
Released October 21, 2025
|
||||
|
||||
This release makes changes to comply with the Google Play policies. See [#1463](https://github.com/binwiederhier/ntfy/issues/1463)
|
||||
or [ef57cd1](https://github.com/binwiederhier/ntfy-android/commit/ef57cd1374118b3e4d7a7ab496afe337e714fff7) for details.
|
||||
|
||||
The policies do not allow directly or indirectly linking to paid plans or donation links that do not go through Google Play.
|
||||
|
||||
**Changes:**
|
||||
|
||||
* Remove the "Donate" button from menu (all variants)
|
||||
* Change default display name from "ntfy.sh/mytopic" to "mytopic" (all variants)
|
||||
* Remove links to ntfy docs and issue tracker (Play variant only)
|
||||
* Remove how-to links to ntfy.sh in a few places (Play variant only)
|
||||
* Remove "Copy topic address" from subscription menu (Play variant only)
|
||||
|
||||
## ntfy Android app v1.17.8
|
||||
Released September 23, 2025
|
||||
|
||||
This is largely a maintenance update to ensure the SDK is up-to-date.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Markdown is now rendered if "Markdown: yes" was passed ([#310](https://github.com/binwiederhier/ntfy/issues/310), thanks to [@NiNiyas](https://github.com/NiNiyas) for reporting)
|
||||
* You can now disable UnifiedPush so ntfy does not act as a UnifiedPush distributor ([#646](https://github.com/binwiederhier/ntfy/issues/646), thanks to [@ollien](https://github.com/ollien) for reporting and to [@wunter8](https://github.com/wunter8) for implementing)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* UnifiedPush subscriptions now include the `Rate-Topics` header to facilitate subscriber-based billing ([#652](https://github.com/binwiederhier/ntfy/issues/652), thanks to [@wunter8](https://github.com/wunter8))
|
||||
* Subscriptions without icons no longer appear to use another subscription's icon ([#634](https://github.com/binwiederhier/ntfy/issues/634), thanks to [@topcaser](https://github.com/topcaser) for reporting and to [@wunter8](https://github.com/wunter8) for fixing)
|
||||
* Bumped all dependencies to the latest versions (no ticket)
|
||||
|
||||
## ntfy server v2.14.0
|
||||
Released August 5, 2025
|
||||
|
||||
This release adds support for [declarative users](config.md#users-via-the-config), [declarative ACL entries](config.md#acl-entries-via-the-config) and [declarative tokens](config.md#tokens-via-the-config). This allows you to define users, ACL entries and tokens in the config file, which is useful for static deployments or deployments that use a configuration management system.
|
||||
|
||||
It also adds support for [pre-defined templates](publish.md#pre-defined-templates) and [custom templates](publish.md#custom-templates) for enhanced JSON webhook support, as well as advanced [template functions](publish.md#template-functions) based on the [Sprig](https://github.com/Masterminds/sprig) functions.
|
||||
|
||||
❤️ If you like ntfy, **please consider sponsoring me** via [GitHub Sponsors](https://github.com/sponsors/binwiederhier), [Liberapay](https://en.liberapay.com/ntfy/), Bitcoin (`1626wjrw3uWk9adyjCfYwafw4sQWujyjn8`), or by buying a [paid plan via the web app](https://ntfy.sh/app). ntfy
|
||||
will always remain open source.
|
||||
|
||||
**Features:**
|
||||
|
||||
* [Declarative users](config.md#users-via-the-config), [declarative ACL entries](config.md#acl-entries-via-the-config) and [declarative tokens](config.md#tokens-via-the-config) ([#464](https://github.com/binwiederhier/ntfy/issues/464), [#1384](https://github.com/binwiederhier/ntfy/pull/1384), [#1413](https://github.com/binwiederhier/ntfy/pull/1413), thanks to [pinpox](https://github.com/pinpox) for reporting, to [@wunter8](https://github.com/wunter8) for reviewing and implementing parts of it)
|
||||
* [Pre-defined templates](publish.md#pre-defined-templates) and [custom templates](publish.md#custom-templates) for enhanced JSON webhook support ([#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 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
|
||||
|
||||
This is mainly a maintenance release that updates dependencies, though since it's been over a year, there are a few
|
||||
@@ -62,7 +228,7 @@ user support in Discord/Matrix/GitHub! You rock, man!
|
||||
* Update new languages from Weblate. Thanks to all the contributors!
|
||||
* Added Tamil (தமிழ்) as a new language to the web app
|
||||
|
||||
### ntfy server v2.11.0
|
||||
## ntfy server v2.11.0
|
||||
Released May 13, 2024
|
||||
|
||||
This is a tiny release that fixes a database index issue that caused performance issues on ntfy.sh. It also fixes a bug
|
||||
@@ -77,7 +243,7 @@ and [Liberapay](https://en.liberapay.com/ntfy/), or buying a [paid plan via the
|
||||
* Do not set rate visitor for non-eligible topics (no ticket)
|
||||
* Do not cache `config.js` ([#1098](https://github.com/binwiederhier/ntfy/pull/1098), thanks to [@wunter8](https://github.com/wunter8))
|
||||
|
||||
### ntfy server v2.10.0
|
||||
## ntfy server v2.10.0
|
||||
Released Mar 27, 2024
|
||||
|
||||
This release adds support for **message templating** in the ntfy server, which allows you to include a message and/or
|
||||
@@ -88,7 +254,7 @@ This is great for services that let you specify a webhook URL but do not let you
|
||||
|
||||
* [Message templating](publish.md#message-templating): You can now include a message and/or title template that will be filled with values from a JSON body ([#724](https://github.com/binwiederhier/ntfy/issues/724), thanks to [@wunter8](https://github.com/wunter8) for implementing)
|
||||
|
||||
### ntfy server v2.9.0
|
||||
## ntfy server v2.9.0
|
||||
Released Mar 7, 2024
|
||||
|
||||
A small release after a long pause (lots of day job work). This release adds for **larger messages** and **longer
|
||||
@@ -1433,24 +1599,15 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
## Not released yet
|
||||
|
||||
### ntfy server v2.13.0 (UNRELEASED)
|
||||
### ntfy Android app v1.22.x (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))
|
||||
|
||||
### ntfy Android app v1.16.1 (UNRELEASED)
|
||||
|
||||
**Features:**
|
||||
|
||||
* You can now disable UnifiedPush so ntfy does not act as a UnifiedPush distributor ([#646](https://github.com/binwiederhier/ntfy/issues/646), thanks to [@ollien](https://github.com/ollien) for reporting and to [@wunter8](https://github.com/wunter8) for implementing)
|
||||
* Support for self-signed certs and client certs for mTLS (#215, #530, ntfy-android#149)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* UnifiedPush subscriptions now include the `Rate-Topics` header to facilitate subscriber-based billing ([#652](https://github.com/binwiederhier/ntfy/issues/652), thanks to [@wunter8](https://github.com/wunter8))
|
||||
* Subscriptions without icons no longer appear to use another subscription's icon ([#634](https://github.com/binwiederhier/ntfy/issues/634), thanks to [@topcaser](https://github.com/topcaser) for reporting and to [@wunter8](https://github.com/wunter8) for fixing)
|
||||
* Bumped all dependencies to the latest versions (no ticket)
|
||||
|
||||
**Additional languages:**
|
||||
|
||||
* Swedish (thanks to [@hellbown](https://hosted.weblate.org/user/hellbown/))
|
||||
* Use server-specific user for attachment downloads (#1529, thanks to @ManInDark for reporting)
|
||||
* Fix crash in sharing dialog (thanks to @rogeliodh)
|
||||
* Fix crash when exiting multi-delete in detail view
|
||||
* Fix potential crashes with icon downloader and backuper
|
||||
|
||||
BIN
docs/static/img/android-screenshot-template-custom.png
vendored
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
docs/static/img/android-screenshot-template-predefined.png
vendored
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
docs/static/img/badge-appstore.png
vendored
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 24 KiB |
BIN
docs/static/img/badge-fdroid.png
vendored
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 17 KiB |
BIN
docs/static/img/badge-googleplay.png
vendored
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 4.6 KiB |
BIN
docs/static/img/screenshot-github-webhook-config.png
vendored
Normal file
|
After Width: | Height: | Size: 96 KiB |
@@ -295,7 +295,7 @@ Available filters (all case-insensitive):
|
||||
| `message` | `X-Message`, `m` | `ntfy.sh/mytopic/json?message=lalala` | Only return messages that match this exact message string |
|
||||
| `title` | `X-Title`, `t` | `ntfy.sh/mytopic/json?title=some+title` | Only return messages that match this exact title string |
|
||||
| `priority` | `X-Priority`, `prio`, `p` | `ntfy.sh/mytopic/json?p=high,urgent` | Only return messages that match *any priority listed* (comma-separated) |
|
||||
| `tags` | `X-Tags`, `tag`, `ta` | `ntfy.sh/mytopic?/jsontags=error,alert` | Only return messages that match *all listed tags* (comma-separated) |
|
||||
| `tags` | `X-Tags`, `tag`, `ta` | `ntfy.sh/mytopic/json?tags=error,alert` | Only return messages that match *all listed tags* (comma-separated) |
|
||||
|
||||
### Subscribe to multiple topics
|
||||
It's possible to subscribe to multiple topics in one HTTP call by providing a comma-separated list of topics
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,17 +4,22 @@ 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
|
||||
the F-Droid flavor does not use Firebase. The iOS app can be downloaded from the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).
|
||||
You can get the Android app from [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy),
|
||||
[F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/), or via the APKs from [GitHub Releases](https://github.com/binwiederhier/ntfy-android/releases).
|
||||
The Google Play and F-Droid releases are largely identical, with the one exception that the F-Droid flavor does not use Firebase.
|
||||
The iOS app can be downloaded from the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).
|
||||
|
||||
Alternatively, you may also want to consider using the **[progressive web app (PWA)](pwa.md)** instead of the native app.
|
||||
The PWA is a website that you can add to your home screen, and it will behave just like a native app.
|
||||
|
||||
If you're downloading the APKs from [GitHub](https://github.com/binwiederhier/ntfy-android/releases), they are signed with
|
||||
a certificate with the following SHA-256 fingerprint: `6e145d7ae685eff75468e5067e03a6c3645453343e4e181dac8b6b17ff67489d`.
|
||||
You can also query the DNS TXT records for `ntfy.sh` to find this fingerprint.
|
||||
|
||||
## Overview
|
||||
A picture is worth a thousand words. Here are a few screenshots showing what the app looks like. It's all pretty
|
||||
straight forward. You can add topics and as soon as you add them, you can [publish messages](../publish.md) to them.
|
||||
@@ -95,7 +100,7 @@ The reason for this is [Firebase Cloud Messaging (FCM)](https://firebase.google.
|
||||
notifications. Firebase is overall pretty bad at delivering messages in time, but on Android, most apps are stuck with it.
|
||||
|
||||
The ntfy Android app uses Firebase only for the main host `ntfy.sh`, and only in the Google Play flavor of the app.
|
||||
It won't use Firebase for any self-hosted servers, and not at all in the the F-Droid flavor.
|
||||
It won't use Firebase for any self-hosted servers, and not at all in the F-Droid flavor.
|
||||
|
||||
## Share to topic
|
||||
_Supported on:_ :material-android:
|
||||
@@ -124,10 +129,11 @@ or to simply directly link to a topic from a mobile website.
|
||||
|
||||
**Supported link formats:**
|
||||
|
||||
| Link format | Example | Description |
|
||||
|-------------------------------------------------------------------------------|-------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| <span style="white-space: nowrap">`ntfy://<host>/<topic>`</span> | `ntfy://ntfy.sh/mytopic` | Directly opens the Android app detail view for the given topic and server. Subscribes to the topic if not already subscribed. This is equivalent to the web view `https://ntfy.sh/mytopic` (HTTPS!) |
|
||||
| <span style="white-space: nowrap">`ntfy://<host>/<topic>?secure=false`</span> | `ntfy://example.com/mytopic?secure=false` | Same as above, except that this will use HTTP instead of HTTPS as topic URL. This is equivalent to the web view `http://example.com/mytopic` (HTTP!) |
|
||||
| Link format | Example | Description |
|
||||
|---------------------------------------------------------------------------------|-------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| <span style="white-space: nowrap">`ntfy://<host>/<topic>`</span> | `ntfy://ntfy.sh/mytopic` | Directly opens the Android app detail view for the given topic and server. Subscribes to the topic if not already subscribed. This is equivalent to the web view `https://ntfy.sh/mytopic` (HTTPS!) |
|
||||
| <span style="white-space: nowrap">`ntfy://<host>/<topic>?display=<name>`</span> | `ntfy://ntfy.sh/mytopic?display=My+Topic` | Same as above, but also defines a display name for the topic. |
|
||||
| <span style="white-space: nowrap">`ntfy://<host>/<topic>?secure=false`</span> | `ntfy://example.com/mytopic?secure=false` | Same as above, except that this will use HTTP instead of HTTPS as topic URL. This is equivalent to the web view `http://example.com/mytopic` (HTTP!) |
|
||||
|
||||
## Integrations
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Troubleshooting
|
||||
This page lists a few suggestions of what to do when things don't work as expected. This is not a complete list.
|
||||
If this page does not help, feel free to drop by the [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org)
|
||||
and ask there. We're happy to help.
|
||||
If this page does not help, feel free to reach out via one of the channels listed on the [contact page](contact.md).
|
||||
We're happy to help.
|
||||
|
||||
## ntfy server
|
||||
If you host your own ntfy server, and you're having issues with any component, it is always helpful to enable debugging/tracing
|
||||
|
||||
112
go.mod
@@ -1,27 +1,27 @@
|
||||
module heckel.io/ntfy/v2
|
||||
|
||||
go 1.24
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.0
|
||||
toolchain go1.24.5
|
||||
|
||||
require (
|
||||
cloud.google.com/go/firestore v1.18.0 // indirect
|
||||
cloud.google.com/go/storage v1.55.0 // indirect
|
||||
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||
cloud.google.com/go/firestore v1.20.0 // indirect
|
||||
cloud.google.com/go/storage v1.59.0 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/emersion/go-smtp v0.18.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.9
|
||||
github.com/gabriel-vasile/mimetype v1.4.12
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/mattn/go-sqlite3 v1.14.28
|
||||
github.com/mattn/go-sqlite3 v1.14.33
|
||||
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
|
||||
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
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
golang.org/x/crypto v0.46.0
|
||||
golang.org/x/oauth2 v0.34.0 // indirect
|
||||
golang.org/x/sync v0.19.0
|
||||
golang.org/x/term v0.39.0
|
||||
golang.org/x/time v0.14.0
|
||||
google.golang.org/api v0.259.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
@@ -30,75 +30,75 @@ 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.18.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/prometheus/client_golang v1.23.2
|
||||
github.com/stripe/stripe-go/v74 v74.30.0
|
||||
golang.org/x/text v0.33.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
|
||||
cel.dev/expr v0.25.1 // indirect
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
cloud.google.com/go/auth v0.18.0 // 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
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
cloud.google.com/go/iam v1.5.3 // indirect
|
||||
cloud.google.com/go/longrunning v0.8.0 // indirect
|
||||
cloud.google.com/go/monitoring v1.24.3 // indirect
|
||||
github.com/AlekSi/pointer v1.2.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.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.30.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.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
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.0 // 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.3 // 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.3.0 // 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/enterprise-certificate-proxy v0.3.9 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.64.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // 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
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect
|
||||
go.opentelemetry.io/otel v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sys v0.40.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/protobuf v1.36.6 // indirect
|
||||
google.golang.org/genproto v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||
google.golang.org/grpc v1.78.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
271
go.sum
@@ -1,41 +1,45 @@
|
||||
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=
|
||||
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
||||
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0=
|
||||
cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo=
|
||||
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=
|
||||
cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
|
||||
cloud.google.com/go/firestore v1.18.0 h1:cuydCaLS7Vl2SatAeivXyhbhDEIR8BDmtn4egDhIn2s=
|
||||
cloud.google.com/go/firestore v1.18.0/go.mod h1:5ye0v48PhseZBdcl0qbl3uttu7FIEwEYVaWm0UIEOEU=
|
||||
cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8=
|
||||
cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE=
|
||||
cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc=
|
||||
cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA=
|
||||
cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE=
|
||||
cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY=
|
||||
cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM=
|
||||
cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U=
|
||||
cloud.google.com/go/storage v1.55.0 h1:NESjdAToN9u1tmhVqhXCaCwYBuvEhZLLv0gBr+2znf0=
|
||||
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=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
cloud.google.com/go/firestore v1.20.0 h1:JLlT12QP0fM2SJirKVyu2spBCO8leElaW0OOtPm6HEo=
|
||||
cloud.google.com/go/firestore v1.20.0/go.mod h1:jqu4yKdBmDN5srneWzx3HlKrHFWFdlkgjgQ6BKIOFQo=
|
||||
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
|
||||
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
|
||||
cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY=
|
||||
cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw=
|
||||
cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E=
|
||||
cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY=
|
||||
cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8=
|
||||
cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk=
|
||||
cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
|
||||
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
|
||||
cloud.google.com/go/storage v1.58.0 h1:PflFXlmFJjG/nBeR9B7pKddLQWaFaRWx4uUi/LyNxxo=
|
||||
cloud.google.com/go/storage v1.58.0/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI=
|
||||
cloud.google.com/go/storage v1.59.0 h1:9p3yDzEN9Vet4JnbN90FECIw6n4FCXcKBK1scxtQnw8=
|
||||
cloud.google.com/go/storage v1.59.0/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI=
|
||||
cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
|
||||
cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
|
||||
firebase.google.com/go/v4 v4.18.0 h1:S+g0P72oDGqOaG4wlLErX3zQmU9plVdu7j+Bc3R1qFw=
|
||||
firebase.google.com/go/v4 v4.18.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs=
|
||||
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/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0/go.mod h1:l9rva3ApbBpEJxSNYnwT9N4CDLrWgtq3u8736C5hyJw=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0 h1:xfK3bbi6F2RDtaZFtUdKO3osOBIhNb+xTs8lFW6yx9o=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
|
||||
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=
|
||||
@@ -46,8 +50,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls=
|
||||
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -58,20 +62,20 @@ github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-smtp v0.17.0 h1:tq90evlrcyqRfE6DSXaWVH54oX6OuZOQECEmhWBMEtI=
|
||||
github.com/emersion/go-smtp v0.17.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
||||
github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
|
||||
github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
|
||||
github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM=
|
||||
github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329/go.mod h1:Alz8LEClvR7xKsrq3qzoc4N0guvVNSS8KmSChGYr9hs=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
|
||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
|
||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
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/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
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 +85,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.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
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=
|
||||
@@ -96,10 +100,12 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.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/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.9 h1:TOpi/QG8iDcZlkQlGlFUti/ZtyLkliXvHDcyUIMuFrU=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.9/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||
github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y=
|
||||
github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
@@ -112,8 +118,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
||||
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
||||
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
@@ -127,63 +135,67 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
||||
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.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/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/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
|
||||
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE=
|
||||
github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
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/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/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 h1:RN3ifU8y4prNWeEnQp2kRRHz8UwonAEYZl8tUzHEXAk=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0/go.mod h1:habDz3tEWiFANTo6oUE99EmaFUrCNYAAg3wiVmusm70=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
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.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
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,10 +210,10 @@ 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/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -209,8 +221,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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
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 +235,10 @@ 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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -234,8 +248,10 @@ 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.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -247,10 +263,12 @@ 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.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
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,22 +277,35 @@ 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=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.258.0 h1:IKo1j5FBlN74fe5isA2PVozN3Y5pwNKriEgAXPOkDAc=
|
||||
google.golang.org/api v0.258.0/go.mod h1:qhOMTQEZ6lUps63ZNq9jhODswwjkjYYguA7fA3TBFww=
|
||||
google.golang.org/api v0.259.0 h1:90TaGVIxScrh1Vn/XI2426kRpBqHwWIzVBzJsVZ5XrQ=
|
||||
google.golang.org/api v0.259.0/go.mod h1:LC2ISWGWbRoyQVpxGntWwLWN/vLNxxKBK9KuJRI8Te4=
|
||||
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
|
||||
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
|
||||
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
|
||||
google.golang.org/genproto v0.0.0-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-20251213004720-97cd9d5aeac2 h1:stRtB2UVzFOWnorVuwF0BVVEjQ3AN6SjHWdg811UIQM=
|
||||
google.golang.org/genproto v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0=
|
||||
google.golang.org/genproto v0.0.0-20251222181119-0a764e51fe1b h1:kqShdsddZrS6q+DGBCA73CzHsKDu5vW4qw78tFnbVvY=
|
||||
google.golang.org/genproto v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:gw1DtiPCt5uh/HV9STVEeaO00S5ATsJiJ2LsZV8lcDI=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 h1:7LRqPCEdE4TP4/9psdaB7F2nhZFfBiGJomA5sojLWdU=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -94,10 +94,13 @@ 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
|
||||
- "Development": develop.md
|
||||
- "Contributing": contributing.md
|
||||
- "Privacy policy": privacy.md
|
||||
- "Contact": contact.md
|
||||
|
||||
|
||||
|
||||
21
payments/payments.go
Normal file
@@ -0,0 +1,21 @@
|
||||
//go:build !nopayments
|
||||
|
||||
package payments
|
||||
|
||||
import "github.com/stripe/stripe-go/v74"
|
||||
|
||||
// Available is a constant used to indicate that Stripe support is available.
|
||||
// It can be disabled with the 'nopayments' build tag.
|
||||
const Available = true
|
||||
|
||||
// SubscriptionStatus is an alias for stripe.SubscriptionStatus
|
||||
type SubscriptionStatus stripe.SubscriptionStatus
|
||||
|
||||
// PriceRecurringInterval is an alias for stripe.PriceRecurringInterval
|
||||
type PriceRecurringInterval stripe.PriceRecurringInterval
|
||||
|
||||
// Setup sets the Stripe secret key and disables telemetry
|
||||
func Setup(stripeSecretKey string) {
|
||||
stripe.EnableTelemetry = false // Whoa!
|
||||
stripe.Key = stripeSecretKey
|
||||
}
|
||||
18
payments/payments_dummy.go
Normal file
@@ -0,0 +1,18 @@
|
||||
//go:build nopayments
|
||||
|
||||
package payments
|
||||
|
||||
// Available is a constant used to indicate that Stripe support is available.
|
||||
// It can be disabled with the 'nopayments' build tag.
|
||||
const Available = false
|
||||
|
||||
// SubscriptionStatus is a dummy type
|
||||
type SubscriptionStatus string
|
||||
|
||||
// PriceRecurringInterval is dummy type
|
||||
type PriceRecurringInterval string
|
||||
|
||||
// Setup is a dummy type
|
||||
func Setup(stripeSecretKey string) {
|
||||
// Nothing to see here
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
// Defines default config settings (excluding limits, see below)
|
||||
const (
|
||||
DefaultListenHTTP = ":80"
|
||||
DefaultConfigFile = "/etc/ntfy/server.yml"
|
||||
DefaultTemplateDir = "/etc/ntfy/templates"
|
||||
DefaultCacheDuration = 12 * time.Hour
|
||||
DefaultCacheBatchTimeout = time.Duration(0)
|
||||
DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!)
|
||||
@@ -61,6 +63,8 @@ const (
|
||||
DefaultVisitorAuthFailureLimitReplenish = time.Minute
|
||||
DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB
|
||||
DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB
|
||||
DefaultVisitorPrefixBitsIPv4 = 32 // Use the entire IPv4 address for rate limiting
|
||||
DefaultVisitorPrefixBitsIPv6 = 64 // Use /64 for IPv6 rate limiting
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -91,12 +95,16 @@ type Config struct {
|
||||
AuthFile string
|
||||
AuthStartupQueries string
|
||||
AuthDefault user.Permission
|
||||
AuthUsers []*user.User
|
||||
AuthAccess map[string][]*user.Grant
|
||||
AuthTokens map[string][]*user.Token
|
||||
AuthBcryptCost int
|
||||
AuthStatsQueueWriterInterval time.Duration
|
||||
AttachmentCacheDir string
|
||||
AttachmentTotalSizeLimit int64
|
||||
AttachmentFileSizeLimit int64
|
||||
AttachmentExpiryDuration time.Duration
|
||||
TemplateDir string // Directory to load named templates from
|
||||
KeepaliveInterval time.Duration
|
||||
ManagerInterval time.Duration
|
||||
DisallowedTopics []string
|
||||
@@ -133,7 +141,7 @@ type Config struct {
|
||||
VisitorAttachmentDailyBandwidthLimit int64
|
||||
VisitorRequestLimitBurst int
|
||||
VisitorRequestLimitReplenish time.Duration
|
||||
VisitorRequestExemptIPAddrs []netip.Prefix
|
||||
VisitorRequestExemptPrefixes []netip.Prefix
|
||||
VisitorMessageDailyLimit int
|
||||
VisitorEmailLimitBurst int
|
||||
VisitorEmailLimitReplenish time.Duration
|
||||
@@ -141,21 +149,23 @@ 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
|
||||
BillingContact string
|
||||
EnableSignup bool // Enable creation of accounts via API and UI
|
||||
EnableLogin bool
|
||||
RequireLogin bool
|
||||
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 +173,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 +202,7 @@ func NewConfig() *Config {
|
||||
AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit,
|
||||
AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit,
|
||||
AttachmentExpiryDuration: DefaultAttachmentExpiryDuration,
|
||||
TemplateDir: DefaultTemplateDir,
|
||||
KeepaliveInterval: DefaultKeepaliveInterval,
|
||||
ManagerInterval: DefaultManagerInterval,
|
||||
DisallowedTopics: DefaultDisallowedTopics,
|
||||
@@ -220,11 +232,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 +246,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,
|
||||
@@ -243,6 +257,7 @@ func NewConfig() *Config {
|
||||
EnableSignup: false,
|
||||
EnableLogin: false,
|
||||
EnableReservations: false,
|
||||
RequireLogin: false,
|
||||
AccessControlAllowOrigin: "*",
|
||||
Version: "",
|
||||
WebPushPrivateKey: "",
|
||||
|
||||
@@ -123,6 +123,8 @@ var (
|
||||
errHTTPBadRequestTemplateDisallowedFunctionCalls = &errHTTP{40044, http.StatusBadRequest, "invalid request: template contains disallowed function calls, e.g. template, call, or define", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPBadRequestTemplateExecuteFailed = &errHTTP{40045, http.StatusBadRequest, "invalid request: template execution failed", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil}
|
||||
errHTTPBadRequestTemplateFileNotFound = &errHTTP{40047, http.StatusBadRequest, "invalid request: template file not found", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
|
||||
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||
@@ -130,6 +132,8 @@ var (
|
||||
errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", "", nil}
|
||||
errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", "", nil}
|
||||
errHTTPConflictPhoneNumberExists = &errHTTP{40904, http.StatusConflict, "conflict: phone number already exists", "", nil}
|
||||
errHTTPConflictProvisionedUserChange = &errHTTP{40905, http.StatusConflict, "conflict: cannot change or delete provisioned user", "", nil}
|
||||
errHTTPConflictProvisionedTokenChange = &errHTTP{40906, http.StatusConflict, "conflict: cannot change or delete provisioned token", "", nil}
|
||||
errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil}
|
||||
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||
@@ -35,7 +37,7 @@ const (
|
||||
priority INT NOT NULL,
|
||||
tags TEXT NOT NULL,
|
||||
click TEXT NOT NULL,
|
||||
icon TEXT NOT NULL,
|
||||
icon TEXT NOT NULL,
|
||||
actions TEXT NOT NULL,
|
||||
attachment_name TEXT NOT NULL,
|
||||
attachment_type TEXT NOT NULL,
|
||||
@@ -72,30 +74,30 @@ const (
|
||||
selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
|
||||
selectMessagesByIDQuery = `
|
||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
FROM messages
|
||||
FROM messages
|
||||
WHERE mid = ?
|
||||
`
|
||||
selectMessagesSinceTimeQuery = `
|
||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
FROM messages
|
||||
FROM messages
|
||||
WHERE topic = ? AND time >= ? AND published = 1
|
||||
ORDER BY time, id
|
||||
`
|
||||
selectMessagesSinceTimeIncludeScheduledQuery = `
|
||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
FROM messages
|
||||
FROM messages
|
||||
WHERE topic = ? AND time >= ?
|
||||
ORDER BY time, id
|
||||
`
|
||||
selectMessagesSinceIDQuery = `
|
||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
FROM messages
|
||||
FROM messages
|
||||
WHERE topic = ? AND id > ? AND published = 1
|
||||
ORDER BY time, id
|
||||
`
|
||||
selectMessagesSinceIDIncludeScheduledQuery = `
|
||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
FROM messages
|
||||
FROM messages
|
||||
WHERE topic = ? AND (id > ? OR published = 0)
|
||||
ORDER BY time, id
|
||||
`
|
||||
@@ -105,10 +107,10 @@ const (
|
||||
WHERE topic = ? AND published = 1
|
||||
ORDER BY time DESC, id DESC
|
||||
LIMIT 1
|
||||
`
|
||||
`
|
||||
selectMessagesDueQuery = `
|
||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
FROM messages
|
||||
FROM messages
|
||||
WHERE time <= ? AND published = 0
|
||||
ORDER BY time, id
|
||||
`
|
||||
@@ -282,10 +284,17 @@ type messageCache struct {
|
||||
db *sql.DB
|
||||
queue *util.BatchingQueue[*message]
|
||||
nop bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// newSqliteCache creates a SQLite file-backed cache
|
||||
func newSqliteCache(filename, startupQueries string, cacheDuration time.Duration, batchSize int, batchTimeout time.Duration, nop bool) (*messageCache, error) {
|
||||
// Check the parent directory of the database file (makes for friendly error messages)
|
||||
parentDir := filepath.Dir(filename)
|
||||
if !util.FileExists(parentDir) {
|
||||
return nil, fmt.Errorf("cache database directory %s does not exist or is not accessible", parentDir)
|
||||
}
|
||||
// Open database
|
||||
db, err := sql.Open("sqlite3", filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -340,6 +349,8 @@ func (c *messageCache) AddMessage(m *message) error {
|
||||
// addMessages synchronously stores a match of messages. If the database is locked, the transaction waits until
|
||||
// SQLite's busy_timeout is exceeded before erroring out.
|
||||
func (c *messageCache) addMessages(ms []*message) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.nop {
|
||||
return nil
|
||||
}
|
||||
@@ -521,6 +532,8 @@ func (c *messageCache) Message(id string) (*message, error) {
|
||||
}
|
||||
|
||||
func (c *messageCache) MarkPublished(m *message) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
_, err := c.db.Exec(updateMessagePublishedQuery, m.ID)
|
||||
return err
|
||||
}
|
||||
@@ -566,6 +579,8 @@ func (c *messageCache) Topics() (map[string]*topic, error) {
|
||||
}
|
||||
|
||||
func (c *messageCache) DeleteMessages(ids ...string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
tx, err := c.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -580,6 +595,8 @@ func (c *messageCache) DeleteMessages(ids ...string) error {
|
||||
}
|
||||
|
||||
func (c *messageCache) ExpireMessages(topics ...string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
tx, err := c.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -614,6 +631,8 @@ func (c *messageCache) AttachmentsExpired() ([]string, error) {
|
||||
}
|
||||
|
||||
func (c *messageCache) MarkAttachmentsDeleted(ids ...string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
tx, err := c.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -759,6 +778,8 @@ func readMessage(rows *sql.Rows) (*message, error) {
|
||||
}
|
||||
|
||||
func (c *messageCache) UpdateStats(messages int64) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
_, err := c.db.Exec(updateStatsQuery, messages)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ package server
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -90,6 +92,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 < 5000; 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))
|
||||
}
|
||||
|
||||
167
server/server.go
@@ -31,9 +31,12 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"gopkg.in/yaml.v2"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/payments"
|
||||
"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 +123,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 +141,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
|
||||
@@ -158,7 +166,7 @@ func New(conf *Config) (*Server, error) {
|
||||
mailer = &smtpSender{config: conf}
|
||||
}
|
||||
var stripe stripeAPI
|
||||
if conf.StripeSecretKey != "" {
|
||||
if payments.Available && conf.StripeSecretKey != "" {
|
||||
stripe = newStripeAPI()
|
||||
}
|
||||
messageCache, err := createMessageCache(conf)
|
||||
@@ -189,7 +197,18 @@ func New(conf *Config) (*Server, error) {
|
||||
}
|
||||
var userManager *user.Manager
|
||||
if conf.AuthFile != "" {
|
||||
userManager, err = user.NewManager(conf.AuthFile, conf.AuthStartupQueries, conf.AuthDefault, conf.AuthBcryptCost, conf.AuthStatsQueueWriterInterval)
|
||||
authConfig := &user.Config{
|
||||
Filename: conf.AuthFile,
|
||||
StartupQueries: conf.AuthStartupQueries,
|
||||
DefaultAccess: conf.AuthDefault,
|
||||
ProvisionEnabled: true, // Enable provisioning of users and access
|
||||
Users: conf.AuthUsers,
|
||||
Access: conf.AuthAccess,
|
||||
Tokens: conf.AuthTokens,
|
||||
BcryptCost: conf.AuthBcryptCost,
|
||||
QueueWriterInterval: conf.AuthStatsQueueWriterInterval,
|
||||
}
|
||||
userManager, err = user.NewManager(authConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -581,6 +600,7 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
|
||||
BaseURL: "", // Will translate to window.location.origin
|
||||
AppRoot: s.config.WebRoot,
|
||||
EnableLogin: s.config.EnableLogin,
|
||||
RequireLogin: s.config.RequireLogin,
|
||||
EnableSignup: s.config.EnableSignup,
|
||||
EnablePayments: s.config.StripeSecretKey != "",
|
||||
EnableCalls: s.config.TwilioAccount != "",
|
||||
@@ -760,7 +780,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 +956,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 +972,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||
}
|
||||
if attach != "" {
|
||||
if !urlRegex.MatchString(attach) {
|
||||
return false, false, "", "", false, false, errHTTPBadRequestAttachmentURLInvalid
|
||||
return false, false, "", "", "", false, errHTTPBadRequestAttachmentURLInvalid
|
||||
}
|
||||
m.Attachment.URL = attach
|
||||
if m.Attachment.Name == "" {
|
||||
@@ -970,48 +990,53 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||
}
|
||||
if icon != "" {
|
||||
if !urlRegex.MatchString(icon) {
|
||||
return false, false, "", "", false, false, errHTTPBadRequestIconURLInvalid
|
||||
return false, false, "", "", "", false, errHTTPBadRequestIconURLInvalid
|
||||
}
|
||||
m.Icon = icon
|
||||
}
|
||||
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
|
||||
if s.smtpSender == nil && email != "" {
|
||||
return false, false, "", "", false, false, errHTTPBadRequestEmailDisabled
|
||||
return false, false, "", "", "", false, errHTTPBadRequestEmailDisabled
|
||||
}
|
||||
call = readParam(r, "x-call", "call")
|
||||
if call != "" && (s.config.TwilioAccount == "" || s.userManager == nil) {
|
||||
return false, false, "", "", false, false, errHTTPBadRequestPhoneCallsDisabled
|
||||
return false, false, "", "", "", false, errHTTPBadRequestPhoneCallsDisabled
|
||||
} else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) {
|
||||
return false, false, "", "", false, false, errHTTPBadRequestPhoneNumberInvalid
|
||||
return false, false, "", "", "", false, errHTTPBadRequestPhoneNumberInvalid
|
||||
}
|
||||
template = templateMode(readParam(r, "x-template", "template", "tpl"))
|
||||
messageStr := readParam(r, "x-message", "message", "m")
|
||||
if !template.InlineMode() {
|
||||
// Convert "\n" to literal newline everything but inline mode
|
||||
messageStr = strings.ReplaceAll(messageStr, "\\n", "\n")
|
||||
}
|
||||
messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
|
||||
if messageStr != "" {
|
||||
m.Message = messageStr
|
||||
}
|
||||
var e error
|
||||
m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
|
||||
if e != nil {
|
||||
return false, false, "", "", false, false, errHTTPBadRequestPriorityInvalid
|
||||
return false, false, "", "", "", false, errHTTPBadRequestPriorityInvalid
|
||||
}
|
||||
m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta")
|
||||
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
|
||||
if delayStr != "" {
|
||||
if !cache {
|
||||
return false, false, "", "", false, false, errHTTPBadRequestDelayNoCache
|
||||
return false, false, "", "", "", false, errHTTPBadRequestDelayNoCache
|
||||
}
|
||||
if email != "" {
|
||||
return false, false, "", "", false, false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
|
||||
return false, false, "", "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
|
||||
}
|
||||
if call != "" {
|
||||
return false, false, "", "", false, false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet)
|
||||
return false, false, "", "", "", false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet)
|
||||
}
|
||||
delay, err := util.ParseFutureTime(delayStr, time.Now())
|
||||
if err != nil {
|
||||
return false, false, "", "", false, false, errHTTPBadRequestDelayCannotParse
|
||||
return false, false, "", "", "", false, errHTTPBadRequestDelayCannotParse
|
||||
} else if delay.Unix() < time.Now().Add(s.config.MessageDelayMin).Unix() {
|
||||
return false, false, "", "", false, false, errHTTPBadRequestDelayTooSmall
|
||||
return false, false, "", "", "", false, errHTTPBadRequestDelayTooSmall
|
||||
} else if delay.Unix() > time.Now().Add(s.config.MessageDelayMax).Unix() {
|
||||
return false, false, "", "", false, false, errHTTPBadRequestDelayTooLarge
|
||||
return false, false, "", "", "", false, errHTTPBadRequestDelayTooLarge
|
||||
}
|
||||
m.Time = delay.Unix()
|
||||
}
|
||||
@@ -1019,14 +1044,13 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||
if actionsStr != "" {
|
||||
m.Actions, e = parseActions(actionsStr)
|
||||
if e != nil {
|
||||
return false, false, "", "", false, false, errHTTPBadRequestActionsInvalid.Wrap("%s", e.Error())
|
||||
return false, false, "", "", "", false, errHTTPBadRequestActionsInvalid.Wrap("%s", e.Error())
|
||||
}
|
||||
}
|
||||
contentType, markdown := readParam(r, "content-type", "content_type"), readBoolParam(r, false, "x-markdown", "markdown", "md")
|
||||
if markdown || strings.ToLower(contentType) == "text/markdown" {
|
||||
m.ContentType = "text/markdown"
|
||||
}
|
||||
template = readBoolParam(r, false, "x-template", "template", "tpl")
|
||||
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
|
||||
contentEncoding := readParam(r, "content-encoding")
|
||||
if unifiedpush || contentEncoding == "aes128gcm" {
|
||||
@@ -1058,7 +1082,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 +1091,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 +1128,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 +1136,69 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *message, body *util.PeekedR
|
||||
return errHTTPEntityTooLargeJSONBody
|
||||
}
|
||||
peekedBody := strings.TrimSpace(string(body.PeekedBytes))
|
||||
if m.Message, err = replaceTemplate(m.Message, peekedBody); err != nil {
|
||||
return err
|
||||
if template.FileMode() {
|
||||
if err := s.renderTemplateFromFile(m, template.FileName(), peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := s.renderTemplateFromParams(m, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if m.Title, err = replaceTemplate(m.Title, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(m.Message) > s.config.MessageSizeLimit {
|
||||
if len(m.Title) > s.config.MessageSizeLimit || len(m.Message) > s.config.MessageSizeLimit {
|
||||
return errHTTPBadRequestTemplateMessageTooLarge
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func replaceTemplate(tpl string, source string) (string, error) {
|
||||
// renderTemplateFromFile transforms the JSON message body according to a template from the filesystem.
|
||||
// The template file must be in the templates directory, or in the configured template directory.
|
||||
func (s *Server) renderTemplateFromFile(m *message, templateName, peekedBody string) error {
|
||||
if !templateNameRegex.MatchString(templateName) {
|
||||
return errHTTPBadRequestTemplateFileNotFound
|
||||
}
|
||||
templateContent, _ := templatesFs.ReadFile(filepath.Join(templatesDir, templateName+templateFileExtension)) // Read from the embedded filesystem first
|
||||
if s.config.TemplateDir != "" {
|
||||
if b, _ := os.ReadFile(filepath.Join(s.config.TemplateDir, templateName+templateFileExtension)); len(b) > 0 {
|
||||
templateContent = b
|
||||
}
|
||||
}
|
||||
if len(templateContent) == 0 {
|
||||
return errHTTPBadRequestTemplateFileNotFound
|
||||
}
|
||||
var tpl templateFile
|
||||
if err := yaml.Unmarshal(templateContent, &tpl); err != nil {
|
||||
return errHTTPBadRequestTemplateFileInvalid
|
||||
}
|
||||
var err error
|
||||
if tpl.Message != nil {
|
||||
if m.Message, err = s.renderTemplate(*tpl.Message, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if tpl.Title != nil {
|
||||
if m.Title, err = s.renderTemplate(*tpl.Title, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderTemplateFromParams transforms the JSON message body according to the inline template in the
|
||||
// message and title parameters.
|
||||
func (s *Server) renderTemplateFromParams(m *message, peekedBody string) error {
|
||||
var err error
|
||||
if m.Message, err = s.renderTemplate(m.Message, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Title, err = s.renderTemplate(m.Title, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderTemplate renders a template with the given JSON source data.
|
||||
func (s *Server) renderTemplate(tpl string, source string) (string, error) {
|
||||
if templateDisallowedRegex.MatchString(tpl) {
|
||||
return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
|
||||
}
|
||||
@@ -1132,15 +1206,16 @@ func replaceTemplate(tpl string, source string) (string, error) {
|
||||
if err := json.Unmarshal([]byte(source), &data); err != nil {
|
||||
return "", errHTTPBadRequestTemplateMessageNotJSON
|
||||
}
|
||||
t, err := template.New("").Parse(tpl)
|
||||
t, err := template.New("").Funcs(sprig.TxtFuncMap()).Parse(tpl)
|
||||
if err != nil {
|
||||
return "", errHTTPBadRequestTemplateInvalid
|
||||
return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error())
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := t.Execute(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), data); err != nil {
|
||||
return "", errHTTPBadRequestTemplateExecuteFailed
|
||||
limitWriter := util.NewLimitWriter(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), util.NewFixedLimiter(templateMaxOutputBytes))
|
||||
if err := t.Execute(limitWriter, data); err != nil {
|
||||
return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("%s", err.Error())
|
||||
}
|
||||
return buf.String(), nil
|
||||
return strings.TrimSpace(strings.ReplaceAll(buf.String(), "\\n", "\n")), nil // replace any remaining "\n" (those outside of template curly braces) with newlines
|
||||
}
|
||||
|
||||
func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) error {
|
||||
@@ -1937,7 +2012,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 +2087,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 +2098,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)
|
||||
|
||||
@@ -82,6 +82,14 @@
|
||||
# set to "read-write" (default), "read-only", "write-only" or "deny-all".
|
||||
# - auth-startup-queries allows you to run commands when the database is initialized, e.g. to enable
|
||||
# WAL mode. This is similar to cache-startup-queries. See above for details.
|
||||
# - auth-users is a list of users that are automatically created when the server starts.
|
||||
# Each entry is in the format "<username>:<password-hash>:<role>", e.g. "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:user"
|
||||
# Use 'ntfy user hash' to generate the password hash from a password.
|
||||
# - auth-access is a list of access control entries that are automatically created when the server starts.
|
||||
# Each entry is in the format "<username>:<topic-pattern>:<access>", e.g. "phil:mytopic:rw" or "phil:phil-*:rw".
|
||||
# - auth-tokens is a list of access tokens that are automatically created when the server starts.
|
||||
# Each entry is in the format "<username>:<token>[:<label>]", e.g. "phil:tk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef:My token".
|
||||
# Use 'ntfy token generate' to generate a new access token.
|
||||
#
|
||||
# Debian/RPM package users:
|
||||
# Use /var/lib/ntfy/user.db as user database to avoid permission issues. The package
|
||||
@@ -94,6 +102,9 @@
|
||||
# auth-file: <filename>
|
||||
# auth-default-access: "read-write"
|
||||
# auth-startup-queries:
|
||||
# auth-users:
|
||||
# auth-access:
|
||||
# auth-tokens:
|
||||
|
||||
# If set, the X-Forwarded-For header (or whatever is configured in proxy-forwarded-header) is used to determine
|
||||
# the visitor IP address instead of the remote address of the connection.
|
||||
@@ -105,13 +116,13 @@
|
||||
# proxy-forwarded-header. Without this, the remote address of the incoming connection is used.
|
||||
# - proxy-forwarded-header is the header to use to identify visitors. It may be a single IP address (e.g. 1.2.3.4),
|
||||
# a comma-separated list of IP addresses (e.g. "1.2.3.4, 5.6.7.8"), or an RFC 7239-style header (e.g. "for=1.2.3.4;by=proxy.example.com, for=5.6.7.8").
|
||||
# - proxy-trusted-addresses is a comma-separated list of IP addresses that are removed from the forwarded header
|
||||
# - proxy-trusted-hosts is a comma-separated list of IP addresses, hostnames or CIDRs that are removed from the forwarded header
|
||||
# to determine the real IP address. This is only useful if there are multiple proxies involved that add themselves to
|
||||
# the forwarded header.
|
||||
#
|
||||
# behind-proxy: false
|
||||
# proxy-forwarded-header: "X-Forwarded-For"
|
||||
# proxy-trusted-addresses:
|
||||
# proxy-trusted-hosts:
|
||||
|
||||
# If enabled, clients can attach files to notifications as attachments. Minimum settings to enable attachments
|
||||
# are "attachment-cache-dir" and "base-url".
|
||||
@@ -126,6 +137,26 @@
|
||||
# attachment-file-size-limit: "15M"
|
||||
# attachment-expiry-duration: "3h"
|
||||
|
||||
# Template directory for message templates.
|
||||
#
|
||||
# When "X-Template: <name>" (aliases: "Template: <name>", "Tpl: <name>") or "?template=<name>" is set, transform the message
|
||||
# based on one of the built-in pre-defined templates, or on a template defined in the "template-dir" directory.
|
||||
#
|
||||
# Template files must have the ".yml" extension and must be formatted as YAML. They may contain "title" and "message" keys,
|
||||
# which are interpreted as Go templates.
|
||||
#
|
||||
# Example template file (e.g. /etc/ntfy/templates/grafana.yml):
|
||||
# title: |
|
||||
# {{- if eq .status "firing" }}
|
||||
# {{ .title | default "Alert firing" }}
|
||||
# {{- else if eq .status "resolved" }}
|
||||
# {{ .title | default "Alert resolved" }}
|
||||
# {{- end }}
|
||||
# message: |
|
||||
# {{ .message | trunc 2000 }}
|
||||
#
|
||||
# template-dir: "/etc/ntfy/templates"
|
||||
|
||||
# If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set,
|
||||
# messages will additionally be sent out as e-mail using an external SMTP server.
|
||||
#
|
||||
@@ -227,9 +258,11 @@
|
||||
#
|
||||
# - enable-signup allows users to sign up via the web app, or API
|
||||
# - enable-login allows users to log in via the web app, or API
|
||||
# - require-login redirects users to the login page if they are not logged in (disallows web app access without login)
|
||||
# - enable-reservations allows users to reserve topics (if their tier allows it)
|
||||
#
|
||||
# enable-signup: false
|
||||
# require-login: false
|
||||
# enable-login: false
|
||||
# enable-reservations: false
|
||||
|
||||
@@ -292,6 +325,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
|
||||
|
||||
@@ -85,6 +85,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
||||
response.Username = u.Name
|
||||
response.Role = string(u.Role)
|
||||
response.SyncTopic = u.SyncTopic
|
||||
response.Provisioned = u.Provisioned
|
||||
if u.Prefs != nil {
|
||||
if u.Prefs.Language != nil {
|
||||
response.Language = *u.Prefs.Language
|
||||
@@ -139,11 +140,12 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
||||
lastOrigin = t.LastOrigin.String()
|
||||
}
|
||||
response.Tokens = append(response.Tokens, &apiAccountTokenResponse{
|
||||
Token: t.Value,
|
||||
Label: t.Label,
|
||||
LastAccess: t.LastAccess.Unix(),
|
||||
LastOrigin: lastOrigin,
|
||||
Expires: t.Expires.Unix(),
|
||||
Token: t.Value,
|
||||
Label: t.Label,
|
||||
LastAccess: t.LastAccess.Unix(),
|
||||
LastOrigin: lastOrigin,
|
||||
Expires: t.Expires.Unix(),
|
||||
Provisioned: t.Provisioned,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -174,6 +176,12 @@ func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v *
|
||||
if _, err := s.userManager.Authenticate(u.Name, req.Password); err != nil {
|
||||
return errHTTPBadRequestIncorrectPasswordConfirmation
|
||||
}
|
||||
if err := s.userManager.CanChangeUser(u.Name); err != nil {
|
||||
if errors.Is(err, user.ErrProvisionedUserChange) {
|
||||
return errHTTPConflictProvisionedUserChange
|
||||
}
|
||||
return err
|
||||
}
|
||||
if s.webPush != nil && u.ID != "" {
|
||||
if err := s.webPush.RemoveSubscriptionsByUserID(u.ID); err != nil {
|
||||
logvr(v, r).Err(err).Warn("Error removing web push subscriptions for %s", u.Name)
|
||||
@@ -208,6 +216,9 @@ func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
logvr(v, r).Tag(tagAccount).Debug("Changing password for user %s", u.Name)
|
||||
if err := s.userManager.ChangePassword(u.Name, req.NewPassword, false); err != nil {
|
||||
if errors.Is(err, user.ErrProvisionedUserChange) {
|
||||
return errHTTPConflictProvisionedUserChange
|
||||
}
|
||||
return err
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
@@ -234,7 +245,7 @@ func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request
|
||||
"token_expires": expires,
|
||||
}).
|
||||
Debug("Creating token for user %s", u.Name)
|
||||
token, err := s.userManager.CreateToken(u.ID, label, expires, v.IP())
|
||||
token, err := s.userManager.CreateToken(u.ID, label, expires, v.IP(), false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -274,6 +285,9 @@ func (s *Server) handleAccountTokenUpdate(w http.ResponseWriter, r *http.Request
|
||||
Debug("Updating token for user %s as deleted", u.Name)
|
||||
token, err := s.userManager.ChangeToken(u.ID, req.Token, req.Label, expires)
|
||||
if err != nil {
|
||||
if errors.Is(err, user.ErrProvisionedTokenChange) {
|
||||
return errHTTPConflictProvisionedTokenChange
|
||||
}
|
||||
return err
|
||||
}
|
||||
response := &apiAccountTokenResponse{
|
||||
@@ -296,6 +310,9 @@ func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
}
|
||||
if err := s.userManager.RemoveToken(u.ID, token); err != nil {
|
||||
if errors.Is(err, user.ErrProvisionedTokenChange) {
|
||||
return errHTTPConflictProvisionedTokenChange
|
||||
}
|
||||
return err
|
||||
}
|
||||
logvr(v, r).
|
||||
|
||||
@@ -176,7 +176,7 @@ func TestAccount_ChangeSettings(t *testing.T) {
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
u, _ := s.userManager.User("phil")
|
||||
token, _ := s.userManager.CreateToken(u.ID, "", time.Unix(0, 0), netip.IPv4Unspecified())
|
||||
token, _ := s.userManager.CreateToken(u.ID, "", time.Unix(0, 0), netip.IPv4Unspecified(), false)
|
||||
|
||||
rr := request(t, s, "PATCH", "/v1/account/settings", `{"notification": {"sound": "juntos"},"ignored": true}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
@@ -251,7 +251,11 @@ func TestAccount_Subscription_AddUpdateDelete(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAccount_ChangePassword(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
conf := newTestConfigWithAuthFile(t)
|
||||
conf.AuthUsers = []*user.User{
|
||||
{Name: "philuser", Hash: "$2a$10$U4WSIYY6evyGmZaraavM2e2JeVG6EMGUKN1uUwufUeeRd4Jpg6cGC", Role: user.RoleUser}, // philuser:philpass
|
||||
}
|
||||
s := newTestServer(t, conf)
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
@@ -281,6 +285,12 @@ func TestAccount_ChangePassword(t *testing.T) {
|
||||
"Authorization": util.BasicAuth("phil", "new password"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Cannot change password of provisioned user
|
||||
rr = request(t, s, "POST", "/v1/account/password", `{"password": "philpass", "new_password": "new password"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("philuser", "philpass"),
|
||||
})
|
||||
require.Equal(t, 409, rr.Code)
|
||||
}
|
||||
|
||||
func TestAccount_ChangePassword_NoAccount(t *testing.T) {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build !nofirebase
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
@@ -14,6 +16,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// FirebaseAvailable is a constant used to indicate that Firebase support is available.
|
||||
// It can be disabled with the 'nofirebase' build tag.
|
||||
FirebaseAvailable = true
|
||||
|
||||
fcmMessageLimit = 4000
|
||||
fcmApnsBodyMessageLimit = 100
|
||||
)
|
||||
@@ -73,8 +79,8 @@ type firebaseSenderImpl struct {
|
||||
client *messaging.Client
|
||||
}
|
||||
|
||||
func newFirebaseSender(credentialsFile string) (*firebaseSenderImpl, error) {
|
||||
fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(credentialsFile))
|
||||
func newFirebaseSender(credentialsFile string) (firebaseSender, error) {
|
||||
fb, err := firebase.NewApp(context.Background(), nil, option.WithAuthCredentialsFile(option.ServiceAccount, credentialsFile))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
38
server/server_firebase_dummy.go
Normal file
@@ -0,0 +1,38 @@
|
||||
//go:build nofirebase
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
)
|
||||
|
||||
const (
|
||||
// FirebaseAvailable is a constant used to indicate that Firebase support is available.
|
||||
// It can be disabled with the 'nofirebase' build tag.
|
||||
FirebaseAvailable = false
|
||||
)
|
||||
|
||||
var (
|
||||
errFirebaseNotAvailable = errors.New("Firebase not available")
|
||||
errFirebaseTemporarilyBanned = errors.New("visitor temporarily banned from using Firebase")
|
||||
)
|
||||
|
||||
type firebaseClient struct {
|
||||
}
|
||||
|
||||
func (c *firebaseClient) Send(v *visitor, m *message) error {
|
||||
return errFirebaseNotAvailable
|
||||
}
|
||||
|
||||
type firebaseSender interface {
|
||||
Send(m string) error
|
||||
}
|
||||
|
||||
func newFirebaseClient(sender firebaseSender, auther user.Auther) *firebaseClient {
|
||||
return nil
|
||||
}
|
||||
|
||||
func newFirebaseSender(credentialsFile string) (firebaseSender, error) {
|
||||
return nil, errFirebaseNotAvailable
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build !nofirebase
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build !nopayments
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
@@ -12,6 +14,7 @@ import (
|
||||
"github.com/stripe/stripe-go/v74/subscription"
|
||||
"github.com/stripe/stripe-go/v74/webhook"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/payments"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"io"
|
||||
@@ -22,7 +25,7 @@ import (
|
||||
|
||||
// Payments in ntfy are done via Stripe.
|
||||
//
|
||||
// Pretty much all payments related things are in this file. The following processes
|
||||
// Pretty much all payments-related things are in this file. The following processes
|
||||
// handle payments:
|
||||
//
|
||||
// - Checkout:
|
||||
@@ -464,8 +467,8 @@ func (s *Server) updateSubscriptionAndTier(r *http.Request, v *visitor, u *user.
|
||||
billing := &user.Billing{
|
||||
StripeCustomerID: customerID,
|
||||
StripeSubscriptionID: subscriptionID,
|
||||
StripeSubscriptionStatus: stripe.SubscriptionStatus(status),
|
||||
StripeSubscriptionInterval: stripe.PriceRecurringInterval(interval),
|
||||
StripeSubscriptionStatus: payments.SubscriptionStatus(status),
|
||||
StripeSubscriptionInterval: payments.PriceRecurringInterval(interval),
|
||||
StripeSubscriptionPaidUntil: time.Unix(paidUntil, 0),
|
||||
StripeSubscriptionCancelAt: time.Unix(cancelAt, 0),
|
||||
}
|
||||
|
||||
47
server/server_payments_dummy.go
Normal file
@@ -0,0 +1,47 @@
|
||||
//go:build nopayments
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type stripeAPI interface {
|
||||
CancelSubscription(id string) (string, error)
|
||||
}
|
||||
|
||||
func newStripeAPI() stripeAPI {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) fetchStripePrices() (map[string]int64, error) {
|
||||
return nil, errHTTPNotFound
|
||||
}
|
||||
|
||||
func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
return errHTTPNotFound
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
return errHTTPNotFound
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
return errHTTPNotFound
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
return errHTTPNotFound
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountBillingSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
return errHTTPNotFound
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountBillingPortalSessionCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
return errHTTPNotFound
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountBillingWebhook(_ http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
return errHTTPNotFound
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build !nopayments
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
@@ -6,6 +8,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stripe/stripe-go/v74"
|
||||
"golang.org/x/time/rate"
|
||||
"heckel.io/ntfy/v2/payments"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"io"
|
||||
@@ -345,8 +348,8 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes
|
||||
require.Nil(t, u.Tier)
|
||||
require.Equal(t, "", u.Billing.StripeCustomerID)
|
||||
require.Equal(t, "", u.Billing.StripeSubscriptionID)
|
||||
require.Equal(t, stripe.SubscriptionStatus(""), u.Billing.StripeSubscriptionStatus)
|
||||
require.Equal(t, stripe.PriceRecurringInterval(""), u.Billing.StripeSubscriptionInterval)
|
||||
require.Equal(t, payments.SubscriptionStatus(""), u.Billing.StripeSubscriptionStatus)
|
||||
require.Equal(t, payments.PriceRecurringInterval(""), u.Billing.StripeSubscriptionInterval)
|
||||
require.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.Unix())
|
||||
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
|
||||
require.Equal(t, int64(0), u.Stats.Messages) // Messages and emails are not persisted for no-tier users!
|
||||
@@ -362,8 +365,8 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes
|
||||
require.Equal(t, "starter", u.Tier.Code) // Not "pro"
|
||||
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
|
||||
require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID)
|
||||
require.Equal(t, stripe.SubscriptionStatusActive, u.Billing.StripeSubscriptionStatus)
|
||||
require.Equal(t, stripe.PriceRecurringIntervalMonth, u.Billing.StripeSubscriptionInterval)
|
||||
require.Equal(t, payments.SubscriptionStatus(stripe.SubscriptionStatusActive), u.Billing.StripeSubscriptionStatus)
|
||||
require.Equal(t, payments.PriceRecurringInterval(stripe.PriceRecurringIntervalMonth), u.Billing.StripeSubscriptionInterval)
|
||||
require.Equal(t, int64(123456789), u.Billing.StripeSubscriptionPaidUntil.Unix())
|
||||
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
|
||||
require.Equal(t, int64(0), u.Stats.Messages)
|
||||
@@ -473,8 +476,8 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
|
||||
billing := &user.Billing{
|
||||
StripeCustomerID: "acct_5555",
|
||||
StripeSubscriptionID: "sub_1234",
|
||||
StripeSubscriptionStatus: stripe.SubscriptionStatusPastDue,
|
||||
StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth,
|
||||
StripeSubscriptionStatus: payments.SubscriptionStatus(stripe.SubscriptionStatusPastDue),
|
||||
StripeSubscriptionInterval: payments.PriceRecurringInterval(stripe.PriceRecurringIntervalMonth),
|
||||
StripeSubscriptionPaidUntil: time.Unix(123, 0),
|
||||
StripeSubscriptionCancelAt: time.Unix(456, 0),
|
||||
}
|
||||
@@ -517,10 +520,10 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
|
||||
require.Equal(t, "starter", u.Tier.Code) // Not "pro"
|
||||
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
|
||||
require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID)
|
||||
require.Equal(t, stripe.SubscriptionStatusActive, u.Billing.StripeSubscriptionStatus) // Not "past_due"
|
||||
require.Equal(t, stripe.PriceRecurringIntervalYear, u.Billing.StripeSubscriptionInterval) // Not "month"
|
||||
require.Equal(t, int64(1674268231), u.Billing.StripeSubscriptionPaidUntil.Unix()) // Updated
|
||||
require.Equal(t, int64(1674299999), u.Billing.StripeSubscriptionCancelAt.Unix()) // Updated
|
||||
require.Equal(t, payments.SubscriptionStatus(stripe.SubscriptionStatusActive), u.Billing.StripeSubscriptionStatus) // Not "past_due"
|
||||
require.Equal(t, payments.PriceRecurringInterval(stripe.PriceRecurringIntervalYear), u.Billing.StripeSubscriptionInterval) // Not "month"
|
||||
require.Equal(t, int64(1674268231), u.Billing.StripeSubscriptionPaidUntil.Unix()) // Updated
|
||||
require.Equal(t, int64(1674299999), u.Billing.StripeSubscriptionCancelAt.Unix()) // Updated
|
||||
|
||||
// Verify that reservations were deleted
|
||||
r, err := s.userManager.Reservations("phil")
|
||||
@@ -580,8 +583,8 @@ func TestPayments_Webhook_Subscription_Deleted(t *testing.T) {
|
||||
require.Nil(t, s.userManager.ChangeBilling(u.Name, &user.Billing{
|
||||
StripeCustomerID: "acct_5555",
|
||||
StripeSubscriptionID: "sub_1234",
|
||||
StripeSubscriptionStatus: stripe.SubscriptionStatusPastDue,
|
||||
StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth,
|
||||
StripeSubscriptionStatus: payments.SubscriptionStatus(stripe.SubscriptionStatusPastDue),
|
||||
StripeSubscriptionInterval: payments.PriceRecurringInterval(stripe.PriceRecurringIntervalMonth),
|
||||
StripeSubscriptionPaidUntil: time.Unix(123, 0),
|
||||
StripeSubscriptionCancelAt: time.Unix(0, 0),
|
||||
}))
|
||||
@@ -598,7 +601,7 @@ func TestPayments_Webhook_Subscription_Deleted(t *testing.T) {
|
||||
require.Nil(t, u.Tier)
|
||||
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
|
||||
require.Equal(t, "", u.Billing.StripeSubscriptionID)
|
||||
require.Equal(t, stripe.SubscriptionStatus(""), u.Billing.StripeSubscriptionStatus)
|
||||
require.Equal(t, payments.SubscriptionStatus(""), u.Billing.StripeSubscriptionStatus)
|
||||
require.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.Unix())
|
||||
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
_ "embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -22,7 +23,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SherClockHolmes/webpush-go"
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
@@ -280,30 +280,6 @@ func TestServer_WebEnabled(t *testing.T) {
|
||||
rr = request(t, s2, "GET", "/app.html", "", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
|
||||
func TestServer_WebPushEnabled(t *testing.T) {
|
||||
conf := newTestConfig(t)
|
||||
conf.WebRoot = "" // Disable web app
|
||||
s := newTestServer(t, conf)
|
||||
|
||||
rr := request(t, s, "GET", "/manifest.webmanifest", "", nil)
|
||||
require.Equal(t, 404, rr.Code)
|
||||
|
||||
conf2 := newTestConfig(t)
|
||||
s2 := newTestServer(t, conf2)
|
||||
|
||||
rr = request(t, s2, "GET", "/manifest.webmanifest", "", nil)
|
||||
require.Equal(t, 404, rr.Code)
|
||||
|
||||
conf3 := newTestConfigWithWebPush(t)
|
||||
s3 := newTestServer(t, conf3)
|
||||
|
||||
rr = request(t, s3, "GET", "/manifest.webmanifest", "", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Equal(t, "application/manifest+json", rr.Header().Get("Content-Type"))
|
||||
|
||||
}
|
||||
|
||||
func TestServer_PublishLargeMessage(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
c.AttachmentCacheDir = "" // Disable attachments
|
||||
@@ -1169,7 +1145,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 +1155,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 +1209,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 +1235,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 +2277,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 +2304,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 +2893,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 +2946,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 +2999,223 @@ template ""}}`,
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_SprigFunctions(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
bodies := []string{
|
||||
`{"foo":"bar","nested":{"title":"here"}}`,
|
||||
`{"topic":"ntfy-test"}`,
|
||||
`{"topic":"another-topic"}`,
|
||||
}
|
||||
templates := []string{
|
||||
`{{.foo | upper}} is {{.nested.title | repeat 3}}`,
|
||||
`{{if hasPrefix "ntfy-" .topic}}Topic: {{trimPrefix "ntfy-" .topic}}{{ else }}Topic: {{.topic}}{{end}}`,
|
||||
`{{if hasPrefix "ntfy-" .topic}}Topic: {{trimPrefix "ntfy-" .topic}}{{ else }}Topic: {{.topic}}{{end}}`,
|
||||
}
|
||||
targets := []string{
|
||||
`BAR is hereherehere`,
|
||||
`Topic: test`,
|
||||
`Topic: another-topic`,
|
||||
}
|
||||
for i, body := range bodies {
|
||||
template := templates[i]
|
||||
target := targets[i]
|
||||
t.Run(template, func(t *testing.T) {
|
||||
response := request(t, s, "PUT", `/mytopic`, body, map[string]string{
|
||||
"Template": "yes",
|
||||
"Message": template,
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
m := toMessage(t, response.Body.String())
|
||||
require.Equal(t, target, m.Message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_UnsafeSprigFunctions(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
|
||||
"X-Message": `{{ env "PATH" }}`,
|
||||
"X-Template": "1",
|
||||
})
|
||||
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40043, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_InlineNewlines(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "PUT", "/mytopic", `{}`, map[string]string{
|
||||
"X-Message": `{{"New\nlines"}}`,
|
||||
"X-Title": `{{"New\nlines"}}`,
|
||||
"X-Template": "1",
|
||||
})
|
||||
|
||||
require.Equal(t, 200, response.Code)
|
||||
m := toMessage(t, response.Body.String())
|
||||
require.Equal(t, `New
|
||||
lines`, m.Message)
|
||||
require.Equal(t, `New
|
||||
lines`, m.Title)
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_InlineNewlinesOutsideOfTemplate(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "PUT", "/mytopic", `{"foo":"bar","food":"bag"}`, map[string]string{
|
||||
"X-Message": `{{.foo}}{{"\n"}}{{.food}}`,
|
||||
"X-Title": `{{.food}}{{"\n"}}{{.foo}}`,
|
||||
"X-Template": "1",
|
||||
})
|
||||
|
||||
require.Equal(t, 200, response.Code)
|
||||
m := toMessage(t, response.Body.String())
|
||||
require.Equal(t, `bar
|
||||
bag`, m.Message)
|
||||
require.Equal(t, `bag
|
||||
bar`, m.Title)
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_TemplateFileNewlines(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := newTestConfig(t)
|
||||
c.TemplateDir = t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(c.TemplateDir, "newline.yml"), []byte(`
|
||||
title: |
|
||||
{{.food}}{{"\n"}}{{.foo}}
|
||||
message: |
|
||||
{{.foo}}{{"\n"}}{{.food}}
|
||||
`), 0644))
|
||||
s := newTestServer(t, c)
|
||||
response := request(t, s, "POST", "/mytopic?template=newline", `{"foo":"bar","food":"bag"}`, nil)
|
||||
fmt.Println(response.Body.String())
|
||||
require.Equal(t, 200, response.Code)
|
||||
m := toMessage(t, response.Body.String())
|
||||
require.Equal(t, `bar
|
||||
bag`, m.Message)
|
||||
require.Equal(t, `bag
|
||||
bar`, m.Title)
|
||||
}
|
||||
|
||||
var (
|
||||
//go:embed testdata/webhook_github_comment_created.json
|
||||
githubCommentCreatedJSON string
|
||||
|
||||
//go:embed testdata/webhook_github_issue_opened.json
|
||||
githubIssueOpenedJSON string
|
||||
)
|
||||
|
||||
func TestServer_MessageTemplate_FromNamedTemplate_GitHubCommentCreated(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "POST", "/mytopic?template=github", githubCommentCreatedJSON, nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
m := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "💬 New comment on issue #1389 instant alerts without Pull to refresh", m.Title)
|
||||
require.Equal(t, `Commenter: https://github.com/wunter8
|
||||
Repository: https://github.com/binwiederhier/ntfy
|
||||
Comment link: https://github.com/binwiederhier/ntfy/issues/1389#issuecomment-3078214289
|
||||
|
||||
Comment:
|
||||
These are the things you need to do to get iOS push notifications to work:
|
||||
1. open a browser to the web app of your ntfy instance and copy the URL (including "http://" or "https://", your domain or IP address, and any ports, and excluding any trailing slashes)
|
||||
2. put the URL you copied in the ntfy `+"`"+`base-url`+"`"+` config in server.yml or NTFY_BASE_URL in env variables
|
||||
3. put the URL you copied in the default server URL setting in the iOS ntfy app
|
||||
4. set `+"`"+`upstream-base-url`+"`"+` in server.yml or NTFY_UPSTREAM_BASE_URL in env variables to "https://ntfy.sh" (without a trailing slash)`, m.Message)
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_FromNamedTemplate_GitHubIssueOpened(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "POST", "/mytopic?template=github", githubIssueOpenedJSON, nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
m := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "🐛 Issue opened: #1391 http 500 error (ntfy error 50001)", m.Title)
|
||||
require.Equal(t, `Opened by: https://github.com/TheUser-dev
|
||||
Repository: https://github.com/binwiederhier/ntfy
|
||||
Issue link: https://github.com/binwiederhier/ntfy/issues/1391
|
||||
Labels: 🪲 bug
|
||||
|
||||
Description:
|
||||
:lady_beetle: **Describe the bug**
|
||||
When sending a notification (especially when it happens with multiple requests) this error occurs
|
||||
|
||||
:computer: **Components impacted**
|
||||
ntfy server 2.13.0 in docker, debian 12 arm64
|
||||
|
||||
:bulb: **Screenshots and/or logs**
|
||||
`+"```"+`
|
||||
closed with HTTP 500 (ntfy error 50001) (error=database table is locked, http_method=POST, http_path=/_matrix/push/v1/notify, tag=http, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=30, visitor_id=ip:<edited>, visitor_ip=<edited>, visitor_messages=448, visitor_messages_limit=17280, visitor_messages_remaining=16832, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=57.049697891799994, visitor_seen=2025-07-16T15:06:35.429Z)
|
||||
`+"```"+`
|
||||
|
||||
:crystal_ball: **Additional context**
|
||||
Looks like this has already been fixed by #498, regression?`, m.Message)
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_FromNamedTemplate_GitHubIssueOpened_OverrideConfigTemplate(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := newTestConfig(t)
|
||||
c.TemplateDir = t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(c.TemplateDir, "github.yml"), []byte(`
|
||||
title: |
|
||||
Custom title: action={{ .action }} trunctitle={{ .issue.title | trunc 10 }}
|
||||
message: |
|
||||
Custom message {{ .issue.number }}
|
||||
`), 0644))
|
||||
s := newTestServer(t, c)
|
||||
response := request(t, s, "POST", "/mytopic?template=github", githubIssueOpenedJSON, nil)
|
||||
fmt.Println(response.Body.String())
|
||||
require.Equal(t, 200, response.Code)
|
||||
m := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "Custom title: action=opened trunctitle=http 500 e", m.Title)
|
||||
require.Equal(t, "Custom message 1391", m.Message)
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_Repeat9999_TooLarge(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
|
||||
"X-Message": `{{ repeat 9999 "mystring" }}`,
|
||||
"X-Template": "1",
|
||||
})
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40041, toHTTPError(t, response.Body.String()).Code)
|
||||
require.Contains(t, toHTTPError(t, response.Body.String()).Message, "message or title is too large after replacing template")
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_Repeat10001_TooLarge(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
|
||||
"X-Message": `{{ repeat 10001 "mystring" }}`,
|
||||
"X-Template": "1",
|
||||
})
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code)
|
||||
require.Contains(t, toHTTPError(t, response.Body.String()).Message, "repeat count 10001 exceeds limit of 10000")
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_Until100_000(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
|
||||
"X-Message": `{{ range $i, $e := until 100_000 }}{{end}}`,
|
||||
"X-Template": "1",
|
||||
})
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code)
|
||||
require.Contains(t, toHTTPError(t, response.Body.String()).Message, "too many iterations")
|
||||
}
|
||||
|
||||
func newTestConfig(t *testing.T) *Config {
|
||||
conf := NewConfig()
|
||||
conf.BaseURL = "http://127.0.0.1:12345"
|
||||
conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")
|
||||
conf.CacheStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;"
|
||||
conf.AttachmentCacheDir = t.TempDir()
|
||||
conf.TemplateDir = t.TempDir()
|
||||
return conf
|
||||
}
|
||||
|
||||
@@ -2962,17 +3232,6 @@ func newTestConfigWithAuthFile(t *testing.T) *Config {
|
||||
return conf
|
||||
}
|
||||
|
||||
func newTestConfigWithWebPush(t *testing.T) *Config {
|
||||
conf := newTestConfig(t)
|
||||
privateKey, publicKey, err := webpush.GenerateVAPIDKeys()
|
||||
require.Nil(t, err)
|
||||
conf.WebPushFile = filepath.Join(t.TempDir(), "webpush.db")
|
||||
conf.WebPushEmailAddress = "testing@example.com"
|
||||
conf.WebPushPrivateKey = privateKey
|
||||
conf.WebPushPublicKey = publicKey
|
||||
return conf
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T, config *Config) *Server {
|
||||
server, err := New(config)
|
||||
require.Nil(t, err)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build !nowebpush
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
@@ -13,6 +15,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// WebPushAvailable is a constant used to indicate that WebPush support is available.
|
||||
// It can be disabled with the 'nowebpush' build tag.
|
||||
WebPushAvailable = true
|
||||
|
||||
webPushTopicSubscribeLimit = 50
|
||||
)
|
||||
|
||||
|
||||
29
server/server_webpush_dummy.go
Normal file
@@ -0,0 +1,29 @@
|
||||
//go:build nowebpush
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const (
|
||||
// WebPushAvailable is a constant used to indicate that WebPush support is available.
|
||||
// It can be disabled with the 'nowebpush' build tag.
|
||||
WebPushAvailable = false
|
||||
)
|
||||
|
||||
func (s *Server) handleWebPushUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
return errHTTPNotFound
|
||||
}
|
||||
|
||||
func (s *Server) handleWebPushDelete(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
||||
return errHTTPNotFound
|
||||
}
|
||||
|
||||
func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) {
|
||||
// Nothing to see here
|
||||
}
|
||||
|
||||
func (s *Server) pruneAndNotifyWebPushSubscriptions() {
|
||||
// Nothing to see here
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
//go:build !nowebpush
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/SherClockHolmes/webpush-go"
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
@@ -10,6 +13,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -20,6 +24,28 @@ const (
|
||||
testWebPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v1/AAABBCCCDDEEEFFF"
|
||||
)
|
||||
|
||||
func TestServer_WebPush_Enabled(t *testing.T) {
|
||||
conf := newTestConfig(t)
|
||||
conf.WebRoot = "" // Disable web app
|
||||
s := newTestServer(t, conf)
|
||||
|
||||
rr := request(t, s, "GET", "/manifest.webmanifest", "", nil)
|
||||
require.Equal(t, 404, rr.Code)
|
||||
|
||||
conf2 := newTestConfig(t)
|
||||
s2 := newTestServer(t, conf2)
|
||||
|
||||
rr = request(t, s2, "GET", "/manifest.webmanifest", "", nil)
|
||||
require.Equal(t, 404, rr.Code)
|
||||
|
||||
conf3 := newTestConfigWithWebPush(t)
|
||||
s3 := newTestServer(t, conf3)
|
||||
|
||||
rr = request(t, s3, "GET", "/manifest.webmanifest", "", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Equal(t, "application/manifest+json", rr.Header().Get("Content-Type"))
|
||||
|
||||
}
|
||||
func TestServer_WebPush_Disabled(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
@@ -254,3 +280,14 @@ func requireSubscriptionCount(t *testing.T, s *Server, topic string, expectedLen
|
||||
require.Nil(t, err)
|
||||
require.Len(t, subs, expectedLength)
|
||||
}
|
||||
|
||||
func newTestConfigWithWebPush(t *testing.T) *Config {
|
||||
conf := newTestConfig(t)
|
||||
privateKey, publicKey, err := webpush.GenerateVAPIDKeys()
|
||||
require.Nil(t, err)
|
||||
conf.WebPushFile = filepath.Join(t.TempDir(), "webpush.db")
|
||||
conf.WebPushEmailAddress = "testing@example.com"
|
||||
conf.WebPushPrivateKey = privateKey
|
||||
conf.WebPushPublicKey = publicKey
|
||||
return conf
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
27
server/templates/alertmanager.yml
Normal 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 }}
|
||||
57
server/templates/github.yml
Normal 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 }}
|
||||
10
server/templates/grafana.yml
Normal 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 }}
|
||||
33
server/testdata/webhook_alertmanager_firing.json
vendored
Normal 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=..."
|
||||
}
|
||||
]
|
||||
}
|
||||
261
server/testdata/webhook_github_comment_created.json
vendored
Normal 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 — it’s been invaluable for receiving timely alerts.\n\nI’m a user who relies heavily on ntfy for real-time trading alerts and have noticed that while push notifications arrive instantly, the in-app alert list does not automatically refresh with new messages. Currently, I need to manually pull-to-refresh the alert list to see the latest alerts.\n\nWould it be possible to add a feature that enables automatic refreshing of the alert list as new notifications arrive? This would greatly enhance usability and streamline the user experience, especially for users monitoring time-sensitive information.\n\nThank you for considering this request. I appreciate your hard work and look forward to future updates!",
|
||||
"reactions": {
|
||||
"url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1389/reactions",
|
||||
"total_count": 0,
|
||||
"+1": 0,
|
||||
"-1": 0,
|
||||
"laugh": 0,
|
||||
"hooray": 0,
|
||||
"confused": 0,
|
||||
"heart": 0,
|
||||
"rocket": 0,
|
||||
"eyes": 0
|
||||
},
|
||||
"timeline_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1389/timeline",
|
||||
"performed_via_github_app": null,
|
||||
"state_reason": null
|
||||
},
|
||||
"comment": {
|
||||
"url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments/3078214289",
|
||||
"html_url": "https://github.com/binwiederhier/ntfy/issues/1389#issuecomment-3078214289",
|
||||
"issue_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1389",
|
||||
"id": 3078214289,
|
||||
"node_id": "IC_kwDOGRBhi863edKR",
|
||||
"user": {
|
||||
"login": "wunter8",
|
||||
"id": 8421688,
|
||||
"node_id": "MDQ6VXNlcjg0MjE2ODg=",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/8421688?v=4",
|
||||
"gravatar_id": "",
|
||||
"url": "https://api.github.com/users/wunter8",
|
||||
"html_url": "https://github.com/wunter8",
|
||||
"followers_url": "https://api.github.com/users/wunter8/followers",
|
||||
"following_url": "https://api.github.com/users/wunter8/following{/other_user}",
|
||||
"gists_url": "https://api.github.com/users/wunter8/gists{/gist_id}",
|
||||
"starred_url": "https://api.github.com/users/wunter8/starred{/owner}{/repo}",
|
||||
"subscriptions_url": "https://api.github.com/users/wunter8/subscriptions",
|
||||
"organizations_url": "https://api.github.com/users/wunter8/orgs",
|
||||
"repos_url": "https://api.github.com/users/wunter8/repos",
|
||||
"events_url": "https://api.github.com/users/wunter8/events{/privacy}",
|
||||
"received_events_url": "https://api.github.com/users/wunter8/received_events",
|
||||
"type": "User",
|
||||
"user_view_type": "public",
|
||||
"site_admin": false
|
||||
},
|
||||
"created_at": "2025-07-16T11:45:57Z",
|
||||
"updated_at": "2025-07-16T11:45:57Z",
|
||||
"author_association": "CONTRIBUTOR",
|
||||
"body": "These are the things you need to do to get iOS push notifications to work:\n1. open a browser to the web app of your ntfy instance and copy the URL (including \"http://\" or \"https://\", your domain or IP address, and any ports, and excluding any trailing slashes)\n2. put the URL you copied in the ntfy `base-url` config in server.yml or NTFY_BASE_URL in env variables\n3. put the URL you copied in the default server URL setting in the iOS ntfy app\n4. set `upstream-base-url` in server.yml or NTFY_UPSTREAM_BASE_URL in env variables to \"https://ntfy.sh\" (without a trailing slash)",
|
||||
"reactions": {
|
||||
"url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments/3078214289/reactions",
|
||||
"total_count": 0,
|
||||
"+1": 0,
|
||||
"-1": 0,
|
||||
"laugh": 0,
|
||||
"hooray": 0,
|
||||
"confused": 0,
|
||||
"heart": 0,
|
||||
"rocket": 0,
|
||||
"eyes": 0
|
||||
},
|
||||
"performed_via_github_app": null
|
||||
},
|
||||
"repository": {
|
||||
"id": 420503947,
|
||||
"node_id": "R_kgDOGRBhiw",
|
||||
"name": "ntfy",
|
||||
"full_name": "binwiederhier/ntfy",
|
||||
"private": false,
|
||||
"owner": {
|
||||
"login": "binwiederhier",
|
||||
"id": 664597,
|
||||
"node_id": "MDQ6VXNlcjY2NDU5Nw==",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4",
|
||||
"gravatar_id": "",
|
||||
"url": "https://api.github.com/users/binwiederhier",
|
||||
"html_url": "https://github.com/binwiederhier",
|
||||
"followers_url": "https://api.github.com/users/binwiederhier/followers",
|
||||
"following_url": "https://api.github.com/users/binwiederhier/following{/other_user}",
|
||||
"gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}",
|
||||
"starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}",
|
||||
"subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions",
|
||||
"organizations_url": "https://api.github.com/users/binwiederhier/orgs",
|
||||
"repos_url": "https://api.github.com/users/binwiederhier/repos",
|
||||
"events_url": "https://api.github.com/users/binwiederhier/events{/privacy}",
|
||||
"received_events_url": "https://api.github.com/users/binwiederhier/received_events",
|
||||
"type": "User",
|
||||
"user_view_type": "public",
|
||||
"site_admin": false
|
||||
},
|
||||
"html_url": "https://github.com/binwiederhier/ntfy",
|
||||
"description": "Send push notifications to your phone or desktop using PUT/POST",
|
||||
"fork": false,
|
||||
"url": "https://api.github.com/repos/binwiederhier/ntfy",
|
||||
"forks_url": "https://api.github.com/repos/binwiederhier/ntfy/forks",
|
||||
"keys_url": "https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}",
|
||||
"collaborators_url": "https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}",
|
||||
"teams_url": "https://api.github.com/repos/binwiederhier/ntfy/teams",
|
||||
"hooks_url": "https://api.github.com/repos/binwiederhier/ntfy/hooks",
|
||||
"issue_events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}",
|
||||
"events_url": "https://api.github.com/repos/binwiederhier/ntfy/events",
|
||||
"assignees_url": "https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}",
|
||||
"branches_url": "https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}",
|
||||
"tags_url": "https://api.github.com/repos/binwiederhier/ntfy/tags",
|
||||
"blobs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}",
|
||||
"git_tags_url": "https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}",
|
||||
"git_refs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}",
|
||||
"trees_url": "https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}",
|
||||
"statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}",
|
||||
"languages_url": "https://api.github.com/repos/binwiederhier/ntfy/languages",
|
||||
"stargazers_url": "https://api.github.com/repos/binwiederhier/ntfy/stargazers",
|
||||
"contributors_url": "https://api.github.com/repos/binwiederhier/ntfy/contributors",
|
||||
"subscribers_url": "https://api.github.com/repos/binwiederhier/ntfy/subscribers",
|
||||
"subscription_url": "https://api.github.com/repos/binwiederhier/ntfy/subscription",
|
||||
"commits_url": "https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}",
|
||||
"git_commits_url": "https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}",
|
||||
"comments_url": "https://api.github.com/repos/binwiederhier/ntfy/comments{/number}",
|
||||
"issue_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}",
|
||||
"contents_url": "https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}",
|
||||
"compare_url": "https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}",
|
||||
"merges_url": "https://api.github.com/repos/binwiederhier/ntfy/merges",
|
||||
"archive_url": "https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}",
|
||||
"downloads_url": "https://api.github.com/repos/binwiederhier/ntfy/downloads",
|
||||
"issues_url": "https://api.github.com/repos/binwiederhier/ntfy/issues{/number}",
|
||||
"pulls_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}",
|
||||
"milestones_url": "https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}",
|
||||
"notifications_url": "https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}",
|
||||
"labels_url": "https://api.github.com/repos/binwiederhier/ntfy/labels{/name}",
|
||||
"releases_url": "https://api.github.com/repos/binwiederhier/ntfy/releases{/id}",
|
||||
"deployments_url": "https://api.github.com/repos/binwiederhier/ntfy/deployments",
|
||||
"created_at": "2021-10-23T19:25:32Z",
|
||||
"updated_at": "2025-07-16T10:18:34Z",
|
||||
"pushed_at": "2025-07-13T13:56:19Z",
|
||||
"git_url": "git://github.com/binwiederhier/ntfy.git",
|
||||
"ssh_url": "git@github.com:binwiederhier/ntfy.git",
|
||||
"clone_url": "https://github.com/binwiederhier/ntfy.git",
|
||||
"svn_url": "https://github.com/binwiederhier/ntfy",
|
||||
"homepage": "https://ntfy.sh",
|
||||
"size": 36740,
|
||||
"stargazers_count": 25111,
|
||||
"watchers_count": 25111,
|
||||
"language": "Go",
|
||||
"has_issues": true,
|
||||
"has_projects": true,
|
||||
"has_downloads": true,
|
||||
"has_wiki": true,
|
||||
"has_pages": false,
|
||||
"has_discussions": false,
|
||||
"forks_count": 984,
|
||||
"mirror_url": null,
|
||||
"archived": false,
|
||||
"disabled": false,
|
||||
"open_issues_count": 367,
|
||||
"license": {
|
||||
"key": "apache-2.0",
|
||||
"name": "Apache License 2.0",
|
||||
"spdx_id": "Apache-2.0",
|
||||
"url": "https://api.github.com/licenses/apache-2.0",
|
||||
"node_id": "MDc6TGljZW5zZTI="
|
||||
},
|
||||
"allow_forking": true,
|
||||
"is_template": false,
|
||||
"web_commit_signoff_required": false,
|
||||
"topics": [
|
||||
"curl",
|
||||
"notifications",
|
||||
"ntfy",
|
||||
"ntfysh",
|
||||
"pubsub",
|
||||
"push-notifications",
|
||||
"rest-api"
|
||||
],
|
||||
"visibility": "public",
|
||||
"forks": 984,
|
||||
"open_issues": 367,
|
||||
"watchers": 25111,
|
||||
"default_branch": "main"
|
||||
},
|
||||
"sender": {
|
||||
"login": "wunter8",
|
||||
"id": 8421688,
|
||||
"node_id": "MDQ6VXNlcjg0MjE2ODg=",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/8421688?v=4",
|
||||
"gravatar_id": "",
|
||||
"url": "https://api.github.com/users/wunter8",
|
||||
"html_url": "https://github.com/wunter8",
|
||||
"followers_url": "https://api.github.com/users/wunter8/followers",
|
||||
"following_url": "https://api.github.com/users/wunter8/following{/other_user}",
|
||||
"gists_url": "https://api.github.com/users/wunter8/gists{/gist_id}",
|
||||
"starred_url": "https://api.github.com/users/wunter8/starred{/owner}{/repo}",
|
||||
"subscriptions_url": "https://api.github.com/users/wunter8/subscriptions",
|
||||
"organizations_url": "https://api.github.com/users/wunter8/orgs",
|
||||
"repos_url": "https://api.github.com/users/wunter8/repos",
|
||||
"events_url": "https://api.github.com/users/wunter8/events{/privacy}",
|
||||
"received_events_url": "https://api.github.com/users/wunter8/received_events",
|
||||
"type": "User",
|
||||
"user_view_type": "public",
|
||||
"site_admin": false
|
||||
}
|
||||
}
|
||||
216
server/testdata/webhook_github_issue_opened.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
541
server/testdata/webhook_github_pr_opened.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
141
server/testdata/webhook_github_star_created.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
139
server/testdata/webhook_github_watch_created.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
51
server/testdata/webhook_grafana_resolved.json
vendored
Normal 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"
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
@@ -246,6 +245,51 @@ func (q *queryFilter) Pass(msg *message) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// templateMode represents the mode in which templates are used
|
||||
//
|
||||
// It can be
|
||||
// - empty: templating is disabled
|
||||
// - a boolean string (yes/1/true/no/0/false): inline-templating mode
|
||||
// - a filename (e.g. grafana): template mode with a file
|
||||
type templateMode string
|
||||
|
||||
// Enabled returns true if templating is enabled
|
||||
func (t templateMode) Enabled() bool {
|
||||
return t != ""
|
||||
}
|
||||
|
||||
// InlineMode returns true if inline-templating mode is enabled
|
||||
func (t templateMode) InlineMode() bool {
|
||||
return t.Enabled() && isBoolValue(string(t))
|
||||
}
|
||||
|
||||
// FileMode returns true if file-templating mode is enabled
|
||||
func (t templateMode) FileMode() bool {
|
||||
return t.Enabled() && !isBoolValue(string(t))
|
||||
}
|
||||
|
||||
// FileName returns the filename if file-templating mode is enabled, or an empty string otherwise
|
||||
func (t templateMode) FileName() string {
|
||||
if t.FileMode() {
|
||||
return string(t)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// templateFile represents a template file with title and message
|
||||
// It is used for file-based templates, e.g. grafana, influxdb, etc.
|
||||
//
|
||||
// Example YAML:
|
||||
//
|
||||
// title: "Alert: {{ .Title }}"
|
||||
// message: |
|
||||
// This is a {{ .Type }} alert.
|
||||
// It can be multiline.
|
||||
type templateFile struct {
|
||||
Title *string `yaml:"title"`
|
||||
Message *string `yaml:"message"`
|
||||
}
|
||||
|
||||
type apiHealthResponse struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
}
|
||||
@@ -316,11 +360,12 @@ type apiAccountTokenUpdateRequest struct {
|
||||
}
|
||||
|
||||
type apiAccountTokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
Label string `json:"label,omitempty"`
|
||||
LastAccess int64 `json:"last_access,omitempty"`
|
||||
LastOrigin string `json:"last_origin,omitempty"`
|
||||
Expires int64 `json:"expires,omitempty"` // Unix timestamp
|
||||
Token string `json:"token"`
|
||||
Label string `json:"label,omitempty"`
|
||||
LastAccess int64 `json:"last_access,omitempty"`
|
||||
LastOrigin string `json:"last_origin,omitempty"`
|
||||
Expires int64 `json:"expires,omitempty"` // Unix timestamp
|
||||
Provisioned bool `json:"provisioned,omitempty"` // True if this token was provisioned by the server config
|
||||
}
|
||||
|
||||
type apiAccountPhoneNumberVerifyRequest struct {
|
||||
@@ -382,6 +427,7 @@ type apiAccountResponse struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role,omitempty"`
|
||||
SyncTopic string `json:"sync_topic,omitempty"`
|
||||
Provisioned bool `json:"provisioned,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
Notification *user.NotificationPrefs `json:"notification,omitempty"`
|
||||
Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`
|
||||
@@ -403,6 +449,7 @@ type apiConfigResponse struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
AppRoot string `json:"app_root"`
|
||||
EnableLogin bool `json:"enable_login"`
|
||||
RequireLogin bool `json:"require_login"`
|
||||
EnableSignup bool `json:"enable_signup"`
|
||||
EnablePayments bool `json:"enable_payments"`
|
||||
EnableCalls bool `json:"enable_calls"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build !nofirebase
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -26,7 +28,7 @@ func main() {
|
||||
}
|
||||
data[kv[0]] = kv[1]
|
||||
}
|
||||
fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(*conffile))
|
||||
fb, err := firebase.NewApp(context.Background(), nil, option.WithAuthCredentialsFile(option.ServiceAccount, *conffile))
|
||||
if err != nil {
|
||||
fail(err.Error())
|
||||
}
|
||||
|
||||
690
user/manager.go
@@ -4,7 +4,6 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stripe/stripe-go/v74"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"net/netip"
|
||||
@@ -52,10 +51,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 +66,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")
|
||||
@@ -164,8 +163,8 @@ func TestManager_AddUser_And_Query(t *testing.T) {
|
||||
require.Nil(t, a.ChangeBilling("user", &Billing{
|
||||
StripeCustomerID: "acct_123",
|
||||
StripeSubscriptionID: "sub_123",
|
||||
StripeSubscriptionStatus: stripe.SubscriptionStatusActive,
|
||||
StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth,
|
||||
StripeSubscriptionStatus: "active",
|
||||
StripeSubscriptionInterval: "month",
|
||||
StripeSubscriptionPaidUntil: time.Now().Add(time.Hour),
|
||||
StripeSubscriptionCancelAt: time.Unix(0, 0),
|
||||
}))
|
||||
@@ -194,7 +193,7 @@ func TestManager_MarkUserRemoved_RemoveDeletedUsers(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
require.False(t, u.Deleted)
|
||||
|
||||
token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified())
|
||||
token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified(), false)
|
||||
require.Nil(t, err)
|
||||
|
||||
u, err = a.Authenticate("user", "pass")
|
||||
@@ -241,7 +240,7 @@ func TestManager_CreateToken_Only_Lower(t *testing.T) {
|
||||
u, err := a.User("user")
|
||||
require.Nil(t, err)
|
||||
|
||||
token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified())
|
||||
token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified(), false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, token.Value, strings.ToLower(token.Value))
|
||||
}
|
||||
@@ -277,10 +276,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 +291,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 +339,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 +353,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 +488,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)
|
||||
@@ -523,7 +522,7 @@ func TestManager_Token_Valid(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
|
||||
// Create token for user
|
||||
token, err := a.CreateToken(u.ID, "some label", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
|
||||
token, err := a.CreateToken(u.ID, "some label", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false)
|
||||
require.Nil(t, err)
|
||||
require.NotEmpty(t, token.Value)
|
||||
require.Equal(t, "some label", token.Label)
|
||||
@@ -586,12 +585,12 @@ func TestManager_Token_Expire(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
|
||||
// Create tokens for user
|
||||
token1, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
|
||||
token1, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false)
|
||||
require.Nil(t, err)
|
||||
require.NotEmpty(t, token1.Value)
|
||||
require.True(t, time.Now().Add(71*time.Hour).Unix() < token1.Expires.Unix())
|
||||
|
||||
token2, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
|
||||
token2, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false)
|
||||
require.Nil(t, err)
|
||||
require.NotEmpty(t, token2.Value)
|
||||
require.NotEqual(t, token1.Value, token2.Value)
|
||||
@@ -638,7 +637,7 @@ func TestManager_Token_Extend(t *testing.T) {
|
||||
require.Equal(t, errNoTokenProvided, err)
|
||||
|
||||
// Create token for user
|
||||
token, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
|
||||
token, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false)
|
||||
require.Nil(t, err)
|
||||
require.NotEmpty(t, token.Value)
|
||||
|
||||
@@ -668,12 +667,12 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
|
||||
|
||||
// Create 2 tokens for phil
|
||||
philTokens := make([]string, 0)
|
||||
token, err := a.CreateToken(phil.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
|
||||
token, err := a.CreateToken(phil.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false)
|
||||
require.Nil(t, err)
|
||||
require.NotEmpty(t, token.Value)
|
||||
philTokens = append(philTokens, token.Value)
|
||||
|
||||
token, err = a.CreateToken(phil.ID, "", time.Unix(0, 0), netip.IPv4Unspecified())
|
||||
token, err = a.CreateToken(phil.ID, "", time.Unix(0, 0), netip.IPv4Unspecified(), false)
|
||||
require.Nil(t, err)
|
||||
require.NotEmpty(t, token.Value)
|
||||
philTokens = append(philTokens, token.Value)
|
||||
@@ -682,7 +681,7 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
|
||||
baseTime := time.Now().Add(24 * time.Hour)
|
||||
benTokens := make([]string, 0)
|
||||
for i := 0; i < 62; i++ { //
|
||||
token, err := a.CreateToken(ben.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
|
||||
token, err := a.CreateToken(ben.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false)
|
||||
require.Nil(t, err)
|
||||
require.NotEmpty(t, token.Value)
|
||||
benTokens = append(benTokens, token.Value)
|
||||
@@ -731,7 +730,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 +779,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))
|
||||
|
||||
@@ -781,7 +794,7 @@ func TestManager_EnqueueTokenUpdate(t *testing.T) {
|
||||
u, err := a.User("ben")
|
||||
require.Nil(t, err)
|
||||
|
||||
token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified())
|
||||
token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified(), false)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Queue token update
|
||||
@@ -806,7 +819,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 +1095,237 @@ 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,
|
||||
Users: []*User{
|
||||
{Name: "philuser", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
|
||||
{Name: "philadmin", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleAdmin},
|
||||
},
|
||||
Access: map[string][]*Grant{
|
||||
"philuser": {
|
||||
{TopicPattern: "stats", Permission: PermissionReadWrite},
|
||||
{TopicPattern: "secret", Permission: PermissionRead},
|
||||
},
|
||||
},
|
||||
Tokens: map[string][]*Token{
|
||||
"philuser": {
|
||||
{Value: "tk_op56p8lz5bf3cxkz9je99v9oc37lo", Label: "Alerts token"},
|
||||
},
|
||||
},
|
||||
}
|
||||
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)
|
||||
require.Equal(t, "philuser", users[2].Name)
|
||||
require.Equal(t, RoleUser, users[2].Role)
|
||||
require.Equal(t, "*", users[3].Name)
|
||||
provisionedUserID := users[2].ID // "philuser" is the provisioned user
|
||||
|
||||
grants, err := a.Grants("philuser")
|
||||
require.Nil(t, err)
|
||||
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)
|
||||
|
||||
tokens, err := a.Tokens(provisionedUserID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(tokens))
|
||||
require.Equal(t, "tk_op56p8lz5bf3cxkz9je99v9oc37lo", tokens[0].Value)
|
||||
require.Equal(t, "Alerts token", tokens[0].Label)
|
||||
require.True(t, tokens[0].Provisioned)
|
||||
|
||||
// Update the token last access time and origin (so we can check that it is persisted)
|
||||
lastAccessTime := time.Now().Add(time.Hour)
|
||||
lastOrigin := netip.MustParseAddr("1.1.9.9")
|
||||
err = execTx(a.db, func(tx *sql.Tx) error {
|
||||
return a.updateTokenLastAccessTx(tx, tokens[0].Value, lastAccessTime.Unix(), lastOrigin.String())
|
||||
})
|
||||
require.Nil(t, err)
|
||||
|
||||
// Re-open the DB (second app start)
|
||||
require.Nil(t, a.db.Close())
|
||||
conf.Users = []*User{
|
||||
{Name: "philuser", Hash: "$2a$10$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
|
||||
}
|
||||
conf.Access = map[string][]*Grant{
|
||||
"philuser": {
|
||||
{TopicPattern: "stats12", Permission: PermissionReadWrite},
|
||||
{TopicPattern: "secret12", Permission: PermissionRead},
|
||||
},
|
||||
}
|
||||
conf.Tokens = map[string][]*Token{
|
||||
"philuser": {
|
||||
{Value: "tk_op56p8lz5bf3cxkz9je99v9oc37lo", Label: "Alerts token updated"},
|
||||
{Value: "tk_u48wqendnkx9er21pqqcadlytbutx", Label: "Another token"},
|
||||
},
|
||||
}
|
||||
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, "philuser", users[1].Name)
|
||||
require.Equal(t, RoleUser, users[1].Role)
|
||||
require.Equal(t, RoleUser, users[0].Role)
|
||||
require.Equal(t, "*", users[2].Name)
|
||||
|
||||
grants, err = a.Grants("philuser")
|
||||
require.Nil(t, err)
|
||||
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)
|
||||
|
||||
tokens, err = a.Tokens(provisionedUserID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(tokens))
|
||||
require.Equal(t, "tk_op56p8lz5bf3cxkz9je99v9oc37lo", tokens[0].Value)
|
||||
require.Equal(t, "Alerts token updated", tokens[0].Label)
|
||||
require.Equal(t, lastAccessTime.Unix(), tokens[0].LastAccess.Unix())
|
||||
require.Equal(t, lastOrigin, tokens[0].LastOrigin)
|
||||
require.True(t, tokens[0].Provisioned)
|
||||
require.Equal(t, "tk_u48wqendnkx9er21pqqcadlytbutx", tokens[1].Value)
|
||||
require.Equal(t, "Another token", tokens[1].Label)
|
||||
|
||||
// Try changing provisioned user's password
|
||||
require.Error(t, a.ChangePassword("philuser", "new-pass", false))
|
||||
|
||||
// Re-open the DB again (third app start)
|
||||
require.Nil(t, a.db.Close())
|
||||
conf.Users = []*User{}
|
||||
conf.Access = map[string][]*Grant{}
|
||||
conf.Tokens = map[string][]*Token{}
|
||||
a, err = NewManager(conf)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Check that the provisioned users are all gone
|
||||
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)
|
||||
|
||||
grants, err = a.Grants("philuser")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, len(grants))
|
||||
|
||||
tokens, err = a.Tokens(provisionedUserID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, len(tokens))
|
||||
|
||||
var count int
|
||||
a.db.QueryRow("SELECT COUNT(*) FROM user WHERE provisioned = 1").Scan(&count)
|
||||
require.Equal(t, 0, count)
|
||||
a.db.QueryRow("SELECT COUNT(*) FROM user_grant WHERE provisioned = 1").Scan(&count)
|
||||
require.Equal(t, 0, count)
|
||||
a.db.QueryRow("SELECT COUNT(*) FROM user_token WHERE provisioned = 1").Scan(&count)
|
||||
}
|
||||
|
||||
func TestManager_UpdateNonProvisionedUsersToProvisionedUsers(t *testing.T) {
|
||||
f := filepath.Join(t.TempDir(), "user.db")
|
||||
conf := &Config{
|
||||
Filename: f,
|
||||
DefaultAccess: PermissionReadWrite,
|
||||
ProvisionEnabled: true,
|
||||
Users: []*User{},
|
||||
Access: map[string][]*Grant{
|
||||
Everyone: {
|
||||
{TopicPattern: "food", Permission: PermissionRead},
|
||||
},
|
||||
},
|
||||
}
|
||||
a, err := NewManager(conf)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Manually add user
|
||||
require.Nil(t, a.AddUser("philuser", "manual", RoleUser, false))
|
||||
require.Nil(t, a.AllowAccess("philuser", "stats", PermissionReadWrite))
|
||||
require.Nil(t, a.AllowAccess("philuser", "food", PermissionReadWrite))
|
||||
|
||||
users, err := a.Users()
|
||||
require.Nil(t, err)
|
||||
require.Len(t, users, 2)
|
||||
require.Equal(t, "philuser", users[0].Name)
|
||||
require.Equal(t, RoleUser, users[0].Role)
|
||||
require.False(t, users[0].Provisioned) // Manually added
|
||||
|
||||
grants, err := a.Grants("philuser")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(grants))
|
||||
require.Equal(t, "stats", grants[0].TopicPattern)
|
||||
require.Equal(t, PermissionReadWrite, grants[0].Permission)
|
||||
require.False(t, grants[0].Provisioned) // Manually added
|
||||
require.Equal(t, "food", grants[1].TopicPattern)
|
||||
require.Equal(t, PermissionReadWrite, grants[1].Permission)
|
||||
require.False(t, grants[1].Provisioned) // Manually added
|
||||
|
||||
grants, err = a.Grants(Everyone)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(grants))
|
||||
require.Equal(t, "food", grants[0].TopicPattern)
|
||||
require.Equal(t, PermissionRead, grants[0].Permission)
|
||||
require.True(t, grants[0].Provisioned) // Provisioned entry
|
||||
|
||||
// Re-open the DB (second app start)
|
||||
require.Nil(t, a.db.Close())
|
||||
conf.Users = []*User{
|
||||
{Name: "philuser", Hash: "$2a$10$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
|
||||
}
|
||||
conf.Access = map[string][]*Grant{
|
||||
"philuser": {
|
||||
{TopicPattern: "stats", Permission: PermissionReadWrite},
|
||||
},
|
||||
}
|
||||
a, err = NewManager(conf)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Check that the user was "upgraded" to a provisioned user
|
||||
users, err = a.Users()
|
||||
require.Nil(t, err)
|
||||
require.Len(t, users, 2)
|
||||
require.Equal(t, "philuser", users[0].Name)
|
||||
require.Equal(t, RoleUser, users[0].Role)
|
||||
require.Equal(t, "$2a$10$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", users[0].Hash)
|
||||
require.True(t, users[0].Provisioned) // Updated to provisioned!
|
||||
|
||||
grants, err = a.Grants("philuser")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(grants))
|
||||
require.Equal(t, "stats", grants[0].TopicPattern)
|
||||
require.Equal(t, PermissionReadWrite, grants[0].Permission)
|
||||
require.True(t, grants[0].Provisioned) // Updated to provisioned!
|
||||
require.Equal(t, "food", grants[1].TopicPattern)
|
||||
require.Equal(t, PermissionReadWrite, grants[1].Permission)
|
||||
require.False(t, grants[1].Provisioned) // Manually added grants stay!
|
||||
|
||||
grants, err = a.Grants(Everyone)
|
||||
require.Nil(t, err)
|
||||
require.Empty(t, grants)
|
||||
}
|
||||
|
||||
func TestToFromSQLWildcard(t *testing.T) {
|
||||
require.Equal(t, "up%", toSQLWildcard("up*"))
|
||||
require.Equal(t, "up\\_%", toSQLWildcard("up_*"))
|
||||
@@ -1162,16 +1413,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 +1587,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
|
||||
}
|
||||
|
||||
100
user/types.go
@@ -2,27 +2,27 @@ package user
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/stripe/stripe-go/v74"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/payments"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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,
|
||||
@@ -58,11 +58,12 @@ type Auther interface {
|
||||
|
||||
// Token represents a user token, including expiry date
|
||||
type Token struct {
|
||||
Value string
|
||||
Label string
|
||||
LastAccess time.Time
|
||||
LastOrigin netip.Addr
|
||||
Expires time.Time
|
||||
Value string
|
||||
Label string
|
||||
LastAccess time.Time
|
||||
LastOrigin netip.Addr
|
||||
Expires time.Time
|
||||
Provisioned bool
|
||||
}
|
||||
|
||||
// TokenUpdate holds information about the last access time and origin IP address of a token
|
||||
@@ -139,8 +140,8 @@ type Stats struct {
|
||||
type Billing struct {
|
||||
StripeCustomerID string
|
||||
StripeSubscriptionID string
|
||||
StripeSubscriptionStatus stripe.SubscriptionStatus
|
||||
StripeSubscriptionInterval stripe.PriceRecurringInterval
|
||||
StripeSubscriptionStatus payments.SubscriptionStatus
|
||||
StripeSubscriptionInterval payments.PriceRecurringInterval
|
||||
StripeSubscriptionPaidUntil time.Time
|
||||
StripeSubscriptionCancelAt time.Time
|
||||
}
|
||||
@@ -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
|
||||
@@ -240,48 +242,20 @@ const (
|
||||
everyoneID = "u_everyone"
|
||||
)
|
||||
|
||||
var (
|
||||
allowedUsernameRegex = regexp.MustCompile(`^[-_.+@a-zA-Z0-9]+$`) // Does not include Everyone (*)
|
||||
allowedTopicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No '*'
|
||||
allowedTopicPatternRegex = regexp.MustCompile(`^[-_*A-Za-z0-9]{1,64}$`) // Adds '*' for wildcards!
|
||||
allowedTierRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`)
|
||||
)
|
||||
|
||||
// AllowedRole returns true if the given role can be used for new users
|
||||
func AllowedRole(role Role) bool {
|
||||
return role == RoleUser || role == RoleAdmin
|
||||
}
|
||||
|
||||
// AllowedUsername returns true if the given username is valid
|
||||
func AllowedUsername(username string) bool {
|
||||
return allowedUsernameRegex.MatchString(username)
|
||||
}
|
||||
|
||||
// AllowedTopic returns true if the given topic name is valid
|
||||
func AllowedTopic(topic string) bool {
|
||||
return allowedTopicRegex.MatchString(topic)
|
||||
}
|
||||
|
||||
// AllowedTopicPattern returns true if the given topic pattern is valid; this includes the wildcard character (*)
|
||||
func AllowedTopicPattern(topic string) bool {
|
||||
return allowedTopicPatternRegex.MatchString(topic)
|
||||
}
|
||||
|
||||
// AllowedTier returns true if the given tier name is valid
|
||||
func AllowedTier(tier string) bool {
|
||||
return allowedTierRegex.MatchString(tier)
|
||||
}
|
||||
|
||||
// Error constants used by the package
|
||||
var (
|
||||
ErrUnauthenticated = errors.New("unauthenticated")
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrInvalidArgument = errors.New("invalid argument")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUserExists = errors.New("user already exists")
|
||||
ErrTierNotFound = errors.New("tier not found")
|
||||
ErrTokenNotFound = errors.New("token not found")
|
||||
ErrPhoneNumberNotFound = errors.New("phone number not found")
|
||||
ErrTooManyReservations = errors.New("new tier has lower reservation limit")
|
||||
ErrPhoneNumberExists = errors.New("phone number already exists")
|
||||
ErrUnauthenticated = errors.New("unauthenticated")
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrInvalidArgument = errors.New("invalid argument")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUserExists = errors.New("user already exists")
|
||||
ErrPasswordHashInvalid = errors.New("password hash must be a bcrypt hash, use 'ntfy user hash' to generate")
|
||||
ErrPasswordHashWeak = errors.New("password hash too weak, 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")
|
||||
ErrTooManyReservations = errors.New("new tier has lower reservation limit")
|
||||
ErrPhoneNumberExists = errors.New("phone number already exists")
|
||||
ErrProvisionedUserChange = errors.New("cannot change or delete provisioned user")
|
||||
ErrProvisionedTokenChange = errors.New("cannot change or delete provisioned token")
|
||||
)
|
||||
|
||||
79
user/util.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
allowedUsernameRegex = regexp.MustCompile(`^[-_.+@a-zA-Z0-9]+$`) // Does not include Everyone (*)
|
||||
allowedTopicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No '*'
|
||||
allowedTopicPatternRegex = regexp.MustCompile(`^[-_*A-Za-z0-9]{1,64}$`) // Adds '*' for wildcards!
|
||||
allowedTierRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`)
|
||||
allowedTokenRegex = regexp.MustCompile(`^tk_[-_A-Za-z0-9]{29}$`) // Must be tokenLength-len(tokenPrefix)
|
||||
)
|
||||
|
||||
// AllowedRole returns true if the given role can be used for new users
|
||||
func AllowedRole(role Role) bool {
|
||||
return role == RoleUser || role == RoleAdmin
|
||||
}
|
||||
|
||||
// AllowedUsername returns true if the given username is valid
|
||||
func AllowedUsername(username string) bool {
|
||||
return allowedUsernameRegex.MatchString(username)
|
||||
}
|
||||
|
||||
// AllowedTopic returns true if the given topic name is valid
|
||||
func AllowedTopic(topic string) bool {
|
||||
return allowedTopicRegex.MatchString(topic)
|
||||
}
|
||||
|
||||
// AllowedTopicPattern returns true if the given topic pattern is valid; this includes the wildcard character (*)
|
||||
func AllowedTopicPattern(topic string) bool {
|
||||
return allowedTopicPatternRegex.MatchString(topic)
|
||||
}
|
||||
|
||||
// AllowedTier returns true if the given tier name is valid
|
||||
func AllowedTier(tier string) bool {
|
||||
return allowedTierRegex.MatchString(tier)
|
||||
}
|
||||
|
||||
// ValidPasswordHash checks if the given password hash is a valid bcrypt hash
|
||||
func ValidPasswordHash(hash string, minCost int) error {
|
||||
if !strings.HasPrefix(hash, "$2a$") && !strings.HasPrefix(hash, "$2b$") && !strings.HasPrefix(hash, "$2y$") {
|
||||
return ErrPasswordHashInvalid
|
||||
}
|
||||
cost, err := bcrypt.Cost([]byte(hash))
|
||||
if err != nil { // Check if the hash is valid (length, format, etc.)
|
||||
return err
|
||||
} else if cost < minCost {
|
||||
return ErrPasswordHashWeak
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidToken returns true if the given token matches the naming convention
|
||||
func ValidToken(token string) bool {
|
||||
return allowedTokenRegex.MatchString(token)
|
||||
}
|
||||
|
||||
// GenerateToken generates a new token with a prefix and a fixed length
|
||||
// Lowercase only to support "<topic>+<token>@<domain>" email addresses
|
||||
func GenerateToken() string {
|
||||
return util.RandomLowerStringPrefix(tokenPrefix, tokenLength)
|
||||
}
|
||||
|
||||
// HashPassword hashes the given password using bcrypt with the configured cost
|
||||
func HashPassword(password string) (string, error) {
|
||||
return hashPassword(password, DefaultUserPasswordBcryptCost)
|
||||
}
|
||||
|
||||
func hashPassword(password string, cost int) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), cost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
19
util/sprig/LICENSE.txt
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
Package sprig provides template functions for Go.
|
||||
|
||||
This package contains a number of utility functions for working with data
|
||||
inside of Go `html/template` and `text/template` files.
|
||||
|
||||
To add these functions, use the `template.Funcs()` method:
|
||||
|
||||
t := template.New("foo").Funcs(sprig.FuncMap())
|
||||
|
||||
Note that you should add the function map before you parse any template files.
|
||||
|
||||
In several cases, Sprig reverses the order of arguments from the way they
|
||||
appear in the standard library. This is to make it easier to pipe
|
||||
arguments into functions.
|
||||
|
||||
See http://masterminds.github.io/sprig/ for more detailed documentation on each of the available functions.
|
||||
*/
|
||||
package sprig
|
||||
25
util/sprig/example_test.go
Normal 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
|
||||
}
|
||||