Compare commits
208 Commits
client-ip-
...
v2.15.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,10 +1,10 @@
|
|||||||
|
version: 2
|
||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- go mod download
|
- go mod download
|
||||||
- go mod tidy
|
- go mod tidy
|
||||||
builds:
|
builds:
|
||||||
-
|
- id: ntfy_linux_amd64
|
||||||
id: ntfy_linux_amd64
|
|
||||||
binary: ntfy
|
binary: ntfy
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=1 # required for go-sqlite3
|
- CGO_ENABLED=1 # required for go-sqlite3
|
||||||
@@ -13,8 +13,7 @@ builds:
|
|||||||
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||||
goos: [ linux ]
|
goos: [ linux ]
|
||||||
goarch: [ amd64 ]
|
goarch: [ amd64 ]
|
||||||
-
|
- id: ntfy_linux_armv6
|
||||||
id: ntfy_linux_armv6
|
|
||||||
binary: ntfy
|
binary: ntfy
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=1 # required for go-sqlite3
|
- CGO_ENABLED=1 # required for go-sqlite3
|
||||||
@@ -25,8 +24,7 @@ builds:
|
|||||||
goos: [ linux ]
|
goos: [ linux ]
|
||||||
goarch: [ arm ]
|
goarch: [ arm ]
|
||||||
goarm: [ 6 ]
|
goarm: [ 6 ]
|
||||||
-
|
- id: ntfy_linux_armv7
|
||||||
id: ntfy_linux_armv7
|
|
||||||
binary: ntfy
|
binary: ntfy
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=1 # required for go-sqlite3
|
- CGO_ENABLED=1 # required for go-sqlite3
|
||||||
@@ -37,8 +35,7 @@ builds:
|
|||||||
goos: [ linux ]
|
goos: [ linux ]
|
||||||
goarch: [ arm ]
|
goarch: [ arm ]
|
||||||
goarm: [ 7 ]
|
goarm: [ 7 ]
|
||||||
-
|
- id: ntfy_linux_arm64
|
||||||
id: ntfy_linux_arm64
|
|
||||||
binary: ntfy
|
binary: ntfy
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=1 # required for go-sqlite3
|
- CGO_ENABLED=1 # required for go-sqlite3
|
||||||
@@ -48,8 +45,7 @@ builds:
|
|||||||
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||||
goos: [ linux ]
|
goos: [ linux ]
|
||||||
goarch: [ arm64 ]
|
goarch: [ arm64 ]
|
||||||
-
|
- id: ntfy_windows_amd64
|
||||||
id: ntfy_windows_amd64
|
|
||||||
binary: ntfy
|
binary: ntfy
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
|
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
|
||||||
@@ -58,8 +54,7 @@ builds:
|
|||||||
- "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
- "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||||
goos: [ windows ]
|
goos: [ windows ]
|
||||||
goarch: [ amd64 ]
|
goarch: [ amd64 ]
|
||||||
-
|
- id: ntfy_darwin_all
|
||||||
id: ntfy_darwin_all
|
|
||||||
binary: ntfy
|
binary: ntfy
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
|
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
|
||||||
@@ -69,8 +64,7 @@ builds:
|
|||||||
goos: [ darwin ]
|
goos: [ darwin ]
|
||||||
goarch: [ amd64, arm64 ] # will be combined to "universal binary" (see below)
|
goarch: [ amd64, arm64 ] # will be combined to "universal binary" (see below)
|
||||||
nfpms:
|
nfpms:
|
||||||
-
|
- package_name: ntfy
|
||||||
package_name: ntfy
|
|
||||||
homepage: https://heckel.io/ntfy
|
homepage: https://heckel.io/ntfy
|
||||||
maintainer: Philipp C. Heckel <philipp.heckel@gmail.com>
|
maintainer: Philipp C. Heckel <philipp.heckel@gmail.com>
|
||||||
description: Simple pub-sub notification service
|
description: Simple pub-sub notification service
|
||||||
@@ -106,9 +100,8 @@ nfpms:
|
|||||||
preremove: "scripts/prerm.sh"
|
preremove: "scripts/prerm.sh"
|
||||||
postremove: "scripts/postrm.sh"
|
postremove: "scripts/postrm.sh"
|
||||||
archives:
|
archives:
|
||||||
-
|
- id: ntfy_linux
|
||||||
id: ntfy_linux
|
ids:
|
||||||
builds:
|
|
||||||
- ntfy_linux_amd64
|
- ntfy_linux_amd64
|
||||||
- ntfy_linux_armv6
|
- ntfy_linux_armv6
|
||||||
- ntfy_linux_armv7
|
- ntfy_linux_armv7
|
||||||
@@ -122,19 +115,17 @@ archives:
|
|||||||
- client/client.yml
|
- client/client.yml
|
||||||
- client/ntfy-client.service
|
- client/ntfy-client.service
|
||||||
- client/user/ntfy-client.service
|
- client/user/ntfy-client.service
|
||||||
-
|
- id: ntfy_windows
|
||||||
id: ntfy_windows
|
ids:
|
||||||
builds:
|
|
||||||
- ntfy_windows_amd64
|
- ntfy_windows_amd64
|
||||||
format: zip
|
formats: [ zip ]
|
||||||
wrap_in_directory: true
|
wrap_in_directory: true
|
||||||
files:
|
files:
|
||||||
- LICENSE
|
- LICENSE
|
||||||
- README.md
|
- README.md
|
||||||
- client/client.yml
|
- client/client.yml
|
||||||
-
|
- id: ntfy_darwin
|
||||||
id: ntfy_darwin
|
ids:
|
||||||
builds:
|
|
||||||
- ntfy_darwin_all
|
- ntfy_darwin_all
|
||||||
wrap_in_directory: true
|
wrap_in_directory: true
|
||||||
files:
|
files:
|
||||||
@@ -142,14 +133,13 @@ archives:
|
|||||||
- README.md
|
- README.md
|
||||||
- client/client.yml
|
- client/client.yml
|
||||||
universal_binaries:
|
universal_binaries:
|
||||||
-
|
- id: ntfy_darwin_all
|
||||||
id: ntfy_darwin_all
|
|
||||||
replace: true
|
replace: true
|
||||||
name_template: ntfy
|
name_template: ntfy
|
||||||
checksum:
|
checksum:
|
||||||
name_template: 'checksums.txt'
|
name_template: 'checksums.txt'
|
||||||
snapshot:
|
snapshot:
|
||||||
name_template: "{{ .Tag }}-next"
|
version_template: "{{ .Tag }}-next"
|
||||||
changelog:
|
changelog:
|
||||||
sort: asc
|
sort: asc
|
||||||
filters:
|
filters:
|
||||||
|
|||||||
6
Makefile
@@ -220,7 +220,7 @@ cli-deps-static-sites:
|
|||||||
touch server/docs/index.html server/site/app.html
|
touch server/docs/index.html server/site/app.html
|
||||||
|
|
||||||
cli-deps-all:
|
cli-deps-all:
|
||||||
go install github.com/goreleaser/goreleaser@latest
|
go install github.com/goreleaser/goreleaser/v2@latest
|
||||||
|
|
||||||
cli-deps-gcc-armv6-armv7:
|
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; }
|
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 get -u
|
||||||
go install honnef.co/go/tools/cmd/staticcheck@latest
|
go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||||
go install golang.org/x/lint/golint@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:
|
cli-build-results:
|
||||||
cat dist/config.yaml
|
cat dist/config.yaml
|
||||||
@@ -301,7 +301,7 @@ release: clean cli-deps release-checks docs web check
|
|||||||
goreleaser release --clean
|
goreleaser release --clean
|
||||||
|
|
||||||
release-snapshot: clean cli-deps docs web check
|
release-snapshot: clean cli-deps docs web check
|
||||||
goreleaser release --snapshot --skip-publish --clean
|
goreleaser release --snapshot --clean
|
||||||
|
|
||||||
release-checks:
|
release-checks:
|
||||||
$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-))
|
$(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
|
# 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,
|
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).
|
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
|
## Sponsors
|
||||||
I have just very recently started accepting donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier),
|
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). I would be humbled if you helped me carry the server and developer
|
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. A big fat **Thank You** to the folks who have sponsored ntfy in the past, or are still sponsoring ntfy:
|
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/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>
|
<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/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>
|
<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/),
|
## Contributing
|
||||||
and [DigitalOcean](https://m.do.co/c/442b929528db) (*referral link*) for supporting the project:
|
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
|
## 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.**
|
**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)
|
* [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)
|
* [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
|
* [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
|
||||||
|
|||||||
BIN
assets/sponsors/magicbell.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
@@ -21,7 +21,7 @@
|
|||||||
# default-command:
|
# default-command:
|
||||||
|
|
||||||
# Subscriptions to topics and their actions. This option is primarily used by the systemd service,
|
# 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:
|
# Example:
|
||||||
# subscribe:
|
# subscribe:
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ func WithMarkdown() PublishOption {
|
|||||||
return WithHeader("X-Markdown", "yes")
|
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
|
// WithFilename sets a filename for the attachment, and/or forces the HTTP body to interpreted as an attachment
|
||||||
func WithFilename(filename string) PublishOption {
|
func WithFilename(filename string) PublishOption {
|
||||||
return WithHeader("X-Filename", filename)
|
return WithHeader("X-Filename", filename)
|
||||||
|
|||||||
@@ -105,8 +105,10 @@ func changeAccess(c *cli.Context, manager *user.Manager, username string, topic
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
u, err := manager.User(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)
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
} else if u.Role == user.RoleAdmin {
|
} else if u.Role == user.RoleAdmin {
|
||||||
return fmt.Errorf("user %s is an admin user, access control entries have no effect", username)
|
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
|
return err
|
||||||
}
|
}
|
||||||
if permission.IsReadWrite() {
|
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() {
|
} 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() {
|
} 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 {
|
} 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)
|
return showUserAccess(c, manager, username)
|
||||||
}
|
}
|
||||||
@@ -138,7 +140,7 @@ func resetAllAccess(c *cli.Context, manager *user.Manager) error {
|
|||||||
if err := manager.ResetAccess("", ""); err != nil {
|
if err := manager.ResetAccess("", ""); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Fprintln(c.App.ErrWriter, "reset access for all users")
|
fmt.Fprintln(c.App.Writer, "reset access for all users")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +148,7 @@ func resetUserAccess(c *cli.Context, manager *user.Manager, username string) err
|
|||||||
if err := manager.ResetAccess(username, ""); err != nil {
|
if err := manager.ResetAccess(username, ""); err != nil {
|
||||||
return err
|
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)
|
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 {
|
if err := manager.ResetAccess(username, topic); err != nil {
|
||||||
return err
|
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)
|
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 {
|
func showUserAccess(c *cli.Context, manager *user.Manager, username string) error {
|
||||||
users, err := manager.User(username)
|
users, err := manager.User(username)
|
||||||
if err == user.ErrUserNotFound {
|
if errors.Is(err, user.ErrUserNotFound) {
|
||||||
return fmt.Errorf("user %s does not exist", username)
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -193,34 +195,42 @@ func showUsers(c *cli.Context, manager *user.Manager, users []*user.User) error
|
|||||||
if u.Tier != nil {
|
if u.Tier != nil {
|
||||||
tier = u.Tier.Name
|
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 {
|
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 {
|
} else if len(grants) > 0 {
|
||||||
for _, grant := range grants {
|
for _, grant := range grants {
|
||||||
if grant.Allow.IsReadWrite() {
|
grantProvisioned := ""
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- read-write access to topic %s\n", grant.TopicPattern)
|
if grant.Provisioned {
|
||||||
} else if grant.Allow.IsRead() {
|
grantProvisioned = " (server config)"
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- read-only access to topic %s\n", grant.TopicPattern)
|
}
|
||||||
} else if grant.Allow.IsWrite() {
|
if grant.Permission.IsReadWrite() {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- write-only access to topic %s\n", grant.TopicPattern)
|
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 {
|
} 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 {
|
} 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 {
|
if u.Name == user.Everyone {
|
||||||
access := manager.DefaultAccess()
|
access := manager.DefaultAccess()
|
||||||
if access.IsReadWrite() {
|
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() {
|
} 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() {
|
} 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 {
|
} 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)
|
s, conf, port := newTestServerWithAuth(t)
|
||||||
defer test.StopServer(t, s, port)
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
app, _, _, stderr := newTestApp()
|
app, _, stdout, _ := newTestApp()
|
||||||
require.Nil(t, runAccessCommand(app, conf))
|
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) {
|
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, "ben", "sometopic", "read"))
|
||||||
require.Nil(t, runAccessCommand(app, conf, "everyone", "announcements", "read"))
|
require.Nil(t, runAccessCommand(app, conf, "everyone", "announcements", "read"))
|
||||||
|
|
||||||
app, _, _, stderr := newTestApp()
|
app, _, stdout, _ := newTestApp()
|
||||||
require.Nil(t, runAccessCommand(app, conf))
|
require.Nil(t, runAccessCommand(app, conf))
|
||||||
expected := `user phil (role: admin, tier: none)
|
expected := `user phil (role: admin, tier: none)
|
||||||
- read-write access to all topics (admin role)
|
- read-write access to all topics (admin role)
|
||||||
@@ -41,7 +41,7 @@ user * (role: anonymous, tier: none)
|
|||||||
- read-only access to topic announcements
|
- read-only access to topic announcements
|
||||||
- no access to any (other) topics (server config)
|
- 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
|
// See if access permissions match
|
||||||
app, _, _, _ = newTestApp()
|
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: "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.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.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: "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: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"},
|
||||||
&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"},
|
&cli.StringFlag{Name: "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 --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 --attach="http://some.tld/file.zip" files # Send ZIP archive from URL as attachment
|
||||||
ntfy pub --file=flower.jpg flowers 'Nice!' # Send image.jpg as attachment
|
ntfy pub --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 -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-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
|
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")
|
actions := c.String("actions")
|
||||||
attach := c.String("attach")
|
attach := c.String("attach")
|
||||||
markdown := c.Bool("markdown")
|
markdown := c.Bool("markdown")
|
||||||
|
template := c.String("template")
|
||||||
filename := c.String("filename")
|
filename := c.String("filename")
|
||||||
file := c.String("file")
|
file := c.String("file")
|
||||||
email := c.String("email")
|
email := c.String("email")
|
||||||
@@ -145,6 +148,9 @@ func execPublish(c *cli.Context) error {
|
|||||||
if markdown {
|
if markdown {
|
||||||
options = append(options, client.WithMarkdown())
|
options = append(options, client.WithMarkdown())
|
||||||
}
|
}
|
||||||
|
if template != "" {
|
||||||
|
options = append(options, client.WithTemplate(template))
|
||||||
|
}
|
||||||
if filename != "" {
|
if filename != "" {
|
||||||
options = append(options, client.WithFilename(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") != "" {
|
if c.String("message") != "" {
|
||||||
message = 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,3 +327,12 @@ func runAndWaitForCommand(command []string) (message string, err error) {
|
|||||||
log.Debug("Command succeeded after %s: %s", runtime, prettyCmd)
|
log.Debug("Command succeeded after %s: %s", runtime, prettyCmd)
|
||||||
return fmt.Sprintf("Command succeeded after %s: %s", runtime, prettyCmd), nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
213
cmd/serve.go
@@ -5,13 +5,6 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"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"
|
"io/fs"
|
||||||
"math"
|
"math"
|
||||||
"net"
|
"net"
|
||||||
@@ -22,19 +15,23 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"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() {
|
func init() {
|
||||||
commands = append(commands, cmdServe)
|
commands = append(commands, cmdServe)
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
|
||||||
defaultServerConfigFile = "/etc/ntfy/server.yml"
|
|
||||||
)
|
|
||||||
|
|
||||||
var flagsServe = append(
|
var flagsServe = append(
|
||||||
append([]cli.Flag{}, flagsDefault...),
|
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: "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-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"}),
|
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-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-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.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-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-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-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: "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: "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.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"}),
|
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-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-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: "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-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: "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"}),
|
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.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: "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.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-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.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"}),
|
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-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.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.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.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-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-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: "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)"}),
|
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")
|
authFile := c.String("auth-file")
|
||||||
authStartupQueries := c.String("auth-startup-queries")
|
authStartupQueries := c.String("auth-startup-queries")
|
||||||
authDefaultAccess := c.String("auth-default-access")
|
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")
|
attachmentCacheDir := c.String("attachment-cache-dir")
|
||||||
attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit")
|
attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit")
|
||||||
attachmentFileSizeLimitStr := c.String("attachment-file-size-limit")
|
attachmentFileSizeLimitStr := c.String("attachment-file-size-limit")
|
||||||
attachmentExpiryDurationStr := c.String("attachment-expiry-duration")
|
attachmentExpiryDurationStr := c.String("attachment-expiry-duration")
|
||||||
|
templateDir := c.String("template-dir")
|
||||||
keepaliveIntervalStr := c.String("keepalive-interval")
|
keepaliveIntervalStr := c.String("keepalive-interval")
|
||||||
managerIntervalStr := c.String("manager-interval")
|
managerIntervalStr := c.String("manager-interval")
|
||||||
disallowedTopics := c.StringSlice("disallowed-topics")
|
disallowedTopics := c.StringSlice("disallowed-topics")
|
||||||
webRoot := c.String("web-root")
|
webRoot := c.String("web-root")
|
||||||
enableSignup := c.Bool("enable-signup")
|
enableSignup := c.Bool("enable-signup")
|
||||||
enableLogin := c.Bool("enable-login")
|
enableLogin := c.Bool("enable-login")
|
||||||
|
requireLogin := c.Bool("require-login")
|
||||||
enableReservations := c.Bool("enable-reservations")
|
enableReservations := c.Bool("enable-reservations")
|
||||||
upstreamBaseURL := c.String("upstream-base-url")
|
upstreamBaseURL := c.String("upstream-base-url")
|
||||||
upstreamAccessToken := c.String("upstream-access-token")
|
upstreamAccessToken := c.String("upstream-access-token")
|
||||||
@@ -191,9 +200,11 @@ func execServe(c *cli.Context) error {
|
|||||||
visitorMessageDailyLimit := c.Int("visitor-message-daily-limit")
|
visitorMessageDailyLimit := c.Int("visitor-message-daily-limit")
|
||||||
visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
|
visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
|
||||||
visitorEmailLimitReplenishStr := c.String("visitor-email-limit-replenish")
|
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")
|
behindProxy := c.Bool("behind-proxy")
|
||||||
proxyForwardedHeader := c.String("proxy-forwarded-header")
|
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")
|
stripeSecretKey := c.String("stripe-secret-key")
|
||||||
stripeWebhookKey := c.String("stripe-webhook-key")
|
stripeWebhookKey := c.String("stripe-webhook-key")
|
||||||
billingContact := c.String("billing-contact")
|
billingContact := c.String("billing-contact")
|
||||||
@@ -270,6 +281,8 @@ func execServe(c *cli.Context) error {
|
|||||||
// Check values
|
// Check values
|
||||||
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
|
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
|
||||||
return errors.New("if set, FCM key file must exist")
|
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 == "") {
|
} 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")
|
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 {
|
} 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")
|
return errors.New("if upstream-base-url is set, base-url must also be set")
|
||||||
} else if upstreamBaseURL != "" && baseURL != "" && baseURL == upstreamBaseURL {
|
} 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")
|
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 != "") {
|
} else if authFile == "" && (enableSignup || enableLogin || requireLogin || enableReservations || stripeSecretKey != "") {
|
||||||
return errors.New("cannot set enable-signup, enable-login, enable-reserve-topics, or stripe-secret-key if auth-file is not set")
|
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 {
|
} else if enableSignup && !enableLogin {
|
||||||
return errors.New("cannot set enable-signup without also setting enable-login")
|
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 == "") {
|
} else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") {
|
||||||
return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set")
|
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 == "") {
|
} else if twilioAccount != "" && (twilioAuthToken == "" || twilioPhoneNumber == "" || twilioVerifyService == "" || baseURL == "" || authFile == "") {
|
||||||
@@ -320,10 +337,16 @@ func execServe(c *cli.Context) error {
|
|||||||
if messageSizeLimit > 5*1024*1024 {
|
if messageSizeLimit > 5*1024*1024 {
|
||||||
return errors.New("message-size-limit cannot be higher than 5M")
|
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 {
|
} else if webPushExpiryWarningDuration > 0 && webPushExpiryWarningDuration > webPushExpiryDuration {
|
||||||
return errors.New("web push expiry warning duration cannot be higher than web push expiry duration")
|
return errors.New("web push expiry warning duration cannot be higher than web push expiry duration")
|
||||||
} else if behindProxy && proxyForwardedHeader == "" {
|
} else if behindProxy && proxyForwardedHeader == "" {
|
||||||
return errors.New("if behind-proxy is set, proxy-forwarded-header must also be set")
|
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
|
// Backwards compatibility
|
||||||
@@ -337,11 +360,23 @@ func execServe(c *cli.Context) error {
|
|||||||
webRoot = "/" + webRoot
|
webRoot = "/" + webRoot
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default auth permissions
|
// Convert default auth permission, read provisioned users
|
||||||
authDefault, err := user.ParsePermission(authDefaultAccess)
|
authDefault, err := user.ParsePermission(authDefaultAccess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
|
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
|
// Special case: Unset default
|
||||||
if listenHTTP == "-" {
|
if listenHTTP == "-" {
|
||||||
@@ -349,20 +384,29 @@ func execServe(c *cli.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve hosts
|
// Resolve hosts
|
||||||
visitorRequestLimitExemptIPs := make([]netip.Prefix, 0)
|
visitorRequestLimitExemptPrefixes := make([]netip.Prefix, 0)
|
||||||
for _, host := range visitorRequestLimitExemptHosts {
|
for _, host := range visitorRequestLimitExemptHosts {
|
||||||
ips, err := parseIPHostPrefix(host)
|
prefixes, err := parseIPHostPrefix(host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("cannot resolve host %s: %s, ignoring visitor request exemption", host, err.Error())
|
log.Warn("cannot resolve host %s: %s, ignoring visitor request exemption", host, err.Error())
|
||||||
continue
|
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
|
// Stripe things
|
||||||
if stripeSecretKey != "" {
|
if stripeSecretKey != "" {
|
||||||
stripe.EnableTelemetry = false // Whoa!
|
payments.Setup(stripeSecretKey)
|
||||||
stripe.Key = stripeSecretKey
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add default forbidden topics
|
// Add default forbidden topics
|
||||||
@@ -387,10 +431,14 @@ func execServe(c *cli.Context) error {
|
|||||||
conf.AuthFile = authFile
|
conf.AuthFile = authFile
|
||||||
conf.AuthStartupQueries = authStartupQueries
|
conf.AuthStartupQueries = authStartupQueries
|
||||||
conf.AuthDefault = authDefault
|
conf.AuthDefault = authDefault
|
||||||
|
conf.AuthUsers = authUsers
|
||||||
|
conf.AuthAccess = authAccess
|
||||||
|
conf.AuthTokens = authTokens
|
||||||
conf.AttachmentCacheDir = attachmentCacheDir
|
conf.AttachmentCacheDir = attachmentCacheDir
|
||||||
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
|
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
|
||||||
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
|
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
|
||||||
conf.AttachmentExpiryDuration = attachmentExpiryDuration
|
conf.AttachmentExpiryDuration = attachmentExpiryDuration
|
||||||
|
conf.TemplateDir = templateDir
|
||||||
conf.KeepaliveInterval = keepaliveInterval
|
conf.KeepaliveInterval = keepaliveInterval
|
||||||
conf.ManagerInterval = managerInterval
|
conf.ManagerInterval = managerInterval
|
||||||
conf.DisallowedTopics = disallowedTopics
|
conf.DisallowedTopics = disallowedTopics
|
||||||
@@ -412,28 +460,30 @@ func execServe(c *cli.Context) error {
|
|||||||
conf.MessageDelayMax = messageDelayLimit
|
conf.MessageDelayMax = messageDelayLimit
|
||||||
conf.TotalTopicLimit = totalTopicLimit
|
conf.TotalTopicLimit = totalTopicLimit
|
||||||
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
|
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
|
||||||
|
conf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting
|
||||||
conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit
|
conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit
|
||||||
conf.VisitorAttachmentDailyBandwidthLimit = visitorAttachmentDailyBandwidthLimit
|
conf.VisitorAttachmentDailyBandwidthLimit = visitorAttachmentDailyBandwidthLimit
|
||||||
conf.VisitorRequestLimitBurst = visitorRequestLimitBurst
|
conf.VisitorRequestLimitBurst = visitorRequestLimitBurst
|
||||||
conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish
|
conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish
|
||||||
conf.VisitorRequestExemptIPAddrs = visitorRequestLimitExemptIPs
|
conf.VisitorRequestExemptPrefixes = visitorRequestLimitExemptPrefixes
|
||||||
conf.VisitorMessageDailyLimit = visitorMessageDailyLimit
|
conf.VisitorMessageDailyLimit = visitorMessageDailyLimit
|
||||||
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
|
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
|
||||||
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
|
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
|
||||||
conf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting
|
conf.VisitorPrefixBitsIPv4 = visitorPrefixBitsIPv4
|
||||||
|
conf.VisitorPrefixBitsIPv6 = visitorPrefixBitsIPv6
|
||||||
conf.BehindProxy = behindProxy
|
conf.BehindProxy = behindProxy
|
||||||
conf.ProxyForwardedHeader = proxyForwardedHeader
|
conf.ProxyForwardedHeader = proxyForwardedHeader
|
||||||
conf.ProxyTrustedAddresses = proxyTrustedAddresses
|
conf.ProxyTrustedPrefixes = trustedProxyPrefixes
|
||||||
conf.StripeSecretKey = stripeSecretKey
|
conf.StripeSecretKey = stripeSecretKey
|
||||||
conf.StripeWebhookKey = stripeWebhookKey
|
conf.StripeWebhookKey = stripeWebhookKey
|
||||||
conf.BillingContact = billingContact
|
conf.BillingContact = billingContact
|
||||||
conf.EnableSignup = enableSignup
|
conf.EnableSignup = enableSignup
|
||||||
conf.EnableLogin = enableLogin
|
conf.EnableLogin = enableLogin
|
||||||
|
conf.RequireLogin = requireLogin
|
||||||
conf.EnableReservations = enableReservations
|
conf.EnableReservations = enableReservations
|
||||||
conf.EnableMetrics = enableMetrics
|
conf.EnableMetrics = enableMetrics
|
||||||
conf.MetricsListenHTTP = metricsListenHTTP
|
conf.MetricsListenHTTP = metricsListenHTTP
|
||||||
conf.ProfileListenHTTP = profileListenHTTP
|
conf.ProfileListenHTTP = profileListenHTTP
|
||||||
conf.Version = c.App.Version
|
|
||||||
conf.WebPushPrivateKey = webPushPrivateKey
|
conf.WebPushPrivateKey = webPushPrivateKey
|
||||||
conf.WebPushPublicKey = webPushPublicKey
|
conf.WebPushPublicKey = webPushPublicKey
|
||||||
conf.WebPushFile = webPushFile
|
conf.WebPushFile = webPushFile
|
||||||
@@ -441,6 +491,7 @@ func execServe(c *cli.Context) error {
|
|||||||
conf.WebPushStartupQueries = webPushStartupQueries
|
conf.WebPushStartupQueries = webPushStartupQueries
|
||||||
conf.WebPushExpiryDuration = webPushExpiryDuration
|
conf.WebPushExpiryDuration = webPushExpiryDuration
|
||||||
conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration
|
conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration
|
||||||
|
conf.Version = c.App.Version
|
||||||
|
|
||||||
// Set up hot-reloading of config
|
// Set up hot-reloading of config
|
||||||
go sigHandlerConfigReload(config)
|
go sigHandlerConfigReload(config)
|
||||||
@@ -473,7 +524,7 @@ func sigHandlerConfigReload(config string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) {
|
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)
|
prefix, err := netip.ParsePrefix(host)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
prefixes = append(prefixes, prefix.Masked())
|
prefixes = append(prefixes, prefix.Masked())
|
||||||
@@ -497,6 +548,112 @@ func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) {
|
|||||||
return
|
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 {
|
func reloadLogLevel(inputSource altsrc.InputSourceContext) error {
|
||||||
newLevelStr, err := inputSource.String("log-level")
|
newLevelStr, err := inputSource.String("log-level")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -14,9 +14,461 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"heckel.io/ntfy/v2/client"
|
"heckel.io/ntfy/v2/client"
|
||||||
"heckel.io/ntfy/v2/test"
|
"heckel.io/ntfy/v2/test"
|
||||||
|
"heckel.io/ntfy/v2/user"
|
||||||
"heckel.io/ntfy/v2/util"
|
"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) {
|
func TestCLI_Serve_Unix_Curl(t *testing.T) {
|
||||||
sockFile := filepath.Join(t.TempDir(), "ntfy.sock")
|
sockFile := filepath.Join(t.TempDir(), "ntfy.sock")
|
||||||
configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system
|
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 tier, _ := manager.Tier(code); tier != nil {
|
||||||
if c.Bool("ignore-exists") {
|
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 nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("tier %s already exists", code)
|
return fmt.Errorf("tier %s already exists", code)
|
||||||
@@ -234,7 +234,7 @@ func execTierAdd(c *cli.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(c.App.ErrWriter, "tier added\n\n")
|
fmt.Fprintf(c.App.Writer, "tier added\n\n")
|
||||||
printTier(c, tier)
|
printTier(c, tier)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -315,7 +315,7 @@ func execTierChange(c *cli.Context) error {
|
|||||||
if err := manager.UpdateTier(tier); err != nil {
|
if err := manager.UpdateTier(tier); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(c.App.ErrWriter, "tier updated\n\n")
|
fmt.Fprintf(c.App.Writer, "tier updated\n\n")
|
||||||
printTier(c, tier)
|
printTier(c, tier)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -335,7 +335,7 @@ func execTierDel(c *cli.Context) error {
|
|||||||
if err := manager.RemoveTier(code); err != nil {
|
if err := manager.RemoveTier(code); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(c.App.ErrWriter, "tier %s removed\n", code)
|
fmt.Fprintf(c.App.Writer, "tier %s removed\n", code)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,16 +359,16 @@ func printTier(c *cli.Context, tier *user.Tier) {
|
|||||||
if tier.StripeMonthlyPriceID != "" && tier.StripeYearlyPriceID != "" {
|
if tier.StripeMonthlyPriceID != "" && tier.StripeYearlyPriceID != "" {
|
||||||
prices = fmt.Sprintf("%s / %s", 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.Writer, "tier %s (id: %s)\n", tier.Code, tier.ID)
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Name: %s\n", tier.Name)
|
fmt.Fprintf(c.App.Writer, "- Name: %s\n", tier.Name)
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Message limit: %d\n", tier.MessageLimit)
|
fmt.Fprintf(c.App.Writer, "- 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.Writer, "- 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.Writer, "- Email limit: %d\n", tier.EmailLimit)
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Phone call limit: %d\n", tier.CallLimit)
|
fmt.Fprintf(c.App.Writer, "- Phone call limit: %d\n", tier.CallLimit)
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit)
|
fmt.Fprintf(c.App.Writer, "- 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.Writer, "- 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.Writer, "- 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.Writer, "- 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.Writer, "- 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, "- Stripe prices (monthly/yearly): %s\n", prices)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,21 +12,21 @@ func TestCLI_Tier_AddListChangeDelete(t *testing.T) {
|
|||||||
s, conf, port := newTestServerWithAuth(t)
|
s, conf, port := newTestServerWithAuth(t)
|
||||||
defer test.StopServer(t, s, port)
|
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.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")
|
err := runTierCommand(app, conf, "add", "pro")
|
||||||
require.NotNil(t, err)
|
require.NotNil(t, err)
|
||||||
require.Equal(t, "tier pro already exists", err.Error())
|
require.Equal(t, "tier pro already exists", err.Error())
|
||||||
|
|
||||||
app, _, _, stderr = newTestApp()
|
app, _, stdout, _ = newTestApp()
|
||||||
require.Nil(t, runTierCommand(app, conf, "list"))
|
require.Nil(t, runTierCommand(app, conf, "list"))
|
||||||
require.Contains(t, stderr.String(), "tier pro (id: ti_")
|
require.Contains(t, stdout.String(), "tier pro (id: ti_")
|
||||||
require.Contains(t, stderr.String(), "- Name: Pro")
|
require.Contains(t, stdout.String(), "- Name: Pro")
|
||||||
require.Contains(t, stderr.String(), "- Message limit: 1234")
|
require.Contains(t, stdout.String(), "- Message limit: 1234")
|
||||||
|
|
||||||
app, _, _, stderr = newTestApp()
|
app, _, stdout, _ = newTestApp()
|
||||||
require.Nil(t, runTierCommand(app, conf, "change",
|
require.Nil(t, runTierCommand(app, conf, "change",
|
||||||
"--message-limit=999",
|
"--message-limit=999",
|
||||||
"--message-expiry-duration=2d",
|
"--message-expiry-duration=2d",
|
||||||
@@ -40,18 +40,18 @@ func TestCLI_Tier_AddListChangeDelete(t *testing.T) {
|
|||||||
"--stripe-yearly-price-id=price_992",
|
"--stripe-yearly-price-id=price_992",
|
||||||
"pro",
|
"pro",
|
||||||
))
|
))
|
||||||
require.Contains(t, stderr.String(), "- Message limit: 999")
|
require.Contains(t, stdout.String(), "- Message limit: 999")
|
||||||
require.Contains(t, stderr.String(), "- Message expiry duration: 48h")
|
require.Contains(t, stdout.String(), "- Message expiry duration: 48h")
|
||||||
require.Contains(t, stderr.String(), "- Email limit: 91")
|
require.Contains(t, stdout.String(), "- Email limit: 91")
|
||||||
require.Contains(t, stderr.String(), "- Reservation limit: 98")
|
require.Contains(t, stdout.String(), "- Reservation limit: 98")
|
||||||
require.Contains(t, stderr.String(), "- Attachment file size limit: 100.0 MB")
|
require.Contains(t, stdout.String(), "- Attachment file size limit: 100.0 MB")
|
||||||
require.Contains(t, stderr.String(), "- Attachment expiry duration: 24h")
|
require.Contains(t, stdout.String(), "- Attachment expiry duration: 24h")
|
||||||
require.Contains(t, stderr.String(), "- Attachment total size limit: 10.0 GB")
|
require.Contains(t, stdout.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(), "- Stripe prices (monthly/yearly): price_991 / price_992")
|
||||||
|
|
||||||
app, _, _, stderr = newTestApp()
|
app, _, stdout, _ = newTestApp()
|
||||||
require.Nil(t, runTierCommand(app, conf, "remove", "pro"))
|
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 {
|
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
|
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.`,
|
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.
|
Description: `Manage access tokens for individual users.
|
||||||
|
|
||||||
@@ -112,19 +121,19 @@ func execTokenAdd(c *cli.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
u, err := manager.User(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)
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return err
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if expires.Unix() == 0 {
|
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 {
|
} 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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -141,7 +150,7 @@ func execTokenDel(c *cli.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
u, err := manager.User(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)
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -149,7 +158,7 @@ func execTokenDel(c *cli.Context) error {
|
|||||||
if err := manager.RemoveToken(u.ID, token); err != nil {
|
if err := manager.RemoveToken(u.ID, token); err != nil {
|
||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +174,7 @@ func execTokenList(c *cli.Context) error {
|
|||||||
var users []*user.User
|
var users []*user.User
|
||||||
if username != "" {
|
if username != "" {
|
||||||
u, err := manager.User(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)
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -183,15 +192,15 @@ func execTokenList(c *cli.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
} else if len(tokens) == 0 && username != "" {
|
} 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
|
return nil
|
||||||
} else if len(tokens) == 0 {
|
} else if len(tokens) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
usersWithTokens++
|
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 {
|
for _, t := range tokens {
|
||||||
var label, expires string
|
var label, expires, provisioned string
|
||||||
if t.Label != "" {
|
if t.Label != "" {
|
||||||
label = fmt.Sprintf(" (%s)", t.Label)
|
label = fmt.Sprintf(" (%s)", t.Label)
|
||||||
}
|
}
|
||||||
@@ -200,11 +209,19 @@ func execTokenList(c *cli.Context) error {
|
|||||||
} else {
|
} else {
|
||||||
expires = fmt.Sprintf("expires %s", t.Expires.Format(time.RFC822))
|
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 {
|
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
|
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)
|
s, conf, port := newTestServerWithAuth(t)
|
||||||
defer test.StopServer(t, s, port)
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
app, stdin, _, stderr := newTestApp()
|
app, stdin, stdout, _ := newTestApp()
|
||||||
stdin.WriteString("mypass\nmypass")
|
stdin.WriteString("mypass\nmypass")
|
||||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
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.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.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+`)
|
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.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.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 {
|
func runTokenCommand(app *cli.App, conf *server.Config, args ...string) error {
|
||||||
|
|||||||
65
cmd/user.go
@@ -6,6 +6,7 @@ import (
|
|||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"heckel.io/ntfy/v2/server"
|
||||||
"heckel.io/ntfy/v2/user"
|
"heckel.io/ntfy/v2/user"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -25,7 +26,7 @@ func init() {
|
|||||||
|
|
||||||
var flagsUser = append(
|
var flagsUser = append(
|
||||||
append([]cli.Flag{}, flagsDefault...),
|
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-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"}),
|
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
|
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.
|
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:
|
Example:
|
||||||
ntfy user change-tier phil pro # Change tier to "pro" for user "phil"
|
ntfy user change-tier phil pro # Change tier to "pro" for user "phil"
|
||||||
ntfy user change-tier phil - # Remove tier from user "phil" entirely
|
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 user, _ := manager.User(username); user != nil {
|
||||||
if c.Bool("ignore-exists") {
|
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 nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("user %s already exists", username)
|
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 {
|
if err := manager.AddUser(username, password, role, hashed); err != nil {
|
||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,13 +241,13 @@ func execUserDel(c *cli.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
}
|
}
|
||||||
if err := manager.RemoveUser(username); err != nil {
|
if err := manager.RemoveUser(username); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(c.App.ErrWriter, "user %s removed\n", username)
|
fmt.Fprintf(c.App.Writer, "user %s removed\n", username)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,7 +267,7 @@ func execUserChangePass(c *cli.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
}
|
}
|
||||||
if password == "" {
|
if password == "" {
|
||||||
@@ -263,7 +279,7 @@ func execUserChangePass(c *cli.Context) error {
|
|||||||
if err := manager.ChangePassword(username, password, hashed); err != nil {
|
if err := manager.ChangePassword(username, password, hashed); err != nil {
|
||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,13 +295,26 @@ func execUserChangeRole(c *cli.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
}
|
}
|
||||||
if err := manager.ChangeRole(username, role); err != nil {
|
if err := manager.ChangeRole(username, role); err != nil {
|
||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,19 +332,19 @@ func execUserChangeTier(c *cli.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
}
|
}
|
||||||
if tier == tierReset {
|
if tier == tierReset {
|
||||||
if err := manager.ResetTier(username); err != nil {
|
if err := manager.ResetTier(username); err != nil {
|
||||||
return err
|
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 {
|
} else {
|
||||||
if err := manager.ChangeTier(username, tier); err != nil {
|
if err := manager.ChangeTier(username, tier); err != nil {
|
||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -345,7 +374,15 @@ func createUserManager(c *cli.Context) (*user.Manager, error) {
|
|||||||
if err != nil {
|
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 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) {
|
func readPasswordAndConfirm(c *cli.Context) (string, error) {
|
||||||
|
|||||||
@@ -15,20 +15,20 @@ func TestCLI_User_Add(t *testing.T) {
|
|||||||
s, conf, port := newTestServerWithAuth(t)
|
s, conf, port := newTestServerWithAuth(t)
|
||||||
defer test.StopServer(t, s, port)
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
app, stdin, _, stderr := newTestApp()
|
app, stdin, stdout, _ := newTestApp()
|
||||||
stdin.WriteString("mypass\nmypass")
|
stdin.WriteString("mypass\nmypass")
|
||||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
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) {
|
func TestCLI_User_Add_Exists(t *testing.T) {
|
||||||
s, conf, port := newTestServerWithAuth(t)
|
s, conf, port := newTestServerWithAuth(t)
|
||||||
defer test.StopServer(t, s, port)
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
app, stdin, _, stderr := newTestApp()
|
app, stdin, stdout, _ := newTestApp()
|
||||||
stdin.WriteString("mypass\nmypass")
|
stdin.WriteString("mypass\nmypass")
|
||||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
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()
|
app, stdin, _, _ = newTestApp()
|
||||||
stdin.WriteString("mypass\nmypass")
|
stdin.WriteString("mypass\nmypass")
|
||||||
@@ -41,10 +41,10 @@ func TestCLI_User_Add_Admin(t *testing.T) {
|
|||||||
s, conf, port := newTestServerWithAuth(t)
|
s, conf, port := newTestServerWithAuth(t)
|
||||||
defer test.StopServer(t, s, port)
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
app, stdin, _, stderr := newTestApp()
|
app, stdin, stdout, _ := newTestApp()
|
||||||
stdin.WriteString("mypass\nmypass")
|
stdin.WriteString("mypass\nmypass")
|
||||||
require.Nil(t, runUserCommand(app, conf, "add", "--role=admin", "phil"))
|
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) {
|
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) {
|
func TestCLI_User_ChangePass(t *testing.T) {
|
||||||
s, conf, port := newTestServerWithAuth(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)
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
// Add user
|
// Add user
|
||||||
app, stdin, _, stderr := newTestApp()
|
app, stdin, stdout, _ := newTestApp()
|
||||||
stdin.WriteString("mypass\nmypass")
|
stdin.WriteString("mypass\nmypass")
|
||||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
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
|
// Change pass
|
||||||
app, stdin, _, stderr = newTestApp()
|
app, stdin, stdout, _ = newTestApp()
|
||||||
stdin.WriteString("newpass\nnewpass")
|
stdin.WriteString("newpass\nnewpass")
|
||||||
require.Nil(t, runUserCommand(app, conf, "change-pass", "phil"))
|
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) {
|
func TestCLI_User_ChangeRole(t *testing.T) {
|
||||||
@@ -80,15 +88,15 @@ func TestCLI_User_ChangeRole(t *testing.T) {
|
|||||||
defer test.StopServer(t, s, port)
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
// Add user
|
// Add user
|
||||||
app, stdin, _, stderr := newTestApp()
|
app, stdin, stdout, _ := newTestApp()
|
||||||
stdin.WriteString("mypass\nmypass")
|
stdin.WriteString("mypass\nmypass")
|
||||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
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
|
// Change role
|
||||||
app, _, _, stderr = newTestApp()
|
app, _, stdout, _ = newTestApp()
|
||||||
require.Nil(t, runUserCommand(app, conf, "change-role", "phil", "admin"))
|
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) {
|
func TestCLI_User_Delete(t *testing.T) {
|
||||||
@@ -96,15 +104,15 @@ func TestCLI_User_Delete(t *testing.T) {
|
|||||||
defer test.StopServer(t, s, port)
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
// Add user
|
// Add user
|
||||||
app, stdin, _, stderr := newTestApp()
|
app, stdin, stdout, _ := newTestApp()
|
||||||
stdin.WriteString("mypass\nmypass")
|
stdin.WriteString("mypass\nmypass")
|
||||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
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
|
// Delete user
|
||||||
app, _, _, stderr = newTestApp()
|
app, _, stdout, _ = newTestApp()
|
||||||
require.Nil(t, runUserCommand(app, conf, "del", "phil"))
|
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)
|
// Delete user again (does not exist)
|
||||||
app, _, _, _ = newTestApp()
|
app, _, _, _ = newTestApp()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//go:build !noserver
|
//go:build !noserver && !nowebpush
|
||||||
|
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
@@ -53,9 +53,9 @@ web-push-private-key: %s
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
} 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-public-key: %s
|
||||||
web-push-private-key: %s
|
web-push-private-key: %s
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -9,16 +10,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestCLI_WebPush_GenerateKeys(t *testing.T) {
|
func TestCLI_WebPush_GenerateKeys(t *testing.T) {
|
||||||
app, _, _, stderr := newTestApp()
|
app, _, stdout, _ := newTestApp()
|
||||||
require.Nil(t, runWebPushCommand(app, server.NewConfig(), "keys"))
|
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) {
|
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.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.Contains(t, stdout.String(), "Web Push keys written to key-file.yaml")
|
||||||
require.FileExists(t, "key-file.yaml")
|
require.FileExists(t, filepath.Join(tempDir, "key-file.yaml"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func runWebPushCommand(app *cli.App, conf *server.Config, args ...string) error {
|
func runWebPushCommand(app *cli.App, conf *server.Config, args ...string) error {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
version: "2.1"
|
|
||||||
services:
|
services:
|
||||||
ntfy:
|
ntfy:
|
||||||
image: binwiederhier/ntfy
|
image: binwiederhier/ntfy
|
||||||
@@ -14,4 +13,3 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- 80:80
|
- 80:80
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
|||||||
282
docs/config.md
@@ -18,8 +18,8 @@ get a list of [command line options](#command-line-options).
|
|||||||
|
|
||||||
## Example config
|
## Example config
|
||||||
!!! info
|
!!! info
|
||||||
Definitely check out the **[server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml)** file.
|
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.
|
||||||
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`
|
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.
|
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)"
|
=== "Docker Compose (w/ auth, cache, attachments)"
|
||||||
``` yaml
|
``` yaml
|
||||||
version: '3'
|
|
||||||
services:
|
services:
|
||||||
ntfy:
|
ntfy:
|
||||||
image: binwiederhier/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_CACHE_FILE: /var/lib/ntfy/cache.db
|
||||||
NTFY_AUTH_FILE: /var/lib/ntfy/auth.db
|
NTFY_AUTH_FILE: /var/lib/ntfy/auth.db
|
||||||
NTFY_AUTH_DEFAULT_ACCESS: deny-all
|
NTFY_AUTH_DEFAULT_ACCESS: deny-all
|
||||||
|
NTFY_AUTH_USERS: 'phil:$$2a$$10$$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin' # Must escape '$' as '$$'
|
||||||
NTFY_BEHIND_PROXY: true
|
NTFY_BEHIND_PROXY: true
|
||||||
NTFY_ATTACHMENT_CACHE_DIR: /var/lib/ntfy/attachments
|
NTFY_ATTACHMENT_CACHE_DIR: /var/lib/ntfy/attachments
|
||||||
NTFY_ENABLE_LOGIN: true
|
NTFY_ENABLE_LOGIN: true
|
||||||
@@ -101,7 +101,6 @@ using Docker Compose (i.e. `docker-compose.yml`):
|
|||||||
|
|
||||||
=== "Docker Compose (w/ auth, cache, web push, iOS)"
|
=== "Docker Compose (w/ auth, cache, web push, iOS)"
|
||||||
``` yaml
|
``` yaml
|
||||||
version: '3'
|
|
||||||
services:
|
services:
|
||||||
ntfy:
|
ntfy:
|
||||||
image: binwiederhier/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).
|
(`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.
|
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
|
* `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)
|
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
|
* `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
|
Once configured, you can use
|
||||||
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
|
- the `ntfy user` command and the `auth-users` config option to [add or modify users](#users-and-roles)
|
||||||
accessing them has the right permissions.
|
- 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 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
|
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
|
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)).
|
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-pass phil # Change password for user phil
|
||||||
ntfy user change-role phil admin # Make user phil an admin
|
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 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)
|
### Access control list (ACL)
|
||||||
The access control list (ACL) **manages access to topics for non-admin users, and for anonymous access (`everyone`/`*`)**.
|
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:
|
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
|
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.
|
(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
|
### Access tokens
|
||||||
In addition to username/password auth, ntfy also provides authentication via access tokens. Access tokens are useful
|
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
|
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,
|
and deleting the account, every action can be performed with a token. Granular access tokens are on the roadmap,
|
||||||
but not yet implemented.
|
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
|
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).
|
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 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 add --expires=2d phil # Create token for user phil which expires in 2 days
|
||||||
ntfy token remove phil tk_th2sxr... # Delete token
|
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:**
|
**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 add --expires=30d --label="backups" phil
|
||||||
$ ntfy token list
|
$ ntfy token list
|
||||||
user phil
|
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
|
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).
|
subscribe to topics**. To learn how, check out [authenticate via access tokens](publish.md#access-tokens).
|
||||||
|
|
||||||
### Example: Private instance
|
#### Tokens via the config
|
||||||
The easiest way to configure a private instance is to set `auth-default-access` to `deny-all` in the `server.yml`:
|
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
|
``` yaml
|
||||||
auth-file: "/var/lib/ntfy/user.db"
|
auth-file: "/var/lib/ntfy/user.db"
|
||||||
auth-default-access: "deny-all"
|
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:
|
=== "Config via env variables"
|
||||||
|
``` yaml
|
||||||
```
|
NTFY_AUTH_FILE='/var/lib/ntfy/user.db'
|
||||||
$ ntfy user add --role=admin phil
|
NTFY_AUTH_DEFAULT_ACCESS='deny-all'
|
||||||
password: mypass
|
NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin,backup-script:$2a$10$/ehiQt.w7lhTmHXq.RNsOOkIwiPPeWFIzWYO3DRxNixnWKLX8.uj.:user'
|
||||||
confirm: mypass
|
NTFY_AUTH_ACCESS='backup-service:backups:rw'
|
||||||
user phil added with role admin
|
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)
|
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)"
|
=== "Command line (curl)"
|
||||||
```
|
```
|
||||||
@@ -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`),
|
* `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
|
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`).
|
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
|
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).
|
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)"
|
=== "/etc/ntfy/server.yml (behind a proxy)"
|
||||||
``` yaml
|
``` yaml
|
||||||
@@ -613,7 +783,21 @@ Relevant flags to consider:
|
|||||||
# the visitor IP will be 9.9.9.9 (right-most unknown address).
|
# the visitor IP will be 9.9.9.9 (right-most unknown address).
|
||||||
#
|
#
|
||||||
behind-proxy: true
|
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
|
### TLS/SSL
|
||||||
@@ -1138,6 +1322,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
|
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
|
### Subscriber-based rate limiting
|
||||||
By default, ntfy puts almost all rate limits on the message publisher, e.g. number of messages, requests, and attachment
|
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
|
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 +1498,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`
|
is `INPUT`, but `FORWARD` is used when using docker networks. `DOCKER-USER`, available when using docker, is part of the `FORWARD`
|
||||||
chain.
|
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
|
## Health checks
|
||||||
A preliminary health check API endpoint is exposed at `/v1/health`. The endpoint returns a `json` response in the format shown below.
|
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.
|
If a non-200 HTTP status code is returned or if the returned `healthy` field is `false` the ntfy service should be considered as unhealthy.
|
||||||
@@ -1444,7 +1659,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`. |
|
| `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) |
|
| `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-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-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-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. |
|
| `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 +1689,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-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-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-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-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-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) |
|
| `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-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-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) |
|
| `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-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 |
|
| `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 |
|
| `billing-contact` | `NTFY_BILLING_CONTACT` | *email address* or *website* | - | Payments: Email or website displayed in Upgrade dialog as a billing contact |
|
||||||
@@ -1572,6 +1790,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]
|
--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]
|
--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-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-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-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]
|
--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 +1799,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-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-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-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]
|
--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]
|
||||||
--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-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-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]
|
--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]
|
--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 +1817,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-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-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]
|
--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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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.
|
or POST requests. I use it to notify myself when scripts fail, or long-running commands complete.
|
||||||
|
|
||||||
## Step 1: Get the app
|
## 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://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 src="static/img/badge-fdroid.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 src="static/img/badge-appstore.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.
|
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
|
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"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_amd64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_amd64.tar.gz
|
||||||
tar zxvf ntfy_2.12.0_linux_amd64.tar.gz
|
tar zxvf ntfy_2.15.0_linux_amd64.tar.gz
|
||||||
sudo cp -a ntfy_2.12.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
sudo cp -a ntfy_2.15.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
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv6.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv6.tar.gz
|
||||||
tar zxvf ntfy_2.12.0_linux_armv6.tar.gz
|
tar zxvf ntfy_2.15.0_linux_armv6.tar.gz
|
||||||
sudo cp -a ntfy_2.12.0_linux_armv6/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.15.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.12.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv7.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv7.tar.gz
|
||||||
tar zxvf ntfy_2.12.0_linux_armv7.tar.gz
|
tar zxvf ntfy_2.15.0_linux_armv7.tar.gz
|
||||||
sudo cp -a ntfy_2.12.0_linux_armv7/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.15.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.12.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_arm64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_arm64.tar.gz
|
||||||
tar zxvf ntfy_2.12.0_linux_arm64.tar.gz
|
tar zxvf ntfy_2.15.0_linux_arm64.tar.gz
|
||||||
sudo cp -a ntfy_2.12.0_linux_arm64/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.15.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.12.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
## Debian/Ubuntu repository
|
## 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"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
sudo mkdir -p /etc/apt/keyrings
|
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 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' \
|
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/ntfy.gpg] https://archive.ntfy.sh/apt stable main" \
|
||||||
> /etc/apt/sources.list.d/archive.heckel.io.list"
|
| sudo tee /etc/apt/sources.list.d/ntfy.list
|
||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt install ntfy
|
sudo apt install ntfy
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
@@ -83,10 +89,10 @@ Installation via Debian repository:
|
|||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
sudo mkdir -p /etc/apt/keyrings
|
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 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' \
|
echo "deb [arch=armhf signed-by=/etc/apt/keyrings/ntfy.gpg] https://archive.ntfy.sh/apt stable main" \
|
||||||
> /etc/apt/sources.list.d/archive.heckel.io.list"
|
| sudo tee /etc/apt/sources.list.d/ntfy.list
|
||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt install ntfy
|
sudo apt install ntfy
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
@@ -96,10 +102,10 @@ Installation via Debian repository:
|
|||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
sudo mkdir -p /etc/apt/keyrings
|
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 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' \
|
echo "deb [arch=arm64 signed-by=/etc/apt/keyrings/ntfy.gpg] https://archive.ntfy.sh/apt stable main" \
|
||||||
> /etc/apt/sources.list.d/archive.heckel.io.list"
|
| sudo tee /etc/apt/sources.list.d/ntfy.list
|
||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt install ntfy
|
sudo apt install ntfy
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
@@ -110,7 +116,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```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 dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -118,7 +124,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```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 dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -126,7 +132,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```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 dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -134,7 +140,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```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 dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -144,28 +150,28 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```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 enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```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 enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```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 enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```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 enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
@@ -195,18 +201,18 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos.
|
|||||||
|
|
||||||
## macOS
|
## macOS
|
||||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
|
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`).
|
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
|
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).
|
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
|
||||||
|
|
||||||
```bash
|
```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
|
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.12.0_darwin_all.tar.gz
|
tar zxvf ntfy_2.15.0_darwin_all.tar.gz
|
||||||
sudo cp -a ntfy_2.12.0_darwin_all/ntfy /usr/local/bin/ntfy
|
sudo cp -a ntfy_2.15.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||||
mkdir ~/Library/Application\ Support/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
|
ntfy --help
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -221,10 +227,9 @@ simply run:
|
|||||||
brew install ntfy
|
brew install ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## Windows
|
## Windows
|
||||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
|
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%`.
|
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).
|
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:
|
Using docker-compose with non-root user and healthchecks enabled:
|
||||||
```yaml
|
```yaml
|
||||||
version: "2.3"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
ntfy:
|
ntfy:
|
||||||
image: binwiederhier/ntfy
|
image: binwiederhier/ntfy
|
||||||
@@ -303,6 +306,7 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
restart: unless-stopped
|
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.
|
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
|
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.
|
unmanned pod.
|
||||||
|
|
||||||
|
|
||||||
=== "deployment"
|
=== "deployment"
|
||||||
```yaml
|
```yaml
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
|
|||||||
@@ -95,6 +95,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
|
- [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.
|
- [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.
|
- [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
|
## Projects + scripts
|
||||||
|
|
||||||
@@ -173,7 +175,11 @@ 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)
|
- [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)
|
- [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)
|
- [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)
|
||||||
|
|
||||||
## Blog + forum posts
|
## Blog + forum posts
|
||||||
|
|
||||||
|
|||||||
212
docs/publish.md
1507
docs/publish/template-functions.md
Normal file
115
docs/releases.md
@@ -2,7 +2,92 @@
|
|||||||
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
|
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).
|
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
|
||||||
|
|
||||||
### ntfy server v2.12.0
|
### 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
|
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
|
This is mainly a maintenance release that updates dependencies, though since it's been over a year, there are a few
|
||||||
@@ -62,7 +147,7 @@ user support in Discord/Matrix/GitHub! You rock, man!
|
|||||||
* Update new languages from Weblate. Thanks to all the contributors!
|
* Update new languages from Weblate. Thanks to all the contributors!
|
||||||
* Added Tamil (தமிழ்) as a new language to the web app
|
* Added Tamil (தமிழ்) as a new language to the web app
|
||||||
|
|
||||||
### ntfy server v2.11.0
|
## ntfy server v2.11.0
|
||||||
Released May 13, 2024
|
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
|
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 +162,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 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))
|
* 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
|
Released Mar 27, 2024
|
||||||
|
|
||||||
This release adds support for **message templating** in the ntfy server, which allows you to include a message and/or
|
This release adds support for **message templating** in the ntfy server, which allows you to include a message and/or
|
||||||
@@ -88,7 +173,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)
|
* [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
|
Released Mar 7, 2024
|
||||||
|
|
||||||
A small release after a long pause (lots of day job work). This release adds for **larger messages** and **longer
|
A small release after a long pause (lots of day job work). This release adds for **larger messages** and **longer
|
||||||
@@ -1433,24 +1518,4 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
|||||||
|
|
||||||
## Not released yet
|
## Not released yet
|
||||||
|
|
||||||
### ntfy server v2.13.0 (UNRELEASED)
|
_Nothing to see, move along ..._
|
||||||
|
|
||||||
**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)
|
|
||||||
|
|
||||||
**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/))
|
|
||||||
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 |
@@ -156,7 +156,7 @@ environment variables. Here are a few examples:
|
|||||||
```
|
```
|
||||||
ntfy sub mytopic 'notify-send "$m"'
|
ntfy sub mytopic 'notify-send "$m"'
|
||||||
ntfy sub topic1 /my/script.sh
|
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>
|
<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
|
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).
|
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://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 src="../../static/img/badge-fdroid.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 src="../../static/img/badge-appstore.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
|
You can get the Android app from [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy),
|
||||||
from [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/). Both are largely identical, with the one exception that
|
[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 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).
|
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.
|
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.
|
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
|
## Overview
|
||||||
A picture is worth a thousand words. Here are a few screenshots showing what the app looks like. It's all pretty
|
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.
|
straight forward. You can add topics and as soon as you add them, you can [publish messages](../publish.md) to them.
|
||||||
|
|||||||
108
go.mod
@@ -1,27 +1,27 @@
|
|||||||
module heckel.io/ntfy/v2
|
module heckel.io/ntfy/v2
|
||||||
|
|
||||||
go 1.24
|
go 1.24.0
|
||||||
|
|
||||||
toolchain go1.24.0
|
toolchain go1.24.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go/firestore v1.18.0 // indirect
|
cloud.google.com/go/firestore v1.20.0 // indirect
|
||||||
cloud.google.com/go/storage v1.55.0 // indirect
|
cloud.google.com/go/storage v1.57.2 // indirect
|
||||||
github.com/BurntSushi/toml v1.5.0 // indirect
|
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||||
github.com/emersion/go-smtp v0.18.0
|
github.com/emersion/go-smtp v0.18.0
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9
|
github.com/gabriel-vasile/mimetype v1.4.11
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/mattn/go-sqlite3 v1.14.28
|
github.com/mattn/go-sqlite3 v1.14.32
|
||||||
github.com/olebedev/when v1.1.0
|
github.com/olebedev/when v1.1.0
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/urfave/cli/v2 v2.27.6
|
github.com/urfave/cli/v2 v2.27.7
|
||||||
golang.org/x/crypto v0.38.0
|
golang.org/x/crypto v0.44.0
|
||||||
golang.org/x/oauth2 v0.30.0 // indirect
|
golang.org/x/oauth2 v0.33.0 // indirect
|
||||||
golang.org/x/sync v0.14.0
|
golang.org/x/sync v0.18.0
|
||||||
golang.org/x/term v0.32.0
|
golang.org/x/term v0.37.0
|
||||||
golang.org/x/time v0.11.0
|
golang.org/x/time v0.14.0
|
||||||
google.golang.org/api v0.235.0
|
google.golang.org/api v0.256.0
|
||||||
gopkg.in/yaml.v2 v2.4.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 github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
|
||||||
require (
|
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/SherClockHolmes/webpush-go v1.4.0
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27
|
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
|
github.com/stripe/stripe-go/v74 v74.30.0
|
||||||
|
golang.org/x/text v0.31.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cel.dev/expr v0.24.0 // indirect
|
cel.dev/expr v0.25.1 // indirect
|
||||||
cloud.google.com/go v0.121.2 // indirect
|
cloud.google.com/go v0.123.0 // indirect
|
||||||
cloud.google.com/go/auth v0.16.1 // indirect
|
cloud.google.com/go/auth v0.17.0 // indirect
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // 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/compute/metadata v0.9.0 // indirect
|
||||||
cloud.google.com/go/iam v1.5.2 // indirect
|
cloud.google.com/go/iam v1.5.3 // indirect
|
||||||
cloud.google.com/go/longrunning v0.6.7 // indirect
|
cloud.google.com/go/longrunning v0.7.0 // indirect
|
||||||
cloud.google.com/go/monitoring v1.24.2 // indirect
|
cloud.google.com/go/monitoring v1.24.3 // indirect
|
||||||
github.com/AlekSi/pointer v1.2.0 // 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/detectors/gcp v1.30.0 // indirect
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.52.0 // indirect
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.52.0 // indirect
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 // indirect
|
||||||
github.com/MicahParks/keyfunc v1.9.0 // indirect
|
github.com/MicahParks/keyfunc v1.9.0 // indirect
|
||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // 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-20251110193048-8bfbf64dc13e // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // 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/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||||
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
|
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
|
||||||
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
|
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // 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/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // 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/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/golang/protobuf v1.5.4 // indirect
|
||||||
github.com/google/s2a-go v0.1.9 // indirect
|
github.com/google/s2a-go v0.1.9 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.14.2 // indirect
|
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // 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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.64.0 // indirect
|
github.com/prometheus/common v0.67.2 // indirect
|
||||||
github.com/prometheus/procfs v0.16.1 // indirect
|
github.com/prometheus/procfs v0.19.2 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // 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/stretchr/objx v0.5.2 // indirect
|
||||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||||
github.com/zeebo/errs v1.4.0 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
go.opentelemetry.io/contrib/detectors/gcp v1.38.0 // indirect
|
||||||
go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.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.63.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
go.opentelemetry.io/otel v1.38.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.36.0 // indirect
|
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.36.0 // indirect
|
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk v1.36.0 // indirect
|
go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.36.0 // indirect
|
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.36.0 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
golang.org/x/net v0.40.0 // indirect
|
golang.org/x/net v0.47.0 // indirect
|
||||||
golang.org/x/sys v0.33.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/text v0.25.0 // indirect
|
|
||||||
google.golang.org/appengine/v2 v2.0.6 // indirect
|
google.golang.org/appengine/v2 v2.0.6 // indirect
|
||||||
google.golang.org/genproto v0.0.0-20250528174236-200df99c418a // indirect
|
google.golang.org/genproto v0.0.0-20251111163417-95abcf5c77ba // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba // indirect
|
||||||
google.golang.org/grpc v1.72.2 // indirect
|
google.golang.org/grpc v1.76.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.6 // indirect
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
228
go.sum
@@ -1,41 +1,41 @@
|
|||||||
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
|
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
||||||
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
|
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||||
cloud.google.com/go v0.121.2 h1:v2qQpN6Dx9x2NmwrqlesOt3Ys4ol5/lFZ6Mg1B7OJCg=
|
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||||
cloud.google.com/go v0.121.2/go.mod h1:nRFlrHq39MNVWu+zESP2PosMWA0ryJw8KUBZ2iZpxbw=
|
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||||
cloud.google.com/go/auth v0.16.1 h1:XrXauHMd30LhQYVRHLGvJiYeczweKQXZxsTbV9TiguU=
|
cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
|
||||||
cloud.google.com/go/auth v0.16.1/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI=
|
cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ=
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
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/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.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||||
cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
|
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||||
cloud.google.com/go/firestore v1.18.0 h1:cuydCaLS7Vl2SatAeivXyhbhDEIR8BDmtn4egDhIn2s=
|
cloud.google.com/go/firestore v1.20.0 h1:JLlT12QP0fM2SJirKVyu2spBCO8leElaW0OOtPm6HEo=
|
||||||
cloud.google.com/go/firestore v1.18.0/go.mod h1:5ye0v48PhseZBdcl0qbl3uttu7FIEwEYVaWm0UIEOEU=
|
cloud.google.com/go/firestore v1.20.0/go.mod h1:jqu4yKdBmDN5srneWzx3HlKrHFWFdlkgjgQ6BKIOFQo=
|
||||||
cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8=
|
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
|
||||||
cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE=
|
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
|
||||||
cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc=
|
cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY=
|
||||||
cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA=
|
cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw=
|
||||||
cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE=
|
cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E=
|
||||||
cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY=
|
cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY=
|
||||||
cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM=
|
cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
|
||||||
cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U=
|
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
|
||||||
cloud.google.com/go/storage v1.55.0 h1:NESjdAToN9u1tmhVqhXCaCwYBuvEhZLLv0gBr+2znf0=
|
cloud.google.com/go/storage v1.57.2 h1:sVlym3cHGYhrp6XZKkKb+92I1V42ks2qKKpB0CF5Mb4=
|
||||||
cloud.google.com/go/storage v1.55.0/go.mod h1:ztSmTTwzsdXe5syLVS0YsbFxXuvEmEyZj7v7zChEmuY=
|
cloud.google.com/go/storage v1.57.2/go.mod h1:n5ijg4yiRXXpCu0sJTD6k+eMf7GRrJmPyr9YxLXGHOk=
|
||||||
cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4=
|
cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
|
||||||
cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI=
|
cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
|
||||||
firebase.google.com/go/v4 v4.15.2 h1:KJtV4rAfO2CVCp40hBfVk+mqUqg7+jQKx7yOgFDnXBg=
|
firebase.google.com/go/v4 v4.18.0 h1:S+g0P72oDGqOaG4wlLErX3zQmU9plVdu7j+Bc3R1qFw=
|
||||||
firebase.google.com/go/v4 v4.15.2/go.mod h1:qkD/HtSumrPMTLs0ahQrje5gTw2WKFKrzVFoqy4SbKA=
|
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 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
|
||||||
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
|
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 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
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.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.28.0/go.mod h1:JXkPazkEc/dZTHzOlzv2vT1DlpWSTbSLmu/1KY6Ly0I=
|
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.52.0 h1:QFgWzcdmJlgEAwJz/zePYVJQxfoJGRtgIqZfIUFg5oQ=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.52.0/go.mod h1:ayYHuYU7iNcNtEs1K9k6D/Bju7u1VEHMQm5qQ1n3GtM=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0/go.mod h1:l9rva3ApbBpEJxSNYnwT9N4CDLrWgtq3u8736C5hyJw=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.52.0 h1:0l8ynskVvq1dvIn5vJbFMf/a/3TqFpRmCMrruFbzlvk=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0 h1:xfK3bbi6F2RDtaZFtUdKO3osOBIhNb+xTs8lFW6yx9o=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.52.0/go.mod h1:f/ad5NuHnYz8AOZGuR0cY+l36oSCstdxD73YlIchr6I=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.52.0 h1:wbMd4eG/fOhsCa6+IP8uEDvWF5vl7rNoUWmP5f72Tbs=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.52.0/go.mod h1:gdIm9TxRk5soClCwuB0FtdXsbqtw0aqPwBEurK9tPkw=
|
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 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
|
||||||
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
|
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=
|
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||||
@@ -46,8 +46,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/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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
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-20251110193048-8bfbf64dc13e h1:gt7U1Igw0xbJdyaCM5H2CnlAlPSkzrhsebQB6WQWjLA=
|
||||||
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-20251110193048-8bfbf64dc13e/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 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
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=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -60,18 +60,18 @@ github.com/emersion/go-smtp v0.17.0 h1:tq90evlrcyqRfE6DSXaWVH54oX6OuZOQECEmhWBME
|
|||||||
github.com/emersion/go-smtp v0.17.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
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 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
|
||||||
github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA=
|
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.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
|
||||||
github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
|
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 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
|
||||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
|
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 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
|
||||||
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
|
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
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.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY=
|
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw=
|
github.com/go-jose/go-jose/v4 v4.1.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.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 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
@@ -81,8 +81,8 @@ github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w
|
|||||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
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.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
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.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
@@ -96,10 +96,10 @@ 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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||||
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
|
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
|
||||||
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
|
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
|
||||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
@@ -112,8 +112,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/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 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
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.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||||
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.32/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 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
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=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
@@ -127,63 +127,65 @@ 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.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 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||||
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
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 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
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.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8=
|
||||||
github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko=
|
||||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
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 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
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.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
|
||||||
github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g=
|
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.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 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
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 h1:0Kf0KkeFnY7iRhOwvTerX0Ia1BRw+eV1CVJ51mGYAUY=
|
||||||
github.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
|
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.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
|
||||||
github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
|
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
|
||||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
|
||||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
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/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
go.opentelemetry.io/contrib/detectors/gcp v1.38.0 h1:ZoYbqX7OaA/TAikspPl3ozPI6iY6LiIY9I8cUfm+pJs=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOhIZ3nRLj6FNVrKgtflbaf2ts=
|
||||||
go.opentelemetry.io/contrib/detectors/gcp v1.36.0 h1:F7q2tNlCaHY9nMKHR6XH9/qkp8FktLnIcy6jJNyOCQw=
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo=
|
||||||
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.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ=
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
|
||||||
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.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||||
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 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/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.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||||
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
|
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||||
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
|
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||||
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
|
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
|
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
|
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||||
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
|
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||||
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
|
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||||
|
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-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.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.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.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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
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.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
||||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
@@ -198,10 +200,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.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
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.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
|
||||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
golang.org/x/oauth2 v0.33.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-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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -209,8 +211,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
|||||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.7.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.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.18.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-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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -223,8 +225,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.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.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.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
@@ -234,8 +236,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
|||||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
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.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
@@ -247,10 +249,10 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
|||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.15.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.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
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-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.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
@@ -259,22 +261,24 @@ 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/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-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/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=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
google.golang.org/api v0.235.0/go.mod h1:QpeJkemzkFKe5VCE/PMv7GsUfn9ZF+u+q1Q7w6ckxTg=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
|
google.golang.org/api v0.256.0 h1:u6Khm8+F9sxbCTYNoBHg6/Hwv0N/i+V94MvkOSor6oI=
|
||||||
|
google.golang.org/api v0.256.0/go.mod h1:KIgPhksXADEKJlnEoRa9qAII4rXcy40vfI8HRqcU964=
|
||||||
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
|
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/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-20251111163417-95abcf5c77ba h1:Ze6qXW0j37YCqZdCD2LkzVSxgEWez0cO4NUyd44DiDY=
|
||||||
google.golang.org/genproto v0.0.0-20250528174236-200df99c418a/go.mod h1:Nlk93rrS2X7rV8hiC2gh2A/AJspZhElz9Oh2KGsjLEY=
|
google.golang.org/genproto v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:4FLPzLA8eGAktPOTemJGDgDYRpLYwrNu4u2JtWINhnI=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a h1:SGktgSolFCo75dnHJF2yMvnns6jCmHFJ0vE4Vn2JKvQ=
|
google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba h1:B14OtaXuMaCQsl2deSvNkyPKIzq3BjfxQp8d00QyWx4=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw=
|
google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:G5IanEx8/PgI9w6CFcYQf7jMtHQhZruvfM1i3qOqk5U=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba h1:UKgtfRM7Yh93Sya0Fo8ZzhDP4qBckrrxEr2oF5UIVb8=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||||
google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8=
|
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||||
google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
|
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
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.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.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ nav:
|
|||||||
- "Integrations + projects": integrations.md
|
- "Integrations + projects": integrations.md
|
||||||
- "Release notes": releases.md
|
- "Release notes": releases.md
|
||||||
- "Emojis 🥳 🎉": emojis.md
|
- "Emojis 🥳 🎉": emojis.md
|
||||||
|
- "Template functions": publish/template-functions.md
|
||||||
- "Troubleshooting": troubleshooting.md
|
- "Troubleshooting": troubleshooting.md
|
||||||
- "Known issues": known-issues.md
|
- "Known issues": known-issues.md
|
||||||
- "Deprecation notices": deprecations.md
|
- "Deprecation notices": deprecations.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)
|
// Defines default config settings (excluding limits, see below)
|
||||||
const (
|
const (
|
||||||
DefaultListenHTTP = ":80"
|
DefaultListenHTTP = ":80"
|
||||||
|
DefaultConfigFile = "/etc/ntfy/server.yml"
|
||||||
|
DefaultTemplateDir = "/etc/ntfy/templates"
|
||||||
DefaultCacheDuration = 12 * time.Hour
|
DefaultCacheDuration = 12 * time.Hour
|
||||||
DefaultCacheBatchTimeout = time.Duration(0)
|
DefaultCacheBatchTimeout = time.Duration(0)
|
||||||
DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!)
|
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
|
DefaultVisitorAuthFailureLimitReplenish = time.Minute
|
||||||
DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB
|
DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB
|
||||||
DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 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 (
|
var (
|
||||||
@@ -91,12 +95,16 @@ type Config struct {
|
|||||||
AuthFile string
|
AuthFile string
|
||||||
AuthStartupQueries string
|
AuthStartupQueries string
|
||||||
AuthDefault user.Permission
|
AuthDefault user.Permission
|
||||||
|
AuthUsers []*user.User
|
||||||
|
AuthAccess map[string][]*user.Grant
|
||||||
|
AuthTokens map[string][]*user.Token
|
||||||
AuthBcryptCost int
|
AuthBcryptCost int
|
||||||
AuthStatsQueueWriterInterval time.Duration
|
AuthStatsQueueWriterInterval time.Duration
|
||||||
AttachmentCacheDir string
|
AttachmentCacheDir string
|
||||||
AttachmentTotalSizeLimit int64
|
AttachmentTotalSizeLimit int64
|
||||||
AttachmentFileSizeLimit int64
|
AttachmentFileSizeLimit int64
|
||||||
AttachmentExpiryDuration time.Duration
|
AttachmentExpiryDuration time.Duration
|
||||||
|
TemplateDir string // Directory to load named templates from
|
||||||
KeepaliveInterval time.Duration
|
KeepaliveInterval time.Duration
|
||||||
ManagerInterval time.Duration
|
ManagerInterval time.Duration
|
||||||
DisallowedTopics []string
|
DisallowedTopics []string
|
||||||
@@ -133,7 +141,7 @@ type Config struct {
|
|||||||
VisitorAttachmentDailyBandwidthLimit int64
|
VisitorAttachmentDailyBandwidthLimit int64
|
||||||
VisitorRequestLimitBurst int
|
VisitorRequestLimitBurst int
|
||||||
VisitorRequestLimitReplenish time.Duration
|
VisitorRequestLimitReplenish time.Duration
|
||||||
VisitorRequestExemptIPAddrs []netip.Prefix
|
VisitorRequestExemptPrefixes []netip.Prefix
|
||||||
VisitorMessageDailyLimit int
|
VisitorMessageDailyLimit int
|
||||||
VisitorEmailLimitBurst int
|
VisitorEmailLimitBurst int
|
||||||
VisitorEmailLimitReplenish time.Duration
|
VisitorEmailLimitReplenish time.Duration
|
||||||
@@ -143,19 +151,21 @@ type Config struct {
|
|||||||
VisitorAuthFailureLimitReplenish time.Duration
|
VisitorAuthFailureLimitReplenish time.Duration
|
||||||
VisitorStatsResetTime time.Time // Time of the day at which to reset visitor stats
|
VisitorStatsResetTime time.Time // Time of the day at which to reset visitor stats
|
||||||
VisitorSubscriberRateLimiting bool // Enable subscriber-based rate limiting for UnifiedPush topics
|
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
|
VisitorPrefixBitsIPv4 int // Number of bits for IPv4 rate limiting (default: 32)
|
||||||
ProxyForwardedHeader string // The header field to read the real/client IP address from, if BehindProxy is true, defaults to "X-Forwarded-For"
|
VisitorPrefixBitsIPv6 int // Number of bits for IPv6 rate limiting (default: 64)
|
||||||
ProxyTrustedAddresses []string // List of trusted proxy addresses that will be stripped from the Forwarded header if BehindProxy is true
|
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
|
StripeSecretKey string
|
||||||
StripeWebhookKey string
|
StripeWebhookKey string
|
||||||
StripePriceCacheDuration time.Duration
|
StripePriceCacheDuration time.Duration
|
||||||
BillingContact string
|
BillingContact string
|
||||||
EnableSignup bool // Enable creation of accounts via API and UI
|
EnableSignup bool // Enable creation of accounts via API and UI
|
||||||
EnableLogin bool
|
EnableLogin bool
|
||||||
|
RequireLogin bool
|
||||||
EnableReservations bool // Allow users with role "user" to own/reserve topics
|
EnableReservations bool // Allow users with role "user" to own/reserve topics
|
||||||
EnableMetrics bool
|
EnableMetrics bool
|
||||||
AccessControlAllowOrigin string // CORS header field to restrict access from web clients
|
AccessControlAllowOrigin string // CORS header field to restrict access from web clients
|
||||||
Version string // injected by App
|
|
||||||
WebPushPrivateKey string
|
WebPushPrivateKey string
|
||||||
WebPushPublicKey string
|
WebPushPublicKey string
|
||||||
WebPushFile string
|
WebPushFile string
|
||||||
@@ -163,12 +173,13 @@ type Config struct {
|
|||||||
WebPushStartupQueries string
|
WebPushStartupQueries string
|
||||||
WebPushExpiryDuration time.Duration
|
WebPushExpiryDuration time.Duration
|
||||||
WebPushExpiryWarningDuration time.Duration
|
WebPushExpiryWarningDuration time.Duration
|
||||||
|
Version string // injected by App
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConfig instantiates a default new server config
|
// NewConfig instantiates a default new server config
|
||||||
func NewConfig() *Config {
|
func NewConfig() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
File: "", // Only used for testing
|
File: DefaultConfigFile, // Only used for testing
|
||||||
BaseURL: "",
|
BaseURL: "",
|
||||||
ListenHTTP: DefaultListenHTTP,
|
ListenHTTP: DefaultListenHTTP,
|
||||||
ListenHTTPS: "",
|
ListenHTTPS: "",
|
||||||
@@ -191,6 +202,7 @@ func NewConfig() *Config {
|
|||||||
AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit,
|
AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit,
|
||||||
AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit,
|
AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit,
|
||||||
AttachmentExpiryDuration: DefaultAttachmentExpiryDuration,
|
AttachmentExpiryDuration: DefaultAttachmentExpiryDuration,
|
||||||
|
TemplateDir: DefaultTemplateDir,
|
||||||
KeepaliveInterval: DefaultKeepaliveInterval,
|
KeepaliveInterval: DefaultKeepaliveInterval,
|
||||||
ManagerInterval: DefaultManagerInterval,
|
ManagerInterval: DefaultManagerInterval,
|
||||||
DisallowedTopics: DefaultDisallowedTopics,
|
DisallowedTopics: DefaultDisallowedTopics,
|
||||||
@@ -220,11 +232,12 @@ func NewConfig() *Config {
|
|||||||
TotalTopicLimit: DefaultTotalTopicLimit,
|
TotalTopicLimit: DefaultTotalTopicLimit,
|
||||||
TotalAttachmentSizeLimit: 0,
|
TotalAttachmentSizeLimit: 0,
|
||||||
VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit,
|
VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit,
|
||||||
|
VisitorSubscriberRateLimiting: false,
|
||||||
VisitorAttachmentTotalSizeLimit: DefaultVisitorAttachmentTotalSizeLimit,
|
VisitorAttachmentTotalSizeLimit: DefaultVisitorAttachmentTotalSizeLimit,
|
||||||
VisitorAttachmentDailyBandwidthLimit: DefaultVisitorAttachmentDailyBandwidthLimit,
|
VisitorAttachmentDailyBandwidthLimit: DefaultVisitorAttachmentDailyBandwidthLimit,
|
||||||
VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst,
|
VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst,
|
||||||
VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish,
|
VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish,
|
||||||
VisitorRequestExemptIPAddrs: make([]netip.Prefix, 0),
|
VisitorRequestExemptPrefixes: make([]netip.Prefix, 0),
|
||||||
VisitorMessageDailyLimit: DefaultVisitorMessageDailyLimit,
|
VisitorMessageDailyLimit: DefaultVisitorMessageDailyLimit,
|
||||||
VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst,
|
VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst,
|
||||||
VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish,
|
VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish,
|
||||||
@@ -233,7 +246,8 @@ func NewConfig() *Config {
|
|||||||
VisitorAuthFailureLimitBurst: DefaultVisitorAuthFailureLimitBurst,
|
VisitorAuthFailureLimitBurst: DefaultVisitorAuthFailureLimitBurst,
|
||||||
VisitorAuthFailureLimitReplenish: DefaultVisitorAuthFailureLimitReplenish,
|
VisitorAuthFailureLimitReplenish: DefaultVisitorAuthFailureLimitReplenish,
|
||||||
VisitorStatsResetTime: DefaultVisitorStatsResetTime,
|
VisitorStatsResetTime: DefaultVisitorStatsResetTime,
|
||||||
VisitorSubscriberRateLimiting: false,
|
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
|
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
|
ProxyForwardedHeader: "X-Forwarded-For", // Default header for reverse proxy client IPs
|
||||||
StripeSecretKey: "",
|
StripeSecretKey: "",
|
||||||
@@ -243,6 +257,7 @@ func NewConfig() *Config {
|
|||||||
EnableSignup: false,
|
EnableSignup: false,
|
||||||
EnableLogin: false,
|
EnableLogin: false,
|
||||||
EnableReservations: false,
|
EnableReservations: false,
|
||||||
|
RequireLogin: false,
|
||||||
AccessControlAllowOrigin: "*",
|
AccessControlAllowOrigin: "*",
|
||||||
Version: "",
|
Version: "",
|
||||||
WebPushPrivateKey: "",
|
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}
|
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}
|
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}
|
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}
|
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
|
||||||
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", 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}
|
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}
|
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}
|
errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", "", nil}
|
||||||
errHTTPConflictPhoneNumberExists = &errHTTP{40904, http.StatusConflict, "conflict: phone number 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}
|
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}
|
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}
|
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||||
@@ -282,10 +284,17 @@ type messageCache struct {
|
|||||||
db *sql.DB
|
db *sql.DB
|
||||||
queue *util.BatchingQueue[*message]
|
queue *util.BatchingQueue[*message]
|
||||||
nop bool
|
nop bool
|
||||||
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// newSqliteCache creates a SQLite file-backed cache
|
// newSqliteCache creates a SQLite file-backed cache
|
||||||
func newSqliteCache(filename, startupQueries string, cacheDuration time.Duration, batchSize int, batchTimeout time.Duration, nop bool) (*messageCache, error) {
|
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)
|
db, err := sql.Open("sqlite3", filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
// 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.
|
// SQLite's busy_timeout is exceeded before erroring out.
|
||||||
func (c *messageCache) addMessages(ms []*message) error {
|
func (c *messageCache) addMessages(ms []*message) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
if c.nop {
|
if c.nop {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -521,6 +532,8 @@ func (c *messageCache) Message(id string) (*message, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) MarkPublished(m *message) error {
|
func (c *messageCache) MarkPublished(m *message) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
_, err := c.db.Exec(updateMessagePublishedQuery, m.ID)
|
_, err := c.db.Exec(updateMessagePublishedQuery, m.ID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -566,6 +579,8 @@ func (c *messageCache) Topics() (map[string]*topic, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) DeleteMessages(ids ...string) error {
|
func (c *messageCache) DeleteMessages(ids ...string) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
tx, err := c.db.Begin()
|
tx, err := c.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -580,6 +595,8 @@ func (c *messageCache) DeleteMessages(ids ...string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) ExpireMessages(topics ...string) error {
|
func (c *messageCache) ExpireMessages(topics ...string) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
tx, err := c.db.Begin()
|
tx, err := c.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -614,6 +631,8 @@ func (c *messageCache) AttachmentsExpired() ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) MarkAttachmentsDeleted(ids ...string) error {
|
func (c *messageCache) MarkAttachmentsDeleted(ids ...string) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
tx, err := c.db.Begin()
|
tx, err := c.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -759,6 +778,8 @@ func readMessage(rows *sql.Rows) (*message, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) UpdateStats(messages int64) error {
|
func (c *messageCache) UpdateStats(messages int64) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
_, err := c.db.Exec(updateStatsQuery, messages)
|
_, err := c.db.Exec(updateStatsQuery, messages)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package server
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -90,6 +92,26 @@ func testCacheMessages(t *testing.T, c *messageCache) {
|
|||||||
require.Empty(t, messages)
|
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) {
|
func TestSqliteCache_MessagesScheduled(t *testing.T) {
|
||||||
testCacheMessagesScheduled(t, newSqliteTestCache(t))
|
testCacheMessagesScheduled(t, newSqliteTestCache(t))
|
||||||
}
|
}
|
||||||
|
|||||||
161
server/server.go
@@ -31,9 +31,12 @@ import (
|
|||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
|
"gopkg.in/yaml.v2"
|
||||||
"heckel.io/ntfy/v2/log"
|
"heckel.io/ntfy/v2/log"
|
||||||
|
"heckel.io/ntfy/v2/payments"
|
||||||
"heckel.io/ntfy/v2/user"
|
"heckel.io/ntfy/v2/user"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
|
"heckel.io/ntfy/v2/util/sprig"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server is the main server, providing the UI and API for ntfy
|
// Server is the main server, providing the UI and API for ntfy
|
||||||
@@ -120,6 +123,15 @@ var (
|
|||||||
//go:embed docs
|
//go:embed docs
|
||||||
docsStaticFs embed.FS
|
docsStaticFs embed.FS
|
||||||
docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs}
|
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 (
|
const (
|
||||||
@@ -129,17 +141,13 @@ const (
|
|||||||
newMessageBody = "New message" // Used in poll requests as generic message
|
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
|
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
|
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
|
unifiedPushTopicPrefix = "up" // Temporarily, we rate limit all "up*" topics based on the subscriber
|
||||||
unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part
|
unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part
|
||||||
messagesHistoryMax = 10 // Number of message count values to keep in memory
|
messagesHistoryMax = 10 // Number of message count values to keep in memory
|
||||||
templateMaxExecutionTime = 100 * time.Millisecond
|
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
|
||||||
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`)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// WebSocket constants
|
// WebSocket constants
|
||||||
@@ -158,7 +166,7 @@ func New(conf *Config) (*Server, error) {
|
|||||||
mailer = &smtpSender{config: conf}
|
mailer = &smtpSender{config: conf}
|
||||||
}
|
}
|
||||||
var stripe stripeAPI
|
var stripe stripeAPI
|
||||||
if conf.StripeSecretKey != "" {
|
if payments.Available && conf.StripeSecretKey != "" {
|
||||||
stripe = newStripeAPI()
|
stripe = newStripeAPI()
|
||||||
}
|
}
|
||||||
messageCache, err := createMessageCache(conf)
|
messageCache, err := createMessageCache(conf)
|
||||||
@@ -189,7 +197,18 @@ func New(conf *Config) (*Server, error) {
|
|||||||
}
|
}
|
||||||
var userManager *user.Manager
|
var userManager *user.Manager
|
||||||
if conf.AuthFile != "" {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -581,6 +600,7 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
|
|||||||
BaseURL: "", // Will translate to window.location.origin
|
BaseURL: "", // Will translate to window.location.origin
|
||||||
AppRoot: s.config.WebRoot,
|
AppRoot: s.config.WebRoot,
|
||||||
EnableLogin: s.config.EnableLogin,
|
EnableLogin: s.config.EnableLogin,
|
||||||
|
RequireLogin: s.config.RequireLogin,
|
||||||
EnableSignup: s.config.EnableSignup,
|
EnableSignup: s.config.EnableSignup,
|
||||||
EnablePayments: s.config.StripeSecretKey != "",
|
EnablePayments: s.config.StripeSecretKey != "",
|
||||||
EnableCalls: s.config.TwilioAccount != "",
|
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.
|
// 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
|
// See https://github.com/mastodon/mastodon/blob/730bb3e211a84a2f30e3e2bbeae3f77149824a68/app/workers/web/push_notification_worker.rb#L35-L46
|
||||||
return nil, errHTTPInsufficientStorageUnifiedPush.With(t)
|
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)
|
return nil, errHTTPTooManyRequestsLimitMessages.With(t)
|
||||||
} else if email != "" && !vrate.EmailAllowed() {
|
} else if email != "" && !vrate.EmailAllowed() {
|
||||||
return nil, errHTTPTooManyRequestsLimitEmails.With(t)
|
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")
|
cache = readBoolParam(r, true, "x-cache", "cache")
|
||||||
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
||||||
m.Title = readParam(r, "x-title", "title", "t")
|
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 attach != "" {
|
||||||
if !urlRegex.MatchString(attach) {
|
if !urlRegex.MatchString(attach) {
|
||||||
return false, false, "", "", false, false, errHTTPBadRequestAttachmentURLInvalid
|
return false, false, "", "", "", false, errHTTPBadRequestAttachmentURLInvalid
|
||||||
}
|
}
|
||||||
m.Attachment.URL = attach
|
m.Attachment.URL = attach
|
||||||
if m.Attachment.Name == "" {
|
if m.Attachment.Name == "" {
|
||||||
@@ -970,48 +990,53 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
|||||||
}
|
}
|
||||||
if icon != "" {
|
if icon != "" {
|
||||||
if !urlRegex.MatchString(icon) {
|
if !urlRegex.MatchString(icon) {
|
||||||
return false, false, "", "", false, false, errHTTPBadRequestIconURLInvalid
|
return false, false, "", "", "", false, errHTTPBadRequestIconURLInvalid
|
||||||
}
|
}
|
||||||
m.Icon = icon
|
m.Icon = icon
|
||||||
}
|
}
|
||||||
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
|
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
|
||||||
if s.smtpSender == nil && email != "" {
|
if s.smtpSender == nil && email != "" {
|
||||||
return false, false, "", "", false, false, errHTTPBadRequestEmailDisabled
|
return false, false, "", "", "", false, errHTTPBadRequestEmailDisabled
|
||||||
}
|
}
|
||||||
call = readParam(r, "x-call", "call")
|
call = readParam(r, "x-call", "call")
|
||||||
if call != "" && (s.config.TwilioAccount == "" || s.userManager == nil) {
|
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) {
|
} 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 != "" {
|
if messageStr != "" {
|
||||||
m.Message = messageStr
|
m.Message = messageStr
|
||||||
}
|
}
|
||||||
var e error
|
var e error
|
||||||
m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
|
m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return false, false, "", "", false, false, errHTTPBadRequestPriorityInvalid
|
return false, false, "", "", "", false, errHTTPBadRequestPriorityInvalid
|
||||||
}
|
}
|
||||||
m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta")
|
m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta")
|
||||||
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
|
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
|
||||||
if delayStr != "" {
|
if delayStr != "" {
|
||||||
if !cache {
|
if !cache {
|
||||||
return false, false, "", "", false, false, errHTTPBadRequestDelayNoCache
|
return false, false, "", "", "", false, errHTTPBadRequestDelayNoCache
|
||||||
}
|
}
|
||||||
if email != "" {
|
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 != "" {
|
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())
|
delay, err := util.ParseFutureTime(delayStr, time.Now())
|
||||||
if err != nil {
|
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() {
|
} 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() {
|
} 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()
|
m.Time = delay.Unix()
|
||||||
}
|
}
|
||||||
@@ -1019,14 +1044,13 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
|||||||
if actionsStr != "" {
|
if actionsStr != "" {
|
||||||
m.Actions, e = parseActions(actionsStr)
|
m.Actions, e = parseActions(actionsStr)
|
||||||
if e != nil {
|
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")
|
contentType, markdown := readParam(r, "content-type", "content_type"), readBoolParam(r, false, "x-markdown", "markdown", "md")
|
||||||
if markdown || strings.ToLower(contentType) == "text/markdown" {
|
if markdown || strings.ToLower(contentType) == "text/markdown" {
|
||||||
m.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!
|
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
|
||||||
contentEncoding := readParam(r, "content-encoding")
|
contentEncoding := readParam(r, "content-encoding")
|
||||||
if unifiedpush || contentEncoding == "aes128gcm" {
|
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
|
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
|
||||||
// 7. curl -T file.txt ntfy.sh/mytopic
|
// 7. curl -T file.txt ntfy.sh/mytopic
|
||||||
// In all other cases, mostly if file.txt is > message limit, treat it as an attachment
|
// 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
|
if m.Event == pollRequestEvent { // Case 1
|
||||||
return s.handleBodyDiscard(body)
|
return s.handleBodyDiscard(body)
|
||||||
} else if unifiedpush {
|
} 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
|
return s.handleBodyAsTextMessage(m, body) // Case 3
|
||||||
} else if m.Attachment != nil && m.Attachment.Name != "" {
|
} else if m.Attachment != nil && m.Attachment.Name != "" {
|
||||||
return s.handleBodyAsAttachment(r, v, m, body) // Case 4
|
return s.handleBodyAsAttachment(r, v, m, body) // Case 4
|
||||||
} else if template {
|
} else if template.Enabled() {
|
||||||
return s.handleBodyAsTemplatedTextMessage(m, body) // Case 5
|
return s.handleBodyAsTemplatedTextMessage(m, template, body) // Case 5
|
||||||
} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
|
} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
|
||||||
return s.handleBodyAsTextMessage(m, body) // Case 6
|
return s.handleBodyAsTextMessage(m, body) // Case 6
|
||||||
}
|
}
|
||||||
@@ -1104,7 +1128,7 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser
|
|||||||
return nil
|
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))
|
body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1112,19 +1136,69 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *message, body *util.PeekedR
|
|||||||
return errHTTPEntityTooLargeJSONBody
|
return errHTTPEntityTooLargeJSONBody
|
||||||
}
|
}
|
||||||
peekedBody := strings.TrimSpace(string(body.PeekedBytes))
|
peekedBody := strings.TrimSpace(string(body.PeekedBytes))
|
||||||
if m.Message, err = replaceTemplate(m.Message, peekedBody); err != nil {
|
if template.FileMode() {
|
||||||
|
if err := s.renderTemplateFromFile(m, template.FileName(), peekedBody); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if m.Title, err = replaceTemplate(m.Title, peekedBody); err != nil {
|
} else {
|
||||||
|
if err := s.renderTemplateFromParams(m, peekedBody); err != nil {
|
||||||
return err
|
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 errHTTPBadRequestTemplateMessageTooLarge
|
||||||
}
|
}
|
||||||
return nil
|
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) {
|
if templateDisallowedRegex.MatchString(tpl) {
|
||||||
return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
|
return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
|
||||||
}
|
}
|
||||||
@@ -1132,15 +1206,16 @@ func replaceTemplate(tpl string, source string) (string, error) {
|
|||||||
if err := json.Unmarshal([]byte(source), &data); err != nil {
|
if err := json.Unmarshal([]byte(source), &data); err != nil {
|
||||||
return "", errHTTPBadRequestTemplateMessageNotJSON
|
return "", errHTTPBadRequestTemplateMessageNotJSON
|
||||||
}
|
}
|
||||||
t, err := template.New("").Parse(tpl)
|
t, err := template.New("").Funcs(sprig.TxtFuncMap()).Parse(tpl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", errHTTPBadRequestTemplateInvalid
|
return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error())
|
||||||
}
|
}
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := t.Execute(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), data); err != nil {
|
limitWriter := util.NewLimitWriter(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), util.NewFixedLimiter(templateMaxOutputBytes))
|
||||||
return "", errHTTPBadRequestTemplateExecuteFailed
|
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 {
|
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.
|
// that subsequent logging calls still have a visitor context.
|
||||||
func (s *Server) maybeAuthenticate(r *http.Request) (*visitor, error) {
|
func (s *Server) maybeAuthenticate(r *http.Request) (*visitor, error) {
|
||||||
// Read the "Authorization" header value and exit out early if it's not set
|
// 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)
|
vip := s.visitor(ip, nil)
|
||||||
if s.userManager == nil {
|
if s.userManager == nil {
|
||||||
return vip, nil
|
return vip, nil
|
||||||
@@ -2012,7 +2087,7 @@ func (s *Server) authenticateBearerAuth(r *http.Request, token string) (*user.Us
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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{
|
go s.userManager.EnqueueTokenUpdate(token, &user.TokenUpdate{
|
||||||
LastAccess: time.Now(),
|
LastAccess: time.Now(),
|
||||||
LastOrigin: ip,
|
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 {
|
func (s *Server) visitor(ip netip.Addr, user *user.User) *visitor {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
id := visitorID(ip, user)
|
id := visitorID(ip, user, s.config)
|
||||||
v, exists := s.visitors[id]
|
v, exists := s.visitors[id]
|
||||||
if !exists {
|
if !exists {
|
||||||
s.visitors[id] = newVisitor(s.config, s.messageCache, s.userManager, ip, user)
|
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".
|
# 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
|
# - 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.
|
# 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:
|
# Debian/RPM package users:
|
||||||
# Use /var/lib/ntfy/user.db as user database to avoid permission issues. The package
|
# Use /var/lib/ntfy/user.db as user database to avoid permission issues. The package
|
||||||
@@ -94,6 +102,9 @@
|
|||||||
# auth-file: <filename>
|
# auth-file: <filename>
|
||||||
# auth-default-access: "read-write"
|
# auth-default-access: "read-write"
|
||||||
# auth-startup-queries:
|
# 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
|
# 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.
|
# 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. 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),
|
# - 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").
|
# 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
|
# to determine the real IP address. This is only useful if there are multiple proxies involved that add themselves to
|
||||||
# the forwarded header.
|
# the forwarded header.
|
||||||
#
|
#
|
||||||
# behind-proxy: false
|
# behind-proxy: false
|
||||||
# proxy-forwarded-header: "X-Forwarded-For"
|
# 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
|
# If enabled, clients can attach files to notifications as attachments. Minimum settings to enable attachments
|
||||||
# are "attachment-cache-dir" and "base-url".
|
# are "attachment-cache-dir" and "base-url".
|
||||||
@@ -126,6 +137,26 @@
|
|||||||
# attachment-file-size-limit: "15M"
|
# attachment-file-size-limit: "15M"
|
||||||
# attachment-expiry-duration: "3h"
|
# 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,
|
# 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.
|
# 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-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
|
# - 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-reservations allows users to reserve topics (if their tier allows it)
|
||||||
#
|
#
|
||||||
# enable-signup: false
|
# enable-signup: false
|
||||||
|
# require-login: false
|
||||||
# enable-login: false
|
# enable-login: false
|
||||||
# enable-reservations: false
|
# enable-reservations: false
|
||||||
|
|
||||||
@@ -292,6 +325,18 @@
|
|||||||
# visitor-email-limit-burst: 16
|
# visitor-email-limit-burst: 16
|
||||||
# visitor-email-limit-replenish: "1h"
|
# 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:
|
# 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-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
|
# - 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.Username = u.Name
|
||||||
response.Role = string(u.Role)
|
response.Role = string(u.Role)
|
||||||
response.SyncTopic = u.SyncTopic
|
response.SyncTopic = u.SyncTopic
|
||||||
|
response.Provisioned = u.Provisioned
|
||||||
if u.Prefs != nil {
|
if u.Prefs != nil {
|
||||||
if u.Prefs.Language != nil {
|
if u.Prefs.Language != nil {
|
||||||
response.Language = *u.Prefs.Language
|
response.Language = *u.Prefs.Language
|
||||||
@@ -144,6 +145,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
|||||||
LastAccess: t.LastAccess.Unix(),
|
LastAccess: t.LastAccess.Unix(),
|
||||||
LastOrigin: lastOrigin,
|
LastOrigin: lastOrigin,
|
||||||
Expires: t.Expires.Unix(),
|
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 {
|
if _, err := s.userManager.Authenticate(u.Name, req.Password); err != nil {
|
||||||
return errHTTPBadRequestIncorrectPasswordConfirmation
|
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 s.webPush != nil && u.ID != "" {
|
||||||
if err := s.webPush.RemoveSubscriptionsByUserID(u.ID); err != nil {
|
if err := s.webPush.RemoveSubscriptionsByUserID(u.ID); err != nil {
|
||||||
logvr(v, r).Err(err).Warn("Error removing web push subscriptions for %s", u.Name)
|
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)
|
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 err := s.userManager.ChangePassword(u.Name, req.NewPassword, false); err != nil {
|
||||||
|
if errors.Is(err, user.ErrProvisionedUserChange) {
|
||||||
|
return errHTTPConflictProvisionedUserChange
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
@@ -234,7 +245,7 @@ func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request
|
|||||||
"token_expires": expires,
|
"token_expires": expires,
|
||||||
}).
|
}).
|
||||||
Debug("Creating token for user %s", u.Name)
|
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 {
|
if err != nil {
|
||||||
return err
|
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)
|
Debug("Updating token for user %s as deleted", u.Name)
|
||||||
token, err := s.userManager.ChangeToken(u.ID, req.Token, req.Label, expires)
|
token, err := s.userManager.ChangeToken(u.ID, req.Token, req.Label, expires)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, user.ErrProvisionedTokenChange) {
|
||||||
|
return errHTTPConflictProvisionedTokenChange
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
response := &apiAccountTokenResponse{
|
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 err := s.userManager.RemoveToken(u.ID, token); err != nil {
|
||||||
|
if errors.Is(err, user.ErrProvisionedTokenChange) {
|
||||||
|
return errHTTPConflictProvisionedTokenChange
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
logvr(v, r).
|
logvr(v, r).
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ func TestAccount_ChangeSettings(t *testing.T) {
|
|||||||
|
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||||
u, _ := s.userManager.User("phil")
|
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{
|
rr := request(t, s, "PATCH", "/v1/account/settings", `{"notification": {"sound": "juntos"},"ignored": true}`, map[string]string{
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
@@ -251,7 +251,11 @@ func TestAccount_Subscription_AddUpdateDelete(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAccount_ChangePassword(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()
|
defer s.closeDatabases()
|
||||||
|
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
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"),
|
"Authorization": util.BasicAuth("phil", "new password"),
|
||||||
})
|
})
|
||||||
require.Equal(t, 200, rr.Code)
|
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) {
|
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] {
|
for i, g := range grants[u.ID] {
|
||||||
userGrants[i] = &apiUserGrantResponse{
|
userGrants[i] = &apiUserGrantResponse{
|
||||||
Topic: g.TopicPattern,
|
Topic: g.TopicPattern,
|
||||||
Permission: g.Allow.String(),
|
Permission: g.Permission.String(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
usersResponse[i] = &apiUserResponse{
|
usersResponse[i] = &apiUserResponse{
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build !nofirebase
|
||||||
|
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -14,6 +16,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
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
|
fcmMessageLimit = 4000
|
||||||
fcmApnsBodyMessageLimit = 100
|
fcmApnsBodyMessageLimit = 100
|
||||||
)
|
)
|
||||||
@@ -73,7 +79,7 @@ type firebaseSenderImpl struct {
|
|||||||
client *messaging.Client
|
client *messaging.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFirebaseSender(credentialsFile string) (*firebaseSenderImpl, error) {
|
func newFirebaseSender(credentialsFile string) (firebaseSender, error) {
|
||||||
fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(credentialsFile))
|
fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(credentialsFile))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const (
|
|||||||
|
|
||||||
func (s *Server) limitRequests(next handleFunc) handleFunc {
|
func (s *Server) limitRequests(next handleFunc) handleFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
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)
|
return next(w, r, v)
|
||||||
} else if !v.RequestAllowed() {
|
} else if !v.RequestAllowed() {
|
||||||
return errHTTPTooManyRequestsLimitRequests
|
return errHTTPTooManyRequestsLimitRequests
|
||||||
@@ -40,7 +40,7 @@ func (s *Server) limitRequestsWithTopic(next handleFunc) handleFunc {
|
|||||||
contextRateVisitor: vrate,
|
contextRateVisitor: vrate,
|
||||||
contextTopic: t,
|
contextTopic: t,
|
||||||
})
|
})
|
||||||
if util.ContainsIP(s.config.VisitorRequestExemptIPAddrs, v.ip) {
|
if util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) {
|
||||||
return next(w, r, v)
|
return next(w, r, v)
|
||||||
} else if !vrate.RequestAllowed() {
|
} else if !vrate.RequestAllowed() {
|
||||||
return errHTTPTooManyRequestsLimitRequests
|
return errHTTPTooManyRequestsLimitRequests
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build !nopayments
|
||||||
|
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -12,6 +14,7 @@ import (
|
|||||||
"github.com/stripe/stripe-go/v74/subscription"
|
"github.com/stripe/stripe-go/v74/subscription"
|
||||||
"github.com/stripe/stripe-go/v74/webhook"
|
"github.com/stripe/stripe-go/v74/webhook"
|
||||||
"heckel.io/ntfy/v2/log"
|
"heckel.io/ntfy/v2/log"
|
||||||
|
"heckel.io/ntfy/v2/payments"
|
||||||
"heckel.io/ntfy/v2/user"
|
"heckel.io/ntfy/v2/user"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
"io"
|
"io"
|
||||||
@@ -22,7 +25,7 @@ import (
|
|||||||
|
|
||||||
// Payments in ntfy are done via Stripe.
|
// 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:
|
// handle payments:
|
||||||
//
|
//
|
||||||
// - Checkout:
|
// - Checkout:
|
||||||
@@ -464,8 +467,8 @@ func (s *Server) updateSubscriptionAndTier(r *http.Request, v *visitor, u *user.
|
|||||||
billing := &user.Billing{
|
billing := &user.Billing{
|
||||||
StripeCustomerID: customerID,
|
StripeCustomerID: customerID,
|
||||||
StripeSubscriptionID: subscriptionID,
|
StripeSubscriptionID: subscriptionID,
|
||||||
StripeSubscriptionStatus: stripe.SubscriptionStatus(status),
|
StripeSubscriptionStatus: payments.SubscriptionStatus(status),
|
||||||
StripeSubscriptionInterval: stripe.PriceRecurringInterval(interval),
|
StripeSubscriptionInterval: payments.PriceRecurringInterval(interval),
|
||||||
StripeSubscriptionPaidUntil: time.Unix(paidUntil, 0),
|
StripeSubscriptionPaidUntil: time.Unix(paidUntil, 0),
|
||||||
StripeSubscriptionCancelAt: time.Unix(cancelAt, 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
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -6,6 +8,7 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/stripe/stripe-go/v74"
|
"github.com/stripe/stripe-go/v74"
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
|
"heckel.io/ntfy/v2/payments"
|
||||||
"heckel.io/ntfy/v2/user"
|
"heckel.io/ntfy/v2/user"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
"io"
|
"io"
|
||||||
@@ -345,8 +348,8 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes
|
|||||||
require.Nil(t, u.Tier)
|
require.Nil(t, u.Tier)
|
||||||
require.Equal(t, "", u.Billing.StripeCustomerID)
|
require.Equal(t, "", u.Billing.StripeCustomerID)
|
||||||
require.Equal(t, "", u.Billing.StripeSubscriptionID)
|
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, stripe.PriceRecurringInterval(""), u.Billing.StripeSubscriptionInterval)
|
require.Equal(t, payments.PriceRecurringInterval(""), u.Billing.StripeSubscriptionInterval)
|
||||||
require.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.Unix())
|
require.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.Unix())
|
||||||
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.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!
|
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, "starter", u.Tier.Code) // Not "pro"
|
||||||
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
|
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
|
||||||
require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID)
|
require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID)
|
||||||
require.Equal(t, stripe.SubscriptionStatusActive, u.Billing.StripeSubscriptionStatus)
|
require.Equal(t, payments.SubscriptionStatus(stripe.SubscriptionStatusActive), u.Billing.StripeSubscriptionStatus)
|
||||||
require.Equal(t, stripe.PriceRecurringIntervalMonth, u.Billing.StripeSubscriptionInterval)
|
require.Equal(t, payments.PriceRecurringInterval(stripe.PriceRecurringIntervalMonth), u.Billing.StripeSubscriptionInterval)
|
||||||
require.Equal(t, int64(123456789), u.Billing.StripeSubscriptionPaidUntil.Unix())
|
require.Equal(t, int64(123456789), u.Billing.StripeSubscriptionPaidUntil.Unix())
|
||||||
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
|
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
|
||||||
require.Equal(t, int64(0), u.Stats.Messages)
|
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{
|
billing := &user.Billing{
|
||||||
StripeCustomerID: "acct_5555",
|
StripeCustomerID: "acct_5555",
|
||||||
StripeSubscriptionID: "sub_1234",
|
StripeSubscriptionID: "sub_1234",
|
||||||
StripeSubscriptionStatus: stripe.SubscriptionStatusPastDue,
|
StripeSubscriptionStatus: payments.SubscriptionStatus(stripe.SubscriptionStatusPastDue),
|
||||||
StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth,
|
StripeSubscriptionInterval: payments.PriceRecurringInterval(stripe.PriceRecurringIntervalMonth),
|
||||||
StripeSubscriptionPaidUntil: time.Unix(123, 0),
|
StripeSubscriptionPaidUntil: time.Unix(123, 0),
|
||||||
StripeSubscriptionCancelAt: time.Unix(456, 0),
|
StripeSubscriptionCancelAt: time.Unix(456, 0),
|
||||||
}
|
}
|
||||||
@@ -517,8 +520,8 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
|
|||||||
require.Equal(t, "starter", u.Tier.Code) // Not "pro"
|
require.Equal(t, "starter", u.Tier.Code) // Not "pro"
|
||||||
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
|
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
|
||||||
require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID)
|
require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID)
|
||||||
require.Equal(t, stripe.SubscriptionStatusActive, u.Billing.StripeSubscriptionStatus) // Not "past_due"
|
require.Equal(t, payments.SubscriptionStatus(stripe.SubscriptionStatusActive), u.Billing.StripeSubscriptionStatus) // Not "past_due"
|
||||||
require.Equal(t, stripe.PriceRecurringIntervalYear, u.Billing.StripeSubscriptionInterval) // Not "month"
|
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(1674268231), u.Billing.StripeSubscriptionPaidUntil.Unix()) // Updated
|
||||||
require.Equal(t, int64(1674299999), u.Billing.StripeSubscriptionCancelAt.Unix()) // Updated
|
require.Equal(t, int64(1674299999), u.Billing.StripeSubscriptionCancelAt.Unix()) // Updated
|
||||||
|
|
||||||
@@ -580,8 +583,8 @@ func TestPayments_Webhook_Subscription_Deleted(t *testing.T) {
|
|||||||
require.Nil(t, s.userManager.ChangeBilling(u.Name, &user.Billing{
|
require.Nil(t, s.userManager.ChangeBilling(u.Name, &user.Billing{
|
||||||
StripeCustomerID: "acct_5555",
|
StripeCustomerID: "acct_5555",
|
||||||
StripeSubscriptionID: "sub_1234",
|
StripeSubscriptionID: "sub_1234",
|
||||||
StripeSubscriptionStatus: stripe.SubscriptionStatusPastDue,
|
StripeSubscriptionStatus: payments.SubscriptionStatus(stripe.SubscriptionStatusPastDue),
|
||||||
StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth,
|
StripeSubscriptionInterval: payments.PriceRecurringInterval(stripe.PriceRecurringIntervalMonth),
|
||||||
StripeSubscriptionPaidUntil: time.Unix(123, 0),
|
StripeSubscriptionPaidUntil: time.Unix(123, 0),
|
||||||
StripeSubscriptionCancelAt: time.Unix(0, 0),
|
StripeSubscriptionCancelAt: time.Unix(0, 0),
|
||||||
}))
|
}))
|
||||||
@@ -598,7 +601,7 @@ func TestPayments_Webhook_Subscription_Deleted(t *testing.T) {
|
|||||||
require.Nil(t, u.Tier)
|
require.Nil(t, u.Tier)
|
||||||
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
|
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
|
||||||
require.Equal(t, "", u.Billing.StripeSubscriptionID)
|
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.StripeSubscriptionPaidUntil.Unix())
|
||||||
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
|
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
_ "embed"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -22,7 +23,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/SherClockHolmes/webpush-go"
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"heckel.io/ntfy/v2/log"
|
"heckel.io/ntfy/v2/log"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
@@ -280,30 +280,6 @@ func TestServer_WebEnabled(t *testing.T) {
|
|||||||
rr = request(t, s2, "GET", "/app.html", "", nil)
|
rr = request(t, s2, "GET", "/app.html", "", nil)
|
||||||
require.Equal(t, 200, rr.Code)
|
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) {
|
func TestServer_PublishLargeMessage(t *testing.T) {
|
||||||
c := newTestConfig(t)
|
c := newTestConfig(t)
|
||||||
c.AttachmentCacheDir = "" // Disable attachments
|
c.AttachmentCacheDir = "" // Disable attachments
|
||||||
@@ -1169,7 +1145,7 @@ func (t *testMailer) Count() int {
|
|||||||
return t.count
|
return t.count
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_PublishTooRequests_Defaults(t *testing.T) {
|
func TestServer_PublishTooManyRequests_Defaults(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
for i := 0; i < 60; i++ {
|
for i := 0; i < 60; i++ {
|
||||||
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil)
|
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)
|
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 := newTestConfig(t)
|
||||||
c.VisitorRequestLimitBurst = 3
|
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)
|
s := newTestServer(t, c)
|
||||||
for i := 0; i < 5; i++ { // > 3
|
for i := 0; i < 5; i++ { // > 3
|
||||||
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil)
|
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 := newTestConfig(t)
|
||||||
c.VisitorRequestLimitBurst = 10
|
c.VisitorRequestLimitBurst = 10
|
||||||
c.VisitorMessageDailyLimit = 4
|
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)
|
s := newTestServer(t, c)
|
||||||
for i := 0; i < 8; i++ { // 4
|
for i := 0; i < 8; i++ { // 4
|
||||||
response := request(t, s, "PUT", "/mytopic", "message", nil)
|
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()
|
t.Parallel()
|
||||||
c := newTestConfig(t)
|
c := newTestConfig(t)
|
||||||
c.VisitorRequestLimitBurst = 60
|
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())
|
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) {
|
func TestServer_Visitor_Custom_Forwarded_Header(t *testing.T) {
|
||||||
c := newTestConfig(t)
|
c := newTestConfig(t)
|
||||||
c.BehindProxy = true
|
c.BehindProxy = true
|
||||||
c.ProxyForwardedHeader = "Forwarded"
|
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)
|
s := newTestServer(t, c)
|
||||||
r, _ := http.NewRequest("GET", "/bla", nil)
|
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||||
r.RemoteAddr = "8.9.10.11:1234"
|
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())
|
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) {
|
func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
count := 50000
|
count := 50000
|
||||||
@@ -2833,7 +2893,7 @@ func TestServer_MessageTemplate_Range(t *testing.T) {
|
|||||||
|
|
||||||
require.Equal(t, 200, response.Code)
|
require.Equal(t, 200, response.Code)
|
||||||
m := toMessage(t, response.Body.String())
|
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) {
|
func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageOK(t *testing.T) {
|
||||||
@@ -2886,8 +2946,7 @@ Labels:
|
|||||||
Annotations:
|
Annotations:
|
||||||
- summary = 15m load average too high
|
- summary = 15m load average too high
|
||||||
Source: localhost:3000/alerting/grafana/NW9oDw-4z/view
|
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
|
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)
|
||||||
`, m.Message)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_MessageTemplate_GitHub(t *testing.T) {
|
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 {
|
func newTestConfig(t *testing.T) *Config {
|
||||||
conf := NewConfig()
|
conf := NewConfig()
|
||||||
conf.BaseURL = "http://127.0.0.1:12345"
|
conf.BaseURL = "http://127.0.0.1:12345"
|
||||||
conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")
|
conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")
|
||||||
conf.CacheStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;"
|
conf.CacheStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;"
|
||||||
conf.AttachmentCacheDir = t.TempDir()
|
conf.AttachmentCacheDir = t.TempDir()
|
||||||
|
conf.TemplateDir = t.TempDir()
|
||||||
return conf
|
return conf
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2962,17 +3232,6 @@ func newTestConfigWithAuthFile(t *testing.T) *Config {
|
|||||||
return conf
|
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 {
|
func newTestServer(t *testing.T, config *Config) *Server {
|
||||||
server, err := New(config)
|
server, err := New(config)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build !nowebpush
|
||||||
|
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -13,6 +15,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
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
|
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
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/SherClockHolmes/webpush-go"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"heckel.io/ntfy/v2/user"
|
"heckel.io/ntfy/v2/user"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
@@ -10,6 +13,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -20,6 +24,28 @@ const (
|
|||||||
testWebPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v1/AAABBCCCDDEEEFFF"
|
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) {
|
func TestServer_WebPush_Disabled(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(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.Nil(t, err)
|
||||||
require.Len(t, subs, expectedLength)
|
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"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/emersion/go-smtp"
|
|
||||||
"github.com/microcosm-cc/bluemonday"
|
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
@@ -18,6 +16,9 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/emersion/go-smtp"
|
||||||
|
"github.com/microcosm-cc/bluemonday"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -191,12 +192,12 @@ func (s *smtpSession) publishMessage(m *message) error {
|
|||||||
// Call HTTP handler with fake HTTP request
|
// Call HTTP handler with fake HTTP request
|
||||||
url := fmt.Sprintf("%s/%s", s.backend.config.BaseURL, m.Topic)
|
url := fmt.Sprintf("%s/%s", s.backend.config.BaseURL, m.Topic)
|
||||||
req, err := http.NewRequest("POST", url, strings.NewReader(m.Message))
|
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 {
|
if err != nil {
|
||||||
return err
|
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 != "" {
|
if m.Title != "" {
|
||||||
req.Header.Set("Title", 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/log"
|
||||||
"heckel.io/ntfy/v2/user"
|
"heckel.io/ntfy/v2/user"
|
||||||
|
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -246,6 +245,51 @@ func (q *queryFilter) Pass(msg *message) bool {
|
|||||||
return true
|
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 {
|
type apiHealthResponse struct {
|
||||||
Healthy bool `json:"healthy"`
|
Healthy bool `json:"healthy"`
|
||||||
}
|
}
|
||||||
@@ -321,6 +365,7 @@ type apiAccountTokenResponse struct {
|
|||||||
LastAccess int64 `json:"last_access,omitempty"`
|
LastAccess int64 `json:"last_access,omitempty"`
|
||||||
LastOrigin string `json:"last_origin,omitempty"`
|
LastOrigin string `json:"last_origin,omitempty"`
|
||||||
Expires int64 `json:"expires,omitempty"` // Unix timestamp
|
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 {
|
type apiAccountPhoneNumberVerifyRequest struct {
|
||||||
@@ -382,6 +427,7 @@ type apiAccountResponse struct {
|
|||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Role string `json:"role,omitempty"`
|
Role string `json:"role,omitempty"`
|
||||||
SyncTopic string `json:"sync_topic,omitempty"`
|
SyncTopic string `json:"sync_topic,omitempty"`
|
||||||
|
Provisioned bool `json:"provisioned,omitempty"`
|
||||||
Language string `json:"language,omitempty"`
|
Language string `json:"language,omitempty"`
|
||||||
Notification *user.NotificationPrefs `json:"notification,omitempty"`
|
Notification *user.NotificationPrefs `json:"notification,omitempty"`
|
||||||
Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`
|
Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`
|
||||||
@@ -403,6 +449,7 @@ type apiConfigResponse struct {
|
|||||||
BaseURL string `json:"base_url"`
|
BaseURL string `json:"base_url"`
|
||||||
AppRoot string `json:"app_root"`
|
AppRoot string `json:"app_root"`
|
||||||
EnableLogin bool `json:"enable_login"`
|
EnableLogin bool `json:"enable_login"`
|
||||||
|
RequireLogin bool `json:"require_login"`
|
||||||
EnableSignup bool `json:"enable_signup"`
|
EnableSignup bool `json:"enable_signup"`
|
||||||
EnablePayments bool `json:"enable_payments"`
|
EnablePayments bool `json:"enable_payments"`
|
||||||
EnableCalls bool `json:"enable_calls"`
|
EnableCalls bool `json:"enable_calls"`
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"heckel.io/ntfy/v2/util"
|
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"heckel.io/ntfy/v2/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -20,8 +20,14 @@ var (
|
|||||||
// priorityHeaderIgnoreRegex matches specific patterns of the "Priority" header (RFC 9218), so that it can be ignored
|
// 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$`)
|
priorityHeaderIgnoreRegex = regexp.MustCompile(`^u=\d,\s*(i|\d)$|^u=\d$`)
|
||||||
|
|
||||||
// forwardedHeaderRegex parses IPv4 addresses from the "Forwarded" header (RFC 7239)
|
// forwardedHeaderRegex parses IPv4 and IPv6 addresses from the "Forwarded" header (RFC 7239)
|
||||||
forwardedHeaderRegex = regexp.MustCompile(`(?i)\bfor="?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})"?`)
|
// 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 {
|
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,
|
// extractIPAddress extracts the IP address of the visitor from the request,
|
||||||
// either from the TCP socket or from a proxy header.
|
// 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 behindProxy && proxyForwardedHeader != "" {
|
||||||
if addr, err := extractIPAddressFromHeader(r, proxyForwardedHeader, proxyTrustedAddresses); err == nil {
|
if addr, err := extractIPAddressFromHeader(r, proxyForwardedHeader, proxyTrustedPrefixes); err == nil {
|
||||||
return addr
|
return addr
|
||||||
}
|
}
|
||||||
// Fall back to the remote address if the header is not found or invalid
|
// 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
|
// 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).
|
// 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.
|
// 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)))
|
value := strings.TrimSpace(strings.ToLower(r.Header.Get(forwardedHeader)))
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return netip.IPv4Unspecified(), fmt.Errorf("no %s header found", forwardedHeader)
|
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)
|
addrsStrs := util.Map(util.SplitNoEmpty(value, ","), strings.TrimSpace)
|
||||||
var validAddrs []netip.Addr
|
var validAddrs []netip.Addr
|
||||||
for _, addrStr := range addrsStrs {
|
for _, addrStr := range addrsStrs {
|
||||||
if addr, err := netip.ParseAddr(addrStr); err == nil {
|
// Handle Forwarded header with for="[IPv6]" or for="IPv4"
|
||||||
validAddrs = append(validAddrs, addr)
|
if m := forwardedHeaderRegex.FindStringSubmatch(addrStr); len(m) == 2 {
|
||||||
} else if m := forwardedHeaderRegex.FindStringSubmatch(addrStr); len(m) == 2 {
|
addrRaw := m[1]
|
||||||
if addr, err := netip.ParseAddr(m[1]); err == nil {
|
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)
|
validAddrs = append(validAddrs, addr)
|
||||||
}
|
}
|
||||||
|
} else if addr, err := netip.ParseAddr(addrStr); err == nil {
|
||||||
|
validAddrs = append(validAddrs, addr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Filter out proxy addresses
|
// Filter out proxy addresses
|
||||||
clientAddrs := util.Filter(validAddrs, func(addr netip.Addr) bool {
|
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 {
|
if len(clientAddrs) == 0 {
|
||||||
return netip.IPv4Unspecified(), fmt.Errorf("no client IP address found in %s header: %s", forwardedHeader, value)
|
return netip.IPv4Unspecified(), fmt.Errorf("no client IP address found in %s header: %s", forwardedHeader, value)
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/stretchr/testify/require"
|
"heckel.io/ntfy/v2/user"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestReadBoolParam(t *testing.T) {
|
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("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")
|
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, "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())
|
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("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")
|
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, "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, "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())
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"heckel.io/ntfy/v2/log"
|
|
||||||
"heckel.io/ntfy/v2/user"
|
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
|
"heckel.io/ntfy/v2/log"
|
||||||
|
"heckel.io/ntfy/v2/user"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ func (v *visitor) Context() log.Context {
|
|||||||
func (v *visitor) contextNoLock() log.Context {
|
func (v *visitor) contextNoLock() log.Context {
|
||||||
info := v.infoLightNoLock()
|
info := v.infoLightNoLock()
|
||||||
fields := log.Context{
|
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_ip": v.ip.String(),
|
||||||
"visitor_seen": util.FormatTime(v.seen),
|
"visitor_seen": util.FormatTime(v.seen),
|
||||||
"visitor_messages": info.Stats.Messages,
|
"visitor_messages": info.Stats.Messages,
|
||||||
@@ -524,9 +524,15 @@ func dailyLimitToRate(limit int64) rate.Limit {
|
|||||||
return rate.Limit(limit) * rate.Every(oneDay)
|
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 {
|
if u != nil && u.Tier != nil {
|
||||||
return fmt.Sprintf("user:%s", u.ID)
|
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())
|
return fmt.Sprintf("ip:%s", ip.String())
|
||||||
}
|
}
|
||||||
|
|||||||
596
user/manager.go
@@ -7,11 +7,13 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/mattn/go-sqlite3"
|
"github.com/mattn/go-sqlite3"
|
||||||
"github.com/stripe/stripe-go/v74"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"heckel.io/ntfy/v2/log"
|
"heckel.io/ntfy/v2/log"
|
||||||
|
"heckel.io/ntfy/v2/payments"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -75,6 +77,7 @@ const (
|
|||||||
role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL,
|
role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL,
|
||||||
prefs JSON NOT NULL DEFAULT '{}',
|
prefs JSON NOT NULL DEFAULT '{}',
|
||||||
sync_topic TEXT NOT NULL,
|
sync_topic TEXT NOT NULL,
|
||||||
|
provisioned INT NOT NULL,
|
||||||
stats_messages INT NOT NULL DEFAULT (0),
|
stats_messages INT NOT NULL DEFAULT (0),
|
||||||
stats_emails INT NOT NULL DEFAULT (0),
|
stats_emails INT NOT NULL DEFAULT (0),
|
||||||
stats_calls INT NOT NULL DEFAULT (0),
|
stats_calls INT NOT NULL DEFAULT (0),
|
||||||
@@ -97,6 +100,7 @@ const (
|
|||||||
read INT NOT NULL,
|
read INT NOT NULL,
|
||||||
write INT NOT NULL,
|
write INT NOT NULL,
|
||||||
owner_user_id INT,
|
owner_user_id INT,
|
||||||
|
provisioned INT NOT NULL,
|
||||||
PRIMARY KEY (user_id, topic),
|
PRIMARY KEY (user_id, topic),
|
||||||
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE,
|
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE
|
FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE
|
||||||
@@ -108,9 +112,11 @@ const (
|
|||||||
last_access INT NOT NULL,
|
last_access INT NOT NULL,
|
||||||
last_origin TEXT NOT NULL,
|
last_origin TEXT NOT NULL,
|
||||||
expires INT NOT NULL,
|
expires INT NOT NULL,
|
||||||
|
provisioned INT NOT NULL,
|
||||||
PRIMARY KEY (user_id, token),
|
PRIMARY KEY (user_id, token),
|
||||||
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
CREATE UNIQUE INDEX idx_user_token ON user_token (token);
|
||||||
CREATE TABLE IF NOT EXISTS user_phone (
|
CREATE TABLE IF NOT EXISTS user_phone (
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
phone_number TEXT NOT NULL,
|
phone_number TEXT NOT NULL,
|
||||||
@@ -121,8 +127,8 @@ const (
|
|||||||
id INT PRIMARY KEY,
|
id INT PRIMARY KEY,
|
||||||
version INT NOT NULL
|
version INT NOT NULL
|
||||||
);
|
);
|
||||||
INSERT INTO user (id, user, pass, role, sync_topic, created)
|
INSERT INTO user (id, user, pass, role, sync_topic, provisioned, created)
|
||||||
VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', UNIXEPOCH())
|
VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', false, UNIXEPOCH())
|
||||||
ON CONFLICT (id) DO NOTHING;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
COMMIT;
|
COMMIT;
|
||||||
`
|
`
|
||||||
@@ -132,26 +138,26 @@ const (
|
|||||||
`
|
`
|
||||||
|
|
||||||
selectUserByIDQuery = `
|
selectUserByIDQuery = `
|
||||||
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
||||||
FROM user u
|
FROM user u
|
||||||
LEFT JOIN tier t on t.id = u.tier_id
|
LEFT JOIN tier t on t.id = u.tier_id
|
||||||
WHERE u.id = ?
|
WHERE u.id = ?
|
||||||
`
|
`
|
||||||
selectUserByNameQuery = `
|
selectUserByNameQuery = `
|
||||||
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
||||||
FROM user u
|
FROM user u
|
||||||
LEFT JOIN tier t on t.id = u.tier_id
|
LEFT JOIN tier t on t.id = u.tier_id
|
||||||
WHERE user = ?
|
WHERE user = ?
|
||||||
`
|
`
|
||||||
selectUserByTokenQuery = `
|
selectUserByTokenQuery = `
|
||||||
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
||||||
FROM user u
|
FROM user u
|
||||||
JOIN user_token tk on u.id = tk.user_id
|
JOIN user_token tk on u.id = tk.user_id
|
||||||
LEFT JOIN tier t on t.id = u.tier_id
|
LEFT JOIN tier t on t.id = u.tier_id
|
||||||
WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?)
|
WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?)
|
||||||
`
|
`
|
||||||
selectUserByStripeCustomerIDQuery = `
|
selectUserByStripeCustomerIDQuery = `
|
||||||
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
||||||
FROM user u
|
FROM user u
|
||||||
LEFT JOIN tier t on t.id = u.tier_id
|
LEFT JOIN tier t on t.id = u.tier_id
|
||||||
WHERE u.stripe_customer_id = ?
|
WHERE u.stripe_customer_id = ?
|
||||||
@@ -165,8 +171,8 @@ const (
|
|||||||
`
|
`
|
||||||
|
|
||||||
insertUserQuery = `
|
insertUserQuery = `
|
||||||
INSERT INTO user (id, user, pass, role, sync_topic, created)
|
INSERT INTO user (id, user, pass, role, sync_topic, provisioned, created)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
selectUsernamesQuery = `
|
selectUsernamesQuery = `
|
||||||
SELECT user
|
SELECT user
|
||||||
@@ -179,8 +185,10 @@ const (
|
|||||||
END, user
|
END, user
|
||||||
`
|
`
|
||||||
selectUserCountQuery = `SELECT COUNT(*) FROM user`
|
selectUserCountQuery = `SELECT COUNT(*) FROM user`
|
||||||
|
selectUserIDFromUsernameQuery = `SELECT id FROM user WHERE user = ?`
|
||||||
updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?`
|
updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?`
|
||||||
updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?`
|
updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?`
|
||||||
|
updateUserProvisionedQuery = `UPDATE user SET provisioned = ? WHERE user = ?`
|
||||||
updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?`
|
updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?`
|
||||||
updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ?, stats_calls = ? WHERE id = ?`
|
updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ?, stats_calls = ? WHERE id = ?`
|
||||||
updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0, stats_calls = 0`
|
updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0, stats_calls = 0`
|
||||||
@@ -189,18 +197,18 @@ const (
|
|||||||
deleteUserQuery = `DELETE FROM user WHERE user = ?`
|
deleteUserQuery = `DELETE FROM user WHERE user = ?`
|
||||||
|
|
||||||
upsertUserAccessQuery = `
|
upsertUserAccessQuery = `
|
||||||
INSERT INTO user_access (user_id, topic, read, write, owner_user_id)
|
INSERT INTO user_access (user_id, topic, read, write, owner_user_id, provisioned)
|
||||||
VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?, (SELECT IIF(?='',NULL,(SELECT id FROM user WHERE user=?))))
|
VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?, (SELECT IIF(?='',NULL,(SELECT id FROM user WHERE user=?))), ?)
|
||||||
ON CONFLICT (user_id, topic)
|
ON CONFLICT (user_id, topic)
|
||||||
DO UPDATE SET read=excluded.read, write=excluded.write, owner_user_id=excluded.owner_user_id
|
DO UPDATE SET read=excluded.read, write=excluded.write, owner_user_id=excluded.owner_user_id, provisioned=excluded.provisioned
|
||||||
`
|
`
|
||||||
selectUserAllAccessQuery = `
|
selectUserAllAccessQuery = `
|
||||||
SELECT user_id, topic, read, write
|
SELECT user_id, topic, read, write, provisioned
|
||||||
FROM user_access
|
FROM user_access
|
||||||
ORDER BY LENGTH(topic) DESC, write DESC, read DESC, topic
|
ORDER BY LENGTH(topic) DESC, write DESC, read DESC, topic
|
||||||
`
|
`
|
||||||
selectUserAccessQuery = `
|
selectUserAccessQuery = `
|
||||||
SELECT topic, read, write
|
SELECT topic, read, write, provisioned
|
||||||
FROM user_access
|
FROM user_access
|
||||||
WHERE user_id = (SELECT id FROM user WHERE user = ?)
|
WHERE user_id = (SELECT id FROM user WHERE user = ?)
|
||||||
ORDER BY LENGTH(topic) DESC, write DESC, read DESC, topic
|
ORDER BY LENGTH(topic) DESC, write DESC, read DESC, topic
|
||||||
@@ -244,6 +252,7 @@ const (
|
|||||||
WHERE user_id = (SELECT id FROM user WHERE user = ?)
|
WHERE user_id = (SELECT id FROM user WHERE user = ?)
|
||||||
OR owner_user_id = (SELECT id FROM user WHERE user = ?)
|
OR owner_user_id = (SELECT id FROM user WHERE user = ?)
|
||||||
`
|
`
|
||||||
|
deleteUserAccessProvisionedQuery = `DELETE FROM user_access WHERE provisioned = 1`
|
||||||
deleteTopicAccessQuery = `
|
deleteTopicAccessQuery = `
|
||||||
DELETE FROM user_access
|
DELETE FROM user_access
|
||||||
WHERE (user_id = (SELECT id FROM user WHERE user = ?) OR owner_user_id = (SELECT id FROM user WHERE user = ?))
|
WHERE (user_id = (SELECT id FROM user WHERE user = ?) OR owner_user_id = (SELECT id FROM user WHERE user = ?))
|
||||||
@@ -251,13 +260,20 @@ const (
|
|||||||
`
|
`
|
||||||
|
|
||||||
selectTokenCountQuery = `SELECT COUNT(*) FROM user_token WHERE user_id = ?`
|
selectTokenCountQuery = `SELECT COUNT(*) FROM user_token WHERE user_id = ?`
|
||||||
selectTokensQuery = `SELECT token, label, last_access, last_origin, expires FROM user_token WHERE user_id = ?`
|
selectTokensQuery = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE user_id = ?`
|
||||||
selectTokenQuery = `SELECT token, label, last_access, last_origin, expires FROM user_token WHERE user_id = ? AND token = ?`
|
selectTokenQuery = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE user_id = ? AND token = ?`
|
||||||
insertTokenQuery = `INSERT INTO user_token (user_id, token, label, last_access, last_origin, expires) VALUES (?, ?, ?, ?, ?, ?)`
|
selectAllProvisionedTokensQuery = `SELECT token, label, last_access, last_origin, expires, provisioned FROM user_token WHERE provisioned = 1`
|
||||||
|
upsertTokenQuery = `
|
||||||
|
INSERT INTO user_token (user_id, token, label, last_access, last_origin, expires, provisioned)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (user_id, token)
|
||||||
|
DO UPDATE SET label = excluded.label, expires = excluded.expires, provisioned = excluded.provisioned;
|
||||||
|
`
|
||||||
updateTokenExpiryQuery = `UPDATE user_token SET expires = ? WHERE user_id = ? AND token = ?`
|
updateTokenExpiryQuery = `UPDATE user_token SET expires = ? WHERE user_id = ? AND token = ?`
|
||||||
updateTokenLabelQuery = `UPDATE user_token SET label = ? WHERE user_id = ? AND token = ?`
|
updateTokenLabelQuery = `UPDATE user_token SET label = ? WHERE user_id = ? AND token = ?`
|
||||||
updateTokenLastAccessQuery = `UPDATE user_token SET last_access = ?, last_origin = ? WHERE token = ?`
|
updateTokenLastAccessQuery = `UPDATE user_token SET last_access = ?, last_origin = ? WHERE token = ?`
|
||||||
deleteTokenQuery = `DELETE FROM user_token WHERE user_id = ? AND token = ?`
|
deleteTokenQuery = `DELETE FROM user_token WHERE user_id = ? AND token = ?`
|
||||||
|
deleteProvisionedTokenQuery = `DELETE FROM user_token WHERE token = ?`
|
||||||
deleteAllTokenQuery = `DELETE FROM user_token WHERE user_id = ?`
|
deleteAllTokenQuery = `DELETE FROM user_token WHERE user_id = ?`
|
||||||
deleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires > 0 AND expires < ?`
|
deleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires > 0 AND expires < ?`
|
||||||
deleteExcessTokensQuery = `
|
deleteExcessTokensQuery = `
|
||||||
@@ -312,7 +328,7 @@ const (
|
|||||||
|
|
||||||
// Schema management queries
|
// Schema management queries
|
||||||
const (
|
const (
|
||||||
currentSchemaVersion = 5
|
currentSchemaVersion = 6
|
||||||
insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
|
insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
|
||||||
updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1`
|
updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1`
|
||||||
selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
|
selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
|
||||||
@@ -427,6 +443,100 @@ const (
|
|||||||
migrate4To5UpdateQueries = `
|
migrate4To5UpdateQueries = `
|
||||||
UPDATE user_access SET topic = REPLACE(topic, '_', '\_');
|
UPDATE user_access SET topic = REPLACE(topic, '_', '\_');
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// 5 -> 6
|
||||||
|
migrate5To6UpdateQueries = `
|
||||||
|
PRAGMA foreign_keys=off;
|
||||||
|
|
||||||
|
-- Alter user table: Add provisioned column
|
||||||
|
ALTER TABLE user RENAME TO user_old;
|
||||||
|
CREATE TABLE IF NOT EXISTS user (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
tier_id TEXT,
|
||||||
|
user TEXT NOT NULL,
|
||||||
|
pass TEXT NOT NULL,
|
||||||
|
role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL,
|
||||||
|
prefs JSON NOT NULL DEFAULT '{}',
|
||||||
|
sync_topic TEXT NOT NULL,
|
||||||
|
provisioned INT NOT NULL,
|
||||||
|
stats_messages INT NOT NULL DEFAULT (0),
|
||||||
|
stats_emails INT NOT NULL DEFAULT (0),
|
||||||
|
stats_calls INT NOT NULL DEFAULT (0),
|
||||||
|
stripe_customer_id TEXT,
|
||||||
|
stripe_subscription_id TEXT,
|
||||||
|
stripe_subscription_status TEXT,
|
||||||
|
stripe_subscription_interval TEXT,
|
||||||
|
stripe_subscription_paid_until INT,
|
||||||
|
stripe_subscription_cancel_at INT,
|
||||||
|
created INT NOT NULL,
|
||||||
|
deleted INT,
|
||||||
|
FOREIGN KEY (tier_id) REFERENCES tier (id)
|
||||||
|
);
|
||||||
|
INSERT INTO user
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
tier_id,
|
||||||
|
user,
|
||||||
|
pass,
|
||||||
|
role,
|
||||||
|
prefs,
|
||||||
|
sync_topic,
|
||||||
|
0, -- provisioned
|
||||||
|
stats_messages,
|
||||||
|
stats_emails,
|
||||||
|
stats_calls,
|
||||||
|
stripe_customer_id,
|
||||||
|
stripe_subscription_id,
|
||||||
|
stripe_subscription_status,
|
||||||
|
stripe_subscription_interval,
|
||||||
|
stripe_subscription_paid_until,
|
||||||
|
stripe_subscription_cancel_at,
|
||||||
|
created,
|
||||||
|
deleted
|
||||||
|
FROM user_old;
|
||||||
|
DROP TABLE user_old;
|
||||||
|
|
||||||
|
-- Alter user_access table: Add provisioned column
|
||||||
|
ALTER TABLE user_access RENAME TO user_access_old;
|
||||||
|
CREATE TABLE user_access (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
topic TEXT NOT NULL,
|
||||||
|
read INT NOT NULL,
|
||||||
|
write INT NOT NULL,
|
||||||
|
owner_user_id INT,
|
||||||
|
provisioned INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, topic),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO user_access SELECT *, 0 FROM user_access_old;
|
||||||
|
DROP TABLE user_access_old;
|
||||||
|
|
||||||
|
-- Alter user_token table: Add provisioned column
|
||||||
|
ALTER TABLE user_token RENAME TO user_token_old;
|
||||||
|
CREATE TABLE IF NOT EXISTS user_token (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
last_access INT NOT NULL,
|
||||||
|
last_origin TEXT NOT NULL,
|
||||||
|
expires INT NOT NULL,
|
||||||
|
provisioned INT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, token),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO user_token SELECT *, 0 FROM user_token_old;
|
||||||
|
DROP TABLE user_token_old;
|
||||||
|
|
||||||
|
-- Recreate indices
|
||||||
|
CREATE UNIQUE INDEX idx_user ON user (user);
|
||||||
|
CREATE UNIQUE INDEX idx_user_stripe_customer_id ON user (stripe_customer_id);
|
||||||
|
CREATE UNIQUE INDEX idx_user_stripe_subscription_id ON user (stripe_subscription_id);
|
||||||
|
CREATE UNIQUE INDEX idx_user_token ON user_token (token);
|
||||||
|
|
||||||
|
-- Re-enable foreign keys
|
||||||
|
PRAGMA foreign_keys=on;
|
||||||
|
`
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -435,42 +545,70 @@ var (
|
|||||||
2: migrateFrom2,
|
2: migrateFrom2,
|
||||||
3: migrateFrom3,
|
3: migrateFrom3,
|
||||||
4: migrateFrom4,
|
4: migrateFrom4,
|
||||||
|
5: migrateFrom5,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Manager is an implementation of Manager. It stores users and access control list
|
// Manager is an implementation of Manager. It stores users and access control list
|
||||||
// in a SQLite database.
|
// in a SQLite database.
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
|
config *Config
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
defaultAccess Permission // Default permission if no ACL matches
|
|
||||||
statsQueue map[string]*Stats // "Queue" to asynchronously write user stats to the database (UserID -> Stats)
|
statsQueue map[string]*Stats // "Queue" to asynchronously write user stats to the database (UserID -> Stats)
|
||||||
tokenQueue map[string]*TokenUpdate // "Queue" to asynchronously write token access stats to the database (Token ID -> TokenUpdate)
|
tokenQueue map[string]*TokenUpdate // "Queue" to asynchronously write token access stats to the database (Token ID -> TokenUpdate)
|
||||||
bcryptCost int // Makes testing easier
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Config holds the configuration for the user Manager
|
||||||
|
type Config struct {
|
||||||
|
Filename string // Database filename, e.g. "/var/lib/ntfy/user.db"
|
||||||
|
StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers
|
||||||
|
DefaultAccess Permission // Default permission if no ACL matches
|
||||||
|
ProvisionEnabled bool // Hack: Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands
|
||||||
|
Users []*User // Predefined users to create on startup
|
||||||
|
Access map[string][]*Grant // Predefined access grants to create on startup (username -> []*Grant)
|
||||||
|
Tokens map[string][]*Token // Predefined users to create on startup (username -> []*Token)
|
||||||
|
QueueWriterInterval time.Duration // Interval for the async queue writer to flush stats and token updates to the database
|
||||||
|
BcryptCost int // Cost of generated passwords; lowering makes testing faster
|
||||||
|
}
|
||||||
|
|
||||||
var _ Auther = (*Manager)(nil)
|
var _ Auther = (*Manager)(nil)
|
||||||
|
|
||||||
// NewManager creates a new Manager instance
|
// NewManager creates a new Manager instance
|
||||||
func NewManager(filename, startupQueries string, defaultAccess Permission, bcryptCost int, queueWriterInterval time.Duration) (*Manager, error) {
|
func NewManager(config *Config) (*Manager, error) {
|
||||||
db, err := sql.Open("sqlite3", filename)
|
// Set defaults
|
||||||
|
if config.BcryptCost <= 0 {
|
||||||
|
config.BcryptCost = DefaultUserPasswordBcryptCost
|
||||||
|
}
|
||||||
|
if config.QueueWriterInterval.Seconds() <= 0 {
|
||||||
|
config.QueueWriterInterval = DefaultUserStatsQueueWriterInterval
|
||||||
|
}
|
||||||
|
// Check the parent directory of the database file (makes for friendly error messages)
|
||||||
|
parentDir := filepath.Dir(config.Filename)
|
||||||
|
if !util.FileExists(parentDir) {
|
||||||
|
return nil, fmt.Errorf("user database directory %s does not exist or is not accessible", parentDir)
|
||||||
|
}
|
||||||
|
// Open DB and run setup queries
|
||||||
|
db, err := sql.Open("sqlite3", config.Filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := setupDB(db); err != nil {
|
if err := setupDB(db); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := runStartupQueries(db, startupQueries); err != nil {
|
if err := runStartupQueries(db, config.StartupQueries); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
manager := &Manager{
|
manager := &Manager{
|
||||||
db: db,
|
db: db,
|
||||||
defaultAccess: defaultAccess,
|
config: config,
|
||||||
statsQueue: make(map[string]*Stats),
|
statsQueue: make(map[string]*Stats),
|
||||||
tokenQueue: make(map[string]*TokenUpdate),
|
tokenQueue: make(map[string]*TokenUpdate),
|
||||||
bcryptCost: bcryptCost,
|
|
||||||
}
|
}
|
||||||
go manager.asyncQueueWriter(queueWriterInterval)
|
if err := manager.maybeProvisionUsersAccessAndTokens(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
go manager.asyncQueueWriter(config.QueueWriterInterval)
|
||||||
return manager, nil
|
return manager, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,15 +653,15 @@ func (a *Manager) AuthenticateToken(token string) (*User, error) {
|
|||||||
// CreateToken generates a random token for the given user and returns it. The token expires
|
// CreateToken generates a random token for the given user and returns it. The token expires
|
||||||
// after a fixed duration unless ChangeToken is called. This function also prunes tokens for the
|
// after a fixed duration unless ChangeToken is called. This function also prunes tokens for the
|
||||||
// given user, if there are too many of them.
|
// given user, if there are too many of them.
|
||||||
func (a *Manager) CreateToken(userID, label string, expires time.Time, origin netip.Addr) (*Token, error) {
|
func (a *Manager) CreateToken(userID, label string, expires time.Time, origin netip.Addr, provisioned bool) (*Token, error) {
|
||||||
token := util.RandomLowerStringPrefix(tokenPrefix, tokenLength) // Lowercase only to support "<topic>+<token>@<domain>" email addresses
|
return queryTx(a.db, func(tx *sql.Tx) (*Token, error) {
|
||||||
tx, err := a.db.Begin()
|
return a.createTokenTx(tx, userID, GenerateToken(), label, expires, origin, provisioned)
|
||||||
if err != nil {
|
})
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
|
||||||
|
func (a *Manager) createTokenTx(tx *sql.Tx, userID, token, label string, expires time.Time, origin netip.Addr, provisioned bool) (*Token, error) {
|
||||||
access := time.Now()
|
access := time.Now()
|
||||||
if _, err := tx.Exec(insertTokenQuery, userID, token, label, access.Unix(), origin.String(), expires.Unix()); err != nil {
|
if _, err := tx.Exec(upsertTokenQuery, userID, token, label, access.Unix(), origin.String(), expires.Unix(), provisioned); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rows, err := tx.Query(selectTokenCountQuery, userID)
|
rows, err := tx.Query(selectTokenCountQuery, userID)
|
||||||
@@ -545,15 +683,13 @@ func (a *Manager) CreateToken(userID, label string, expires time.Time, origin ne
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &Token{
|
return &Token{
|
||||||
Value: token,
|
Value: token,
|
||||||
Label: label,
|
Label: label,
|
||||||
LastAccess: access,
|
LastAccess: access,
|
||||||
LastOrigin: origin,
|
LastOrigin: origin,
|
||||||
Expires: expires,
|
Expires: expires,
|
||||||
|
Provisioned: provisioned,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,7 +703,26 @@ func (a *Manager) Tokens(userID string) ([]*Token, error) {
|
|||||||
tokens := make([]*Token, 0)
|
tokens := make([]*Token, 0)
|
||||||
for {
|
for {
|
||||||
token, err := a.readToken(rows)
|
token, err := a.readToken(rows)
|
||||||
if err == ErrTokenNotFound {
|
if errors.Is(err, ErrTokenNotFound) {
|
||||||
|
break
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tokens = append(tokens, token)
|
||||||
|
}
|
||||||
|
return tokens, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Manager) allProvisionedTokens() ([]*Token, error) {
|
||||||
|
rows, err := a.db.Query(selectAllProvisionedTokensQuery)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
tokens := make([]*Token, 0)
|
||||||
|
for {
|
||||||
|
token, err := a.readToken(rows)
|
||||||
|
if errors.Is(err, ErrTokenNotFound) {
|
||||||
break
|
break
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -590,10 +745,11 @@ func (a *Manager) Token(userID, token string) (*Token, error) {
|
|||||||
func (a *Manager) readToken(rows *sql.Rows) (*Token, error) {
|
func (a *Manager) readToken(rows *sql.Rows) (*Token, error) {
|
||||||
var token, label, lastOrigin string
|
var token, label, lastOrigin string
|
||||||
var lastAccess, expires int64
|
var lastAccess, expires int64
|
||||||
|
var provisioned bool
|
||||||
if !rows.Next() {
|
if !rows.Next() {
|
||||||
return nil, ErrTokenNotFound
|
return nil, ErrTokenNotFound
|
||||||
}
|
}
|
||||||
if err := rows.Scan(&token, &label, &lastAccess, &lastOrigin, &expires); err != nil {
|
if err := rows.Scan(&token, &label, &lastAccess, &lastOrigin, &expires, &provisioned); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
} else if err := rows.Err(); err != nil {
|
} else if err := rows.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -608,6 +764,7 @@ func (a *Manager) readToken(rows *sql.Rows) (*Token, error) {
|
|||||||
LastAccess: time.Unix(lastAccess, 0),
|
LastAccess: time.Unix(lastAccess, 0),
|
||||||
LastOrigin: lastOriginIP,
|
LastOrigin: lastOriginIP,
|
||||||
Expires: time.Unix(expires, 0),
|
Expires: time.Unix(expires, 0),
|
||||||
|
Provisioned: provisioned,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,6 +773,9 @@ func (a *Manager) ChangeToken(userID, token string, label *string, expires *time
|
|||||||
if token == "" {
|
if token == "" {
|
||||||
return nil, errNoTokenProvided
|
return nil, errNoTokenProvided
|
||||||
}
|
}
|
||||||
|
if err := a.CanChangeToken(userID, token); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
tx, err := a.db.Begin()
|
tx, err := a.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -639,15 +799,35 @@ func (a *Manager) ChangeToken(userID, token string, label *string, expires *time
|
|||||||
|
|
||||||
// RemoveToken deletes the token defined in User.Token
|
// RemoveToken deletes the token defined in User.Token
|
||||||
func (a *Manager) RemoveToken(userID, token string) error {
|
func (a *Manager) RemoveToken(userID, token string) error {
|
||||||
|
if err := a.CanChangeToken(userID, token); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return execTx(a.db, func(tx *sql.Tx) error {
|
||||||
|
return a.removeTokenTx(tx, userID, token)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Manager) removeTokenTx(tx *sql.Tx, userID, token string) error {
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return errNoTokenProvided
|
return errNoTokenProvided
|
||||||
}
|
}
|
||||||
if _, err := a.db.Exec(deleteTokenQuery, userID, token); err != nil {
|
if _, err := tx.Exec(deleteTokenQuery, userID, token); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CanChangeToken checks if the token can be changed. If the token is provisioned, it cannot be changed.
|
||||||
|
func (a *Manager) CanChangeToken(userID, token string) error {
|
||||||
|
t, err := a.Token(userID, token)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if t.Provisioned {
|
||||||
|
return ErrProvisionedTokenChange
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// RemoveExpiredTokens deletes all expired tokens from the database
|
// RemoveExpiredTokens deletes all expired tokens from the database
|
||||||
func (a *Manager) RemoveExpiredTokens() error {
|
func (a *Manager) RemoveExpiredTokens() error {
|
||||||
if _, err := a.db.Exec(deleteExpiredTokensQuery, time.Now().Unix()); err != nil {
|
if _, err := a.db.Exec(deleteExpiredTokensQuery, time.Now().Unix()); err != nil {
|
||||||
@@ -666,7 +846,7 @@ func (a *Manager) PhoneNumbers(userID string) ([]string, error) {
|
|||||||
phoneNumbers := make([]string, 0)
|
phoneNumbers := make([]string, 0)
|
||||||
for {
|
for {
|
||||||
phoneNumber, err := a.readPhoneNumber(rows)
|
phoneNumber, err := a.readPhoneNumber(rows)
|
||||||
if err == ErrPhoneNumberNotFound {
|
if errors.Is(err, ErrPhoneNumberNotFound) {
|
||||||
break
|
break
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -816,13 +996,20 @@ func (a *Manager) writeTokenUpdateQueue() error {
|
|||||||
log.Tag(tag).Debug("Writing token update queue for %d token(s)", len(tokenQueue))
|
log.Tag(tag).Debug("Writing token update queue for %d token(s)", len(tokenQueue))
|
||||||
for tokenID, update := range tokenQueue {
|
for tokenID, update := range tokenQueue {
|
||||||
log.Tag(tag).Trace("Updating token %s with last access time %v", tokenID, update.LastAccess.Unix())
|
log.Tag(tag).Trace("Updating token %s with last access time %v", tokenID, update.LastAccess.Unix())
|
||||||
if _, err := tx.Exec(updateTokenLastAccessQuery, update.LastAccess.Unix(), update.LastOrigin.String(), tokenID); err != nil {
|
if err := a.updateTokenLastAccessTx(tx, tokenID, update.LastAccess.Unix(), update.LastOrigin.String()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *Manager) updateTokenLastAccessTx(tx *sql.Tx, token string, lastAccess int64, lastOrigin string) error {
|
||||||
|
if _, err := tx.Exec(updateTokenLastAccessQuery, lastAccess, lastOrigin, token); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Authorize returns nil if the given user has access to the given topic using the desired
|
// Authorize returns nil if the given user has access to the given topic using the desired
|
||||||
// permission. The user param may be nil to signal an anonymous user.
|
// permission. The user param may be nil to signal an anonymous user.
|
||||||
func (a *Manager) Authorize(user *User, topic string, perm Permission) error {
|
func (a *Manager) Authorize(user *User, topic string, perm Permission) error {
|
||||||
@@ -843,7 +1030,7 @@ func (a *Manager) Authorize(user *User, topic string, perm Permission) error {
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
if !rows.Next() {
|
if !rows.Next() {
|
||||||
return a.resolvePerms(a.defaultAccess, perm)
|
return a.resolvePerms(a.config.DefaultAccess, perm)
|
||||||
}
|
}
|
||||||
var read, write bool
|
var read, write bool
|
||||||
if err := rows.Scan(&read, &write); err != nil {
|
if err := rows.Scan(&read, &write); err != nil {
|
||||||
@@ -865,23 +1052,33 @@ func (a *Manager) resolvePerms(base, perm Permission) error {
|
|||||||
|
|
||||||
// AddUser adds a user with the given username, password and role
|
// AddUser adds a user with the given username, password and role
|
||||||
func (a *Manager) AddUser(username, password string, role Role, hashed bool) error {
|
func (a *Manager) AddUser(username, password string, role Role, hashed bool) error {
|
||||||
|
return execTx(a.db, func(tx *sql.Tx) error {
|
||||||
|
return a.addUserTx(tx, username, password, role, hashed, false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddUser adds a user with the given username, password and role
|
||||||
|
func (a *Manager) addUserTx(tx *sql.Tx, username, password string, role Role, hashed, provisioned bool) error {
|
||||||
if !AllowedUsername(username) || !AllowedRole(role) {
|
if !AllowedUsername(username) || !AllowedRole(role) {
|
||||||
return ErrInvalidArgument
|
return ErrInvalidArgument
|
||||||
}
|
}
|
||||||
var hash []byte
|
var hash string
|
||||||
var err error = nil
|
var err error = nil
|
||||||
if hashed {
|
if hashed {
|
||||||
hash = []byte(password)
|
hash = password
|
||||||
|
if err := ValidPasswordHash(hash, a.config.BcryptCost); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
hash, err = bcrypt.GenerateFromPassword([]byte(password), a.bcryptCost)
|
hash, err = hashPassword(password, a.config.BcryptCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
userID := util.RandomStringPrefix(userIDPrefix, userIDLength)
|
userID := util.RandomStringPrefix(userIDPrefix, userIDLength)
|
||||||
syncTopic, now := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength), time.Now().Unix()
|
syncTopic, now := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength), time.Now().Unix()
|
||||||
if _, err = a.db.Exec(insertUserQuery, userID, username, hash, role, syncTopic, now); err != nil {
|
if _, err = tx.Exec(insertUserQuery, userID, username, hash, role, syncTopic, provisioned, now); err != nil {
|
||||||
if sqliteErr, ok := err.(sqlite3.Error); ok && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique {
|
if errors.Is(err, sqlite3.ErrConstraintUnique) {
|
||||||
return ErrUserExists
|
return ErrUserExists
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
@@ -892,11 +1089,20 @@ func (a *Manager) AddUser(username, password string, role Role, hashed bool) err
|
|||||||
// RemoveUser deletes the user with the given username. The function returns nil on success, even
|
// RemoveUser deletes the user with the given username. The function returns nil on success, even
|
||||||
// if the user did not exist in the first place.
|
// if the user did not exist in the first place.
|
||||||
func (a *Manager) RemoveUser(username string) error {
|
func (a *Manager) RemoveUser(username string) error {
|
||||||
|
if err := a.CanChangeUser(username); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return execTx(a.db, func(tx *sql.Tx) error {
|
||||||
|
return a.removeUserTx(tx, username)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Manager) removeUserTx(tx *sql.Tx, username string) error {
|
||||||
if !AllowedUsername(username) {
|
if !AllowedUsername(username) {
|
||||||
return ErrInvalidArgument
|
return ErrInvalidArgument
|
||||||
}
|
}
|
||||||
// Rows in user_access, user_token, etc. are deleted via foreign keys
|
// Rows in user_access, user_token, etc. are deleted via foreign keys
|
||||||
if _, err := a.db.Exec(deleteUserQuery, username); err != nil {
|
if _, err := tx.Exec(deleteUserQuery, username); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -1010,13 +1216,14 @@ func (a *Manager) userByToken(token string) (*User, error) {
|
|||||||
func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
|
func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var id, username, hash, role, prefs, syncTopic string
|
var id, username, hash, role, prefs, syncTopic string
|
||||||
|
var provisioned bool
|
||||||
var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString
|
var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString
|
||||||
var messages, emails, calls int64
|
var messages, emails, calls int64
|
||||||
var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64
|
var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64
|
||||||
if !rows.Next() {
|
if !rows.Next() {
|
||||||
return nil, ErrUserNotFound
|
return nil, ErrUserNotFound
|
||||||
}
|
}
|
||||||
if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
|
if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &provisioned, &messages, &emails, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
} else if err := rows.Err(); err != nil {
|
} else if err := rows.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1028,6 +1235,7 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
|
|||||||
Role: Role(role),
|
Role: Role(role),
|
||||||
Prefs: &Prefs{},
|
Prefs: &Prefs{},
|
||||||
SyncTopic: syncTopic,
|
SyncTopic: syncTopic,
|
||||||
|
Provisioned: provisioned,
|
||||||
Stats: &Stats{
|
Stats: &Stats{
|
||||||
Messages: messages,
|
Messages: messages,
|
||||||
Emails: emails,
|
Emails: emails,
|
||||||
@@ -1036,8 +1244,8 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
|
|||||||
Billing: &Billing{
|
Billing: &Billing{
|
||||||
StripeCustomerID: stripeCustomerID.String, // May be empty
|
StripeCustomerID: stripeCustomerID.String, // May be empty
|
||||||
StripeSubscriptionID: stripeSubscriptionID.String, // May be empty
|
StripeSubscriptionID: stripeSubscriptionID.String, // May be empty
|
||||||
StripeSubscriptionStatus: stripe.SubscriptionStatus(stripeSubscriptionStatus.String), // May be empty
|
StripeSubscriptionStatus: payments.SubscriptionStatus(stripeSubscriptionStatus.String), // May be empty
|
||||||
StripeSubscriptionInterval: stripe.PriceRecurringInterval(stripeSubscriptionInterval.String), // May be empty
|
StripeSubscriptionInterval: payments.PriceRecurringInterval(stripeSubscriptionInterval.String), // May be empty
|
||||||
StripeSubscriptionPaidUntil: time.Unix(stripeSubscriptionPaidUntil.Int64, 0), // May be zero
|
StripeSubscriptionPaidUntil: time.Unix(stripeSubscriptionPaidUntil.Int64, 0), // May be zero
|
||||||
StripeSubscriptionCancelAt: time.Unix(stripeSubscriptionCancelAt.Int64, 0), // May be zero
|
StripeSubscriptionCancelAt: time.Unix(stripeSubscriptionCancelAt.Int64, 0), // May be zero
|
||||||
},
|
},
|
||||||
@@ -1078,8 +1286,8 @@ func (a *Manager) AllGrants() (map[string][]Grant, error) {
|
|||||||
grants := make(map[string][]Grant, 0)
|
grants := make(map[string][]Grant, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var userID, topic string
|
var userID, topic string
|
||||||
var read, write bool
|
var read, write, provisioned bool
|
||||||
if err := rows.Scan(&userID, &topic, &read, &write); err != nil {
|
if err := rows.Scan(&userID, &topic, &read, &write, &provisioned); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
} else if err := rows.Err(); err != nil {
|
} else if err := rows.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1089,7 +1297,8 @@ func (a *Manager) AllGrants() (map[string][]Grant, error) {
|
|||||||
}
|
}
|
||||||
grants[userID] = append(grants[userID], Grant{
|
grants[userID] = append(grants[userID], Grant{
|
||||||
TopicPattern: fromSQLWildcard(topic),
|
TopicPattern: fromSQLWildcard(topic),
|
||||||
Allow: NewPermission(read, write),
|
Permission: NewPermission(read, write),
|
||||||
|
Provisioned: provisioned,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return grants, nil
|
return grants, nil
|
||||||
@@ -1105,15 +1314,16 @@ func (a *Manager) Grants(username string) ([]Grant, error) {
|
|||||||
grants := make([]Grant, 0)
|
grants := make([]Grant, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var topic string
|
var topic string
|
||||||
var read, write bool
|
var read, write, provisioned bool
|
||||||
if err := rows.Scan(&topic, &read, &write); err != nil {
|
if err := rows.Scan(&topic, &read, &write, &provisioned); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
} else if err := rows.Err(); err != nil {
|
} else if err := rows.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
grants = append(grants, Grant{
|
grants = append(grants, Grant{
|
||||||
TopicPattern: fromSQLWildcard(topic),
|
TopicPattern: fromSQLWildcard(topic),
|
||||||
Allow: NewPermission(read, write),
|
Permission: NewPermission(read, write),
|
||||||
|
Provisioned: provisioned,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return grants, nil
|
return grants, nil
|
||||||
@@ -1199,18 +1409,41 @@ func (a *Manager) ReservationOwner(topic string) (string, error) {
|
|||||||
|
|
||||||
// ChangePassword changes a user's password
|
// ChangePassword changes a user's password
|
||||||
func (a *Manager) ChangePassword(username, password string, hashed bool) error {
|
func (a *Manager) ChangePassword(username, password string, hashed bool) error {
|
||||||
var hash []byte
|
if err := a.CanChangeUser(username); err != nil {
|
||||||
var err error
|
return err
|
||||||
|
}
|
||||||
|
return execTx(a.db, func(tx *sql.Tx) error {
|
||||||
|
return a.changePasswordTx(tx, username, password, hashed)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanChangeUser checks if the user with the given username can be changed.
|
||||||
|
// This is used to prevent changes to provisioned users, which are defined in the config file.
|
||||||
|
func (a *Manager) CanChangeUser(username string) error {
|
||||||
|
user, err := a.User(username)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if user.Provisioned {
|
||||||
|
return ErrProvisionedUserChange
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Manager) changePasswordTx(tx *sql.Tx, username, password string, hashed bool) error {
|
||||||
|
var hash string
|
||||||
|
var err error
|
||||||
if hashed {
|
if hashed {
|
||||||
hash = []byte(password)
|
hash = password
|
||||||
|
if err := ValidPasswordHash(hash, a.config.BcryptCost); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
hash, err = bcrypt.GenerateFromPassword([]byte(password), a.bcryptCost)
|
hash, err = hashPassword(password, a.config.BcryptCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err := a.db.Exec(updateUserPassQuery, hash, username); err != nil {
|
if _, err := tx.Exec(updateUserPassQuery, hash, username); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -1219,20 +1452,38 @@ func (a *Manager) ChangePassword(username, password string, hashed bool) error {
|
|||||||
// ChangeRole changes a user's role. When a role is changed from RoleUser to RoleAdmin,
|
// ChangeRole changes a user's role. When a role is changed from RoleUser to RoleAdmin,
|
||||||
// all existing access control entries (Grant) are removed, since they are no longer needed.
|
// all existing access control entries (Grant) are removed, since they are no longer needed.
|
||||||
func (a *Manager) ChangeRole(username string, role Role) error {
|
func (a *Manager) ChangeRole(username string, role Role) error {
|
||||||
|
if err := a.CanChangeUser(username); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return execTx(a.db, func(tx *sql.Tx) error {
|
||||||
|
return a.changeRoleTx(tx, username, role)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Manager) changeRoleTx(tx *sql.Tx, username string, role Role) error {
|
||||||
if !AllowedUsername(username) || !AllowedRole(role) {
|
if !AllowedUsername(username) || !AllowedRole(role) {
|
||||||
return ErrInvalidArgument
|
return ErrInvalidArgument
|
||||||
}
|
}
|
||||||
if _, err := a.db.Exec(updateUserRoleQuery, string(role), username); err != nil {
|
if _, err := tx.Exec(updateUserRoleQuery, string(role), username); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if role == RoleAdmin {
|
if role == RoleAdmin {
|
||||||
if _, err := a.db.Exec(deleteUserAccessQuery, username, username); err != nil {
|
if _, err := tx.Exec(deleteUserAccessQuery, username, username); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// changeProvisionedTx changes the provisioned status of a user. This is used to mark users as
|
||||||
|
// provisioned. A provisioned user is a user defined in the config file.
|
||||||
|
func (a *Manager) changeProvisionedTx(tx *sql.Tx, username string, provisioned bool) error {
|
||||||
|
if _, err := tx.Exec(updateUserProvisionedQuery, provisioned, username); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ChangeTier changes a user's tier using the tier code. This function does not delete reservations, messages,
|
// ChangeTier changes a user's tier using the tier code. This function does not delete reservations, messages,
|
||||||
// or attachments, even if the new tier has lower limits in this regard. That has to be done elsewhere.
|
// or attachments, even if the new tier has lower limits in this regard. That has to be done elsewhere.
|
||||||
func (a *Manager) ChangeTier(username, tier string) error {
|
func (a *Manager) ChangeTier(username, tier string) error {
|
||||||
@@ -1306,13 +1557,19 @@ func (a *Manager) AllowReservation(username string, topic string) error {
|
|||||||
// read/write access to a topic. The parameter topicPattern may include wildcards (*). The ACL entry
|
// read/write access to a topic. The parameter topicPattern may include wildcards (*). The ACL entry
|
||||||
// owner may either be a user (username), or the system (empty).
|
// owner may either be a user (username), or the system (empty).
|
||||||
func (a *Manager) AllowAccess(username string, topicPattern string, permission Permission) error {
|
func (a *Manager) AllowAccess(username string, topicPattern string, permission Permission) error {
|
||||||
|
return execTx(a.db, func(tx *sql.Tx) error {
|
||||||
|
return a.allowAccessTx(tx, username, topicPattern, permission, false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Manager) allowAccessTx(tx *sql.Tx, username string, topicPattern string, permission Permission, provisioned bool) error {
|
||||||
if !AllowedUsername(username) && username != Everyone {
|
if !AllowedUsername(username) && username != Everyone {
|
||||||
return ErrInvalidArgument
|
return ErrInvalidArgument
|
||||||
} else if !AllowedTopicPattern(topicPattern) {
|
} else if !AllowedTopicPattern(topicPattern) {
|
||||||
return ErrInvalidArgument
|
return ErrInvalidArgument
|
||||||
}
|
}
|
||||||
owner := ""
|
owner := ""
|
||||||
if _, err := a.db.Exec(upsertUserAccessQuery, username, toSQLWildcard(topicPattern), permission.IsRead(), permission.IsWrite(), owner, owner); err != nil {
|
if _, err := tx.Exec(upsertUserAccessQuery, username, toSQLWildcard(topicPattern), permission.IsRead(), permission.IsWrite(), owner, owner, provisioned); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -1321,19 +1578,25 @@ func (a *Manager) AllowAccess(username string, topicPattern string, permission P
|
|||||||
// ResetAccess removes an access control list entry for a specific username/topic, or (if topic is
|
// ResetAccess removes an access control list entry for a specific username/topic, or (if topic is
|
||||||
// empty) for an entire user. The parameter topicPattern may include wildcards (*).
|
// empty) for an entire user. The parameter topicPattern may include wildcards (*).
|
||||||
func (a *Manager) ResetAccess(username string, topicPattern string) error {
|
func (a *Manager) ResetAccess(username string, topicPattern string) error {
|
||||||
|
return execTx(a.db, func(tx *sql.Tx) error {
|
||||||
|
return a.resetAccessTx(tx, username, topicPattern)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Manager) resetAccessTx(tx *sql.Tx, username string, topicPattern string) error {
|
||||||
if !AllowedUsername(username) && username != Everyone && username != "" {
|
if !AllowedUsername(username) && username != Everyone && username != "" {
|
||||||
return ErrInvalidArgument
|
return ErrInvalidArgument
|
||||||
} else if !AllowedTopicPattern(topicPattern) && topicPattern != "" {
|
} else if !AllowedTopicPattern(topicPattern) && topicPattern != "" {
|
||||||
return ErrInvalidArgument
|
return ErrInvalidArgument
|
||||||
}
|
}
|
||||||
if username == "" && topicPattern == "" {
|
if username == "" && topicPattern == "" {
|
||||||
_, err := a.db.Exec(deleteAllAccessQuery, username)
|
_, err := tx.Exec(deleteAllAccessQuery, username)
|
||||||
return err
|
return err
|
||||||
} else if topicPattern == "" {
|
} else if topicPattern == "" {
|
||||||
_, err := a.db.Exec(deleteUserAccessQuery, username, username)
|
_, err := tx.Exec(deleteUserAccessQuery, username, username)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err := a.db.Exec(deleteTopicAccessQuery, username, username, toSQLWildcard(topicPattern))
|
_, err := tx.Exec(deleteTopicAccessQuery, username, username, toSQLWildcard(topicPattern))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1349,10 +1612,10 @@ func (a *Manager) AddReservation(username string, topic string, everyone Permiss
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
if _, err := tx.Exec(upsertUserAccessQuery, username, escapeUnderscore(topic), true, true, username, username); err != nil {
|
if _, err := tx.Exec(upsertUserAccessQuery, username, escapeUnderscore(topic), true, true, username, username, false); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := tx.Exec(upsertUserAccessQuery, Everyone, escapeUnderscore(topic), everyone.IsRead(), everyone.IsWrite(), username, username); err != nil {
|
if _, err := tx.Exec(upsertUserAccessQuery, Everyone, escapeUnderscore(topic), everyone.IsRead(), everyone.IsWrite(), username, username, false); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
@@ -1387,7 +1650,7 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error {
|
|||||||
|
|
||||||
// DefaultAccess returns the default read/write access if no access control entry matches
|
// DefaultAccess returns the default read/write access if no access control entry matches
|
||||||
func (a *Manager) DefaultAccess() Permission {
|
func (a *Manager) DefaultAccess() Permission {
|
||||||
return a.defaultAccess
|
return a.config.DefaultAccess
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddTier creates a new tier in the database
|
// AddTier creates a new tier in the database
|
||||||
@@ -1439,7 +1702,7 @@ func (a *Manager) Tiers() ([]*Tier, error) {
|
|||||||
tiers := make([]*Tier, 0)
|
tiers := make([]*Tier, 0)
|
||||||
for {
|
for {
|
||||||
tier, err := a.readTier(rows)
|
tier, err := a.readTier(rows)
|
||||||
if err == ErrTierNotFound {
|
if errors.Is(err, ErrTierNotFound) {
|
||||||
break
|
break
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1505,6 +1768,147 @@ func (a *Manager) Close() error {
|
|||||||
return a.db.Close()
|
return a.db.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maybeProvisionUsersAccessAndTokens provisions users, access control entries, and tokens based on the config.
|
||||||
|
func (a *Manager) maybeProvisionUsersAccessAndTokens() error {
|
||||||
|
if !a.config.ProvisionEnabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
existingUsers, err := a.Users()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
provisionUsernames := util.Map(a.config.Users, func(u *User) string {
|
||||||
|
return u.Name
|
||||||
|
})
|
||||||
|
return execTx(a.db, func(tx *sql.Tx) error {
|
||||||
|
if err := a.maybeProvisionUsers(tx, provisionUsernames, existingUsers); err != nil {
|
||||||
|
return fmt.Errorf("failed to provision users: %v", err)
|
||||||
|
}
|
||||||
|
if err := a.maybeProvisionGrants(tx); err != nil {
|
||||||
|
return fmt.Errorf("failed to provision grants: %v", err)
|
||||||
|
}
|
||||||
|
if err := a.maybeProvisionTokens(tx, provisionUsernames); err != nil {
|
||||||
|
return fmt.Errorf("failed to provision tokens: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeProvisionUsers checks if the users in the config are provisioned, and adds or updates them.
|
||||||
|
// It also removes users that are provisioned, but not in the config anymore.
|
||||||
|
func (a *Manager) maybeProvisionUsers(tx *sql.Tx, provisionUsernames []string, existingUsers []*User) error {
|
||||||
|
// Remove users that are provisioned, but not in the config anymore
|
||||||
|
for _, user := range existingUsers {
|
||||||
|
if user.Name == Everyone {
|
||||||
|
continue
|
||||||
|
} else if user.Provisioned && !util.Contains(provisionUsernames, user.Name) {
|
||||||
|
if err := a.removeUserTx(tx, user.Name); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove provisioned user %s: %v", user.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Add or update provisioned users
|
||||||
|
for _, user := range a.config.Users {
|
||||||
|
if user.Name == Everyone {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
existingUser, exists := util.Find(existingUsers, func(u *User) bool {
|
||||||
|
return u.Name == user.Name
|
||||||
|
})
|
||||||
|
if !exists {
|
||||||
|
if err := a.addUserTx(tx, user.Name, user.Hash, user.Role, true, true); err != nil && !errors.Is(err, ErrUserExists) {
|
||||||
|
return fmt.Errorf("failed to add provisioned user %s: %v", user.Name, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !existingUser.Provisioned {
|
||||||
|
if err := a.changeProvisionedTx(tx, user.Name, true); err != nil {
|
||||||
|
return fmt.Errorf("failed to change provisioned status for user %s: %v", user.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if existingUser.Hash != user.Hash {
|
||||||
|
if err := a.changePasswordTx(tx, user.Name, user.Hash, true); err != nil {
|
||||||
|
return fmt.Errorf("failed to change password for provisioned user %s: %v", user.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if existingUser.Role != user.Role {
|
||||||
|
if err := a.changeRoleTx(tx, user.Name, user.Role); err != nil {
|
||||||
|
return fmt.Errorf("failed to change role for provisioned user %s: %v", user.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybyProvisionGrants removes all provisioned grants, and (re-)adds the grants from the config.
|
||||||
|
//
|
||||||
|
// Unlike users and tokens, grants can be just re-added, because they do not carry any state (such as last
|
||||||
|
// access time) or do not have dependent resources (such as grants or tokens).
|
||||||
|
func (a *Manager) maybeProvisionGrants(tx *sql.Tx) error {
|
||||||
|
// Remove all provisioned grants
|
||||||
|
if _, err := tx.Exec(deleteUserAccessProvisionedQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// (Re-)add provisioned grants
|
||||||
|
for username, grants := range a.config.Access {
|
||||||
|
user, exists := util.Find(a.config.Users, func(u *User) bool {
|
||||||
|
return u.Name == username
|
||||||
|
})
|
||||||
|
if !exists && username != Everyone {
|
||||||
|
return fmt.Errorf("user %s is not a provisioned user, refusing to add ACL entry", username)
|
||||||
|
} else if user != nil && user.Role == RoleAdmin {
|
||||||
|
return fmt.Errorf("adding access control entries is not allowed for admin roles for user %s", username)
|
||||||
|
}
|
||||||
|
for _, grant := range grants {
|
||||||
|
if err := a.resetAccessTx(tx, username, grant.TopicPattern); err != nil {
|
||||||
|
return fmt.Errorf("failed to reset access for user %s and topic %s: %v", username, grant.TopicPattern, err)
|
||||||
|
}
|
||||||
|
if err := a.allowAccessTx(tx, username, grant.TopicPattern, grant.Permission, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Manager) maybeProvisionTokens(tx *sql.Tx, provisionUsernames []string) error {
|
||||||
|
// Remove tokens that are provisioned, but not in the config anymore
|
||||||
|
existingTokens, err := a.allProvisionedTokens()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to retrieve existing provisioned tokens: %v", err)
|
||||||
|
}
|
||||||
|
var provisionTokens []string
|
||||||
|
for _, userTokens := range a.config.Tokens {
|
||||||
|
for _, token := range userTokens {
|
||||||
|
provisionTokens = append(provisionTokens, token.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, existingToken := range existingTokens {
|
||||||
|
if !slices.Contains(provisionTokens, existingToken.Value) {
|
||||||
|
if _, err := tx.Exec(deleteProvisionedTokenQuery, existingToken.Value); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove provisioned token %s: %v", existingToken.Value, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// (Re-)add provisioned tokens
|
||||||
|
for username, tokens := range a.config.Tokens {
|
||||||
|
if !slices.Contains(provisionUsernames, username) && username != Everyone {
|
||||||
|
return fmt.Errorf("user %s is not a provisioned user, refusing to add tokens", username)
|
||||||
|
}
|
||||||
|
var userID string
|
||||||
|
row := tx.QueryRow(selectUserIDFromUsernameQuery, username)
|
||||||
|
if err := row.Scan(&userID); err != nil {
|
||||||
|
return fmt.Errorf("failed to find provisioned user %s for provisioned tokens", username)
|
||||||
|
}
|
||||||
|
for _, token := range tokens {
|
||||||
|
if _, err := a.createTokenTx(tx, userID, token.Value, token.Label, time.Unix(0, 0), netip.IPv4Unspecified(), true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// toSQLWildcard converts a wildcard string to a SQL wildcard string. It only allows '*' as wildcards,
|
// toSQLWildcard converts a wildcard string to a SQL wildcard string. It only allows '*' as wildcards,
|
||||||
// and escapes '_', assuming '\' as escape character.
|
// and escapes '_', assuming '\' as escape character.
|
||||||
func toSQLWildcard(s string) string {
|
func toSQLWildcard(s string) string {
|
||||||
@@ -1676,6 +2080,22 @@ func migrateFrom4(db *sql.DB) error {
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func migrateFrom5(db *sql.DB) error {
|
||||||
|
log.Tag(tag).Info("Migrating user database schema: from 5 to 6")
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err := tx.Exec(migrate5To6UpdateQueries); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(updateSchemaVersion, 6); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
func nullString(s string) sql.NullString {
|
func nullString(s string) sql.NullString {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return sql.NullString{}
|
return sql.NullString{}
|
||||||
@@ -1689,3 +2109,35 @@ func nullInt64(v int64) sql.NullInt64 {
|
|||||||
}
|
}
|
||||||
return sql.NullInt64{Int64: v, Valid: true}
|
return sql.NullInt64{Int64: v, Valid: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// execTx executes a function in a transaction. If the function returns an error, the transaction is rolled back.
|
||||||
|
func execTx(db *sql.DB, f func(tx *sql.Tx) error) error {
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if err := f(tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryTx executes a function in a transaction and returns the result. If the function
|
||||||
|
// returns an error, the transaction is rolled back.
|
||||||
|
func queryTx[T any](db *sql.DB, f func(tx *sql.Tx) (T, error)) (T, error) {
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
var zero T
|
||||||
|
return zero, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
t, err := f(tx)
|
||||||
|
if err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/stripe/stripe-go/v74"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
@@ -52,10 +51,10 @@ func TestManager_FullScenario_Default_DenyAll(t *testing.T) {
|
|||||||
benGrants, err := a.Grants("ben")
|
benGrants, err := a.Grants("ben")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, []Grant{
|
require.Equal(t, []Grant{
|
||||||
{"everyonewrite", PermissionDenyAll},
|
{"everyonewrite", PermissionDenyAll, false},
|
||||||
{"mytopic", PermissionReadWrite},
|
{"mytopic", PermissionReadWrite, false},
|
||||||
{"writeme", PermissionWrite},
|
{"writeme", PermissionWrite, false},
|
||||||
{"readme", PermissionRead},
|
{"readme", PermissionRead, false},
|
||||||
}, benGrants)
|
}, benGrants)
|
||||||
|
|
||||||
john, err := a.Authenticate("john", "john")
|
john, err := a.Authenticate("john", "john")
|
||||||
@@ -67,10 +66,10 @@ func TestManager_FullScenario_Default_DenyAll(t *testing.T) {
|
|||||||
johnGrants, err := a.Grants("john")
|
johnGrants, err := a.Grants("john")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, []Grant{
|
require.Equal(t, []Grant{
|
||||||
{"mytopic_deny*", PermissionDenyAll},
|
{"mytopic_deny*", PermissionDenyAll, false},
|
||||||
{"mytopic_ro*", PermissionRead},
|
{"mytopic_ro*", PermissionRead, false},
|
||||||
{"mytopic*", PermissionReadWrite},
|
{"mytopic*", PermissionReadWrite, false},
|
||||||
{"*", PermissionRead},
|
{"*", PermissionRead, false},
|
||||||
}, johnGrants)
|
}, johnGrants)
|
||||||
|
|
||||||
notben, err := a.Authenticate("ben", "this is wrong")
|
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{
|
require.Nil(t, a.ChangeBilling("user", &Billing{
|
||||||
StripeCustomerID: "acct_123",
|
StripeCustomerID: "acct_123",
|
||||||
StripeSubscriptionID: "sub_123",
|
StripeSubscriptionID: "sub_123",
|
||||||
StripeSubscriptionStatus: stripe.SubscriptionStatusActive,
|
StripeSubscriptionStatus: "active",
|
||||||
StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth,
|
StripeSubscriptionInterval: "month",
|
||||||
StripeSubscriptionPaidUntil: time.Now().Add(time.Hour),
|
StripeSubscriptionPaidUntil: time.Now().Add(time.Hour),
|
||||||
StripeSubscriptionCancelAt: time.Unix(0, 0),
|
StripeSubscriptionCancelAt: time.Unix(0, 0),
|
||||||
}))
|
}))
|
||||||
@@ -194,7 +193,7 @@ func TestManager_MarkUserRemoved_RemoveDeletedUsers(t *testing.T) {
|
|||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.False(t, u.Deleted)
|
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)
|
require.Nil(t, err)
|
||||||
|
|
||||||
u, err = a.Authenticate("user", "pass")
|
u, err = a.Authenticate("user", "pass")
|
||||||
@@ -241,7 +240,7 @@ func TestManager_CreateToken_Only_Lower(t *testing.T) {
|
|||||||
u, err := a.User("user")
|
u, err := a.User("user")
|
||||||
require.Nil(t, err)
|
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.Nil(t, err)
|
||||||
require.Equal(t, token.Value, strings.ToLower(token.Value))
|
require.Equal(t, token.Value, strings.ToLower(token.Value))
|
||||||
}
|
}
|
||||||
@@ -277,10 +276,10 @@ func TestManager_UserManagement(t *testing.T) {
|
|||||||
benGrants, err := a.Grants("ben")
|
benGrants, err := a.Grants("ben")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, []Grant{
|
require.Equal(t, []Grant{
|
||||||
{"everyonewrite", PermissionDenyAll},
|
{"everyonewrite", PermissionDenyAll, false},
|
||||||
{"mytopic", PermissionReadWrite},
|
{"mytopic", PermissionReadWrite, false},
|
||||||
{"writeme", PermissionWrite},
|
{"writeme", PermissionWrite, false},
|
||||||
{"readme", PermissionRead},
|
{"readme", PermissionRead, false},
|
||||||
}, benGrants)
|
}, benGrants)
|
||||||
|
|
||||||
everyone, err := a.User(Everyone)
|
everyone, err := a.User(Everyone)
|
||||||
@@ -292,8 +291,8 @@ func TestManager_UserManagement(t *testing.T) {
|
|||||||
everyoneGrants, err := a.Grants(Everyone)
|
everyoneGrants, err := a.Grants(Everyone)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, []Grant{
|
require.Equal(t, []Grant{
|
||||||
{"everyonewrite", PermissionReadWrite},
|
{"everyonewrite", PermissionReadWrite, false},
|
||||||
{"announcements", PermissionRead},
|
{"announcements", PermissionRead, false},
|
||||||
}, everyoneGrants)
|
}, everyoneGrants)
|
||||||
|
|
||||||
// Ben: Before revoking
|
// Ben: Before revoking
|
||||||
@@ -340,7 +339,7 @@ func TestManager_UserManagement(t *testing.T) {
|
|||||||
func TestManager_ChangePassword(t *testing.T) {
|
func TestManager_ChangePassword(t *testing.T) {
|
||||||
a := newTestManager(t, PermissionDenyAll)
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, false))
|
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")
|
_, err := a.Authenticate("phil", "phil")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
@@ -354,7 +353,7 @@ func TestManager_ChangePassword(t *testing.T) {
|
|||||||
_, err = a.Authenticate("phil", "newpass")
|
_, err = a.Authenticate("phil", "newpass")
|
||||||
require.Nil(t, err)
|
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")
|
_, err = a.Authenticate("jane", "jane")
|
||||||
require.Equal(t, ErrUnauthenticated, err)
|
require.Equal(t, ErrUnauthenticated, err)
|
||||||
_, err = a.Authenticate("jane", "newpass")
|
_, err = a.Authenticate("jane", "newpass")
|
||||||
@@ -489,12 +488,12 @@ func TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) {
|
|||||||
benGrants, err := a.Grants("ben")
|
benGrants, err := a.Grants("ben")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, 1, len(benGrants))
|
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)
|
everyoneGrants, err := a.Grants(Everyone)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, 1, len(everyoneGrants))
|
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")
|
benReservations, err := a.Reservations("ben")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
@@ -523,7 +522,7 @@ func TestManager_Token_Valid(t *testing.T) {
|
|||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
// Create token for user
|
// 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.Nil(t, err)
|
||||||
require.NotEmpty(t, token.Value)
|
require.NotEmpty(t, token.Value)
|
||||||
require.Equal(t, "some label", token.Label)
|
require.Equal(t, "some label", token.Label)
|
||||||
@@ -586,12 +585,12 @@ func TestManager_Token_Expire(t *testing.T) {
|
|||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
// Create tokens for user
|
// 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.Nil(t, err)
|
||||||
require.NotEmpty(t, token1.Value)
|
require.NotEmpty(t, token1.Value)
|
||||||
require.True(t, time.Now().Add(71*time.Hour).Unix() < token1.Expires.Unix())
|
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.Nil(t, err)
|
||||||
require.NotEmpty(t, token2.Value)
|
require.NotEmpty(t, token2.Value)
|
||||||
require.NotEqual(t, token1.Value, 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)
|
require.Equal(t, errNoTokenProvided, err)
|
||||||
|
|
||||||
// Create token for user
|
// 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.Nil(t, err)
|
||||||
require.NotEmpty(t, token.Value)
|
require.NotEmpty(t, token.Value)
|
||||||
|
|
||||||
@@ -668,12 +667,12 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
|
|||||||
|
|
||||||
// Create 2 tokens for phil
|
// Create 2 tokens for phil
|
||||||
philTokens := make([]string, 0)
|
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.Nil(t, err)
|
||||||
require.NotEmpty(t, token.Value)
|
require.NotEmpty(t, token.Value)
|
||||||
philTokens = append(philTokens, 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.Nil(t, err)
|
||||||
require.NotEmpty(t, token.Value)
|
require.NotEmpty(t, token.Value)
|
||||||
philTokens = append(philTokens, 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)
|
baseTime := time.Now().Add(24 * time.Hour)
|
||||||
benTokens := make([]string, 0)
|
benTokens := make([]string, 0)
|
||||||
for i := 0; i < 62; i++ { //
|
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.Nil(t, err)
|
||||||
require.NotEmpty(t, token.Value)
|
require.NotEmpty(t, token.Value)
|
||||||
benTokens = append(benTokens, 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) {
|
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, err)
|
||||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
|
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) {
|
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, err)
|
||||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
|
||||||
|
|
||||||
@@ -781,7 +794,7 @@ func TestManager_EnqueueTokenUpdate(t *testing.T) {
|
|||||||
u, err := a.User("ben")
|
u, err := a.User("ben")
|
||||||
require.Nil(t, err)
|
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.Nil(t, err)
|
||||||
|
|
||||||
// Queue token update
|
// Queue token update
|
||||||
@@ -806,7 +819,14 @@ func TestManager_EnqueueTokenUpdate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestManager_ChangeSettings(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, err)
|
||||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
|
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))
|
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) {
|
func TestToFromSQLWildcard(t *testing.T) {
|
||||||
require.Equal(t, "up%", toSQLWildcard("up*"))
|
require.Equal(t, "up%", toSQLWildcard("up*"))
|
||||||
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.NotEqual(t, ben.SyncTopic, phil.SyncTopic)
|
||||||
require.Equal(t, 2, len(benGrants))
|
require.Equal(t, 2, len(benGrants))
|
||||||
require.Equal(t, "secret", benGrants[0].TopicPattern)
|
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, "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, "u_everyone", everyone.ID)
|
||||||
require.Equal(t, Everyone, everyone.Name)
|
require.Equal(t, Everyone, everyone.Name)
|
||||||
require.Equal(t, RoleAnonymous, everyone.Role)
|
require.Equal(t, RoleAnonymous, everyone.Role)
|
||||||
require.Equal(t, 1, len(everyoneGrants))
|
require.Equal(t, 1, len(everyoneGrants))
|
||||||
require.Equal(t, "stats", everyoneGrants[0].TopicPattern)
|
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) {
|
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 {
|
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)
|
require.Nil(t, err)
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ package user
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/stripe/stripe-go/v74"
|
|
||||||
"heckel.io/ntfy/v2/log"
|
"heckel.io/ntfy/v2/log"
|
||||||
|
"heckel.io/ntfy/v2/payments"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -14,7 +13,7 @@ import (
|
|||||||
type User struct {
|
type User struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Hash string // password hash (bcrypt)
|
Hash string // Password hash (bcrypt)
|
||||||
Token string // Only set if token was used to log in
|
Token string // Only set if token was used to log in
|
||||||
Role Role
|
Role Role
|
||||||
Prefs *Prefs
|
Prefs *Prefs
|
||||||
@@ -22,7 +21,8 @@ type User struct {
|
|||||||
Stats *Stats
|
Stats *Stats
|
||||||
Billing *Billing
|
Billing *Billing
|
||||||
SyncTopic string
|
SyncTopic string
|
||||||
Deleted bool
|
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,
|
// TierID returns the ID of the User.Tier, or an empty string if the user has no tier,
|
||||||
@@ -63,6 +63,7 @@ type Token struct {
|
|||||||
LastAccess time.Time
|
LastAccess time.Time
|
||||||
LastOrigin netip.Addr
|
LastOrigin netip.Addr
|
||||||
Expires time.Time
|
Expires time.Time
|
||||||
|
Provisioned bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// TokenUpdate holds information about the last access time and origin IP address of a token
|
// 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 {
|
type Billing struct {
|
||||||
StripeCustomerID string
|
StripeCustomerID string
|
||||||
StripeSubscriptionID string
|
StripeSubscriptionID string
|
||||||
StripeSubscriptionStatus stripe.SubscriptionStatus
|
StripeSubscriptionStatus payments.SubscriptionStatus
|
||||||
StripeSubscriptionInterval stripe.PriceRecurringInterval
|
StripeSubscriptionInterval payments.PriceRecurringInterval
|
||||||
StripeSubscriptionPaidUntil time.Time
|
StripeSubscriptionPaidUntil time.Time
|
||||||
StripeSubscriptionCancelAt 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
|
// Grant is a struct that represents an access control entry to a topic by a user
|
||||||
type Grant struct {
|
type Grant struct {
|
||||||
TopicPattern string // May include wildcard (*)
|
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
|
// Reservation is a struct that represents the ownership over a topic by a user
|
||||||
@@ -240,38 +242,6 @@ const (
|
|||||||
everyoneID = "u_everyone"
|
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
|
// Error constants used by the package
|
||||||
var (
|
var (
|
||||||
ErrUnauthenticated = errors.New("unauthenticated")
|
ErrUnauthenticated = errors.New("unauthenticated")
|
||||||
@@ -279,9 +249,13 @@ var (
|
|||||||
ErrInvalidArgument = errors.New("invalid argument")
|
ErrInvalidArgument = errors.New("invalid argument")
|
||||||
ErrUserNotFound = errors.New("user not found")
|
ErrUserNotFound = errors.New("user not found")
|
||||||
ErrUserExists = errors.New("user already exists")
|
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")
|
ErrTierNotFound = errors.New("tier not found")
|
||||||
ErrTokenNotFound = errors.New("token not found")
|
ErrTokenNotFound = errors.New("token not found")
|
||||||
ErrPhoneNumberNotFound = errors.New("phone number not found")
|
ErrPhoneNumberNotFound = errors.New("phone number not found")
|
||||||
ErrTooManyReservations = errors.New("new tier has lower reservation limit")
|
ErrTooManyReservations = errors.New("new tier has lower reservation limit")
|
||||||
ErrPhoneNumberExists = errors.New("phone number already exists")
|
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
|
||||||
|
}
|
||||||
8
util/sprig/flow_control.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// fail is a function that always returns an error with the given message.
|
||||||
|
func fail(msg string) (string, error) {
|
||||||
|
return "", errors.New(msg)
|
||||||
|
}
|
||||||
16
util/sprig/flow_control_test.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFail(t *testing.T) {
|
||||||
|
const msg = "This is an error!"
|
||||||
|
tpl := fmt.Sprintf(`{{fail "%s"}}`, msg)
|
||||||
|
_, err := runRaw(tpl, nil)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), msg)
|
||||||
|
}
|
||||||
214
util/sprig/functions.go
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
loopExecutionLimit = 10_000 // Limit the number of loop executions to prevent execution from taking too long
|
||||||
|
stringLengthLimit = 100_000 // Limit the length of strings to prevent memory issues
|
||||||
|
sliceSizeLimit = 10_000 // Limit the size of slices to prevent memory issues
|
||||||
|
)
|
||||||
|
|
||||||
|
// TxtFuncMap produces the function map.
|
||||||
|
//
|
||||||
|
// Use this to pass the functions into the template engine:
|
||||||
|
//
|
||||||
|
// tpl := template.New("foo").Funcs(sprig.FuncMap()))
|
||||||
|
//
|
||||||
|
// TxtFuncMap returns a 'text/template'.FuncMap
|
||||||
|
func TxtFuncMap() template.FuncMap {
|
||||||
|
return map[string]any{
|
||||||
|
// Date functions
|
||||||
|
"ago": dateAgo,
|
||||||
|
"date": date,
|
||||||
|
"dateInZone": dateInZone,
|
||||||
|
"dateModify": dateModify,
|
||||||
|
"duration": duration,
|
||||||
|
"durationRound": durationRound,
|
||||||
|
"htmlDate": htmlDate,
|
||||||
|
"htmlDateInZone": htmlDateInZone,
|
||||||
|
"mustDateModify": mustDateModify,
|
||||||
|
"mustToDate": mustToDate,
|
||||||
|
"now": time.Now,
|
||||||
|
"toDate": toDate,
|
||||||
|
"unixEpoch": unixEpoch,
|
||||||
|
|
||||||
|
// Strings
|
||||||
|
"trunc": trunc,
|
||||||
|
"trim": strings.TrimSpace,
|
||||||
|
"upper": strings.ToUpper,
|
||||||
|
"lower": strings.ToLower,
|
||||||
|
"title": title,
|
||||||
|
"substr": substring,
|
||||||
|
"repeat": repeat,
|
||||||
|
"trimAll": trimAll,
|
||||||
|
"trimPrefix": trimPrefix,
|
||||||
|
"trimSuffix": trimSuffix,
|
||||||
|
"contains": contains,
|
||||||
|
"hasPrefix": hasPrefix,
|
||||||
|
"hasSuffix": hasSuffix,
|
||||||
|
"quote": quote,
|
||||||
|
"squote": squote,
|
||||||
|
"cat": cat,
|
||||||
|
"indent": indent,
|
||||||
|
"nindent": nindent,
|
||||||
|
"replace": replace,
|
||||||
|
"plural": plural,
|
||||||
|
"sha1sum": sha1sum,
|
||||||
|
"sha256sum": sha256sum,
|
||||||
|
"sha512sum": sha512sum,
|
||||||
|
"adler32sum": adler32sum,
|
||||||
|
"toString": strval,
|
||||||
|
|
||||||
|
// Wrap Atoi to stop errors.
|
||||||
|
"atoi": atoi,
|
||||||
|
"seq": seq,
|
||||||
|
"toDecimal": toDecimal,
|
||||||
|
"split": split,
|
||||||
|
"splitList": splitList,
|
||||||
|
"splitn": splitn,
|
||||||
|
"toStrings": strslice,
|
||||||
|
|
||||||
|
"until": until,
|
||||||
|
"untilStep": untilStep,
|
||||||
|
|
||||||
|
// Basic arithmetic
|
||||||
|
"add1": add1,
|
||||||
|
"add": add,
|
||||||
|
"sub": sub,
|
||||||
|
"div": div,
|
||||||
|
"mod": mod,
|
||||||
|
"mul": mul,
|
||||||
|
"randInt": randInt,
|
||||||
|
"biggest": maxAsInt64,
|
||||||
|
"max": maxAsInt64,
|
||||||
|
"min": minAsInt64,
|
||||||
|
"maxf": maxAsFloat64,
|
||||||
|
"minf": minAsFloat64,
|
||||||
|
"ceil": ceil,
|
||||||
|
"floor": floor,
|
||||||
|
"round": round,
|
||||||
|
|
||||||
|
// string slices. Note that we reverse the order b/c that's better
|
||||||
|
// for template processing.
|
||||||
|
"join": join,
|
||||||
|
"sortAlpha": sortAlpha,
|
||||||
|
|
||||||
|
// Defaults
|
||||||
|
"default": defaultValue,
|
||||||
|
"empty": empty,
|
||||||
|
"coalesce": coalesce,
|
||||||
|
"all": all,
|
||||||
|
"any": anyNonEmpty,
|
||||||
|
"compact": compact,
|
||||||
|
"mustCompact": mustCompact,
|
||||||
|
"fromJSON": fromJSON,
|
||||||
|
"toJSON": toJSON,
|
||||||
|
"toPrettyJSON": toPrettyJSON,
|
||||||
|
"toRawJSON": toRawJSON,
|
||||||
|
"mustFromJSON": mustFromJSON,
|
||||||
|
"mustToJSON": mustToJSON,
|
||||||
|
"mustToPrettyJSON": mustToPrettyJSON,
|
||||||
|
"mustToRawJSON": mustToRawJSON,
|
||||||
|
"ternary": ternary,
|
||||||
|
|
||||||
|
// Reflection
|
||||||
|
"typeOf": typeOf,
|
||||||
|
"typeIs": typeIs,
|
||||||
|
"typeIsLike": typeIsLike,
|
||||||
|
"kindOf": kindOf,
|
||||||
|
"kindIs": kindIs,
|
||||||
|
"deepEqual": reflect.DeepEqual,
|
||||||
|
|
||||||
|
// Paths
|
||||||
|
"base": path.Base,
|
||||||
|
"dir": path.Dir,
|
||||||
|
"clean": path.Clean,
|
||||||
|
"ext": path.Ext,
|
||||||
|
"isAbs": path.IsAbs,
|
||||||
|
|
||||||
|
// Filepaths
|
||||||
|
"osBase": filepath.Base,
|
||||||
|
"osClean": filepath.Clean,
|
||||||
|
"osDir": filepath.Dir,
|
||||||
|
"osExt": filepath.Ext,
|
||||||
|
"osIsAbs": filepath.IsAbs,
|
||||||
|
|
||||||
|
// Encoding
|
||||||
|
"b64enc": base64encode,
|
||||||
|
"b64dec": base64decode,
|
||||||
|
"b32enc": base32encode,
|
||||||
|
"b32dec": base32decode,
|
||||||
|
|
||||||
|
// Data Structures
|
||||||
|
"tuple": list, // FIXME: with the addition of append/prepend these are no longer immutable.
|
||||||
|
"list": list,
|
||||||
|
"dict": dict,
|
||||||
|
"get": get,
|
||||||
|
"set": set,
|
||||||
|
"unset": unset,
|
||||||
|
"hasKey": hasKey,
|
||||||
|
"pluck": pluck,
|
||||||
|
"keys": keys,
|
||||||
|
"pick": pick,
|
||||||
|
"omit": omit,
|
||||||
|
"values": values,
|
||||||
|
|
||||||
|
"append": push,
|
||||||
|
"push": push,
|
||||||
|
"mustAppend": mustPush,
|
||||||
|
"mustPush": mustPush,
|
||||||
|
"prepend": prepend,
|
||||||
|
"mustPrepend": mustPrepend,
|
||||||
|
"first": first,
|
||||||
|
"mustFirst": mustFirst,
|
||||||
|
"rest": rest,
|
||||||
|
"mustRest": mustRest,
|
||||||
|
"last": last,
|
||||||
|
"mustLast": mustLast,
|
||||||
|
"initial": initial,
|
||||||
|
"mustInitial": mustInitial,
|
||||||
|
"reverse": reverse,
|
||||||
|
"mustReverse": mustReverse,
|
||||||
|
"uniq": uniq,
|
||||||
|
"mustUniq": mustUniq,
|
||||||
|
"without": without,
|
||||||
|
"mustWithout": mustWithout,
|
||||||
|
"has": has,
|
||||||
|
"mustHas": mustHas,
|
||||||
|
"slice": slice,
|
||||||
|
"mustSlice": mustSlice,
|
||||||
|
"concat": concat,
|
||||||
|
"dig": dig,
|
||||||
|
"chunk": chunk,
|
||||||
|
"mustChunk": mustChunk,
|
||||||
|
|
||||||
|
// Flow Control
|
||||||
|
"fail": fail,
|
||||||
|
|
||||||
|
// Regex
|
||||||
|
"regexMatch": regexMatch,
|
||||||
|
"mustRegexMatch": mustRegexMatch,
|
||||||
|
"regexFindAll": regexFindAll,
|
||||||
|
"mustRegexFindAll": mustRegexFindAll,
|
||||||
|
"regexFind": regexFind,
|
||||||
|
"mustRegexFind": mustRegexFind,
|
||||||
|
"regexReplaceAll": regexReplaceAll,
|
||||||
|
"mustRegexReplaceAll": mustRegexReplaceAll,
|
||||||
|
"regexReplaceAllLiteral": regexReplaceAllLiteral,
|
||||||
|
"mustRegexReplaceAllLiteral": mustRegexReplaceAllLiteral,
|
||||||
|
"regexSplit": regexSplit,
|
||||||
|
"mustRegexSplit": mustRegexSplit,
|
||||||
|
"regexQuoteMeta": regexQuoteMeta,
|
||||||
|
|
||||||
|
// URLs
|
||||||
|
"urlParse": urlParse,
|
||||||
|
"urlJoin": urlJoin,
|
||||||
|
}
|
||||||
|
}
|
||||||
28
util/sprig/functions_linux_test.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOsBase(t *testing.T) {
|
||||||
|
assert.NoError(t, runt(`{{ osBase "foo/bar" }}`, "bar"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOsDir(t *testing.T) {
|
||||||
|
assert.NoError(t, runt(`{{ osDir "foo/bar/baz" }}`, "foo/bar"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOsIsAbs(t *testing.T) {
|
||||||
|
assert.NoError(t, runt(`{{ osIsAbs "/foo" }}`, "true"))
|
||||||
|
assert.NoError(t, runt(`{{ osIsAbs "foo" }}`, "false"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOsClean(t *testing.T) {
|
||||||
|
assert.NoError(t, runt(`{{ osClean "/foo/../foo/../bar" }}`, "/bar"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOsExt(t *testing.T) {
|
||||||
|
assert.NoError(t, runt(`{{ osExt "/foo/bar/baz.txt" }}`, ".txt"))
|
||||||
|
}
|
||||||
70
util/sprig/functions_test.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBase(t *testing.T) {
|
||||||
|
assert.NoError(t, runt(`{{ base "foo/bar" }}`, "bar"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDir(t *testing.T) {
|
||||||
|
assert.NoError(t, runt(`{{ dir "foo/bar/baz" }}`, "foo/bar"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAbs(t *testing.T) {
|
||||||
|
assert.NoError(t, runt(`{{ isAbs "/foo" }}`, "true"))
|
||||||
|
assert.NoError(t, runt(`{{ isAbs "foo" }}`, "false"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClean(t *testing.T) {
|
||||||
|
assert.NoError(t, runt(`{{ clean "/foo/../foo/../bar" }}`, "/bar"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExt(t *testing.T) {
|
||||||
|
assert.NoError(t, runt(`{{ ext "/foo/bar/baz.txt" }}`, ".txt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegex(t *testing.T) {
|
||||||
|
assert.NoError(t, runt(`{{ regexQuoteMeta "1.2.3" }}`, "1\\.2\\.3"))
|
||||||
|
assert.NoError(t, runt(`{{ regexQuoteMeta "pretzel" }}`, "pretzel"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// runt runs a template and checks that the output exactly matches the expected string.
|
||||||
|
func runt(tpl, expect string) error {
|
||||||
|
return runtv(tpl, expect, map[string]string{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// runtv takes a template, and expected return, and values for substitution.
|
||||||
|
//
|
||||||
|
// It runs the template and verifies that the output is an exact match.
|
||||||
|
func runtv(tpl, expect string, vars any) error {
|
||||||
|
fmap := TxtFuncMap()
|
||||||
|
t := template.Must(template.New("test").Funcs(fmap).Parse(tpl))
|
||||||
|
var b bytes.Buffer
|
||||||
|
err := t.Execute(&b, vars)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if expect != b.String() {
|
||||||
|
return fmt.Errorf("expected '%s', got '%s'", expect, b.String())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runRaw runs a template with the given variables and returns the result.
|
||||||
|
func runRaw(tpl string, vars any) (string, error) {
|
||||||
|
fmap := TxtFuncMap()
|
||||||
|
t := template.Must(template.New("test").Funcs(fmap).Parse(tpl))
|
||||||
|
var b bytes.Buffer
|
||||||
|
err := t.Execute(&b, vars)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
505
util/sprig/list.go
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reflection is used in these functions so that slices and arrays of strings,
|
||||||
|
// ints, and other types not implementing []any can be worked with.
|
||||||
|
// For example, this is useful if you need to work on the output of regexs.
|
||||||
|
|
||||||
|
// list creates a new list (slice) containing the provided arguments.
|
||||||
|
// It accepts any number of arguments of any type and returns them as a slice.
|
||||||
|
func list(v ...any) []any {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// push appends an element to the end of a list (slice or array).
|
||||||
|
// It takes a list and a value, and returns a new list with the value appended.
|
||||||
|
// This function will panic if the first argument is not a slice or array.
|
||||||
|
func push(list any, v any) []any {
|
||||||
|
l, err := mustPush(list, v)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustPush is the implementation of push that returns an error instead of panicking.
|
||||||
|
// It converts the input list to a slice of any type, then appends the value.
|
||||||
|
func mustPush(list any, v any) ([]any, error) {
|
||||||
|
tp := reflect.TypeOf(list).Kind()
|
||||||
|
switch tp {
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
l2 := reflect.ValueOf(list)
|
||||||
|
l := l2.Len()
|
||||||
|
nl := make([]any, l)
|
||||||
|
for i := 0; i < l; i++ {
|
||||||
|
nl[i] = l2.Index(i).Interface()
|
||||||
|
}
|
||||||
|
return append(nl, v), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("cannot push on type %s", tp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepend adds an element to the beginning of a list (slice or array).
|
||||||
|
// It takes a list and a value, and returns a new list with the value at the start.
|
||||||
|
// This function will panic if the first argument is not a slice or array.
|
||||||
|
func prepend(list any, v any) []any {
|
||||||
|
l, err := mustPrepend(list, v)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustPrepend is the implementation of prepend that returns an error instead of panicking.
|
||||||
|
// It converts the input list to a slice of any type, then prepends the value.
|
||||||
|
func mustPrepend(list any, v any) ([]any, error) {
|
||||||
|
tp := reflect.TypeOf(list).Kind()
|
||||||
|
switch tp {
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
l2 := reflect.ValueOf(list)
|
||||||
|
l := l2.Len()
|
||||||
|
nl := make([]any, l)
|
||||||
|
for i := 0; i < l; i++ {
|
||||||
|
nl[i] = l2.Index(i).Interface()
|
||||||
|
}
|
||||||
|
return append([]any{v}, nl...), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("cannot prepend on type %s", tp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// chunk divides a list into sub-lists of the specified size.
|
||||||
|
// It takes a size and a list, and returns a list of lists, each containing
|
||||||
|
// up to 'size' elements from the original list.
|
||||||
|
// This function will panic if the second argument is not a slice or array.
|
||||||
|
func chunk(size int, list any) [][]any {
|
||||||
|
l, err := mustChunk(size, list)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustChunk is the implementation of chunk that returns an error instead of panicking.
|
||||||
|
// It divides the input list into chunks of the specified size.
|
||||||
|
func mustChunk(size int, list any) ([][]any, error) {
|
||||||
|
tp := reflect.TypeOf(list).Kind()
|
||||||
|
switch tp {
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
l2 := reflect.ValueOf(list)
|
||||||
|
l := l2.Len()
|
||||||
|
numChunks := int(math.Floor(float64(l-1)/float64(size)) + 1)
|
||||||
|
if numChunks > sliceSizeLimit {
|
||||||
|
return nil, fmt.Errorf("number of chunks %d exceeds maximum limit of %d", numChunks, sliceSizeLimit)
|
||||||
|
}
|
||||||
|
result := make([][]any, numChunks)
|
||||||
|
for i := 0; i < numChunks; i++ {
|
||||||
|
clen := size
|
||||||
|
// Handle the last chunk which might be smaller
|
||||||
|
if i == numChunks-1 {
|
||||||
|
clen = int(math.Floor(math.Mod(float64(l), float64(size))))
|
||||||
|
if clen == 0 {
|
||||||
|
clen = size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result[i] = make([]any, clen)
|
||||||
|
for j := 0; j < clen; j++ {
|
||||||
|
ix := i*size + j
|
||||||
|
result[i][j] = l2.Index(ix).Interface()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("cannot chunk type %s", tp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// last returns the last element of a list (slice or array).
|
||||||
|
// If the list is empty, it returns nil.
|
||||||
|
// This function will panic if the argument is not a slice or array.
|
||||||
|
func last(list any) any {
|
||||||
|
l, err := mustLast(list)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustLast is the implementation of last that returns an error instead of panicking.
|
||||||
|
// It returns the last element of the list or nil if the list is empty.
|
||||||
|
func mustLast(list any) (any, error) {
|
||||||
|
tp := reflect.TypeOf(list).Kind()
|
||||||
|
switch tp {
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
l2 := reflect.ValueOf(list)
|
||||||
|
|
||||||
|
l := l2.Len()
|
||||||
|
if l == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return l2.Index(l - 1).Interface(), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("cannot find last on type %s", tp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// first returns the first element of a list (slice or array).
|
||||||
|
// If the list is empty, it returns nil.
|
||||||
|
// This function will panic if the argument is not a slice or array.
|
||||||
|
func first(list any) any {
|
||||||
|
l, err := mustFirst(list)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustFirst is the implementation of first that returns an error instead of panicking.
|
||||||
|
// It returns the first element of the list or nil if the list is empty.
|
||||||
|
func mustFirst(list any) (any, error) {
|
||||||
|
tp := reflect.TypeOf(list).Kind()
|
||||||
|
switch tp {
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
l2 := reflect.ValueOf(list)
|
||||||
|
|
||||||
|
l := l2.Len()
|
||||||
|
if l == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return l2.Index(0).Interface(), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("cannot find first on type %s", tp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rest returns all elements of a list except the first one.
|
||||||
|
// If the list is empty, it returns nil.
|
||||||
|
// This function will panic if the argument is not a slice or array.
|
||||||
|
func rest(list any) []any {
|
||||||
|
l, err := mustRest(list)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustRest is the implementation of rest that returns an error instead of panicking.
|
||||||
|
// It returns all elements of the list except the first one, or nil if the list is empty.
|
||||||
|
func mustRest(list any) ([]any, error) {
|
||||||
|
tp := reflect.TypeOf(list).Kind()
|
||||||
|
switch tp {
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
l2 := reflect.ValueOf(list)
|
||||||
|
l := l2.Len()
|
||||||
|
if l == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
nl := make([]any, l-1)
|
||||||
|
for i := 1; i < l; i++ {
|
||||||
|
nl[i-1] = l2.Index(i).Interface()
|
||||||
|
}
|
||||||
|
return nl, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("cannot find rest on type %s", tp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// initial returns all elements of a list except the last one.
|
||||||
|
// If the list is empty, it returns nil.
|
||||||
|
// This function will panic if the argument is not a slice or array.
|
||||||
|
func initial(list any) []any {
|
||||||
|
l, err := mustInitial(list)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustInitial is the implementation of initial that returns an error instead of panicking.
|
||||||
|
// It returns all elements of the list except the last one, or nil if the list is empty.
|
||||||
|
func mustInitial(list any) ([]any, error) {
|
||||||
|
tp := reflect.TypeOf(list).Kind()
|
||||||
|
switch tp {
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
l2 := reflect.ValueOf(list)
|
||||||
|
l := l2.Len()
|
||||||
|
if l == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
nl := make([]any, l-1)
|
||||||
|
for i := 0; i < l-1; i++ {
|
||||||
|
nl[i] = l2.Index(i).Interface()
|
||||||
|
}
|
||||||
|
return nl, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("cannot find initial on type %s", tp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortAlpha sorts a list of strings alphabetically.
|
||||||
|
// If the input is not a slice or array, it returns a single-element slice
|
||||||
|
// containing the string representation of the input.
|
||||||
|
func sortAlpha(list any) []string {
|
||||||
|
k := reflect.Indirect(reflect.ValueOf(list)).Kind()
|
||||||
|
switch k {
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
a := strslice(list)
|
||||||
|
s := sort.StringSlice(a)
|
||||||
|
s.Sort()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return []string{strval(list)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reverse returns a new list with the elements in reverse order.
|
||||||
|
// This function will panic if the argument is not a slice or array.
|
||||||
|
func reverse(v any) []any {
|
||||||
|
l, err := mustReverse(v)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustReverse is the implementation of reverse that returns an error instead of panicking.
|
||||||
|
// It returns a new list with the elements in reverse order.
|
||||||
|
func mustReverse(v any) ([]any, error) {
|
||||||
|
tp := reflect.TypeOf(v).Kind()
|
||||||
|
switch tp {
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
l2 := reflect.ValueOf(v)
|
||||||
|
l := l2.Len()
|
||||||
|
// We do not sort in place because the incoming array should not be altered.
|
||||||
|
nl := make([]any, l)
|
||||||
|
for i := 0; i < l; i++ {
|
||||||
|
nl[l-i-1] = l2.Index(i).Interface()
|
||||||
|
}
|
||||||
|
return nl, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("cannot find reverse on type %s", tp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// compact returns a new list with all "empty" elements removed.
|
||||||
|
// An element is considered empty if it's nil, zero, an empty string, or an empty collection.
|
||||||
|
// This function will panic if the argument is not a slice or array.
|
||||||
|
func compact(list any) []any {
|
||||||
|
l, err := mustCompact(list)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustCompact is the implementation of compact that returns an error instead of panicking.
|
||||||
|
// It returns a new list with all "empty" elements removed.
|
||||||
|
func mustCompact(list any) ([]any, error) {
|
||||||
|
tp := reflect.TypeOf(list).Kind()
|
||||||
|
switch tp {
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
l2 := reflect.ValueOf(list)
|
||||||
|
l := l2.Len()
|
||||||
|
var nl []any
|
||||||
|
var item any
|
||||||
|
for i := 0; i < l; i++ {
|
||||||
|
item = l2.Index(i).Interface()
|
||||||
|
if !empty(item) {
|
||||||
|
nl = append(nl, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nl, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("cannot compact on type %s", tp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// uniq returns a new list with duplicate elements removed.
|
||||||
|
// The first occurrence of each element is kept.
|
||||||
|
// This function will panic if the argument is not a slice or array.
|
||||||
|
func uniq(list any) []any {
|
||||||
|
l, err := mustUniq(list)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustUniq is the implementation of uniq that returns an error instead of panicking.
|
||||||
|
// It returns a new list with duplicate elements removed.
|
||||||
|
func mustUniq(list any) ([]any, error) {
|
||||||
|
tp := reflect.TypeOf(list).Kind()
|
||||||
|
switch tp {
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
l2 := reflect.ValueOf(list)
|
||||||
|
l := l2.Len()
|
||||||
|
var dest []any
|
||||||
|
var item any
|
||||||
|
for i := 0; i < l; i++ {
|
||||||
|
item = l2.Index(i).Interface()
|
||||||
|
if !inList(dest, item) {
|
||||||
|
dest = append(dest, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dest, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("cannot find uniq on type %s", tp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// inList checks if a value is present in a list.
|
||||||
|
// It uses deep equality comparison to check for matches.
|
||||||
|
// Returns true if the value is found, false otherwise.
|
||||||
|
func inList(haystack []any, needle any) bool {
|
||||||
|
for _, h := range haystack {
|
||||||
|
if reflect.DeepEqual(needle, h) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// without returns a new list with all occurrences of the specified values removed.
|
||||||
|
// This function will panic if the first argument is not a slice or array.
|
||||||
|
func without(list any, omit ...any) []any {
|
||||||
|
l, err := mustWithout(list, omit...)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustWithout is the implementation of without that returns an error instead of panicking.
|
||||||
|
// It returns a new list with all occurrences of the specified values removed.
|
||||||
|
func mustWithout(list any, omit ...any) ([]any, error) {
|
||||||
|
tp := reflect.TypeOf(list).Kind()
|
||||||
|
switch tp {
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
l2 := reflect.ValueOf(list)
|
||||||
|
l := l2.Len()
|
||||||
|
res := []any{}
|
||||||
|
var item any
|
||||||
|
for i := 0; i < l; i++ {
|
||||||
|
item = l2.Index(i).Interface()
|
||||||
|
if !inList(omit, item) {
|
||||||
|
res = append(res, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("cannot find without on type %s", tp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// has checks if a value is present in a list.
|
||||||
|
// Returns true if the value is found, false otherwise.
|
||||||
|
// This function will panic if the second argument is not a slice or array.
|
||||||
|
func has(needle any, haystack any) bool {
|
||||||
|
l, err := mustHas(needle, haystack)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustHas is the implementation of has that returns an error instead of panicking.
|
||||||
|
// It checks if a value is present in a list.
|
||||||
|
func mustHas(needle any, haystack any) (bool, error) {
|
||||||
|
if haystack == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
tp := reflect.TypeOf(haystack).Kind()
|
||||||
|
switch tp {
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
l2 := reflect.ValueOf(haystack)
|
||||||
|
var item any
|
||||||
|
l := l2.Len()
|
||||||
|
for i := 0; i < l; i++ {
|
||||||
|
item = l2.Index(i).Interface()
|
||||||
|
if reflect.DeepEqual(needle, item) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("cannot find has on type %s", tp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// slice extracts a portion of a list based on the provided indices.
|
||||||
|
// Usage examples:
|
||||||
|
// $list := [1, 2, 3, 4, 5]
|
||||||
|
// slice $list -> list[0:5] = list[:]
|
||||||
|
// slice $list 0 3 -> list[0:3] = list[:3]
|
||||||
|
// slice $list 3 5 -> list[3:5]
|
||||||
|
// slice $list 3 -> list[3:5] = list[3:]
|
||||||
|
//
|
||||||
|
// This function will panic if the first argument is not a slice or array.
|
||||||
|
func slice(list any, indices ...any) any {
|
||||||
|
l, err := mustSlice(list, indices...)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustSlice is the implementation of slice that returns an error instead of panicking.
|
||||||
|
// It extracts a portion of a list based on the provided indices.
|
||||||
|
func mustSlice(list any, indices ...any) (any, error) {
|
||||||
|
tp := reflect.TypeOf(list).Kind()
|
||||||
|
switch tp {
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
l2 := reflect.ValueOf(list)
|
||||||
|
l := l2.Len()
|
||||||
|
if l == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
// Determine start and end indices
|
||||||
|
var start, end int
|
||||||
|
if len(indices) > 0 {
|
||||||
|
start = toInt(indices[0])
|
||||||
|
}
|
||||||
|
if len(indices) < 2 {
|
||||||
|
end = l
|
||||||
|
} else {
|
||||||
|
end = toInt(indices[1])
|
||||||
|
}
|
||||||
|
return l2.Slice(start, end).Interface(), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("list should be type of slice or array but %s", tp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// concat combines multiple lists into a single list.
|
||||||
|
// It takes any number of lists and returns a new list containing all elements.
|
||||||
|
// This function will panic if any argument is not a slice or array.
|
||||||
|
func concat(lists ...any) any {
|
||||||
|
var res []any
|
||||||
|
for _, list := range lists {
|
||||||
|
tp := reflect.TypeOf(list).Kind()
|
||||||
|
switch tp {
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
l2 := reflect.ValueOf(list)
|
||||||
|
for i := 0; i < l2.Len(); i++ {
|
||||||
|
res = append(res, l2.Index(i).Interface())
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
panic(fmt.Sprintf("cannot concat type %s as list", tp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
367
util/sprig/list_test.go
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTuple(t *testing.T) {
|
||||||
|
tpl := `{{$t := tuple 1 "a" "foo"}}{{index $t 2}}{{index $t 0 }}{{index $t 1}}`
|
||||||
|
if err := runt(tpl, "foo1a"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestList(t *testing.T) {
|
||||||
|
tpl := `{{$t := list 1 "a" "foo"}}{{index $t 2}}{{index $t 0 }}{{index $t 1}}`
|
||||||
|
if err := runt(tpl, "foo1a"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPush(t *testing.T) {
|
||||||
|
// Named `append` in the function map
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ $t := tuple 1 2 3 }}{{ append $t 4 | len }}`: "4",
|
||||||
|
`{{ $t := tuple 1 2 3 4 }}{{ append $t 5 | join "-" }}`: "1-2-3-4-5",
|
||||||
|
`{{ $t := regexSplit "/" "foo/bar/baz" -1 }}{{ append $t "qux" | join "-" }}`: "foo-bar-baz-qux",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustPush(t *testing.T) {
|
||||||
|
// Named `append` in the function map
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ $t := tuple 1 2 3 }}{{ mustAppend $t 4 | len }}`: "4",
|
||||||
|
`{{ $t := tuple 1 2 3 4 }}{{ mustAppend $t 5 | join "-" }}`: "1-2-3-4-5",
|
||||||
|
`{{ $t := regexSplit "/" "foo/bar/baz" -1 }}{{ mustPush $t "qux" | join "-" }}`: "foo-bar-baz-qux",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChunk(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ tuple 1 2 3 4 5 6 7 | chunk 3 | len }}`: "3",
|
||||||
|
`{{ tuple | chunk 3 | len }}`: "0",
|
||||||
|
`{{ range ( tuple 1 2 3 4 5 6 7 8 9 | chunk 3 ) }}{{. | join "-"}}|{{end}}`: "1-2-3|4-5-6|7-8-9|",
|
||||||
|
`{{ range ( tuple 1 2 3 4 5 6 7 8 | chunk 3 ) }}{{. | join "-"}}|{{end}}`: "1-2-3|4-5-6|7-8|",
|
||||||
|
`{{ range ( tuple 1 2 | chunk 3 ) }}{{. | join "-"}}|{{end}}`: "1-2|",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustChunk(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ tuple 1 2 3 4 5 6 7 | mustChunk 3 | len }}`: "3",
|
||||||
|
`{{ tuple | mustChunk 3 | len }}`: "0",
|
||||||
|
`{{ range ( tuple 1 2 3 4 5 6 7 8 9 | mustChunk 3 ) }}{{. | join "-"}}|{{end}}`: "1-2-3|4-5-6|7-8-9|",
|
||||||
|
`{{ range ( tuple 1 2 3 4 5 6 7 8 | mustChunk 3 ) }}{{. | join "-"}}|{{end}}`: "1-2-3|4-5-6|7-8|",
|
||||||
|
`{{ range ( tuple 1 2 | mustChunk 3 ) }}{{. | join "-"}}|{{end}}`: "1-2|",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
err := runt(`{{ tuple `+strings.Repeat(" 0", 10001)+` | mustChunk 1 }}`, "a")
|
||||||
|
assert.ErrorContains(t, err, "number of chunks 10001 exceeds maximum limit of 10000")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepend(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ $t := tuple 1 2 3 }}{{ prepend $t 0 | len }}`: "4",
|
||||||
|
`{{ $t := tuple 1 2 3 4 }}{{ prepend $t 0 | join "-" }}`: "0-1-2-3-4",
|
||||||
|
`{{ $t := regexSplit "/" "foo/bar/baz" -1 }}{{ prepend $t "qux" | join "-" }}`: "qux-foo-bar-baz",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustPrepend(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ $t := tuple 1 2 3 }}{{ mustPrepend $t 0 | len }}`: "4",
|
||||||
|
`{{ $t := tuple 1 2 3 4 }}{{ mustPrepend $t 0 | join "-" }}`: "0-1-2-3-4",
|
||||||
|
`{{ $t := regexSplit "/" "foo/bar/baz" -1 }}{{ mustPrepend $t "qux" | join "-" }}`: "qux-foo-bar-baz",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirst(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 2 3 | first }}`: "1",
|
||||||
|
`{{ list | first }}`: "<no value>",
|
||||||
|
`{{ regexSplit "/src/" "foo/src/bar" -1 | first }}`: "foo",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustFirst(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 2 3 | mustFirst }}`: "1",
|
||||||
|
`{{ list | mustFirst }}`: "<no value>",
|
||||||
|
`{{ regexSplit "/src/" "foo/src/bar" -1 | mustFirst }}`: "foo",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLast(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 2 3 | last }}`: "3",
|
||||||
|
`{{ list | last }}`: "<no value>",
|
||||||
|
`{{ regexSplit "/src/" "foo/src/bar" -1 | last }}`: "bar",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustLast(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 2 3 | mustLast }}`: "3",
|
||||||
|
`{{ list | mustLast }}`: "<no value>",
|
||||||
|
`{{ regexSplit "/src/" "foo/src/bar" -1 | mustLast }}`: "bar",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitial(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 2 3 | initial | len }}`: "2",
|
||||||
|
`{{ list 1 2 3 | initial | last }}`: "2",
|
||||||
|
`{{ list 1 2 3 | initial | first }}`: "1",
|
||||||
|
`{{ list | initial }}`: "[]",
|
||||||
|
`{{ regexSplit "/" "foo/bar/baz" -1 | initial }}`: "[foo bar]",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustInitial(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 2 3 | mustInitial | len }}`: "2",
|
||||||
|
`{{ list 1 2 3 | mustInitial | last }}`: "2",
|
||||||
|
`{{ list 1 2 3 | mustInitial | first }}`: "1",
|
||||||
|
`{{ list | mustInitial }}`: "[]",
|
||||||
|
`{{ regexSplit "/" "foo/bar/baz" -1 | mustInitial }}`: "[foo bar]",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRest(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 2 3 | rest | len }}`: "2",
|
||||||
|
`{{ list 1 2 3 | rest | last }}`: "3",
|
||||||
|
`{{ list 1 2 3 | rest | first }}`: "2",
|
||||||
|
`{{ list | rest }}`: "[]",
|
||||||
|
`{{ regexSplit "/" "foo/bar/baz" -1 | rest }}`: "[bar baz]",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustRest(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 2 3 | mustRest | len }}`: "2",
|
||||||
|
`{{ list 1 2 3 | mustRest | last }}`: "3",
|
||||||
|
`{{ list 1 2 3 | mustRest | first }}`: "2",
|
||||||
|
`{{ list | mustRest }}`: "[]",
|
||||||
|
`{{ regexSplit "/" "foo/bar/baz" -1 | mustRest }}`: "[bar baz]",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReverse(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 2 3 | reverse | first }}`: "3",
|
||||||
|
`{{ list 1 2 3 | reverse | rest | first }}`: "2",
|
||||||
|
`{{ list 1 2 3 | reverse | last }}`: "1",
|
||||||
|
`{{ list 1 2 3 4 | reverse }}`: "[4 3 2 1]",
|
||||||
|
`{{ list 1 | reverse }}`: "[1]",
|
||||||
|
`{{ list | reverse }}`: "[]",
|
||||||
|
`{{ regexSplit "/" "foo/bar/baz" -1 | reverse }}`: "[baz bar foo]",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustReverse(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 2 3 | mustReverse | first }}`: "3",
|
||||||
|
`{{ list 1 2 3 | mustReverse | rest | first }}`: "2",
|
||||||
|
`{{ list 1 2 3 | mustReverse | last }}`: "1",
|
||||||
|
`{{ list 1 2 3 4 | mustReverse }}`: "[4 3 2 1]",
|
||||||
|
`{{ list 1 | mustReverse }}`: "[1]",
|
||||||
|
`{{ list | mustReverse }}`: "[]",
|
||||||
|
`{{ regexSplit "/" "foo/bar/baz" -1 | mustReverse }}`: "[baz bar foo]",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompact(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 0 "" "hello" | compact }}`: `[1 hello]`,
|
||||||
|
`{{ list "" "" | compact }}`: `[]`,
|
||||||
|
`{{ list | compact }}`: `[]`,
|
||||||
|
`{{ regexSplit "/" "foo//bar" -1 | compact }}`: "[foo bar]",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustCompact(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 0 "" "hello" | mustCompact }}`: `[1 hello]`,
|
||||||
|
`{{ list "" "" | mustCompact }}`: `[]`,
|
||||||
|
`{{ list | mustCompact }}`: `[]`,
|
||||||
|
`{{ regexSplit "/" "foo//bar" -1 | mustCompact }}`: "[foo bar]",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUniq(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 2 3 4 | uniq }}`: `[1 2 3 4]`,
|
||||||
|
`{{ list "a" "b" "c" "d" | uniq }}`: `[a b c d]`,
|
||||||
|
`{{ list 1 1 1 1 2 2 2 2 | uniq }}`: `[1 2]`,
|
||||||
|
`{{ list "foo" 1 1 1 1 "foo" "foo" | uniq }}`: `[foo 1]`,
|
||||||
|
`{{ list | uniq }}`: `[]`,
|
||||||
|
`{{ regexSplit "/" "foo/foo/bar" -1 | uniq }}`: "[foo bar]",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustUniq(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 2 3 4 | mustUniq }}`: `[1 2 3 4]`,
|
||||||
|
`{{ list "a" "b" "c" "d" | mustUniq }}`: `[a b c d]`,
|
||||||
|
`{{ list 1 1 1 1 2 2 2 2 | mustUniq }}`: `[1 2]`,
|
||||||
|
`{{ list "foo" 1 1 1 1 "foo" "foo" | mustUniq }}`: `[foo 1]`,
|
||||||
|
`{{ list | mustUniq }}`: `[]`,
|
||||||
|
`{{ regexSplit "/" "foo/foo/bar" -1 | mustUniq }}`: "[foo bar]",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithout(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ without (list 1 2 3 4) 1 }}`: `[2 3 4]`,
|
||||||
|
`{{ without (list "a" "b" "c" "d") "a" }}`: `[b c d]`,
|
||||||
|
`{{ without (list 1 1 1 1 2) 1 }}`: `[2]`,
|
||||||
|
`{{ without (list) 1 }}`: `[]`,
|
||||||
|
`{{ without (list 1 2 3) }}`: `[1 2 3]`,
|
||||||
|
`{{ without list }}`: `[]`,
|
||||||
|
`{{ without (regexSplit "/" "foo/bar/baz" -1 ) "foo" }}`: "[bar baz]",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustWithout(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ mustWithout (list 1 2 3 4) 1 }}`: `[2 3 4]`,
|
||||||
|
`{{ mustWithout (list "a" "b" "c" "d") "a" }}`: `[b c d]`,
|
||||||
|
`{{ mustWithout (list 1 1 1 1 2) 1 }}`: `[2]`,
|
||||||
|
`{{ mustWithout (list) 1 }}`: `[]`,
|
||||||
|
`{{ mustWithout (list 1 2 3) }}`: `[1 2 3]`,
|
||||||
|
`{{ mustWithout list }}`: `[]`,
|
||||||
|
`{{ mustWithout (regexSplit "/" "foo/bar/baz" -1 ) "foo" }}`: "[bar baz]",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHas(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 2 3 | has 1 }}`: `true`,
|
||||||
|
`{{ list 1 2 3 | has 4 }}`: `false`,
|
||||||
|
`{{ regexSplit "/" "foo/bar/baz" -1 | has "bar" }}`: `true`,
|
||||||
|
`{{ has "bar" nil }}`: `false`,
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustHas(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 2 3 | mustHas 1 }}`: `true`,
|
||||||
|
`{{ list 1 2 3 | mustHas 4 }}`: `false`,
|
||||||
|
`{{ regexSplit "/" "foo/bar/baz" -1 | mustHas "bar" }}`: `true`,
|
||||||
|
`{{ mustHas "bar" nil }}`: `false`,
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSlice(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ slice (list 1 2 3) }}`: "[1 2 3]",
|
||||||
|
`{{ slice (list 1 2 3) 0 1 }}`: "[1]",
|
||||||
|
`{{ slice (list 1 2 3) 1 3 }}`: "[2 3]",
|
||||||
|
`{{ slice (list 1 2 3) 1 }}`: "[2 3]",
|
||||||
|
`{{ slice (regexSplit "/" "foo/bar/baz" -1) 1 2 }}`: "[bar]",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustSlice(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ mustSlice (list 1 2 3) }}`: "[1 2 3]",
|
||||||
|
`{{ mustSlice (list 1 2 3) 0 1 }}`: "[1]",
|
||||||
|
`{{ mustSlice (list 1 2 3) 1 3 }}`: "[2 3]",
|
||||||
|
`{{ mustSlice (list 1 2 3) 1 }}`: "[2 3]",
|
||||||
|
`{{ mustSlice (regexSplit "/" "foo/bar/baz" -1) 1 2 }}`: "[bar]",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcat(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ concat (list 1 2 3) }}`: "[1 2 3]",
|
||||||
|
`{{ concat (list 1 2 3) (list 4 5) }}`: "[1 2 3 4 5]",
|
||||||
|
`{{ concat (list 1 2 3) (list 4 5) (list) }}`: "[1 2 3 4 5]",
|
||||||
|
`{{ concat (list 1 2 3) (list 4 5) (list nil) }}`: "[1 2 3 4 5 <nil>]",
|
||||||
|
`{{ concat (list 1 2 3) (list 4 5) (list ( list "foo" ) ) }}`: "[1 2 3 4 5 [foo]]",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
499
util/sprig/numeric.go
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"math/rand"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// toFloat64 converts a value to a 64-bit float.
|
||||||
|
// It handles various input types:
|
||||||
|
// - string: parsed as a float, returns 0 if parsing fails
|
||||||
|
// - integer types: converted to float64
|
||||||
|
// - unsigned integer types: converted to float64
|
||||||
|
// - float types: returned as is
|
||||||
|
// - bool: true becomes 1.0, false becomes 0.0
|
||||||
|
// - other types: returns 0.0
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - v: The value to convert to float64
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - float64: The converted value
|
||||||
|
func toFloat64(v any) float64 {
|
||||||
|
if str, ok := v.(string); ok {
|
||||||
|
iv, err := strconv.ParseFloat(str, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return iv
|
||||||
|
}
|
||||||
|
|
||||||
|
val := reflect.Indirect(reflect.ValueOf(v))
|
||||||
|
switch val.Kind() {
|
||||||
|
case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int:
|
||||||
|
return float64(val.Int())
|
||||||
|
case reflect.Uint8, reflect.Uint16, reflect.Uint32:
|
||||||
|
return float64(val.Uint())
|
||||||
|
case reflect.Uint, reflect.Uint64:
|
||||||
|
return float64(val.Uint())
|
||||||
|
case reflect.Float32, reflect.Float64:
|
||||||
|
return val.Float()
|
||||||
|
case reflect.Bool:
|
||||||
|
if val.Bool() {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toInt converts a value to a 32-bit integer.
|
||||||
|
// This is a wrapper around toInt64 that casts the result to int.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - v: The value to convert to int
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - int: The converted value
|
||||||
|
func toInt(v any) int {
|
||||||
|
// It's not optimal. But I don't want duplicate toInt64 code.
|
||||||
|
return int(toInt64(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
// toInt64 converts a value to a 64-bit integer.
|
||||||
|
// It handles various input types:
|
||||||
|
// - string: parsed as an integer, returns 0 if parsing fails
|
||||||
|
// - integer types: converted to int64
|
||||||
|
// - unsigned integer types: converted to int64 (values > MaxInt64 become MaxInt64)
|
||||||
|
// - float types: truncated to int64
|
||||||
|
// - bool: true becomes 1, false becomes 0
|
||||||
|
// - other types: returns 0
|
||||||
|
func toInt64(v any) int64 {
|
||||||
|
if str, ok := v.(string); ok {
|
||||||
|
iv, err := strconv.ParseInt(str, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return iv
|
||||||
|
}
|
||||||
|
val := reflect.Indirect(reflect.ValueOf(v))
|
||||||
|
switch val.Kind() {
|
||||||
|
case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int:
|
||||||
|
return val.Int()
|
||||||
|
case reflect.Uint8, reflect.Uint16, reflect.Uint32:
|
||||||
|
return int64(val.Uint())
|
||||||
|
case reflect.Uint, reflect.Uint64:
|
||||||
|
tv := val.Uint()
|
||||||
|
if tv <= math.MaxInt64 {
|
||||||
|
return int64(tv)
|
||||||
|
}
|
||||||
|
// TODO: What is the sensible thing to do here?
|
||||||
|
return math.MaxInt64
|
||||||
|
case reflect.Float32, reflect.Float64:
|
||||||
|
return int64(val.Float())
|
||||||
|
case reflect.Bool:
|
||||||
|
if val.Bool() {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add1 increments a value by 1.
|
||||||
|
// The input is first converted to int64 using toInt64.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - i: The value to increment
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - int64: The incremented value
|
||||||
|
func add1(i any) int64 {
|
||||||
|
return toInt64(i) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// add sums all the provided values.
|
||||||
|
// All inputs are converted to int64 using toInt64 before addition.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - i: A variadic list of values to sum
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - int64: The sum of all values
|
||||||
|
func add(i ...any) int64 {
|
||||||
|
var a int64
|
||||||
|
for _, b := range i {
|
||||||
|
a += toInt64(b)
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// sub subtracts the second value from the first.
|
||||||
|
// Both inputs are converted to int64 using toInt64 before subtraction.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - a: The value to subtract from
|
||||||
|
// - b: The value to subtract
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - int64: The result of a - b
|
||||||
|
func sub(a, b any) int64 {
|
||||||
|
return toInt64(a) - toInt64(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// div divides the first value by the second.
|
||||||
|
// Both inputs are converted to int64 using toInt64 before division.
|
||||||
|
// Note: This performs integer division, so the result is truncated.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - a: The dividend
|
||||||
|
// - b: The divisor
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - int64: The result of a / b
|
||||||
|
//
|
||||||
|
// Panics:
|
||||||
|
// - If b evaluates to 0 (division by zero)
|
||||||
|
func div(a, b any) int64 {
|
||||||
|
return toInt64(a) / toInt64(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mod returns the remainder of dividing the first value by the second.
|
||||||
|
// Both inputs are converted to int64 using toInt64 before the modulo operation.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - a: The dividend
|
||||||
|
// - b: The divisor
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - int64: The remainder of a / b
|
||||||
|
//
|
||||||
|
// Panics:
|
||||||
|
// - If b evaluates to 0 (modulo by zero)
|
||||||
|
func mod(a, b any) int64 {
|
||||||
|
return toInt64(a) % toInt64(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mul multiplies all the provided values.
|
||||||
|
// All inputs are converted to int64 using toInt64 before multiplication.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - a: The first value to multiply
|
||||||
|
// - v: Additional values to multiply with a
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - int64: The product of all values
|
||||||
|
func mul(a any, v ...any) int64 {
|
||||||
|
val := toInt64(a)
|
||||||
|
for _, b := range v {
|
||||||
|
val = val * toInt64(b)
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
// randInt generates a random integer between min (inclusive) and max (exclusive).
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - min: The lower bound (inclusive)
|
||||||
|
// - max: The upper bound (exclusive)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - int: A random integer in the range [min, max)
|
||||||
|
//
|
||||||
|
// Panics:
|
||||||
|
// - If max <= min (via rand.Intn)
|
||||||
|
func randInt(min, max int) int {
|
||||||
|
return rand.Intn(max-min) + min
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxAsInt64 returns the maximum value from a list of values as an int64.
|
||||||
|
// All inputs are converted to int64 using toInt64 before comparison.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - a: The first value to compare
|
||||||
|
// - i: Additional values to compare
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - int64: The maximum value from all inputs
|
||||||
|
func maxAsInt64(a any, i ...any) int64 {
|
||||||
|
aa := toInt64(a)
|
||||||
|
for _, b := range i {
|
||||||
|
bb := toInt64(b)
|
||||||
|
if bb > aa {
|
||||||
|
aa = bb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return aa
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxAsFloat64 returns the maximum value from a list of values as a float64.
|
||||||
|
// All inputs are converted to float64 using toFloat64 before comparison.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - a: The first value to compare
|
||||||
|
// - i: Additional values to compare
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - float64: The maximum value from all inputs
|
||||||
|
func maxAsFloat64(a any, i ...any) float64 {
|
||||||
|
m := toFloat64(a)
|
||||||
|
for _, b := range i {
|
||||||
|
m = math.Max(m, toFloat64(b))
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// minAsInt64 returns the minimum value from a list of values as an int64.
|
||||||
|
// All inputs are converted to int64 using toInt64 before comparison.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - a: The first value to compare
|
||||||
|
// - i: Additional values to compare
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - int64: The minimum value from all inputs
|
||||||
|
func minAsInt64(a any, i ...any) int64 {
|
||||||
|
aa := toInt64(a)
|
||||||
|
for _, b := range i {
|
||||||
|
bb := toInt64(b)
|
||||||
|
if bb < aa {
|
||||||
|
aa = bb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return aa
|
||||||
|
}
|
||||||
|
|
||||||
|
// minAsFloat64 returns the minimum value from a list of values as a float64.
|
||||||
|
// All inputs are converted to float64 using toFloat64 before comparison.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - a: The first value to compare
|
||||||
|
// - i: Additional values to compare
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - float64: The minimum value from all inputs
|
||||||
|
func minAsFloat64(a any, i ...any) float64 {
|
||||||
|
m := toFloat64(a)
|
||||||
|
for _, b := range i {
|
||||||
|
m = math.Min(m, toFloat64(b))
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// until generates a sequence of integers from 0 to count (exclusive).
|
||||||
|
// If count is negative, it generates a sequence from 0 to count (inclusive) with step -1.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - count: The end value (exclusive if positive, inclusive if negative)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - []int: A slice containing the generated sequence
|
||||||
|
func until(count int) []int {
|
||||||
|
step := 1
|
||||||
|
if count < 0 {
|
||||||
|
step = -1
|
||||||
|
}
|
||||||
|
return untilStep(0, count, step)
|
||||||
|
}
|
||||||
|
|
||||||
|
// untilStep generates a sequence of integers from start to stop with the specified step.
|
||||||
|
// The sequence is generated as follows:
|
||||||
|
// - If step is 0, returns an empty slice
|
||||||
|
// - If stop < start and step < 0, generates a decreasing sequence from start to stop (exclusive)
|
||||||
|
// - If stop > start and step > 0, generates an increasing sequence from start to stop (exclusive)
|
||||||
|
// - Otherwise, returns an empty slice
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - start: The starting value (inclusive)
|
||||||
|
// - stop: The ending value (exclusive)
|
||||||
|
// - step: The increment between values
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - []int: A slice containing the generated sequence
|
||||||
|
//
|
||||||
|
// Panics:
|
||||||
|
// - If the number of iterations would exceed loopExecutionLimit
|
||||||
|
func untilStep(start, stop, step int) []int {
|
||||||
|
var v []int
|
||||||
|
if step == 0 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
iterations := math.Abs(float64(stop)-float64(start)) / float64(step)
|
||||||
|
if iterations > loopExecutionLimit {
|
||||||
|
panic(fmt.Sprintf("too many iterations in untilStep; max allowed is %d, got %f", loopExecutionLimit, iterations))
|
||||||
|
}
|
||||||
|
if stop < start {
|
||||||
|
if step >= 0 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
for i := start; i > stop; i += step {
|
||||||
|
v = append(v, i)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
if step <= 0 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
for i := start; i < stop; i += step {
|
||||||
|
v = append(v, i)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// floor returns the greatest integer value less than or equal to the input.
|
||||||
|
// The input is first converted to float64 using toFloat64.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - a: The value to floor
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - float64: The greatest integer value less than or equal to a
|
||||||
|
func floor(a any) float64 {
|
||||||
|
return math.Floor(toFloat64(a))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ceil returns the least integer value greater than or equal to the input.
|
||||||
|
// The input is first converted to float64 using toFloat64.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - a: The value to ceil
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - float64: The least integer value greater than or equal to a
|
||||||
|
func ceil(a any) float64 {
|
||||||
|
return math.Ceil(toFloat64(a))
|
||||||
|
}
|
||||||
|
|
||||||
|
// round rounds a number to a specified number of decimal places.
|
||||||
|
// The input is first converted to float64 using toFloat64.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - a: The value to round
|
||||||
|
// - p: The number of decimal places to round to
|
||||||
|
// - rOpt: Optional rounding threshold (default is 0.5)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - float64: The rounded value
|
||||||
|
//
|
||||||
|
// Examples:
|
||||||
|
// - round(3.14159, 2) returns 3.14
|
||||||
|
// - round(3.14159, 2, 0.6) returns 3.14 (only rounds up if fraction ≥ 0.6)
|
||||||
|
func round(a any, p int, rOpt ...float64) float64 {
|
||||||
|
roundOn := .5
|
||||||
|
if len(rOpt) > 0 {
|
||||||
|
roundOn = rOpt[0]
|
||||||
|
}
|
||||||
|
val := toFloat64(a)
|
||||||
|
places := toFloat64(p)
|
||||||
|
var round float64
|
||||||
|
pow := math.Pow(10, places)
|
||||||
|
digit := pow * val
|
||||||
|
_, div := math.Modf(digit)
|
||||||
|
if div >= roundOn {
|
||||||
|
round = math.Ceil(digit)
|
||||||
|
} else {
|
||||||
|
round = math.Floor(digit)
|
||||||
|
}
|
||||||
|
return round / pow
|
||||||
|
}
|
||||||
|
|
||||||
|
// toDecimal converts a value from octal to decimal.
|
||||||
|
// The input is first converted to a string using fmt.Sprint, then parsed as an octal number.
|
||||||
|
// If the parsing fails, it returns 0.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - v: The octal value to convert
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - int64: The decimal representation of the octal value
|
||||||
|
func toDecimal(v any) int64 {
|
||||||
|
result, err := strconv.ParseInt(fmt.Sprint(v), 8, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// atoi converts a string to an integer.
|
||||||
|
// If the conversion fails, it returns 0.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - a: The string to convert
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - int: The integer value of the string
|
||||||
|
func atoi(a string) int {
|
||||||
|
i, _ := strconv.Atoi(a)
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
// seq generates a sequence of integers and returns them as a space-delimited string.
|
||||||
|
// The behavior depends on the number of parameters:
|
||||||
|
// - 0 params: Returns an empty string
|
||||||
|
// - 1 param: Generates sequence from 1 to param[0]
|
||||||
|
// - 2 params: Generates sequence from param[0] to param[1]
|
||||||
|
// - 3 params: Generates sequence from param[0] to param[2] with step param[1]
|
||||||
|
//
|
||||||
|
// If the end is less than the start, the sequence will be decreasing unless
|
||||||
|
// a positive step is explicitly provided (which would result in an empty string).
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - params: Variable number of integers defining the sequence
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: A space-delimited string of the generated sequence
|
||||||
|
func seq(params ...int) string {
|
||||||
|
increment := 1
|
||||||
|
switch len(params) {
|
||||||
|
case 0:
|
||||||
|
return ""
|
||||||
|
case 1:
|
||||||
|
start := 1
|
||||||
|
end := params[0]
|
||||||
|
if end < start {
|
||||||
|
increment = -1
|
||||||
|
}
|
||||||
|
return intArrayToString(untilStep(start, end+increment, increment), " ")
|
||||||
|
case 3:
|
||||||
|
start := params[0]
|
||||||
|
end := params[2]
|
||||||
|
step := params[1]
|
||||||
|
if end < start {
|
||||||
|
increment = -1
|
||||||
|
if step > 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return intArrayToString(untilStep(start, end+increment, step), " ")
|
||||||
|
case 2:
|
||||||
|
start := params[0]
|
||||||
|
end := params[1]
|
||||||
|
step := 1
|
||||||
|
if end < start {
|
||||||
|
step = -1
|
||||||
|
}
|
||||||
|
return intArrayToString(untilStep(start, end+step, step), " ")
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// intArrayToString converts a slice of integers to a space-delimited string.
|
||||||
|
// The function removes the square brackets that would normally appear when
|
||||||
|
// converting a slice to a string.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - slice: The slice of integers to convert
|
||||||
|
// - delimiter: The delimiter to use between elements
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: A delimited string representation of the integer slice
|
||||||
|
func intArrayToString(slice []int, delimiter string) string {
|
||||||
|
return strings.Trim(strings.Join(strings.Fields(fmt.Sprint(slice)), delimiter), "[]")
|
||||||
|
}
|
||||||
307
util/sprig/numeric_test.go
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUntil(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{range $i, $e := until 5}}{{$i}}{{$e}}{{end}}`: "0011223344",
|
||||||
|
`{{range $i, $e := until -5}}{{$i}}{{$e}} {{end}}`: "00 1-1 2-2 3-3 4-4 ",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
if err := runt(tpl, expect); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestUntilStep(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{range $i, $e := untilStep 0 5 1}}{{$i}}{{$e}}{{end}}`: "0011223344",
|
||||||
|
`{{range $i, $e := untilStep 3 6 1}}{{$i}}{{$e}}{{end}}`: "031425",
|
||||||
|
`{{range $i, $e := untilStep 0 -10 -2}}{{$i}}{{$e}} {{end}}`: "00 1-2 2-4 3-6 4-8 ",
|
||||||
|
`{{range $i, $e := untilStep 3 0 1}}{{$i}}{{$e}}{{end}}`: "",
|
||||||
|
`{{range $i, $e := untilStep 3 99 0}}{{$i}}{{$e}}{{end}}`: "",
|
||||||
|
`{{range $i, $e := untilStep 3 99 -1}}{{$i}}{{$e}}{{end}}`: "",
|
||||||
|
`{{range $i, $e := untilStep 3 0 0}}{{$i}}{{$e}}{{end}}`: "",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
if err := runt(tpl, expect); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
func TestBiggest(t *testing.T) {
|
||||||
|
tpl := `{{ biggest 1 2 3 345 5 6 7}}`
|
||||||
|
if err := runt(tpl, `345`); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tpl = `{{ max 345}}`
|
||||||
|
if err := runt(tpl, `345`); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestMaxf(t *testing.T) {
|
||||||
|
tpl := `{{ maxf 1 2 3 345.7 5 6 7}}`
|
||||||
|
if err := runt(tpl, `345.7`); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tpl = `{{ max 345 }}`
|
||||||
|
if err := runt(tpl, `345`); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestMin(t *testing.T) {
|
||||||
|
tpl := `{{ min 1 2 3 345 5 6 7}}`
|
||||||
|
if err := runt(tpl, `1`); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tpl = `{{ min 345}}`
|
||||||
|
if err := runt(tpl, `345`); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMinf(t *testing.T) {
|
||||||
|
tpl := `{{ minf 1.4 2 3 345.6 5 6 7}}`
|
||||||
|
if err := runt(tpl, `1.4`); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tpl = `{{ minf 345 }}`
|
||||||
|
if err := runt(tpl, `345`); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToFloat64(t *testing.T) {
|
||||||
|
target := float64(102)
|
||||||
|
if target != toFloat64(int8(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toFloat64(int(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toFloat64(int32(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toFloat64(int16(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toFloat64(int64(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toFloat64("102") {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if toFloat64("frankie") != 0 {
|
||||||
|
t.Errorf("Expected 0")
|
||||||
|
}
|
||||||
|
if target != toFloat64(uint16(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toFloat64(uint64(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if toFloat64(float64(102.1234)) != 102.1234 {
|
||||||
|
t.Errorf("Expected 102.1234")
|
||||||
|
}
|
||||||
|
if toFloat64(true) != 1 {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestToInt64(t *testing.T) {
|
||||||
|
target := int64(102)
|
||||||
|
if target != toInt64(int8(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toInt64(int(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toInt64(int32(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toInt64(int16(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toInt64(int64(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toInt64("102") {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if toInt64("frankie") != 0 {
|
||||||
|
t.Errorf("Expected 0")
|
||||||
|
}
|
||||||
|
if target != toInt64(uint16(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toInt64(uint64(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toInt64(float64(102.1234)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if toInt64(true) != 1 {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToInt(t *testing.T) {
|
||||||
|
target := int(102)
|
||||||
|
if target != toInt(int8(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toInt(int(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toInt(int32(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toInt(int16(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toInt(int64(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toInt("102") {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if toInt("frankie") != 0 {
|
||||||
|
t.Errorf("Expected 0")
|
||||||
|
}
|
||||||
|
if target != toInt(uint16(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toInt(uint64(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toInt(float64(102.1234)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if toInt(true) != 1 {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToDecimal(t *testing.T) {
|
||||||
|
tests := map[any]int64{
|
||||||
|
"777": 511,
|
||||||
|
777: 511,
|
||||||
|
770: 504,
|
||||||
|
755: 493,
|
||||||
|
}
|
||||||
|
|
||||||
|
for input, expectedResult := range tests {
|
||||||
|
result := toDecimal(input)
|
||||||
|
if result != expectedResult {
|
||||||
|
t.Errorf("Expected %v but got %v", expectedResult, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdd1(t *testing.T) {
|
||||||
|
tpl := `{{ 3 | add1 }}`
|
||||||
|
if err := runt(tpl, `4`); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdd(t *testing.T) {
|
||||||
|
tpl := `{{ 3 | add 1 2}}`
|
||||||
|
if err := runt(tpl, `6`); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiv(t *testing.T) {
|
||||||
|
tpl := `{{ 4 | div 5 }}`
|
||||||
|
if err := runt(tpl, `1`); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMul(t *testing.T) {
|
||||||
|
tpl := `{{ 1 | mul "2" 3 "4"}}`
|
||||||
|
if err := runt(tpl, `24`); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSub(t *testing.T) {
|
||||||
|
tpl := `{{ 3 | sub 14 }}`
|
||||||
|
if err := runt(tpl, `11`); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCeil(t *testing.T) {
|
||||||
|
assert.Equal(t, 123.0, ceil(123))
|
||||||
|
assert.Equal(t, 123.0, ceil("123"))
|
||||||
|
assert.Equal(t, 124.0, ceil(123.01))
|
||||||
|
assert.Equal(t, 124.0, ceil("123.01"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFloor(t *testing.T) {
|
||||||
|
assert.Equal(t, 123.0, floor(123))
|
||||||
|
assert.Equal(t, 123.0, floor("123"))
|
||||||
|
assert.Equal(t, 123.0, floor(123.9999))
|
||||||
|
assert.Equal(t, 123.0, floor("123.9999"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRound(t *testing.T) {
|
||||||
|
assert.Equal(t, 123.556, round(123.5555, 3))
|
||||||
|
assert.Equal(t, 123.556, round("123.55555", 3))
|
||||||
|
assert.Equal(t, 124.0, round(123.500001, 0))
|
||||||
|
assert.Equal(t, 123.0, round(123.49999999, 0))
|
||||||
|
assert.Equal(t, 123.23, round(123.2329999, 2, .3))
|
||||||
|
assert.Equal(t, 123.24, round(123.233, 2, .3))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRandomInt(t *testing.T) {
|
||||||
|
var tests = []struct {
|
||||||
|
min int
|
||||||
|
max int
|
||||||
|
}{
|
||||||
|
{10, 11},
|
||||||
|
{10, 13},
|
||||||
|
{0, 1},
|
||||||
|
{5, 50},
|
||||||
|
}
|
||||||
|
for _, v := range tests {
|
||||||
|
x, _ := runRaw(fmt.Sprintf(`{{ randInt %d %d }}`, v.min, v.max), nil)
|
||||||
|
r, err := strconv.Atoi(x)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, func(min, max, r int) bool {
|
||||||
|
return r >= v.min && r < v.max
|
||||||
|
}(v.min, v.max, r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeq(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{seq 0 1 3}}`: "0 1 2 3",
|
||||||
|
`{{seq 0 3 10}}`: "0 3 6 9",
|
||||||
|
`{{seq 3 3 2}}`: "",
|
||||||
|
`{{seq 3 -3 2}}`: "3",
|
||||||
|
`{{seq}}`: "",
|
||||||
|
`{{seq 0 4}}`: "0 1 2 3 4",
|
||||||
|
`{{seq 5}}`: "1 2 3 4 5",
|
||||||
|
`{{seq -5}}`: "1 0 -1 -2 -3 -4 -5",
|
||||||
|
`{{seq 0}}`: "1 0",
|
||||||
|
`{{seq 0 1 2 3}}`: "",
|
||||||
|
`{{seq 0 -4}}`: "0 -1 -2 -3 -4",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
if err := runt(tpl, expect); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
util/sprig/reflect.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
)
|
||||||
|
|
||||||
|
// typeIs returns true if the src is the type named in target.
|
||||||
|
// It compares the type name of src with the target string.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - target: The type name to check against
|
||||||
|
// - src: The value whose type will be checked
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - bool: True if the type name of src matches target, false otherwise
|
||||||
|
func typeIs(target string, src any) bool {
|
||||||
|
return target == typeOf(src)
|
||||||
|
}
|
||||||
|
|
||||||
|
// typeIsLike returns true if the src is the type named in target or a pointer to that type.
|
||||||
|
// This is useful when you need to check for both a type and a pointer to that type.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - target: The type name to check against
|
||||||
|
// - src: The value whose type will be checked
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - bool: True if the type of src matches target or "*"+target, false otherwise
|
||||||
|
func typeIsLike(target string, src any) bool {
|
||||||
|
t := typeOf(src)
|
||||||
|
return target == t || "*"+target == t
|
||||||
|
}
|
||||||
|
|
||||||
|
// typeOf returns the type of a value as a string.
|
||||||
|
// It uses fmt.Sprintf with the %T format verb to get the type name.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - src: The value whose type name will be returned
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The type name of src
|
||||||
|
func typeOf(src any) string {
|
||||||
|
return fmt.Sprintf("%T", src)
|
||||||
|
}
|
||||||
|
|
||||||
|
// kindIs returns true if the kind of src matches the target kind.
|
||||||
|
// This checks the underlying kind (e.g., "string", "int", "map") rather than the specific type.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - target: The kind name to check against
|
||||||
|
// - src: The value whose kind will be checked
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - bool: True if the kind of src matches target, false otherwise
|
||||||
|
func kindIs(target string, src any) bool {
|
||||||
|
return target == kindOf(src)
|
||||||
|
}
|
||||||
|
|
||||||
|
// kindOf returns the kind of a value as a string.
|
||||||
|
// The kind represents the specific Go type category (e.g., "string", "int", "map", "slice").
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - src: The value whose kind will be returned
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The kind of src as a string
|
||||||
|
func kindOf(src any) string {
|
||||||
|
return reflect.ValueOf(src).Kind().String()
|
||||||
|
}
|
||||||