Compare commits
440 Commits
remove-rat
...
v2.13.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ce08a18c0 | ||
|
|
8d6f1eecdf | ||
|
|
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 | ||
|
|
ae27c3a5ab | ||
|
|
48cb816111 | ||
|
|
ff904a5ca6 | ||
|
|
8e7de80353 | ||
|
|
9c8a8f8795 | ||
|
|
df73c6f655 | ||
|
|
c1e657db8b | ||
|
|
62c8a13ed4 | ||
|
|
994266ab04 | ||
|
|
a41e3a1e76 | ||
|
|
86bec660bf | ||
|
|
30301c8a7f | ||
|
|
7b470a7f6f | ||
|
|
9d5891963a | ||
|
|
de8e3bc2aa | ||
|
|
d3f7aa7008 | ||
|
|
bbfaf2fc4d | ||
|
|
db4ac158e3 | ||
|
|
7a33e16945 | ||
|
|
eac49feb04 | ||
|
|
849884c947 | ||
|
|
2cb4d089ab | ||
|
|
dc797f8594 | ||
|
|
061677a78b | ||
|
|
b4f15ec9d4 | ||
|
|
af17661053 | ||
|
|
635ec88c4f | ||
|
|
905f048ab4 | ||
|
|
7f86108379 | ||
|
|
425e6d064e | ||
|
|
ebb61fcccf | ||
|
|
9f72eb804d | ||
|
|
42af71e546 | ||
|
|
df818cfebc | ||
|
|
0de1990c01 | ||
|
|
f40023aa23 | ||
|
|
5765a707fc | ||
|
|
5eb84f759b | ||
|
|
df7dd9c498 | ||
|
|
6fe3913aee | ||
|
|
0ad9716241 | ||
|
|
f4c37ccfb9 | ||
|
|
7182d3a4e5 | ||
|
|
eecd3245f0 | ||
|
|
4dc3b38c95 | ||
|
|
9edab24d4c | ||
|
|
3b627b27b3 | ||
|
|
80462f7ee5 | ||
|
|
65e377ec63 | ||
|
|
45e1707d3b | ||
|
|
0581a9e680 | ||
|
|
0fb60ae72d | ||
|
|
e36e4856c9 | ||
|
|
fa48639517 | ||
|
|
2b40ad9a12 | ||
|
|
ad7ab18fb7 | ||
|
|
8f9dafce20 | ||
|
|
69cf773834 | ||
|
|
b2b9891a58 | ||
|
|
3bf02d3cd9 | ||
|
|
8777990d2d | ||
|
|
70f0e7ccc7 | ||
|
|
adfacf820e | ||
|
|
35e15cfd9d | ||
|
|
4e2a884da5 | ||
|
|
29cf4f16d1 | ||
|
|
609c9fa37d | ||
|
|
2eb5eb3e29 | ||
|
|
a92306b181 | ||
|
|
047cc22dba | ||
|
|
f31d777b69 | ||
|
|
ac983cd9bc | ||
|
|
dd45fd90b7 | ||
|
|
e76e6274a3 | ||
|
|
161ce468fe | ||
|
|
04df6f1390 | ||
|
|
79852fec59 | ||
|
|
92de1b5a88 | ||
|
|
fc93de9a28 | ||
|
|
ae9fa85676 | ||
|
|
b26666f635 | ||
|
|
70a9301e25 | ||
|
|
86c548ae37 | ||
|
|
1e1b2be464 | ||
|
|
1b8906f1fd | ||
|
|
b81f7b21a9 | ||
|
|
db2dc09189 | ||
|
|
5f6b7e6f82 | ||
|
|
6daf4141c6 | ||
|
|
41083cfd07 | ||
|
|
c03f795508 | ||
|
|
58d7cb8ef8 | ||
|
|
8acf0f4350 | ||
|
|
236b7b7a16 | ||
|
|
871883f6e9 | ||
|
|
a92c8a9ec9 | ||
|
|
1c6aa49fca | ||
|
|
49d258706d | ||
|
|
bbce1200b4 | ||
|
|
94d0c5a335 | ||
|
|
7835fc65c4 | ||
|
|
dc6b8ece1e | ||
|
|
f595dff66f | ||
|
|
0514ea4ac0 | ||
|
|
1598087e1f | ||
|
|
3709ea689a | ||
|
|
f4aba12546 | ||
|
|
521fe791b0 | ||
|
|
6d15b9face | ||
|
|
9fbe7804dd | ||
|
|
faa4dcbcee | ||
|
|
ad3e7960ce | ||
|
|
3234189cd2 | ||
|
|
e64a0bd8c9 | ||
|
|
97a59f19e0 | ||
|
|
7067d8aa77 | ||
|
|
5999653456 | ||
|
|
9ce6b03450 | ||
|
|
7e916516e0 | ||
|
|
09c2b4bdca | ||
|
|
978ee81df3 | ||
|
|
86f2ab8a55 | ||
|
|
e4aff00455 | ||
|
|
e88f24bae7 | ||
|
|
b4797ef212 | ||
|
|
2f8c0e4d5d | ||
|
|
56231f9288 | ||
|
|
ef7c7c7b09 | ||
|
|
88e4b8f0e6 | ||
|
|
090bdd93ba | ||
|
|
790044e899 | ||
|
|
7aab7d387f | ||
|
|
a461aafb91 | ||
|
|
1569c22a65 | ||
|
|
2091ceb4d2 | ||
|
|
ec337b5de9 | ||
|
|
5a245f889c | ||
|
|
ee595067ba | ||
|
|
bd4b5e9e1b | ||
|
|
786e588397 | ||
|
|
2dfb53ec53 | ||
|
|
a30c5eb9cf | ||
|
|
d96d4b03c7 | ||
|
|
3257ce91ef | ||
|
|
f563b671c8 | ||
|
|
ea1cda5f92 | ||
|
|
36ba27ba09 | ||
|
|
60eccba2fa | ||
|
|
aec4b97fae | ||
|
|
389ae682a5 | ||
|
|
3f21da7768 | ||
|
|
0ad266a495 | ||
|
|
bd192edf1e | ||
|
|
d1ac8d03e0 | ||
|
|
44b7c2f198 | ||
|
|
cdae5493e2 | ||
|
|
f110472204 | ||
|
|
3f1342c05b | ||
|
|
8b95b1a213 | ||
|
|
d4dfd3f657 | ||
|
|
c1d718ee68 | ||
|
|
bd08a120cd | ||
|
|
c9126e7aa9 | ||
|
|
db9b974e47 | ||
|
|
889a6f03f8 | ||
|
|
6af8d03470 | ||
|
|
6b2cfb1d1d | ||
|
|
35458230a8 | ||
|
|
bd39cf4b54 | ||
|
|
f739a3067e | ||
|
|
2344eee2c6 | ||
|
|
5822a2ec41 | ||
|
|
a49cafbadb | ||
|
|
0aee6252bb | ||
|
|
0e6a483b2f | ||
|
|
20c014ba8d | ||
|
|
926967b6e7 | ||
|
|
6345e7f864 | ||
|
|
80bc600ff0 | ||
|
|
758828e7aa | ||
|
|
4c179b7d9d | ||
|
|
27398e7d72 | ||
|
|
19f8a35588 | ||
|
|
8feb0f1a2e | ||
|
|
9241b0550c | ||
|
|
136b656ccb | ||
|
|
c844c24a16 | ||
|
|
90f21ba408 | ||
|
|
b843c69c16 | ||
|
|
630f2957de | ||
|
|
d243c22510 | ||
|
|
fbf325a630 | ||
|
|
84f421a464 | ||
|
|
d38c149263 | ||
|
|
fc3624cd50 | ||
|
|
78533e27fe | ||
|
|
02e46c1d03 | ||
|
|
81f05b3f15 | ||
|
|
eb700b4b6c | ||
|
|
89c884ab4d | ||
|
|
0fe0b0c9d7 | ||
|
|
bad3ef43b7 | ||
|
|
903ef71b6f | ||
|
|
5726f8e9ba | ||
|
|
5b10cd660b | ||
|
|
333d901661 | ||
|
|
112efaae90 | ||
|
|
61bb8a0286 | ||
|
|
be2bebf517 | ||
|
|
a4cf40907b | ||
|
|
6562ba6987 | ||
|
|
6da554d1e5 | ||
|
|
72f36f8296 | ||
|
|
e8685baf15 | ||
|
|
f8085f8686 | ||
|
|
3139c13e50 | ||
|
|
4a2b5676d9 | ||
|
|
a12195d3c7 | ||
|
|
412e78c4d0 | ||
|
|
22bdc91630 | ||
|
|
e94c2fef52 | ||
|
|
694363013d | ||
|
|
fb6a408cca | ||
|
|
89437019fb | ||
|
|
a095ab56bb | ||
|
|
92905fd860 | ||
|
|
01c216d506 | ||
|
|
999678565b | ||
|
|
3454a5ca16 | ||
|
|
63c96b4e80 | ||
|
|
003fec5f83 | ||
|
|
f0d8f0ad8e | ||
|
|
20cca8e888 | ||
|
|
49a548252c | ||
|
|
21dbcf65dc | ||
|
|
dee213d90c | ||
|
|
19b99e8285 | ||
|
|
0c68b6a2c7 | ||
|
|
76b753062d | ||
|
|
ceec0bc71d | ||
|
|
6ecd96cf6e | ||
|
|
8d38672baf | ||
|
|
36a149dd7a | ||
|
|
1249d9473a | ||
|
|
5941a8f2a6 | ||
|
|
2e8daa962c | ||
|
|
3f4d0ef3ea | ||
|
|
0fba690d02 | ||
|
|
5211d06f2c | ||
|
|
7121d14bfa | ||
|
|
d5a1e38082 | ||
|
|
3ad61c4736 | ||
|
|
9d3fc20e58 | ||
|
|
0be467f809 | ||
|
|
ec75ce0787 | ||
|
|
d11b1007ef | ||
|
|
c542dd8c6f | ||
|
|
37697aed27 | ||
|
|
4360d157b2 | ||
|
|
c3c4d65f99 | ||
|
|
ffd7645c0b | ||
|
|
043738a475 | ||
|
|
fb52ad6fdb | ||
|
|
29318f9d61 | ||
|
|
030f7266f7 | ||
|
|
9692de1469 | ||
|
|
eab90a0275 | ||
|
|
e6f70f8e41 | ||
|
|
499b0dd839 | ||
|
|
31d0c812ce | ||
|
|
d37f861f6b | ||
|
|
0a49a8d88b | ||
|
|
b63ef0defb | ||
|
|
f8068ef561 | ||
|
|
2608687e98 | ||
|
|
02564a40c7 | ||
|
|
bdd49f4e16 | ||
|
|
33b603def5 | ||
|
|
6eff5553b5 | ||
|
|
7cac03c1ec | ||
|
|
b33918f267 | ||
|
|
f68ad6acdf | ||
|
|
a533bf9efb | ||
|
|
66ea805cde | ||
|
|
7c3b6e4521 | ||
|
|
9cb3d056fe | ||
|
|
4111bee0c4 | ||
|
|
e4c2b938d3 | ||
|
|
fc7cf5933f | ||
|
|
e4d22ebd8b | ||
|
|
69d6e0f890 | ||
|
|
ecab7fbf65 | ||
|
|
75887e4a62 | ||
|
|
130039f5c8 | ||
|
|
bec0d4807b | ||
|
|
5ee62033b5 | ||
|
|
3e02d7b0bb | ||
|
|
290ed1124e | ||
|
|
fc62682334 | ||
|
|
28404565d2 | ||
|
|
f8548e9d46 | ||
|
|
d90b290cd2 | ||
|
|
21c6776269 | ||
|
|
7fed392e0c | ||
|
|
913b59b5e3 | ||
|
|
4692ca7b7f | ||
|
|
af16542d02 | ||
|
|
5511812e30 | ||
|
|
547b09a7e5 | ||
|
|
b9c176ddba | ||
|
|
f971377cbb | ||
|
|
a04f2f9c9a | ||
|
|
763eafd5dd | ||
|
|
9247dac50d | ||
|
|
de65d07518 | ||
|
|
1966f80855 | ||
|
|
4b2e38320d | ||
|
|
83356f565e | ||
|
|
7fd5f0b29d | ||
|
|
03737dbf5c | ||
|
|
867cf28080 | ||
|
|
b2eb5b94bd | ||
|
|
df7d6baec5 | ||
|
|
a4f5c8dee7 | ||
|
|
4c0ec3f75b | ||
|
|
12bbe9a1ae | ||
|
|
0a589f6242 | ||
|
|
ab2dd6136e | ||
|
|
4d64515e45 | ||
|
|
411597ecc2 | ||
|
|
1a426da913 | ||
|
|
7936c38feb | ||
|
|
d0beaa900f | ||
|
|
f4bf8fd9bb | ||
|
|
d866cb2fd9 | ||
|
|
0ab6171962 | ||
|
|
b7c2898b9c | ||
|
|
d155ebb3a4 | ||
|
|
3d5bb92774 | ||
|
|
a2f1a45097 | ||
|
|
b43fff7c7e | ||
|
|
b186c7a324 | ||
|
|
e50b6f4075 | ||
|
|
e5342d5eca | ||
|
|
a25fc1daaa | ||
|
|
6e75108aa9 | ||
|
|
0735a5cb48 | ||
|
|
088aede833 | ||
|
|
2119954f67 | ||
|
|
220b012ae3 | ||
|
|
e1278a5e92 | ||
|
|
0ae62d36d2 | ||
|
|
70729edb2b | ||
|
|
de2f7d3e9b | ||
|
|
8c69234e28 | ||
|
|
a9b7c4530b | ||
|
|
76fc015775 | ||
|
|
ef302d22a9 | ||
|
|
7ab8ef73a3 | ||
|
|
7616b24e30 | ||
|
|
05c1b264f2 | ||
|
|
35ccfdb8d8 | ||
|
|
d744204052 | ||
|
|
6c3aca4cd6 | ||
|
|
d54db74f5a | ||
|
|
4e1980c2cc | ||
|
|
40f990fffe | ||
|
|
8931f25ac5 | ||
|
|
94f60fb5b8 | ||
|
|
01b397a31a | ||
|
|
f2cd1edc57 | ||
|
|
243123fd7e | ||
|
|
36b33030f3 | ||
|
|
17709f2fb7 | ||
|
|
a8c17c1856 | ||
|
|
8ac920d28c | ||
|
|
239c620707 | ||
|
|
f2e600d681 | ||
|
|
6486db99fa | ||
|
|
766ca05e15 | ||
|
|
6c41f69db7 | ||
|
|
cae696c323 | ||
|
|
42dc8bc3f5 | ||
|
|
896a1b007f | ||
|
|
fc5973751a | ||
|
|
9ca5133c76 | ||
|
|
509f59ac3c | ||
|
|
9d8482a119 | ||
|
|
34343fae02 | ||
|
|
1ff6ecf5d8 | ||
|
|
f2f7ad8253 | ||
|
|
78de5d4866 | ||
|
|
9449b0b875 | ||
|
|
b86c50c60a | ||
|
|
34f90facb2 | ||
|
|
99a0c72d49 | ||
|
|
c4d9e397ab | ||
|
|
3f49f51847 | ||
|
|
7dfcda9306 | ||
|
|
b6983e6866 | ||
|
|
4cfc64e528 | ||
|
|
f2d6f09671 | ||
|
|
0f44d20da5 | ||
|
|
aaf53d5d3f | ||
|
|
4b1468cfd8 | ||
|
|
00fe639a95 | ||
|
|
94781c89f3 | ||
|
|
a3312f69fb | ||
|
|
6a10bac017 | ||
|
|
f565302a0f | ||
|
|
6a3f169a47 | ||
|
|
6bd8875375 | ||
|
|
3691e59af1 |
2
.github/workflows/build.yaml
vendored
@@ -9,7 +9,7 @@ jobs:
|
|||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v4
|
uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: '1.22.x'
|
go-version: '1.24.x'
|
||||||
- name: Install node
|
- name: Install node
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
|
|||||||
2
.github/workflows/release.yaml
vendored
@@ -12,7 +12,7 @@ jobs:
|
|||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v4
|
uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: '1.22.x'
|
go-version: '1.24.x'
|
||||||
- name: Install node
|
- name: Install node
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
|
|||||||
2
.github/workflows/test.yaml
vendored
@@ -9,7 +9,7 @@ jobs:
|
|||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v4
|
uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: '1.22.x'
|
go-version: '1.24.x'
|
||||||
- name: Install node
|
- name: Install node
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
|
|||||||
1
.gitignore
vendored
@@ -15,3 +15,4 @@ node_modules/
|
|||||||
__pycache__
|
__pycache__
|
||||||
web/dev-dist/
|
web/dev-dist/
|
||||||
venv/
|
venv/
|
||||||
|
cmd/key-file.yaml
|
||||||
|
|||||||
@@ -1,76 +1,70 @@
|
|||||||
|
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
|
||||||
tags: [sqlite_omit_load_extension,osusergo,netgo]
|
tags: [ sqlite_omit_load_extension,osusergo,netgo ]
|
||||||
ldflags:
|
ldflags:
|
||||||
- "-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
|
||||||
- CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi
|
- CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi
|
||||||
tags: [sqlite_omit_load_extension,osusergo,netgo]
|
tags: [ sqlite_omit_load_extension,osusergo,netgo ]
|
||||||
ldflags:
|
ldflags:
|
||||||
- "-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: [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
|
||||||
- CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi
|
- CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi
|
||||||
tags: [sqlite_omit_load_extension,osusergo,netgo]
|
tags: [ sqlite_omit_load_extension,osusergo,netgo ]
|
||||||
ldflags:
|
ldflags:
|
||||||
- "-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: [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
|
||||||
- CC=aarch64-linux-gnu-gcc # apt install gcc-aarch64-linux-gnu
|
- CC=aarch64-linux-gnu-gcc # apt install gcc-aarch64-linux-gnu
|
||||||
tags: [sqlite_omit_load_extension,osusergo,netgo]
|
tags: [ sqlite_omit_load_extension,osusergo,netgo ]
|
||||||
ldflags:
|
ldflags:
|
||||||
- "-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
|
||||||
tags: [noserver] # don't include server files
|
tags: [ noserver ] # don't include server files
|
||||||
ldflags:
|
ldflags:
|
||||||
- "-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
|
||||||
tags: [noserver] # don't include server files
|
tags: [ noserver ] # don't include server files
|
||||||
ldflags:
|
ldflags:
|
||||||
- "-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: [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
|
||||||
@@ -90,6 +84,8 @@ nfpms:
|
|||||||
type: "config|noreplace"
|
type: "config|noreplace"
|
||||||
- src: client/ntfy-client.service
|
- src: client/ntfy-client.service
|
||||||
dst: /lib/systemd/system/ntfy-client.service
|
dst: /lib/systemd/system/ntfy-client.service
|
||||||
|
- src: client/user/ntfy-client.service
|
||||||
|
dst: /lib/systemd/user/ntfy-client.service
|
||||||
- dst: /var/cache/ntfy
|
- dst: /var/cache/ntfy
|
||||||
type: dir
|
type: dir
|
||||||
- dst: /var/cache/ntfy/attachments
|
- dst: /var/cache/ntfy/attachments
|
||||||
@@ -104,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
|
||||||
@@ -119,19 +114,18 @@ archives:
|
|||||||
- server/ntfy.service
|
- server/ntfy.service
|
||||||
- client/client.yml
|
- client/client.yml
|
||||||
- client/ntfy-client.service
|
- client/ntfy-client.service
|
||||||
-
|
- client/user/ntfy-client.service
|
||||||
id: ntfy_windows
|
- id: ntfy_windows
|
||||||
builds:
|
ids:
|
||||||
- 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:
|
||||||
@@ -139,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:
|
||||||
@@ -197,3 +190,15 @@ docker_manifests:
|
|||||||
- *arm64v8_image
|
- *arm64v8_image
|
||||||
- *armv7_image
|
- *armv7_image
|
||||||
- *armv6_image
|
- *armv6_image
|
||||||
|
- name_template: "binwiederhier/ntfy:v{{ .Major }}"
|
||||||
|
image_templates:
|
||||||
|
- *amd64_image
|
||||||
|
- *arm64v8_image
|
||||||
|
- *armv7_image
|
||||||
|
- *armv6_image
|
||||||
|
- name_template: "binwiederhier/ntfy:v{{ .Major }}.{{ .Minor }}"
|
||||||
|
image_templates:
|
||||||
|
- *amd64_image
|
||||||
|
- *arm64v8_image
|
||||||
|
- *armv7_image
|
||||||
|
- *armv6_image
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM golang:1.21-bullseye as builder
|
FROM golang:1.24-bullseye as builder
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
ARG COMMIT=unknown
|
ARG COMMIT=unknown
|
||||||
@@ -44,6 +44,8 @@ RUN make VERSION=$VERSION COMMIT=$COMMIT cli-linux-server
|
|||||||
|
|
||||||
FROM alpine
|
FROM alpine
|
||||||
|
|
||||||
|
ARG VERSION=dev
|
||||||
|
|
||||||
LABEL org.opencontainers.image.authors="philipp.heckel@gmail.com"
|
LABEL org.opencontainers.image.authors="philipp.heckel@gmail.com"
|
||||||
LABEL org.opencontainers.image.url="https://ntfy.sh/"
|
LABEL org.opencontainers.image.url="https://ntfy.sh/"
|
||||||
LABEL org.opencontainers.image.documentation="https://docs.ntfy.sh/"
|
LABEL org.opencontainers.image.documentation="https://docs.ntfy.sh/"
|
||||||
@@ -52,6 +54,7 @@ LABEL org.opencontainers.image.vendor="Philipp C. Heckel"
|
|||||||
LABEL org.opencontainers.image.licenses="Apache-2.0, GPL-2.0"
|
LABEL org.opencontainers.image.licenses="Apache-2.0, GPL-2.0"
|
||||||
LABEL org.opencontainers.image.title="ntfy"
|
LABEL org.opencontainers.image.title="ntfy"
|
||||||
LABEL org.opencontainers.image.description="Send push notifications to your phone or desktop using PUT/POST"
|
LABEL org.opencontainers.image.description="Send push notifications to your phone or desktop using PUT/POST"
|
||||||
|
LABEL org.opencontainers.image.version="$VERSION"
|
||||||
|
|
||||||
COPY --from=builder /app/dist/ntfy_linux_server/ntfy /usr/bin/ntfy
|
COPY --from=builder /app/dist/ntfy_linux_server/ntfy /usr/bin/ntfy
|
||||||
|
|
||||||
|
|||||||
4
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; }
|
||||||
@@ -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-))
|
||||||
|
|||||||
85
README.md
@@ -9,7 +9,6 @@
|
|||||||
[](https://discord.gg/cT7ECsZj9w)
|
[](https://discord.gg/cT7ECsZj9w)
|
||||||
[](https://matrix.to/#/#ntfy:matrix.org)
|
[](https://matrix.to/#/#ntfy:matrix.org)
|
||||||
[](https://matrix.to/#/#ntfy-space:matrix.org)
|
[](https://matrix.to/#/#ntfy-space:matrix.org)
|
||||||
[](https://discuss.ntfy.sh/c/ntfy)
|
|
||||||
[](https://ntfy.statuspage.io/)
|
[](https://ntfy.statuspage.io/)
|
||||||
[](https://gitpod.io/#https://github.com/binwiederhier/ntfy)
|
[](https://gitpod.io/#https://github.com/binwiederhier/ntfy)
|
||||||
|
|
||||||
@@ -50,7 +49,6 @@ works best for you:
|
|||||||
|
|
||||||
* [Discord server](https://discord.gg/cT7ECsZj9w) - direct chat with the community
|
* [Discord server](https://discord.gg/cT7ECsZj9w) - direct chat with the community
|
||||||
* [Matrix room #ntfy](https://matrix.to/#/#ntfy:matrix.org) (+ [Matrix space](https://matrix.to/#/#ntfy-space:matrix.org)) - same chat, bridged from Discord
|
* [Matrix room #ntfy](https://matrix.to/#/#ntfy:matrix.org) (+ [Matrix space](https://matrix.to/#/#ntfy-space:matrix.org)) - same chat, bridged from Discord
|
||||||
* [Lemmy discussion board](https://discuss.ntfy.sh/c/ntfy) - asynchronous forum (_new as of June 2023_)
|
|
||||||
* [GitHub issues](https://github.com/binwiederhier/ntfy/issues) - questions, features, bugs
|
* [GitHub issues](https://github.com/binwiederhier/ntfy/issues) - questions, features, bugs
|
||||||
|
|
||||||
## Announcements/beta testers
|
## Announcements/beta testers
|
||||||
@@ -58,20 +56,18 @@ For announcements of new releases and cutting-edge beta versions, please subscri
|
|||||||
topic. If you'd like to test the iOS app, join [TestFlight](https://testflight.apple.com/join/P1fFnAm9). For Android betas,
|
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 already 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>
|
||||||
|
|
||||||
|
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>
|
||||||
@@ -168,14 +164,65 @@ account costs. Even small donations are very much appreciated. A big fat **Thank
|
|||||||
<a href="https://github.com/ubipo"><img src="https://github.com/ubipo.png" width="40px" /></a>
|
<a href="https://github.com/ubipo"><img src="https://github.com/ubipo.png" width="40px" /></a>
|
||||||
<a href="https://github.com/tka85"><img src="https://github.com/tka85.png" width="40px" /></a>
|
<a href="https://github.com/tka85"><img src="https://github.com/tka85.png" width="40px" /></a>
|
||||||
<a href="https://github.com/beekeeb"><img src="https://github.com/beekeeb.png" width="40px" /></a>
|
<a href="https://github.com/beekeeb"><img src="https://github.com/beekeeb.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/Emiliaaah"><img src="https://github.com/Emiliaaah.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/zark0s"><img src="https://github.com/zark0s.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/tomershvueli"><img src="https://github.com/tomershvueli.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/CataIana"><img src="https://github.com/CataIana.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/ajay-actuary"><img src="https://github.com/ajay-actuary.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/mursec"><img src="https://github.com/mursec.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/FrameXX"><img src="https://github.com/FrameXX.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/vovayartsev"><img src="https://github.com/vovayartsev.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/dwain-lab"><img src="https://github.com/dwain-lab.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/brookmg"><img src="https://github.com/brookmg.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/siebej"><img src="https://github.com/siebej.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/rxsantos"><img src="https://github.com/rxsantos.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/hermannx5"><img src="https://github.com/hermannx5.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/rwxd"><img src="https://github.com/rwxd.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/Integral-Tech"><img src="https://github.com/Integral-Tech.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/TheTomik1"><img src="https://github.com/TheTomik1.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/dav23r"><img src="https://github.com/dav23r.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/stannynuytkens"><img src="https://github.com/stannynuytkens.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/danbartram"><img src="https://github.com/danbartram.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/arthurgleckler"><img src="https://github.com/arthurgleckler.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/tomroth04"><img src="https://github.com/tomroth04.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/Circenn5130"><img src="https://github.com/Circenn5130.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/jceloria"><img src="https://github.com/jceloria.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/afunworm"><img src="https://github.com/afunworm.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/PTR-inc"><img src="https://github.com/PTR-inc.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/spudooli"><img src="https://github.com/spudooli.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/IMarkoMC"><img src="https://github.com/IMarkoMC.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/rubund"><img src="https://github.com/rubund.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/Riolku"><img src="https://github.com/Riolku.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/arnbrhm"><img src="https://github.com/arnbrhm.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/herzkerl"><img src="https://github.com/herzkerl.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/0x45796164"><img src="https://github.com/0x45796164.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/madchr1st"><img src="https://github.com/madchr1st.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/avalentic"><img src="https://github.com/avalentic.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/TheCraiggers"><img src="https://github.com/TheCraiggers.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/sheetd"><img src="https://github.com/sheetd.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/dlt-green"><img src="https://github.com/dlt-green.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/suhlig"><img src="https://github.com/suhlig.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/Proximus888"><img src="https://github.com/Proximus888.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/wielandp"><img src="https://github.com/wielandp.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/chxseh"><img src="https://github.com/chxseh.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>
|
||||||
|
|
||||||
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.**
|
||||||
|
|
||||||
|
|||||||
BIN
assets/sponsors/magicbell.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
@@ -2,6 +2,7 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"gopkg.in/yaml.v2"
|
"gopkg.in/yaml.v2"
|
||||||
|
"heckel.io/ntfy/v2/log"
|
||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ func NewConfig() *Config {
|
|||||||
|
|
||||||
// LoadConfig loads the Client config from a yaml file
|
// LoadConfig loads the Client config from a yaml file
|
||||||
func LoadConfig(filename string) (*Config, error) {
|
func LoadConfig(filename string) (*Config, error) {
|
||||||
|
log.Debug("Loading client config from %s", filename)
|
||||||
b, err := os.ReadFile(filename)
|
b, err := os.ReadFile(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
10
client/user/ntfy-client.service
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=ntfy client
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/bin/ntfy subscribe --config "%h/.config/ntfy/client.yml" --from-config
|
||||||
|
Restart=on-failure
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -69,6 +69,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
|
||||||
@@ -254,6 +255,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 +322,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
|
||||||
|
}
|
||||||
|
|||||||
225
cmd/serve.go
@@ -5,23 +5,23 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/stripe/stripe-go/v74"
|
|
||||||
"heckel.io/ntfy/v2/user"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"math"
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"heckel.io/ntfy/v2/log"
|
"github.com/stripe/stripe-go/v74"
|
||||||
|
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
"github.com/urfave/cli/v2/altsrc"
|
"github.com/urfave/cli/v2/altsrc"
|
||||||
|
"heckel.io/ntfy/v2/log"
|
||||||
"heckel.io/ntfy/v2/server"
|
"heckel.io/ntfy/v2/server"
|
||||||
|
"heckel.io/ntfy/v2/user"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ const (
|
|||||||
|
|
||||||
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, DefaultText: defaultServerConfigFile, Usage: "config file"},
|
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, 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"}),
|
||||||
@@ -45,19 +45,19 @@ var flagsServe = append(
|
|||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"cert_file", "E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"cert_file", "E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"firebase_key_file", "F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"firebase_key_file", "F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"cache_file", "C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"cache_file", "C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}),
|
||||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"cache_duration", "b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: server.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-duration", Aliases: []string{"cache_duration", "b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: util.FormatDuration(server.DefaultCacheDuration), Usage: "buffer messages for this time to allow `since` requests"}),
|
||||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "cache-batch-size", Aliases: []string{"cache_batch_size"}, EnvVars: []string{"NTFY_BATCH_SIZE"}, Usage: "max size of messages to batch together when writing to message cache (if zero, writes are synchronous)"}),
|
altsrc.NewIntFlag(&cli.IntFlag{Name: "cache-batch-size", Aliases: []string{"cache_batch_size"}, EnvVars: []string{"NTFY_BATCH_SIZE"}, Usage: "max size of messages to batch together when writing to message cache (if zero, writes are synchronous)"}),
|
||||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-batch-timeout", Aliases: []string{"cache_batch_timeout"}, EnvVars: []string{"NTFY_CACHE_BATCH_TIMEOUT"}, Usage: "timeout for batched async writes to the message cache (if zero, writes are synchronous)"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-batch-timeout", Aliases: []string{"cache_batch_timeout"}, EnvVars: []string{"NTFY_CACHE_BATCH_TIMEOUT"}, Value: util.FormatDuration(server.DefaultCacheBatchTimeout), Usage: "timeout for batched async writes to the message cache (if zero, writes are synchronous)"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-startup-queries", Aliases: []string{"cache_startup_queries"}, EnvVars: []string{"NTFY_CACHE_STARTUP_QUERIES"}, Usage: "queries run when the cache database is initialized"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-startup-queries", Aliases: []string{"cache_startup_queries"}, EnvVars: []string{"NTFY_CACHE_STARTUP_QUERIES"}, Usage: "queries run when the cache database is initialized"}),
|
||||||
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.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"}, DefaultText: "5G", 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"}, DefaultText: "15M", 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.NewDurationFlag(&cli.DurationFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: server.DefaultAttachmentExpiryDuration, DefaultText: "3h", 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.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: 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.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: 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"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", Aliases: []string{"web_root"}, EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "/", Usage: "sets root of the web app (e.g. /, or /app), or disables it (disable)"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", Aliases: []string{"web_root"}, EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "/", Usage: "sets root of the web app (e.g. /, or /app), or disables it (disable)"}),
|
||||||
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"}),
|
||||||
@@ -76,18 +76,24 @@ var flagsServe = append(
|
|||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-auth-token", Aliases: []string{"twilio_auth_token"}, EnvVars: []string{"NTFY_TWILIO_AUTH_TOKEN"}, Usage: "Twilio auth token"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-auth-token", Aliases: []string{"twilio_auth_token"}, EnvVars: []string{"NTFY_TWILIO_AUTH_TOKEN"}, Usage: "Twilio auth token"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-phone-number", Aliases: []string{"twilio_phone_number"}, EnvVars: []string{"NTFY_TWILIO_PHONE_NUMBER"}, Usage: "Twilio number to use for outgoing calls"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-phone-number", Aliases: []string{"twilio_phone_number"}, EnvVars: []string{"NTFY_TWILIO_PHONE_NUMBER"}, Usage: "Twilio number to use for outgoing calls"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-verify-service", Aliases: []string{"twilio_verify_service"}, EnvVars: []string{"NTFY_TWILIO_VERIFY_SERVICE"}, Usage: "Twilio Verify service ID, used for phone number verification"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-verify-service", Aliases: []string{"twilio_verify_service"}, EnvVars: []string{"NTFY_TWILIO_VERIFY_SERVICE"}, Usage: "Twilio Verify service ID, used for phone number verification"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "message-size-limit", Aliases: []string{"message_size_limit"}, EnvVars: []string{"NTFY_MESSAGE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultMessageSizeLimit), Usage: "size limit for the message (see docs for limitations)"}),
|
||||||
|
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.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: "100M", Usage: "total storage limit used for attachments per visitor"}),
|
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "visitor-subscriber-rate-limiting", Aliases: []string{"visitor_subscriber_rate_limiting"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING"}, Value: false, Usage: "enables subscriber-based rate limiting"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultVisitorAttachmentTotalSizeLimit), Usage: "total storage limit used for attachments per visitor"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", Aliases: []string{"visitor_attachment_daily_bandwidth_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload bandwidth limit per visitor"}),
|
altsrc.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"}),
|
||||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", Aliases: []string{"visitor_request_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-replenish", Aliases: []string{"visitor_request_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorRequestLimitReplenish), Usage: "interval at which burst limit is replenished (one per x)"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", Aliases: []string{"visitor_request_limit_exempt_hosts"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", Aliases: []string{"visitor_request_limit_exempt_hosts"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}),
|
||||||
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.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: 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.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
|
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv6", Aliases: []string{"visitor_prefix_bits_ipv6"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV6"}, Value: server.DefaultVisitorPrefixBitsIPv6, Usage: "number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)"}),
|
||||||
|
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting)"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-forwarded-header", Aliases: []string{"proxy_forwarded_header"}, EnvVars: []string{"NTFY_PROXY_FORWARDED_HEADER"}, Value: "X-Forwarded-For", Usage: "use specified header to determine visitor IP address (for rate limiting)"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-trusted-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)"}),
|
||||||
@@ -99,6 +105,8 @@ var flagsServe = append(
|
|||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-file", Aliases: []string{"web_push_file"}, EnvVars: []string{"NTFY_WEB_PUSH_FILE"}, Usage: "file used to store web push subscriptions"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-file", Aliases: []string{"web_push_file"}, EnvVars: []string{"NTFY_WEB_PUSH_FILE"}, Usage: "file used to store web push subscriptions"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-email-address", Aliases: []string{"web_push_email_address"}, EnvVars: []string{"NTFY_WEB_PUSH_EMAIL_ADDRESS"}, Usage: "e-mail address of sender, required to use browser push services"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-email-address", Aliases: []string{"web_push_email_address"}, EnvVars: []string{"NTFY_WEB_PUSH_EMAIL_ADDRESS"}, Usage: "e-mail address of sender, required to use browser push services"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-startup-queries", Aliases: []string{"web_push_startup_queries"}, EnvVars: []string{"NTFY_WEB_PUSH_STARTUP_QUERIES"}, Usage: "queries run when the web push database is initialized"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-startup-queries", Aliases: []string{"web_push_startup_queries"}, EnvVars: []string{"NTFY_WEB_PUSH_STARTUP_QUERIES"}, Usage: "queries run when the web push database is initialized"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-expiry-duration", Aliases: []string{"web_push_expiry_duration"}, EnvVars: []string{"NTFY_WEB_PUSH_EXPIRY_DURATION"}, Value: util.FormatDuration(server.DefaultWebPushExpiryDuration), Usage: "automatically expire unused subscriptions after this time"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-expiry-warning-duration", Aliases: []string{"web_push_expiry_warning_duration"}, EnvVars: []string{"NTFY_WEB_PUSH_EXPIRY_WARNING_DURATION"}, Value: util.FormatDuration(server.DefaultWebPushExpiryWarningDuration), Usage: "send web push warning notification after this time before expiring unused subscriptions"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
var cmdServe = &cli.Command{
|
var cmdServe = &cli.Command{
|
||||||
@@ -126,7 +134,7 @@ func execServe(c *cli.Context) error {
|
|||||||
|
|
||||||
// Read all the options
|
// Read all the options
|
||||||
config := c.String("config")
|
config := c.String("config")
|
||||||
baseURL := c.String("base-url")
|
baseURL := strings.TrimSuffix(c.String("base-url"), "/")
|
||||||
listenHTTP := c.String("listen-http")
|
listenHTTP := c.String("listen-http")
|
||||||
listenHTTPS := c.String("listen-https")
|
listenHTTPS := c.String("listen-https")
|
||||||
listenUnix := c.String("listen-unix")
|
listenUnix := c.String("listen-unix")
|
||||||
@@ -139,20 +147,22 @@ func execServe(c *cli.Context) error {
|
|||||||
webPushFile := c.String("web-push-file")
|
webPushFile := c.String("web-push-file")
|
||||||
webPushEmailAddress := c.String("web-push-email-address")
|
webPushEmailAddress := c.String("web-push-email-address")
|
||||||
webPushStartupQueries := c.String("web-push-startup-queries")
|
webPushStartupQueries := c.String("web-push-startup-queries")
|
||||||
|
webPushExpiryDurationStr := c.String("web-push-expiry-duration")
|
||||||
|
webPushExpiryWarningDurationStr := c.String("web-push-expiry-warning-duration")
|
||||||
cacheFile := c.String("cache-file")
|
cacheFile := c.String("cache-file")
|
||||||
cacheDuration := c.Duration("cache-duration")
|
cacheDurationStr := c.String("cache-duration")
|
||||||
cacheStartupQueries := c.String("cache-startup-queries")
|
cacheStartupQueries := c.String("cache-startup-queries")
|
||||||
cacheBatchSize := c.Int("cache-batch-size")
|
cacheBatchSize := c.Int("cache-batch-size")
|
||||||
cacheBatchTimeout := c.Duration("cache-batch-timeout")
|
cacheBatchTimeoutStr := c.String("cache-batch-timeout")
|
||||||
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")
|
||||||
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")
|
||||||
attachmentExpiryDuration := c.Duration("attachment-expiry-duration")
|
attachmentExpiryDurationStr := c.String("attachment-expiry-duration")
|
||||||
keepaliveInterval := c.Duration("keepalive-interval")
|
keepaliveIntervalStr := c.String("keepalive-interval")
|
||||||
managerInterval := c.Duration("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")
|
||||||
@@ -171,18 +181,24 @@ func execServe(c *cli.Context) error {
|
|||||||
twilioAuthToken := c.String("twilio-auth-token")
|
twilioAuthToken := c.String("twilio-auth-token")
|
||||||
twilioPhoneNumber := c.String("twilio-phone-number")
|
twilioPhoneNumber := c.String("twilio-phone-number")
|
||||||
twilioVerifyService := c.String("twilio-verify-service")
|
twilioVerifyService := c.String("twilio-verify-service")
|
||||||
|
messageSizeLimitStr := c.String("message-size-limit")
|
||||||
|
messageDelayLimitStr := c.String("message-delay-limit")
|
||||||
totalTopicLimit := c.Int("global-topic-limit")
|
totalTopicLimit := c.Int("global-topic-limit")
|
||||||
visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
|
visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
|
||||||
visitorSubscriberRateLimiting := c.Bool("visitor-subscriber-rate-limiting")
|
visitorSubscriberRateLimiting := c.Bool("visitor-subscriber-rate-limiting")
|
||||||
visitorAttachmentTotalSizeLimitStr := c.String("visitor-attachment-total-size-limit")
|
visitorAttachmentTotalSizeLimitStr := c.String("visitor-attachment-total-size-limit")
|
||||||
visitorAttachmentDailyBandwidthLimitStr := c.String("visitor-attachment-daily-bandwidth-limit")
|
visitorAttachmentDailyBandwidthLimitStr := c.String("visitor-attachment-daily-bandwidth-limit")
|
||||||
visitorRequestLimitBurst := c.Int("visitor-request-limit-burst")
|
visitorRequestLimitBurst := c.Int("visitor-request-limit-burst")
|
||||||
visitorRequestLimitReplenish := c.Duration("visitor-request-limit-replenish")
|
visitorRequestLimitReplenishStr := c.String("visitor-request-limit-replenish")
|
||||||
visitorRequestLimitExemptHosts := util.SplitNoEmpty(c.String("visitor-request-limit-exempt-hosts"), ",")
|
visitorRequestLimitExemptHosts := util.SplitNoEmpty(c.String("visitor-request-limit-exempt-hosts"), ",")
|
||||||
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")
|
||||||
visitorEmailLimitReplenish := c.Duration("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")
|
||||||
|
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")
|
||||||
@@ -190,6 +206,72 @@ func execServe(c *cli.Context) error {
|
|||||||
enableMetrics := c.Bool("enable-metrics") || metricsListenHTTP != ""
|
enableMetrics := c.Bool("enable-metrics") || metricsListenHTTP != ""
|
||||||
profileListenHTTP := c.String("profile-listen-http")
|
profileListenHTTP := c.String("profile-listen-http")
|
||||||
|
|
||||||
|
// Convert durations
|
||||||
|
cacheDuration, err := util.ParseDuration(cacheDurationStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid cache duration: %s", cacheDurationStr)
|
||||||
|
}
|
||||||
|
cacheBatchTimeout, err := util.ParseDuration(cacheBatchTimeoutStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid cache batch timeout: %s", cacheBatchTimeoutStr)
|
||||||
|
}
|
||||||
|
attachmentExpiryDuration, err := util.ParseDuration(attachmentExpiryDurationStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid attachment expiry duration: %s", attachmentExpiryDurationStr)
|
||||||
|
}
|
||||||
|
keepaliveInterval, err := util.ParseDuration(keepaliveIntervalStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid keepalive interval: %s", keepaliveIntervalStr)
|
||||||
|
}
|
||||||
|
managerInterval, err := util.ParseDuration(managerIntervalStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid manager interval: %s", managerIntervalStr)
|
||||||
|
}
|
||||||
|
messageDelayLimit, err := util.ParseDuration(messageDelayLimitStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid message delay limit: %s", messageDelayLimitStr)
|
||||||
|
}
|
||||||
|
visitorRequestLimitReplenish, err := util.ParseDuration(visitorRequestLimitReplenishStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid visitor request limit replenish: %s", visitorRequestLimitReplenishStr)
|
||||||
|
}
|
||||||
|
visitorEmailLimitReplenish, err := util.ParseDuration(visitorEmailLimitReplenishStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid visitor email limit replenish: %s", visitorEmailLimitReplenishStr)
|
||||||
|
}
|
||||||
|
webPushExpiryDuration, err := util.ParseDuration(webPushExpiryDurationStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid web push expiry duration: %s", webPushExpiryDurationStr)
|
||||||
|
}
|
||||||
|
webPushExpiryWarningDuration, err := util.ParseDuration(webPushExpiryWarningDurationStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid web push expiry warning duration: %s", webPushExpiryWarningDurationStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert sizes to bytes
|
||||||
|
messageSizeLimit, err := util.ParseSize(messageSizeLimitStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid message size limit: %s", messageSizeLimitStr)
|
||||||
|
}
|
||||||
|
attachmentTotalSizeLimit, err := util.ParseSize(attachmentTotalSizeLimitStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid attachment total size limit: %s", attachmentTotalSizeLimitStr)
|
||||||
|
}
|
||||||
|
attachmentFileSizeLimit, err := util.ParseSize(attachmentFileSizeLimitStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid attachment file size limit: %s", attachmentFileSizeLimitStr)
|
||||||
|
}
|
||||||
|
visitorAttachmentTotalSizeLimit, err := util.ParseSize(visitorAttachmentTotalSizeLimitStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid visitor attachment total size limit: %s", visitorAttachmentTotalSizeLimitStr)
|
||||||
|
}
|
||||||
|
visitorAttachmentDailyBandwidthLimit, err := util.ParseSize(visitorAttachmentDailyBandwidthLimitStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid visitor attachment daily bandwidth limit: %s", visitorAttachmentDailyBandwidthLimitStr)
|
||||||
|
} else if visitorAttachmentDailyBandwidthLimit > math.MaxInt {
|
||||||
|
return fmt.Errorf("config option visitor-attachment-daily-bandwidth-limit must be lower than %d", math.MaxInt)
|
||||||
|
}
|
||||||
|
|
||||||
// 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")
|
||||||
@@ -213,10 +295,15 @@ func execServe(c *cli.Context) error {
|
|||||||
return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set")
|
return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set")
|
||||||
} else if attachmentCacheDir != "" && baseURL == "" {
|
} else if attachmentCacheDir != "" && baseURL == "" {
|
||||||
return errors.New("if attachment-cache-dir is set, base-url must also be set")
|
return errors.New("if attachment-cache-dir is set, base-url must also be set")
|
||||||
} else if baseURL != "" && !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
|
} else if baseURL != "" {
|
||||||
return errors.New("if set, base-url must start with http:// or https://")
|
u, err := url.Parse(baseURL)
|
||||||
} else if baseURL != "" && strings.HasSuffix(baseURL, "/") {
|
if err != nil {
|
||||||
return errors.New("if set, base-url must not end with a slash (/)")
|
return fmt.Errorf("if set, base-url must be a valid URL, e.g. https://ntfy.mydomain.com: %v", err)
|
||||||
|
} else if u.Scheme != "http" && u.Scheme != "https" {
|
||||||
|
return errors.New("if set, base-url must be a valid URL starting with http:// or https://, e.g. https://ntfy.mydomain.com")
|
||||||
|
} else if u.Path != "" {
|
||||||
|
return fmt.Errorf("if set, base-url must not have a path (%s), as hosting ntfy on a sub-path is not supported, e.g. https://ntfy.mydomain.com", u.Path)
|
||||||
|
}
|
||||||
} else if upstreamBaseURL != "" && !strings.HasPrefix(upstreamBaseURL, "http://") && !strings.HasPrefix(upstreamBaseURL, "https://") {
|
} else if upstreamBaseURL != "" && !strings.HasPrefix(upstreamBaseURL, "http://") && !strings.HasPrefix(upstreamBaseURL, "https://") {
|
||||||
return errors.New("if set, upstream-base-url must start with http:// or https://")
|
return errors.New("if set, upstream-base-url must start with http:// or https://")
|
||||||
} else if upstreamBaseURL != "" && strings.HasSuffix(upstreamBaseURL, "/") {
|
} else if upstreamBaseURL != "" && strings.HasSuffix(upstreamBaseURL, "/") {
|
||||||
@@ -233,6 +320,19 @@ func execServe(c *cli.Context) error {
|
|||||||
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 == "") {
|
||||||
return errors.New("if twilio-account is set, twilio-auth-token, twilio-phone-number, twilio-verify-service, base-url, and auth-file must also be set")
|
return errors.New("if twilio-account is set, twilio-auth-token, twilio-phone-number, twilio-verify-service, base-url, and auth-file must also be set")
|
||||||
|
} else if messageSizeLimit > server.DefaultMessageSizeLimit {
|
||||||
|
log.Warn("message-size-limit is greater than 4K, this is not recommended and largely untested, and may lead to issues with some clients")
|
||||||
|
if messageSizeLimit > 5*1024*1024 {
|
||||||
|
return errors.New("message-size-limit cannot be higher than 5M")
|
||||||
|
}
|
||||||
|
} else if webPushExpiryWarningDuration > 0 && webPushExpiryWarningDuration > webPushExpiryDuration {
|
||||||
|
return errors.New("web push expiry warning duration cannot be higher than web push expiry duration")
|
||||||
|
} else if behindProxy && proxyForwardedHeader == "" {
|
||||||
|
return errors.New("if behind-proxy is set, proxy-forwarded-header must also be set")
|
||||||
|
} else if visitorPrefixBitsIPv4 < 1 || visitorPrefixBitsIPv4 > 32 {
|
||||||
|
return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32")
|
||||||
|
} else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 {
|
||||||
|
return errors.New("visitor-prefix-bits-ipv6 must be between 1 and 128")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backwards compatibility
|
// Backwards compatibility
|
||||||
@@ -257,35 +357,25 @@ func execServe(c *cli.Context) error {
|
|||||||
listenHTTP = ""
|
listenHTTP = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert sizes to bytes
|
|
||||||
attachmentTotalSizeLimit, err := parseSize(attachmentTotalSizeLimitStr, server.DefaultAttachmentTotalSizeLimit)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
attachmentFileSizeLimit, err := parseSize(attachmentFileSizeLimitStr, server.DefaultAttachmentFileSizeLimit)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
visitorAttachmentTotalSizeLimit, err := parseSize(visitorAttachmentTotalSizeLimitStr, server.DefaultVisitorAttachmentTotalSizeLimit)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
visitorAttachmentDailyBandwidthLimit, err := parseSize(visitorAttachmentDailyBandwidthLimitStr, server.DefaultVisitorAttachmentDailyBandwidthLimit)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
} else if visitorAttachmentDailyBandwidthLimit > math.MaxInt {
|
|
||||||
return fmt.Errorf("config option visitor-attachment-daily-bandwidth-limit must be lower than %d", math.MaxInt)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
||||||
@@ -337,18 +427,24 @@ func execServe(c *cli.Context) error {
|
|||||||
conf.TwilioAuthToken = twilioAuthToken
|
conf.TwilioAuthToken = twilioAuthToken
|
||||||
conf.TwilioPhoneNumber = twilioPhoneNumber
|
conf.TwilioPhoneNumber = twilioPhoneNumber
|
||||||
conf.TwilioVerifyService = twilioVerifyService
|
conf.TwilioVerifyService = twilioVerifyService
|
||||||
|
conf.MessageSizeLimit = int(messageSizeLimit)
|
||||||
|
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.ProxyTrustedPrefixes = trustedProxyPrefixes
|
||||||
conf.StripeSecretKey = stripeSecretKey
|
conf.StripeSecretKey = stripeSecretKey
|
||||||
conf.StripeWebhookKey = stripeWebhookKey
|
conf.StripeWebhookKey = stripeWebhookKey
|
||||||
conf.BillingContact = billingContact
|
conf.BillingContact = billingContact
|
||||||
@@ -358,12 +454,14 @@ func execServe(c *cli.Context) error {
|
|||||||
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
|
||||||
conf.WebPushEmailAddress = webPushEmailAddress
|
conf.WebPushEmailAddress = webPushEmailAddress
|
||||||
conf.WebPushStartupQueries = webPushStartupQueries
|
conf.WebPushStartupQueries = webPushStartupQueries
|
||||||
|
conf.WebPushExpiryDuration = webPushExpiryDuration
|
||||||
|
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)
|
||||||
@@ -371,25 +469,14 @@ func execServe(c *cli.Context) error {
|
|||||||
// Run server
|
// Run server
|
||||||
s, err := server.New(conf)
|
s, err := server.New(conf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err.Error())
|
log.Fatal("%s", err.Error())
|
||||||
} else if err := s.Run(); err != nil {
|
} else if err := s.Run(); err != nil {
|
||||||
log.Fatal(err.Error())
|
log.Fatal("%s", err.Error())
|
||||||
}
|
}
|
||||||
log.Info("Exiting.")
|
log.Info("Exiting.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseSize(s string, defaultValue int64) (v int64, err error) {
|
|
||||||
if s == "" {
|
|
||||||
return defaultValue, nil
|
|
||||||
}
|
|
||||||
v, err = util.ParseSize(s)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return v, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func sigHandlerConfigReload(config string) {
|
func sigHandlerConfigReload(config string) {
|
||||||
sigs := make(chan os.Signal, 1)
|
sigs := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigs, syscall.SIGHUP)
|
signal.Notify(sigs, syscall.SIGHUP)
|
||||||
@@ -407,7 +494,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())
|
||||||
|
|||||||
@@ -310,28 +310,43 @@ func loadConfig(c *cli.Context) (*client.Config, error) {
|
|||||||
if filename != "" {
|
if filename != "" {
|
||||||
return client.LoadConfig(filename)
|
return client.LoadConfig(filename)
|
||||||
}
|
}
|
||||||
configFile := defaultClientConfigFile()
|
configFile, err := defaultClientConfigFile()
|
||||||
if s, _ := os.Stat(configFile); s != nil {
|
if err != nil {
|
||||||
return client.LoadConfig(configFile)
|
log.Warn("Could not determine default client config file: %s", err.Error())
|
||||||
|
} else {
|
||||||
|
if s, _ := os.Stat(configFile); s != nil {
|
||||||
|
return client.LoadConfig(configFile)
|
||||||
|
}
|
||||||
|
log.Debug("Config file %s not found", configFile)
|
||||||
}
|
}
|
||||||
|
log.Debug("Loading default config")
|
||||||
return client.NewConfig(), nil
|
return client.NewConfig(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
//lint:ignore U1000 Conditionally used in different builds
|
//lint:ignore U1000 Conditionally used in different builds
|
||||||
func defaultClientConfigFileUnix() string {
|
func defaultClientConfigFileUnix() (string, error) {
|
||||||
u, _ := user.Current()
|
u, err := user.Current()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("could not determine current user: %w", err)
|
||||||
|
}
|
||||||
configFile := clientRootConfigFileUnixAbsolute
|
configFile := clientRootConfigFileUnixAbsolute
|
||||||
if u.Uid != "0" {
|
if u.Uid != "0" {
|
||||||
homeDir, _ := os.UserConfigDir()
|
homeDir, err := os.UserConfigDir()
|
||||||
return filepath.Join(homeDir, clientUserConfigFileUnixRelative)
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("could not determine user config dir: %w", err)
|
||||||
|
}
|
||||||
|
return filepath.Join(homeDir, clientUserConfigFileUnixRelative), nil
|
||||||
}
|
}
|
||||||
return configFile
|
return configFile, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
//lint:ignore U1000 Conditionally used in different builds
|
//lint:ignore U1000 Conditionally used in different builds
|
||||||
func defaultClientConfigFileWindows() string {
|
func defaultClientConfigFileWindows() (string, error) {
|
||||||
homeDir, _ := os.UserConfigDir()
|
homeDir, err := os.UserConfigDir()
|
||||||
return filepath.Join(homeDir, clientUserConfigFileWindowsRelative)
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("could not determine user config dir: %w", err)
|
||||||
|
}
|
||||||
|
return filepath.Join(homeDir, clientUserConfigFileWindowsRelative), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func logMessagePrefix(m *client.Message) string {
|
func logMessagePrefix(m *client.Message) string {
|
||||||
|
|||||||
@@ -11,6 +11,6 @@ var (
|
|||||||
scriptLauncher = []string{"sh", "-c"}
|
scriptLauncher = []string{"sh", "-c"}
|
||||||
)
|
)
|
||||||
|
|
||||||
func defaultClientConfigFile() string {
|
func defaultClientConfigFile() (string, error) {
|
||||||
return defaultClientConfigFileUnix()
|
return defaultClientConfigFileUnix()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,6 @@ var (
|
|||||||
scriptLauncher = []string{"sh", "-c"}
|
scriptLauncher = []string{"sh", "-c"}
|
||||||
)
|
)
|
||||||
|
|
||||||
func defaultClientConfigFile() string {
|
func defaultClientConfigFile() (string, error) {
|
||||||
return defaultClientConfigFileUnix()
|
return defaultClientConfigFileUnix()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,6 @@ var (
|
|||||||
scriptLauncher = []string{"cmd.exe", "/Q", "/C"}
|
scriptLauncher = []string{"cmd.exe", "/Q", "/C"}
|
||||||
)
|
)
|
||||||
|
|
||||||
func defaultClientConfigFile() string {
|
func defaultClientConfigFile() (string, error) {
|
||||||
return defaultClientConfigFileWindows()
|
return defaultClientConfigFileWindows()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -366,9 +366,9 @@ func printTier(c *cli.Context, tier *user.Tier) {
|
|||||||
fmt.Fprintf(c.App.ErrWriter, "- Email limit: %d\n", tier.EmailLimit)
|
fmt.Fprintf(c.App.ErrWriter, "- Email limit: %d\n", tier.EmailLimit)
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Phone call limit: %d\n", tier.CallLimit)
|
fmt.Fprintf(c.App.ErrWriter, "- Phone call limit: %d\n", tier.CallLimit)
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit)
|
fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit)
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSize(tier.AttachmentFileSizeLimit))
|
fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSizeHuman(tier.AttachmentFileSizeLimit))
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSize(tier.AttachmentTotalSizeLimit))
|
fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSizeHuman(tier.AttachmentTotalSizeLimit))
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment expiry duration: %s (%d seconds)\n", tier.AttachmentExpiryDuration.String(), int64(tier.AttachmentExpiryDuration.Seconds()))
|
fmt.Fprintf(c.App.ErrWriter, "- Attachment 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.FormatSize(tier.AttachmentBandwidthLimit))
|
fmt.Fprintf(c.App.ErrWriter, "- Attachment daily bandwidth limit: %s\n", util.FormatSizeHuman(tier.AttachmentBandwidthLimit))
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Stripe prices (monthly/yearly): %s\n", prices)
|
fmt.Fprintf(c.App.ErrWriter, "- Stripe prices (monthly/yearly): %s\n", prices)
|
||||||
}
|
}
|
||||||
|
|||||||
37
cmd/user.go
@@ -42,7 +42,7 @@ var cmdUser = &cli.Command{
|
|||||||
Name: "add",
|
Name: "add",
|
||||||
Aliases: []string{"a"},
|
Aliases: []string{"a"},
|
||||||
Usage: "Adds a new user",
|
Usage: "Adds a new user",
|
||||||
UsageText: "ntfy user add [--role=admin|user] USERNAME\nNTFY_PASSWORD=... ntfy user add [--role=admin|user] USERNAME",
|
UsageText: "ntfy user add [--role=admin|user] USERNAME\nNTFY_PASSWORD=... ntfy user add [--role=admin|user] USERNAME\nNTFY_PASSWORD_HASH=... ntfy user add [--role=admin|user] USERNAME",
|
||||||
Action: execUserAdd,
|
Action: execUserAdd,
|
||||||
Flags: []cli.Flag{
|
Flags: []cli.Flag{
|
||||||
&cli.StringFlag{Name: "role", Aliases: []string{"r"}, Value: string(user.RoleUser), Usage: "user role"},
|
&cli.StringFlag{Name: "role", Aliases: []string{"r"}, Value: string(user.RoleUser), Usage: "user role"},
|
||||||
@@ -55,12 +55,13 @@ granted otherwise by the auth-default-access setting). An admin user has read an
|
|||||||
topics.
|
topics.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
ntfy user add phil # Add regular user phil
|
ntfy user add phil # Add regular user phil
|
||||||
ntfy user add --role=admin phil # Add admin user phil
|
ntfy user add --role=admin phil # Add admin user phil
|
||||||
NTFY_PASSWORD=... ntfy user add phil # Add user, using env variable to set password (for scripts)
|
NTFY_PASSWORD=... ntfy user add phil # Add user, using env variable to set password (for scripts)
|
||||||
|
NTFY_PASSWORD_HASH=... ntfy user add phil # Add user, using env variable to set password hash (for scripts)
|
||||||
|
|
||||||
You may set the NTFY_PASSWORD environment variable to pass the password. This is useful if
|
You may set the NTFY_PASSWORD environment variable to pass the password, or NTFY_PASSWORD_HASH to pass
|
||||||
you are creating users via scripts.
|
directly the bcrypt hash. This is useful if you are creating users via scripts.
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -79,7 +80,7 @@ Example:
|
|||||||
Name: "change-pass",
|
Name: "change-pass",
|
||||||
Aliases: []string{"chp"},
|
Aliases: []string{"chp"},
|
||||||
Usage: "Changes a user's password",
|
Usage: "Changes a user's password",
|
||||||
UsageText: "ntfy user change-pass USERNAME\nNTFY_PASSWORD=... ntfy user change-pass USERNAME",
|
UsageText: "ntfy user change-pass USERNAME\nNTFY_PASSWORD=... ntfy user change-pass USERNAME\nNTFY_PASSWORD_HASH=... ntfy user change-pass USERNAME",
|
||||||
Action: execUserChangePass,
|
Action: execUserChangePass,
|
||||||
Description: `Change the password for the given user.
|
Description: `Change the password for the given user.
|
||||||
|
|
||||||
@@ -89,9 +90,10 @@ it twice.
|
|||||||
Example:
|
Example:
|
||||||
ntfy user change-pass phil
|
ntfy user change-pass phil
|
||||||
NTFY_PASSWORD=.. ntfy user change-pass phil
|
NTFY_PASSWORD=.. ntfy user change-pass phil
|
||||||
|
NTFY_PASSWORD_HASH=.. ntfy user change-pass phil
|
||||||
|
|
||||||
You may set the NTFY_PASSWORD environment variable to pass the new password. This is
|
You may set the NTFY_PASSWORD environment variable to pass the new password or NTFY_PASSWORD_HASH to pass
|
||||||
useful if you are updating users via scripts.
|
directly the bcrypt hash. This is useful if you are updating users via scripts.
|
||||||
|
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
@@ -174,7 +176,12 @@ variable to pass the new password. This is useful if you are creating/updating u
|
|||||||
func execUserAdd(c *cli.Context) error {
|
func execUserAdd(c *cli.Context) error {
|
||||||
username := c.Args().Get(0)
|
username := c.Args().Get(0)
|
||||||
role := user.Role(c.String("role"))
|
role := user.Role(c.String("role"))
|
||||||
password := os.Getenv("NTFY_PASSWORD")
|
password, hashed := os.LookupEnv("NTFY_PASSWORD_HASH")
|
||||||
|
|
||||||
|
if !hashed {
|
||||||
|
password = os.Getenv("NTFY_PASSWORD")
|
||||||
|
}
|
||||||
|
|
||||||
if username == "" {
|
if username == "" {
|
||||||
return errors.New("username expected, type 'ntfy user add --help' for help")
|
return errors.New("username expected, type 'ntfy user add --help' for help")
|
||||||
} else if username == userEveryone || username == user.Everyone {
|
} else if username == userEveryone || username == user.Everyone {
|
||||||
@@ -200,7 +207,7 @@ func execUserAdd(c *cli.Context) error {
|
|||||||
}
|
}
|
||||||
password = p
|
password = p
|
||||||
}
|
}
|
||||||
if err := manager.AddUser(username, password, role); 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.ErrWriter, "user %s added with role %s\n", username, role)
|
||||||
@@ -230,7 +237,11 @@ func execUserDel(c *cli.Context) error {
|
|||||||
|
|
||||||
func execUserChangePass(c *cli.Context) error {
|
func execUserChangePass(c *cli.Context) error {
|
||||||
username := c.Args().Get(0)
|
username := c.Args().Get(0)
|
||||||
password := os.Getenv("NTFY_PASSWORD")
|
password, hashed := os.LookupEnv("NTFY_PASSWORD_HASH")
|
||||||
|
|
||||||
|
if !hashed {
|
||||||
|
password = os.Getenv("NTFY_PASSWORD")
|
||||||
|
}
|
||||||
if username == "" {
|
if username == "" {
|
||||||
return errors.New("username expected, type 'ntfy user change-pass --help' for help")
|
return errors.New("username expected, type 'ntfy user change-pass --help' for help")
|
||||||
} else if username == userEveryone || username == user.Everyone {
|
} else if username == userEveryone || username == user.Everyone {
|
||||||
@@ -249,7 +260,7 @@ func execUserChangePass(c *cli.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := manager.ChangePassword(username, password); 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.ErrWriter, "changed password for user %s\n", username)
|
||||||
|
|||||||
@@ -4,9 +4,16 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/SherClockHolmes/webpush-go"
|
"github.com/SherClockHolmes/webpush-go"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
|
"github.com/urfave/cli/v2/altsrc"
|
||||||
|
)
|
||||||
|
|
||||||
|
var flagsWebPush = append(
|
||||||
|
[]cli.Flag{},
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "output-file", Aliases: []string{"f"}, Usage: "write VAPID keys to this file"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -26,6 +33,7 @@ var cmdWebPush = &cli.Command{
|
|||||||
Usage: "Generate VAPID keys to enable browser background push notifications",
|
Usage: "Generate VAPID keys to enable browser background push notifications",
|
||||||
UsageText: "ntfy webpush keys",
|
UsageText: "ntfy webpush keys",
|
||||||
Category: categoryServer,
|
Category: categoryServer,
|
||||||
|
Flags: flagsWebPush,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -35,7 +43,19 @@ func generateWebPushKeys(c *cli.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = fmt.Fprintf(c.App.ErrWriter, `Web Push keys generated. Add the following lines to your config file:
|
|
||||||
|
if outputFile := c.String("output-file"); outputFile != "" {
|
||||||
|
contents := fmt.Sprintf(`---
|
||||||
|
web-push-public-key: %s
|
||||||
|
web-push-private-key: %s
|
||||||
|
`, publicKey, privateKey)
|
||||||
|
err = os.WriteFile(outputFile, []byte(contents), 0660)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = fmt.Fprintf(c.App.ErrWriter, "Web Push keys written to %s.\n", outputFile)
|
||||||
|
} else {
|
||||||
|
_, err = fmt.Fprintf(c.App.ErrWriter, `Web Push keys generated. Add the following lines to your config file:
|
||||||
|
|
||||||
web-push-public-key: %s
|
web-push-public-key: %s
|
||||||
web-push-private-key: %s
|
web-push-private-key: %s
|
||||||
@@ -44,5 +64,6 @@ web-push-email-address: <email address>
|
|||||||
|
|
||||||
See https://ntfy.sh/docs/config/#web-push for details.
|
See https://ntfy.sh/docs/config/#web-push for details.
|
||||||
`, publicKey, privateKey)
|
`, publicKey, privateKey)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ func TestCLI_WebPush_GenerateKeys(t *testing.T) {
|
|||||||
require.Contains(t, stderr.String(), "Web Push keys generated.")
|
require.Contains(t, stderr.String(), "Web Push keys generated.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCLI_WebPush_WriteKeysToFile(t *testing.T) {
|
||||||
|
app, _, _, stderr := newTestApp()
|
||||||
|
require.Nil(t, runWebPushCommand(app, server.NewConfig(), "keys", "--output-file=key-file.yaml"))
|
||||||
|
require.Contains(t, stderr.String(), "Web Push keys written to key-file.yaml")
|
||||||
|
require.FileExists(t, "key-file.yaml")
|
||||||
|
}
|
||||||
|
|
||||||
func runWebPushCommand(app *cli.App, conf *server.Config, args ...string) error {
|
func runWebPushCommand(app *cli.App, conf *server.Config, args ...string) error {
|
||||||
webPushArgs := []string{
|
webPushArgs := []string{
|
||||||
"ntfy",
|
"ntfy",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
219
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.
|
||||||
@@ -50,6 +50,7 @@ Here are a few working sample configs using a `/etc/ntfy/server.yml` file:
|
|||||||
listen-http: ":2586"
|
listen-http: ":2586"
|
||||||
cache-file: "/var/cache/ntfy/cache.db"
|
cache-file: "/var/cache/ntfy/cache.db"
|
||||||
attachment-cache-dir: "/var/cache/ntfy/attachments"
|
attachment-cache-dir: "/var/cache/ntfy/attachments"
|
||||||
|
behind-proxy: true
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "server.yml (ntfy.sh config)"
|
=== "server.yml (ntfy.sh config)"
|
||||||
@@ -78,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
|
||||||
@@ -100,7 +100,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
|
||||||
@@ -294,7 +293,7 @@ want to use a dedicated token to publish from your backup host, and one from you
|
|||||||
but not yet implemented.
|
but not yet implemented.
|
||||||
|
|
||||||
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 20 tokens (hardcoded).
|
automatically (or never expire). Each user can have up to 60 tokens (hardcoded).
|
||||||
|
|
||||||
**Example commands** (type `ntfy token --help` or `ntfy token COMMAND --help` for more details):
|
**Example commands** (type `ntfy token --help` or `ntfy token COMMAND --help` for more details):
|
||||||
```
|
```
|
||||||
@@ -404,10 +403,10 @@ with the given username/password. Be sure to use HTTPS to avoid eavesdropping an
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Example: UnifiedPush
|
### Example: UnifiedPush
|
||||||
[UnifiedPush](https://unifiedpush.org) requires that the [application server](https://unifiedpush.org/spec/definitions/#application-server) (e.g. Synapse, Fediverse Server, …)
|
[UnifiedPush](https://unifiedpush.org) requires that the [application server](https://unifiedpush.org/developers/spec/definitions/#application-server) (e.g. Synapse, Fediverse Server, …)
|
||||||
has anonymous write access to the [topic](https://unifiedpush.org/spec/definitions/#endpoint) used for push messages.
|
has anonymous write access to the [topic](https://unifiedpush.org/developers/spec/definitions/#endpoint) used for push messages.
|
||||||
The topic names used by UnifiedPush all start with the `up*` prefix. Please refer to the
|
The topic names used by UnifiedPush all start with the `up*` prefix. Please refer to the
|
||||||
**[UnifiedPush documentation](https://unifiedpush.org/users/distributors/ntfy/#limit-access-to-some-users)** for more details.
|
**[UnifiedPush documentation](https://unifiedpush.org/users/distributors/ntfy/#limit-access-to-some-users-acl)** for more details.
|
||||||
|
|
||||||
To enable support for UnifiedPush for private servers (i.e. `auth-default-access: "deny-all"`), you should either
|
To enable support for UnifiedPush for private servers (i.e. `auth-default-access: "deny-all"`), you should either
|
||||||
allow anonymous write access for the entire prefix or explicitly per topic:
|
allow anonymous write access for the entire prefix or explicitly per topic:
|
||||||
@@ -552,17 +551,91 @@ It may be desirable to run ntfy behind a proxy (e.g. nginx, HAproxy or Apache),
|
|||||||
using Let's Encrypt using certbot, or simply because you'd like to share the ports (80/443) with other services.
|
using Let's Encrypt using certbot, or simply because you'd like to share the ports (80/443) with other services.
|
||||||
Whatever your reasons may be, there are a few things to consider.
|
Whatever your reasons may be, there are a few things to consider.
|
||||||
|
|
||||||
|
### IP-based rate limiting
|
||||||
If you are running ntfy behind a proxy, you should set the `behind-proxy` flag. This will instruct the
|
If you are running ntfy behind a proxy, you should set the `behind-proxy` flag. This will instruct the
|
||||||
[rate limiting](#rate-limiting) logic to use the `X-Forwarded-For` header as the primary identifier for a visitor,
|
[rate limiting](#rate-limiting) logic to use the header configured in `proxy-forwarded-header` (default is `X-Forwarded-For`)
|
||||||
as opposed to the remote IP address. If the `behind-proxy` flag is not set, all visitors will
|
as the primary identifier for a visitor, as opposed to the remote IP address.
|
||||||
be counted as one, because from the perspective of the ntfy server, they all share the proxy's IP address.
|
|
||||||
|
|
||||||
=== "/etc/ntfy/server.yml"
|
If the `behind-proxy` flag is not set, all visitors will be counted as one, because from the perspective of the
|
||||||
|
ntfy server, they all share the proxy's IP address.
|
||||||
|
|
||||||
|
Relevant flags to consider:
|
||||||
|
|
||||||
|
* `behind-proxy` makes it so that the real visitor IP address is extracted from the header defined in `proxy-forwarded-header`.
|
||||||
|
Without this, the remote address of the incoming connection is used (default: `false`).
|
||||||
|
* `proxy-forwarded-header` is the header to use to identify visitors (default: `X-Forwarded-For`). It may be a single IP address (e.g. `1.2.3.4`),
|
||||||
|
a comma-separated list of IP addresses (e.g. `1.2.3.4, 5.6.7.8`), or an [RFC 7239](https://datatracker.ietf.org/doc/html/rfc7239)-style
|
||||||
|
header (e.g. `for=1.2.3.4;by=proxy.example.com, for=5.6.7.8`).
|
||||||
|
* `proxy-trusted-hosts` is a comma-separated list of IP addresses, hosts or CIDRs that are removed from the forwarded header
|
||||||
|
to determine the real IP address. This is only useful if there are multiple proxies involved that add themselves to
|
||||||
|
the forwarded header (default: empty).
|
||||||
|
* `visitor-prefix-bits-ipv4` is the number of bits of the IPv4 address to use for rate limiting (default is `32`, which is the entire
|
||||||
|
IP address). In IPv4 environments, by default, a visitor's **full IPv4 address** is used as-is for rate limiting. This means that
|
||||||
|
if someone publishes messages from multiple IP addresses, they will be counted as separate visitors. You can adjust this by setting the `visitor-prefix-bits-ipv4` config option. To group visitors in a /24 subnet and count them as one, for instance,
|
||||||
|
set it to `24`. In that case, `1.2.3.4` and `1.2.3.99` are treated as the same visitor.
|
||||||
|
* `visitor-prefix-bits-ipv6` is the number of bits of the IPv6 address to use for rate limiting (default is `64`, which is a /64 subnet).
|
||||||
|
In IPv6 environments, by default, a visitor's IP address is **truncated to the /64 subnet**, meaning that `2001:db8:25:86:1::1` and
|
||||||
|
`2001:db8:25:86:2::1` are treated as the same visitor. Use the `visitor-prefix-bits-ipv6` config option to adjust this behavior.
|
||||||
|
See [IPv6 considerations](#ipv6-considerations) for more details.
|
||||||
|
|
||||||
|
=== "/etc/ntfy/server.yml (behind a proxy)"
|
||||||
``` yaml
|
``` yaml
|
||||||
# Tell ntfy to use "X-Forwarded-For" to identify visitors
|
# Tell ntfy to use "X-Forwarded-For" header to identify visitors for rate limiting
|
||||||
|
#
|
||||||
|
# Example: If "X-Forwarded-For: 9.9.9.9, 1.2.3.4" is set,
|
||||||
|
# the visitor IP will be 1.2.3.4 (right-most address).
|
||||||
|
#
|
||||||
behind-proxy: true
|
behind-proxy: true
|
||||||
```
|
```
|
||||||
|
|
||||||
|
=== "/etc/ntfy/server.yml (X-Client-IP header)"
|
||||||
|
``` yaml
|
||||||
|
# Tell ntfy to use "X-Client-IP" header to identify visitors for rate limiting
|
||||||
|
#
|
||||||
|
# Example: If "X-Client-IP: 9.9.9.9" is set,
|
||||||
|
# the visitor IP will be 9.9.9.9.
|
||||||
|
#
|
||||||
|
behind-proxy: true
|
||||||
|
proxy-forwarded-header: "X-Client-IP"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "/etc/ntfy/server.yml (Forwarded header)"
|
||||||
|
``` yaml
|
||||||
|
# Tell ntfy to use "Forwarded" header (RFC 7239) to identify visitors for rate limiting
|
||||||
|
#
|
||||||
|
# Example: If "Forwarded: for=1.2.3.4;by=proxy.example.com, for=9.9.9.9" is set,
|
||||||
|
# the visitor IP will be 9.9.9.9.
|
||||||
|
#
|
||||||
|
behind-proxy: true
|
||||||
|
proxy-forwarded-header: "Forwarded"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "/etc/ntfy/server.yml (multiple proxies)"
|
||||||
|
``` yaml
|
||||||
|
# Tell ntfy to use "X-Forwarded-For" header to identify visitors for rate limiting,
|
||||||
|
# and to strip the IP addresses of the proxies 1.2.3.4 and 1.2.3.5
|
||||||
|
#
|
||||||
|
# Example: If "X-Forwarded-For: 9.9.9.9, 1.2.3.4" is set,
|
||||||
|
# the visitor IP will be 9.9.9.9 (right-most unknown address).
|
||||||
|
#
|
||||||
|
behind-proxy: true
|
||||||
|
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
|
||||||
ntfy supports HTTPS/TLS by setting the `listen-https` [config option](#config-options). However, if you
|
ntfy supports HTTPS/TLS by setting the `listen-https` [config option](#config-options). However, if you
|
||||||
are behind a proxy, it is recommended that TLS/SSL termination is done by the proxy itself (see below).
|
are behind a proxy, it is recommended that TLS/SSL termination is done by the proxy itself (see below).
|
||||||
@@ -631,7 +704,7 @@ or the root domain:
|
|||||||
listen 443 ssl http2;
|
listen 443 ssl http2;
|
||||||
server_name ntfy.sh;
|
server_name ntfy.sh;
|
||||||
|
|
||||||
# See https://ssl-config.mozilla.org/#server=nginx&version=1.18.0&config=intermediate&openssl=1.1.1k&hsts=false&ocsp=false&guideline=5.6see https://ssl-config.mozilla.org/#server=nginx&version=1.18.0&config=intermediate&openssl=1.1.1k&hsts=false&ocsp=false&guideline=5.6
|
# See https://ssl-config.mozilla.org/#server=nginx&version=1.18.0&config=intermediate&openssl=1.1.1k&hsts=false&ocsp=false&guideline=5.6
|
||||||
ssl_session_timeout 1d;
|
ssl_session_timeout 1d;
|
||||||
ssl_session_cache shared:MozSSL:10m; # about 40000 sessions
|
ssl_session_cache shared:MozSSL:10m; # about 40000 sessions
|
||||||
ssl_session_tickets off;
|
ssl_session_tickets off;
|
||||||
@@ -698,7 +771,7 @@ or the root domain:
|
|||||||
listen 443 ssl http2;
|
listen 443 ssl http2;
|
||||||
server_name ntfy.sh;
|
server_name ntfy.sh;
|
||||||
|
|
||||||
# See https://ssl-config.mozilla.org/#server=nginx&version=1.18.0&config=intermediate&openssl=1.1.1k&hsts=false&ocsp=false&guideline=5.6see https://ssl-config.mozilla.org/#server=nginx&version=1.18.0&config=intermediate&openssl=1.1.1k&hsts=false&ocsp=false&guideline=5.6
|
# See https://ssl-config.mozilla.org/#server=nginx&version=1.18.0&config=intermediate&openssl=1.1.1k&hsts=false&ocsp=false&guideline=5.6
|
||||||
ssl_session_timeout 1d;
|
ssl_session_timeout 1d;
|
||||||
ssl_session_cache shared:MozSSL:10m; # about 40000 sessions
|
ssl_session_cache shared:MozSSL:10m; # about 40000 sessions
|
||||||
ssl_session_tickets off;
|
ssl_session_tickets off;
|
||||||
@@ -777,6 +850,7 @@ or the root domain:
|
|||||||
```
|
```
|
||||||
# Note that this config is most certainly incomplete. Please help out and let me know what's missing
|
# Note that this config is most certainly incomplete. Please help out and let me know what's missing
|
||||||
# via Discord/Matrix or in a GitHub issue.
|
# via Discord/Matrix or in a GitHub issue.
|
||||||
|
# Note: Caddy automatically handles both HTTP and WebSockets with reverse_proxy
|
||||||
|
|
||||||
ntfy.sh, http://nfty.sh {
|
ntfy.sh, http://nfty.sh {
|
||||||
reverse_proxy 127.0.0.1:2586
|
reverse_proxy 127.0.0.1:2586
|
||||||
@@ -864,7 +938,7 @@ it'll show `New message` as a popup.
|
|||||||
## Web Push
|
## Web Push
|
||||||
[Web Push](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) ([RFC8030](https://datatracker.ietf.org/doc/html/rfc8030))
|
[Web Push](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) ([RFC8030](https://datatracker.ietf.org/doc/html/rfc8030))
|
||||||
allows ntfy to receive push notifications, even when the ntfy web app (or even the browser, depending on the platform) is closed.
|
allows ntfy to receive push notifications, even when the ntfy web app (or even the browser, depending on the platform) is closed.
|
||||||
When enabled, the user can enable **background notifications** for their topics in the wep app under Settings. Once enabled by the
|
When enabled, the user can enable **background notifications** for their topics in the web app under Settings. Once enabled by the
|
||||||
user, ntfy will forward published messages to the push endpoint (browser-provided, e.g. fcm.googleapis.com), which will then
|
user, ntfy will forward published messages to the push endpoint (browser-provided, e.g. fcm.googleapis.com), which will then
|
||||||
forward it to the browser.
|
forward it to the browser.
|
||||||
|
|
||||||
@@ -875,7 +949,9 @@ a database to keep track of the browser's subscriptions, and an admin email addr
|
|||||||
- `web-push-private-key` is the generated VAPID private key, e.g. AA2BB1234567890abcdefzxcvbnm1234567890
|
- `web-push-private-key` is the generated VAPID private key, e.g. AA2BB1234567890abcdefzxcvbnm1234567890
|
||||||
- `web-push-file` is a database file to keep track of browser subscription endpoints, e.g. `/var/cache/ntfy/webpush.db`
|
- `web-push-file` is a database file to keep track of browser subscription endpoints, e.g. `/var/cache/ntfy/webpush.db`
|
||||||
- `web-push-email-address` is the admin email address send to the push provider, e.g. `sysadmin@example.com`
|
- `web-push-email-address` is the admin email address send to the push provider, e.g. `sysadmin@example.com`
|
||||||
- `web-push-startup-queries` is an optional list of queries to run on startup`
|
- `web-push-startup-queries` is an optional list of queries to run on startup`
|
||||||
|
- `web-push-expiry-warning-duration` defines the duration after which unused subscriptions are sent a warning (default is `55d`)
|
||||||
|
- `web-push-expiry-duration` defines the duration after which unused subscriptions will expire (default is `60d`)
|
||||||
|
|
||||||
Limitations:
|
Limitations:
|
||||||
|
|
||||||
@@ -902,8 +978,8 @@ web-push-file: /var/cache/ntfy/webpush.db
|
|||||||
web-push-email-address: sysadmin@example.com
|
web-push-email-address: sysadmin@example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
The `web-push-file` is used to store the push subscriptions. Unused subscriptions will send out a warning after 7 days,
|
The `web-push-file` is used to store the push subscriptions. Unused subscriptions will send out a warning after 55 days,
|
||||||
and will automatically expire after 9 days (not configurable). If the gateway returns an error (e.g. 410 Gone when a user has unsubscribed),
|
and will automatically expire after 60 days (default). If the gateway returns an error (e.g. 410 Gone when a user has unsubscribed),
|
||||||
subscriptions are also removed automatically.
|
subscriptions are also removed automatically.
|
||||||
|
|
||||||
The web app refreshes subscriptions on start and regularly on an interval, but this file should be persisted across restarts. If the subscription
|
The web app refreshes subscriptions on start and regularly on an interval, but this file should be persisted across restarts. If the subscription
|
||||||
@@ -995,6 +1071,15 @@ are the easiest), and then configure the following options:
|
|||||||
After you have configured phone calls, create a [tier](#tiers) with a call limit (e.g. `ntfy tier create --call-limit=10 ...`),
|
After you have configured phone calls, create a [tier](#tiers) with a call limit (e.g. `ntfy tier create --call-limit=10 ...`),
|
||||||
and then assign it to a user. Users may then use the `X-Call` header to receive a phone call when publishing a message.
|
and then assign it to a user. Users may then use the `X-Call` header to receive a phone call when publishing a message.
|
||||||
|
|
||||||
|
## Message limits
|
||||||
|
There are a few message limits that you can configure:
|
||||||
|
|
||||||
|
* `message-size-limit` defines the max size of a message body. Please note message sizes >4K are **not recommended,
|
||||||
|
and largely untested**. The Android/iOS and other clients may not work, or work properly. If FCM and/or APNS is used,
|
||||||
|
the limit should stay 4K, because their limits are around that size. If you increase this size limit regardless,
|
||||||
|
FCM and APNS will NOT work for large messages.
|
||||||
|
* `message-delay-limit` defines the max delay of a message when using the "Delay" header and [scheduled delivery](publish.md#scheduled-delivery).
|
||||||
|
|
||||||
## Rate limiting
|
## Rate limiting
|
||||||
!!! info
|
!!! info
|
||||||
Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag.
|
Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag.
|
||||||
@@ -1073,6 +1158,18 @@ If this ever happens, there will be a log message that looks something like this
|
|||||||
WARN Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor
|
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
|
||||||
@@ -1092,8 +1189,8 @@ response if no "rate visitor" has been previously registered. This is to avoid b
|
|||||||
To enable subscriber-based rate limiting, set `visitor-subscriber-rate-limiting: true`.
|
To enable subscriber-based rate limiting, set `visitor-subscriber-rate-limiting: true`.
|
||||||
|
|
||||||
!!! info
|
!!! info
|
||||||
Due to a denial-of-service issue, support for the `Rate-Topics` header was removed entirely. This is unfortunate,
|
Due to a [denial-of-service issue](https://github.com/binwiederhier/ntfy/issues/1048), support for the `Rate-Topics`
|
||||||
but subscriber-based rate limiting will still work for `up*` topics.
|
header was removed entirely. This is unfortunate, but subscriber-based rate limiting will still work for `up*` topics.
|
||||||
|
|
||||||
## Tuning for scale
|
## Tuning for scale
|
||||||
If you're running ntfy for your home server, you probably don't need to worry about scale at all. In its default config,
|
If you're running ntfy for your home server, you probably don't need to worry about scale at all. In its default config,
|
||||||
@@ -1233,6 +1330,29 @@ and [here](https://easyengine.io/tutorials/nginx/block-wp-login-php-bruteforce-a
|
|||||||
maxretry = 10
|
maxretry = 10
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Note that if you run nginx in a container, append `, chain=DOCKER-USER` to the jail.local action. By default, the jail action chain
|
||||||
|
is `INPUT`, but `FORWARD` is used when using docker networks. `DOCKER-USER`, available when using docker, is part of the `FORWARD`
|
||||||
|
chain.
|
||||||
|
|
||||||
|
The official ntfy.sh server uses fail2ban to ban IPs. Check out ntfy.sh's [Ansible fail2ban role](https://github.com/binwiederhier/ntfy-ansible/tree/main/roles/fail2ban) for details. Ban actors are banned for 1 hour initially, and up to
|
||||||
|
4 hours at a time for repeated offenses. IPv4 addresses are banned individually, while IPv6 addresses are banned by their `/56` prefix.
|
||||||
|
|
||||||
|
## IPv6 support
|
||||||
|
ntfy fully supports IPv6, though there are a few things to keep in mind.
|
||||||
|
|
||||||
|
- **Listening on an IPv6 address**: By default, ntfy listens on `:80` (IPv4-only). If you want to listen on an IPv6 address, you need to
|
||||||
|
explicitly set the `listen-http` and/or `listen-https` options in your `server.yml` file to an IPv6 address, e.g. `[::]:80`. To listen on
|
||||||
|
IPv4 and IPv6, you must run ntfy behind a reverse proxy, e.g. `listen :80; listen [::]:80;` in nginx.
|
||||||
|
- **Rate limiting:** By default, ntfy uses the `/64` subnet of the visitor's IPv6 address for rate limiting. This means that all visitors in the same `/64`
|
||||||
|
subnet are treated as one visitor. If you want to change this, you can set the `visitor-prefix-bits-ipv6` option in your `server.yml` file to a different
|
||||||
|
value (e.g. `48` for `/48` subnets). See [IPv6 considerations](#ipv6-considerations) and [IP-based rate limiting](#ip-based-rate-limiting) for more details.
|
||||||
|
- **Banning IPs with fail2ban:** By default, if you're using the `iptables-multiport` action, fail2ban bans individual IPv4 and IPv6 addresses via `iptables` and `ip6tables`. While this behavior is fine for IPv4, it is not for IPv6, because every host can technically have up to 2^64 addresses. Please ensure that your `actionban` and `actionunban` commands
|
||||||
|
support IPv6 and also ban the entire prefix (e.g. `/48`). See [Banning bad actors](#banning-bad-actors-fail2ban) for details.
|
||||||
|
|
||||||
|
!!! info
|
||||||
|
The official ntfy.sh server supports IPv6. Check out ntfy.sh's [Ansible repository](https://github.com/binwiederhier/ntfy-ansible) for examples of how to
|
||||||
|
configure [ntfy](https://github.com/binwiederhier/ntfy-ansible/tree/main/roles/ntfy), [nginx](https://github.com/binwiederhier/ntfy-ansible/tree/main/roles/nginx) and [fail2ban](https://github.com/binwiederhier/ntfy-ansible/tree/main/roles/fail2ban).
|
||||||
|
|
||||||
## Health checks
|
## 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.
|
||||||
@@ -1365,15 +1485,17 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
|||||||
| `listen-unix-mode` | `NTFY_LISTEN_UNIX_MODE` | *file mode* | *system default* | File mode of the Unix socket, e.g. 0700 or 0777 |
|
| `listen-unix-mode` | `NTFY_LISTEN_UNIX_MODE` | *file mode* | *system default* | File mode of the Unix socket, e.g. 0700 or 0777 |
|
||||||
| `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. |
|
| `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. |
|
||||||
| `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. |
|
| `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. |
|
||||||
| `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM](#firebase-fcm). |
|
| `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM)](#firebase-fcm). |
|
||||||
| `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache). |
|
| `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache). |
|
||||||
| `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely. |
|
| `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely. |
|
||||||
| `cache-startup-queries` | `NTFY_CACHE_STARTUP_QUERIES` | *string (SQL queries)* | - | SQL queries to run during database startup; this is useful for tuning and [enabling WAL mode](#wal-for-message-cache) |
|
| `cache-startup-queries` | `NTFY_CACHE_STARTUP_QUERIES` | *string (SQL queries)* | - | SQL queries to run during database startup; this is useful for tuning and [enabling WAL mode](#message-cache) |
|
||||||
| `cache-batch-size` | `NTFY_CACHE_BATCH_SIZE` | *int* | 0 | Max size of messages to batch together when writing to message cache (if zero, writes are synchronous) |
|
| `cache-batch-size` | `NTFY_CACHE_BATCH_SIZE` | *int* | 0 | Max size of messages to batch together when writing to message cache (if zero, writes are synchronous) |
|
||||||
| `cache-batch-timeout` | `NTFY_CACHE_BATCH_TIMEOUT` | *duration* | 0s | Timeout for batched async writes to the message cache (if zero, writes are synchronous) |
|
| `cache-batch-timeout` | `NTFY_CACHE_BATCH_TIMEOUT` | *duration* | 0s | Timeout for batched async writes to the message cache (if zero, writes are synchronous) |
|
||||||
| `auth-file` | `NTFY_AUTH_FILE` | *filename* | - | Auth database file used for access control. If set, enables authentication and access control. See [access control](#access-control). |
|
| `auth-file` | `NTFY_AUTH_FILE` | *filename* | - | Auth database file used for access control. If set, enables authentication and access control. See [access control](#access-control). |
|
||||||
| `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, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection. |
|
| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting) |
|
||||||
|
| `proxy-forwarded-header` | `NTFY_PROXY_FORWARDED_HEADER` | *string* | `X-Forwarded-For` | Use specified header to determine visitor IP address (for rate limiting) |
|
||||||
|
| `proxy-trusted-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. |
|
||||||
@@ -1391,6 +1513,8 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
|||||||
| `twilio-verify-service` | `NTFY_TWILIO_VERIFY_SERVICE` | *string* | - | Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 |
|
| `twilio-verify-service` | `NTFY_TWILIO_VERIFY_SERVICE` | *string* | - | Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 |
|
||||||
| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 45s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. |
|
| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 45s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. |
|
||||||
| `manager-interval` | `NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. |
|
| `manager-interval` | `NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. |
|
||||||
|
| `message-size-limit` | `NTFY_MESSAGE_SIZE_LIMIT` | *size* | 4K | The size limit for the message body. Please note that this is largely untested, and that FCM/APNS have limits around 4KB. If you increase this size limit, FCM and APNS will NOT work for large messages. |
|
||||||
|
| `message-delay-limit` | `NTFY_MESSAGE_DELAY_LIMIT` | *duration* | 3d | Amount of time a message can be [scheduled](publish.md#scheduled-delivery) into the future when using the `Delay` header |
|
||||||
| `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. |
|
| `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. |
|
||||||
| `upstream-base-url` | `NTFY_UPSTREAM_BASE_URL` | *URL* | `https://ntfy.sh` | Forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers |
|
| `upstream-base-url` | `NTFY_UPSTREAM_BASE_URL` | *URL* | `https://ntfy.sh` | Forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers |
|
||||||
| `upstream-access-token` | `NTFY_UPSTREAM_ACCESS_TOKEN` | *string* | `tk_zyYLYj...` | Access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth |
|
| `upstream-access-token` | `NTFY_UPSTREAM_ACCESS_TOKEN` | *string* | `tk_zyYLYj...` | Access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth |
|
||||||
@@ -1401,9 +1525,11 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
|||||||
| `visitor-message-daily-limit` | `NTFY_VISITOR_MESSAGE_DAILY_LIMIT` | *number* | - | Rate limiting: Allowed number of messages per day per visitor, reset every day at midnight (UTC). By default, this value is unset. |
|
| `visitor-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 |
|
||||||
@@ -1416,8 +1542,13 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
|||||||
| `web-push-file` | `NTFY_WEB_PUSH_FILE` | *string* | - | Web Push: Database file that stores subscriptions |
|
| `web-push-file` | `NTFY_WEB_PUSH_FILE` | *string* | - | Web Push: Database file that stores subscriptions |
|
||||||
| `web-push-email-address` | `NTFY_WEB_PUSH_EMAIL_ADDRESS` | *string* | - | Web Push: Sender email address |
|
| `web-push-email-address` | `NTFY_WEB_PUSH_EMAIL_ADDRESS` | *string* | - | Web Push: Sender email address |
|
||||||
| `web-push-startup-queries` | `NTFY_WEB_PUSH_STARTUP_QUERIES` | *string* | - | Web Push: SQL queries to run against subscription database at startup |
|
| `web-push-startup-queries` | `NTFY_WEB_PUSH_STARTUP_QUERIES` | *string* | - | Web Push: SQL queries to run against subscription database at startup |
|
||||||
|
| `web-push-expiry-duration` | `NTFY_WEB_PUSH_EXPIRY_DURATION` | *duration* | 60d | Web Push: Duration after which a subscription is considered stale and will be deleted. This is to prevent stale subscriptions. |
|
||||||
|
| `web-push-expiry-warning-duration` | `NTFY_WEB_PUSH_EXPIRY_WARNING_DURATION` | *duration* | 55d | Web Push: Duration after which a warning is sent to subscribers that their subscription will expire soon. This is to prevent stale subscriptions. |
|
||||||
|
| `log-format` | `NTFY_LOG_FORMAT` | *string* | `text` | Defines the output format, can be text or json |
|
||||||
|
| `log-file` | `NTFY_LOG_FILE` | *string* | - | Defines the filename to write logs to. If this is not set, ntfy logs to stderr |
|
||||||
|
| `log-level` | `NTFY_LOG_LEVEL` | *string* | `info` | Defines the default log level, can be one of trace, debug, info, warn or error |
|
||||||
|
|
||||||
The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.
|
The format for a *duration* is: `<number>(smhd)`, e.g. 30s, 20m, 1h or 3d.
|
||||||
The format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k.
|
The format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k.
|
||||||
|
|
||||||
## Command line options
|
## Command line options
|
||||||
@@ -1449,7 +1580,7 @@ OPTIONS:
|
|||||||
--log-level-overrides value, --log_level_overrides value [ --log-level-overrides value, --log_level_overrides value ] set log level overrides [$NTFY_LOG_LEVEL_OVERRIDES]
|
--log-level-overrides value, --log_level_overrides value [ --log-level-overrides value, --log_level_overrides value ] set log level overrides [$NTFY_LOG_LEVEL_OVERRIDES]
|
||||||
--log-format value, --log_format value set log format (default: "text") [$NTFY_LOG_FORMAT]
|
--log-format value, --log_format value set log format (default: "text") [$NTFY_LOG_FORMAT]
|
||||||
--log-file value, --log_file value set log file, default is STDOUT [$NTFY_LOG_FILE]
|
--log-file value, --log_file value set log file, default is STDOUT [$NTFY_LOG_FILE]
|
||||||
--config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
|
--config value, -c value config file (default: "/etc/ntfy/server.yml") [$NTFY_CONFIG_FILE]
|
||||||
--base-url value, --base_url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
|
--base-url value, --base_url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
|
||||||
--listen-http value, --listen_http value, -l value ip:port used as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
|
--listen-http value, --listen_http value, -l value ip:port used as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
|
||||||
--listen-https value, --listen_https value, -L value ip:port used as HTTPS listen address [$NTFY_LISTEN_HTTPS]
|
--listen-https value, --listen_https value, -L value ip:port used as HTTPS listen address [$NTFY_LISTEN_HTTPS]
|
||||||
@@ -1459,19 +1590,19 @@ OPTIONS:
|
|||||||
--cert-file value, --cert_file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE]
|
--cert-file value, --cert_file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE]
|
||||||
--firebase-key-file value, --firebase_key_file value, -F value Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE]
|
--firebase-key-file value, --firebase_key_file value, -F value Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE]
|
||||||
--cache-file value, --cache_file value, -C value cache file used for message caching [$NTFY_CACHE_FILE]
|
--cache-file value, --cache_file value, -C value cache file used for message caching [$NTFY_CACHE_FILE]
|
||||||
--cache-duration since, --cache_duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION]
|
--cache-duration since, --cache_duration since, -b since buffer messages for this time to allow since requests (default: "12h") [$NTFY_CACHE_DURATION]
|
||||||
--cache-batch-size value, --cache_batch_size value max size of messages to batch together when writing to message cache (if zero, writes are synchronous) (default: 0) [$NTFY_BATCH_SIZE]
|
--cache-batch-size value, --cache_batch_size value max size of messages to batch together when writing to message cache (if zero, writes are synchronous) (default: 0) [$NTFY_BATCH_SIZE]
|
||||||
--cache-batch-timeout value, --cache_batch_timeout value timeout for batched async writes to the message cache (if zero, writes are synchronous) (default: 0s) [$NTFY_CACHE_BATCH_TIMEOUT]
|
--cache-batch-timeout value, --cache_batch_timeout value timeout for batched async writes to the message cache (if zero, writes are synchronous) (default: "0s") [$NTFY_CACHE_BATCH_TIMEOUT]
|
||||||
--cache-startup-queries value, --cache_startup_queries value queries run when the cache database is initialized [$NTFY_CACHE_STARTUP_QUERIES]
|
--cache-startup-queries value, --cache_startup_queries value queries run when the cache database is initialized [$NTFY_CACHE_STARTUP_QUERIES]
|
||||||
--auth-file value, --auth_file value, -H value auth database file used for access control [$NTFY_AUTH_FILE]
|
--auth-file value, --auth_file value, -H value auth database file used for access control [$NTFY_AUTH_FILE]
|
||||||
--auth-startup-queries value, --auth_startup_queries value queries run when the auth database is initialized [$NTFY_AUTH_STARTUP_QUERIES]
|
--auth-startup-queries value, --auth_startup_queries value queries run when the auth database is initialized [$NTFY_AUTH_STARTUP_QUERIES]
|
||||||
--auth-default-access value, --auth_default_access value, -p value default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS]
|
--auth-default-access value, --auth_default_access value, -p value default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS]
|
||||||
--attachment-cache-dir value, --attachment_cache_dir value cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR]
|
--attachment-cache-dir value, --attachment_cache_dir value cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR]
|
||||||
--attachment-total-size-limit value, --attachment_total_size_limit value, -A value limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
--attachment-total-size-limit value, --attachment_total_size_limit value, -A value limit of the on-disk attachment cache (default: "5G") [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
||||||
--attachment-file-size-limit value, --attachment_file_size_limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
|
--attachment-file-size-limit value, --attachment_file_size_limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: "15M") [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
|
||||||
--attachment-expiry-duration value, --attachment_expiry_duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION]
|
--attachment-expiry-duration value, --attachment_expiry_duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: "3h") [$NTFY_ATTACHMENT_EXPIRY_DURATION]
|
||||||
--keepalive-interval value, --keepalive_interval value, -k value interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL]
|
--keepalive-interval value, --keepalive_interval value, -k value interval of keepalive messages (default: "45s") [$NTFY_KEEPALIVE_INTERVAL]
|
||||||
--manager-interval value, --manager_interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
|
--manager-interval value, --manager_interval value, -m value interval of for message pruning and stats printing (default: "1m") [$NTFY_MANAGER_INTERVAL]
|
||||||
--disallowed-topics value, --disallowed_topics value [ --disallowed-topics value, --disallowed_topics value ] topics that are not allowed to be used [$NTFY_DISALLOWED_TOPICS]
|
--disallowed-topics value, --disallowed_topics value [ --disallowed-topics value, --disallowed_topics value ] topics that are not allowed to be used [$NTFY_DISALLOWED_TOPICS]
|
||||||
--web-root value, --web_root value sets root of the web app (e.g. /, or /app), or disables it (disable) (default: "/") [$NTFY_WEB_ROOT]
|
--web-root value, --web_root value sets root of the web app (e.g. /, or /app), or disables it (disable) (default: "/") [$NTFY_WEB_ROOT]
|
||||||
--enable-signup, --enable_signup allows users to sign up via the web app, or API (default: false) [$NTFY_ENABLE_SIGNUP]
|
--enable-signup, --enable_signup allows users to sign up via the web app, or API (default: false) [$NTFY_ENABLE_SIGNUP]
|
||||||
@@ -1490,18 +1621,24 @@ OPTIONS:
|
|||||||
--twilio-auth-token value, --twilio_auth_token value Twilio auth token [$NTFY_TWILIO_AUTH_TOKEN]
|
--twilio-auth-token value, --twilio_auth_token value Twilio auth token [$NTFY_TWILIO_AUTH_TOKEN]
|
||||||
--twilio-phone-number value, --twilio_phone_number value Twilio number to use for outgoing calls [$NTFY_TWILIO_PHONE_NUMBER]
|
--twilio-phone-number value, --twilio_phone_number value Twilio number to use for outgoing calls [$NTFY_TWILIO_PHONE_NUMBER]
|
||||||
--twilio-verify-service value, --twilio_verify_service value Twilio Verify service ID, used for phone number verification [$NTFY_TWILIO_VERIFY_SERVICE]
|
--twilio-verify-service value, --twilio_verify_service value Twilio Verify service ID, used for phone number verification [$NTFY_TWILIO_VERIFY_SERVICE]
|
||||||
|
--message-size-limit value, --message_size_limit value size limit for the message (see docs for limitations) (default: "4K") [$NTFY_MESSAGE_SIZE_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]
|
||||||
--visitor-request-limit-replenish value, --visitor_request_limit_replenish value interval at which burst limit is replenished (one per x) (default: 5s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
|
--visitor-request-limit-replenish value, --visitor_request_limit_replenish value interval at which burst limit is replenished (one per x) (default: "5s") [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
|
||||||
--visitor-request-limit-exempt-hosts value, --visitor_request_limit_exempt_hosts value hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS]
|
--visitor-request-limit-exempt-hosts value, --visitor_request_limit_exempt_hosts value hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS]
|
||||||
--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: 1h0m0s) [$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]
|
||||||
@@ -1512,6 +1649,8 @@ OPTIONS:
|
|||||||
--web-push-private-key value, --web_push_private_key value private key used for web push notifications [$NTFY_WEB_PUSH_PRIVATE_KEY]
|
--web-push-private-key value, --web_push_private_key value private key used for web push notifications [$NTFY_WEB_PUSH_PRIVATE_KEY]
|
||||||
--web-push-file value, --web_push_file value file used to store web push subscriptions [$NTFY_WEB_PUSH_FILE]
|
--web-push-file value, --web_push_file value file used to store web push subscriptions [$NTFY_WEB_PUSH_FILE]
|
||||||
--web-push-email-address value, --web_push_email_address value e-mail address of sender, required to use browser push services [$NTFY_WEB_PUSH_EMAIL_ADDRESS]
|
--web-push-email-address value, --web_push_email_address value e-mail address of sender, required to use browser push services [$NTFY_WEB_PUSH_EMAIL_ADDRESS]
|
||||||
--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]
|
||||||
--help, -h show help
|
--web-push-expiry-duration value, --web_push_expiry_duration value automatically expire unused subscriptions after this time (default: "60d") [$NTFY_WEB_PUSH_EXPIRY_DURATION]
|
||||||
|
--web-push-expiry-warning-duration value, --web_push_expiry_warning_duration value send web push warning notification after this time before expiring unused subscriptions (default: "55d") [$NTFY_WEB_PUSH_EXPIRY_WARNING_DURATION]
|
||||||
|
--help, -h
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -363,7 +363,7 @@ To build your own version with Firebase, you must:
|
|||||||
* And change `app_base_url` in [values.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/values.xml)
|
* And change `app_base_url` in [values.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/values.xml)
|
||||||
* Then run:
|
* Then run:
|
||||||
```
|
```
|
||||||
# To build an unsigned .apk (app/build/outputs/apk/play/*.apk)
|
# To build an unsigned .apk (app/build/outputs/apk/play/release/*.apk)
|
||||||
./gradlew assemblePlayRelease
|
./gradlew assemblePlayRelease
|
||||||
|
|
||||||
# To build a bundle .aab (app/play/release/*.aab)
|
# To build a bundle .aab (app/play/release/*.aab)
|
||||||
@@ -384,7 +384,7 @@ strictly based off of my development on this app. There may be other versions of
|
|||||||
### Apple setup
|
### Apple setup
|
||||||
|
|
||||||
!!! info
|
!!! info
|
||||||
Along with this step, the [PLIST Deployment](#plist-deployment-and-configuration) step is also required
|
Along with this step, the [PLIST Deployment](#plist-config) step is also required
|
||||||
for these changes to take effect in the iOS app.
|
for these changes to take effect in the iOS app.
|
||||||
|
|
||||||
1. [Create a new key in Apple Developer Member Center](https://developer.apple.com/account/resources/authkeys/add)
|
1. [Create a new key in Apple Developer Member Center](https://developer.apple.com/account/resources/authkeys/add)
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ GitHub have been hopeless. In case it ever becomes available, I want to know imm
|
|||||||
*/6 * * * * if curl -s https://api.github.com/users/ntfy | grep "Not Found"; then curl -d "github.com/ntfy is available" -H "Tags: tada" -H "Prio: high" ntfy.sh/my-alerts; fi
|
*/6 * * * * if curl -s https://api.github.com/users/ntfy | grep "Not Found"; then curl -d "github.com/ntfy is available" -H "Tags: tada" -H "Prio: high" ntfy.sh/my-alerts; fi
|
||||||
```
|
```
|
||||||
|
|
||||||
|
You can also use [`ntfy-run`](https://github.com/quantum5/ntfy-run) to send the output of your cronjob in the
|
||||||
|
notification, so that you know exactly why it failed:
|
||||||
|
|
||||||
|
```
|
||||||
|
0 0 * * * ntfy-run -n https://ntfy.sh/backups --success-priority low --failure-tags warning ~/backup-computer
|
||||||
|
```
|
||||||
|
|
||||||
## Low disk space alerts
|
## Low disk space alerts
|
||||||
Here's a simple cronjob that I use to alert me when the disk space on the root disk is running low. It's simple, but
|
Here's a simple cronjob that I use to alert me when the disk space on the root disk is running low. It's simple, but
|
||||||
@@ -161,15 +167,30 @@ services:
|
|||||||
watchtower:
|
watchtower:
|
||||||
image: containrrr/watchtower
|
image: containrrr/watchtower
|
||||||
environment:
|
environment:
|
||||||
- WATCHTOWER_NOTIFICATIONS=shoutrrr
|
- WATCHTOWER_NOTIFICATION_SKIP_TITLE=True
|
||||||
- WATCHTOWER_NOTIFICATION_URL=ntfy://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates
|
- WATCHTOWER_NOTIFICATION_URL=ntfy://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The environment variable `WATCHTOWER_NOTIFICATION_SKIP_TITLE` is required to prevent Watchtower from [replacing the `title` query parameter](https://containrrr.dev/watchtower/notifications/#settings). If omitted, the provided notification title will not be used.
|
||||||
|
|
||||||
Or, if you only want to send notifications using shoutrrr:
|
Or, if you only want to send notifications using shoutrrr:
|
||||||
```
|
```
|
||||||
shoutrrr send -u "ntfy://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage"
|
shoutrrr send -u "ntfy://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Authentication tokens are also supported:
|
||||||
|
|
||||||
|
- (Recommended) Ntfy url format (replace the domain, topic and token with your own):
|
||||||
|
```
|
||||||
|
ntfy://:TOKEN@DOMAIN/TOPIC
|
||||||
|
```
|
||||||
|
|
||||||
|
- Generic webhook and authorization header using this url format (replace the domain, topic and token with your own):
|
||||||
|
|
||||||
|
```
|
||||||
|
generic+https://DOMAIN/TOPIC?@authorization=Bearer+TOKEN`
|
||||||
|
```
|
||||||
|
|
||||||
## Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd
|
## Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd
|
||||||
|
|
||||||
<!-- Sonarr v4 is in beta as of May 2023, should be updated to remove v3 reference when stable -->
|
<!-- Sonarr v4 is in beta as of May 2023, should be updated to remove v3 reference when stable -->
|
||||||
@@ -598,6 +619,8 @@ This will only work on selfhosted [traccar](https://www.traccar.org/) ([Github](
|
|||||||
|
|
||||||
The easiest way to integrate traccar with ntfy, is to configure ntfy as the SMS provider for your instance. You then can set your ntfy topic as your account's phone number in traccar. Sending the email notifications to ntfy will not work, as ntfy does not support HTML emails.
|
The easiest way to integrate traccar with ntfy, is to configure ntfy as the SMS provider for your instance. You then can set your ntfy topic as your account's phone number in traccar. Sending the email notifications to ntfy will not work, as ntfy does not support HTML emails.
|
||||||
|
|
||||||
|
**Info:** Add a phone number to your traccar account not in device, as otherwise it will not try to send SMS.
|
||||||
|
|
||||||
**Caution:** JSON publishing is only possible, when POST-ing to the root URL of the ntfy instance. (see [documentation](publish.md#publish-as-json))
|
**Caution:** JSON publishing is only possible, when POST-ing to the root URL of the ntfy instance. (see [documentation](publish.md#publish-as-json))
|
||||||
```xml
|
```xml
|
||||||
<entry key='sms.http.url'>https://ntfy.sh</entry>
|
<entry key='sms.http.url'>https://ntfy.sh</entry>
|
||||||
@@ -617,3 +640,56 @@ or by simply providing traccar with a valid username/password combination.
|
|||||||
<entry key='sms.http.user'>phil</entry>
|
<entry key='sms.http.user'>phil</entry>
|
||||||
<entry key='sms.http.password'>mypass</entry>
|
<entry key='sms.http.password'>mypass</entry>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Terminal Notifications for Long-Running Commands
|
||||||
|
|
||||||
|
This example provides a simple way to send notifications using [ntfy.sh](https://ntfy.sh) when a terminal command completes. It includes success or failure indicators based on the command's exit status.
|
||||||
|
|
||||||
|
Store your ntfy.sh bearer token securely if access control is enabled:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
echo "your_bearer_token_here" > ~/.ntfy_token
|
||||||
|
chmod 600 ~/.ntfy_token
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the following function and alias to your `.bashrc` or `.bash_profile`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Function for alert notifications using ntfy.sh
|
||||||
|
notify_via_ntfy() {
|
||||||
|
local exit_status=$? # Capture the exit status before doing anything else
|
||||||
|
local token=$(< ~/.ntfy_token) # Securely read the token
|
||||||
|
local status_icon="$([ $exit_status -eq 0 ] && echo magic_wand || echo warning)"
|
||||||
|
local last_command=$(history | tail -n1 | sed -e 's/^[[:space:]]*[0-9]\{1,\}[[:space:]]*//' -e 's/[;&|][[:space:]]*alert$//')
|
||||||
|
|
||||||
|
curl -s -X POST "https://n.example.dev/alerts" \
|
||||||
|
-H "Authorization: Bearer $token" \
|
||||||
|
-H "Title: Terminal" \
|
||||||
|
-H "X-Priority: 3" \
|
||||||
|
-H "Tags: $status_icon" \
|
||||||
|
-d "Command: $last_command (Exit: $exit_status)"
|
||||||
|
|
||||||
|
echo "Tags: $status_icon"
|
||||||
|
echo "$last_command (Exit: $exit_status)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add an "alert" alias for long running commands using ntfy.sh
|
||||||
|
alias alert='notify_via_ntfy'
|
||||||
|
```
|
||||||
|
|
||||||
|
Now you can run any long-running command and append `alert` to notify when it completes:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sleep 10; alert
|
||||||
|
```
|
||||||
|

|
||||||
|
|
||||||
|
**Notification Sent** with a success 🪄 (`magic_wand`) or failure ⚠️ (`warning`) tag.
|
||||||
|
|
||||||
|
To test failure notifications:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
false; alert # Always fails (exit 1)
|
||||||
|
ls --invalid; alert # Invalid option
|
||||||
|
cat nonexistent_file; alert # File not found
|
||||||
|
```
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
def copy_fonts(config, **kwargs):
|
|
||||||
site_dir = config['site_dir']
|
def on_post_build(config, **kwargs):
|
||||||
shutil.copytree('docs/static/fonts', os.path.join(site_dir, 'get'))
|
site_dir = config["site_dir"]
|
||||||
|
shutil.copytree("docs/static/fonts", os.path.join(site_dir, "get"))
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ 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 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
|
||||||
pick a name and use it later when you [publish a message](publish.md). Note that **topic names are public, so it's wise
|
pick a name and use it later when you [publish a message](publish.md). Note that **topic names are public, so it's wise
|
||||||
to choose something that cannot be guessed easily.**
|
to choose something that cannot be guessed easily.**
|
||||||
|
|||||||
@@ -30,37 +30,37 @@ deb/rpm packages.
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_amd64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_amd64.tar.gz
|
||||||
tar zxvf ntfy_2.8.0_linux_amd64.tar.gz
|
tar zxvf ntfy_2.13.0_linux_amd64.tar.gz
|
||||||
sudo cp -a ntfy_2.8.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
sudo cp -a ntfy_2.13.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.8.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.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.8.0/ntfy_2.8.0_linux_armv6.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv6.tar.gz
|
||||||
tar zxvf ntfy_2.8.0_linux_armv6.tar.gz
|
tar zxvf ntfy_2.13.0_linux_armv6.tar.gz
|
||||||
sudo cp -a ntfy_2.8.0_linux_armv6/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.13.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.8.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.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.8.0/ntfy_2.8.0_linux_armv7.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv7.tar.gz
|
||||||
tar zxvf ntfy_2.8.0_linux_armv7.tar.gz
|
tar zxvf ntfy_2.13.0_linux_armv7.tar.gz
|
||||||
sudo cp -a ntfy_2.8.0_linux_armv7/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.13.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.8.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.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.8.0/ntfy_2.8.0_linux_arm64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_arm64.tar.gz
|
||||||
tar zxvf ntfy_2.8.0_linux_arm64.tar.gz
|
tar zxvf ntfy_2.13.0_linux_arm64.tar.gz
|
||||||
sudo cp -a ntfy_2.8.0_linux_arm64/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.13.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.8.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_amd64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_amd64.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -118,7 +118,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_armv6.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv6.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -126,7 +126,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_armv7.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv7.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -134,7 +134,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_arm64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_arm64.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -144,28 +144,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.8.0/ntfy_2.8.0_linux_amd64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_amd64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl 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.8.0/ntfy_2.8.0_linux_armv6.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv6.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl 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.8.0/ntfy_2.8.0_linux_armv7.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv7.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl 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.8.0/ntfy_2.8.0_linux_arm64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_arm64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
@@ -195,18 +195,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.8.0/ntfy_2.8.0_darwin_all.tar.gz),
|
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_darwin_all.tar.gz),
|
||||||
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
|
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.8.0/ntfy_2.8.0_darwin_all.tar.gz > ntfy_2.8.0_darwin_all.tar.gz
|
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_darwin_all.tar.gz > ntfy_2.13.0_darwin_all.tar.gz
|
||||||
tar zxvf ntfy_2.8.0_darwin_all.tar.gz
|
tar zxvf ntfy_2.13.0_darwin_all.tar.gz
|
||||||
sudo cp -a ntfy_2.8.0_darwin_all/ntfy /usr/local/bin/ntfy
|
sudo cp -a ntfy_2.13.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||||
mkdir ~/Library/Application\ Support/ntfy
|
mkdir ~/Library/Application\ Support/ntfy
|
||||||
cp ntfy_2.8.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
cp ntfy_2.13.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||||
ntfy --help
|
ntfy --help
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -224,7 +224,7 @@ 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.8.0/ntfy_2.8.0_windows_amd64.zip),
|
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_windows_amd64.zip),
|
||||||
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
|
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 +280,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
|
||||||
@@ -540,7 +538,7 @@ kubectl apply -k /ntfy
|
|||||||
cpu: 150m
|
cpu: 150m
|
||||||
memory: 150Mi
|
memory: 150Mi
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- mountPath: /etc/ntfy/server.yml
|
- mountPath: /etc/ntfy
|
||||||
subPath: server.yml
|
subPath: server.yml
|
||||||
name: config-volume # generated vie configMapGenerator from kustomization file
|
name: config-volume # generated vie configMapGenerator from kustomization file
|
||||||
- mountPath: /var/cache/ntfy
|
- mountPath: /var/cache/ntfy
|
||||||
|
|||||||
@@ -4,8 +4,21 @@ There are quite a few projects that work with ntfy, integrate ntfy, or have been
|
|||||||
|
|
||||||
I've added a ⭐ to projects or posts that have a significant following, or had a lot of interaction by the community.
|
I've added a ⭐ to projects or posts that have a significant following, or had a lot of interaction by the community.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Official integrations](#official-integrations)
|
||||||
|
- [Integration via HTTP/SMTP/etc.](#integration-via-httpsmtpetc)
|
||||||
|
- [UnifiedPush integrations](#unifiedpush-integrations)
|
||||||
|
- [Libraries](#libraries)
|
||||||
|
- [CLIs + GUIs](#clis-guis)
|
||||||
|
- [Projects + scripts](#projects-scripts)
|
||||||
|
- [Blog + forum posts](#blog-forum-posts)
|
||||||
|
- [Alternative ntfy servers](#alternative-ntfy-servers)
|
||||||
|
|
||||||
## Official integrations
|
## Official integrations
|
||||||
|
|
||||||
|
- [changedetection.io](https://changedetection.io) ⭐ - Website change detection and notification
|
||||||
|
- [Home Assistant](https://www.home-assistant.io/integrations/ntfy) ⭐ - Home Assistant is an open-source platform for automating and controlling smart home devices.
|
||||||
- [Healthchecks.io](https://healthchecks.io/) ⭐ - Online service for monitoring regularly running tasks such as cron jobs
|
- [Healthchecks.io](https://healthchecks.io/) ⭐ - Online service for monitoring regularly running tasks such as cron jobs
|
||||||
- [Apprise](https://github.com/caronc/apprise/wiki/Notify_ntfy) ⭐ - Push notifications that work with just about every platform
|
- [Apprise](https://github.com/caronc/apprise/wiki/Notify_ntfy) ⭐ - Push notifications that work with just about every platform
|
||||||
- [Uptime Kuma](https://uptime.kuma.pet/) ⭐ - A self-hosted monitoring tool
|
- [Uptime Kuma](https://uptime.kuma.pet/) ⭐ - A self-hosted monitoring tool
|
||||||
@@ -16,7 +29,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
|||||||
- [Gatus](https://gatus.io/) ⭐ - Automated service health dashboard
|
- [Gatus](https://gatus.io/) ⭐ - Automated service health dashboard
|
||||||
- [Automatisch](https://automatisch.io/) ⭐ - Open source Zapier alternative / workflow automation tool
|
- [Automatisch](https://automatisch.io/) ⭐ - Open source Zapier alternative / workflow automation tool
|
||||||
- [FlexGet](https://flexget.com/Plugins/Notifiers/ntfysh) ⭐ - Multipurpose automation tool for all of your media
|
- [FlexGet](https://flexget.com/Plugins/Notifiers/ntfysh) ⭐ - Multipurpose automation tool for all of your media
|
||||||
- [Shoutrrr](https://containrrr.dev/shoutrrr/v0.7/services/ntfy/) ⭐ - Notification library for gophers and their furry friends.
|
- [Shoutrrr](https://containrrr.dev/shoutrrr/v0.8/services/ntfy/) ⭐ - Notification library for gophers and their furry friends.
|
||||||
- [Netdata](https://learn.netdata.cloud/docs/alerts-and-notifications/notifications/agent-alert-notifications/ntfy) ⭐ - Real-time performance monitoring
|
- [Netdata](https://learn.netdata.cloud/docs/alerts-and-notifications/notifications/agent-alert-notifications/ntfy) ⭐ - Real-time performance monitoring
|
||||||
- [Deployer](https://github.com/deployphp/deployer) ⭐ - PHP deployment tool
|
- [Deployer](https://github.com/deployphp/deployer) ⭐ - PHP deployment tool
|
||||||
- [Scrt.link](https://scrt.link/) - Share a secret
|
- [Scrt.link](https://scrt.link/) - Share a secret
|
||||||
@@ -24,15 +37,22 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
|||||||
- [diun](https://crazymax.dev/diun/) - Docker Image Update Notifier
|
- [diun](https://crazymax.dev/diun/) - Docker Image Update Notifier
|
||||||
- [Cloudron](https://www.cloudron.io/store/sh.ntfy.cloudronapp.html) - Platform that makes it easy to manage web apps on your server
|
- [Cloudron](https://www.cloudron.io/store/sh.ntfy.cloudronapp.html) - Platform that makes it easy to manage web apps on your server
|
||||||
- [Xitoring](https://xitoring.com/docs/notifications/notification-roles/ntfy/) - Server and Uptime monitoring
|
- [Xitoring](https://xitoring.com/docs/notifications/notification-roles/ntfy/) - Server and Uptime monitoring
|
||||||
- [changedetection.io](https://changedetection.io) ⭐ - Website change detection and notification
|
- [HetrixTools](https://docs.hetrixtools.com/ntfy-sh-notifications/) - Uptime monitoring
|
||||||
|
- [EasyMorph](https://help.easymorph.com/doku.php?id=transformations:sendntfymessage) - Visual data transformation and automation tool
|
||||||
|
- [Monibot](https://monibot.io/) - Monibot monitors your websites, servers and applications and notifies you if something goes wrong.
|
||||||
|
- [Miniflux](https://miniflux.app/docs/ntfy.html) - Minimalist and opinionated feed reader
|
||||||
|
- [Beszel](https://beszel.dev/guide/notifications/ntfy) - Server monitoring platform
|
||||||
|
|
||||||
## Integration via HTTP/SMTP/etc.
|
## Integration via HTTP/SMTP/etc.
|
||||||
|
|
||||||
- [Watchtower](https://containrrr.dev/watchtower/) ⭐ - Automating Docker container base image updates (see [integration example](examples.md#watchtower-shoutrrr))
|
- [Watchtower](https://containrrr.dev/watchtower/) ⭐ - Automating Docker container base image updates (see [integration example](examples.md#watchtower-shoutrrr))
|
||||||
- [Jellyfin](https://jellyfin.org/) ⭐ - The Free Software Media System (see [integration example](examples.md#))
|
- [Jellyfin](https://jellyfin.org/) ⭐ - The Free Software Media System (see [integration example](examples.md#))
|
||||||
- [Overseer](https://docs.overseerr.dev/using-overseerr/notifications/webhooks) ⭐ - a request management and media discovery tool for Plex (see [integration example](examples.md#jellyseerroverseerr-webhook))
|
- [Overseerr](https://docs.overseerr.dev/using-overseerr/notifications/webhooks) ⭐ - a request management and media discovery tool for Plex (see [integration example](examples.md#jellyseerroverseerr-webhook))
|
||||||
- [Tautulli](https://github.com/Tautulli/Tautulli) ⭐ - Monitoring and tracking tool for Plex (integration [via webhook](https://github.com/Tautulli/Tautulli/wiki/Notification-Agents-Guide#webhook))
|
- [Tautulli](https://github.com/Tautulli/Tautulli) ⭐ - Monitoring and tracking tool for Plex (integration [via webhook](https://github.com/Tautulli/Tautulli/wiki/Notification-Agents-Guide#webhook))
|
||||||
- [Mailrise](https://github.com/YoRyan/mailrise) - An SMTP gateway (integration via [Apprise](https://github.com/caronc/apprise/wiki/Notify_ntfy))
|
- [Mailrise](https://github.com/YoRyan/mailrise) - An SMTP gateway (integration via [Apprise](https://github.com/caronc/apprise/wiki/Notify_ntfy))
|
||||||
|
- [Proxmox-Ntfy](https://github.com/qtsone/proxmox-ntfy) - Python script that monitors Proxmox tasks and sends notifications using the Ntfy service.
|
||||||
|
- [Scrutiny](https://github.com/AnalogJ/scrutiny) - WebUI for smartd S.M.A.R.T monitoring. Scrutiny includes shoutrrr/ntfy integration ([see integration README](https://github.com/AnalogJ/scrutiny?tab=readme-ov-file#notifications))
|
||||||
|
- [UptimeObserver](https://uptimeobserver.com) - Uptime Monitoring tool for Websites, APIs, SSL Certificates, DNS, Domain Names and Ports. [Integration Guide](https://support.uptimeobserver.com/integrations/ntfy/)
|
||||||
|
|
||||||
## [UnifiedPush](https://unifiedpush.org/users/apps/) integrations
|
## [UnifiedPush](https://unifiedpush.org/users/apps/) integrations
|
||||||
|
|
||||||
@@ -60,16 +80,22 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
|||||||
- [gotfy](https://github.com/AnthonyHewins/gotfy) - A Go wrapper for the ntfy API (Go)
|
- [gotfy](https://github.com/AnthonyHewins/gotfy) - A Go wrapper for the ntfy API (Go)
|
||||||
- [symfony/ntfy-notifier](https://symfony.com/components/NtfyNotifier) ⭐ - Symfony Notifier integration for ntfy (PHP)
|
- [symfony/ntfy-notifier](https://symfony.com/components/NtfyNotifier) ⭐ - Symfony Notifier integration for ntfy (PHP)
|
||||||
- [ntfy-java](https://github.com/MaheshBabu11/ntfy-java/) - A Java package to interact with a ntfy server (Java)
|
- [ntfy-java](https://github.com/MaheshBabu11/ntfy-java/) - A Java package to interact with a ntfy server (Java)
|
||||||
|
- [aiontfy](https://github.com/tr4nt0r/aiontfy) - Asynchronous client library for publishing and subscribing to ntfy (Python)
|
||||||
|
|
||||||
## CLIs + GUIs
|
## CLIs + GUIs
|
||||||
|
|
||||||
- [ntfy.sh.sh](https://github.com/mininmobile/ntfy.sh.sh) - Run scripts on ntfy.sh events
|
- [ntfy.sh.sh](https://github.com/mininmobile/ntfy.sh.sh) - Run scripts on ntfy.sh events
|
||||||
- [ntfy Desktop client](https://codeberg.org/zvava/ntfy-desktop) - Cross-platform desktop application for ntfy
|
- [ntfy-desktop](https://codeberg.org/zvava/ntfy-desktop) - Cross-platform desktop application for ntfy
|
||||||
|
- [ntfy-desktop](https://github.com/Aetherinox/ntfy-desktop) - Desktop client for Windows, Linux, and MacOS with push notifications
|
||||||
- [ntfy svelte front-end](https://github.com/novatorem/Ntfy) - Front-end built with svelte
|
- [ntfy svelte front-end](https://github.com/novatorem/Ntfy) - Front-end built with svelte
|
||||||
- [wio-ntfy-ticker](https://github.com/nachotp/wio-ntfy-ticker) - Ticker display for a ntfy.sh topic
|
- [wio-ntfy-ticker](https://github.com/nachotp/wio-ntfy-ticker) - Ticker display for a ntfy.sh topic
|
||||||
- [ntfysh-windows](https://github.com/lucas-bortoli/ntfysh-windows) - A ntfy client for Windows Desktop
|
- [ntfysh-windows](https://github.com/lucas-bortoli/ntfysh-windows) - A ntfy client for Windows Desktop
|
||||||
- [ntfyr](https://github.com/haxwithaxe/ntfyr) - A simple commandline tool to send notifications to ntfy
|
- [ntfyr](https://github.com/haxwithaxe/ntfyr) - A simple commandline tool to send notifications to ntfy
|
||||||
- [ntfy.py](https://github.com/ioqy/ntfy-client-python) - ntfy.py is a simple nfty.sh client for sending notifications
|
- [ntfy.py](https://github.com/ioqy/ntfy-client-python) - ntfy.py is a simple nfty.sh client for sending notifications
|
||||||
|
- [wlzntfy](https://github.com/Walzen-Group/ntfy-toaster) - A minimalistic, receive-only toast notification client for Windows 11
|
||||||
|
- [Ntfy_CSV_Reminders](https://github.com/thiswillbeyourgithub/Ntfy_CSV_Reminders) - A Python tool that sends random-timing phone notifications for recurring tasks by using daily probability checks based on CSV-defined frequencies.
|
||||||
|
- [Daily Fact Ntfy](https://github.com/thiswillbeyourgithub/Daily_Fact_Ntfy) - Generate [llm](https://github.com/simonw/llm) generated fact every day about any topic you're interested in.
|
||||||
|
- [ntfyexec](https://github.com/alecthomas/ntfyexec) - Send a notification through ntfy.sh if a command fails
|
||||||
|
|
||||||
## Projects + scripts
|
## Projects + scripts
|
||||||
|
|
||||||
@@ -78,6 +104,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
|||||||
- [Grafana-to-ntfy](https://gitlab.com/Saibe1111/grafana-to-ntfy) - Grafana-to-ntfy alerts channel (Node Js)
|
- [Grafana-to-ntfy](https://gitlab.com/Saibe1111/grafana-to-ntfy) - Grafana-to-ntfy alerts channel (Node Js)
|
||||||
- [ntfy-long-zsh-command](https://github.com/robfox92/ntfy-long-zsh-command) - Notifies you once a long-running command completes (zsh)
|
- [ntfy-long-zsh-command](https://github.com/robfox92/ntfy-long-zsh-command) - Notifies you once a long-running command completes (zsh)
|
||||||
- [ntfy-shellscripts](https://github.com/nickexyz/ntfy-shellscripts) - A few scripts for the ntfy project (Shell)
|
- [ntfy-shellscripts](https://github.com/nickexyz/ntfy-shellscripts) - A few scripts for the ntfy project (Shell)
|
||||||
|
- [alertmanager-ntfy-relay](https://github.com/therobbielee/alertmanager-ntfy-relay) - ntfy.sh relay for Alertmanager (Go)
|
||||||
- [QuickStatus](https://github.com/corneliusroot/QuickStatus) - A shell script to alert to any immediate problems upon login (Shell)
|
- [QuickStatus](https://github.com/corneliusroot/QuickStatus) - A shell script to alert to any immediate problems upon login (Shell)
|
||||||
- [ntfy.el](https://github.com/shombando/ntfy) - Send notifications from Emacs (Emacs)
|
- [ntfy.el](https://github.com/shombando/ntfy) - Send notifications from Emacs (Emacs)
|
||||||
- [backup-projects](https://gist.github.com/anthonyaxenov/826ba65abbabd5b00196bc3e6af76002) - Stupidly simple backup script for own projects (Shell)
|
- [backup-projects](https://gist.github.com/anthonyaxenov/826ba65abbabd5b00196bc3e6af76002) - Stupidly simple backup script for own projects (Shell)
|
||||||
@@ -125,7 +152,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
|||||||
- [ntfyd](https://github.com/joachimschmidt557/ntfyd) - ntfy desktop daemon (Zig)
|
- [ntfyd](https://github.com/joachimschmidt557/ntfyd) - ntfy desktop daemon (Zig)
|
||||||
- [ntfy-browser](https://github.com/johman10/ntfy-browser) - browser extension to receive notifications without having the page open (TypeScript)
|
- [ntfy-browser](https://github.com/johman10/ntfy-browser) - browser extension to receive notifications without having the page open (TypeScript)
|
||||||
- [ntfy-electron](https://github.com/xdpirate/ntfy-electron) - Electron wrapper for the ntfy web app (JS)
|
- [ntfy-electron](https://github.com/xdpirate/ntfy-electron) - Electron wrapper for the ntfy web app (JS)
|
||||||
- [systemd-ntfy-poweronoff](https://github.com/stendler/systemd-ntfy-poweronoff) - Systemd services to send notifications on system startup and shutdown (Go)
|
- [systemd-ntfy-poweronoff](https://github.com/stendler/systemd-ntfy-poweronoff) - Systemd services to send notifications on system startup, shutdown and service failure
|
||||||
- [msgdrop](https://github.com/jbrubake/msgdrop) - Send and receive encrypted messages (Bash)
|
- [msgdrop](https://github.com/jbrubake/msgdrop) - Send and receive encrypted messages (Bash)
|
||||||
- [vigilant](https://github.com/VerifiedJoseph/vigilant) - Monitor RSS/ATOM and JSON feeds, and send push notifications on new entries (PHP)
|
- [vigilant](https://github.com/VerifiedJoseph/vigilant) - Monitor RSS/ATOM and JSON feeds, and send push notifications on new entries (PHP)
|
||||||
- [ansible-role-ntfy-alertmanager](https://github.com/bleetube/ansible-role-ntfy-alertmanager) - Ansible role to install xenrox/ntfy-alertmanager
|
- [ansible-role-ntfy-alertmanager](https://github.com/bleetube/ansible-role-ntfy-alertmanager) - Ansible role to install xenrox/ntfy-alertmanager
|
||||||
@@ -138,9 +165,41 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
|||||||
- [jetspotter](https://github.com/vvanouytsel/jetspotter) - send notifications when planes are spotted near you (Go)
|
- [jetspotter](https://github.com/vvanouytsel/jetspotter) - send notifications when planes are spotted near you (Go)
|
||||||
- [monitoring_ntfy](https://www.drupal.org/project/monitoring_ntfy) - Drupal monitoring Ntfy.sh integration (PHP/Drupal)
|
- [monitoring_ntfy](https://www.drupal.org/project/monitoring_ntfy) - Drupal monitoring Ntfy.sh integration (PHP/Drupal)
|
||||||
- [Notify](https://flathub.org/apps/com.ranfdev.Notify) - Native GTK4 client for ntfy (Rust)
|
- [Notify](https://flathub.org/apps/com.ranfdev.Notify) - Native GTK4 client for ntfy (Rust)
|
||||||
|
- [notify-via-ntfy](https://exchange.checkmk.com/p/notify-via-ntfy) - Checkmk plugin to send notifications via ntfy (Python)
|
||||||
|
- [ntfy-java](https://github.com/MaheshBabu11/ntfy-java/) - A Java package to interact with a ntfy server (Java)
|
||||||
|
- [container-update-check](https://github.com/stendler/container-update-check) - Scripts to check and notify if a podman or docker container image can be updated (Podman/Shell)
|
||||||
|
- [ignition-combustion-template](https://github.com/stendler/ignition-combustion-template) - Templates and scripts to generate a configuration to automatically setup a system on first boot. Including systemd-ntfy-poweronoff (Shell)
|
||||||
|
- [ntfy-run](https://github.com/quantum5/ntfy-run) - Tool to run a command, capture its output, and send it to ntfy (Rust)
|
||||||
|
- [Clipboard IO](https://github.com/jim3692/clipboard-io) - End to end encrypted clipboard
|
||||||
|
- [ntfy-me-mcp](https://github.com/gitmotion/ntfy-me-mcp) - An ntfy MCP server for sending/fetching ntfy notifications to your self-hosted ntfy server from AI Agents (supports secure token auth & more - use with npx or docker!) (Node/Typescript)
|
||||||
|
- [InvaderInformant](https://github.com/patricksthannon/InvaderInformant) - Script for Mac OS systems that monitors new or dropped connections to your network using ntfy (Shell)
|
||||||
|
- [NtfyPwsh](https://github.com/ptmorris1/NtfyPwsh) - PowerShell module to help send messages to ntfy (PowerShell)
|
||||||
|
- [ntfyrr](https://github.com/leukosaima/ntfyrr) - Currently an Overseerr webhook notification to ntfy helper service.
|
||||||
|
|
||||||
## Blog + forum posts
|
## Blog + forum posts
|
||||||
|
|
||||||
|
- [Device notifications via HTTP with ntfy](https://alistairshepherd.uk/writing/ntfy/) - alistairshepherd.uk - 6/2025
|
||||||
|
- [Notifications about (almost) anything with ntfy.sh](https://hamatti.org/posts/notifications-about-almost-anything-with-ntfy-sh/) - hamatti.org - 6/2025
|
||||||
|
- [I set up a self-hosted notification service for everything, and I'll never look back](https://www.xda-developers.com/set-up-self-hosted-notification-service/) ⭐ - xda-developers.com - 5/2025
|
||||||
|
- [How to Set Up Ntfy: Self-Hosted Push Notifications Made Easy](https://www.youtube.com/watch?v=wDJDiAYZ3H0) - youtube.com (sass drew) - 1/2025
|
||||||
|
- [The NTFY is a game-changer FREE solution for IT people](https://www.youtube.com/watch?v=NtlztHT-sRw) - youtube.com (Valters Tech Turf) - 1/2025
|
||||||
|
- [Notify: A Powerful Tool for Real-Time Notifications (ntfy.sh)](https://www.youtube.com/watch?v=XXTTeVfGBz0) - youtube.com (LinuxCloudHacks) - 12/2025
|
||||||
|
- [Push notifications with ntfy and n8n](https://www.youtube.com/watch?v=DKG1R3xYvwQ) - youtube.com (Oskar) - 10/2024
|
||||||
|
- [Setup ntfy for selfhosted notifications with Cloudflare Tunnel](https://medium.com/@svenvanginkel/setup-ntfy-for-selfhosted-notifications-with-cloudflare-tunnel-e342f470177d) - medium.com (Sven van Ginkel) - 10/2024
|
||||||
|
- [Self-Host NTFY - How It Works & Easy Setup Guide](https://www.youtube.com/watch?v=79wHc_jfrJE) ⭐ - youtube.com (Techdox)- 9/2024
|
||||||
|
- [ntfy / Emacs Lisp](https://speechcode.com/blog/ntfy/) - speechcode.com - 3/2024
|
||||||
|
- [Boost Your Productivity with ntfy.sh: The Ultimate Notification Tool for Command-Line Users](https://dev.to/archetypal/boost-your-productivity-with-ntfysh-the-ultimate-notification-tool-for-command-line-users-iil) - dev.to - 3/2024
|
||||||
|
- [Nextcloud Talk (F-Droid version) notifications using ntfy (ntfy.sh)](https://www.youtube.com/watch?v=0a6PpfN5PD8) - youtube.com - 2/2024
|
||||||
|
- [ZFS and SMART Warnings via Ntfy](https://rair.dev/zfs-smart-ntfy/) - rair.dev - 2/2024
|
||||||
|
- [Automating Security Camera Notifications With Home Assistant and Ntfy](https://runtimeterror.dev/automating-camera-notifications-home-assistant-ntfy/) ⭐ - runtimeterror.dev - 2/2024
|
||||||
|
- [Ntfy: self-hosted notification service](https://medium.com/@williamdonze/ntfy-self-hosted-notification-service-0f3eada6e657) ⭐ - williamdonze.medium.com - 1/2024
|
||||||
|
- [Let’s Supercharge Snowflake Alerts with Cool ntfy Open-source Notifications!](https://sarathi-data-ml-cloud.medium.com/lets-supercharge-snowflake-alerts-with-cool-ntfy-open-source-notifications-296da442c331) - sarathi-data-ml-cloud.medium.com - 1/2024
|
||||||
|
- [Setting up NTFY with Ngnix-Proxy-Manager, authentication and Ansible notifications](https://random-it-blog.de/rocky-linux/setting-up-ntfy-with-ngnix-proxy-manager-authentication-and-ansible-notifications/) - random-it-blog.de - 12/2023
|
||||||
|
- [Introducing the Monitoring Ntfy.sh Integration Module: Real-time Notifications for Drupal Monitoring](https://cyberschorsch.dev/drupal/introducing-monitoring-ntfysh-integration-module-real-time-notifications-drupal-monitoring) - cyberschorsch.dev - 11/2023
|
||||||
|
- [How to install Ntfy.sh on CasaOS using BigBearCasaOS](https://www.youtube.com/watch?v=wSWhtSNwTd8) - youtube.com - 10/2023
|
||||||
|
- [Podman Update Notifications via Ntfy](https://rair.dev/podman-update-notifications-ntfy/) - rair.dev - 9/2023
|
||||||
|
- [Easy Push Notifications With ntfy.sh](https://runtimeterror.dev/easy-push-notifications-with-ntfy/) ⭐ - runtimeterror.dev - 9/2023
|
||||||
|
- [Ntfy: Your Ultimate Push Notification Powerhouse!](https://kkamalesh117.medium.com/ntfy-your-ultimate-push-notification-powerhouse-1968c070f1d1) - kkamalesh117.medium.com - 9/2023
|
||||||
- [Installing Self Host NTFY On Linux Using Docker Container](https://www.pinoylinux.org/topicsplus/containers/installing-self-host-ntfy-on-linux-using-docker-container/) - pinoylinux.org - 9/2023
|
- [Installing Self Host NTFY On Linux Using Docker Container](https://www.pinoylinux.org/topicsplus/containers/installing-self-host-ntfy-on-linux-using-docker-container/) - pinoylinux.org - 9/2023
|
||||||
- [Homelab Notifications with ntfy](https://blog.alexsguardian.net/posts/2023/09/12/selfhosting-ntfy/) ⭐ - alexsguardian.net - 9/2023
|
- [Homelab Notifications with ntfy](https://blog.alexsguardian.net/posts/2023/09/12/selfhosting-ntfy/) ⭐ - alexsguardian.net - 9/2023
|
||||||
- [Why NTFY is the Ultimate Push Notification Tool for Your Needs](https://osintph.medium.com/why-ntfy-is-the-ultimate-push-notification-tool-for-your-needs-e767421c84c5) - osintph.medium.com - 9/2023
|
- [Why NTFY is the Ultimate Push Notification Tool for Your Needs](https://osintph.medium.com/why-ntfy-is-the-ultimate-push-notification-tool-for-your-needs-e767421c84c5) - osintph.medium.com - 9/2023
|
||||||
@@ -225,7 +284,8 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
|||||||
- [ntfy otro sistema de notificaciones pub-sub simple basado en HTTP](https://ugeek.github.io/blog/post/2021-11-05-ntfy-sh-otro-sistema-de-notificaciones-pub-sub-simple-basado-en-http.html) - ugeek.github.io - 11/2021
|
- [ntfy otro sistema de notificaciones pub-sub simple basado en HTTP](https://ugeek.github.io/blog/post/2021-11-05-ntfy-sh-otro-sistema-de-notificaciones-pub-sub-simple-basado-en-http.html) - ugeek.github.io - 11/2021
|
||||||
- [Show HN: A tool to send push notifications to your phone, written in Go](https://news.ycombinator.com/item?id=29715464) ⭐ - news.ycombinator.com - 12/2021
|
- [Show HN: A tool to send push notifications to your phone, written in Go](https://news.ycombinator.com/item?id=29715464) ⭐ - news.ycombinator.com - 12/2021
|
||||||
- [Reddit selfhostable post](https://www.reddit.com/r/selfhosted/comments/qxlsm9/my_open_source_notification_android_app_and/) ⭐ - reddit.com - 11/2021
|
- [Reddit selfhostable post](https://www.reddit.com/r/selfhosted/comments/qxlsm9/my_open_source_notification_android_app_and/) ⭐ - reddit.com - 11/2021
|
||||||
|
- [ntfy on The Canary in the Cage Podcast](https://odysee.com/@TheCanaryInTheCage:b/The-Canary-in-the-Cage-Episode-42:1?r=4gitYjTacQqPEjf22874USecDQYJ5y5E&t=3062) - odysee.com - 1/2025
|
||||||
|
- [NtfyPwsh - A PowerShell Module to Send Ntfy Messages](https://ptmorris1.github.io/posts/NtfyPwsh/) - github.io - 5/2025
|
||||||
|
|
||||||
## Alternative ntfy servers
|
## Alternative ntfy servers
|
||||||
|
|
||||||
|
|||||||
175
docs/publish.md
153
docs/releases.md
@@ -2,10 +2,146 @@
|
|||||||
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.13.0
|
||||||
|
Released July 10, 2025
|
||||||
|
|
||||||
|
This is a relatively small release, mainly to support IPv6 and to add more sophisticated
|
||||||
|
proxy header support. Quick reminder that if you like ntfy, **please consider sponsoring us**
|
||||||
|
via [GitHub Sponsors](https://github.com/sponsors/binwiederhier) and [Liberapay](https://en.liberapay.com/ntfy/), or buying a [paid plan via the web app](https://ntfy.sh/app).
|
||||||
|
ntfy will always remain open source.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Full [IPv6 support](config.md#ipv6-support) for ntfy and the official ntfy.sh server ([#519](https://github.com/binwiederhier/ntfy/issues/519)/[#1380](https://github.com/binwiederhier/ntfy/pull/1380)/[ansible#4](https://github.com/binwiederhier/ntfy-ansible/pull/4))
|
||||||
|
* Support `X-Client-IP`, `X-Real-IP`, `Forwarded` headers for [rate limiting](config.md#ip-based-rate-limiting) via `proxy-forwarded-header` and `proxy-trusted-hosts` ([#1360](https://github.com/binwiederhier/ntfy/pull/1360)/[#1252](https://github.com/binwiederhier/ntfy/pull/1252), thanks to [@pixitha](https://github.com/pixitha))
|
||||||
|
* Add STDIN support for `ntfy publish` ([#1382](https://github.com/binwiederhier/ntfy/pull/1382), thanks to [@srevn](https://github.com/srevn))
|
||||||
|
|
||||||
|
**Languages**
|
||||||
|
|
||||||
|
* Update new languages from Weblate. Thanks to all the contributors!
|
||||||
|
* Added Estonian (Esti), Galician (Galego), Romanian (Română), Slovak (Slovenčina) as new languages to the web app
|
||||||
|
|
||||||
|
### ntfy server v2.12.0
|
||||||
|
Released May 29, 2025
|
||||||
|
|
||||||
|
This is mainly a maintenance release that updates dependencies, though since it's been over a year, there are a few
|
||||||
|
new features and bug fixes as well.
|
||||||
|
|
||||||
|
Thanks to everyone who contributed to this release, and special thanks to [@wunter8](https://github.com/wunter8) for his continued
|
||||||
|
user support in Discord/Matrix/GitHub! You rock, man!
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Add username/password auth to email publishing ([#1164](https://github.com/binwiederhier/ntfy/pull/1164), thanks to [@bishtawi](https://github.com/bishtawi))
|
||||||
|
* Write VAPID keys to file in `ntfy webpush --output-file` ([#1138](https://github.com/binwiederhier/ntfy/pull/1138), thanks to [@nogweii](https://github.com/nogweii))
|
||||||
|
* Add Docker major/minor version to image tags ([#1271](https://github.com/binwiederhier/ntfy/pull/1271), thanks to [@RoboMagus](https://github.com/RoboMagus))
|
||||||
|
* Add `latest` subscription param for grabbing just the most recent message ([#1216](https://github.com/binwiederhier/ntfy/pull/1216), thanks to [@wunter8](https://github.com/wunter8))
|
||||||
|
* Allow using `NTFY_PASSWORD_HASH` in `ntfy user` command instead of raw password ([#1340](https://github.com/binwiederhier/ntfy/pull/1340), thanks to [@Tom-Hubrecht](https://github.com/Tom-Hubrecht) for implementing)
|
||||||
|
* You can now change passwords via `v1/users` API ([#1267](https://github.com/binwiederhier/ntfy/pull/1267), thanks to [@wunter8](https://github.com/wunter8) for implementing)
|
||||||
|
* Make WebPush subscription warning/expiry configurable, increase default to 55/60 days ([#1212](https://github.com/binwiederhier/ntfy/pull/1212), thanks to [@KuroSetsuna29](https://github.com/KuroSetsuna29))
|
||||||
|
* Support [systemd user service](https://docs.ntfy.sh/subscribe/cli/#using-the-systemd-service) `ntfy-client.service` ([#1002](https://github.com/binwiederhier/ntfy/pull/1002), thanks to [@dandersch](https://github.com/dandersch))
|
||||||
|
|
||||||
|
**Bug fixes + maintenance:**
|
||||||
|
|
||||||
|
* Security updates for dependencies and Docker images ([#1341](https://github.com/binwiederhier/ntfy/pull/1341))
|
||||||
|
* Upgrade to Vite 6 ([#1342](https://github.com/binwiederhier/ntfy/pull/1342), thanks Dependabot)
|
||||||
|
* Fix iOS delivery issues for read-protected topics ([#1207](https://github.com/binwiederhier/ntfy/pull/1287), thanks a lot to [@barart](https://github.com/barart)!)
|
||||||
|
* Add `Date` header to outgoing emails to avoid rejection ([#1141](https://github.com/binwiederhier/ntfy/pull/1141), thanks to [@pcouy](https://github.com/pcouy))
|
||||||
|
* Fix IP address parsing when behind a proxy ([#1266](https://github.com/binwiederhier/ntfy/pull/1266), thanks to [@mmatuska](https://github.com/mmatuska))
|
||||||
|
* Make sure UnifiedPush messages are not treated as attachments ([#1312](https://github.com/binwiederhier/ntfy/pull/1312), thanks to [@vkrause](https://github.com/vkrause))
|
||||||
|
* Add OCI image version to Docker image ([#1307](https://github.com/binwiederhier/ntfy/pull/1307), thanks to [@jlssmt](https://github.com/jlssmt))
|
||||||
|
* WebSocket returning incorrect HTTP error code ([#1338](https://github.com/binwiederhier/ntfy/pull/1338) / [#1337](https://github.com/binwiederhier/ntfy/pull/1337), thanks to [@wunter8](https://github.com/wunter8) for debugging and implementing)
|
||||||
|
* Make Markdown in the web app scrollable horizontally ([#1262](https://github.com/binwiederhier/ntfy/pull/1262), thanks to [@rake5k](https://github.com/rake5k) for fixing)
|
||||||
|
* Make sure WebPush subscription topics are actually deleted (no ticket)
|
||||||
|
* Increase the number of access tokens per user to 60 ([#1308](https://github.com/binwiederhier/ntfy/issues/1308))
|
||||||
|
* Allow specifying `cache` and `firebase` via JSON publishing ([#1119](https://github.com/binwiederhier/ntfy/issues/1119)/[#1123](https://github.com/binwiederhier/ntfy/pull/1123), thanks to [@stendler](https://github.com/stendler))
|
||||||
|
|
||||||
|
**Documentation:**
|
||||||
|
|
||||||
|
* Lots of new integrations and projects. Amazing!
|
||||||
|
* [ntfy-me-mcp](https://github.com/gitmotion/ntfy-me-mcp)
|
||||||
|
* [UptimeObserver](https://uptimeobserver.com)
|
||||||
|
* [alertmanager-ntfy-relay](https://github.com/therobbielee/alertmanager-ntfy-relay)
|
||||||
|
* [Monibot](https://monibot.io/)
|
||||||
|
* [Daily_Fact_Ntfy](https://github.com/thiswillbeyourgithub/Daily_Fact_Ntfy)
|
||||||
|
* [EasyMorph](https://help.easymorph.com/doku.php?id=transformations:sendntfymessage)
|
||||||
|
* [ntfy-run](https://github.com/quantum5/ntfy-run)
|
||||||
|
* [Clipboard IO](https://github.com/jim3692/clipboard-io)
|
||||||
|
* [ntfy-me-mcp](https://github.com/gitmotion/ntfy-me-mcp)
|
||||||
|
* [InvaderInformant](https://github.com/patricksthannon/InvaderInformant)
|
||||||
|
* Various docs updates ([#1161](https://github.com/binwiederhier/ntfy/pull/1161), thanks to [@OneWeekNotice](https://github.com/OneWeekNotice))
|
||||||
|
* Typo in config docs ([#1177](https://github.com/binwiederhier/ntfy/pull/1177), thanks to [@hoho4190](https://github.com/hoho4190))
|
||||||
|
* Typo in CLI docs ([#1172](https://github.com/binwiederhier/ntfy/pull/1172), thanks to [@anirvan](https://github.com/anirvan))
|
||||||
|
* Correction about MacroDroid ([#1137](https://github.com/binwiederhier/ntfy/pull/1137), thanks to [@ShlomoCode](https://github.com/ShlomoCode))
|
||||||
|
* Note about fail2ban in Docker ([#1175](https://github.com/binwiederhier/ntfy/pull/1175)), thanks to [@Measurity](https://github.com/Measurity))
|
||||||
|
* Lots of other tiny docs updates, thanks to everyone who contributed!
|
||||||
|
|
||||||
|
**Languages**
|
||||||
|
|
||||||
|
* Update new languages from Weblate. Thanks to all the contributors!
|
||||||
|
* Added Tamil (தமிழ்) as a new language to the web app
|
||||||
|
|
||||||
|
### ntfy server v2.11.0
|
||||||
|
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
|
||||||
|
in the rate visitor logic that caused rate visitors to be assigned to seemingly random topics. Nothing major this time.
|
||||||
|
|
||||||
|
❤️ 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.
|
||||||
|
|
||||||
|
**Bug fixes + maintenance:**
|
||||||
|
|
||||||
|
* Re-add database index `idx_topic` to the `messages` table to fix performance issues on ntfy.sh (no ticket, big thanks to [@tcaputi](https://github.com/tcaputi) for finding this issue)
|
||||||
|
* Do not set rate visitor for non-eligible topics (no ticket)
|
||||||
|
* Do not cache `config.js` ([#1098](https://github.com/binwiederhier/ntfy/pull/1098), thanks to [@wunter8](https://github.com/wunter8))
|
||||||
|
|
||||||
|
### ntfy server v2.10.0
|
||||||
|
Released Mar 27, 2024
|
||||||
|
|
||||||
|
This release adds support for **message templating** in the ntfy server, which allows you to include a message and/or
|
||||||
|
title template that will be filled with values from a JSON body (e.g. `curl -gd '{"alert":"Disk space low"}' "ntfy.sh/mytopic?tpl=1&m={{.alert}}"`).
|
||||||
|
This is great for services that let you specify a webhook URL but do not let you change the webhook body (such as GitHub, or Grafana).
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* [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
|
||||||
|
Released Mar 7, 2024
|
||||||
|
|
||||||
|
A small release after a long pause (lots of day job work). This release adds for **larger messages** and **longer
|
||||||
|
message delays** in scheduled delivery messages. The web app also now supports pasting images from the clipboard. Other
|
||||||
|
than that, only a few bug fixes and documentation updates, and a teeny tiny breaking change 😬.
|
||||||
|
|
||||||
|
!!! info
|
||||||
|
⚠️ **Breaking change**: The `Rate-Topics` header was removed due to a [DoS issue](https://github.com/binwiederhier/ntfy/issues/1048). This only affects
|
||||||
|
installations with `visitor-subscriber-rate-limiting: true`, which is not the default and likely very rarely used.
|
||||||
|
Normally I'd never remove a feature, but this is a security issue, and likely affects almost nobody.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Support for larger message delays with `message-delay-limit` (see [message limits](config.md#message-limits), [#1050](https://github.com/binwiederhier/ntfy/pull/1050)/[#1019](https://github.com/binwiederhier/ntfy/issues/1019), thanks to [@MrChadMWood](https://github.com/MrChadMWood) for reporting)
|
||||||
|
* Support for larger message body sizes with `message-size-limit` (use at your own risk, see [message limits](config.md#message-limits), [#836](https://github.com/binwiederhier/ntfy/pull/836)/[#1050](https://github.com/binwiederhier/ntfy/pull/1050), thanks to [@zhzy0077](https://github.com/zhzy0077) for implementing this, and to [@nkjshlsqja7331](https://github.com/nkjshlsqja7331) for reporting)
|
||||||
|
* Web app: You can now paste images into the message bar or publish dialog ([#963](https://github.com/binwiederhier/ntfy/pull/963)/[#572](https://github.com/binwiederhier/ntfy/issues/572), thanks to [@cmj2002](https://github.com/cmj2002) for implementing, and [@rounakdatta](https://github.com/rounakdatta) for reporting)
|
||||||
|
|
||||||
|
**Bug fixes + maintenance:**
|
||||||
|
|
||||||
|
* ⚠️ Remove `Rate-Topics` header due to DoS security issue if `visitor-subscriber-rate-limiting: true` ([#1048](https://github.com/binwiederhier/ntfy/issues/1048))
|
||||||
|
|
||||||
|
**Documentation:**
|
||||||
|
|
||||||
|
* Remove `mkdocs-simple-hooks` ([#1016](https://github.com/binwiederhier/ntfy/pull/1016), thanks to [@Tom-Hubrecht](https://github.com/Tom-Hubrecht))
|
||||||
|
* Update Watchtower example ([#1014](https://github.com/binwiederhier/ntfy/pull/1014), thanks to [@lennart-m](https://github.com/lennart-m))
|
||||||
|
* Fix dead links ([#1022](https://github.com/binwiederhier/ntfy/pull/1022), thanks to [@DerRockWolf](https://github.com/DerRockWolf))
|
||||||
|
* PowerShell file upload example ([#1004](https://github.com/binwiederhier/ntfy/pull/1004), thanks to [@YMan84](https://github.com/YMan84))
|
||||||
|
|
||||||
## ntfy iOS app v1.3
|
## ntfy iOS app v1.3
|
||||||
Released Nov 26, 2023
|
Released Nov 26, 2023
|
||||||
|
|
||||||
This release (hopefully) fixes the issues with the iOS UI not updating properly when new notifications arrive, as well as notifications not being received (anymore) after previously working. Both issues have been annoying and known bugs for a long time, and I hope that they are finally fixed.
|
This release (hopefully) fixes the issues with the iOS UI not updating properly when new notifications arrive, as well
|
||||||
|
as notifications not being received (anymore) after previously working. Both issues have been annoying and known bugs
|
||||||
|
for a long time, and I hope that they are finally fixed.
|
||||||
|
|
||||||
Many thanks to [@tcaputi](https://github.com/tcaputi) for fixing the issues, and to the anonymous donor for sponsoring these fixes.
|
Many thanks to [@tcaputi](https://github.com/tcaputi) for fixing the issues, and to the anonymous donor for sponsoring these fixes.
|
||||||
|
|
||||||
@@ -13,10 +149,13 @@ Many thanks to [@tcaputi](https://github.com/tcaputi) for fixing the issues, and
|
|||||||
|
|
||||||
* UI not updating properly ([#267](https://github.com/binwiederhier/ntfy/issues/267)/[#402](https://github.com/binwiederhier/ntfy/issues/402), thanks to [@tcaputi](https://github.com/tcaputi))
|
* UI not updating properly ([#267](https://github.com/binwiederhier/ntfy/issues/267)/[#402](https://github.com/binwiederhier/ntfy/issues/402), thanks to [@tcaputi](https://github.com/tcaputi))
|
||||||
|
|
||||||
### ntfy server v2.8.0
|
## ntfy server v2.8.0
|
||||||
Released November 19, 2023
|
Released November 19, 2023
|
||||||
|
|
||||||
This release brings a handful of random bug fixes: two unrelated access control list fixes, a fix around web app crashes for languages with underscores in the language code (e.g. `zh_Hant`, `zh_Hans`, `pt_BR`, ...), a workaround for the `Priority` header (often used in Cloudflare setups), and support among others support for HTML-only emails (finally), web app crash fixes
|
This release brings a handful of random bug fixes: two unrelated access control list fixes, a fix around web app crashes
|
||||||
|
for languages with underscores in the language code (e.g. `zh_Hant`, `zh_Hans`, `pt_BR`, ...), a workaround for the
|
||||||
|
`Priority` header (often used in Cloudflare setups), and support among others support for HTML-only emails (finally),
|
||||||
|
web app crash fixes
|
||||||
|
|
||||||
**Bug fixes + maintenance:**
|
**Bug fixes + maintenance:**
|
||||||
|
|
||||||
@@ -629,7 +768,7 @@ minute or so, due to competing stats gathering (personal installations will like
|
|||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
|
||||||
* Add `cache-startup-queries` option to allow custom [SQLite performance tuning](config.md#wal-for-message-cache) (no ticket)
|
* Add `cache-startup-queries` option to allow custom [SQLite performance tuning](config.md#message-cache) (no ticket)
|
||||||
* ntfy CLI can now [wait for a command or PID](subscribe/cli.md#wait-for-pidcommand) before publishing ([#263](https://github.com/binwiederhier/ntfy/issues/263), thanks to the [original ntfy](https://github.com/dschep/ntfy) for the idea)
|
* ntfy CLI can now [wait for a command or PID](subscribe/cli.md#wait-for-pidcommand) before publishing ([#263](https://github.com/binwiederhier/ntfy/issues/263), thanks to the [original ntfy](https://github.com/dschep/ntfy) for the idea)
|
||||||
* Trace: Log entire HTTP request to simplify debugging (no ticket)
|
* Trace: Log entire HTTP request to simplify debugging (no ticket)
|
||||||
* Allow setting user password via `NTFY_PASSWORD` env variable ([#327](https://github.com/binwiederhier/ntfy/pull/327), thanks to [@Kenix3](https://github.com/Kenix3))
|
* Allow setting user password via `NTFY_PASSWORD` env variable ([#327](https://github.com/binwiederhier/ntfy/pull/327), thanks to [@Kenix3](https://github.com/Kenix3))
|
||||||
@@ -1313,12 +1452,6 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
|||||||
|
|
||||||
## Not released yet
|
## Not released yet
|
||||||
|
|
||||||
### ntfy server v2.9.0
|
|
||||||
|
|
||||||
**Bug fixes + maintenance:**
|
|
||||||
|
|
||||||
* Remove `Rate-Topics` header due to DoS security issue if `visitor-subscriber-rate-limiting: true` ([#1048](https://github.com/binwiederhier/ntfy/issues/1048))
|
|
||||||
|
|
||||||
### ntfy Android app v1.16.1 (UNRELEASED)
|
### ntfy Android app v1.16.1 (UNRELEASED)
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
|||||||
BIN
docs/static/img/android-screenshot-template.jpg
vendored
Normal file
|
After Width: | Height: | Size: 122 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/mobile-screenshot-notification.png
vendored
Normal file
|
After Width: | Height: | Size: 71 KiB |
130
docs/static/js/extra.js
vendored
@@ -1,99 +1,103 @@
|
|||||||
// Link tabs, as per https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs
|
// Link tabs, as per https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs
|
||||||
|
|
||||||
const savedCodeTab = localStorage.getItem('savedTab')
|
const savedCodeTab = localStorage.getItem("savedTab");
|
||||||
const codeTabs = document.querySelectorAll(".tabbed-set > input")
|
const codeTabs = document.querySelectorAll(".tabbed-set > input");
|
||||||
for (const tab of codeTabs) {
|
for (const tab of codeTabs) {
|
||||||
tab.addEventListener("click", () => {
|
tab.addEventListener("click", () => {
|
||||||
const current = document.querySelector(`label[for=${tab.id}]`)
|
const current = document.querySelector(`label[for=${tab.id}]`);
|
||||||
const pos = current.getBoundingClientRect().top
|
const pos = current.getBoundingClientRect().top;
|
||||||
const labelContent = current.innerHTML
|
const labelContent = current.innerHTML;
|
||||||
const labels = document.querySelectorAll('.tabbed-set > label, .tabbed-alternate > .tabbed-labels > label')
|
const labels = document.querySelectorAll(".tabbed-set > label, .tabbed-alternate > .tabbed-labels > label");
|
||||||
for (const label of labels) {
|
for (const label of labels) {
|
||||||
if (label.innerHTML === labelContent) {
|
if (label.innerHTML === labelContent) {
|
||||||
document.querySelector(`input[id=${label.getAttribute('for')}]`).checked = true
|
document.querySelector(`input[id=${label.getAttribute("for")}]`).checked = true;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Preserve scroll position
|
|
||||||
const delta = (current.getBoundingClientRect().top) - pos
|
|
||||||
window.scrollBy(0, delta)
|
|
||||||
|
|
||||||
// Save
|
|
||||||
localStorage.setItem('savedTab', labelContent)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Select saved tab
|
|
||||||
const current = document.querySelector(`label[for=${tab.id}]`)
|
|
||||||
const labelContent = current.innerHTML
|
|
||||||
if (savedCodeTab === labelContent) {
|
|
||||||
tab.checked = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preserve scroll position
|
||||||
|
const delta = (current.getBoundingClientRect().top) - pos;
|
||||||
|
window.scrollBy(0, delta);
|
||||||
|
|
||||||
|
// Save
|
||||||
|
localStorage.setItem("savedTab", labelContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select saved tab
|
||||||
|
const current = document.querySelector(`label[for=${tab.id}]`);
|
||||||
|
const labelContent = current.innerHTML;
|
||||||
|
if (savedCodeTab === labelContent) {
|
||||||
|
tab.checked = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lightbox for screenshot
|
// Lightbox for screenshot
|
||||||
|
|
||||||
const lightbox = document.createElement('div');
|
const lightbox = document.createElement("div");
|
||||||
lightbox.classList.add('lightbox');
|
lightbox.classList.add("lightbox");
|
||||||
document.body.appendChild(lightbox);
|
document.body.appendChild(lightbox);
|
||||||
|
|
||||||
const showScreenshotOverlay = (e, el, group, index) => {
|
const showScreenshotOverlay = (e, el, group, index) => {
|
||||||
lightbox.classList.add('show');
|
lightbox.classList.add("show");
|
||||||
document.addEventListener('keydown', nextScreenshotKeyboardListener);
|
document.addEventListener("keydown", nextScreenshotKeyboardListener);
|
||||||
return showScreenshot(e, group, index);
|
return showScreenshot(e, group, index);
|
||||||
};
|
};
|
||||||
|
|
||||||
const showScreenshot = (e, group, index) => {
|
const showScreenshot = (e, group, index) => {
|
||||||
const actualIndex = resolveScreenshotIndex(group, index);
|
const actualIndex = resolveScreenshotIndex(group, index);
|
||||||
lightbox.innerHTML = '<div class="close-lightbox"></div>' + screenshots[group][actualIndex].innerHTML;
|
lightbox.innerHTML = "<div class=\"close-lightbox\"></div>" + screenshots[group][actualIndex].innerHTML;
|
||||||
lightbox.querySelector('img').onclick = (e) => { return showScreenshot(e, group, actualIndex+1); };
|
lightbox.querySelector("img").onclick = (e) => {
|
||||||
currentScreenshotGroup = group;
|
return showScreenshot(e, group, actualIndex + 1);
|
||||||
currentScreenshotIndex = actualIndex;
|
};
|
||||||
e.stopPropagation();
|
currentScreenshotGroup = group;
|
||||||
return false;
|
currentScreenshotIndex = actualIndex;
|
||||||
|
e.stopPropagation();
|
||||||
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextScreenshot = (e) => {
|
const nextScreenshot = (e) => {
|
||||||
return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex+1);
|
return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const previousScreenshot = (e) => {
|
const previousScreenshot = (e) => {
|
||||||
return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex-1);
|
return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex - 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveScreenshotIndex = (group, index) => {
|
const resolveScreenshotIndex = (group, index) => {
|
||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
return screenshots[group].length - 1;
|
return screenshots[group].length - 1;
|
||||||
} else if (index > screenshots[group].length - 1) {
|
} else if (index > screenshots[group].length - 1) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
return index;
|
return index;
|
||||||
};
|
};
|
||||||
|
|
||||||
const hideScreenshotOverlay = (e) => {
|
const hideScreenshotOverlay = (e) => {
|
||||||
lightbox.classList.remove('show');
|
lightbox.classList.remove("show");
|
||||||
document.removeEventListener('keydown', nextScreenshotKeyboardListener);
|
document.removeEventListener("keydown", nextScreenshotKeyboardListener);
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextScreenshotKeyboardListener = (e) => {
|
const nextScreenshotKeyboardListener = (e) => {
|
||||||
switch (e.keyCode) {
|
switch (e.keyCode) {
|
||||||
case 37:
|
case 37:
|
||||||
previousScreenshot(e);
|
previousScreenshot(e);
|
||||||
break;
|
break;
|
||||||
case 39:
|
case 39:
|
||||||
nextScreenshot(e);
|
nextScreenshot(e);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let currentScreenshotGroup = '';
|
let currentScreenshotGroup = "";
|
||||||
let currentScreenshotIndex = 0;
|
let currentScreenshotIndex = 0;
|
||||||
let screenshots = {};
|
let screenshots = {};
|
||||||
Array.from(document.getElementsByClassName('screenshots')).forEach((sg) => {
|
Array.from(document.getElementsByClassName("screenshots")).forEach((sg) => {
|
||||||
const group = sg.id;
|
const group = sg.id;
|
||||||
screenshots[group] = [...sg.querySelectorAll('a')];
|
screenshots[group] = [...sg.querySelectorAll("a")];
|
||||||
screenshots[group].forEach((el, index) => {
|
screenshots[group].forEach((el, index) => {
|
||||||
el.onclick = (e) => { return showScreenshotOverlay(e, el, group, index); };
|
el.onclick = (e) => {
|
||||||
});
|
return showScreenshotOverlay(e, el, group, index);
|
||||||
|
};
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
lightbox.onclick = hideScreenshotOverlay;
|
lightbox.onclick = hideScreenshotOverlay;
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ easy to use. Here's what it looks like. You may also want to check out the [full
|
|||||||
### Subscribe as raw stream
|
### Subscribe as raw stream
|
||||||
The `/raw` endpoint will output one line per message, and **will only include the message body**. It's useful for extremely
|
The `/raw` endpoint will output one line per message, and **will only include the message body**. It's useful for extremely
|
||||||
simple scripts, and doesn't include all the data. Additional fields such as [priority](../publish.md#message-priority),
|
simple scripts, and doesn't include all the data. Additional fields such as [priority](../publish.md#message-priority),
|
||||||
[tags](../publish.md#tags--emojis--) or [message title](../publish.md#message-title) are not included in this output
|
[tags](../publish.md#tags-emojis) or [message title](../publish.md#message-title) are not included in this output
|
||||||
format. Keepalive messages are sent as empty lines.
|
format. Keepalive messages are sent as empty lines.
|
||||||
|
|
||||||
=== "Command line (curl)"
|
=== "Command line (curl)"
|
||||||
@@ -257,6 +257,14 @@ curl -s "ntfy.sh/mytopic/json?since=1645970742"
|
|||||||
curl -s "ntfy.sh/mytopic/json?since=nFS3knfcQ1xe"
|
curl -s "ntfy.sh/mytopic/json?since=nFS3knfcQ1xe"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Fetch latest message
|
||||||
|
If you only want the most recent message sent to a topic and do not have a message ID or timestamp to use with
|
||||||
|
`since=`, you can use `since=latest` to grab the most recent message from the cache for a particular topic.
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -s "ntfy.sh/mytopic/json?poll=1&since=latest"
|
||||||
|
```
|
||||||
|
|
||||||
### Fetch scheduled messages
|
### Fetch scheduled messages
|
||||||
Messages that are [scheduled to be delivered](../publish.md#scheduled-delivery) at a later date are not typically
|
Messages that are [scheduled to be delivered](../publish.md#scheduled-delivery) at a later date are not typically
|
||||||
returned when subscribing via the API, which makes sense, because after all, the messages have technically not been
|
returned when subscribing via the API, which makes sense, because after all, the messages have technically not been
|
||||||
@@ -305,7 +313,7 @@ Depending on whether the server is configured to support [access control](../con
|
|||||||
may be read/write protected so that only users with the correct credentials can subscribe or publish to them.
|
may be read/write protected so that only users with the correct credentials can subscribe or publish to them.
|
||||||
To publish/subscribe to protected topics, you can:
|
To publish/subscribe to protected topics, you can:
|
||||||
|
|
||||||
* Use [basic auth](../publish.md#basic-auth), e.g. `Authorization: Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk`
|
* Use [basic auth](../publish.md#authentication), e.g. `Authorization: Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk`
|
||||||
* or use the [`auth` query parameter](../publish.md#query-param), e.g. `?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw`
|
* or use the [`auth` query parameter](../publish.md#query-param), e.g. `?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw`
|
||||||
|
|
||||||
Please refer to the [publishing documentation](../publish.md#authentication) for additional details.
|
Please refer to the [publishing documentation](../publish.md#authentication) for additional details.
|
||||||
|
|||||||
@@ -190,6 +190,10 @@ Here's an example config file that subscribes to three different topics, executi
|
|||||||
|
|
||||||
=== "~/.config/ntfy/client.yml (Linux)"
|
=== "~/.config/ntfy/client.yml (Linux)"
|
||||||
```yaml
|
```yaml
|
||||||
|
default-host: https://ntfy.sh
|
||||||
|
default-user: phill
|
||||||
|
default-password: mypass
|
||||||
|
|
||||||
subscribe:
|
subscribe:
|
||||||
- topic: echo-this
|
- topic: echo-this
|
||||||
command: 'echo "Message received: $message"'
|
command: 'echo "Message received: $message"'
|
||||||
@@ -210,9 +214,12 @@ Here's an example config file that subscribes to three different topics, executi
|
|||||||
fi
|
fi
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
=== "~/Library/Application Support/ntfy/client.yml (macOS)"
|
=== "~/Library/Application Support/ntfy/client.yml (macOS)"
|
||||||
```yaml
|
```yaml
|
||||||
|
default-host: https://ntfy.sh
|
||||||
|
default-user: phill
|
||||||
|
default-password: mypass
|
||||||
|
|
||||||
subscribe:
|
subscribe:
|
||||||
- topic: echo-this
|
- topic: echo-this
|
||||||
command: 'echo "Message received: $message"'
|
command: 'echo "Message received: $message"'
|
||||||
@@ -226,6 +233,10 @@ Here's an example config file that subscribes to three different topics, executi
|
|||||||
|
|
||||||
=== "%AppData%\ntfy\client.yml (Windows)"
|
=== "%AppData%\ntfy\client.yml (Windows)"
|
||||||
```yaml
|
```yaml
|
||||||
|
default-host: https://ntfy.sh
|
||||||
|
default-user: phill
|
||||||
|
default-password: mypass
|
||||||
|
|
||||||
subscribe:
|
subscribe:
|
||||||
- topic: echo-this
|
- topic: echo-this
|
||||||
command: 'echo Message received: %message%'
|
command: 'echo Message received: %message%'
|
||||||
@@ -263,43 +274,31 @@ will be used, otherwise, the subscription settings will override the defaults.
|
|||||||
require authentication), be sure that the servers/topics you subscribe to use HTTPS to prevent leaking the username and password.
|
require authentication), be sure that the servers/topics you subscribe to use HTTPS to prevent leaking the username and password.
|
||||||
|
|
||||||
### Using the systemd service
|
### Using the systemd service
|
||||||
You can use the `ntfy-client` systemd service (see [ntfy-client.service](https://github.com/binwiederhier/ntfy/blob/main/client/ntfy-client.service))
|
You can use the `ntfy-client` systemd services to subscribe to multiple topics just like in the example above.
|
||||||
to subscribe to multiple topics just like in the example above. The service is automatically installed (but not started)
|
|
||||||
if you install the deb/rpm package. To configure it, simply edit `/etc/ntfy/client.yml` and run `sudo systemctl restart ntfy-client`.
|
|
||||||
|
|
||||||
!!! info
|
You have the option of either enabling `ntfy-client` as a **system service** (see [here](https://github.com/binwiederhier/ntfy/blob/main/client/ntfy-client.service))
|
||||||
The `ntfy-client.service` runs as user `ntfy`, meaning that typical Linux permission restrictions apply. See below
|
or **user service** (see [here](https://github.com/binwiederhier/ntfy/blob/main/client/user/ntfy-client.service)). Neither system service nor user service are enabled or started by default, so you have to do that yourself.
|
||||||
for how to fix this.
|
|
||||||
|
|
||||||
If the service runs on your personal desktop machine, you may want to override the service user/group (`User=` and `Group=`), and
|
**System service:** The `ntfy-client` systemd system service runs as the `ntfy` user. When enabled, it is started at system boot. To configure it as a system
|
||||||
adjust the `DISPLAY` and `DBUS_SESSION_BUS_ADDRESS` environment variables. This will allow you to run commands in your X session
|
service, edit `/etc/ntfy/client.yml` and then enable/start the service (as root), like so:
|
||||||
as the primary machine user.
|
|
||||||
|
|
||||||
You can either manually override these systemd service entries with `sudo systemctl edit ntfy-client`, and add this
|
|
||||||
(assuming your user is `phil`). Don't forget to run `sudo systemctl daemon-reload` and `sudo systemctl restart ntfy-client`
|
|
||||||
after editing the service file:
|
|
||||||
|
|
||||||
=== "/etc/systemd/system/ntfy-client.service.d/override.conf"
|
|
||||||
```
|
|
||||||
[Service]
|
|
||||||
User=phil
|
|
||||||
Group=phil
|
|
||||||
Environment="DISPLAY=:0" "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus"
|
|
||||||
```
|
|
||||||
Or you can run the following script that creates this override config for you:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
sudo sh -c 'cat > /etc/systemd/system/ntfy-client.service.d/override.conf' <<EOF
|
sudo systemctl enable ntfy-client
|
||||||
[Service]
|
|
||||||
User=$USER
|
|
||||||
Group=$USER
|
|
||||||
Environment="DISPLAY=:0" "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus"
|
|
||||||
EOF
|
|
||||||
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl restart ntfy-client
|
sudo systemctl restart ntfy-client
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The system service runs as user `ntfy`, meaning that typical Linux permission restrictions apply. It also means that the system service cannot run commands in your X session as the primary machine user (unlike the user service).
|
||||||
|
|
||||||
|
**User service:** The `ntfy-client` user service is run when the user logs into their desktop environment. To enable/start it, edit `~/.config/ntfy/client.yml` and
|
||||||
|
run the following commands (without sudo!):
|
||||||
|
|
||||||
|
```
|
||||||
|
systemctl --user enable ntfy-client
|
||||||
|
systemctl --user restart ntfy-client
|
||||||
|
```
|
||||||
|
|
||||||
|
Unlike the system service, the user service can interact with the user's desktop environment, and run commands like `notify-send` to display desktop notifications.
|
||||||
|
It can also run commands that require access to the user's home directory, such as `gnome-calculator`.
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
Depending on whether the server is configured to support [access control](../config.md#access-control), some topics
|
Depending on whether the server is configured to support [access control](../config.md#access-control), some topics
|
||||||
@@ -317,7 +316,7 @@ You can either add your username and password to the configuration file:
|
|||||||
password: mypass
|
password: mypass
|
||||||
```
|
```
|
||||||
|
|
||||||
Or with the `ntfy subscibe` command:
|
Or with the `ntfy subscribe` command:
|
||||||
```
|
```
|
||||||
ntfy subscribe \
|
ntfy subscribe \
|
||||||
-u phil:mypass \
|
-u phil:mypass \
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ to receive notifications directly on your phone. Just like the server, this app
|
|||||||
on GitHub ([Android](https://github.com/binwiederhier/ntfy-android), [iOS](https://github.com/binwiederhier/ntfy-ios)). Feel free to
|
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 both [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) and
|
||||||
from [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/). Both are largely identical, with the one exception that
|
from [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/). Both are largely identical, with the one exception that
|
||||||
|
|||||||
134
go.mod
@@ -1,27 +1,27 @@
|
|||||||
module heckel.io/ntfy/v2
|
module heckel.io/ntfy/v2
|
||||||
|
|
||||||
go 1.21
|
go 1.24
|
||||||
|
|
||||||
toolchain go1.21.3
|
toolchain go1.24.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go/firestore v1.15.0 // indirect
|
cloud.google.com/go/firestore v1.18.0 // indirect
|
||||||
cloud.google.com/go/storage v1.39.0 // indirect
|
cloud.google.com/go/storage v1.55.0 // indirect
|
||||||
github.com/BurntSushi/toml v1.3.2 // indirect
|
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.3 // 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.3
|
github.com/gabriel-vasile/mimetype v1.4.9
|
||||||
github.com/gorilla/websocket v1.5.1
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/mattn/go-sqlite3 v1.14.22
|
github.com/mattn/go-sqlite3 v1.14.28
|
||||||
github.com/olebedev/when v1.0.0
|
github.com/olebedev/when v1.1.0
|
||||||
github.com/stretchr/testify v1.8.4
|
github.com/stretchr/testify v1.10.0
|
||||||
github.com/urfave/cli/v2 v2.27.1
|
github.com/urfave/cli/v2 v2.27.7
|
||||||
golang.org/x/crypto v0.21.0
|
golang.org/x/crypto v0.39.0
|
||||||
golang.org/x/oauth2 v0.18.0 // indirect
|
golang.org/x/oauth2 v0.30.0 // indirect
|
||||||
golang.org/x/sync v0.6.0
|
golang.org/x/sync v0.15.0
|
||||||
golang.org/x/term v0.18.0
|
golang.org/x/term v0.32.0
|
||||||
golang.org/x/time v0.5.0
|
golang.org/x/time v0.12.0
|
||||||
google.golang.org/api v0.168.0
|
google.golang.org/api v0.240.0
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,61 +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.13.0
|
firebase.google.com/go/v4 v4.16.1
|
||||||
github.com/SherClockHolmes/webpush-go v1.3.0
|
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||||
github.com/microcosm-cc/bluemonday v1.0.26
|
github.com/microcosm-cc/bluemonday v1.0.27
|
||||||
github.com/prometheus/client_golang v1.19.0
|
github.com/prometheus/client_golang v1.22.0
|
||||||
github.com/stripe/stripe-go/v74 v74.30.0
|
github.com/stripe/stripe-go/v74 v74.30.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go v0.112.1 // indirect
|
cel.dev/expr v0.24.0 // indirect
|
||||||
cloud.google.com/go/compute v1.25.0 // indirect
|
cloud.google.com/go v0.121.3 // indirect
|
||||||
cloud.google.com/go/compute/metadata v0.2.3 // indirect
|
cloud.google.com/go/auth v0.16.2 // indirect
|
||||||
cloud.google.com/go/iam v1.1.6 // indirect
|
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||||
cloud.google.com/go/longrunning v0.5.5 // indirect
|
cloud.google.com/go/compute/metadata v0.7.0 // indirect
|
||||||
|
cloud.google.com/go/iam v1.5.2 // indirect
|
||||||
|
cloud.google.com/go/longrunning v0.6.7 // indirect
|
||||||
|
cloud.google.com/go/monitoring v1.24.2 // indirect
|
||||||
github.com/AlekSi/pointer v1.2.0 // indirect
|
github.com/AlekSi/pointer v1.2.0 // indirect
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect
|
||||||
github.com/MicahParks/keyfunc v1.9.0 // indirect
|
github.com/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.2.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect
|
||||||
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||||
|
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
|
||||||
|
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/go-logr/logr v1.4.1 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.1 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
|
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
|
||||||
github.com/golang/protobuf v1.5.4 // indirect
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
github.com/google/s2a-go v0.1.7 // 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.2 // indirect
|
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.12.2 // indirect
|
github.com/googleapis/gax-go/v2 v2.14.2 // indirect
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/kr/text v0.2.0 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||||
github.com/prometheus/client_model v0.6.0 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/prometheus/common v0.50.0 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/procfs v0.13.0 // indirect
|
github.com/prometheus/common v0.65.0 // indirect
|
||||||
|
github.com/prometheus/procfs v0.17.0 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
github.com/stretchr/objx v0.5.0 // indirect
|
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
|
||||||
github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e // indirect
|
github.com/stretchr/objx v0.5.2 // indirect
|
||||||
go.opencensus.io v0.24.0 // indirect
|
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect
|
github.com/zeebo/errs v1.4.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
|
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.24.0 // indirect
|
go.opentelemetry.io/contrib/detectors/gcp v1.37.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.24.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.24.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
|
||||||
golang.org/x/net v0.22.0 // indirect
|
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||||
golang.org/x/sys v0.18.0 // indirect
|
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||||
golang.org/x/text v0.14.0 // indirect
|
go.opentelemetry.io/otel/sdk v1.37.0 // indirect
|
||||||
google.golang.org/appengine v1.6.8 // indirect
|
go.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect
|
||||||
google.golang.org/appengine/v2 v2.0.5 // indirect
|
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||||
google.golang.org/genproto v0.0.0-20240304212257-790db918fca8 // indirect
|
golang.org/x/net v0.41.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240304212257-790db918fca8 // indirect
|
golang.org/x/sys v0.33.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240304212257-790db918fca8 // indirect
|
golang.org/x/text v0.26.0 // indirect
|
||||||
google.golang.org/grpc v1.62.1 // indirect
|
google.golang.org/appengine/v2 v2.0.6 // indirect
|
||||||
google.golang.org/protobuf v1.33.0 // indirect
|
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
|
||||||
|
google.golang.org/grpc v1.73.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.6 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
395
go.sum
@@ -1,207 +1,219 @@
|
|||||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
|
||||||
cloud.google.com/go v0.112.1 h1:uJSeirPke5UNZHIb4SxfZklVSiWWVqW4oXlETwZziwM=
|
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
|
||||||
cloud.google.com/go v0.112.1/go.mod h1:+Vbu+Y1UU+I1rjmzeMOb/8RfkKJK2Gyxi1X6jJCZLo4=
|
cloud.google.com/go v0.121.3 h1:84RD+hQXNdY5Sw/MWVAx5O9Aui/rd5VQ9HEcdN19afo=
|
||||||
cloud.google.com/go/compute v1.25.0 h1:H1/4SqSUhjPFE7L5ddzHOfY2bCAvjwNRZPNl6Ni5oYU=
|
cloud.google.com/go v0.121.3/go.mod h1:6vWF3nJWRrEUv26mMB3FEIU/o1MQNVPG1iHdisa2SJc=
|
||||||
cloud.google.com/go/compute v1.25.0/go.mod h1:GR7F0ZPZH8EhChlMo9FkLd7eUTwEymjqQagxzilIxIE=
|
cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4=
|
||||||
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
|
cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA=
|
||||||
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
|
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||||
cloud.google.com/go/firestore v1.15.0 h1:/k8ppuWOtNuDHt2tsRV42yI21uaGnKDEQnRFeBpbFF8=
|
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||||
cloud.google.com/go/firestore v1.15.0/go.mod h1:GWOxFXcv8GZUtYpWHw/w6IuYNux/BtmeVTMmjrm4yhk=
|
cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
|
||||||
cloud.google.com/go/iam v1.1.6 h1:bEa06k05IO4f4uJonbB5iAgKTPpABy1ayxaIZV/GHVc=
|
cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
|
||||||
cloud.google.com/go/iam v1.1.6/go.mod h1:O0zxdPeGBoFdWW3HWmBxJsk0pfvNM/p/qa82rWOGTwI=
|
cloud.google.com/go/firestore v1.18.0 h1:cuydCaLS7Vl2SatAeivXyhbhDEIR8BDmtn4egDhIn2s=
|
||||||
cloud.google.com/go/longrunning v0.5.5 h1:GOE6pZFdSrTb4KAiKnXsJBtlE6mEyaW44oKyMILWnOg=
|
cloud.google.com/go/firestore v1.18.0/go.mod h1:5ye0v48PhseZBdcl0qbl3uttu7FIEwEYVaWm0UIEOEU=
|
||||||
cloud.google.com/go/longrunning v0.5.5/go.mod h1:WV2LAxD8/rg5Z1cNW6FJ/ZpX4E4VnDnoTk0yawPBB7s=
|
cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8=
|
||||||
cloud.google.com/go/storage v1.39.0 h1:brbjUa4hbDHhpQf48tjqMaXEV+f1OGoaTmQau9tmCsA=
|
cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE=
|
||||||
cloud.google.com/go/storage v1.39.0/go.mod h1:OAEj/WZwUYjA3YHQ10/YcN9ttGuEpLwvaoyBXIPikEk=
|
cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc=
|
||||||
firebase.google.com/go/v4 v4.13.0 h1:meFz9nvDNh/FDyrEykoAzSfComcQbmnQSjoHrePRqeI=
|
cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA=
|
||||||
firebase.google.com/go/v4 v4.13.0/go.mod h1:e1/gaR6EnbQfsmTnAMx1hnz+ninJIrrr/RAh59Tpfn8=
|
cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE=
|
||||||
|
cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY=
|
||||||
|
cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM=
|
||||||
|
cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U=
|
||||||
|
cloud.google.com/go/storage v1.55.0 h1:NESjdAToN9u1tmhVqhXCaCwYBuvEhZLLv0gBr+2znf0=
|
||||||
|
cloud.google.com/go/storage v1.55.0/go.mod h1:ztSmTTwzsdXe5syLVS0YsbFxXuvEmEyZj7v7zChEmuY=
|
||||||
|
cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4=
|
||||||
|
cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI=
|
||||||
|
firebase.google.com/go/v4 v4.16.1 h1:Kl5cgXmM0VOWDGT1UAx6b0T2UFWa14ak0CvYqeI7Py4=
|
||||||
|
firebase.google.com/go/v4 v4.16.1/go.mod h1:aAPJq/bOyb23tBlc1K6GR+2E8sOGAeJSc8wIJVgl9SM=
|
||||||
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 v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||||
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
|
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 h1:UQUsRi8WTzhZntp5313l+CHIAT95ojUI2lpP/ExlZa4=
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw=
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM=
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs=
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0 h1:4LP6hvB4I5ouTbGgWtixJhgED6xdf67twf9PoY96Tbg=
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0/go.mod h1:jUZ5LYlw40WMd07qxcQJD5M40aUxrfwqQX1g7zxYnrQ=
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ=
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo=
|
||||||
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
|
github.com/MicahParks/keyfunc v1.9.0 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.3.0 h1:CAu3FvEE9QS4drc3iKNgpBWFfGqNthKlZhp5QpYnu6k=
|
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||||
github.com/SherClockHolmes/webpush-go v1.3.0/go.mod h1:AxRHmJuYwKGG1PVgYzToik1lphQvDnqFYDqimHvwhIw=
|
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
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/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls=
|
||||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
|
||||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM=
|
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
|
||||||
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=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 h1:hH4PQfOndHDlpzYfLAAfl63E8Le6F2+EL/cdhlkyRJY=
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||||
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
github.com/emersion/go-smtp v0.17.0 h1:tq90evlrcyqRfE6DSXaWVH54oX6OuZOQECEmhWBMEtI=
|
github.com/emersion/go-smtp v0.17.0 h1:tq90evlrcyqRfE6DSXaWVH54oX6OuZOQECEmhWBMEtI=
|
||||||
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.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
|
||||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
|
||||||
|
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
|
||||||
|
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
|
||||||
|
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
|
||||||
|
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
|
||||||
github.com/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.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
|
||||||
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
|
||||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
|
||||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
|
||||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
|
||||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
|
||||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
|
||||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
|
||||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
|
||||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
|
||||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
|
||||||
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.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
|
||||||
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=
|
||||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
|
||||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
|
||||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
|
||||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
|
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
|
||||||
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
|
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
|
||||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||||
|
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 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.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||||
github.com/googleapis/gax-go/v2 v2.12.2 h1:mhN09QQW1jEWeMF74zGR81R30z4VJzjZsfkUhuHF+DA=
|
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
|
||||||
github.com/googleapis/gax-go/v2 v2.12.2/go.mod h1:61M8vcyyXR2kqKFxKrfA22jaA8JGF7Dc8App1U3H6jc=
|
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
|
||||||
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.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
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/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
|
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
|
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/olebedev/when v1.0.0 h1:T2DZCj8HxUhOVxcqaLOmzuTr+iZLtMHsZEim7mjIA2w=
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
github.com/olebedev/when v1.0.0/go.mod h1:T0THb4kP9D3NNqlvCwIG4GyUioTAzEhB4RNVzig/43E=
|
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/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
|
github.com/olebedev/when v1.1.0 h1:dlpoRa7huImhNtEx4yl0WYfTHVEWmJmIWd7fEkTHayc=
|
||||||
|
github.com/olebedev/when v1.1.0/go.mod h1:T0THb4kP9D3NNqlvCwIG4GyUioTAzEhB4RNVzig/43E=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
||||||
|
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
|
||||||
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/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
||||||
github.com/prometheus/client_model v0.6.0 h1:k1v3CzpSRUTrKMppY35TLwPvxHqBu0bYgxZzqGIgaos=
|
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
||||||
github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOAGQPoaGhyTchlyt8=
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
github.com/prometheus/common v0.50.0 h1:YSZE6aa9+luNa2da6/Tik0q0A5AbR+U003TItK57CPQ=
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
github.com/prometheus/common v0.50.0/go.mod h1:wHFBCEVWVmHMUpg7pYcOm2QUR/ocQdYSJVQJKnHc3xQ=
|
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
||||||
github.com/prometheus/procfs v0.13.0 h1:GqzLlQyfsPbaEHaQkO7tbDlriv/4o5Hudv6OXHGKX7o=
|
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
||||||
github.com/prometheus/procfs v0.13.0/go.mod h1:cd4PFCR54QLnGKPaKGA6l+cfuNXtht43ZKY6tow0Y1g=
|
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
||||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
||||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
|
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
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.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g=
|
||||||
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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
|
||||||
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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
|
||||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
|
||||||
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.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho=
|
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
|
||||||
github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
|
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
|
||||||
github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e h1:+SOyEddqYF09QP7vr7CgJ1eti3pY9Fn3LHO1M1r/0sI=
|
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
|
||||||
github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
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=
|
||||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
|
||||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg=
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0=
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
|
go.opentelemetry.io/contrib/detectors/gcp v1.37.0 h1:B+WbN9RPsvobe6q4vP6KgM8/9plR/HNjgGBrfcOlweA=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
|
go.opentelemetry.io/contrib/detectors/gcp v1.37.0/go.mod h1:K5zQ3TT7p2ru9Qkzk0bKtCql0RGkPj9pRjpXgZJZ+rU=
|
||||||
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 h1:rbRJ8BBoVMsQShESYZ0FkvcITu8X8QNwJogcLUmDNNw=
|
||||||
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0/go.mod h1:ru6KHrNtNHxM4nD/vd6QrLVWgKhxPYgblq4VAtNawTQ=
|
||||||
go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
|
||||||
go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
|
||||||
go.opentelemetry.io/otel/sdk v1.22.0 h1:6coWHw9xw7EfClIC/+O31R8IY3/+EiRFHevmHafB2Gw=
|
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||||
go.opentelemetry.io/otel/sdk v1.22.0/go.mod h1:iu7luyVGYovrRpe2fmj3CVKouQNdTOkxtLzPvPz1DOc=
|
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||||
go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY=
|
||||||
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw=
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
|
||||||
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.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
|
||||||
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/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
|
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI=
|
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||||
golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8=
|
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
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=
|
||||||
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
|
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/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||||
|
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
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=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
@@ -209,14 +221,23 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.18.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.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||||
|
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/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=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
|
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
|
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
|
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||||
|
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||||
golang.org/x/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=
|
||||||
@@ -224,60 +245,38 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
|||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
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/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
|
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||||
|
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||||
|
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||||
|
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
|
||||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
|
||||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
|
||||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
|
||||||
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=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||||
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-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=
|
||||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
|
google.golang.org/api v0.240.0 h1:PxG3AA2UIqT1ofIzWV2COM3j3JagKTKSwy7L6RHNXNU=
|
||||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
google.golang.org/api v0.240.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
|
||||||
google.golang.org/api v0.168.0 h1:MBRe+Ki4mMN93jhDDbpuRLjRddooArz4FeSObvUMmjY=
|
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
|
||||||
google.golang.org/api v0.168.0/go.mod h1:gpNOiMA2tZ4mf5R9Iwf4rK/Dcz0fbdIgWYWVoxmsyLg=
|
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
|
||||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
|
||||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s=
|
||||||
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
|
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY=
|
||||||
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc=
|
||||||
google.golang.org/appengine/v2 v2.0.5 h1:4C+F3Cd3L2nWEfSmFEZDPjQvDwL8T0YCeZBysZifP3k=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
|
||||||
google.golang.org/appengine/v2 v2.0.5/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
|
||||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
|
||||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
|
||||||
google.golang.org/genproto v0.0.0-20240304212257-790db918fca8 h1:Fe8QycXyEd9mJgnwB9kmw00WgB43eQ/xYO5C6gceybQ=
|
|
||||||
google.golang.org/genproto v0.0.0-20240304212257-790db918fca8/go.mod h1:yA7a1bW1kwl459Ol0m0lV4hLTfrL/7Bkk4Mj2Ir1mWI=
|
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240304212257-790db918fca8 h1:8eadJkXbwDEMNwcB5O0s5Y5eCfyuCLdvaiOIaGTrWmQ=
|
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240304212257-790db918fca8/go.mod h1:O1cOfN1Cy6QEYr7VxtjOyP5AdAuR0aJ/MYZaaof623Y=
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240304212257-790db918fca8 h1:IR+hp6ypxjH24bkMfEJ0yHR21+gwPWdV+/IBrPQyn3k=
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240304212257-790db918fca8/go.mod h1:UCOku4NytXMJuLQE5VuqA5lX3PcHCBo8pxNyvkf4xBs=
|
|
||||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
|
||||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
|
||||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
|
||||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
|
||||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
|
||||||
google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk=
|
|
||||||
google.golang.org/grpc v1.62.1/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE=
|
|
||||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
|
||||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
|
||||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
|
||||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
|
||||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
|
||||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
|
||||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
|
||||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
|
||||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
|
||||||
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.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
|
||||||
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.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||||
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=
|
||||||
@@ -286,5 +285,3 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
|||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
|
||||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ func (w *peekLogWriter) Write(p []byte) (n int, err error) {
|
|||||||
if len(p) == 0 || p[0] == '{' || CurrentFormat() == TextFormat {
|
if len(p) == 0 || p[0] == '{' || CurrentFormat() == TextFormat {
|
||||||
return w.w.Write(p)
|
return w.w.Write(p)
|
||||||
}
|
}
|
||||||
m := newEvent().Tag(tagStdLog).Render(InfoLevel, strings.TrimSpace(string(p)))
|
m := newEvent().Tag(tagStdLog).Render(InfoLevel, "%s", strings.TrimSpace(string(p)))
|
||||||
if m == "" {
|
if m == "" {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|||||||
2
main.go
@@ -23,7 +23,7 @@ If you want to chat, simply join the Discord server (https://discord.gg/cT7ECsZj
|
|||||||
the Matrix room (https://matrix.to/#/#ntfy:matrix.org).
|
the Matrix room (https://matrix.to/#/#ntfy:matrix.org).
|
||||||
|
|
||||||
ntfy %s (%s), runtime %s, built at %s
|
ntfy %s (%s), runtime %s, built at %s
|
||||||
Copyright (C) 2022 Philipp C. Heckel, licensed under Apache License 2.0 & GPLv2
|
Copyright (C) Philipp C. Heckel, licensed under Apache License 2.0 & GPLv2
|
||||||
`, version, commit[:7], runtime.Version(), date)
|
`, version, commit[:7], runtime.Version(), date)
|
||||||
|
|
||||||
app := cmd.New()
|
app := cmd.New()
|
||||||
|
|||||||
@@ -65,15 +65,15 @@ markdown_extensions:
|
|||||||
- md_in_html
|
- md_in_html
|
||||||
- pymdownx.emoji:
|
- pymdownx.emoji:
|
||||||
emoji_index: !!python/name:material.extensions.emoji.twemoji
|
emoji_index: !!python/name:material.extensions.emoji.twemoji
|
||||||
emoji_generator: !!python/name:materialx.emoji.to_svg
|
emoji_generator: !!python/name:material.extensions.emoji.to_svg
|
||||||
|
|
||||||
|
hooks:
|
||||||
|
- docs/hooks.py
|
||||||
|
|
||||||
plugins:
|
plugins:
|
||||||
- search
|
- search
|
||||||
- minify:
|
- minify:
|
||||||
minify_html: true
|
minify_html: true
|
||||||
- mkdocs-simple-hooks:
|
|
||||||
hooks:
|
|
||||||
on_post_build: "docs.hooks:copy_fonts"
|
|
||||||
|
|
||||||
nav:
|
nav:
|
||||||
- "Getting started": index.md
|
- "Getting started": index.md
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
# The documentation uses 'mkdocs', which is written in Python
|
# The documentation uses 'mkdocs', which is written in Python
|
||||||
mkdocs-material
|
mkdocs-material
|
||||||
mkdocs-minify-plugin
|
mkdocs-minify-plugin
|
||||||
mkdocs-simple-hooks
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ if [ "$1" = "configure" ] || [ "$1" -ge 1 ]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
if systemctl is-active -q ntfy-client.service; then
|
if systemctl is-active -q ntfy-client.service; then
|
||||||
echo "Restarting ntfy-client.service ..."
|
echo "Restarting ntfy-client.service (system) ..."
|
||||||
if [ -x /usr/bin/deb-systemd-invoke ]; then
|
if [ -x /usr/bin/deb-systemd-invoke ]; then
|
||||||
deb-systemd-invoke try-restart ntfy-client.service >/dev/null || true
|
deb-systemd-invoke try-restart ntfy-client.service >/dev/null || true
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ import (
|
|||||||
const (
|
const (
|
||||||
DefaultListenHTTP = ":80"
|
DefaultListenHTTP = ":80"
|
||||||
DefaultCacheDuration = 12 * time.Hour
|
DefaultCacheDuration = 12 * time.Hour
|
||||||
|
DefaultCacheBatchTimeout = time.Duration(0)
|
||||||
DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!)
|
DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!)
|
||||||
DefaultManagerInterval = time.Minute
|
DefaultManagerInterval = time.Minute
|
||||||
DefaultDelayedSenderInterval = 10 * time.Second
|
DefaultDelayedSenderInterval = 10 * time.Second
|
||||||
DefaultMinDelay = 10 * time.Second
|
DefaultMessageDelayMin = 10 * time.Second
|
||||||
DefaultMaxDelay = 3 * 24 * time.Hour
|
DefaultMessageDelayMax = 3 * 24 * time.Hour
|
||||||
DefaultFirebaseKeepaliveInterval = 3 * time.Hour // ~control topic (Android), not too frequently to save battery
|
DefaultFirebaseKeepaliveInterval = 3 * time.Hour // ~control topic (Android), not too frequently to save battery
|
||||||
DefaultFirebasePollInterval = 20 * time.Minute // ~poll topic (iOS), max. 2-3 times per hour (see docs)
|
DefaultFirebasePollInterval = 20 * time.Minute // ~poll topic (iOS), max. 2-3 times per hour (see docs)
|
||||||
DefaultFirebaseQuotaExceededPenaltyDuration = 10 * time.Minute // Time that over-users are locked out of Firebase if it returns "quota exceeded"
|
DefaultFirebaseQuotaExceededPenaltyDuration = 10 * time.Minute // Time that over-users are locked out of Firebase if it returns "quota exceeded"
|
||||||
@@ -25,8 +26,8 @@ const (
|
|||||||
|
|
||||||
// Defines default Web Push settings
|
// Defines default Web Push settings
|
||||||
const (
|
const (
|
||||||
DefaultWebPushExpiryWarningDuration = 7 * 24 * time.Hour
|
DefaultWebPushExpiryWarningDuration = 55 * 24 * time.Hour
|
||||||
DefaultWebPushExpiryDuration = 9 * 24 * time.Hour
|
DefaultWebPushExpiryDuration = 60 * 24 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
// Defines all global and per-visitor limits
|
// Defines all global and per-visitor limits
|
||||||
@@ -34,7 +35,7 @@ const (
|
|||||||
// - total topic limit: max number of topics overall
|
// - total topic limit: max number of topics overall
|
||||||
// - various attachment limits
|
// - various attachment limits
|
||||||
const (
|
const (
|
||||||
DefaultMessageLengthLimit = 4096 // Bytes
|
DefaultMessageSizeLimit = 4096 // Bytes; note that FCM/APNS have a limit of ~4 KB for the entire message
|
||||||
DefaultTotalTopicLimit = 15000
|
DefaultTotalTopicLimit = 15000
|
||||||
DefaultAttachmentTotalSizeLimit = int64(5 * 1024 * 1024 * 1024) // 5 GB
|
DefaultAttachmentTotalSizeLimit = int64(5 * 1024 * 1024 * 1024) // 5 GB
|
||||||
DefaultAttachmentFileSizeLimit = int64(15 * 1024 * 1024) // 15 MB
|
DefaultAttachmentFileSizeLimit = int64(15 * 1024 * 1024) // 15 MB
|
||||||
@@ -60,6 +61,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 (
|
||||||
@@ -122,9 +125,9 @@ type Config struct {
|
|||||||
MetricsEnable bool
|
MetricsEnable bool
|
||||||
MetricsListenHTTP string
|
MetricsListenHTTP string
|
||||||
ProfileListenHTTP string
|
ProfileListenHTTP string
|
||||||
MessageLimit int
|
MessageDelayMin time.Duration
|
||||||
MinDelay time.Duration
|
MessageDelayMax time.Duration
|
||||||
MaxDelay time.Duration
|
MessageSizeLimit int
|
||||||
TotalTopicLimit int
|
TotalTopicLimit int
|
||||||
TotalAttachmentSizeLimit int64
|
TotalAttachmentSizeLimit int64
|
||||||
VisitorSubscriptionLimit int
|
VisitorSubscriptionLimit int
|
||||||
@@ -132,7 +135,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
|
||||||
@@ -140,9 +143,13 @@ type Config struct {
|
|||||||
VisitorAccountCreationLimitReplenish time.Duration
|
VisitorAccountCreationLimitReplenish time.Duration
|
||||||
VisitorAuthFailureLimitBurst int
|
VisitorAuthFailureLimitBurst int
|
||||||
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
|
VisitorPrefixBitsIPv4 int // Number of bits for IPv4 rate limiting (default: 32)
|
||||||
|
VisitorPrefixBitsIPv6 int // Number of bits for IPv6 rate limiting (default: 64)
|
||||||
|
BehindProxy bool // If true, the server will trust the proxy client IP header to determine the client IP address (IPv4 and IPv6 supported)
|
||||||
|
ProxyForwardedHeader string // The header field to read the real/client IP address from, if BehindProxy is true, defaults to "X-Forwarded-For" (IPv4 and IPv6 supported)
|
||||||
|
ProxyTrustedPrefixes []netip.Prefix // List of trusted proxy networks (IPv4 or IPv6) that will be stripped from the Forwarded header if BehindProxy is true
|
||||||
StripeSecretKey string
|
StripeSecretKey string
|
||||||
StripeWebhookKey string
|
StripeWebhookKey string
|
||||||
StripePriceCacheDuration time.Duration
|
StripePriceCacheDuration time.Duration
|
||||||
@@ -152,7 +159,6 @@ type Config struct {
|
|||||||
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
|
||||||
@@ -160,6 +166,7 @@ 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
|
||||||
@@ -211,17 +218,18 @@ func NewConfig() *Config {
|
|||||||
TwilioPhoneNumber: "",
|
TwilioPhoneNumber: "",
|
||||||
TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests
|
TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests
|
||||||
TwilioVerifyService: "",
|
TwilioVerifyService: "",
|
||||||
MessageLimit: DefaultMessageLengthLimit,
|
MessageSizeLimit: DefaultMessageSizeLimit,
|
||||||
MinDelay: DefaultMinDelay,
|
MessageDelayMin: DefaultMessageDelayMin,
|
||||||
MaxDelay: DefaultMaxDelay,
|
MessageDelayMax: DefaultMessageDelayMax,
|
||||||
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,
|
||||||
@@ -230,8 +238,10 @@ func NewConfig() *Config {
|
|||||||
VisitorAuthFailureLimitBurst: DefaultVisitorAuthFailureLimitBurst,
|
VisitorAuthFailureLimitBurst: DefaultVisitorAuthFailureLimitBurst,
|
||||||
VisitorAuthFailureLimitReplenish: DefaultVisitorAuthFailureLimitReplenish,
|
VisitorAuthFailureLimitReplenish: DefaultVisitorAuthFailureLimitReplenish,
|
||||||
VisitorStatsResetTime: DefaultVisitorStatsResetTime,
|
VisitorStatsResetTime: DefaultVisitorStatsResetTime,
|
||||||
VisitorSubscriberRateLimiting: false,
|
VisitorPrefixBitsIPv4: DefaultVisitorPrefixBitsIPv4, // Default: use full IPv4 address
|
||||||
BehindProxy: false,
|
VisitorPrefixBitsIPv6: DefaultVisitorPrefixBitsIPv6, // Default: use /64 for IPv6
|
||||||
|
BehindProxy: false, // If true, the server will trust the proxy client IP header to determine the client IP address
|
||||||
|
ProxyForwardedHeader: "X-Forwarded-For", // Default header for reverse proxy client IPs
|
||||||
StripeSecretKey: "",
|
StripeSecretKey: "",
|
||||||
StripeWebhookKey: "",
|
StripeWebhookKey: "",
|
||||||
StripePriceCacheDuration: DefaultStripePriceCacheDuration,
|
StripePriceCacheDuration: DefaultStripePriceCacheDuration,
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ var (
|
|||||||
errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages", nil}
|
errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages", nil}
|
||||||
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid request: topic invalid", "", nil}
|
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid request: topic invalid", "", nil}
|
||||||
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid request: topic name is not allowed", "", nil}
|
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid request: topic name is not allowed", "", nil}
|
||||||
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", "", nil}
|
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid request: message must be UTF-8 encoded", "", nil}
|
||||||
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments", nil}
|
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments", nil}
|
||||||
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments", nil}
|
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments", nil}
|
||||||
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil}
|
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil}
|
||||||
@@ -113,10 +113,16 @@ var (
|
|||||||
errHTTPBadRequestPhoneNumberNotVerified = &errHTTP{40034, http.StatusBadRequest, "invalid request: phone number not verified, or no matching verified numbers found", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
errHTTPBadRequestPhoneNumberNotVerified = &errHTTP{40034, http.StatusBadRequest, "invalid request: phone number not verified, or no matching verified numbers found", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
||||||
errHTTPBadRequestAnonymousCallsNotAllowed = &errHTTP{40035, http.StatusBadRequest, "invalid request: anonymous phone calls are not allowed", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
errHTTPBadRequestAnonymousCallsNotAllowed = &errHTTP{40035, http.StatusBadRequest, "invalid request: anonymous phone calls are not allowed", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
||||||
errHTTPBadRequestPhoneNumberVerifyChannelInvalid = &errHTTP{40036, http.StatusBadRequest, "invalid request: verification channel must be 'sms' or 'call'", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
errHTTPBadRequestPhoneNumberVerifyChannelInvalid = &errHTTP{40036, http.StatusBadRequest, "invalid request: verification channel must be 'sms' or 'call'", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
||||||
errHTTPBadRequestDelayNoCall = &errHTTP{40037, http.StatusBadRequest, "delayed call notifications are not supported", "", nil}
|
errHTTPBadRequestDelayNoCall = &errHTTP{40037, http.StatusBadRequest, "invalid request: delayed call notifications are not supported", "", nil}
|
||||||
errHTTPBadRequestWebPushSubscriptionInvalid = &errHTTP{40038, http.StatusBadRequest, "invalid request: web push payload malformed", "", nil}
|
errHTTPBadRequestWebPushSubscriptionInvalid = &errHTTP{40038, http.StatusBadRequest, "invalid request: web push payload malformed", "", nil}
|
||||||
errHTTPBadRequestWebPushEndpointUnknown = &errHTTP{40039, http.StatusBadRequest, "invalid request: web push endpoint unknown", "", nil}
|
errHTTPBadRequestWebPushEndpointUnknown = &errHTTP{40039, http.StatusBadRequest, "invalid request: web push endpoint unknown", "", nil}
|
||||||
errHTTPBadRequestWebPushTopicCountTooHigh = &errHTTP{40040, http.StatusBadRequest, "invalid request: too many web push topic subscriptions", "", nil}
|
errHTTPBadRequestWebPushTopicCountTooHigh = &errHTTP{40040, http.StatusBadRequest, "invalid request: too many web push topic subscriptions", "", nil}
|
||||||
|
errHTTPBadRequestTemplateMessageTooLarge = &errHTTP{40041, http.StatusBadRequest, "invalid request: message or title is too large after replacing template", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||||
|
errHTTPBadRequestTemplateMessageNotJSON = &errHTTP{40042, http.StatusBadRequest, "invalid request: message body must be JSON if templating is enabled", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||||
|
errHTTPBadRequestTemplateInvalid = &errHTTP{40043, http.StatusBadRequest, "invalid request: could not parse template", "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}
|
||||||
|
errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", 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}
|
||||||
|
|||||||
@@ -99,6 +99,13 @@ const (
|
|||||||
WHERE topic = ? AND (id > ? OR published = 0)
|
WHERE topic = ? AND (id > ? OR published = 0)
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
|
selectMessagesLatestQuery = `
|
||||||
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||||
|
FROM messages
|
||||||
|
WHERE topic = ? AND published = 1
|
||||||
|
ORDER BY time DESC, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
selectMessagesDueQuery = `
|
selectMessagesDueQuery = `
|
||||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
@@ -122,7 +129,7 @@ const (
|
|||||||
|
|
||||||
// Schema management queries
|
// Schema management queries
|
||||||
const (
|
const (
|
||||||
currentSchemaVersion = 12
|
currentSchemaVersion = 13
|
||||||
createSchemaVersionTableQuery = `
|
createSchemaVersionTableQuery = `
|
||||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||||
id INT PRIMARY KEY,
|
id INT PRIMARY KEY,
|
||||||
@@ -246,6 +253,11 @@ const (
|
|||||||
migrate11To12AlterMessagesTableQuery = `
|
migrate11To12AlterMessagesTableQuery = `
|
||||||
ALTER TABLE messages ADD COLUMN content_type TEXT NOT NULL DEFAULT('');
|
ALTER TABLE messages ADD COLUMN content_type TEXT NOT NULL DEFAULT('');
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// 12 -> 13
|
||||||
|
migrate12To13AlterMessagesTableQuery = `
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||||
|
`
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -262,6 +274,7 @@ var (
|
|||||||
9: migrateFrom9,
|
9: migrateFrom9,
|
||||||
10: migrateFrom10,
|
10: migrateFrom10,
|
||||||
11: migrateFrom11,
|
11: migrateFrom11,
|
||||||
|
12: migrateFrom12,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -410,6 +423,8 @@ func (c *messageCache) addMessages(ms []*message) error {
|
|||||||
func (c *messageCache) Messages(topic string, since sinceMarker, scheduled bool) ([]*message, error) {
|
func (c *messageCache) Messages(topic string, since sinceMarker, scheduled bool) ([]*message, error) {
|
||||||
if since.IsNone() {
|
if since.IsNone() {
|
||||||
return make([]*message, 0), nil
|
return make([]*message, 0), nil
|
||||||
|
} else if since.IsLatest() {
|
||||||
|
return c.messagesLatest(topic)
|
||||||
} else if since.IsID() {
|
} else if since.IsID() {
|
||||||
return c.messagesSinceID(topic, since, scheduled)
|
return c.messagesSinceID(topic, since, scheduled)
|
||||||
}
|
}
|
||||||
@@ -456,6 +471,14 @@ func (c *messageCache) messagesSinceID(topic string, since sinceMarker, schedule
|
|||||||
return readMessages(rows)
|
return readMessages(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *messageCache) messagesLatest(topic string) ([]*message, error) {
|
||||||
|
rows, err := c.db.Query(selectMessagesLatestQuery, topic)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return readMessages(rows)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *messageCache) MessagesDue() ([]*message, error) {
|
func (c *messageCache) MessagesDue() ([]*message, error) {
|
||||||
rows, err := c.db.Query(selectMessagesDueQuery, time.Now().Unix())
|
rows, err := c.db.Query(selectMessagesDueQuery, time.Now().Unix())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -970,3 +993,19 @@ func migrateFrom11(db *sql.DB, _ time.Duration) error {
|
|||||||
}
|
}
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func migrateFrom12(db *sql.DB, _ time.Duration) error {
|
||||||
|
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 12 to 13")
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err := tx.Exec(migrate12To13AlterMessagesTableQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(updateSchemaVersion, 13); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -67,6 +66,11 @@ func testCacheMessages(t *testing.T, c *messageCache) {
|
|||||||
require.Equal(t, 1, len(messages))
|
require.Equal(t, 1, len(messages))
|
||||||
require.Equal(t, "my other message", messages[0].Message)
|
require.Equal(t, "my other message", messages[0].Message)
|
||||||
|
|
||||||
|
// mytopic: latest
|
||||||
|
messages, _ = c.Messages("mytopic", sinceLatestMessage, false)
|
||||||
|
require.Equal(t, 1, len(messages))
|
||||||
|
require.Equal(t, "my other message", messages[0].Message)
|
||||||
|
|
||||||
// example: count
|
// example: count
|
||||||
counts, err = c.MessageCounts()
|
counts, err = c.MessageCounts()
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
@@ -509,6 +513,14 @@ func TestSqliteCache_Migration_From1(t *testing.T) {
|
|||||||
messages, err = c.Messages("mytopic", sinceAllMessages, true)
|
messages, err = c.Messages("mytopic", sinceAllMessages, true)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, 11, len(messages))
|
require.Equal(t, 11, len(messages))
|
||||||
|
|
||||||
|
// Check that index "idx_topic" exists
|
||||||
|
rows, err := c.db.Query(`SELECT name FROM sqlite_master WHERE type='index' AND name='idx_topic'`)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.True(t, rows.Next())
|
||||||
|
var indexName string
|
||||||
|
require.Nil(t, rows.Scan(&indexName))
|
||||||
|
require.Equal(t, "idx_topic", indexName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSqliteCache_Migration_From9(t *testing.T) {
|
func TestSqliteCache_Migration_From9(t *testing.T) {
|
||||||
@@ -675,15 +687,15 @@ func checkSchemaVersion(t *testing.T, db *sql.DB) {
|
|||||||
|
|
||||||
func TestMemCache_NopCache(t *testing.T) {
|
func TestMemCache_NopCache(t *testing.T) {
|
||||||
c, _ := newNopCache()
|
c, _ := newNopCache()
|
||||||
assert.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "my message")))
|
require.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "my message")))
|
||||||
|
|
||||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||||
assert.Nil(t, err)
|
require.Nil(t, err)
|
||||||
assert.Empty(t, messages)
|
require.Empty(t, messages)
|
||||||
|
|
||||||
topics, err := c.Topics()
|
topics, err := c.Topics()
|
||||||
assert.Nil(t, err)
|
require.Nil(t, err)
|
||||||
assert.Empty(t, topics)
|
require.Empty(t, topics)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSqliteTestCache(t *testing.T) *messageCache {
|
func newSqliteTestCache(t *testing.T) *messageCache {
|
||||||
@@ -700,16 +712,12 @@ func newSqliteTestCacheFile(t *testing.T) string {
|
|||||||
|
|
||||||
func newSqliteTestCacheFromFile(t *testing.T, filename, startupQueries string) *messageCache {
|
func newSqliteTestCacheFromFile(t *testing.T, filename, startupQueries string) *messageCache {
|
||||||
c, err := newSqliteCache(filename, startupQueries, time.Hour, 0, 0, false)
|
c, err := newSqliteCache(filename, startupQueries, time.Hour, 0, 0, false)
|
||||||
if err != nil {
|
require.Nil(t, err)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMemTestCache(t *testing.T) *messageCache {
|
func newMemTestCache(t *testing.T) *messageCache {
|
||||||
c, err := newMemCache()
|
c, err := newMemCache()
|
||||||
if err != nil {
|
require.Nil(t, err)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|||||||
164
server/server.go
@@ -23,6 +23,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"text/template"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
@@ -123,15 +124,22 @@ var (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
firebaseControlTopic = "~control" // See Android if changed
|
firebaseControlTopic = "~control" // See Android if changed
|
||||||
firebasePollTopic = "~poll" // See iOS if changed
|
firebasePollTopic = "~poll" // See iOS if changed (DISABLED for now)
|
||||||
emptyMessageBody = "triggered" // Used if message body is empty
|
emptyMessageBody = "triggered" // Used if message body is empty
|
||||||
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 = 16384 // Max number of bytes for a JSON request body
|
jsonBodyBytesLimit = 32768 // 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
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
@@ -405,7 +413,8 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor,
|
|||||||
} else {
|
} else {
|
||||||
ev.Info("WebSocket error: %s", err.Error())
|
ev.Info("WebSocket error: %s", err.Error())
|
||||||
}
|
}
|
||||||
return // Do not attempt to write to upgraded connection
|
w.WriteHeader(httpErr.HTTPCode)
|
||||||
|
return // Do not attempt to write any body to upgraded connection
|
||||||
}
|
}
|
||||||
if isNormalError {
|
if isNormalError {
|
||||||
ev.Debug("Connection closed with HTTP %d (ntfy error %d)", httpErr.HTTPCode, httpErr.Code)
|
ev.Debug("Connection closed with HTTP %d (ntfy error %d)", httpErr.HTTPCode, httpErr.Code)
|
||||||
@@ -437,8 +446,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
|||||||
return s.ensureWebPushEnabled(s.handleWebManifest)(w, r, v)
|
return s.ensureWebPushEnabled(s.handleWebManifest)(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == apiUsersPath {
|
} else if r.Method == http.MethodGet && r.URL.Path == apiUsersPath {
|
||||||
return s.ensureAdmin(s.handleUsersGet)(w, r, v)
|
return s.ensureAdmin(s.handleUsersGet)(w, r, v)
|
||||||
} else if r.Method == http.MethodPut && r.URL.Path == apiUsersPath {
|
} else if r.Method == http.MethodPost && r.URL.Path == apiUsersPath {
|
||||||
return s.ensureAdmin(s.handleUsersAdd)(w, r, v)
|
return s.ensureAdmin(s.handleUsersAdd)(w, r, v)
|
||||||
|
} else if r.Method == http.MethodPut && r.URL.Path == apiUsersPath {
|
||||||
|
return s.ensureAdmin(s.handleUsersUpdate)(w, r, v)
|
||||||
} else if r.Method == http.MethodDelete && r.URL.Path == apiUsersPath {
|
} else if r.Method == http.MethodDelete && r.URL.Path == apiUsersPath {
|
||||||
return s.ensureAdmin(s.handleUsersDelete)(w, r, v)
|
return s.ensureAdmin(s.handleUsersDelete)(w, r, v)
|
||||||
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == apiUsersAccessPath {
|
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == apiUsersAccessPath {
|
||||||
@@ -585,6 +596,7 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/javascript")
|
w.Header().Set("Content-Type", "text/javascript")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
_, err = io.WriteString(w, fmt.Sprintf("// Generated server configuration\nvar config = %s;\n", string(b)))
|
_, err = io.WriteString(w, fmt.Sprintf("// Generated server configuration\nvar config = %s;\n", string(b)))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -673,7 +685,7 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
|
|||||||
// - avoid abuse (e.g. 1 uploader, 1k downloaders)
|
// - avoid abuse (e.g. 1 uploader, 1k downloaders)
|
||||||
// - and also uses the higher bandwidth limits of a paying user
|
// - and also uses the higher bandwidth limits of a paying user
|
||||||
m, err := s.messageCache.Message(messageID)
|
m, err := s.messageCache.Message(messageID)
|
||||||
if err == errMessageNotFound {
|
if errors.Is(err, errMessageNotFound) {
|
||||||
if s.config.CacheBatchTimeout > 0 {
|
if s.config.CacheBatchTimeout > 0 {
|
||||||
// Strange edge case: If we immediately after upload request the file (the web app does this for images),
|
// Strange edge case: If we immediately after upload request the file (the web app does this for images),
|
||||||
// and messages are persisted asynchronously, retry fetching from the database
|
// and messages are persisted asynchronously, retry fetching from the database
|
||||||
@@ -733,12 +745,12 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
body, err := util.Peek(r.Body, s.config.MessageLimit)
|
body, err := util.Peek(r.Body, s.config.MessageSizeLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
m := newDefaultMessage(t.ID, "")
|
m := newDefaultMessage(t.ID, "")
|
||||||
cache, firebase, email, call, unifiedpush, e := s.parsePublishParams(r, m)
|
cache, firebase, email, call, template, unifiedpush, e := s.parsePublishParams(r, m)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return nil, e.With(t)
|
return nil, e.With(t)
|
||||||
}
|
}
|
||||||
@@ -748,7 +760,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)
|
||||||
@@ -769,7 +781,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
|
|||||||
if cache {
|
if cache {
|
||||||
m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix()
|
m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix()
|
||||||
}
|
}
|
||||||
if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil {
|
if err := s.handlePublishBody(r, v, m, body, template, unifiedpush); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if m.Message == "" {
|
if m.Message == "" {
|
||||||
@@ -872,7 +884,7 @@ func (s *Server) sendToFirebase(v *visitor, m *message) {
|
|||||||
logvm(v, m).Tag(tagFirebase).Debug("Publishing to Firebase")
|
logvm(v, m).Tag(tagFirebase).Debug("Publishing to Firebase")
|
||||||
if err := s.firebaseClient.Send(v, m); err != nil {
|
if err := s.firebaseClient.Send(v, m); err != nil {
|
||||||
minc(metricFirebasePublishedFailure)
|
minc(metricFirebasePublishedFailure)
|
||||||
if err == errFirebaseTemporarilyBanned {
|
if errors.Is(err, errFirebaseTemporarilyBanned) {
|
||||||
logvm(v, m).Tag(tagFirebase).Err(err).Debug("Unable to publish to Firebase: %v", err.Error())
|
logvm(v, m).Tag(tagFirebase).Err(err).Debug("Unable to publish to Firebase: %v", err.Error())
|
||||||
} else {
|
} else {
|
||||||
logvm(v, m).Tag(tagFirebase).Err(err).Warn("Unable to publish to Firebase: %v", err.Error())
|
logvm(v, m).Tag(tagFirebase).Err(err).Warn("Unable to publish to Firebase: %v", err.Error())
|
||||||
@@ -924,7 +936,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, unifiedpush bool, err *errHTTP) {
|
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template bool, 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")
|
||||||
@@ -940,7 +952,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, errHTTPBadRequestAttachmentURLInvalid
|
return false, false, "", "", false, false, errHTTPBadRequestAttachmentURLInvalid
|
||||||
}
|
}
|
||||||
m.Attachment.URL = attach
|
m.Attachment.URL = attach
|
||||||
if m.Attachment.Name == "" {
|
if m.Attachment.Name == "" {
|
||||||
@@ -958,19 +970,19 @@ 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, errHTTPBadRequestIconURLInvalid
|
return false, 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, errHTTPBadRequestEmailDisabled
|
return false, 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, errHTTPBadRequestPhoneCallsDisabled
|
return false, false, "", "", false, false, errHTTPBadRequestPhoneCallsDisabled
|
||||||
} else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) {
|
} else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) {
|
||||||
return false, false, "", "", false, errHTTPBadRequestPhoneNumberInvalid
|
return false, false, "", "", false, false, errHTTPBadRequestPhoneNumberInvalid
|
||||||
}
|
}
|
||||||
messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
|
messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
|
||||||
if messageStr != "" {
|
if messageStr != "" {
|
||||||
@@ -979,27 +991,27 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
|||||||
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, errHTTPBadRequestPriorityInvalid
|
return false, 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, errHTTPBadRequestDelayNoCache
|
return false, false, "", "", false, false, errHTTPBadRequestDelayNoCache
|
||||||
}
|
}
|
||||||
if email != "" {
|
if email != "" {
|
||||||
return false, false, "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
|
return false, false, "", "", false, false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
|
||||||
}
|
}
|
||||||
if call != "" {
|
if call != "" {
|
||||||
return false, false, "", "", false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet)
|
return false, 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, errHTTPBadRequestDelayCannotParse
|
return false, false, "", "", false, false, errHTTPBadRequestDelayCannotParse
|
||||||
} else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() {
|
} else if delay.Unix() < time.Now().Add(s.config.MessageDelayMin).Unix() {
|
||||||
return false, false, "", "", false, errHTTPBadRequestDelayTooSmall
|
return false, false, "", "", false, false, errHTTPBadRequestDelayTooSmall
|
||||||
} else if delay.Unix() > time.Now().Add(s.config.MaxDelay).Unix() {
|
} else if delay.Unix() > time.Now().Add(s.config.MessageDelayMax).Unix() {
|
||||||
return false, false, "", "", false, errHTTPBadRequestDelayTooLarge
|
return false, false, "", "", false, false, errHTTPBadRequestDelayTooLarge
|
||||||
}
|
}
|
||||||
m.Time = delay.Unix()
|
m.Time = delay.Unix()
|
||||||
}
|
}
|
||||||
@@ -1007,15 +1019,17 @@ 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, errHTTPBadRequestActionsInvalid.Wrap(e.Error())
|
return false, 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!
|
||||||
if unifiedpush {
|
contentEncoding := readParam(r, "content-encoding")
|
||||||
|
if unifiedpush || contentEncoding == "aes128gcm" {
|
||||||
firebase = false
|
firebase = false
|
||||||
unifiedpush = true
|
unifiedpush = true
|
||||||
}
|
}
|
||||||
@@ -1025,7 +1039,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
|||||||
cache = false
|
cache = false
|
||||||
email = ""
|
email = ""
|
||||||
}
|
}
|
||||||
return cache, firebase, email, call, unifiedpush, nil
|
return cache, firebase, email, call, template, unifiedpush, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message.
|
// handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message.
|
||||||
@@ -1033,16 +1047,18 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
|||||||
// 1. curl -X POST -H "Poll: 1234" ntfy.sh/...
|
// 1. curl -X POST -H "Poll: 1234" ntfy.sh/...
|
||||||
// If a message is flagged as poll request, the body does not matter and is discarded
|
// If a message is flagged as poll request, the body does not matter and is discarded
|
||||||
// 2. curl -T somebinarydata.bin "ntfy.sh/mytopic?up=1"
|
// 2. curl -T somebinarydata.bin "ntfy.sh/mytopic?up=1"
|
||||||
// If body is binary, encode as base64, if not do not encode
|
// If UnifiedPush is enabled, encode as base64 if body is binary, and do not trim
|
||||||
// 3. curl -H "Attach: http://example.com/file.jpg" ntfy.sh/mytopic
|
// 3. curl -H "Attach: http://example.com/file.jpg" ntfy.sh/mytopic
|
||||||
// Body must be a message, because we attached an external URL
|
// Body must be a message, because we attached an external URL
|
||||||
// 4. curl -T short.txt -H "Filename: short.txt" ntfy.sh/mytopic
|
// 4. curl -T short.txt -H "Filename: short.txt" ntfy.sh/mytopic
|
||||||
// Body must be attachment, because we passed a filename
|
// Body must be attachment, because we passed a filename
|
||||||
// 5. curl -T file.txt ntfy.sh/mytopic
|
// 5. curl -H "Template: yes" -T file.txt ntfy.sh/mytopic
|
||||||
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
|
// If templating is enabled, read up to 32k and treat message body as JSON
|
||||||
// 6. curl -T file.txt ntfy.sh/mytopic
|
// 6. curl -T file.txt ntfy.sh/mytopic
|
||||||
// If file.txt is > message limit, treat it as an attachment
|
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
|
||||||
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, unifiedpush bool) error {
|
// 7. curl -T file.txt ntfy.sh/mytopic
|
||||||
|
// In all other cases, mostly if file.txt is > message limit, treat it as an attachment
|
||||||
|
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, template, unifiedpush bool) error {
|
||||||
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 {
|
||||||
@@ -1051,10 +1067,12 @@ 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 {
|
||||||
|
return s.handleBodyAsTemplatedTextMessage(m, 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 5
|
return s.handleBodyAsTextMessage(m, body) // Case 6
|
||||||
}
|
}
|
||||||
return s.handleBodyAsAttachment(r, v, m, body) // Case 6
|
return s.handleBodyAsAttachment(r, v, m, body) // Case 7
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleBodyDiscard(body *util.PeekedReadCloser) error {
|
func (s *Server) handleBodyDiscard(body *util.PeekedReadCloser) error {
|
||||||
@@ -1086,6 +1104,45 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBodyAsTemplatedTextMessage(m *message, body *util.PeekedReadCloser) error {
|
||||||
|
body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if body.LimitReached {
|
||||||
|
return errHTTPEntityTooLargeJSONBody
|
||||||
|
}
|
||||||
|
peekedBody := strings.TrimSpace(string(body.PeekedBytes))
|
||||||
|
if m.Message, err = replaceTemplate(m.Message, peekedBody); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if m.Title, err = replaceTemplate(m.Title, peekedBody); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(m.Message) > s.config.MessageSizeLimit {
|
||||||
|
return errHTTPBadRequestTemplateMessageTooLarge
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceTemplate(tpl string, source string) (string, error) {
|
||||||
|
if templateDisallowedRegex.MatchString(tpl) {
|
||||||
|
return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
|
||||||
|
}
|
||||||
|
var data any
|
||||||
|
if err := json.Unmarshal([]byte(source), &data); err != nil {
|
||||||
|
return "", errHTTPBadRequestTemplateMessageNotJSON
|
||||||
|
}
|
||||||
|
t, err := template.New("").Parse(tpl)
|
||||||
|
if err != nil {
|
||||||
|
return "", errHTTPBadRequestTemplateInvalid
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := t.Execute(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), data); err != nil {
|
||||||
|
return "", errHTTPBadRequestTemplateExecuteFailed
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
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 {
|
||||||
if s.fileCache == nil || s.config.BaseURL == "" || s.config.AttachmentCacheDir == "" {
|
if s.fileCache == nil || s.config.BaseURL == "" || s.config.AttachmentCacheDir == "" {
|
||||||
return errHTTPBadRequestAttachmentsDisallowed.With(m)
|
return errHTTPBadRequestAttachmentsDisallowed.With(m)
|
||||||
@@ -1128,7 +1185,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
|
|||||||
util.NewFixedLimiter(vinfo.Stats.AttachmentTotalSizeRemaining),
|
util.NewFixedLimiter(vinfo.Stats.AttachmentTotalSizeRemaining),
|
||||||
}
|
}
|
||||||
m.Attachment.Size, err = s.fileCache.Write(m.ID, body, limiters...)
|
m.Attachment.Size, err = s.fileCache.Write(m.ID, body, limiters...)
|
||||||
if err == util.ErrLimitReached {
|
if errors.Is(err, util.ErrLimitReached) {
|
||||||
return errHTTPEntityTooLargeAttachment.With(m)
|
return errHTTPEntityTooLargeAttachment.With(m)
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1447,6 +1504,9 @@ func (s *Server) maybeSetRateVisitors(r *http.Request, v *visitor, topics []*top
|
|||||||
// - topic is not reserved, and v.user has write access
|
// - topic is not reserved, and v.user has write access
|
||||||
writableRateTopics := make([]*topic, 0)
|
writableRateTopics := make([]*topic, 0)
|
||||||
for _, t := range topics {
|
for _, t := range topics {
|
||||||
|
if !util.Contains(eligibleRateTopics, t) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
ownerUserID, err := s.userManager.ReservationOwner(t.ID)
|
ownerUserID, err := s.userManager.ReservationOwner(t.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1500,8 +1560,8 @@ func (s *Server) sendOldMessages(topics []*topic, since sinceMarker, scheduled b
|
|||||||
|
|
||||||
// parseSince returns a timestamp identifying the time span from which cached messages should be received.
|
// parseSince returns a timestamp identifying the time span from which cached messages should be received.
|
||||||
//
|
//
|
||||||
// Values in the "since=..." parameter can be either a unix timestamp or a duration (e.g. 12h), or
|
// Values in the "since=..." parameter can be either a unix timestamp or a duration (e.g. 12h),
|
||||||
// "all" for all messages.
|
// "all" for all messages, or "latest" for the most recent message for a topic
|
||||||
func parseSince(r *http.Request, poll bool) (sinceMarker, error) {
|
func parseSince(r *http.Request, poll bool) (sinceMarker, error) {
|
||||||
since := readParam(r, "x-since", "since", "si")
|
since := readParam(r, "x-since", "since", "si")
|
||||||
|
|
||||||
@@ -1513,6 +1573,8 @@ func parseSince(r *http.Request, poll bool) (sinceMarker, error) {
|
|||||||
return sinceNoMessages, nil
|
return sinceNoMessages, nil
|
||||||
} else if since == "all" {
|
} else if since == "all" {
|
||||||
return sinceAllMessages, nil
|
return sinceAllMessages, nil
|
||||||
|
} else if since == "latest" {
|
||||||
|
return sinceLatestMessage, nil
|
||||||
} else if since == "none" {
|
} else if since == "none" {
|
||||||
return sinceNoMessages, nil
|
return sinceNoMessages, nil
|
||||||
}
|
}
|
||||||
@@ -1754,7 +1816,7 @@ func (s *Server) sendDelayedMessage(v *visitor, m *message) error {
|
|||||||
// before passing it on to the next handler. This is meant to be used in combination with handlePublish.
|
// before passing it on to the next handler. This is meant to be used in combination with handlePublish.
|
||||||
func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
|
func (s *Server) transformBodyJSON(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 {
|
||||||
m, err := readJSONWithLimit[publishMessage](r.Body, s.config.MessageLimit*2, false) // 2x to account for JSON format overhead
|
m, err := readJSONWithLimit[publishMessage](r.Body, s.config.MessageSizeLimit*2, false) // 2x to account for JSON format overhead
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1772,7 +1834,7 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
|
|||||||
if m.Priority != 0 {
|
if m.Priority != 0 {
|
||||||
r.Header.Set("X-Priority", fmt.Sprintf("%d", m.Priority))
|
r.Header.Set("X-Priority", fmt.Sprintf("%d", m.Priority))
|
||||||
}
|
}
|
||||||
if m.Tags != nil && len(m.Tags) > 0 {
|
if len(m.Tags) > 0 {
|
||||||
r.Header.Set("X-Tags", strings.Join(m.Tags, ","))
|
r.Header.Set("X-Tags", strings.Join(m.Tags, ","))
|
||||||
}
|
}
|
||||||
if m.Attach != "" {
|
if m.Attach != "" {
|
||||||
@@ -1806,13 +1868,19 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
|
|||||||
if m.Call != "" {
|
if m.Call != "" {
|
||||||
r.Header.Set("X-Call", m.Call)
|
r.Header.Set("X-Call", m.Call)
|
||||||
}
|
}
|
||||||
|
if m.Cache != "" {
|
||||||
|
r.Header.Set("X-Cache", m.Cache)
|
||||||
|
}
|
||||||
|
if m.Firebase != "" {
|
||||||
|
r.Header.Set("X-Firebase", m.Firebase)
|
||||||
|
}
|
||||||
return next(w, r, v)
|
return next(w, r, v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) transformMatrixJSON(next handleFunc) handleFunc {
|
func (s *Server) transformMatrixJSON(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 {
|
||||||
newRequest, err := newRequestFromMatrixJSON(r, s.config.BaseURL, s.config.MessageLimit)
|
newRequest, err := newRequestFromMatrixJSON(r, s.config.BaseURL, s.config.MessageSizeLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logvr(v, r).Tag(tagMatrix).Err(err).Debug("Invalid Matrix request")
|
logvr(v, r).Tag(tagMatrix).Err(err).Debug("Invalid Matrix request")
|
||||||
if e, ok := err.(*errMatrixPushkeyRejected); ok {
|
if e, ok := err.(*errMatrixPushkeyRejected); ok {
|
||||||
@@ -1829,14 +1897,14 @@ func (s *Server) transformMatrixJSON(next handleFunc) handleFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) authorizeTopicWrite(next handleFunc) handleFunc {
|
func (s *Server) authorizeTopicWrite(next handleFunc) handleFunc {
|
||||||
return s.autorizeTopic(next, user.PermissionWrite)
|
return s.authorizeTopic(next, user.PermissionWrite)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) authorizeTopicRead(next handleFunc) handleFunc {
|
func (s *Server) authorizeTopicRead(next handleFunc) handleFunc {
|
||||||
return s.autorizeTopic(next, user.PermissionRead)
|
return s.authorizeTopic(next, user.PermissionRead)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) autorizeTopic(next handleFunc, perm user.Permission) handleFunc {
|
func (s *Server) authorizeTopic(next handleFunc, perm user.Permission) handleFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
if s.userManager == nil {
|
if s.userManager == nil {
|
||||||
return next(w, r, v)
|
return next(w, r, v)
|
||||||
@@ -1868,8 +1936,8 @@ func (s *Server) autorizeTopic(next handleFunc, perm user.Permission) handleFunc
|
|||||||
// This function will ALWAYS return a visitor, even if an error occurs (e.g. unauthorized), so
|
// This function will ALWAYS return a visitor, even if an error occurs (e.g. unauthorized), so
|
||||||
// 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 "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)
|
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
|
||||||
@@ -1944,7 +2012,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)
|
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,
|
||||||
@@ -1955,7 +2023,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)
|
||||||
|
|||||||
@@ -95,13 +95,23 @@
|
|||||||
# auth-default-access: "read-write"
|
# auth-default-access: "read-write"
|
||||||
# auth-startup-queries:
|
# auth-startup-queries:
|
||||||
|
|
||||||
# If set, the X-Forwarded-For header is used to determine the visitor IP address
|
# If set, the X-Forwarded-For header (or whatever is configured in proxy-forwarded-header) is used to determine
|
||||||
# instead of the remote address of the connection.
|
# the visitor IP address instead of the remote address of the connection.
|
||||||
#
|
#
|
||||||
# WARNING: If you are behind a proxy, you must set this, otherwise all visitors are rate limited
|
# WARNING: If you are behind a proxy, you must set this, otherwise all visitors are rate-limited
|
||||||
# as if they are one.
|
# as if they are one.
|
||||||
#
|
#
|
||||||
|
# - behind-proxy makes it so that the real visitor IP address is extracted from the header defined in
|
||||||
|
# proxy-forwarded-header. Without this, the remote address of the incoming connection is used.
|
||||||
|
# - proxy-forwarded-header is the header to use to identify visitors. It may be a single IP address (e.g. 1.2.3.4),
|
||||||
|
# a comma-separated list of IP addresses (e.g. "1.2.3.4, 5.6.7.8"), or an RFC 7239-style header (e.g. "for=1.2.3.4;by=proxy.example.com, for=5.6.7.8").
|
||||||
|
# - proxy-trusted-hosts is a comma-separated list of IP addresses, hostnames or CIDRs that are removed from the forwarded header
|
||||||
|
# to determine the real IP address. This is only useful if there are multiple proxies involved that add themselves to
|
||||||
|
# the forwarded header.
|
||||||
|
#
|
||||||
# behind-proxy: false
|
# behind-proxy: false
|
||||||
|
# proxy-forwarded-header: "X-Forwarded-For"
|
||||||
|
# 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".
|
||||||
@@ -138,7 +148,7 @@
|
|||||||
# - smtp-server-domain is the e-mail domain, e.g. ntfy.sh
|
# - smtp-server-domain is the e-mail domain, e.g. ntfy.sh
|
||||||
# - smtp-server-addr-prefix is an optional prefix for the e-mail addresses to prevent spam. If set to "ntfy-",
|
# - smtp-server-addr-prefix is an optional prefix for the e-mail addresses to prevent spam. If set to "ntfy-",
|
||||||
# for instance, only e-mails to ntfy-$topic@ntfy.sh will be accepted. If this is not set, all emails to
|
# for instance, only e-mails to ntfy-$topic@ntfy.sh will be accepted. If this is not set, all emails to
|
||||||
# $topic@ntfy.sh will be accepted (which may obviously be a spam problem).
|
# $topic@ntfy.sh will be accepted (which may be a spam problem).
|
||||||
#
|
#
|
||||||
# smtp-server-listen:
|
# smtp-server-listen:
|
||||||
# smtp-server-domain:
|
# smtp-server-domain:
|
||||||
@@ -146,7 +156,7 @@
|
|||||||
|
|
||||||
# Web Push support (background notifications for browsers)
|
# Web Push support (background notifications for browsers)
|
||||||
#
|
#
|
||||||
# If enabled, allows ntfy to receive push notifications, even when the ntfy web app is closed. When enabled, users
|
# If enabled, allows the ntfy web app to receive push notifications, even when the web app is closed. When enabled, users
|
||||||
# can enable background notifications in the web app. Once enabled, ntfy will forward published messages to the push
|
# can enable background notifications in the web app. Once enabled, ntfy will forward published messages to the push
|
||||||
# endpoint, which will then forward it to the browser.
|
# endpoint, which will then forward it to the browser.
|
||||||
#
|
#
|
||||||
@@ -155,15 +165,19 @@
|
|||||||
#
|
#
|
||||||
# - web-push-public-key is the generated VAPID public key, e.g. AA1234BBCCddvveekaabcdfqwertyuiopasdfghjklzxcvbnm1234567890
|
# - web-push-public-key is the generated VAPID public key, e.g. AA1234BBCCddvveekaabcdfqwertyuiopasdfghjklzxcvbnm1234567890
|
||||||
# - web-push-private-key is the generated VAPID private key, e.g. AA2BB1234567890abcdefzxcvbnm1234567890
|
# - web-push-private-key is the generated VAPID private key, e.g. AA2BB1234567890abcdefzxcvbnm1234567890
|
||||||
# - web-push-file is a database file to keep track of browser subscription endpoints, e.g. `/var/cache/ntfy/webpush.db`
|
# - web-push-file is a database file to keep track of browser subscription endpoints, e.g. /var/cache/ntfy/webpush.db
|
||||||
# - web-push-email-address is the admin email address send to the push provider, e.g. `sysadmin@example.com`
|
# - web-push-email-address is the admin email address send to the push provider, e.g. sysadmin@example.com
|
||||||
# - web-push-startup-queries is an optional list of queries to run on startup`
|
# - web-push-startup-queries is an optional list of queries to run on startup`
|
||||||
|
# - web-push-expiry-warning-duration defines the duration after which unused subscriptions are sent a warning (default is 55d`)
|
||||||
|
# - web-push-expiry-duration defines the duration after which unused subscriptions will expire (default is 60d)
|
||||||
#
|
#
|
||||||
# web-push-public-key:
|
# web-push-public-key:
|
||||||
# web-push-private-key:
|
# web-push-private-key:
|
||||||
# web-push-file:
|
# web-push-file:
|
||||||
# web-push-email-address:
|
# web-push-email-address:
|
||||||
# web-push-startup-queries:
|
# web-push-startup-queries:
|
||||||
|
# web-push-expiry-warning-duration: "55d"
|
||||||
|
# web-push-expiry-duration: "60d"
|
||||||
|
|
||||||
# If enabled, ntfy can perform voice calls via Twilio via the "X-Call" header.
|
# If enabled, ntfy can perform voice calls via Twilio via the "X-Call" header.
|
||||||
#
|
#
|
||||||
@@ -236,6 +250,16 @@
|
|||||||
# upstream-base-url:
|
# upstream-base-url:
|
||||||
# upstream-access-token:
|
# upstream-access-token:
|
||||||
|
|
||||||
|
# Configures message-specific limits
|
||||||
|
#
|
||||||
|
# - message-size-limit defines the max size of a message body. Please note message sizes >4K are NOT RECOMMENDED,
|
||||||
|
# and largely untested. If FCM and/or APNS is used, the limit should stay 4K, because their limits are around that size.
|
||||||
|
# If you increase this size limit regardless, FCM and APNS will NOT work for large messages.
|
||||||
|
# - message-delay-limit defines the max delay of a message when using the "Delay" header.
|
||||||
|
#
|
||||||
|
# message-size-limit: "4k"
|
||||||
|
# message-delay-limit: "3d"
|
||||||
|
|
||||||
# Rate limiting: Total number of topics before the server rejects new topics.
|
# Rate limiting: Total number of topics before the server rejects new topics.
|
||||||
#
|
#
|
||||||
# global-topic-limit: 15000
|
# global-topic-limit: 15000
|
||||||
@@ -268,6 +292,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
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"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"
|
||||||
@@ -36,7 +37,10 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *
|
|||||||
return errHTTPConflictUserExists
|
return errHTTPConflictUserExists
|
||||||
}
|
}
|
||||||
logvr(v, r).Tag(tagAccount).Field("user_name", newAccount.Username).Info("Creating user %s", newAccount.Username)
|
logvr(v, r).Tag(tagAccount).Field("user_name", newAccount.Username).Info("Creating user %s", newAccount.Username)
|
||||||
if err := s.userManager.AddUser(newAccount.Username, newAccount.Password, user.RoleUser); err != nil {
|
if err := s.userManager.AddUser(newAccount.Username, newAccount.Password, user.RoleUser, false); err != nil {
|
||||||
|
if errors.Is(err, user.ErrInvalidArgument) {
|
||||||
|
return errHTTPBadRequestInvalidUsername
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
v.AccountCreated()
|
v.AccountCreated()
|
||||||
@@ -203,7 +207,7 @@ func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Requ
|
|||||||
return errHTTPBadRequestIncorrectPasswordConfirmation
|
return errHTTPBadRequestIncorrectPasswordConfirmation
|
||||||
}
|
}
|
||||||
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); err != nil {
|
if err := s.userManager.ChangePassword(u.Name, req.NewPassword, false); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
|
|||||||
@@ -87,9 +87,9 @@ func TestAccount_Signup_AsUser(t *testing.T) {
|
|||||||
defer s.closeDatabases()
|
defer s.closeDatabases()
|
||||||
|
|
||||||
log.Info("1")
|
log.Info("1")
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||||
log.Info("2")
|
log.Info("2")
|
||||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||||
log.Info("3")
|
log.Info("3")
|
||||||
rr := request(t, s, "POST", "/v1/account", `{"username":"emma", "password":"emma"}`, map[string]string{
|
rr := request(t, s, "POST", "/v1/account", `{"username":"emma", "password":"emma"}`, map[string]string{
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
@@ -174,7 +174,7 @@ func TestAccount_ChangeSettings(t *testing.T) {
|
|||||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||||
defer s.closeDatabases()
|
defer s.closeDatabases()
|
||||||
|
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
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())
|
||||||
|
|
||||||
@@ -203,7 +203,7 @@ func TestAccount_Subscription_AddUpdateDelete(t *testing.T) {
|
|||||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||||
defer s.closeDatabases()
|
defer s.closeDatabases()
|
||||||
|
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||||
|
|
||||||
rr := request(t, s, "POST", "/v1/account/subscription", `{"base_url": "http://abc.com", "topic": "def"}`, map[string]string{
|
rr := request(t, s, "POST", "/v1/account/subscription", `{"base_url": "http://abc.com", "topic": "def"}`, map[string]string{
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
@@ -254,7 +254,7 @@ func TestAccount_ChangePassword(t *testing.T) {
|
|||||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||||
defer s.closeDatabases()
|
defer s.closeDatabases()
|
||||||
|
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||||
|
|
||||||
rr := request(t, s, "POST", "/v1/account/password", `{"password": "WRONG", "new_password": ""}`, map[string]string{
|
rr := request(t, s, "POST", "/v1/account/password", `{"password": "WRONG", "new_password": ""}`, map[string]string{
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
@@ -296,7 +296,7 @@ func TestAccount_ExtendToken(t *testing.T) {
|
|||||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||||
defer s.closeDatabases()
|
defer s.closeDatabases()
|
||||||
|
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||||
|
|
||||||
rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
@@ -332,7 +332,7 @@ func TestAccount_ExtendToken_NoTokenProvided(t *testing.T) {
|
|||||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||||
defer s.closeDatabases()
|
defer s.closeDatabases()
|
||||||
|
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||||
|
|
||||||
rr := request(t, s, "PATCH", "/v1/account/token", "", map[string]string{
|
rr := request(t, s, "PATCH", "/v1/account/token", "", map[string]string{
|
||||||
"Authorization": util.BasicAuth("phil", "phil"), // Not Bearer!
|
"Authorization": util.BasicAuth("phil", "phil"), // Not Bearer!
|
||||||
@@ -345,7 +345,7 @@ func TestAccount_DeleteToken(t *testing.T) {
|
|||||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||||
defer s.closeDatabases()
|
defer s.closeDatabases()
|
||||||
|
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||||
|
|
||||||
rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
@@ -455,14 +455,14 @@ func TestAccount_Reservation_AddAdminSuccess(t *testing.T) {
|
|||||||
Code: "pro",
|
Code: "pro",
|
||||||
ReservationLimit: 2,
|
ReservationLimit: 2,
|
||||||
}))
|
}))
|
||||||
require.Nil(t, s.userManager.AddUser("noadmin1", "pass", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("noadmin1", "pass", user.RoleUser, false))
|
||||||
require.Nil(t, s.userManager.ChangeTier("noadmin1", "pro"))
|
require.Nil(t, s.userManager.ChangeTier("noadmin1", "pro"))
|
||||||
require.Nil(t, s.userManager.AddReservation("noadmin1", "mytopic", user.PermissionDenyAll))
|
require.Nil(t, s.userManager.AddReservation("noadmin1", "mytopic", user.PermissionDenyAll))
|
||||||
|
|
||||||
require.Nil(t, s.userManager.AddUser("noadmin2", "pass", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("noadmin2", "pass", user.RoleUser, false))
|
||||||
require.Nil(t, s.userManager.ChangeTier("noadmin2", "pro"))
|
require.Nil(t, s.userManager.ChangeTier("noadmin2", "pro"))
|
||||||
|
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "adminpass", user.RoleAdmin))
|
require.Nil(t, s.userManager.AddUser("phil", "adminpass", user.RoleAdmin, false))
|
||||||
|
|
||||||
// Admin can reserve topic
|
// Admin can reserve topic
|
||||||
rr := request(t, s, "POST", "/v1/account/reservation", `{"topic":"sometopic","everyone":"deny-all"}`, map[string]string{
|
rr := request(t, s, "POST", "/v1/account/reservation", `{"topic":"sometopic","everyone":"deny-all"}`, map[string]string{
|
||||||
@@ -624,7 +624,7 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
|
|||||||
s := newTestServer(t, conf)
|
s := newTestServer(t, conf)
|
||||||
|
|
||||||
// Create user with tier
|
// Create user with tier
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "mypass", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("phil", "mypass", user.RoleUser, false))
|
||||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||||
Code: "pro",
|
Code: "pro",
|
||||||
MessageLimit: 20,
|
MessageLimit: 20,
|
||||||
@@ -718,11 +718,11 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
|
|||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||||
Code: "starter",
|
Code: "starter",
|
||||||
MessageLimit: 10,
|
MessageSizeLimit: 10,
|
||||||
}))
|
}))
|
||||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||||
Code: "pro",
|
Code: "pro",
|
||||||
MessageLimit: 20,
|
MessageSizeLimit: 20,
|
||||||
}))
|
}))
|
||||||
require.Nil(t, s.userManager.ChangeTier("phil", "starter"))
|
require.Nil(t, s.userManager.ChangeTier("phil", "starter"))
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"heckel.io/ntfy/v2/user"
|
"heckel.io/ntfy/v2/user"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
@@ -38,14 +39,14 @@ func (s *Server) handleUsersGet(w http.ResponseWriter, r *http.Request, v *visit
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleUsersAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
func (s *Server) handleUsersAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
req, err := readJSONWithLimit[apiUserAddRequest](r.Body, jsonBodyBytesLimit, false)
|
req, err := readJSONWithLimit[apiUserAddOrUpdateRequest](r.Body, jsonBodyBytesLimit, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
} else if !user.AllowedUsername(req.Username) || req.Password == "" {
|
} else if !user.AllowedUsername(req.Username) || (req.Password == "" && req.Hash == "") {
|
||||||
return errHTTPBadRequest.Wrap("username invalid, or password missing")
|
return errHTTPBadRequest.Wrap("username invalid, or password/password_hash missing")
|
||||||
}
|
}
|
||||||
u, err := s.userManager.User(req.Username)
|
u, err := s.userManager.User(req.Username)
|
||||||
if err != nil && err != user.ErrUserNotFound {
|
if err != nil && !errors.Is(err, user.ErrUserNotFound) {
|
||||||
return err
|
return err
|
||||||
} else if u != nil {
|
} else if u != nil {
|
||||||
return errHTTPConflictUserExists
|
return errHTTPConflictUserExists
|
||||||
@@ -53,13 +54,17 @@ func (s *Server) handleUsersAdd(w http.ResponseWriter, r *http.Request, v *visit
|
|||||||
var tier *user.Tier
|
var tier *user.Tier
|
||||||
if req.Tier != "" {
|
if req.Tier != "" {
|
||||||
tier, err = s.userManager.Tier(req.Tier)
|
tier, err = s.userManager.Tier(req.Tier)
|
||||||
if err == user.ErrTierNotFound {
|
if errors.Is(err, user.ErrTierNotFound) {
|
||||||
return errHTTPBadRequestTierInvalid
|
return errHTTPBadRequestTierInvalid
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := s.userManager.AddUser(req.Username, req.Password, user.RoleUser); err != nil {
|
password, hashed := req.Password, false
|
||||||
|
if req.Hash != "" {
|
||||||
|
password, hashed = req.Hash, true
|
||||||
|
}
|
||||||
|
if err := s.userManager.AddUser(req.Username, password, user.RoleUser, hashed); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if tier != nil {
|
if tier != nil {
|
||||||
@@ -70,13 +75,60 @@ func (s *Server) handleUsersAdd(w http.ResponseWriter, r *http.Request, v *visit
|
|||||||
return s.writeJSON(w, newSuccessResponse())
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUsersUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
req, err := readJSONWithLimit[apiUserAddOrUpdateRequest](r.Body, jsonBodyBytesLimit, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if !user.AllowedUsername(req.Username) {
|
||||||
|
return errHTTPBadRequest.Wrap("username invalid")
|
||||||
|
} else if req.Password == "" && req.Hash == "" && req.Tier == "" {
|
||||||
|
return errHTTPBadRequest.Wrap("need to provide at least one of \"password\", \"password_hash\" or \"tier\"")
|
||||||
|
}
|
||||||
|
u, err := s.userManager.User(req.Username)
|
||||||
|
if err != nil && !errors.Is(err, user.ErrUserNotFound) {
|
||||||
|
return err
|
||||||
|
} else if u != nil {
|
||||||
|
if u.IsAdmin() {
|
||||||
|
return errHTTPForbidden
|
||||||
|
}
|
||||||
|
if req.Hash != "" {
|
||||||
|
if err := s.userManager.ChangePassword(req.Username, req.Hash, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if req.Password != "" {
|
||||||
|
if err := s.userManager.ChangePassword(req.Username, req.Password, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
password, hashed := req.Password, false
|
||||||
|
if req.Hash != "" {
|
||||||
|
password, hashed = req.Hash, true
|
||||||
|
}
|
||||||
|
if err := s.userManager.AddUser(req.Username, password, user.RoleUser, hashed); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.Tier != "" {
|
||||||
|
if _, err = s.userManager.Tier(req.Tier); errors.Is(err, user.ErrTierNotFound) {
|
||||||
|
return errHTTPBadRequestTierInvalid
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.userManager.ChangeTier(req.Username, req.Tier); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleUsersDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
func (s *Server) handleUsersDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
req, err := readJSONWithLimit[apiUserDeleteRequest](r.Body, jsonBodyBytesLimit, false)
|
req, err := readJSONWithLimit[apiUserDeleteRequest](r.Body, jsonBodyBytesLimit, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
u, err := s.userManager.User(req.Username)
|
u, err := s.userManager.User(req.Username)
|
||||||
if err == user.ErrUserNotFound {
|
if errors.Is(err, user.ErrUserNotFound) {
|
||||||
return errHTTPBadRequestUserNotFound
|
return errHTTPBadRequestUserNotFound
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -98,7 +150,7 @@ func (s *Server) handleAccessAllow(w http.ResponseWriter, r *http.Request, v *vi
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = s.userManager.User(req.Username)
|
_, err = s.userManager.User(req.Username)
|
||||||
if err == user.ErrUserNotFound {
|
if errors.Is(err, user.ErrUserNotFound) {
|
||||||
return errHTTPBadRequestUserNotFound
|
return errHTTPBadRequestUserNotFound
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ func TestUser_AddRemove(t *testing.T) {
|
|||||||
defer s.closeDatabases()
|
defer s.closeDatabases()
|
||||||
|
|
||||||
// Create admin, tier
|
// Create admin, tier
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||||
Code: "tier1",
|
Code: "tier1",
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Create user via API
|
// Create user via API
|
||||||
rr := request(t, s, "PUT", "/v1/users", `{"username": "ben", "password":"ben"}`, map[string]string{
|
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben"}`, map[string]string{
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
})
|
})
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
@@ -49,6 +49,226 @@ func TestUser_AddRemove(t *testing.T) {
|
|||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
})
|
})
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
// Check user was deleted
|
||||||
|
users, err = s.userManager.Users()
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 3, len(users))
|
||||||
|
require.Equal(t, "phil", users[0].Name)
|
||||||
|
require.Equal(t, "emma", users[1].Name)
|
||||||
|
require.Equal(t, user.Everyone, users[2].Name)
|
||||||
|
|
||||||
|
// Reject invalid user change
|
||||||
|
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben"}`, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 400, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUser_AddWithPasswordHash(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
|
// Create admin
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||||
|
|
||||||
|
// Create user via API
|
||||||
|
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "hash":"$2a$04$2aPIIqPXQU16OfkSUZH1XOzpu1gsPRKkrfVdFLgWQ.tqb.vtTCuVe"}`, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
// Check that user can login with password
|
||||||
|
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("ben", "ben"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
// Check users
|
||||||
|
users, err := s.userManager.Users()
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 3, len(users))
|
||||||
|
require.Equal(t, "phil", users[0].Name)
|
||||||
|
require.Equal(t, user.RoleAdmin, users[0].Role)
|
||||||
|
require.Equal(t, "ben", users[1].Name)
|
||||||
|
require.Equal(t, user.RoleUser, users[1].Role)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUser_ChangeUserPassword(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
|
// Create admin
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||||
|
|
||||||
|
// Create user via API
|
||||||
|
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password": "ben"}`, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
// Try to login with first password
|
||||||
|
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("ben", "ben"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
// Change password via API
|
||||||
|
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "password": "ben-two"}`, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
// Make sure first password fails
|
||||||
|
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("ben", "ben"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 401, rr.Code)
|
||||||
|
|
||||||
|
// Try to login with second password
|
||||||
|
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("ben", "ben-two"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUser_ChangeUserTier(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
|
// Create admin, tier
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||||
|
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||||
|
Code: "tier1",
|
||||||
|
}))
|
||||||
|
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||||
|
Code: "tier2",
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Create user with tier via API
|
||||||
|
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben", "tier": "tier1"}`, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
// Check users
|
||||||
|
users, err := s.userManager.Users()
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 3, len(users))
|
||||||
|
require.Equal(t, "phil", users[0].Name)
|
||||||
|
require.Equal(t, "ben", users[1].Name)
|
||||||
|
require.Equal(t, user.RoleUser, users[1].Role)
|
||||||
|
require.Equal(t, "tier1", users[1].Tier.Code)
|
||||||
|
|
||||||
|
// Change user tier via API
|
||||||
|
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "tier": "tier2"}`, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
// Check users again
|
||||||
|
users, err = s.userManager.Users()
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "tier2", users[1].Tier.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUser_ChangeUserPasswordAndTier(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
|
// Create admin, tier
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||||
|
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||||
|
Code: "tier1",
|
||||||
|
}))
|
||||||
|
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||||
|
Code: "tier2",
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Create user with tier via API
|
||||||
|
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben", "tier": "tier1"}`, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
// Check users
|
||||||
|
users, err := s.userManager.Users()
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 3, len(users))
|
||||||
|
require.Equal(t, "phil", users[0].Name)
|
||||||
|
require.Equal(t, "ben", users[1].Name)
|
||||||
|
require.Equal(t, user.RoleUser, users[1].Role)
|
||||||
|
require.Equal(t, "tier1", users[1].Tier.Code)
|
||||||
|
|
||||||
|
// Change user password and tier via API
|
||||||
|
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "password":"ben-two", "tier": "tier2"}`, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
// Make sure first password fails
|
||||||
|
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("ben", "ben"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 401, rr.Code)
|
||||||
|
|
||||||
|
// Try to login with second password
|
||||||
|
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("ben", "ben-two"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
// Check new tier
|
||||||
|
users, err = s.userManager.Users()
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "tier2", users[1].Tier.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUser_ChangeUserPasswordWithHash(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
|
// Create admin
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||||
|
|
||||||
|
// Create user with tier via API
|
||||||
|
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"not-ben"}`, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
// Try to login with first password
|
||||||
|
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("ben", "not-ben"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
// Change user password and tier via API
|
||||||
|
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "hash":"$2a$04$2aPIIqPXQU16OfkSUZH1XOzpu1gsPRKkrfVdFLgWQ.tqb.vtTCuVe"}`, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
// Try to login with second password
|
||||||
|
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("ben", "ben"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUser_DontChangeAdminPassword(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
|
// Create admin
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||||
|
require.Nil(t, s.userManager.AddUser("admin", "admin", user.RoleAdmin, false))
|
||||||
|
|
||||||
|
// Try to change password via API
|
||||||
|
rr := request(t, s, "PUT", "/v1/users", `{"username": "admin", "password": "admin-new"}`, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 403, rr.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUser_AddRemove_Failures(t *testing.T) {
|
func TestUser_AddRemove_Failures(t *testing.T) {
|
||||||
@@ -56,23 +276,23 @@ func TestUser_AddRemove_Failures(t *testing.T) {
|
|||||||
defer s.closeDatabases()
|
defer s.closeDatabases()
|
||||||
|
|
||||||
// Create admin
|
// Create admin
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||||
|
|
||||||
// Cannot create user with invalid username
|
// Cannot create user with invalid username
|
||||||
rr := request(t, s, "PUT", "/v1/users", `{"username": "not valid", "password":"ben"}`, map[string]string{
|
rr := request(t, s, "POST", "/v1/users", `{"username": "not valid", "password":"ben"}`, map[string]string{
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
})
|
})
|
||||||
require.Equal(t, 400, rr.Code)
|
require.Equal(t, 400, rr.Code)
|
||||||
|
|
||||||
// Cannot create user if user already exists
|
// Cannot create user if user already exists
|
||||||
rr = request(t, s, "PUT", "/v1/users", `{"username": "phil", "password":"phil"}`, map[string]string{
|
rr = request(t, s, "POST", "/v1/users", `{"username": "phil", "password":"phil"}`, map[string]string{
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
})
|
})
|
||||||
require.Equal(t, 40901, toHTTPError(t, rr.Body.String()).Code)
|
require.Equal(t, 40901, toHTTPError(t, rr.Body.String()).Code)
|
||||||
|
|
||||||
// Cannot create user with invalid tier
|
// Cannot create user with invalid tier
|
||||||
rr = request(t, s, "PUT", "/v1/users", `{"username": "emma", "password":"emma", "tier": "invalid"}`, map[string]string{
|
rr = request(t, s, "POST", "/v1/users", `{"username": "emma", "password":"emma", "tier": "invalid"}`, map[string]string{
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
})
|
})
|
||||||
require.Equal(t, 40030, toHTTPError(t, rr.Body.String()).Code)
|
require.Equal(t, 40030, toHTTPError(t, rr.Body.String()).Code)
|
||||||
@@ -97,8 +317,8 @@ func TestAccess_AllowReset(t *testing.T) {
|
|||||||
defer s.closeDatabases()
|
defer s.closeDatabases()
|
||||||
|
|
||||||
// User and admin
|
// User and admin
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||||
|
|
||||||
// Subscribing not allowed
|
// Subscribing not allowed
|
||||||
rr := request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
|
rr := request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
|
||||||
@@ -138,7 +358,7 @@ func TestAccess_AllowReset_NonAdminAttempt(t *testing.T) {
|
|||||||
defer s.closeDatabases()
|
defer s.closeDatabases()
|
||||||
|
|
||||||
// User
|
// User
|
||||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||||
|
|
||||||
// Grant access fails, because non-admin
|
// Grant access fails, because non-admin
|
||||||
rr := request(t, s, "POST", "/v1/users/access", `{"username": "ben", "topic":"gold", "permission":"ro"}`, map[string]string{
|
rr := request(t, s, "POST", "/v1/users/access", `{"username": "ben", "topic":"gold", "permission":"ro"}`, map[string]string{
|
||||||
@@ -154,8 +374,8 @@ func TestAccess_AllowReset_KillConnection(t *testing.T) {
|
|||||||
defer s.closeDatabases()
|
defer s.closeDatabases()
|
||||||
|
|
||||||
// User and admin, grant access to "gol*" topics
|
// User and admin, grant access to "gol*" topics
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||||
require.Nil(t, s.userManager.AllowAccess("ben", "gol*", user.PermissionRead)) // Wildcard!
|
require.Nil(t, s.userManager.AllowAccess("ben", "gol*", user.PermissionRead)) // Wildcard!
|
||||||
|
|
||||||
start, timeTaken := time.Now(), atomic.Int64{}
|
start, timeTaken := time.Now(), atomic.Int64{}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func (c *firebaseClient) Send(v *visitor, m *message) error {
|
|||||||
ev.Field("firebase_message", util.MaybeMarshalJSON(fbm)).Trace("Firebase message")
|
ev.Field("firebase_message", util.MaybeMarshalJSON(fbm)).Trace("Firebase message")
|
||||||
}
|
}
|
||||||
err = c.sender.Send(fbm)
|
err = c.sender.Send(fbm)
|
||||||
if err == errFirebaseQuotaExceeded {
|
if errors.Is(err, errFirebaseQuotaExceeded) {
|
||||||
logvm(v, m).
|
logvm(v, m).
|
||||||
Tag(tagFirebase).
|
Tag(tagFirebase).
|
||||||
Err(err).
|
Err(err).
|
||||||
@@ -133,56 +133,55 @@ func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, erro
|
|||||||
"time": fmt.Sprintf("%d", m.Time),
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
"event": m.Event,
|
"event": m.Event,
|
||||||
"topic": m.Topic,
|
"topic": m.Topic,
|
||||||
"message": m.Message,
|
"message": newMessageBody,
|
||||||
"poll_id": m.PollID,
|
"poll_id": m.PollID,
|
||||||
}
|
}
|
||||||
apnsConfig = createAPNSAlertConfig(m, data)
|
apnsConfig = createAPNSAlertConfig(m, data)
|
||||||
case messageEvent:
|
case messageEvent:
|
||||||
allowForward := true
|
|
||||||
if auther != nil {
|
if auther != nil {
|
||||||
allowForward = auther.Authorize(nil, m.Topic, user.PermissionRead) == nil
|
// If "anonymous read" for a topic is not allowed, we cannot send the message along
|
||||||
}
|
|
||||||
if allowForward {
|
|
||||||
data = map[string]string{
|
|
||||||
"id": m.ID,
|
|
||||||
"time": fmt.Sprintf("%d", m.Time),
|
|
||||||
"event": m.Event,
|
|
||||||
"topic": m.Topic,
|
|
||||||
"priority": fmt.Sprintf("%d", m.Priority),
|
|
||||||
"tags": strings.Join(m.Tags, ","),
|
|
||||||
"click": m.Click,
|
|
||||||
"icon": m.Icon,
|
|
||||||
"title": m.Title,
|
|
||||||
"message": m.Message,
|
|
||||||
"content_type": m.ContentType,
|
|
||||||
"encoding": m.Encoding,
|
|
||||||
}
|
|
||||||
if len(m.Actions) > 0 {
|
|
||||||
actions, err := json.Marshal(m.Actions)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
data["actions"] = string(actions)
|
|
||||||
}
|
|
||||||
if m.Attachment != nil {
|
|
||||||
data["attachment_name"] = m.Attachment.Name
|
|
||||||
data["attachment_type"] = m.Attachment.Type
|
|
||||||
data["attachment_size"] = fmt.Sprintf("%d", m.Attachment.Size)
|
|
||||||
data["attachment_expires"] = fmt.Sprintf("%d", m.Attachment.Expires)
|
|
||||||
data["attachment_url"] = m.Attachment.URL
|
|
||||||
}
|
|
||||||
apnsConfig = createAPNSAlertConfig(m, data)
|
|
||||||
} else {
|
|
||||||
// If anonymous read for a topic is not allowed, we cannot send the message along
|
|
||||||
// via Firebase. Instead, we send a "poll_request" message, asking the client to poll.
|
// via Firebase. Instead, we send a "poll_request" message, asking the client to poll.
|
||||||
data = map[string]string{
|
//
|
||||||
"id": m.ID,
|
// The data map needs to contain all the fields for it to function properly. If not all
|
||||||
"time": fmt.Sprintf("%d", m.Time),
|
// fields are set, the iOS app fails to decode the message.
|
||||||
"event": pollRequestEvent,
|
//
|
||||||
"topic": m.Topic,
|
// See https://github.com/binwiederhier/ntfy/pull/1345
|
||||||
|
if err := auther.Authorize(nil, m.Topic, user.PermissionRead); err != nil {
|
||||||
|
m = toPollRequest(m)
|
||||||
}
|
}
|
||||||
// TODO Handle APNS?
|
|
||||||
}
|
}
|
||||||
|
data = map[string]string{
|
||||||
|
"id": m.ID,
|
||||||
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
|
"event": m.Event,
|
||||||
|
"topic": m.Topic,
|
||||||
|
"priority": fmt.Sprintf("%d", m.Priority),
|
||||||
|
"tags": strings.Join(m.Tags, ","),
|
||||||
|
"click": m.Click,
|
||||||
|
"icon": m.Icon,
|
||||||
|
"title": m.Title,
|
||||||
|
"message": m.Message,
|
||||||
|
"content_type": m.ContentType,
|
||||||
|
"encoding": m.Encoding,
|
||||||
|
}
|
||||||
|
if len(m.Actions) > 0 {
|
||||||
|
actions, err := json.Marshal(m.Actions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data["actions"] = string(actions)
|
||||||
|
}
|
||||||
|
if m.Attachment != nil {
|
||||||
|
data["attachment_name"] = m.Attachment.Name
|
||||||
|
data["attachment_type"] = m.Attachment.Type
|
||||||
|
data["attachment_size"] = fmt.Sprintf("%d", m.Attachment.Size)
|
||||||
|
data["attachment_expires"] = fmt.Sprintf("%d", m.Attachment.Expires)
|
||||||
|
data["attachment_url"] = m.Attachment.URL
|
||||||
|
}
|
||||||
|
if m.PollID != "" {
|
||||||
|
data["poll_id"] = m.PollID
|
||||||
|
}
|
||||||
|
apnsConfig = createAPNSAlertConfig(m, data)
|
||||||
}
|
}
|
||||||
var androidConfig *messaging.AndroidConfig
|
var androidConfig *messaging.AndroidConfig
|
||||||
if m.Priority >= 4 {
|
if m.Priority >= 4 {
|
||||||
@@ -276,3 +275,17 @@ func maybeTruncateAPNSBodyMessage(s string) string {
|
|||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// toPollRequest converts a message to a poll request message.
|
||||||
|
//
|
||||||
|
// This empties all the fields that are not needed for a poll request and just sets the required fields,
|
||||||
|
// most importantly, the PollID.
|
||||||
|
func toPollRequest(m *message) *message {
|
||||||
|
pr := newPollRequestMessage(m.Topic, m.ID)
|
||||||
|
pr.ID = m.ID
|
||||||
|
pr.Time = m.Time
|
||||||
|
pr.Priority = m.Priority // Keep priority
|
||||||
|
pr.ContentType = m.ContentType
|
||||||
|
pr.Encoding = m.Encoding
|
||||||
|
return pr
|
||||||
|
}
|
||||||
|
|||||||
@@ -223,14 +223,25 @@ func TestToFirebaseMessage_Message_Normal_Not_Allowed(t *testing.T) {
|
|||||||
require.Equal(t, &messaging.AndroidConfig{
|
require.Equal(t, &messaging.AndroidConfig{
|
||||||
Priority: "high",
|
Priority: "high",
|
||||||
}, fbm.Android)
|
}, fbm.Android)
|
||||||
require.Equal(t, "", fbm.Data["message"])
|
require.Equal(t, "New message", fbm.Data["message"])
|
||||||
require.Equal(t, "", fbm.Data["priority"])
|
require.Equal(t, "5", fbm.Data["priority"])
|
||||||
require.Equal(t, map[string]string{
|
require.Equal(t, map[string]string{
|
||||||
"id": m.ID,
|
"id": m.ID,
|
||||||
"time": fmt.Sprintf("%d", m.Time),
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
"event": "poll_request",
|
"event": "poll_request",
|
||||||
"topic": "mytopic",
|
"topic": "mytopic",
|
||||||
|
"message": "New message",
|
||||||
|
"title": "",
|
||||||
|
"tags": "",
|
||||||
|
"click": "",
|
||||||
|
"icon": "",
|
||||||
|
"priority": "5",
|
||||||
|
"encoding": "",
|
||||||
|
"content_type": "",
|
||||||
|
"poll_id": m.ID,
|
||||||
}, fbm.Data)
|
}, fbm.Data)
|
||||||
|
require.Equal(t, "", fbm.APNS.Payload.Aps.Alert.Title)
|
||||||
|
require.Equal(t, "New message", fbm.APNS.Payload.Aps.Alert.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToFirebaseMessage_PollRequest(t *testing.T) {
|
func TestToFirebaseMessage_PollRequest(t *testing.T) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ func TestPayments_SubscriptionCreate_NotAStripeCustomer_Success(t *testing.T) {
|
|||||||
Code: "pro",
|
Code: "pro",
|
||||||
StripeMonthlyPriceID: "price_123",
|
StripeMonthlyPriceID: "price_123",
|
||||||
}))
|
}))
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||||
|
|
||||||
// Create subscription
|
// Create subscription
|
||||||
response := request(t, s, "POST", "/v1/account/billing/subscription", `{"tier": "pro", "interval": "month"}`, map[string]string{
|
response := request(t, s, "POST", "/v1/account/billing/subscription", `{"tier": "pro", "interval": "month"}`, map[string]string{
|
||||||
@@ -184,7 +184,7 @@ func TestPayments_SubscriptionCreate_StripeCustomer_Success(t *testing.T) {
|
|||||||
Code: "pro",
|
Code: "pro",
|
||||||
StripeMonthlyPriceID: "price_123",
|
StripeMonthlyPriceID: "price_123",
|
||||||
}))
|
}))
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||||
|
|
||||||
u, err := s.userManager.User("phil")
|
u, err := s.userManager.User("phil")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
@@ -226,7 +226,7 @@ func TestPayments_AccountDelete_Cancels_Subscription(t *testing.T) {
|
|||||||
Code: "pro",
|
Code: "pro",
|
||||||
StripeMonthlyPriceID: "price_123",
|
StripeMonthlyPriceID: "price_123",
|
||||||
}))
|
}))
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||||
|
|
||||||
u, err := s.userManager.User("phil")
|
u, err := s.userManager.User("phil")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
@@ -280,7 +280,7 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes
|
|||||||
MessageLimit: 220, // 220 * 5% = 11 requests before rate limiting kicks in
|
MessageLimit: 220, // 220 * 5% = 11 requests before rate limiting kicks in
|
||||||
MessageExpiryDuration: time.Hour,
|
MessageExpiryDuration: time.Hour,
|
||||||
}))
|
}))
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) // No tier
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) // No tier
|
||||||
u, err := s.userManager.User("phil")
|
u, err := s.userManager.User("phil")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
@@ -461,7 +461,7 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
|
|||||||
AttachmentTotalSizeLimit: 1000000,
|
AttachmentTotalSizeLimit: 1000000,
|
||||||
AttachmentBandwidthLimit: 1000000,
|
AttachmentBandwidthLimit: 1000000,
|
||||||
}))
|
}))
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||||
require.Nil(t, s.userManager.AddReservation("phil", "atopic", user.PermissionDenyAll))
|
require.Nil(t, s.userManager.AddReservation("phil", "atopic", user.PermissionDenyAll))
|
||||||
require.Nil(t, s.userManager.AddReservation("phil", "ztopic", user.PermissionDenyAll))
|
require.Nil(t, s.userManager.AddReservation("phil", "ztopic", user.PermissionDenyAll))
|
||||||
@@ -570,7 +570,7 @@ func TestPayments_Webhook_Subscription_Deleted(t *testing.T) {
|
|||||||
StripeMonthlyPriceID: "price_1234",
|
StripeMonthlyPriceID: "price_1234",
|
||||||
ReservationLimit: 1,
|
ReservationLimit: 1,
|
||||||
}))
|
}))
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||||
require.Nil(t, s.userManager.AddReservation("phil", "atopic", user.PermissionDenyAll))
|
require.Nil(t, s.userManager.AddReservation("phil", "atopic", user.PermissionDenyAll))
|
||||||
|
|
||||||
@@ -658,7 +658,7 @@ func TestPayments_Subscription_Update_Different_Tier(t *testing.T) {
|
|||||||
StripeMonthlyPriceID: "price_456",
|
StripeMonthlyPriceID: "price_456",
|
||||||
StripeYearlyPriceID: "price_457",
|
StripeYearlyPriceID: "price_457",
|
||||||
}))
|
}))
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||||
require.Nil(t, s.userManager.ChangeBilling("phil", &user.Billing{
|
require.Nil(t, s.userManager.ChangeBilling("phil", &user.Billing{
|
||||||
StripeCustomerID: "acct_123",
|
StripeCustomerID: "acct_123",
|
||||||
@@ -690,7 +690,7 @@ func TestPayments_Subscription_Delete_At_Period_End(t *testing.T) {
|
|||||||
Return(&stripe.Subscription{}, nil)
|
Return(&stripe.Subscription{}, nil)
|
||||||
|
|
||||||
// Create user
|
// Create user
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||||
require.Nil(t, s.userManager.ChangeBilling("phil", &user.Billing{
|
require.Nil(t, s.userManager.ChangeBilling("phil", &user.Billing{
|
||||||
StripeCustomerID: "acct_123",
|
StripeCustomerID: "acct_123",
|
||||||
StripeSubscriptionID: "sub_123",
|
StripeSubscriptionID: "sub_123",
|
||||||
@@ -724,7 +724,7 @@ func TestPayments_CreatePortalSession(t *testing.T) {
|
|||||||
}, nil)
|
}, nil)
|
||||||
|
|
||||||
// Create user
|
// Create user
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||||
require.Nil(t, s.userManager.ChangeBilling("phil", &user.Billing{
|
require.Nil(t, s.userManager.ChangeBilling("phil", &user.Billing{
|
||||||
StripeCustomerID: "acct_123",
|
StripeCustomerID: "acct_123",
|
||||||
StripeSubscriptionID: "sub_123",
|
StripeSubscriptionID: "sub_123",
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func TestServer_Twilio_Call_Add_Verify_Call_Delete_Success(t *testing.T) {
|
|||||||
MessageLimit: 10,
|
MessageLimit: 10,
|
||||||
CallLimit: 1,
|
CallLimit: 1,
|
||||||
}))
|
}))
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||||
u, err := s.userManager.User("phil")
|
u, err := s.userManager.User("phil")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
@@ -140,7 +140,7 @@ func TestServer_Twilio_Call_Success(t *testing.T) {
|
|||||||
MessageLimit: 10,
|
MessageLimit: 10,
|
||||||
CallLimit: 1,
|
CallLimit: 1,
|
||||||
}))
|
}))
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||||
u, err := s.userManager.User("phil")
|
u, err := s.userManager.User("phil")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
@@ -185,7 +185,7 @@ func TestServer_Twilio_Call_Success_With_Yes(t *testing.T) {
|
|||||||
MessageLimit: 10,
|
MessageLimit: 10,
|
||||||
CallLimit: 1,
|
CallLimit: 1,
|
||||||
}))
|
}))
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||||
u, err := s.userManager.User("phil")
|
u, err := s.userManager.User("phil")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
@@ -216,7 +216,7 @@ func TestServer_Twilio_Call_UnverifiedNumber(t *testing.T) {
|
|||||||
MessageLimit: 10,
|
MessageLimit: 10,
|
||||||
CallLimit: 1,
|
CallLimit: 1,
|
||||||
}))
|
}))
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||||
|
|
||||||
// Do the thing
|
// Do the thing
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ func TestServer_WebPush_TopicSubscribeProtected_Allowed(t *testing.T) {
|
|||||||
config.AuthDefault = user.PermissionDenyAll
|
config.AuthDefault = user.PermissionDenyAll
|
||||||
s := newTestServer(t, config)
|
s := newTestServer(t, config)
|
||||||
|
|
||||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||||
require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite))
|
require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite))
|
||||||
|
|
||||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), map[string]string{
|
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), map[string]string{
|
||||||
@@ -126,7 +126,7 @@ func TestServer_WebPush_DeleteAccountUnsubscribe(t *testing.T) {
|
|||||||
config := configureAuth(t, newTestConfigWithWebPush(t))
|
config := configureAuth(t, newTestConfigWithWebPush(t))
|
||||||
s := newTestServer(t, config)
|
s := newTestServer(t, config)
|
||||||
|
|
||||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||||
require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite))
|
require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite))
|
||||||
|
|
||||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), map[string]string{
|
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), map[string]string{
|
||||||
@@ -212,7 +212,7 @@ func TestServer_WebPush_Expiry(t *testing.T) {
|
|||||||
addSubscription(t, s, pushService.URL+"/push-receive", "test-topic")
|
addSubscription(t, s, pushService.URL+"/push-receive", "test-topic")
|
||||||
requireSubscriptionCount(t, s, "test-topic", 1)
|
requireSubscriptionCount(t, s, "test-topic", 1)
|
||||||
|
|
||||||
_, err := s.webPush.db.Exec("UPDATE subscription SET updated_at = ?", time.Now().Add(-7*24*time.Hour).Unix())
|
_, err := s.webPush.db.Exec("UPDATE subscription SET updated_at = ?", time.Now().Add(-55*24*time.Hour).Unix())
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
s.pruneAndNotifyWebPushSubscriptions()
|
s.pruneAndNotifyWebPushSubscriptions()
|
||||||
@@ -222,7 +222,7 @@ func TestServer_WebPush_Expiry(t *testing.T) {
|
|||||||
return received.Load()
|
return received.Load()
|
||||||
})
|
})
|
||||||
|
|
||||||
_, err = s.webPush.db.Exec("UPDATE subscription SET updated_at = ?", time.Now().Add(-9*24*time.Hour).Unix())
|
_, err = s.webPush.db.Exec("UPDATE subscription SET updated_at = ?", time.Now().Add(-60*24*time.Hour).Unix())
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
s.pruneAndNotifyWebPushSubscriptions()
|
s.pruneAndNotifyWebPushSubscriptions()
|
||||||
|
|||||||
@@ -110,9 +110,11 @@ func formatMail(baseURL, senderIP, from, to string, m *message) (string, error)
|
|||||||
if trailer != "" {
|
if trailer != "" {
|
||||||
message += "\n\n" + trailer
|
message += "\n\n" + trailer
|
||||||
}
|
}
|
||||||
|
date := time.Unix(m.Time, 0).UTC().Format(time.RFC1123Z)
|
||||||
subject = mime.BEncoding.Encode("utf-8", subject)
|
subject = mime.BEncoding.Encode("utf-8", subject)
|
||||||
body := `From: "{shortTopicURL}" <{from}>
|
body := `From: "{shortTopicURL}" <{from}>
|
||||||
To: {to}
|
To: {to}
|
||||||
|
Date: {date}
|
||||||
Subject: {subject}
|
Subject: {subject}
|
||||||
Content-Type: text/plain; charset="utf-8"
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
|
||||||
@@ -122,6 +124,7 @@ Content-Type: text/plain; charset="utf-8"
|
|||||||
This message was sent by {ip} at {time} via {topicURL}`
|
This message was sent by {ip} at {time} via {topicURL}`
|
||||||
body = strings.ReplaceAll(body, "{from}", from)
|
body = strings.ReplaceAll(body, "{from}", from)
|
||||||
body = strings.ReplaceAll(body, "{to}", to)
|
body = strings.ReplaceAll(body, "{to}", to)
|
||||||
|
body = strings.ReplaceAll(body, "{date}", date)
|
||||||
body = strings.ReplaceAll(body, "{subject}", subject)
|
body = strings.ReplaceAll(body, "{subject}", subject)
|
||||||
body = strings.ReplaceAll(body, "{message}", message)
|
body = strings.ReplaceAll(body, "{message}", message)
|
||||||
body = strings.ReplaceAll(body, "{topicURL}", topicURL)
|
body = strings.ReplaceAll(body, "{topicURL}", topicURL)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ func TestFormatMail_Basic(t *testing.T) {
|
|||||||
})
|
})
|
||||||
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||||
To: phil@example.com
|
To: phil@example.com
|
||||||
|
Date: Fri, 24 Dec 2021 21:43:24 +0000
|
||||||
Subject: A simple message
|
Subject: A simple message
|
||||||
Content-Type: text/plain; charset="utf-8"
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ func TestFormatMail_JustEmojis(t *testing.T) {
|
|||||||
})
|
})
|
||||||
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||||
To: phil@example.com
|
To: phil@example.com
|
||||||
|
Date: Fri, 24 Dec 2021 21:43:24 +0000
|
||||||
Subject: =?utf-8?b?8J+YgCBBIHNpbXBsZSBtZXNzYWdl?=
|
Subject: =?utf-8?b?8J+YgCBBIHNpbXBsZSBtZXNzYWdl?=
|
||||||
Content-Type: text/plain; charset="utf-8"
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
|
||||||
@@ -57,6 +59,7 @@ func TestFormatMail_JustOtherTags(t *testing.T) {
|
|||||||
})
|
})
|
||||||
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||||
To: phil@example.com
|
To: phil@example.com
|
||||||
|
Date: Fri, 24 Dec 2021 21:43:24 +0000
|
||||||
Subject: A simple message
|
Subject: A simple message
|
||||||
Content-Type: text/plain; charset="utf-8"
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
|
||||||
@@ -80,6 +83,7 @@ func TestFormatMail_JustPriority(t *testing.T) {
|
|||||||
})
|
})
|
||||||
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||||
To: phil@example.com
|
To: phil@example.com
|
||||||
|
Date: Fri, 24 Dec 2021 21:43:24 +0000
|
||||||
Subject: A simple message
|
Subject: A simple message
|
||||||
Content-Type: text/plain; charset="utf-8"
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
|
||||||
@@ -103,6 +107,7 @@ func TestFormatMail_UTF8Subject(t *testing.T) {
|
|||||||
})
|
})
|
||||||
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||||
To: phil@example.com
|
To: phil@example.com
|
||||||
|
Date: Fri, 24 Dec 2021 21:43:24 +0000
|
||||||
Subject: =?utf-8?b?IDo6IEEgbm90IHNvIHNpbXBsZSB0aXRsZSDDtsOkw7zDnyDCoUhvbGEsIHNl?= =?utf-8?b?w7FvciE=?=
|
Subject: =?utf-8?b?IDo6IEEgbm90IHNvIHNpbXBsZSB0aXRsZSDDtsOkw7zDnyDCoUhvbGEsIHNl?= =?utf-8?b?w7FvciE=?=
|
||||||
Content-Type: text/plain; charset="utf-8"
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
|
||||||
@@ -126,6 +131,7 @@ func TestFormatMail_WithAllTheThings(t *testing.T) {
|
|||||||
})
|
})
|
||||||
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||||
To: phil@example.com
|
To: phil@example.com
|
||||||
|
Date: Fri, 24 Dec 2021 21:43:24 +0000
|
||||||
Subject: =?utf-8?b?4pqg77iPIPCfkoAgT2ggbm8g8J+ZiCBUaGlzIGlzIGEgbWVzc2FnZSBhY3Jv?= =?utf-8?b?c3MgbXVsdGlwbGUgbGluZXM=?=
|
Subject: =?utf-8?b?4pqg77iPIPCfkoAgT2ggbm8g8J+ZiCBUaGlzIGlzIGEgbWVzc2FnZSBhY3Jv?= =?utf-8?b?c3MgbXVsdGlwbGUgbGluZXM=?=
|
||||||
Content-Type: text/plain; charset="utf-8"
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
@@ -70,15 +71,19 @@ func (b *smtpBackend) Counts() (total int64, success int64, failure int64) {
|
|||||||
|
|
||||||
// smtpSession is returned after EHLO.
|
// smtpSession is returned after EHLO.
|
||||||
type smtpSession struct {
|
type smtpSession struct {
|
||||||
backend *smtpBackend
|
backend *smtpBackend
|
||||||
conn *smtp.Conn
|
conn *smtp.Conn
|
||||||
topic string
|
topic string
|
||||||
token string
|
token string // If email address contains token, e.g. topic+token@domain
|
||||||
mu sync.Mutex
|
basicAuth string // If SMTP AUTH PLAIN was used
|
||||||
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *smtpSession) AuthPlain(username, _ string) error {
|
func (s *smtpSession) AuthPlain(username, password string) error {
|
||||||
logem(s.conn).Field("smtp_username", username).Debug("AUTH PLAIN (with username %s)", username)
|
logem(s.conn).Field("smtp_username", username).Debug("AUTH PLAIN (with username %s)", username)
|
||||||
|
s.mu.Lock()
|
||||||
|
s.basicAuth = base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", username, password)))
|
||||||
|
s.mu.Unlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,8 +155,8 @@ func (s *smtpSession) Data(r io.Reader) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
body = strings.TrimSpace(body)
|
body = strings.TrimSpace(body)
|
||||||
if len(body) > conf.MessageLimit {
|
if len(body) > conf.MessageSizeLimit {
|
||||||
body = body[:conf.MessageLimit]
|
body = body[:conf.MessageSizeLimit]
|
||||||
}
|
}
|
||||||
m := newDefaultMessage(s.topic, body)
|
m := newDefaultMessage(s.topic, body)
|
||||||
subject := strings.TrimSpace(msg.Header.Get("Subject"))
|
subject := strings.TrimSpace(msg.Header.Get("Subject"))
|
||||||
@@ -187,9 +192,9 @@ 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.RequestURI = "/" + m.Topic // just for the logs
|
||||||
req.RemoteAddr = remoteAddr // rate limiting!!
|
req.RemoteAddr = remoteAddr // rate limiting!!
|
||||||
req.Header.Set("X-Forwarded-For", remoteAddr)
|
req.Header.Set(s.backend.config.ProxyForwardedHeader, remoteAddr) // Set X-Forwarded-For header
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -198,6 +203,8 @@ func (s *smtpSession) publishMessage(m *message) error {
|
|||||||
}
|
}
|
||||||
if s.token != "" {
|
if s.token != "" {
|
||||||
req.Header.Add("Authorization", "Bearer "+s.token)
|
req.Header.Add("Authorization", "Bearer "+s.token)
|
||||||
|
} else if s.basicAuth != "" {
|
||||||
|
req.Header.Add("Authorization", "Basic "+s.basicAuth)
|
||||||
}
|
}
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
s.backend.handler(rr, req)
|
s.backend.handler(rr, req)
|
||||||
@@ -214,6 +221,9 @@ func (s *smtpSession) Reset() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *smtpSession) Logout() error {
|
func (s *smtpSession) Logout() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.basicAuth = ""
|
||||||
|
s.mu.Unlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1386,6 +1386,28 @@ what's up
|
|||||||
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
|
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSmtpBackend_PlaintextWithPlainAuth(t *testing.T) {
|
||||||
|
email := `EHLO example.com
|
||||||
|
AUTH PLAIN dGVzdAB0ZXN0ADEyMzQ=
|
||||||
|
MAIL FROM: phil@example.com
|
||||||
|
RCPT TO: ntfy-mytopic@ntfy.sh
|
||||||
|
DATA
|
||||||
|
Subject: Very short mail
|
||||||
|
|
||||||
|
what's up
|
||||||
|
.
|
||||||
|
`
|
||||||
|
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
require.Equal(t, "/mytopic", r.URL.Path)
|
||||||
|
require.Equal(t, "Very short mail", r.Header.Get("Title"))
|
||||||
|
require.Equal(t, "Basic dGVzdDoxMjM0", r.Header.Get("Authorization"))
|
||||||
|
require.Equal(t, "what's up", readAll(t, r.Body))
|
||||||
|
})
|
||||||
|
defer s.Close()
|
||||||
|
defer c.Close()
|
||||||
|
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
|
||||||
|
}
|
||||||
|
|
||||||
type smtpHandlerFunc func(http.ResponseWriter, *http.Request)
|
type smtpHandlerFunc func(http.ResponseWriter, *http.Request)
|
||||||
|
|
||||||
func newTestSMTPServer(t *testing.T, handler smtpHandlerFunc) (s *smtp.Server, c net.Conn, conf *Config, scanner *bufio.Scanner) {
|
func newTestSMTPServer(t *testing.T, handler smtpHandlerFunc) (s *smtp.Server, c net.Conn, conf *Config, scanner *bufio.Scanner) {
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestTopic_CancelSubscribersExceptUser(t *testing.T) {
|
func TestTopic_CancelSubscribersExceptUser(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
subFn := func(v *visitor, msg *message) error {
|
subFn := func(v *visitor, msg *message) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ type publishMessage struct {
|
|||||||
Filename string `json:"filename"`
|
Filename string `json:"filename"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Call string `json:"call"`
|
Call string `json:"call"`
|
||||||
|
Cache string `json:"cache"` // use string as it defaults to true (or use &bool instead)
|
||||||
|
Firebase string `json:"firebase"` // use string as it defaults to true (or use &bool instead)
|
||||||
Delay string `json:"delay"`
|
Delay string `json:"delay"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,8 +171,12 @@ func (t sinceMarker) IsNone() bool {
|
|||||||
return t == sinceNoMessages
|
return t == sinceNoMessages
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t sinceMarker) IsLatest() bool {
|
||||||
|
return t == sinceLatestMessage
|
||||||
|
}
|
||||||
|
|
||||||
func (t sinceMarker) IsID() bool {
|
func (t sinceMarker) IsID() bool {
|
||||||
return t.id != ""
|
return t.id != "" && t.id != "latest"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t sinceMarker) Time() time.Time {
|
func (t sinceMarker) Time() time.Time {
|
||||||
@@ -182,8 +188,9 @@ func (t sinceMarker) ID() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
sinceAllMessages = sinceMarker{time.Unix(0, 0), ""}
|
sinceAllMessages = sinceMarker{time.Unix(0, 0), ""}
|
||||||
sinceNoMessages = sinceMarker{time.Unix(1, 0), ""}
|
sinceNoMessages = sinceMarker{time.Unix(1, 0), ""}
|
||||||
|
sinceLatestMessage = sinceMarker{time.Unix(0, 0), "latest"}
|
||||||
)
|
)
|
||||||
|
|
||||||
type queryFilter struct {
|
type queryFilter struct {
|
||||||
@@ -248,9 +255,10 @@ type apiStatsResponse struct {
|
|||||||
MessagesRate float64 `json:"messages_rate"` // Average number of messages per second
|
MessagesRate float64 `json:"messages_rate"` // Average number of messages per second
|
||||||
}
|
}
|
||||||
|
|
||||||
type apiUserAddRequest struct {
|
type apiUserAddOrUpdateRequest struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
|
Hash string `json:"hash"`
|
||||||
Tier string `json:"tier"`
|
Tier string `json:"tier"`
|
||||||
// Do not add 'role' here. We don't want to add admins via the API.
|
// Do not add 'role' here. We don't want to add admins via the API.
|
||||||
}
|
}
|
||||||
|
|||||||
119
server/util.go
@@ -2,19 +2,32 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"heckel.io/ntfy/v2/util"
|
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"heckel.io/ntfy/v2/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
mimeDecoder mime.WordDecoder
|
mimeDecoder mime.WordDecoder
|
||||||
|
|
||||||
|
// 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 and IPv6 addresses from the "Forwarded" header (RFC 7239)
|
||||||
|
// IPv6 addresses in Forwarded header are enclosed in square brackets. The port is optional.
|
||||||
|
//
|
||||||
|
// Examples:
|
||||||
|
// for="1.2.3.4"
|
||||||
|
// for="[2001:db8::1]"; for=1.2.3.4:8080, by=phil
|
||||||
|
// for="1.2.3.4:8080"
|
||||||
|
forwardedHeaderRegex = regexp.MustCompile(`(?i)\bfor="?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|\[[0-9a-f:]+])(?::\d+)?"?`)
|
||||||
)
|
)
|
||||||
|
|
||||||
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
|
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
|
||||||
@@ -33,15 +46,11 @@ func toBool(value string) bool {
|
|||||||
return value == "1" || value == "yes" || value == "true"
|
return value == "1" || value == "yes" || value == "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
func readCommaSeparatedParam(r *http.Request, names ...string) (params []string) {
|
func readCommaSeparatedParam(r *http.Request, names ...string) []string {
|
||||||
paramStr := readParam(r, names...)
|
if paramStr := readParam(r, names...); paramStr != "" {
|
||||||
if paramStr != "" {
|
return util.Map(util.SplitNoEmpty(paramStr, ","), strings.TrimSpace)
|
||||||
params = make([]string, 0)
|
|
||||||
for _, s := range util.SplitNoEmpty(paramStr, ",") {
|
|
||||||
params = append(params, strings.TrimSpace(s))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return params
|
return []string{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func readParam(r *http.Request, names ...string) string {
|
func readParam(r *http.Request, names ...string) string {
|
||||||
@@ -72,41 +81,75 @@ func readQueryParam(r *http.Request, names ...string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr {
|
// extractIPAddress extracts the IP address of the visitor from the request,
|
||||||
remoteAddr := r.RemoteAddr
|
// either from the TCP socket or from a proxy header.
|
||||||
addrPort, err := netip.ParseAddrPort(remoteAddr)
|
func extractIPAddress(r *http.Request, behindProxy bool, proxyForwardedHeader string, proxyTrustedPrefixes []netip.Prefix) netip.Addr {
|
||||||
ip := addrPort.Addr()
|
if behindProxy && proxyForwardedHeader != "" {
|
||||||
|
if addr, err := extractIPAddressFromHeader(r, proxyForwardedHeader, proxyTrustedPrefixes); err == nil {
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
// Fall back to the remote address if the header is not found or invalid
|
||||||
|
}
|
||||||
|
addrPort, err := netip.ParseAddrPort(r.RemoteAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// This should not happen in real life; only in tests. So, using falling back to 0.0.0.0 if address unspecified
|
logr(r).Err(err).Warn("unable to parse IP (%s), new visitor with unspecified IP (0.0.0.0) created", r.RemoteAddr)
|
||||||
ip, err = netip.ParseAddr(remoteAddr)
|
return netip.IPv4Unspecified()
|
||||||
if err != nil {
|
}
|
||||||
ip = netip.IPv4Unspecified()
|
return addrPort.Addr()
|
||||||
if remoteAddr != "@" || !behindProxy { // RemoteAddr is @ when unix socket is used
|
}
|
||||||
logr(r).Err(err).Warn("unable to parse IP (%s), new visitor with unspecified IP (0.0.0.0) created", remoteAddr)
|
|
||||||
|
// extractIPAddressFromHeader extracts the last IP address from the specified header.
|
||||||
|
//
|
||||||
|
// It supports multiple formats:
|
||||||
|
// - single IP address
|
||||||
|
// - comma-separated list
|
||||||
|
// - RFC 7239-style list (Forwarded header)
|
||||||
|
//
|
||||||
|
// If there are multiple addresses, we first remove the trusted IP addresses from the list, and
|
||||||
|
// then take the right-most address in the list (as this is the one added by our proxy server).
|
||||||
|
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For for details.
|
||||||
|
func extractIPAddressFromHeader(r *http.Request, forwardedHeader string, trustedPrefixes []netip.Prefix) (netip.Addr, error) {
|
||||||
|
value := strings.TrimSpace(strings.ToLower(r.Header.Get(forwardedHeader)))
|
||||||
|
if value == "" {
|
||||||
|
return netip.IPv4Unspecified(), fmt.Errorf("no %s header found", forwardedHeader)
|
||||||
|
}
|
||||||
|
// Extract valid addresses
|
||||||
|
addrsStrs := util.Map(util.SplitNoEmpty(value, ","), strings.TrimSpace)
|
||||||
|
var validAddrs []netip.Addr
|
||||||
|
for _, addrStr := range addrsStrs {
|
||||||
|
// Handle Forwarded header with for="[IPv6]" or for="IPv4"
|
||||||
|
if m := forwardedHeaderRegex.FindStringSubmatch(addrStr); len(m) == 2 {
|
||||||
|
addrRaw := m[1]
|
||||||
|
if strings.HasPrefix(addrRaw, "[") && strings.HasSuffix(addrRaw, "]") {
|
||||||
|
addrRaw = addrRaw[1 : len(addrRaw)-1]
|
||||||
|
}
|
||||||
|
if addr, err := netip.ParseAddr(addrRaw); err == nil {
|
||||||
|
validAddrs = append(validAddrs, addr)
|
||||||
|
}
|
||||||
|
} else if addr, err := netip.ParseAddr(addrStr); err == nil {
|
||||||
|
validAddrs = append(validAddrs, addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Filter out proxy addresses
|
||||||
|
clientAddrs := util.Filter(validAddrs, func(addr netip.Addr) bool {
|
||||||
|
for _, prefix := range trustedPrefixes {
|
||||||
|
if prefix.Contains(addr) {
|
||||||
|
return false // Address is in the trusted range, ignore it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if len(clientAddrs) == 0 {
|
||||||
|
return netip.IPv4Unspecified(), fmt.Errorf("no client IP address found in %s header: %s", forwardedHeader, value)
|
||||||
}
|
}
|
||||||
if behindProxy && strings.TrimSpace(r.Header.Get("X-Forwarded-For")) != "" {
|
return clientAddrs[len(clientAddrs)-1], nil
|
||||||
// X-Forwarded-For can contain multiple addresses (see #328). If we are behind a proxy,
|
|
||||||
// only the right-most address can be trusted (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.
|
|
||||||
ips := util.SplitNoEmpty(r.Header.Get("X-Forwarded-For"), ",")
|
|
||||||
realIP, err := netip.ParseAddr(strings.TrimSpace(util.LastString(ips, remoteAddr)))
|
|
||||||
if err != nil {
|
|
||||||
logr(r).Err(err).Error("invalid IP address %s received in X-Forwarded-For header", ip)
|
|
||||||
// Fall back to regular remote address if X-Forwarded-For is damaged
|
|
||||||
} else {
|
|
||||||
ip = realIP
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ip
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func readJSONWithLimit[T any](r io.ReadCloser, limit int, allowEmpty bool) (*T, error) {
|
func readJSONWithLimit[T any](r io.ReadCloser, limit int, allowEmpty bool) (*T, error) {
|
||||||
obj, err := util.UnmarshalJSONWithLimit[T](r, limit, allowEmpty)
|
obj, err := util.UnmarshalJSONWithLimit[T](r, limit, allowEmpty)
|
||||||
if err == util.ErrUnmarshalJSON {
|
if errors.Is(err, util.ErrUnmarshalJSON) {
|
||||||
return nil, errHTTPBadRequestJSONInvalid
|
return nil, errHTTPBadRequestJSONInvalid
|
||||||
} else if err == util.ErrTooLargeJSON {
|
} else if errors.Is(err, util.ErrTooLargeJSON) {
|
||||||
return nil, errHTTPEntityTooLargeJSONBody
|
return nil, errHTTPEntityTooLargeJSONBody
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -132,7 +175,7 @@ func fromContext[T any](r *http.Request, key contextKey) (T, error) {
|
|||||||
|
|
||||||
// maybeDecodeHeader decodes the given header value if it is MIME encoded, e.g. "=?utf-8?q?Hello_World?=",
|
// maybeDecodeHeader decodes the given header value if it is MIME encoded, e.g. "=?utf-8?q?Hello_World?=",
|
||||||
// or returns the original header value if it is not MIME encoded. It also calls maybeIgnoreSpecialHeader
|
// or returns the original header value if it is not MIME encoded. It also calls maybeIgnoreSpecialHeader
|
||||||
// to ignore new HTTP "Priority" header.
|
// to ignore the new HTTP "Priority" header.
|
||||||
func maybeDecodeHeader(name, value string) string {
|
func maybeDecodeHeader(name, value string) string {
|
||||||
decoded, err := mimeDecoder.DecodeHeader(value)
|
decoded, err := mimeDecoder.DecodeHeader(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -141,7 +184,7 @@ func maybeDecodeHeader(name, value string) string {
|
|||||||
return maybeIgnoreSpecialHeader(name, decoded)
|
return maybeIgnoreSpecialHeader(name, decoded)
|
||||||
}
|
}
|
||||||
|
|
||||||
// maybeIgnoreSpecialHeader ignores new HTTP "Priority" header (see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-priority)
|
// maybeIgnoreSpecialHeader ignores the new HTTP "Priority" header (RFC 9218, see https://datatracker.ietf.org/doc/html/rfc9218)
|
||||||
//
|
//
|
||||||
// Cloudflare (and potentially other providers) add this to requests when forwarding to the backend (ntfy),
|
// Cloudflare (and potentially other providers) add this to requests when forwarding to the backend (ntfy),
|
||||||
// so we just ignore it. If the "Priority" header is set to "u=*, i" or "u=*" (by Cloudflare), the header will be ignored.
|
// so we just ignore it. If the "Priority" header is set to "u=*, i" or "u=*" (by Cloudflare), the header will be ignored.
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -88,3 +91,74 @@ func TestMaybeDecodeHeaders(t *testing.T) {
|
|||||||
r.Header.Set("X-Priority", "5") // ntfy priority header
|
r.Header.Set("X-Priority", "5") // ntfy priority header
|
||||||
require.Equal(t, "5", readHeaderParam(r, "x-priority", "priority", "p"))
|
require.Equal(t, "5", readHeaderParam(r, "x-priority", "priority", "p"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractIPAddress(t *testing.T) {
|
||||||
|
r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", nil)
|
||||||
|
r.RemoteAddr = "10.0.0.1:1234"
|
||||||
|
r.Header.Set("X-Forwarded-For", " 1.2.3.4 , 5.6.7.8")
|
||||||
|
r.Header.Set("X-Client-IP", "9.10.11.12")
|
||||||
|
r.Header.Set("X-Real-IP", "13.14.15.16, 1.1.1.1")
|
||||||
|
r.Header.Set("Forwarded", "for=17.18.19.20;by=proxy.example.com, by=2.2.2.2;for=1.1.1.1")
|
||||||
|
|
||||||
|
trustedProxies := []netip.Prefix{netip.MustParsePrefix("1.1.1.1/32")}
|
||||||
|
|
||||||
|
require.Equal(t, "5.6.7.8", extractIPAddress(r, true, "X-Forwarded-For", trustedProxies).String())
|
||||||
|
require.Equal(t, "9.10.11.12", extractIPAddress(r, true, "X-Client-IP", trustedProxies).String())
|
||||||
|
require.Equal(t, "13.14.15.16", extractIPAddress(r, true, "X-Real-IP", trustedProxies).String())
|
||||||
|
require.Equal(t, "17.18.19.20", extractIPAddress(r, true, "Forwarded", trustedProxies).String())
|
||||||
|
require.Equal(t, "10.0.0.1", extractIPAddress(r, false, "X-Forwarded-For", trustedProxies).String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractIPAddress_UnixSocket(t *testing.T) {
|
||||||
|
r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", nil)
|
||||||
|
r.RemoteAddr = "@"
|
||||||
|
r.Header.Set("X-Forwarded-For", "1.2.3.4, 5.6.7.8, 1.1.1.1")
|
||||||
|
r.Header.Set("Forwarded", "by=bla.example.com;for=17.18.19.20")
|
||||||
|
|
||||||
|
trustedProxies := []netip.Prefix{netip.MustParsePrefix("1.1.1.1/32")}
|
||||||
|
|
||||||
|
require.Equal(t, "5.6.7.8", extractIPAddress(r, true, "X-Forwarded-For", trustedProxies).String())
|
||||||
|
require.Equal(t, "17.18.19.20", extractIPAddress(r, true, "Forwarded", trustedProxies).String())
|
||||||
|
require.Equal(t, "0.0.0.0", extractIPAddress(r, false, "X-Forwarded-For", trustedProxies).String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractIPAddress_MixedIPv4IPv6(t *testing.T) {
|
||||||
|
r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", nil)
|
||||||
|
r.RemoteAddr = "[2001:db8:abcd::1]:1234"
|
||||||
|
r.Header.Set("X-Forwarded-For", "1.2.3.4, 2001:db8:abcd::2, 5.6.7.8")
|
||||||
|
trustedProxies := []netip.Prefix{netip.MustParsePrefix("1.2.3.0/24")}
|
||||||
|
require.Equal(t, "5.6.7.8", extractIPAddress(r, true, "X-Forwarded-For", trustedProxies).String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractIPAddress_TrustedIPv6Prefix(t *testing.T) {
|
||||||
|
r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", nil)
|
||||||
|
r.RemoteAddr = "[2001:db8:abcd::1]:1234"
|
||||||
|
r.Header.Set("X-Forwarded-For", "2001:db8:aaaa::1, 2001:db8:aaaa::2, 2001:db8:abcd:2::3")
|
||||||
|
trustedProxies := []netip.Prefix{netip.MustParsePrefix("2001:db8:aaaa::/48")}
|
||||||
|
require.Equal(t, "2001:db8:abcd:2::3", extractIPAddress(r, true, "X-Forwarded-For", trustedProxies).String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVisitorID(t *testing.T) {
|
||||||
|
confWithDefaults := &Config{
|
||||||
|
VisitorPrefixBitsIPv4: 32,
|
||||||
|
VisitorPrefixBitsIPv6: 64,
|
||||||
|
}
|
||||||
|
confWithShortenedPrefixes := &Config{
|
||||||
|
VisitorPrefixBitsIPv4: 16,
|
||||||
|
VisitorPrefixBitsIPv6: 56,
|
||||||
|
}
|
||||||
|
userWithTier := &user.User{
|
||||||
|
ID: "u_123",
|
||||||
|
Tier: &user.Tier{},
|
||||||
|
}
|
||||||
|
require.Equal(t, "ip:1.2.3.4", visitorID(netip.MustParseAddr("1.2.3.4"), nil, confWithDefaults))
|
||||||
|
require.Equal(t, "ip:2a01:599:b26:2397::", visitorID(netip.MustParseAddr("2a01:599:b26:2397:dbe7:5aa2:95ce:1e83"), nil, confWithDefaults))
|
||||||
|
require.Equal(t, "ip:2001:db8:25:86::", visitorID(netip.MustParseAddr("2001:db8:25:86:1::1"), nil, confWithDefaults))
|
||||||
|
require.Equal(t, "ip:2001:db8:25:86::", visitorID(netip.MustParseAddr("2001:db8:25:86:2::1"), nil, confWithDefaults))
|
||||||
|
|
||||||
|
require.Equal(t, "user:u_123", visitorID(netip.MustParseAddr("1.2.3.4"), userWithTier, confWithDefaults))
|
||||||
|
require.Equal(t, "user:u_123", visitorID(netip.MustParseAddr("2a01:599:b26:2397:dbe7:5aa2:95ce:1e83"), userWithTier, confWithDefaults))
|
||||||
|
|
||||||
|
require.Equal(t, "ip:1.2.0.0", visitorID(netip.MustParseAddr("1.2.3.4"), nil, confWithShortenedPrefixes))
|
||||||
|
require.Equal(t, "ip:2a01:599:b26:2300::", visitorID(netip.MustParseAddr("2a01:599:b26:2397:dbe7:5aa2:95ce:1e83"), nil, confWithShortenedPrefixes))
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ package server
|
|||||||
|
|
||||||
import (
|
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,10 +30,10 @@ const (
|
|||||||
visitorDefaultCallsLimit = int64(0)
|
visitorDefaultCallsLimit = int64(0)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Constants used to convert a tier-user's MessageLimit (see user.Tier) into adequate request limiter
|
// Constants used to convert a tier-user's MessageSizeLimit (see user.Tier) into adequate request limiter
|
||||||
// values (token bucket). This is only used to increase the values in server.yml, never decrease them.
|
// values (token bucket). This is only used to increase the values in server.yml, never decrease them.
|
||||||
//
|
//
|
||||||
// Example: Assuming a user.Tier's MessageLimit is 10,000:
|
// Example: Assuming a user.Tier's MessageSizeLimit is 10,000:
|
||||||
// - the allowed burst is 500 (= 10,000 * 5%), which is < 1000 (the max)
|
// - the allowed burst is 500 (= 10,000 * 5%), which is < 1000 (the max)
|
||||||
// - the replenish rate is 2 * 10,000 / 24 hours
|
// - the replenish rate is 2 * 10,000 / 24 hours
|
||||||
const (
|
const (
|
||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,8 +79,9 @@ const (
|
|||||||
deleteWebPushSubscriptionByUserIDQuery = `DELETE FROM subscription WHERE user_id = ?`
|
deleteWebPushSubscriptionByUserIDQuery = `DELETE FROM subscription WHERE user_id = ?`
|
||||||
deleteWebPushSubscriptionByAgeQuery = `DELETE FROM subscription WHERE updated_at <= ?` // Full table scan!
|
deleteWebPushSubscriptionByAgeQuery = `DELETE FROM subscription WHERE updated_at <= ?` // Full table scan!
|
||||||
|
|
||||||
insertWebPushSubscriptionTopicQuery = `INSERT INTO subscription_topic (subscription_id, topic) VALUES (?, ?)`
|
insertWebPushSubscriptionTopicQuery = `INSERT INTO subscription_topic (subscription_id, topic) VALUES (?, ?)`
|
||||||
deleteWebPushSubscriptionTopicAllQuery = `DELETE FROM subscription_topic WHERE subscription_id = ?`
|
deleteWebPushSubscriptionTopicAllQuery = `DELETE FROM subscription_topic WHERE subscription_id = ?`
|
||||||
|
deleteWebPushSubscriptionTopicWithoutSubscription = `DELETE FROM subscription_topic WHERE subscription_id NOT IN (SELECT id FROM subscription)`
|
||||||
)
|
)
|
||||||
|
|
||||||
// Schema management queries
|
// Schema management queries
|
||||||
@@ -271,6 +272,10 @@ func (c *webPushStore) RemoveSubscriptionsByUserID(userID string) error {
|
|||||||
// RemoveExpiredSubscriptions removes all subscriptions that have not been updated for a given time period
|
// RemoveExpiredSubscriptions removes all subscriptions that have not been updated for a given time period
|
||||||
func (c *webPushStore) RemoveExpiredSubscriptions(expireAfter time.Duration) error {
|
func (c *webPushStore) RemoveExpiredSubscriptions(expireAfter time.Duration) error {
|
||||||
_, err := c.db.Exec(deleteWebPushSubscriptionByAgeQuery, time.Now().Add(-expireAfter).Unix())
|
_, err := c.db.Exec(deleteWebPushSubscriptionByAgeQuery, time.Now().Add(-expireAfter).Unix())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = c.db.Exec(deleteWebPushSubscriptionTopicWithoutSubscription)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ func StartServer(t *testing.T) (*server.Server, int) {
|
|||||||
|
|
||||||
// StartServerWithConfig starts a server.Server with a random port and waits for the server to be up
|
// StartServerWithConfig starts a server.Server with a random port and waits for the server to be up
|
||||||
func StartServerWithConfig(t *testing.T, conf *server.Config) (*server.Server, int) {
|
func StartServerWithConfig(t *testing.T, conf *server.Config) (*server.Server, int) {
|
||||||
port := 10000 + rand.Intn(20000)
|
port := 10000 + rand.Intn(30000)
|
||||||
conf.ListenHTTP = fmt.Sprintf(":%d", port)
|
conf.ListenHTTP = fmt.Sprintf(":%d", port)
|
||||||
conf.AttachmentCacheDir = t.TempDir()
|
conf.AttachmentCacheDir = t.TempDir()
|
||||||
conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")
|
conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const (
|
|||||||
userHardDeleteAfterDuration = 7 * 24 * time.Hour
|
userHardDeleteAfterDuration = 7 * 24 * time.Hour
|
||||||
tokenPrefix = "tk_"
|
tokenPrefix = "tk_"
|
||||||
tokenLength = 32
|
tokenLength = 32
|
||||||
tokenMaxCount = 20 // Only keep this many tokens in the table per user
|
tokenMaxCount = 60 // Only keep this many tokens in the table per user
|
||||||
tag = "user_manager"
|
tag = "user_manager"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -864,13 +864,19 @@ 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) error {
|
func (a *Manager) AddUser(username, password string, role Role, hashed bool) error {
|
||||||
if !AllowedUsername(username) || !AllowedRole(role) {
|
if !AllowedUsername(username) || !AllowedRole(role) {
|
||||||
return ErrInvalidArgument
|
return ErrInvalidArgument
|
||||||
}
|
}
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), a.bcryptCost)
|
var hash []byte
|
||||||
if err != nil {
|
var err error = nil
|
||||||
return err
|
if hashed {
|
||||||
|
hash = []byte(password)
|
||||||
|
} else {
|
||||||
|
hash, err = bcrypt.GenerateFromPassword([]byte(password), a.bcryptCost)
|
||||||
|
if err != nil {
|
||||||
|
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()
|
||||||
@@ -1192,10 +1198,17 @@ 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) error {
|
func (a *Manager) ChangePassword(username, password string, hashed bool) error {
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), a.bcryptCost)
|
var hash []byte
|
||||||
if err != nil {
|
var err error
|
||||||
return err
|
|
||||||
|
if hashed {
|
||||||
|
hash = []byte(password)
|
||||||
|
} else {
|
||||||
|
hash, err = bcrypt.GenerateFromPassword([]byte(password), a.bcryptCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if _, err := a.db.Exec(updateUserPassQuery, hash, username); err != nil {
|
if _, err := a.db.Exec(updateUserPassQuery, hash, username); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const minBcryptTimingMillis = int64(50) // Ideally should be >100ms, but this should also run on a Raspberry Pi without massive resources
|
const minBcryptTimingMillis = int64(40) // Ideally should be >100ms, but this should also run on a Raspberry Pi without massive resources
|
||||||
|
|
||||||
func TestManager_FullScenario_Default_DenyAll(t *testing.T) {
|
func TestManager_FullScenario_Default_DenyAll(t *testing.T) {
|
||||||
a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval)
|
a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval)
|
||||||
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin))
|
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, false))
|
||||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
|
||||||
require.Nil(t, a.AddUser("john", "john", RoleUser))
|
require.Nil(t, a.AddUser("john", "john", RoleUser, false))
|
||||||
require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite))
|
require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite))
|
||||||
require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead))
|
require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead))
|
||||||
require.Nil(t, a.AllowAccess("ben", "writeme", PermissionWrite))
|
require.Nil(t, a.AllowAccess("ben", "writeme", PermissionWrite))
|
||||||
@@ -134,7 +134,7 @@ func TestManager_Access_Order_LengthWriteRead(t *testing.T) {
|
|||||||
// and longer ACL rules are prioritized as well.
|
// and longer ACL rules are prioritized as well.
|
||||||
|
|
||||||
a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval)
|
a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval)
|
||||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
|
||||||
require.Nil(t, a.AllowAccess("ben", "test*", PermissionReadWrite))
|
require.Nil(t, a.AllowAccess("ben", "test*", PermissionReadWrite))
|
||||||
require.Nil(t, a.AllowAccess("ben", "*", PermissionRead))
|
require.Nil(t, a.AllowAccess("ben", "*", PermissionRead))
|
||||||
|
|
||||||
@@ -147,20 +147,20 @@ func TestManager_Access_Order_LengthWriteRead(t *testing.T) {
|
|||||||
|
|
||||||
func TestManager_AddUser_Invalid(t *testing.T) {
|
func TestManager_AddUser_Invalid(t *testing.T) {
|
||||||
a := newTestManager(t, PermissionDenyAll)
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
require.Equal(t, ErrInvalidArgument, a.AddUser(" invalid ", "pass", RoleAdmin))
|
require.Equal(t, ErrInvalidArgument, a.AddUser(" invalid ", "pass", RoleAdmin, false))
|
||||||
require.Equal(t, ErrInvalidArgument, a.AddUser("validuser", "pass", "invalid-role"))
|
require.Equal(t, ErrInvalidArgument, a.AddUser("validuser", "pass", "invalid-role", false))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestManager_AddUser_Timing(t *testing.T) {
|
func TestManager_AddUser_Timing(t *testing.T) {
|
||||||
a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval)
|
a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval)
|
||||||
start := time.Now().UnixMilli()
|
start := time.Now().UnixMilli()
|
||||||
require.Nil(t, a.AddUser("user", "pass", RoleAdmin))
|
require.Nil(t, a.AddUser("user", "pass", RoleAdmin, false))
|
||||||
require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis)
|
require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestManager_AddUser_And_Query(t *testing.T) {
|
func TestManager_AddUser_And_Query(t *testing.T) {
|
||||||
a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval)
|
a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval)
|
||||||
require.Nil(t, a.AddUser("user", "pass", RoleAdmin))
|
require.Nil(t, a.AddUser("user", "pass", RoleAdmin, false))
|
||||||
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",
|
||||||
@@ -187,7 +187,7 @@ func TestManager_MarkUserRemoved_RemoveDeletedUsers(t *testing.T) {
|
|||||||
a := newTestManager(t, PermissionDenyAll)
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
|
|
||||||
// Create user, add reservations and token
|
// Create user, add reservations and token
|
||||||
require.Nil(t, a.AddUser("user", "pass", RoleAdmin))
|
require.Nil(t, a.AddUser("user", "pass", RoleAdmin, false))
|
||||||
require.Nil(t, a.AddReservation("user", "mytopic", PermissionRead))
|
require.Nil(t, a.AddReservation("user", "mytopic", PermissionRead))
|
||||||
|
|
||||||
u, err := a.User("user")
|
u, err := a.User("user")
|
||||||
@@ -237,7 +237,7 @@ func TestManager_CreateToken_Only_Lower(t *testing.T) {
|
|||||||
a := newTestManager(t, PermissionDenyAll)
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
|
|
||||||
// Create user, add reservations and token
|
// Create user, add reservations and token
|
||||||
require.Nil(t, a.AddUser("user", "pass", RoleAdmin))
|
require.Nil(t, a.AddUser("user", "pass", RoleAdmin, false))
|
||||||
u, err := a.User("user")
|
u, err := a.User("user")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
@@ -248,8 +248,8 @@ func TestManager_CreateToken_Only_Lower(t *testing.T) {
|
|||||||
|
|
||||||
func TestManager_UserManagement(t *testing.T) {
|
func TestManager_UserManagement(t *testing.T) {
|
||||||
a := newTestManager(t, PermissionDenyAll)
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin))
|
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, false))
|
||||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
|
||||||
require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite))
|
require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite))
|
||||||
require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead))
|
require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead))
|
||||||
require.Nil(t, a.AllowAccess("ben", "writeme", PermissionWrite))
|
require.Nil(t, a.AllowAccess("ben", "writeme", PermissionWrite))
|
||||||
@@ -339,21 +339,31 @@ 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))
|
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, false))
|
||||||
|
require.Nil(t, a.AddUser("jane", "$2b$10$OyqU72muEy7VMd1SAU2Iru5IbeSMgrtCGHu/fWLmxL1MwlijQXWbG", RoleUser, true))
|
||||||
|
|
||||||
_, err := a.Authenticate("phil", "phil")
|
_, err := a.Authenticate("phil", "phil")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
require.Nil(t, a.ChangePassword("phil", "newpass"))
|
_, err = a.Authenticate("jane", "jane")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
require.Nil(t, a.ChangePassword("phil", "newpass", false))
|
||||||
_, err = a.Authenticate("phil", "phil")
|
_, err = a.Authenticate("phil", "phil")
|
||||||
require.Equal(t, ErrUnauthenticated, err)
|
require.Equal(t, ErrUnauthenticated, err)
|
||||||
_, 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))
|
||||||
|
_, err = a.Authenticate("jane", "jane")
|
||||||
|
require.Equal(t, ErrUnauthenticated, err)
|
||||||
|
_, err = a.Authenticate("jane", "newpass")
|
||||||
|
require.Nil(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestManager_ChangeRole(t *testing.T) {
|
func TestManager_ChangeRole(t *testing.T) {
|
||||||
a := newTestManager(t, PermissionDenyAll)
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
|
||||||
require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite))
|
require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite))
|
||||||
require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead))
|
require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead))
|
||||||
|
|
||||||
@@ -378,8 +388,8 @@ func TestManager_ChangeRole(t *testing.T) {
|
|||||||
|
|
||||||
func TestManager_Reservations(t *testing.T) {
|
func TestManager_Reservations(t *testing.T) {
|
||||||
a := newTestManager(t, PermissionDenyAll)
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
require.Nil(t, a.AddUser("phil", "phil", RoleUser))
|
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
|
||||||
require.Nil(t, a.AddReservation("ben", "ztopic_", PermissionDenyAll))
|
require.Nil(t, a.AddReservation("ben", "ztopic_", PermissionDenyAll))
|
||||||
require.Nil(t, a.AddReservation("ben", "readme", PermissionRead))
|
require.Nil(t, a.AddReservation("ben", "readme", PermissionRead))
|
||||||
require.Nil(t, a.AllowAccess("ben", "something-else", PermissionRead))
|
require.Nil(t, a.AllowAccess("ben", "something-else", PermissionRead))
|
||||||
@@ -460,7 +470,7 @@ func TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) {
|
|||||||
AttachmentTotalSizeLimit: 524288000,
|
AttachmentTotalSizeLimit: 524288000,
|
||||||
AttachmentExpiryDuration: 24 * time.Hour,
|
AttachmentExpiryDuration: 24 * time.Hour,
|
||||||
}))
|
}))
|
||||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
|
||||||
require.Nil(t, a.ChangeTier("ben", "pro"))
|
require.Nil(t, a.ChangeTier("ben", "pro"))
|
||||||
require.Nil(t, a.AddReservation("ben", "mytopic", PermissionDenyAll))
|
require.Nil(t, a.AddReservation("ben", "mytopic", PermissionDenyAll))
|
||||||
|
|
||||||
@@ -507,7 +517,7 @@ func TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) {
|
|||||||
|
|
||||||
func TestManager_Token_Valid(t *testing.T) {
|
func TestManager_Token_Valid(t *testing.T) {
|
||||||
a := newTestManager(t, PermissionDenyAll)
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
|
||||||
|
|
||||||
u, err := a.User("ben")
|
u, err := a.User("ben")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
@@ -551,7 +561,7 @@ func TestManager_Token_Valid(t *testing.T) {
|
|||||||
|
|
||||||
func TestManager_Token_Invalid(t *testing.T) {
|
func TestManager_Token_Invalid(t *testing.T) {
|
||||||
a := newTestManager(t, PermissionDenyAll)
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
|
||||||
|
|
||||||
u, err := a.AuthenticateToken(strings.Repeat("x", 32)) // 32 == token length
|
u, err := a.AuthenticateToken(strings.Repeat("x", 32)) // 32 == token length
|
||||||
require.Nil(t, u)
|
require.Nil(t, u)
|
||||||
@@ -570,7 +580,7 @@ func TestManager_Token_NotFound(t *testing.T) {
|
|||||||
|
|
||||||
func TestManager_Token_Expire(t *testing.T) {
|
func TestManager_Token_Expire(t *testing.T) {
|
||||||
a := newTestManager(t, PermissionDenyAll)
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
|
||||||
|
|
||||||
u, err := a.User("ben")
|
u, err := a.User("ben")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
@@ -618,7 +628,7 @@ func TestManager_Token_Expire(t *testing.T) {
|
|||||||
|
|
||||||
func TestManager_Token_Extend(t *testing.T) {
|
func TestManager_Token_Extend(t *testing.T) {
|
||||||
a := newTestManager(t, PermissionDenyAll)
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
|
||||||
|
|
||||||
// Try to extend token for user without token
|
// Try to extend token for user without token
|
||||||
u, err := a.User("ben")
|
u, err := a.User("ben")
|
||||||
@@ -647,8 +657,8 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
|
|||||||
// Tests that tokens are automatically deleted when the maximum number of tokens is reached
|
// Tests that tokens are automatically deleted when the maximum number of tokens is reached
|
||||||
|
|
||||||
a := newTestManager(t, PermissionDenyAll)
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
|
||||||
require.Nil(t, a.AddUser("phil", "phil", RoleUser))
|
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||||
|
|
||||||
ben, err := a.User("ben")
|
ben, err := a.User("ben")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
@@ -668,10 +678,10 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
|
|||||||
require.NotEmpty(t, token.Value)
|
require.NotEmpty(t, token.Value)
|
||||||
philTokens = append(philTokens, token.Value)
|
philTokens = append(philTokens, token.Value)
|
||||||
|
|
||||||
// Create 22 tokens for ben (only 20 allowed!)
|
// Create 62 tokens for ben (only 60 allowed!)
|
||||||
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 < 22; 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())
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.NotEmpty(t, token.Value)
|
require.NotEmpty(t, token.Value)
|
||||||
@@ -690,7 +700,7 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
|
|||||||
require.Equal(t, ErrUnauthenticated, err)
|
require.Equal(t, ErrUnauthenticated, err)
|
||||||
|
|
||||||
// Ben: The other tokens should still work
|
// Ben: The other tokens should still work
|
||||||
for i := 2; i < 22; i++ {
|
for i := 2; i < 62; i++ {
|
||||||
userWithToken, err := a.AuthenticateToken(benTokens[i])
|
userWithToken, err := a.AuthenticateToken(benTokens[i])
|
||||||
require.Nil(t, err, "token[%d]=%s failed", i, benTokens[i])
|
require.Nil(t, err, "token[%d]=%s failed", i, benTokens[i])
|
||||||
require.Equal(t, "ben", userWithToken.Name)
|
require.Equal(t, "ben", userWithToken.Name)
|
||||||
@@ -710,7 +720,7 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
|
|||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.True(t, rows.Next())
|
require.True(t, rows.Next())
|
||||||
require.Nil(t, rows.Scan(&benCount))
|
require.Nil(t, rows.Scan(&benCount))
|
||||||
require.Equal(t, 20, benCount)
|
require.Equal(t, 60, benCount)
|
||||||
|
|
||||||
var philCount int
|
var philCount int
|
||||||
rows, err = a.db.Query(`SELECT COUNT(*) FROM user_token WHERE user_id=?`, phil.ID)
|
rows, err = a.db.Query(`SELECT COUNT(*) FROM user_token WHERE user_id=?`, phil.ID)
|
||||||
@@ -723,7 +733,7 @@ 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)
|
a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 1500*time.Millisecond)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
|
||||||
|
|
||||||
// Baseline: No messages or emails
|
// Baseline: No messages or emails
|
||||||
u, err := a.User("ben")
|
u, err := a.User("ben")
|
||||||
@@ -765,7 +775,7 @@ 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)
|
a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 500*time.Millisecond)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
|
||||||
|
|
||||||
// Create user and token
|
// Create user and token
|
||||||
u, err := a.User("ben")
|
u, err := a.User("ben")
|
||||||
@@ -798,7 +808,7 @@ 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)
|
a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 1500*time.Millisecond)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
|
||||||
|
|
||||||
// No settings
|
// No settings
|
||||||
u, err := a.User("ben")
|
u, err := a.User("ben")
|
||||||
@@ -866,7 +876,7 @@ func TestManager_Tier_Create_Update_List_Delete(t *testing.T) {
|
|||||||
AttachmentBandwidthLimit: 21474836480,
|
AttachmentBandwidthLimit: 21474836480,
|
||||||
StripeMonthlyPriceID: "price_2",
|
StripeMonthlyPriceID: "price_2",
|
||||||
}))
|
}))
|
||||||
require.Nil(t, a.AddUser("phil", "phil", RoleUser))
|
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||||
require.Nil(t, a.ChangeTier("phil", "pro"))
|
require.Nil(t, a.ChangeTier("phil", "pro"))
|
||||||
|
|
||||||
ti, err := a.Tier("pro")
|
ti, err := a.Tier("pro")
|
||||||
@@ -981,7 +991,7 @@ func TestManager_Tier_Change_And_Reset(t *testing.T) {
|
|||||||
Name: "Pro",
|
Name: "Pro",
|
||||||
ReservationLimit: 4,
|
ReservationLimit: 4,
|
||||||
}))
|
}))
|
||||||
require.Nil(t, a.AddUser("phil", "phil", RoleUser))
|
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||||
require.Nil(t, a.ChangeTier("phil", "pro"))
|
require.Nil(t, a.ChangeTier("phil", "pro"))
|
||||||
|
|
||||||
// Add 10 reservations (pro tier allows that)
|
// Add 10 reservations (pro tier allows that)
|
||||||
@@ -1007,7 +1017,7 @@ func TestManager_Tier_Change_And_Reset(t *testing.T) {
|
|||||||
func TestUser_PhoneNumberAddListRemove(t *testing.T) {
|
func TestUser_PhoneNumberAddListRemove(t *testing.T) {
|
||||||
a := newTestManager(t, PermissionDenyAll)
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
|
|
||||||
require.Nil(t, a.AddUser("phil", "phil", RoleUser))
|
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||||
phil, err := a.User("phil")
|
phil, err := a.User("phil")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Nil(t, a.AddPhoneNumber(phil.ID, "+1234567890"))
|
require.Nil(t, a.AddPhoneNumber(phil.ID, "+1234567890"))
|
||||||
@@ -1032,8 +1042,8 @@ func TestUser_PhoneNumberAddListRemove(t *testing.T) {
|
|||||||
func TestUser_PhoneNumberAdd_Multiple_Users_Same_Number(t *testing.T) {
|
func TestUser_PhoneNumberAdd_Multiple_Users_Same_Number(t *testing.T) {
|
||||||
a := newTestManager(t, PermissionDenyAll)
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
|
|
||||||
require.Nil(t, a.AddUser("phil", "phil", RoleUser))
|
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
|
||||||
phil, err := a.User("phil")
|
phil, err := a.User("phil")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
ben, err := a.User("ben")
|
ben, err := a.User("ben")
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
allowedUsernameRegex = regexp.MustCompile(`^[-_.@a-zA-Z0-9]+$`) // Does not include Everyone (*)
|
allowedUsernameRegex = regexp.MustCompile(`^[-_.+@a-zA-Z0-9]+$`) // Does not include Everyone (*)
|
||||||
allowedTopicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No '*'
|
allowedTopicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No '*'
|
||||||
allowedTopicPatternRegex = regexp.MustCompile(`^[-_*A-Za-z0-9]{1,64}$`) // Adds '*' for wildcards!
|
allowedTopicPatternRegex = regexp.MustCompile(`^[-_*A-Za-z0-9]{1,64}$`) // Adds '*' for wildcards!
|
||||||
allowedTierRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`)
|
allowedTierRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`)
|
||||||
|
|||||||
@@ -61,3 +61,15 @@ func TestTierContext(t *testing.T) {
|
|||||||
require.Equal(t, "price_456", context["stripe_yearly_price_id"])
|
require.Equal(t, "price_456", context["stripe_yearly_price_id"])
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUsernameRegex(t *testing.T) {
|
||||||
|
username := "phil"
|
||||||
|
usernameEmail := "phil@ntfy.sh"
|
||||||
|
usernameEmailAlias := "phil+alias@ntfy.sh"
|
||||||
|
usernameInvalid := "phil\rocks"
|
||||||
|
|
||||||
|
require.True(t, AllowedUsername(username))
|
||||||
|
require.True(t, AllowedUsername(usernameEmail))
|
||||||
|
require.True(t, AllowedUsername(usernameEmailAlias))
|
||||||
|
require.False(t, AllowedUsername(usernameInvalid))
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package util
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -26,7 +27,7 @@ func Peek(underlying io.ReadCloser, limit int) (*PeekedReadCloser, error) {
|
|||||||
}
|
}
|
||||||
peeked := make([]byte, limit)
|
peeked := make([]byte, limit)
|
||||||
read, err := io.ReadFull(underlying, peeked)
|
read, err := io.ReadFull(underlying, peeked)
|
||||||
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
|
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && err != io.EOF {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &PeekedReadCloser{
|
return &PeekedReadCloser{
|
||||||
@@ -44,7 +45,7 @@ func (r *PeekedReadCloser) Read(p []byte) (n int, err error) {
|
|||||||
return 0, io.EOF
|
return 0, io.EOF
|
||||||
}
|
}
|
||||||
n, err = r.peeked.Read(p)
|
n, err = r.peeked.Read(p)
|
||||||
if err == io.EOF {
|
if errors.Is(err, io.EOF) {
|
||||||
return r.underlying.Read(p)
|
return r.underlying.Read(p)
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
|
|||||||
40
util/time.go
@@ -10,8 +10,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errUnparsableTime = errors.New("unable to parse time")
|
errInvalidDuration = errors.New("unable to parse duration")
|
||||||
durationStrRegex = regexp.MustCompile(`(?i)^(\d+)\s*(d|days?|h|hours?|m|mins?|minutes?|s|secs?|seconds?)$`)
|
durationStrRegex = regexp.MustCompile(`(?i)^(\d+)\s*(d|days?|h|hours?|m|mins?|minutes?|s|secs?|seconds?)$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -51,7 +51,7 @@ func ParseFutureTime(s string, now time.Time) (time.Time, error) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
return time.Time{}, errUnparsableTime
|
return time.Time{}, errInvalidDuration
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseDuration is like time.ParseDuration, except that it also understands days (d), which
|
// ParseDuration is like time.ParseDuration, except that it also understands days (d), which
|
||||||
@@ -65,7 +65,7 @@ func ParseDuration(s string) (time.Duration, error) {
|
|||||||
if matches != nil {
|
if matches != nil {
|
||||||
number, err := strconv.Atoi(matches[1])
|
number, err := strconv.Atoi(matches[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, errUnparsableTime
|
return 0, errInvalidDuration
|
||||||
}
|
}
|
||||||
switch unit := matches[2][0:1]; unit {
|
switch unit := matches[2][0:1]; unit {
|
||||||
case "d":
|
case "d":
|
||||||
@@ -77,10 +77,28 @@ func ParseDuration(s string) (time.Duration, error) {
|
|||||||
case "s":
|
case "s":
|
||||||
return time.Duration(number) * time.Second, nil
|
return time.Duration(number) * time.Second, nil
|
||||||
default:
|
default:
|
||||||
return 0, errUnparsableTime
|
return 0, errInvalidDuration
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 0, errUnparsableTime
|
return 0, errInvalidDuration
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatDuration formats a time.Duration into a human-readable string, e.g. "2d", "20h", "30m", "40s".
|
||||||
|
// It rounds to the largest unit that is not zero, thereby effectively rounding down.
|
||||||
|
func FormatDuration(d time.Duration) string {
|
||||||
|
if d >= 24*time.Hour {
|
||||||
|
return strconv.Itoa(int(d/(24*time.Hour))) + "d"
|
||||||
|
}
|
||||||
|
if d >= time.Hour {
|
||||||
|
return strconv.Itoa(int(d/time.Hour)) + "h"
|
||||||
|
}
|
||||||
|
if d >= time.Minute {
|
||||||
|
return strconv.Itoa(int(d/time.Minute)) + "m"
|
||||||
|
}
|
||||||
|
if d >= time.Second {
|
||||||
|
return strconv.Itoa(int(d/time.Second)) + "s"
|
||||||
|
}
|
||||||
|
return "0s"
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseFromDuration(s string, now time.Time) (time.Time, error) {
|
func parseFromDuration(s string, now time.Time) (time.Time, error) {
|
||||||
@@ -88,7 +106,7 @@ func parseFromDuration(s string, now time.Time) (time.Time, error) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return now.Add(d), nil
|
return now.Add(d), nil
|
||||||
}
|
}
|
||||||
return time.Time{}, errUnparsableTime
|
return time.Time{}, errInvalidDuration
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseUnixTime(s string, now time.Time) (time.Time, error) {
|
func parseUnixTime(s string, now time.Time) (time.Time, error) {
|
||||||
@@ -96,7 +114,7 @@ func parseUnixTime(s string, now time.Time) (time.Time, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return time.Time{}, err
|
return time.Time{}, err
|
||||||
} else if int64(t) < now.Unix() {
|
} else if int64(t) < now.Unix() {
|
||||||
return time.Time{}, errUnparsableTime
|
return time.Time{}, errInvalidDuration
|
||||||
}
|
}
|
||||||
return time.Unix(int64(t), 0).UTC(), nil
|
return time.Unix(int64(t), 0).UTC(), nil
|
||||||
}
|
}
|
||||||
@@ -104,7 +122,7 @@ func parseUnixTime(s string, now time.Time) (time.Time, error) {
|
|||||||
func parseNaturalTime(s string, now time.Time) (time.Time, error) {
|
func parseNaturalTime(s string, now time.Time) (time.Time, error) {
|
||||||
r, err := when.EN.Parse(s, now) // returns "nil, nil" if no matches!
|
r, err := when.EN.Parse(s, now) // returns "nil, nil" if no matches!
|
||||||
if err != nil || r == nil {
|
if err != nil || r == nil {
|
||||||
return time.Time{}, errUnparsableTime
|
return time.Time{}, errInvalidDuration
|
||||||
} else if r.Time.After(now) {
|
} else if r.Time.After(now) {
|
||||||
return r.Time, nil
|
return r.Time, nil
|
||||||
}
|
}
|
||||||
@@ -112,9 +130,9 @@ func parseNaturalTime(s string, now time.Time) (time.Time, error) {
|
|||||||
// simply append "tomorrow, " to it.
|
// simply append "tomorrow, " to it.
|
||||||
r, err = when.EN.Parse("tomorrow, "+s, now) // returns "nil, nil" if no matches!
|
r, err = when.EN.Parse("tomorrow, "+s, now) // returns "nil, nil" if no matches!
|
||||||
if err != nil || r == nil {
|
if err != nil || r == nil {
|
||||||
return time.Time{}, errUnparsableTime
|
return time.Time{}, errInvalidDuration
|
||||||
} else if r.Time.After(now) {
|
} else if r.Time.After(now) {
|
||||||
return r.Time, nil
|
return r.Time, nil
|
||||||
}
|
}
|
||||||
return time.Time{}, errUnparsableTime
|
return time.Time{}, errInvalidDuration
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,3 +92,27 @@ func TestParseDuration(t *testing.T) {
|
|||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, time.Duration(0), d)
|
require.Equal(t, time.Duration(0), d)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFormatDuration(t *testing.T) {
|
||||||
|
values := []struct {
|
||||||
|
duration time.Duration
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{24 * time.Second, "24s"},
|
||||||
|
{56 * time.Minute, "56m"},
|
||||||
|
{time.Hour, "1h"},
|
||||||
|
{2 * time.Hour, "2h"},
|
||||||
|
{24 * time.Hour, "1d"},
|
||||||
|
{3 * 24 * time.Hour, "3d"},
|
||||||
|
}
|
||||||
|
for _, value := range values {
|
||||||
|
require.Equal(t, value.expected, FormatDuration(value.duration))
|
||||||
|
d, err := ParseDuration(FormatDuration(value.duration))
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equalf(t, value.duration, d, "duration does not match: %v != %v", value.duration, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatDuration_Rounded(t *testing.T) {
|
||||||
|
require.Equal(t, "1d", FormatDuration(47*time.Hour))
|
||||||
|
}
|
||||||
|
|||||||
34
util/timeout_writer.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrWriteTimeout is returned when a write timed out
|
||||||
|
var ErrWriteTimeout = errors.New("write operation failed due to timeout since creation")
|
||||||
|
|
||||||
|
// TimeoutWriter wraps an io.Writer that will time out after the given timeout
|
||||||
|
type TimeoutWriter struct {
|
||||||
|
writer io.Writer
|
||||||
|
timeout time.Duration
|
||||||
|
start time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTimeoutWriter creates a new TimeoutWriter
|
||||||
|
func NewTimeoutWriter(w io.Writer, timeout time.Duration) *TimeoutWriter {
|
||||||
|
return &TimeoutWriter{
|
||||||
|
writer: w,
|
||||||
|
timeout: timeout,
|
||||||
|
start: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write implements the io.Writer interface, failing if called after the timeout period from creation.
|
||||||
|
func (tw *TimeoutWriter) Write(p []byte) (n int, err error) {
|
||||||
|
if time.Since(tw.start) > tw.timeout {
|
||||||
|
return 0, errors.New("write operation failed due to timeout since creation")
|
||||||
|
}
|
||||||
|
return tw.writer.Write(p)
|
||||||
|
}
|
||||||
49
util/util.go
@@ -7,6 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
@@ -16,10 +17,9 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/time/rate"
|
|
||||||
|
|
||||||
"github.com/gabriel-vasile/mimetype"
|
"github.com/gabriel-vasile/mimetype"
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -98,12 +98,26 @@ func SplitKV(s string, sep string) (key string, value string) {
|
|||||||
return "", strings.TrimSpace(kv[0])
|
return "", strings.TrimSpace(kv[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
// LastString returns the last string in a slice, or def if s is empty
|
// Map applies a function to each element of a slice and returns a new slice with the results
|
||||||
func LastString(s []string, def string) string {
|
// Example: Map([]int{1, 2, 3}, func(i int) int { return i * 2 }) -> []int{2, 4, 6}
|
||||||
if len(s) == 0 {
|
func Map[T any, U any](slice []T, f func(T) U) []U {
|
||||||
return def
|
result := make([]U, len(slice))
|
||||||
|
for i, v := range slice {
|
||||||
|
result[i] = f(v)
|
||||||
}
|
}
|
||||||
return s[len(s)-1]
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter returns a new slice containing only the elements of the original slice for which the
|
||||||
|
// given function returns true.
|
||||||
|
func Filter[T any](slice []T, f func(T) bool) []T {
|
||||||
|
result := make([]T, 0)
|
||||||
|
for _, v := range slice {
|
||||||
|
if f(v) {
|
||||||
|
result = append(result, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// RandomString returns a random string with a given length
|
// RandomString returns a random string with a given length
|
||||||
@@ -215,6 +229,8 @@ func ParseSize(s string) (int64, error) {
|
|||||||
return -1, fmt.Errorf("cannot convert number %s", matches[1])
|
return -1, fmt.Errorf("cannot convert number %s", matches[1])
|
||||||
}
|
}
|
||||||
switch strings.ToUpper(matches[2]) {
|
switch strings.ToUpper(matches[2]) {
|
||||||
|
case "T":
|
||||||
|
return int64(value) * 1024 * 1024 * 1024 * 1024, nil
|
||||||
case "G":
|
case "G":
|
||||||
return int64(value) * 1024 * 1024 * 1024, nil
|
return int64(value) * 1024 * 1024 * 1024, nil
|
||||||
case "M":
|
case "M":
|
||||||
@@ -226,8 +242,23 @@ func ParseSize(s string) (int64, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatSize formats bytes into a human-readable notation, e.g. 2.1 MB
|
// FormatSize formats the size in a way that it can be parsed by ParseSize.
|
||||||
|
// It does not include decimal places. Uneven sizes are rounded down.
|
||||||
func FormatSize(b int64) string {
|
func FormatSize(b int64) string {
|
||||||
|
const unit = 1024
|
||||||
|
if b < unit {
|
||||||
|
return fmt.Sprintf("%d", b)
|
||||||
|
}
|
||||||
|
div, exp := int64(unit), 0
|
||||||
|
for n := b / unit; n >= unit; n /= unit {
|
||||||
|
div *= unit
|
||||||
|
exp++
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d%c", int(math.Floor(float64(b)/float64(div))), "KMGT"[exp])
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatSizeHuman formats bytes into a human-readable notation, e.g. 2.1 MB
|
||||||
|
func FormatSizeHuman(b int64) string {
|
||||||
const unit = 1024
|
const unit = 1024
|
||||||
if b < unit {
|
if b < unit {
|
||||||
return fmt.Sprintf("%d bytes", b)
|
return fmt.Sprintf("%d bytes", b)
|
||||||
@@ -237,7 +268,7 @@ func FormatSize(b int64) string {
|
|||||||
div *= unit
|
div *= unit
|
||||||
exp++
|
exp++
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
|
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGT"[exp])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadPassword will read a password from STDIN. If the terminal supports it, it will not print the
|
// ReadPassword will read a password from STDIN. If the terminal supports it, it will not print the
|
||||||
|
|||||||
@@ -110,33 +110,47 @@ func TestShortTopicURL(t *testing.T) {
|
|||||||
|
|
||||||
func TestParseSize_10GSuccess(t *testing.T) {
|
func TestParseSize_10GSuccess(t *testing.T) {
|
||||||
s, err := ParseSize("10G")
|
s, err := ParseSize("10G")
|
||||||
if err != nil {
|
require.Nil(t, err)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
require.Equal(t, int64(10*1024*1024*1024), s)
|
require.Equal(t, int64(10*1024*1024*1024), s)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseSize_10MUpperCaseSuccess(t *testing.T) {
|
func TestParseSize_10MUpperCaseSuccess(t *testing.T) {
|
||||||
s, err := ParseSize("10M")
|
s, err := ParseSize("10M")
|
||||||
if err != nil {
|
require.Nil(t, err)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
require.Equal(t, int64(10*1024*1024), s)
|
require.Equal(t, int64(10*1024*1024), s)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseSize_10kLowerCaseSuccess(t *testing.T) {
|
func TestParseSize_10kLowerCaseSuccess(t *testing.T) {
|
||||||
s, err := ParseSize("10k")
|
s, err := ParseSize("10k")
|
||||||
if err != nil {
|
require.Nil(t, err)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
require.Equal(t, int64(10*1024), s)
|
require.Equal(t, int64(10*1024), s)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseSize_FailureInvalid(t *testing.T) {
|
func TestParseSize_FailureInvalid(t *testing.T) {
|
||||||
_, err := ParseSize("not a size")
|
_, err := ParseSize("not a size")
|
||||||
if err == nil {
|
require.Error(t, err)
|
||||||
t.Fatalf("expected error, but got none")
|
}
|
||||||
|
|
||||||
|
func TestFormatSize(t *testing.T) {
|
||||||
|
values := []struct {
|
||||||
|
size int64
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{10, "10"},
|
||||||
|
{10 * 1024, "10K"},
|
||||||
|
{10 * 1024 * 1024, "10M"},
|
||||||
|
{10 * 1024 * 1024 * 1024, "10G"},
|
||||||
}
|
}
|
||||||
|
for _, value := range values {
|
||||||
|
require.Equal(t, value.expected, FormatSize(value.size))
|
||||||
|
s, err := ParseSize(FormatSize(value.size))
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equalf(t, value.size, s, "size does not match: %d != %d", value.size, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatSize_Rounded(t *testing.T) {
|
||||||
|
require.Equal(t, "10K", FormatSize(10*1024+999))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSplitKV(t *testing.T) {
|
func TestSplitKV(t *testing.T) {
|
||||||
@@ -153,11 +167,6 @@ func TestSplitKV(t *testing.T) {
|
|||||||
require.Equal(t, "value=with=separator", value)
|
require.Equal(t, "value=with=separator", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLastString(t *testing.T) {
|
|
||||||
require.Equal(t, "last", LastString([]string{"first", "second", "last"}, "default"))
|
|
||||||
require.Equal(t, "default", LastString([]string{}, "default"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestQuoteCommand(t *testing.T) {
|
func TestQuoteCommand(t *testing.T) {
|
||||||
require.Equal(t, `ls -al "Document Folder"`, QuoteCommand([]string{"ls", "-al", "Document Folder"}))
|
require.Equal(t, `ls -al "Document Folder"`, QuoteCommand([]string{"ls", "-al", "Document Folder"}))
|
||||||
require.Equal(t, `rsync -av /home/phil/ root@example.com:/home/phil/`, QuoteCommand([]string{"rsync", "-av", "/home/phil/", "root@example.com:/home/phil/"}))
|
require.Equal(t, `rsync -av /home/phil/ root@example.com:/home/phil/`, QuoteCommand([]string{"rsync", "-av", "/home/phil/", "root@example.com:/home/phil/"}))
|
||||||
|
|||||||
7474
web/package-lock.json
generated
@@ -44,8 +44,8 @@
|
|||||||
"eslint-plugin-react": "^7.32.2",
|
"eslint-plugin-react": "^7.32.2",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"prettier": "^2.8.8",
|
"prettier": "^2.8.8",
|
||||||
"vite": "^4.3.9",
|
"vite": "^6.3.5",
|
||||||
"vite-plugin-pwa": "^0.15.0"
|
"vite-plugin-pwa": "^1.0.0"
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"production": [
|
"production": [
|
||||||
|
|||||||
@@ -330,5 +330,35 @@
|
|||||||
"account_basics_tier_paid_until": "تم دفع مبلغ الاشتراك إلى غاية {{date}}، وسيتم تجديده تِلْقائيًا",
|
"account_basics_tier_paid_until": "تم دفع مبلغ الاشتراك إلى غاية {{date}}، وسيتم تجديده تِلْقائيًا",
|
||||||
"account_basics_tier_canceled_subscription": "تم إلغاء اشتراكك وسيتم إعادته إلى مستوى حساب مجاني بداية مِن {{date}}.",
|
"account_basics_tier_canceled_subscription": "تم إلغاء اشتراكك وسيتم إعادته إلى مستوى حساب مجاني بداية مِن {{date}}.",
|
||||||
"account_delete_dialog_billing_warning": "إلغاء حسابك أيضاً يلغي اشتراكك في الفوترة فوراً ولن تتمكن من الوصول إلى لوح الفوترة بعد الآن.",
|
"account_delete_dialog_billing_warning": "إلغاء حسابك أيضاً يلغي اشتراكك في الفوترة فوراً ولن تتمكن من الوصول إلى لوح الفوترة بعد الآن.",
|
||||||
"nav_upgrade_banner_description": "حجز المواضيع والمزيد من الرسائل ورسائل البريد الإلكتروني والمرفقات الأكبر حجمًا"
|
"nav_upgrade_banner_description": "حجز المواضيع والمزيد من الرسائل ورسائل البريد الإلكتروني والمرفقات الأكبر حجمًا",
|
||||||
|
"prefs_appearance_theme_dark": "الوضع الليلي",
|
||||||
|
"prefs_appearance_theme_light": "الوضع النهاري",
|
||||||
|
"publish_dialog_checkbox_markdown": "تنسيق على هيئة ماركداون",
|
||||||
|
"alert_not_supported_context_description": "الإشعارات مسموحة فقط على بروتوكول HTTPS المأمن, هذه القيود <mdnLink>خصائص الإشعارات</mdnLink>",
|
||||||
|
"publish_dialog_call_reset": "حذف اتصال بالهاتف",
|
||||||
|
"publish_dialog_call_label": "اتصال هاتفي",
|
||||||
|
"publish_dialog_chip_call_label": "اتصال هاتفي",
|
||||||
|
"publish_dialog_delay_placeholder": "تأخير التوصيل, مثال {{unixTimestamp}}, {{relativeTime}}, او \"{{naturalLanguage}}\" (اللغة الإنجليزية فقط)",
|
||||||
|
"publish_dialog_attachment_limits_file_and_quota_reached": "تجاوز حجم {{fileSizeLimit}} الملف, {{remainingBytes}} متبقي",
|
||||||
|
"prefs_reservations_dialog_title_delete": "حذف حجز موضوع",
|
||||||
|
"publish_dialog_call_item": "اتصل برقم الهاتف {{number}}",
|
||||||
|
"publish_dialog_chip_call_no_verified_numbers_tooltip": "لا يوجد ارقام هواتف معرفة",
|
||||||
|
"action_bar_mute_notifications": "كتم الإشعارات",
|
||||||
|
"action_bar_unmute_notifications": "إلغاء كتم الإشعارات",
|
||||||
|
"alert_notification_ios_install_required_description": "اضغط على زر المشاركة ثم إضافة إلى الصفحة الرئيسية لتستقبل الإشعارات على أجهزة أبل",
|
||||||
|
"alert_notification_ios_install_required_title": "يجب تثبيت الصفحة",
|
||||||
|
"alert_notification_permission_denied_description": "الرجاء اعادة منح الصلاحيات في المتصفح",
|
||||||
|
"alert_notification_permission_denied_title": "الإشعارات مغلقة",
|
||||||
|
"notifications_actions_failed_notification": "حدث غير منفذ",
|
||||||
|
"prefs_notifications_web_push_disabled": "ملغي",
|
||||||
|
"account_basics_phone_numbers_dialog_channel_call": "اتصل",
|
||||||
|
"account_basics_phone_numbers_title": "أرقام الهواتف",
|
||||||
|
"account_basics_phone_numbers_dialog_channel_sms": "رسالة نصية قصيرة",
|
||||||
|
"account_basics_phone_numbers_dialog_check_verification_button": "رمز التأكيد",
|
||||||
|
"account_basics_phone_numbers_dialog_number_label": "رقم الهاتف",
|
||||||
|
"account_basics_phone_numbers_dialog_verify_button_call": "اتصل بي",
|
||||||
|
"account_basics_phone_numbers_dialog_code_label": "رمز التحقّق",
|
||||||
|
"account_upgrade_dialog_tier_price_per_month": "شهر",
|
||||||
|
"prefs_appearance_theme_title": "الحُلّة",
|
||||||
|
"subscribe_dialog_subscribe_use_another_background_info": "لن يتم استلام الاشعارات من الخوادم الخارجية عندما يكون تطبيق الويب مغلقاً"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"action_bar_clear_notifications": "Премахване на известия",
|
"action_bar_clear_notifications": "Премахване на известия",
|
||||||
"alert_notification_permission_required_description": "Разрешете на мрежовия четец да показва известия.",
|
"alert_notification_permission_required_description": "Разрешете на мрежовия четец да показва известия",
|
||||||
"notifications_attachment_copy_url_title": "Копиране на адреса на прикачения файл",
|
"notifications_attachment_copy_url_title": "Копиране на адреса на прикачения файл",
|
||||||
"notifications_example": "Пример",
|
"notifications_example": "Пример",
|
||||||
"notifications_no_subscriptions_title": "Липсват абонаменти.",
|
"notifications_no_subscriptions_title": "Липсват абонаменти",
|
||||||
"nav_topics_title": "Абонаменти",
|
"nav_topics_title": "Абонаменти",
|
||||||
"action_bar_send_test_notification": "Пробно известие",
|
"action_bar_send_test_notification": "Пробно известие",
|
||||||
"action_bar_unsubscribe": "Отписване",
|
"action_bar_unsubscribe": "Отписване",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"publish_dialog_chip_email_label": "Препращане към ел. поща",
|
"publish_dialog_chip_email_label": "Препращане към ел. поща",
|
||||||
"publish_dialog_chip_attach_url_label": "Прикачване на файл от адрес",
|
"publish_dialog_chip_attach_url_label": "Прикачване на файл от адрес",
|
||||||
"publish_dialog_chip_attach_file_label": "Прикачване местен файл",
|
"publish_dialog_chip_attach_file_label": "Прикачване местен файл",
|
||||||
"publish_dialog_chip_delay_label": "Забавяне на изпращането",
|
"publish_dialog_chip_delay_label": "Отлагане на изпращането",
|
||||||
"publish_dialog_chip_topic_label": "Промяна на темата",
|
"publish_dialog_chip_topic_label": "Промяна на темата",
|
||||||
"publish_dialog_button_cancel_sending": "Отменяне на изпращането",
|
"publish_dialog_button_cancel_sending": "Отменяне на изпращането",
|
||||||
"publish_dialog_button_cancel": "Отказ",
|
"publish_dialog_button_cancel": "Отказ",
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
"prefs_notifications_delete_after_never": "Никога",
|
"prefs_notifications_delete_after_never": "Никога",
|
||||||
"prefs_users_add_button": "Добавяне",
|
"prefs_users_add_button": "Добавяне",
|
||||||
"prefs_users_dialog_password_label": "Парола",
|
"prefs_users_dialog_password_label": "Парола",
|
||||||
"alert_not_supported_description": "Мрежовият четец не поддържа известия.",
|
"alert_not_supported_description": "Мрежовият четец не поддържа известия",
|
||||||
"message_bar_type_message": "Въведете съобщение",
|
"message_bar_type_message": "Въведете съобщение",
|
||||||
"message_bar_error_publishing": "Грешка при изпращане на известието",
|
"message_bar_error_publishing": "Грешка при изпращане на известието",
|
||||||
"notifications_copied_to_clipboard": "Копирано в междинната памет",
|
"notifications_copied_to_clipboard": "Копирано в междинната памет",
|
||||||
@@ -55,16 +55,16 @@
|
|||||||
"notifications_attachment_open_title": "Към {{url}}",
|
"notifications_attachment_open_title": "Към {{url}}",
|
||||||
"notifications_attachment_copy_url_button": "Копиране на адреса",
|
"notifications_attachment_copy_url_button": "Копиране на адреса",
|
||||||
"notifications_attachment_open_button": "Отваряне на прикачения файл",
|
"notifications_attachment_open_button": "Отваряне на прикачения файл",
|
||||||
"notifications_attachment_link_expires": "препратката изтича на {{date}}",
|
"notifications_attachment_link_expires": "давността на препратката изтича на {{date}}",
|
||||||
"notifications_actions_open_url_title": "Към {{url}}",
|
"notifications_actions_open_url_title": "Към {{url}}",
|
||||||
"notifications_click_copy_url_button": "Копиране на препратка",
|
"notifications_click_copy_url_button": "Копиране на препратка",
|
||||||
"notifications_click_open_button": "Отваряне",
|
"notifications_click_open_button": "Отваряне",
|
||||||
"notifications_click_copy_url_title": "Копиране на препратката в междинната памет",
|
"notifications_click_copy_url_title": "Копиране на препратката в междинната памет",
|
||||||
"notifications_none_for_topic_title": "Темата е все още празна",
|
"notifications_none_for_topic_title": "Темата е все още празна",
|
||||||
"notifications_none_for_any_title": "Липсват известия.",
|
"notifications_none_for_any_title": "Липсват известия",
|
||||||
"notifications_none_for_topic_description": "За да изпратите известия в тази тема направете заявка чрез методите PUT или POST към адреса ѝ.",
|
"notifications_none_for_topic_description": "За да изпратите известия в тази тема направете заявка чрез методите PUT или POST към адреса ѝ.",
|
||||||
"notifications_none_for_any_description": "За да изпратите известия в тема направете заявка чрез методите PUT или POST към адреса ѝ. Ето пример с една от вашите теми.",
|
"notifications_none_for_any_description": "За да изпратите известия в тема направете заявка чрез методите PUT или POST към адреса ѝ. Ето пример с една от вашите теми.",
|
||||||
"notifications_no_subscriptions_description": "Щракнете върху „{{linktext}}“, за да създадете тема или да се абонирате. След това като направите заявка чрез методите PUT или POST ще ги получите тук.",
|
"notifications_no_subscriptions_description": "Щракнете върху „{{linktext}}“, за да създадете или да се абонирате за тема. След това като изпратите съобщение с методите PUT или POST ще го получите тук.",
|
||||||
"notifications_more_details": "За допълнителна информация посетете <websiteLink>страницата</websiteLink> или <docsLink>документацията</docsLink>.",
|
"notifications_more_details": "За допълнителна информация посетете <websiteLink>страницата</websiteLink> или <docsLink>документацията</docsLink>.",
|
||||||
"publish_dialog_priority_min": "Най-нисък приоритет",
|
"publish_dialog_priority_min": "Най-нисък приоритет",
|
||||||
"publish_dialog_attachment_limits_file_reached": "надвишава ограничението от {{fileSizeLimit}} за размер на файл",
|
"publish_dialog_attachment_limits_file_reached": "надвишава ограничението от {{fileSizeLimit}} за размер на файл",
|
||||||
@@ -84,14 +84,14 @@
|
|||||||
"publish_dialog_topic_label": "Име на темата",
|
"publish_dialog_topic_label": "Име на темата",
|
||||||
"publish_dialog_title_label": "Заглавие",
|
"publish_dialog_title_label": "Заглавие",
|
||||||
"publish_dialog_priority_label": "Приоритет",
|
"publish_dialog_priority_label": "Приоритет",
|
||||||
"publish_dialog_click_placeholder": "Адрес, който се отваря при щракване върху известието",
|
"publish_dialog_click_placeholder": "Адрес, който се отваря при докосване на известието",
|
||||||
"publish_dialog_email_placeholder": "Поща, на която да се препрати известието, напр. phil@example.com",
|
"publish_dialog_email_placeholder": "Адрес, към който да бъдат препращани известия, напр. phil@example.com",
|
||||||
"publish_dialog_attach_label": "Адрес на прикачения файл",
|
"publish_dialog_attach_label": "Адрес на прикачения файл",
|
||||||
"publish_dialog_filename_placeholder": "Име на прикачения файл",
|
"publish_dialog_filename_placeholder": "Име на прикачения файл",
|
||||||
"publish_dialog_attach_placeholder": "Прикачете файл от адрес, напр. https://f-droid.org/F-Droid.apk",
|
"publish_dialog_attach_placeholder": "Прикачете файл от адрес, напр. https://f-droid.org/F-Droid.apk",
|
||||||
"prefs_notifications_delete_after_three_hours": "След три часа",
|
"prefs_notifications_delete_after_three_hours": "След три часа",
|
||||||
"publish_dialog_filename_label": "Име на файла",
|
"publish_dialog_filename_label": "Име на файла",
|
||||||
"publish_dialog_delay_label": "Забавяне",
|
"publish_dialog_delay_label": "Отлагане",
|
||||||
"publish_dialog_details_examples_description": "За примери и подробно описание на всички възможности при изпращане, вижте <docsLink>документацията</docsLink>.",
|
"publish_dialog_details_examples_description": "За примери и подробно описание на всички възможности при изпращане, вижте <docsLink>документацията</docsLink>.",
|
||||||
"publish_dialog_button_send": "Изпращане",
|
"publish_dialog_button_send": "Изпращане",
|
||||||
"publish_dialog_checkbox_publish_another": "Изпращане на повече",
|
"publish_dialog_checkbox_publish_another": "Изпращане на повече",
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
"subscribe_dialog_login_button_login": "Вход",
|
"subscribe_dialog_login_button_login": "Вход",
|
||||||
"subscribe_dialog_error_user_not_authorized": "Потребителят {{username}} няма достъп",
|
"subscribe_dialog_error_user_not_authorized": "Потребителят {{username}} няма достъп",
|
||||||
"prefs_appearance_title": "Външен вид",
|
"prefs_appearance_title": "Външен вид",
|
||||||
"publish_dialog_delay_placeholder": "Забавяне на изпращането, {{unixTimestamp}}, {{relativeTime}} или „{{naturalLanguage}}“ (на английски)",
|
"publish_dialog_delay_placeholder": "Отлагане на изпращането, {{unixTimestamp}}, {{relativeTime}} или „{{naturalLanguage}}“ (на английски)",
|
||||||
"prefs_notifications_delete_after_one_week": "След една седмица",
|
"prefs_notifications_delete_after_one_week": "След една седмица",
|
||||||
"prefs_users_title": "Управление на потребители",
|
"prefs_users_title": "Управление на потребители",
|
||||||
"prefs_users_table_base_url_header": "Адрес на услугата",
|
"prefs_users_table_base_url_header": "Адрес на услугата",
|
||||||
@@ -177,7 +177,7 @@
|
|||||||
"publish_dialog_topic_reset": "Нулиране на тема",
|
"publish_dialog_topic_reset": "Нулиране на тема",
|
||||||
"publish_dialog_click_reset": "Премахване на адрес",
|
"publish_dialog_click_reset": "Премахване на адрес",
|
||||||
"publish_dialog_email_reset": "Премахване на препращането към ел. поща",
|
"publish_dialog_email_reset": "Премахване на препращането към ел. поща",
|
||||||
"publish_dialog_delay_reset": "Премахва забавянето на изпращането",
|
"publish_dialog_delay_reset": "Премахва отлагането на изпращането",
|
||||||
"publish_dialog_attached_file_remove": "Премахване на прикачения файл",
|
"publish_dialog_attached_file_remove": "Премахване на прикачения файл",
|
||||||
"emoji_picker_search_clear": "Изчистване на търсенето",
|
"emoji_picker_search_clear": "Изчистване на търсенето",
|
||||||
"subscribe_dialog_subscribe_base_url_label": "Адрес на услугата",
|
"subscribe_dialog_subscribe_base_url_label": "Адрес на услугата",
|
||||||
@@ -212,7 +212,7 @@
|
|||||||
"nav_upgrade_banner_label": "Надграждане до ntfy Pro",
|
"nav_upgrade_banner_label": "Надграждане до ntfy Pro",
|
||||||
"signup_form_confirm_password": "Парола отново",
|
"signup_form_confirm_password": "Парола отново",
|
||||||
"signup_disabled": "Регистрациите са затворени",
|
"signup_disabled": "Регистрациите са затворени",
|
||||||
"signup_error_creation_limit_reached": "Достигнатео е ограничението за създаване на профили",
|
"signup_error_creation_limit_reached": "Достигнато е ограничението за създаване на профили",
|
||||||
"display_name_dialog_title": "Промяна на показваното име",
|
"display_name_dialog_title": "Промяна на показваното име",
|
||||||
"action_bar_reservation_edit": "Промяна на резервацията",
|
"action_bar_reservation_edit": "Промяна на резервацията",
|
||||||
"action_bar_sign_up": "Регистриране",
|
"action_bar_sign_up": "Регистриране",
|
||||||
@@ -220,7 +220,7 @@
|
|||||||
"alert_not_supported_context_description": "Известията се поддържат само през HTTPS. Това е ограничение на <mdnLink>Notifications API</mdnLink>.",
|
"alert_not_supported_context_description": "Известията се поддържат само през HTTPS. Това е ограничение на <mdnLink>Notifications API</mdnLink>.",
|
||||||
"display_name_dialog_description": "Изберете друго име за темата, което да се показва в списъка с абонаменти. Помага за по-лесното разпознаване на теми със сложни имена.",
|
"display_name_dialog_description": "Изберете друго име за темата, което да се показва в списъка с абонаменти. Помага за по-лесното разпознаване на теми със сложни имена.",
|
||||||
"subscribe_dialog_error_topic_already_reserved": "Темата вече е резервирана",
|
"subscribe_dialog_error_topic_already_reserved": "Темата вече е резервирана",
|
||||||
"nav_upgrade_banner_description": "Резервиране на теми, повече съобщения и имейли и по-големи прикачени файлове",
|
"nav_upgrade_banner_description": "Резервиране на теми, повече съобщения и писма, по-големи прикачени файлове",
|
||||||
"display_name_dialog_placeholder": "Наименование",
|
"display_name_dialog_placeholder": "Наименование",
|
||||||
"reserve_dialog_checkbox_label": "Резервиране на тема и настройки за достъп",
|
"reserve_dialog_checkbox_label": "Резервиране на тема и настройки за достъп",
|
||||||
"subscribe_dialog_subscribe_button_generate_topic_name": "Произволно име",
|
"subscribe_dialog_subscribe_button_generate_topic_name": "Произволно име",
|
||||||
@@ -257,7 +257,7 @@
|
|||||||
"account_tokens_dialog_button_cancel": "Отказ",
|
"account_tokens_dialog_button_cancel": "Отказ",
|
||||||
"account_delete_title": "Премахване на профила",
|
"account_delete_title": "Премахване на профила",
|
||||||
"account_upgrade_dialog_title": "Промяна нивото на профила",
|
"account_upgrade_dialog_title": "Промяна нивото на профила",
|
||||||
"account_usage_emails_title": "Изпратени съобщения",
|
"account_usage_emails_title": "Изпратени електронни писма",
|
||||||
"account_usage_reservations_title": "Резервирани теми",
|
"account_usage_reservations_title": "Резервирани теми",
|
||||||
"account_usage_reservations_none": "Няма резервирани теми",
|
"account_usage_reservations_none": "Няма резервирани теми",
|
||||||
"account_usage_cannot_create_portal_session": "Порталът за разплащане не може да бъде отворен",
|
"account_usage_cannot_create_portal_session": "Порталът за разплащане не може да бъде отворен",
|
||||||
@@ -332,8 +332,76 @@
|
|||||||
"account_upgrade_dialog_tier_price_per_month": "на месец",
|
"account_upgrade_dialog_tier_price_per_month": "на месец",
|
||||||
"account_upgrade_dialog_button_pay_now": "Плащане и абониране",
|
"account_upgrade_dialog_button_pay_now": "Плащане и абониране",
|
||||||
"account_upgrade_dialog_tier_selected_label": "Избрано",
|
"account_upgrade_dialog_tier_selected_label": "Избрано",
|
||||||
"account_upgrade_dialog_button_update_subscription": "Премяна на абонамент",
|
"account_upgrade_dialog_button_update_subscription": "Промяна на абонамент",
|
||||||
"account_upgrade_dialog_reservations_warning_other": "Избраното ниво разрешава по-малко резервирани теми, от колкото текущото. Преди промяна на нивото <strong>изтрийте най-малко {{count}} резервирани теми</strong>. Можете да премахвате теми в <Link>Настройки</Link>.",
|
"account_upgrade_dialog_reservations_warning_other": "Избраното ниво разрешава по-малко резервирани теми, от колкото текущото. Преди промяна на нивото <strong>изтрийте най-малко {{count}} резервирани теми</strong>. Можете да премахвате теми в <Link>Настройки</Link>.",
|
||||||
"account_tokens_table_expires_header": "Изтича",
|
"account_tokens_table_expires_header": "Изтича",
|
||||||
"account_tokens_table_never_expires": "Никога"
|
"account_tokens_table_never_expires": "Никога",
|
||||||
|
"prefs_reservations_title": "Резервирани теми",
|
||||||
|
"prefs_reservations_table_click_to_subscribe": "Докоснете, за да се абонирате",
|
||||||
|
"prefs_reservations_dialog_title_delete": "Премахване на резервирането",
|
||||||
|
"prefs_reservations_table_everyone_read_only": "Аз мога да публикувам и да се абонирам, всички останали могат да се абонират",
|
||||||
|
"prefs_reservations_table_not_subscribed": "Без абонамент",
|
||||||
|
"account_tokens_table_token_header": "Код за достъп",
|
||||||
|
"account_tokens_table_create_token_button": "Създаване на код за достъп",
|
||||||
|
"account_tokens_dialog_expires_x_days": "Кодът за достъп изтича след {{days}} дена",
|
||||||
|
"account_tokens_dialog_expires_never": "Кодът за достъп не изтича",
|
||||||
|
"account_tokens_delete_dialog_title": "Премахване на код за достъп",
|
||||||
|
"prefs_reservations_limit_reached": "Достигнахте ограничението за брой резервирани теми.",
|
||||||
|
"prefs_reservations_add_button": "Добавяне на тема",
|
||||||
|
"prefs_reservations_delete_button": "Нулиране на правата за достъп",
|
||||||
|
"prefs_reservations_table": "Списък с резервирани теми",
|
||||||
|
"prefs_reservations_dialog_title_add": "Резервиране на тема",
|
||||||
|
"prefs_reservations_dialog_title_edit": "Променяне на резервирана тема",
|
||||||
|
"account_tokens_table_current_session": "Текущ сеанс на четеца",
|
||||||
|
"account_tokens_table_copied_to_clipboard": "Кодът за достъп е копиран",
|
||||||
|
"account_tokens_table_cannot_delete_or_edit": "Не можете да променяте или премахвате кода за достъп на текущия сеанс",
|
||||||
|
"account_tokens_table_last_origin_tooltip": "От адрес по IP {{ip}}, щракнете за подробности",
|
||||||
|
"account_tokens_dialog_title_create": "Създаване на код за достъп",
|
||||||
|
"account_tokens_dialog_title_edit": "Променяне на код за достъп",
|
||||||
|
"account_tokens_dialog_title_delete": "Премахване на код за достъп",
|
||||||
|
"account_tokens_dialog_label": "Етикет, напр. Известия от Radarr",
|
||||||
|
"account_tokens_dialog_button_create": "Създаване на код за достъп",
|
||||||
|
"account_tokens_dialog_button_update": "Променяне на код за достъп",
|
||||||
|
"account_tokens_dialog_expires_label": "Кодът за достъп изтича след",
|
||||||
|
"account_tokens_dialog_expires_x_hours": "Кодът за достъп изтича след {{hours}} часа",
|
||||||
|
"account_tokens_dialog_expires_unchanged": "Без промяна на давността",
|
||||||
|
"account_tokens_delete_dialog_submit_button": "Безвъзвратно премахване на код за достъп",
|
||||||
|
"prefs_users_description_no_sync": "Потребителите и паролите не се синхронизират заедно с профила.",
|
||||||
|
"prefs_users_table_cannot_delete_or_edit": "Влезлият потребител не може да бъде премахнат",
|
||||||
|
"prefs_reservations_table_everyone_deny_all": "Само аз мога да публикувам и да се абонирам",
|
||||||
|
"prefs_reservations_table_everyone_write_only": "Аз мога да публикувам и да се абонирам, всички останали могат да публикуват",
|
||||||
|
"prefs_reservations_table_everyone_read_write": "Всички могат да публикуват и да се абонират",
|
||||||
|
"reservation_delete_dialog_submit_button": "Премахване на резервирането",
|
||||||
|
"account_tokens_description": "Използвайте код за достъп когато публикувате или се абонирате през ППИ на ntfy, за да не се налага да изпращате потребителско име и парола. Прочетете <Link>документацията</Link> за повече информация.",
|
||||||
|
"account_tokens_delete_dialog_description": "Преди да премахвате код за достъп се уверете, че не се използва от приложения или скриптове. <strong>Действието е необратимо.</strong>",
|
||||||
|
"prefs_reservations_dialog_description": "Резервирането ви осигурява собственост върху темата и ви дава възможност да определяте права за достъп от други потребители.",
|
||||||
|
"reservation_delete_dialog_action_keep_title": "Пазене на съобщения и прикачени файлове",
|
||||||
|
"reservation_delete_dialog_action_keep_description": "Съобщенията и прикачените файлове, които са във временната памет на сървъра ще бъдат достъпни за всеки, който знае името на темата.",
|
||||||
|
"reservation_delete_dialog_action_delete_title": "Премахване на съобщения и прикачени файлове",
|
||||||
|
"reservation_delete_dialog_action_delete_description": "Съобщенията и прикачените файлове, които са във временната памет ще бъдат премахнати. Действието е необратимо.",
|
||||||
|
"prefs_reservations_description": "Тук можете да резервирате тема за собствено ползване. Резервирането ви осигурява собственост върху темата и ви дава възможност да определяте права за достъп от други потребители.",
|
||||||
|
"reservation_delete_dialog_description": "С премахването на резервирането вие се отказвате от собствеността върху темата и давате възможност друг потребител да я резервира. Можете да оставите или да премахнете съществуващите съобщения и прикачени файлове.",
|
||||||
|
"alert_notification_permission_denied_description": "Включете ги от мрежовия четец",
|
||||||
|
"alert_notification_permission_denied_title": "Известията са изключени",
|
||||||
|
"notifications_actions_failed_notification": "Действието е неуспешно",
|
||||||
|
"publish_dialog_checkbox_markdown": "Съобщението е Markdown",
|
||||||
|
"prefs_notifications_web_push_disabled_description": "Известията ще бъдат получавани докато приложението за уеб работи (чрез WebSocket)",
|
||||||
|
"prefs_notifications_web_push_enabled": "Включено за {{server}}",
|
||||||
|
"prefs_notifications_web_push_disabled": "Изключено",
|
||||||
|
"prefs_appearance_theme_dark": "Тъмна",
|
||||||
|
"prefs_appearance_theme_light": "Светла",
|
||||||
|
"error_boundary_button_reload_ntfy": "Презареждне на ntfy",
|
||||||
|
"web_push_unknown_notification_title": "Получено е неочаквано известие",
|
||||||
|
"web_push_unknown_notification_body": "Вероятно ще трябва да обновите ntfy като отворите приложението за уеб",
|
||||||
|
"alert_notification_ios_install_required_title": "Необходимо е инсталиране за iOS",
|
||||||
|
"alert_notification_ios_install_required_description": "Докоснете бутона Споделяне и Добавяне към началния екран, за да включите известията под iOS",
|
||||||
|
"subscribe_dialog_subscribe_use_another_background_info": "Известията от други сървъри няма да бъдат получавани ако приложението за уеб не е отворено",
|
||||||
|
"action_bar_mute_notifications": "Заглушаване на известия",
|
||||||
|
"prefs_notifications_web_push_title": "Известия във фонов режим",
|
||||||
|
"prefs_notifications_web_push_enabled_description": "Известията ще бъдат получавани даже и ако приложението за уеб не работи (чрез Web Push)",
|
||||||
|
"prefs_appearance_theme_title": "Цветова тема",
|
||||||
|
"prefs_appearance_theme_system": "Системна (подразбирана)",
|
||||||
|
"web_push_subscription_expiring_title": "Известията временно ще бъдат спрени",
|
||||||
|
"web_push_subscription_expiring_body": "За да продължите да получавате известия, отворете ntfy",
|
||||||
|
"action_bar_unmute_notifications": "Включване звука на известията"
|
||||||
}
|
}
|
||||||
|
|||||||
1
web/public/static/langs/bn.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
7
web/public/static/langs/ca.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"nav_button_documentation": "Documentació",
|
||||||
|
"action_bar_profile_title": "Perfil",
|
||||||
|
"action_bar_settings": "Configuració",
|
||||||
|
"action_bar_account": "Compte",
|
||||||
|
"common_add": "Afegir"
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
"alert_notification_permission_required_description": "Udělte prohlížeči oprávnění k zobrazování oznámení na ploše.",
|
"alert_notification_permission_required_description": "Udělte prohlížeči oprávnění k zobrazování oznámení na ploše.",
|
||||||
"alert_notification_permission_required_button": "Udělit nyní",
|
"alert_notification_permission_required_button": "Udělit nyní",
|
||||||
"alert_not_supported_title": "Oznámení nejsou podporována",
|
"alert_not_supported_title": "Oznámení nejsou podporována",
|
||||||
"alert_not_supported_description": "Oznámení nejsou ve vašem prohlížeči podporována.",
|
"alert_not_supported_description": "Oznámení nejsou ve vašem prohlížeči podporována",
|
||||||
"notifications_copied_to_clipboard": "Zkopírováno do schránky",
|
"notifications_copied_to_clipboard": "Zkopírováno do schránky",
|
||||||
"notifications_tags": "Značky",
|
"notifications_tags": "Značky",
|
||||||
"notifications_attachment_copy_url_title": "Kopírovat URL přílohy do schránky",
|
"notifications_attachment_copy_url_title": "Kopírovat URL přílohy do schránky",
|
||||||
@@ -380,5 +380,28 @@
|
|||||||
"account_usage_calls_title": "Uskutečněné telefonáty",
|
"account_usage_calls_title": "Uskutečněné telefonáty",
|
||||||
"account_upgrade_dialog_tier_features_no_calls": "Žádné telefonní hovory",
|
"account_upgrade_dialog_tier_features_no_calls": "Žádné telefonní hovory",
|
||||||
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} denní telefonní hovor",
|
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} denní telefonní hovor",
|
||||||
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} denních telefonních hovorů"
|
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} denních telefonních hovorů",
|
||||||
|
"prefs_notifications_web_push_enabled": "Povoleno pro {{server}}",
|
||||||
|
"error_boundary_button_reload_ntfy": "Znovu načíst ntfy",
|
||||||
|
"web_push_subscription_expiring_body": "Otevřete ntfy a pokračujte v přijímání oznámení",
|
||||||
|
"action_bar_mute_notifications": "Ztlumit oznámení",
|
||||||
|
"action_bar_unmute_notifications": "Zrušit ztlumení oznámení",
|
||||||
|
"alert_notification_permission_denied_title": "Oznámení jsou blokována",
|
||||||
|
"alert_notification_permission_denied_description": "Prosím, znovu je povolte ve svém prohlížeči",
|
||||||
|
"alert_notification_ios_install_required_title": "Je vyžadována instalace iOS",
|
||||||
|
"alert_notification_ios_install_required_description": "Kliknutím na ikonu Sdílet a Přidat na domovskou obrazovku povolíte oznámení v systému iOS",
|
||||||
|
"notifications_actions_failed_notification": "Neúspěšná akce",
|
||||||
|
"publish_dialog_checkbox_markdown": "Formátovat jako Markdown",
|
||||||
|
"subscribe_dialog_subscribe_use_another_background_info": "Oznámení z jiných serverů nebudou přijímána, pokud není otevřena webová aplikace",
|
||||||
|
"prefs_notifications_web_push_title": "Oznámení na pozadí",
|
||||||
|
"prefs_notifications_web_push_enabled_description": "Oznámení jsou přijímána, i když webová aplikace není spuštěna (prostřednictvím Web Push)",
|
||||||
|
"prefs_notifications_web_push_disabled_description": "Oznámení jsou přijímána, když je webová aplikace spuštěna (přes WebSocket)",
|
||||||
|
"prefs_notifications_web_push_disabled": "Zakázáno",
|
||||||
|
"prefs_appearance_theme_title": "Motiv",
|
||||||
|
"prefs_appearance_theme_system": "Systém (výchozí)",
|
||||||
|
"prefs_appearance_theme_dark": "Tmavý režim",
|
||||||
|
"prefs_appearance_theme_light": "Světlý režim",
|
||||||
|
"web_push_subscription_expiring_title": "Oznámení budou pozastavena",
|
||||||
|
"web_push_unknown_notification_title": "Neznámé oznámení přijaté ze serveru",
|
||||||
|
"web_push_unknown_notification_body": "Možná bude nutné aktualizovat ntfy otevřením webové aplikace"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"common_save": "Gem",
|
"common_save": "Gem",
|
||||||
"common_add": "Tilføj",
|
"common_add": "Tilføj",
|
||||||
"signup_title": "Opret en ntfy konto",
|
"signup_title": "Opret en ntfy-konto",
|
||||||
"signup_form_username": "Brugernavn",
|
"signup_form_username": "Brugernavn",
|
||||||
"signup_form_password": "Kodeord",
|
"signup_form_password": "Kodeord",
|
||||||
"signup_form_confirm_password": "Bekræft kodeord",
|
"signup_form_confirm_password": "Bekræft kodeord",
|
||||||
@@ -10,24 +10,24 @@
|
|||||||
"signup_error_username_taken": "Brugernavnet {{username}} er optaget",
|
"signup_error_username_taken": "Brugernavnet {{username}} er optaget",
|
||||||
"login_form_button_submit": "Log ind",
|
"login_form_button_submit": "Log ind",
|
||||||
"action_bar_show_menu": "Vis menu",
|
"action_bar_show_menu": "Vis menu",
|
||||||
"action_bar_logo_alt": "ntfy logo",
|
"action_bar_logo_alt": "ntfy-logo",
|
||||||
"action_bar_settings": "Indstillinger",
|
"action_bar_settings": "Indstillinger",
|
||||||
"signup_form_button_submit": "Opret konto",
|
"signup_form_button_submit": "Opret konto",
|
||||||
"signup_form_toggle_password_visibility": "Skift synlighed af adgangskode",
|
"signup_form_toggle_password_visibility": "Skift synlighed af adgangskode",
|
||||||
"signup_disabled": "Tilmelding er deaktiveret",
|
"signup_disabled": "Tilmelding er deaktiveret",
|
||||||
"signup_error_creation_limit_reached": "Grænsen for kontooprettelse er nået",
|
"signup_error_creation_limit_reached": "Grænsen for kontooprettelse er nået",
|
||||||
"login_title": "Log ind på din ntfy konto",
|
"login_title": "Log ind på din ntfy-konto",
|
||||||
"login_link_signup": "Opret konto",
|
"login_link_signup": "Opret konto",
|
||||||
"login_disabled": "Login er deaktiveret",
|
"login_disabled": "Login er deaktiveret",
|
||||||
"action_bar_reservation_add": "Reserver emne",
|
"action_bar_reservation_add": "Reserver emne",
|
||||||
"action_bar_reservation_edit": "Rediger reservation",
|
"action_bar_reservation_edit": "Rediger reservation",
|
||||||
"action_bar_reservation_delete": "Fjern reservation",
|
"action_bar_reservation_delete": "Fjern reservation",
|
||||||
"action_bar_reservation_limit_reached": "Grænsen er nået",
|
"action_bar_reservation_limit_reached": "Grænsen er nået",
|
||||||
"action_bar_send_test_notification": "Send test notifikation",
|
"action_bar_send_test_notification": "Send testnotifikation",
|
||||||
"action_bar_unsubscribe": "Afmeld",
|
"action_bar_unsubscribe": "Afmeld",
|
||||||
"action_bar_toggle_mute": "Slå lyden fra/til for notifikationer",
|
"action_bar_toggle_mute": "Slå lyden fra/til for notifikationer",
|
||||||
"action_bar_change_display_name": "Skift visningsnavn",
|
"action_bar_change_display_name": "Skift visningsnavn",
|
||||||
"action_bar_toggle_action_menu": "Åben/luk handlings menu",
|
"action_bar_toggle_action_menu": "Åben/luk handlingsmenu",
|
||||||
"action_bar_profile_title": "Profil",
|
"action_bar_profile_title": "Profil",
|
||||||
"action_bar_profile_settings": "Indstillinger",
|
"action_bar_profile_settings": "Indstillinger",
|
||||||
"action_bar_profile_logout": "Log ud",
|
"action_bar_profile_logout": "Log ud",
|
||||||
@@ -58,9 +58,9 @@
|
|||||||
"notifications_attachment_open_title": "Gå til {{url}}",
|
"notifications_attachment_open_title": "Gå til {{url}}",
|
||||||
"notifications_attachment_open_button": "Åben vedhæftning",
|
"notifications_attachment_open_button": "Åben vedhæftning",
|
||||||
"notifications_attachment_link_expires": "link udløber {{date}}",
|
"notifications_attachment_link_expires": "link udløber {{date}}",
|
||||||
"notifications_attachment_link_expired": "download link er udløbet",
|
"notifications_attachment_link_expired": "download-link er udløbet",
|
||||||
"notifications_attachment_file_image": "billedfil",
|
"notifications_attachment_file_image": "billedfil",
|
||||||
"notifications_attachment_file_app": "Android app fil",
|
"notifications_attachment_file_app": "Android-appfil",
|
||||||
"notifications_attachment_file_document": "andet dokument",
|
"notifications_attachment_file_document": "andet dokument",
|
||||||
"notifications_click_copy_url_title": "Kopier linkets URL til udklipsholderen",
|
"notifications_click_copy_url_title": "Kopier linkets URL til udklipsholderen",
|
||||||
"notifications_click_copy_url_button": "Kopier link",
|
"notifications_click_copy_url_button": "Kopier link",
|
||||||
|
|||||||
@@ -31,12 +31,12 @@
|
|||||||
"notifications_attachment_open_title": "Gehe zu {{url}}",
|
"notifications_attachment_open_title": "Gehe zu {{url}}",
|
||||||
"notifications_none_for_any_title": "Du hast keine Benachrichtigungen empfangen.",
|
"notifications_none_for_any_title": "Du hast keine Benachrichtigungen empfangen.",
|
||||||
"action_bar_send_test_notification": "Test-Benachrichtigung senden",
|
"action_bar_send_test_notification": "Test-Benachrichtigung senden",
|
||||||
"alert_notification_permission_required_description": "Dem Browser erlauben, Desktop-Benachrichtigungen anzuzeigen.",
|
"alert_notification_permission_required_description": "Browser erlauben, Desktop-Benachrichtigungen anzuzeigen",
|
||||||
"notifications_tags": "Tags",
|
"notifications_tags": "Tags",
|
||||||
"message_bar_type_message": "Gib hier eine Nachricht ein",
|
"message_bar_type_message": "Gib hier eine Nachricht ein",
|
||||||
"message_bar_error_publishing": "Fehler beim Senden der Benachrichtigung",
|
"message_bar_error_publishing": "Fehler beim Senden der Benachrichtigung",
|
||||||
"alert_not_supported_title": "Benachrichtigungen werden nicht unterstützt",
|
"alert_not_supported_title": "Benachrichtigungen werden nicht unterstützt",
|
||||||
"alert_not_supported_description": "Benachrichtigungen werden von Deinem Browser nicht unterstützt.",
|
"alert_not_supported_description": "Benachrichtigungen werden von deinem Browser nicht unterstützt",
|
||||||
"action_bar_settings": "Einstellungen",
|
"action_bar_settings": "Einstellungen",
|
||||||
"action_bar_clear_notifications": "Alle Benachrichtigungen löschen",
|
"action_bar_clear_notifications": "Alle Benachrichtigungen löschen",
|
||||||
"alert_notification_permission_required_button": "Jetzt erlauben",
|
"alert_notification_permission_required_button": "Jetzt erlauben",
|
||||||
@@ -208,11 +208,11 @@
|
|||||||
"action_bar_change_display_name": "Anzeigenamen ändern",
|
"action_bar_change_display_name": "Anzeigenamen ändern",
|
||||||
"action_bar_reservation_add": "Thema reservieren",
|
"action_bar_reservation_add": "Thema reservieren",
|
||||||
"action_bar_reservation_edit": "Reservierung ändern",
|
"action_bar_reservation_edit": "Reservierung ändern",
|
||||||
"action_bar_reservation_delete": "Reservierung löschen",
|
"action_bar_reservation_delete": "Reservierung entfernen",
|
||||||
"action_bar_reservation_limit_reached": "Grenze erreicht",
|
"action_bar_reservation_limit_reached": "Grenze erreicht",
|
||||||
"action_bar_profile_title": "Profil",
|
"action_bar_profile_title": "Profil",
|
||||||
"action_bar_profile_settings": "Einstellungen",
|
"action_bar_profile_settings": "Einstellungen",
|
||||||
"action_bar_profile_logout": "Abmelden",
|
"action_bar_profile_logout": "Ausloggen",
|
||||||
"action_bar_sign_in": "Anmelden",
|
"action_bar_sign_in": "Anmelden",
|
||||||
"signup_form_password": "Kennwort",
|
"signup_form_password": "Kennwort",
|
||||||
"signup_form_toggle_password_visibility": "Kennwort-Sichtbarkeit umschalten",
|
"signup_form_toggle_password_visibility": "Kennwort-Sichtbarkeit umschalten",
|
||||||
@@ -380,5 +380,28 @@
|
|||||||
"account_basics_phone_numbers_dialog_check_verification_button": "Code bestätigen",
|
"account_basics_phone_numbers_dialog_check_verification_button": "Code bestätigen",
|
||||||
"account_usage_calls_title": "Getätigte Anrufe",
|
"account_usage_calls_title": "Getätigte Anrufe",
|
||||||
"account_usage_calls_none": "Noch keine Anrufe mit diesem Account getätigt",
|
"account_usage_calls_none": "Noch keine Anrufe mit diesem Account getätigt",
|
||||||
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} Telefonanrufe pro Tag"
|
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} Telefonanrufe pro Tag",
|
||||||
|
"action_bar_mute_notifications": "Benachrichtigungen stummschalten",
|
||||||
|
"action_bar_unmute_notifications": "Stummschaltung von Benachrichtigungen aufheben",
|
||||||
|
"alert_notification_permission_denied_title": "Benachrichtigungen sind blockiert",
|
||||||
|
"alert_notification_permission_denied_description": "Bitte reaktiviere diese in deinem Browser",
|
||||||
|
"notifications_actions_failed_notification": "Aktion nicht erfolgreich",
|
||||||
|
"alert_notification_ios_install_required_title": "iOS Installation erforderlich",
|
||||||
|
"alert_notification_ios_install_required_description": "Klicke auf das Teilen-Symbol und “Zum Home-Bildschirm” um auf iOS Benachrichtigungen zu aktivieren",
|
||||||
|
"subscribe_dialog_subscribe_use_another_background_info": "Benachrichtigungen von anderen Servern werden nicht empfangen, wenn die Web App nicht geöffnet ist",
|
||||||
|
"publish_dialog_checkbox_markdown": "Als Markdown formatieren",
|
||||||
|
"prefs_notifications_web_push_title": "Hintergrundbenachrichtigungen",
|
||||||
|
"prefs_notifications_web_push_disabled_description": "Benachrichtigungen werden empfangen, wenn die Web App geöffnet ist (via WebSocket)",
|
||||||
|
"prefs_notifications_web_push_enabled": "Aktiviert für {{server}}",
|
||||||
|
"prefs_notifications_web_push_disabled": "Deaktiviert",
|
||||||
|
"prefs_appearance_theme_title": "Thema",
|
||||||
|
"prefs_appearance_theme_system": "System (Standard)",
|
||||||
|
"prefs_appearance_theme_dark": "Nachtmodus",
|
||||||
|
"prefs_appearance_theme_light": "Tagmodus",
|
||||||
|
"error_boundary_button_reload_ntfy": "ntfy neu laden",
|
||||||
|
"web_push_subscription_expiring_title": "Benachrichtigungen werden pausiert",
|
||||||
|
"web_push_subscription_expiring_body": "Öffne ntfy um weiterhin Benachrichtigungen zu erhalten",
|
||||||
|
"web_push_unknown_notification_title": "Unbekannte Benachrichtigung vom Server empfangen",
|
||||||
|
"web_push_unknown_notification_body": "Du musst möglicherweise ntfy aktualisieren, indem du die Web App öffnest",
|
||||||
|
"prefs_notifications_web_push_enabled_description": "Benachrichtigungen werden empfangen, auch wenn die Web App nicht geöffnet ist (via Web Push)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"message_bar_type_message": "Escriba un mensaje aquí",
|
"message_bar_type_message": "Escriba un mensaje aquí",
|
||||||
"message_bar_error_publishing": "Error al publicar la notificación",
|
"message_bar_error_publishing": "Error al publicar la notificación",
|
||||||
"alert_notification_permission_required_title": "Las notificaciones están deshabilitadas",
|
"alert_notification_permission_required_title": "Las notificaciones están deshabilitadas",
|
||||||
"alert_notification_permission_required_description": "Concede a tu navegador permiso para mostrar notificaciones en el escritorio.",
|
"alert_notification_permission_required_description": "Concede a tu navegador permiso para mostrar notificaciones de escritorio",
|
||||||
"nav_button_all_notifications": "Todas las notificaciones",
|
"nav_button_all_notifications": "Todas las notificaciones",
|
||||||
"nav_button_settings": "Ajustes",
|
"nav_button_settings": "Ajustes",
|
||||||
"nav_button_subscribe": "Suscribirse al tópico",
|
"nav_button_subscribe": "Suscribirse al tópico",
|
||||||
@@ -16,13 +16,13 @@
|
|||||||
"nav_button_publish_message": "Publicar notificación",
|
"nav_button_publish_message": "Publicar notificación",
|
||||||
"notifications_copied_to_clipboard": "Copiado al portapapeles",
|
"notifications_copied_to_clipboard": "Copiado al portapapeles",
|
||||||
"alert_not_supported_title": "Notificaciones no soportadas",
|
"alert_not_supported_title": "Notificaciones no soportadas",
|
||||||
"alert_not_supported_description": "Las notificaciones no están soportadas por tu navegador.",
|
"alert_not_supported_description": "Su navegador no admite notificaciones",
|
||||||
"notifications_tags": "Etiquetas",
|
"notifications_tags": "Etiquetas",
|
||||||
"notifications_attachment_copy_url_title": "Copiar la URL del archivo adjunto en el portapapeles",
|
"notifications_attachment_copy_url_title": "Copiar la URL del archivo adjunto en el portapapeles",
|
||||||
"notifications_attachment_copy_url_button": "Copiar URL",
|
"notifications_attachment_copy_url_button": "Copiar URL",
|
||||||
"notifications_attachment_open_title": "Ir a {{url}}",
|
"notifications_attachment_open_title": "Ir a {{url}}",
|
||||||
"notifications_attachment_open_button": "Abrir archivo adjunto",
|
"notifications_attachment_open_button": "Abrir archivo adjunto",
|
||||||
"notifications_attachment_link_expires": "el enlace expira el día {{fecha}}",
|
"notifications_attachment_link_expires": "el enlace expira el día {{date}}",
|
||||||
"notifications_attachment_link_expired": "el enlace de descarga ha expirado",
|
"notifications_attachment_link_expired": "el enlace de descarga ha expirado",
|
||||||
"notifications_click_copy_url_title": "Copiar la URL del enlace en el portapapeles",
|
"notifications_click_copy_url_title": "Copiar la URL del enlace en el portapapeles",
|
||||||
"notifications_click_copy_url_button": "Copiar enlace",
|
"notifications_click_copy_url_button": "Copiar enlace",
|
||||||
@@ -226,7 +226,7 @@
|
|||||||
"account_basics_password_dialog_current_password_incorrect": "Contraseña incorrecta",
|
"account_basics_password_dialog_current_password_incorrect": "Contraseña incorrecta",
|
||||||
"account_usage_unlimited": "Ilimitado",
|
"account_usage_unlimited": "Ilimitado",
|
||||||
"account_usage_title": "Uso",
|
"account_usage_title": "Uso",
|
||||||
"account_usage_of_limit": "de {{límite}}",
|
"account_usage_of_limit": "de {{limit}}",
|
||||||
"account_usage_limits_reset_daily": "Los límites de uso se restablecen diariamente a la medianoche (UTC)",
|
"account_usage_limits_reset_daily": "Los límites de uso se restablecen diariamente a la medianoche (UTC)",
|
||||||
"account_basics_tier_description": "Nivel de poder de tu cuenta",
|
"account_basics_tier_description": "Nivel de poder de tu cuenta",
|
||||||
"account_basics_tier_admin": "Administrador",
|
"account_basics_tier_admin": "Administrador",
|
||||||
@@ -247,7 +247,7 @@
|
|||||||
"account_basics_tier_free": "Gratis",
|
"account_basics_tier_free": "Gratis",
|
||||||
"account_basics_tier_upgrade_button": "Actualizar a Pro",
|
"account_basics_tier_upgrade_button": "Actualizar a Pro",
|
||||||
"account_basics_tier_change_button": "Cambiar",
|
"account_basics_tier_change_button": "Cambiar",
|
||||||
"account_basics_tier_paid_until": "Suscripción pagada hasta {{fecha}}, y se renovará automáticamente",
|
"account_basics_tier_paid_until": "Suscripción pagada hasta {{date}}, y se renovará automáticamente",
|
||||||
"account_basics_tier_manage_billing_button": "Administrar la facturación",
|
"account_basics_tier_manage_billing_button": "Administrar la facturación",
|
||||||
"account_basics_tier_title": "Tipo de cuenta",
|
"account_basics_tier_title": "Tipo de cuenta",
|
||||||
"account_tokens_description": "Utilice tokens de acceso al publicar y suscribirse a través de la API de ntfy para no tener que enviar las credenciales de su cuenta. Consulte la <Link>documentación</Link> para obtener más información.",
|
"account_tokens_description": "Utilice tokens de acceso al publicar y suscribirse a través de la API de ntfy para no tener que enviar las credenciales de su cuenta. Consulte la <Link>documentación</Link> para obtener más información.",
|
||||||
@@ -371,8 +371,8 @@
|
|||||||
"account_basics_phone_numbers_dialog_channel_call": "Llamar",
|
"account_basics_phone_numbers_dialog_channel_call": "Llamar",
|
||||||
"account_usage_calls_title": "Llamadas telefónicas realizadas",
|
"account_usage_calls_title": "Llamadas telefónicas realizadas",
|
||||||
"account_usage_calls_none": "No se pueden hacer llamadas telefónicas con esta cuenta",
|
"account_usage_calls_none": "No se pueden hacer llamadas telefónicas con esta cuenta",
|
||||||
"account_upgrade_dialog_tier_features_calls_one": "{{llamadas}} llamadas telefónicas diarias",
|
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} llamadas telefónicas diarias",
|
||||||
"account_upgrade_dialog_tier_features_calls_other": "{{llamadas}} llamadas telefónicas diarias",
|
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} llamadas telefónicas diarias",
|
||||||
"account_upgrade_dialog_tier_features_no_calls": "No hay llamadas telefónicas",
|
"account_upgrade_dialog_tier_features_no_calls": "No hay llamadas telefónicas",
|
||||||
"publish_dialog_call_reset": "Eliminar llamada telefónica",
|
"publish_dialog_call_reset": "Eliminar llamada telefónica",
|
||||||
"account_basics_phone_numbers_dialog_description": "Para utilizar la función de notificación de llamadas, tiene que añadir y verificar al menos un número de teléfono. La verificación puede realizarse mediante un SMS o una llamada telefónica.",
|
"account_basics_phone_numbers_dialog_description": "Para utilizar la función de notificación de llamadas, tiene que añadir y verificar al menos un número de teléfono. La verificación puede realizarse mediante un SMS o una llamada telefónica.",
|
||||||
@@ -381,5 +381,28 @@
|
|||||||
"account_basics_phone_numbers_dialog_title": "Agregar número de teléfono",
|
"account_basics_phone_numbers_dialog_title": "Agregar número de teléfono",
|
||||||
"account_basics_phone_numbers_dialog_code_placeholder": "p.ej. 123456",
|
"account_basics_phone_numbers_dialog_code_placeholder": "p.ej. 123456",
|
||||||
"publish_dialog_call_item": "Llamar al número de teléfono {{number}}",
|
"publish_dialog_call_item": "Llamar al número de teléfono {{number}}",
|
||||||
"publish_dialog_chip_call_no_verified_numbers_tooltip": "No hay números de teléfono verificados"
|
"publish_dialog_chip_call_no_verified_numbers_tooltip": "No hay números de teléfono verificados",
|
||||||
|
"action_bar_mute_notifications": "Silenciar Notificaciones",
|
||||||
|
"action_bar_unmute_notifications": "Reactivar notificaciones",
|
||||||
|
"alert_notification_permission_denied_title": "Notificaciones bloqueadas",
|
||||||
|
"alert_notification_permission_denied_description": "Porfavor, reactivelas en su navegador",
|
||||||
|
"alert_notification_ios_install_required_title": "Requiere instalacion de iOS",
|
||||||
|
"alert_notification_ios_install_required_description": "Haz click en el icono de compartir y Añadir a pantalla de inicio para activar las notificaciones de iOS",
|
||||||
|
"notifications_actions_failed_notification": "Acción fallida",
|
||||||
|
"publish_dialog_checkbox_markdown": "Formatear como Markdown",
|
||||||
|
"subscribe_dialog_subscribe_use_another_background_info": "Las notificaciones de otros servidores no se recibirán cuando la aplicación web no esté abierta",
|
||||||
|
"prefs_notifications_web_push_title": "Notificaciones en segundo plano",
|
||||||
|
"prefs_notifications_web_push_enabled_description": "Las notificaciones se reciben incluso cuando la aplicación web no se está ejecutando (a través de Web Push)",
|
||||||
|
"prefs_notifications_web_push_disabled": "Desactivado",
|
||||||
|
"prefs_appearance_theme_title": "Tema",
|
||||||
|
"prefs_appearance_theme_system": "Sistema (por defecto)",
|
||||||
|
"error_boundary_button_reload_ntfy": "Volver a cargar ntfy",
|
||||||
|
"web_push_subscription_expiring_title": "Las notificaciones se pausarán",
|
||||||
|
"prefs_notifications_web_push_disabled_description": "Las notificaciones se reciben cuando la aplicación web se está ejecutando (a través de WebSocket)",
|
||||||
|
"prefs_notifications_web_push_enabled": "Activado para {{server}}",
|
||||||
|
"prefs_appearance_theme_light": "Claro",
|
||||||
|
"prefs_appearance_theme_dark": "Oscuro",
|
||||||
|
"web_push_subscription_expiring_body": "Abrir ntfy para seguir recibiendo notificaciones",
|
||||||
|
"web_push_unknown_notification_title": "Notificación desconocida recibida del servidor",
|
||||||
|
"web_push_unknown_notification_body": "Puede que necesites actualizar ntfy abriendo la aplicación web"
|
||||||
}
|
}
|
||||||
|
|||||||
274
web/public/static/langs/et.json
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
{
|
||||||
|
"signup_title": "Loo ntfy kasutajakonto",
|
||||||
|
"signup_form_username": "Kasutajanimi",
|
||||||
|
"signup_form_password": "Salasõna",
|
||||||
|
"signup_form_confirm_password": "Kinnita salasõna õigsust",
|
||||||
|
"signup_already_have_account": "Sul juba on kasutajakonto olemas? Siis logi sisse!",
|
||||||
|
"signup_disabled": "Kasutajakonto loomine pole hetkel lubatud",
|
||||||
|
"signup_error_username_taken": "Kasutajanimi {{username}} on juba olemas",
|
||||||
|
"signup_error_creation_limit_reached": "Kasutajakontode loomise ülempiir on käes",
|
||||||
|
"login_title": "Logi sisse oma ntfy kasutajakontole",
|
||||||
|
"login_form_button_submit": "Logi sisse",
|
||||||
|
"login_link_signup": "Liitu",
|
||||||
|
"login_disabled": "Sisselogimine pole hetkel kasutusel",
|
||||||
|
"action_bar_show_menu": "Näita menüüd",
|
||||||
|
"action_bar_logo_alt": "ntfy logo",
|
||||||
|
"action_bar_settings": "Seadistused",
|
||||||
|
"action_bar_change_display_name": "Muuda kuvatavat nime",
|
||||||
|
"common_cancel": "Katkesta",
|
||||||
|
"common_save": "Salvesta",
|
||||||
|
"common_back": "Tagasi",
|
||||||
|
"common_copy_to_clipboard": "Kopeeri lõikelauale",
|
||||||
|
"common_add": "Lisa",
|
||||||
|
"signup_form_button_submit": "Liitu",
|
||||||
|
"signup_form_toggle_password_visibility": "Vaheta salasõna nähtavust",
|
||||||
|
"action_bar_account": "Kasutajakonto",
|
||||||
|
"action_bar_sign_in": "Logi sisse",
|
||||||
|
"nav_button_documentation": "Dokumentatsioon",
|
||||||
|
"action_bar_profile_title": "Profiil",
|
||||||
|
"action_bar_profile_settings": "Seadistused",
|
||||||
|
"action_bar_sign_up": "Liitu",
|
||||||
|
"message_bar_type_message": "Sisesta oma sõnum siia",
|
||||||
|
"message_bar_error_publishing": "Viga teavituse avaldamisel",
|
||||||
|
"message_bar_show_dialog": "Näita avaldamisvaadet",
|
||||||
|
"message_bar_publish": "Avalda sõnum",
|
||||||
|
"nav_topics_title": "Tellitud teemad",
|
||||||
|
"nav_button_all_notifications": "Kõik teavitused",
|
||||||
|
"nav_button_account": "Kasutajakonto",
|
||||||
|
"nav_button_settings": "Seadistused",
|
||||||
|
"nav_button_publish_message": "Avalda teavitus",
|
||||||
|
"nav_button_subscribe": "Telli teema",
|
||||||
|
"nav_button_muted": "Teavitused on summutatud",
|
||||||
|
"nav_button_connecting": "loome ühendust",
|
||||||
|
"nav_upgrade_banner_label": "Uuenda ntfy Pro teenuseks",
|
||||||
|
"action_bar_profile_logout": "Logi välja",
|
||||||
|
"notifications_list_item": "Teavitus",
|
||||||
|
"account_tokens_table_expires_header": "Aegub",
|
||||||
|
"notifications_attachment_file_document": "muu dokument",
|
||||||
|
"notifications_list": "Teavituste loend",
|
||||||
|
"notifications_delete": "Kustuta",
|
||||||
|
"notifications_copied_to_clipboard": "Kopeeritud lõikelauale",
|
||||||
|
"alert_notification_permission_denied_description": "Palun luba nad veebibrauseris uuesti",
|
||||||
|
"account_tokens_table_last_access_header": "Viimase kasutamise aeg",
|
||||||
|
"account_tokens_table_token_header": "Tunnusluba",
|
||||||
|
"account_tokens_table_last_origin_tooltip": "IP-aadressilt {{ip}}, klõpsi täpsema teabe nägemiseks",
|
||||||
|
"action_bar_reservation_add": "Reserveeri teema",
|
||||||
|
"action_bar_reservation_edit": "Muuda reserveeringut",
|
||||||
|
"action_bar_reservation_delete": "Eemalda reserveering",
|
||||||
|
"action_bar_reservation_limit_reached": "Ülempiir on käes",
|
||||||
|
"action_bar_send_test_notification": "Saata testteavitus",
|
||||||
|
"action_bar_clear_notifications": "Kustuta kõik teavitused",
|
||||||
|
"action_bar_mute_notifications": "Summuta teavitused",
|
||||||
|
"nav_upgrade_banner_description": "Reserveeri teemasid, rohkem sõnumeid ja e-kirju ning suuremad manused",
|
||||||
|
"action_bar_unmute_notifications": "Lõpeta teavituste summutamine",
|
||||||
|
"action_bar_unsubscribe": "Lõpeta tellimus",
|
||||||
|
"action_bar_toggle_mute": "Lülita teavituste summutamine sisse/välja",
|
||||||
|
"action_bar_toggle_action_menu": "Ava/sulge tegevuste menüü",
|
||||||
|
"notifications_mark_read": "Märgi loetuks",
|
||||||
|
"notifications_tags": "Sildid",
|
||||||
|
"notifications_priority_x": "{{priority}}. prioriteet",
|
||||||
|
"notifications_new_indicator": "Uus teavitus",
|
||||||
|
"notifications_attachment_image": "Pilt manusena",
|
||||||
|
"notifications_attachment_copy_url_title": "Kopeeri manuse võrguaadress lõikelauale",
|
||||||
|
"notifications_attachment_copy_url_button": "Kopeeri võrguaadress",
|
||||||
|
"notifications_attachment_open_title": "Ava {{url}} aadress",
|
||||||
|
"notifications_attachment_open_button": "Ava manus",
|
||||||
|
"notifications_attachment_link_expires": "link aegub {{date}}",
|
||||||
|
"notifications_attachment_link_expired": "allalaadimise link on aegunud",
|
||||||
|
"notifications_attachment_file_image": "pildifail",
|
||||||
|
"notifications_attachment_file_video": "videofail",
|
||||||
|
"notifications_attachment_file_audio": "helifail",
|
||||||
|
"notifications_attachment_file_app": "Androidi rakenduse fail",
|
||||||
|
"notifications_click_copy_url_title": "Kopeeri lingi võrguaadress lõikelauale",
|
||||||
|
"notifications_click_copy_url_button": "Kopeeri link",
|
||||||
|
"notifications_click_open_button": "Ava link",
|
||||||
|
"notifications_actions_open_url_title": "Ava {{url}} aadress",
|
||||||
|
"notifications_actions_not_supported": "Toiming pole veebirakenduses toetatud",
|
||||||
|
"alert_notification_permission_required_title": "Teavitused pole kasutusel",
|
||||||
|
"alert_notification_permission_required_description": "Anna oma brauserile õigused näidata töölauateavitusi",
|
||||||
|
"alert_notification_permission_required_button": "Luba nüüd",
|
||||||
|
"alert_notification_permission_denied_title": "Teavitused on blokeeritud",
|
||||||
|
"alert_notification_ios_install_required_title": "Vajalik on iOS-i paigaldamine",
|
||||||
|
"alert_not_supported_title": "Teavitused pole toetatud",
|
||||||
|
"alert_not_supported_description": "Teavitused pole sinu veebibrauseris toetatud",
|
||||||
|
"account_tokens_table_label_header": "Silt",
|
||||||
|
"account_tokens_table_never_expires": "Ei aegu iialgi",
|
||||||
|
"account_tokens_table_current_session": "Praegune brauserisessioon",
|
||||||
|
"account_tokens_table_copied_to_clipboard": "Ligipääsu tunnusluba on kopeeritud",
|
||||||
|
"account_tokens_table_cannot_delete_or_edit": "Praeguse sessiooni tunnusluba ei saa muuta ega kustutada",
|
||||||
|
"account_tokens_table_create_token_button": "Loo ligipääsuks vajalik tunnusluba",
|
||||||
|
"account_tokens_dialog_title_create": "Loo ligipääsuks vajalik tunnusluba",
|
||||||
|
"account_tokens_dialog_title_edit": "Muuda ligipääsuks vajalikku tunnusluba",
|
||||||
|
"account_tokens_dialog_title_delete": "Kustuta ligipääsuks vajalik tunnusluba",
|
||||||
|
"subscribe_dialog_login_password_label": "Salasõna",
|
||||||
|
"publish_dialog_filename_label": "Failinimi",
|
||||||
|
"prefs_reservations_table_access_header": "Ligipääs",
|
||||||
|
"publish_dialog_chip_click_label": "Klõpsi võrguaadressi",
|
||||||
|
"subscribe_dialog_subscribe_button_cancel": "Katkesta",
|
||||||
|
"publish_dialog_delay_label": "Viivitus",
|
||||||
|
"account_basics_password_title": "Salasõna",
|
||||||
|
"account_upgrade_dialog_button_cancel": "Katkesta",
|
||||||
|
"notifications_example": "Näide",
|
||||||
|
"account_usage_title": "Kasutus",
|
||||||
|
"account_basics_title": "Kasutajakonto",
|
||||||
|
"prefs_reservations_table_topic_header": "Teema",
|
||||||
|
"account_delete_dialog_button_cancel": "Katkesta",
|
||||||
|
"account_delete_dialog_label": "Salasõna",
|
||||||
|
"publish_dialog_message_label": "Sõnum",
|
||||||
|
"account_basics_phone_numbers_dialog_channel_call": "Kõne",
|
||||||
|
"prefs_users_dialog_password_label": "Salasõna",
|
||||||
|
"subscribe_dialog_subscribe_button_subscribe": "Telli",
|
||||||
|
"publish_dialog_priority_label": "Prioriteet",
|
||||||
|
"subscribe_dialog_login_button_login": "Logi sisse",
|
||||||
|
"subscribe_dialog_error_user_anonymous": "anonüümne",
|
||||||
|
"prefs_appearance_theme_title": "Kujundus",
|
||||||
|
"publish_dialog_button_cancel": "Katkesta",
|
||||||
|
"account_usage_unlimited": "Piiramatu",
|
||||||
|
"prefs_notifications_delete_after_never": "Mitte kunagi",
|
||||||
|
"account_upgrade_dialog_interval_monthly": "Iga kuu",
|
||||||
|
"account_upgrade_dialog_tier_price_per_month": "kuu",
|
||||||
|
"prefs_notifications_web_push_disabled": "Pole kasutusel",
|
||||||
|
"prefs_appearance_title": "Välimus",
|
||||||
|
"prefs_appearance_language_title": "Keel",
|
||||||
|
"prefs_reservations_dialog_topic_label": "Teema",
|
||||||
|
"publish_dialog_priority_min": "Väikseim tähtsus",
|
||||||
|
"notifications_actions_failed_notification": "Ebaõnnestunud toiming",
|
||||||
|
"publish_dialog_title_label": "Pealkiri",
|
||||||
|
"publish_dialog_tags_label": "Sildid",
|
||||||
|
"publish_dialog_email_label": "E-post",
|
||||||
|
"display_name_dialog_placeholder": "Kuvatav nimi",
|
||||||
|
"publish_dialog_title_no_topic": "Avalda teavitus",
|
||||||
|
"publish_dialog_progress_uploading": "Laadin üles…",
|
||||||
|
"publish_dialog_message_published": "Teavitus on saadetud",
|
||||||
|
"publish_dialog_emoji_picker_show": "Vali emoji",
|
||||||
|
"publish_dialog_priority_low": "Vähetähtis",
|
||||||
|
"publish_dialog_priority_default": "Vaikimisi tähtsus",
|
||||||
|
"publish_dialog_priority_high": "Oluline",
|
||||||
|
"publish_dialog_priority_max": "Väga oluline",
|
||||||
|
"publish_dialog_base_url_label": "Teenuse võrguaadress",
|
||||||
|
"publish_dialog_topic_label": "Teema nimi",
|
||||||
|
"publish_dialog_topic_reset": "Lähtesta teema",
|
||||||
|
"publish_dialog_click_label": "Klõpsi võrguaadressi",
|
||||||
|
"publish_dialog_call_label": "Telefonikõne",
|
||||||
|
"publish_dialog_button_send": "Saada",
|
||||||
|
"publish_dialog_attach_label": "Manuse võrguaadress",
|
||||||
|
"publish_dialog_filename_placeholder": "Manuse failinimi",
|
||||||
|
"publish_dialog_other_features": "Lisavõimalused:",
|
||||||
|
"publish_dialog_chip_call_label": "Telefonikõne",
|
||||||
|
"publish_dialog_chip_delay_label": "Viivita saatmisega",
|
||||||
|
"publish_dialog_chip_topic_label": "Muuda teemat",
|
||||||
|
"publish_dialog_button_cancel_sending": "Katkesta saatmine",
|
||||||
|
"account_basics_username_title": "Kasutajanimi",
|
||||||
|
"account_basics_phone_numbers_dialog_channel_sms": "Tekstisõnum",
|
||||||
|
"account_basics_tier_admin": "Peakasutaja",
|
||||||
|
"account_basics_tier_basic": "Baasteenus",
|
||||||
|
"account_basics_tier_free": "Tasuta",
|
||||||
|
"account_basics_tier_interval_monthly": "kord kuus",
|
||||||
|
"account_basics_tier_interval_yearly": "kord aastas",
|
||||||
|
"account_basics_tier_change_button": "Muuda",
|
||||||
|
"account_upgrade_dialog_interval_yearly": "Kord aastas",
|
||||||
|
"account_upgrade_dialog_tier_selected_label": "Valitud",
|
||||||
|
"account_upgrade_dialog_tier_current_label": "Praegune",
|
||||||
|
"account_tokens_dialog_button_cancel": "Katkesta",
|
||||||
|
"prefs_notifications_title": "Teavitused",
|
||||||
|
"prefs_users_table_user_header": "Kasutaja",
|
||||||
|
"prefs_reservations_dialog_access_label": "Ligipääs",
|
||||||
|
"priority_min": "min",
|
||||||
|
"priority_low": "madal",
|
||||||
|
"priority_default": "vaikimisi",
|
||||||
|
"priority_high": "kõrge",
|
||||||
|
"priority_max": "kõrgeim",
|
||||||
|
"alert_notification_ios_install_required_description": "Teavituste lubamiseks iOS-is klõpsi „Jaga“ ikooni ja vali „Lisa avaekraanile“",
|
||||||
|
"notifications_none_for_topic_title": "Sul pole selles teemas veel ühtegi teavitust.",
|
||||||
|
"notifications_none_for_topic_description": "Selles teemas teavituste saatmiseks tee PUT või POST meetodiga päring teema võrguaadressile.",
|
||||||
|
"publish_dialog_base_url_placeholder": "Teenuse võrguaadress, nt. https://toresait.com",
|
||||||
|
"notifications_loading": "Laadin teavitusi…",
|
||||||
|
"publish_dialog_title_topic": "Avalda teemas {{topic}}",
|
||||||
|
"publish_dialog_progress_uploading_detail": "Üleslaadimisel {{loaded}}/{{total}} ({{percent}}%) …",
|
||||||
|
"publish_dialog_topic_placeholder": "Teema nimi, nt. kati_teavitused",
|
||||||
|
"publish_dialog_title_placeholder": "Teavituse pealkiri, nt. Andmeruumi teavitus",
|
||||||
|
"publish_dialog_message_placeholder": "Siia sisesta sõnum",
|
||||||
|
"notifications_none_for_any_title": "Sa pole veel saanud ühtegi teavitust.",
|
||||||
|
"publish_dialog_chip_attach_file_label": "Lisa kohalik fail",
|
||||||
|
"publish_dialog_chip_attach_url_label": "Lisa fail võrguaadressilt",
|
||||||
|
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Kinnitatud telefoninumbreid ei leidu",
|
||||||
|
"publish_dialog_chip_email_label": "Edasta e-posti aadressile",
|
||||||
|
"subscribe_dialog_subscribe_base_url_label": "Teenuse võrguaadress",
|
||||||
|
"subscribe_dialog_subscribe_button_generate_topic_name": "Loo nimi",
|
||||||
|
"publish_dialog_checkbox_markdown": "Kasuta Markdown-vormingut",
|
||||||
|
"subscribe_dialog_login_title": "Vajalik on sisselogimine",
|
||||||
|
"subscribe_dialog_login_username_label": "Kasutajanimi, nt. kadri",
|
||||||
|
"account_basics_phone_numbers_dialog_verify_button_sms": "Saada SMS",
|
||||||
|
"account_basics_username_description": "Hei, see oled sina ❤",
|
||||||
|
"account_basics_username_admin_tooltip": "Sina oled peakasutaja",
|
||||||
|
"account_basics_phone_numbers_dialog_verify_button_call": "Helista mulle",
|
||||||
|
"account_basics_phone_numbers_dialog_code_label": "Kinnituskood",
|
||||||
|
"account_basics_phone_numbers_dialog_code_placeholder": "nt. 123456",
|
||||||
|
"account_basics_phone_numbers_dialog_check_verification_button": "Korda koodi",
|
||||||
|
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} sõnum päevas",
|
||||||
|
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} sõnumit päevas",
|
||||||
|
"account_upgrade_dialog_button_redirect_signup": "Liitu kohe",
|
||||||
|
"notifications_actions_http_request_title": "Tee päring HTTP {{method}}-meetodiga võrguaadressile {{url}}",
|
||||||
|
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} e-kirja päevas",
|
||||||
|
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} e-kiri päevas",
|
||||||
|
"alert_not_supported_context_description": "Teavitused võivad kasutada vaid HTTPS-ühendust. See on <mdnLink>Teavituste API</mdnLink> piirang.",
|
||||||
|
"publish_dialog_tags_placeholder": "Komadega eraldatud siltide loend, nt. hoiatus, srv1-varundus",
|
||||||
|
"display_name_dialog_title": "Muuda kuvatavat nime",
|
||||||
|
"display_name_dialog_description": "Lisa teemale alternatiivne nimi, mida kuvatakse tellimuste loendis. See on näiteks abiks keerukate nimedega teemade tuvastamiseks.",
|
||||||
|
"reserve_dialog_checkbox_label": "Reserveeri teema ja seadista ligipääs",
|
||||||
|
"publish_dialog_attachment_limits_file_reached": "ületab failisuuruse piiri: {{fileSizeLimit}}",
|
||||||
|
"publish_dialog_attachment_limits_quota_reached": "ületab kvooti, jäänud on {{remainingBytes}}",
|
||||||
|
"publish_dialog_attachment_limits_file_and_quota_reached": "ületab failisuuruse ülempiiri ({{fileSizeLimit}}) ja kvooti, jäänud on {{remainingBytes}}",
|
||||||
|
"publish_dialog_click_placeholder": "Teavituse klõpsimisel avatav võrguaadress",
|
||||||
|
"publish_dialog_click_reset": "Eemalda klikatav võrguaadress",
|
||||||
|
"publish_dialog_email_placeholder": "Aadress, kuhu teavitus edastatakse, nt. kadri@torefirma.com",
|
||||||
|
"publish_dialog_email_reset": "Eemalda edastamiseks kasutatav e-posti aadress",
|
||||||
|
"publish_dialog_call_item": "Helista telefoninumbrile {{number}}",
|
||||||
|
"publish_dialog_call_reset": "Eemalda helistamine",
|
||||||
|
"publish_dialog_attach_placeholder": "Lisa fail võrguaadressilt, nt. https://f-droid.org/F-Droid.apk",
|
||||||
|
"publish_dialog_attach_reset": "Eemalda manuse lisamisel kasutatav võrguaadress",
|
||||||
|
"publish_dialog_delay_reset": "Eemalda viivitus teavituse edastamisel",
|
||||||
|
"account_basics_password_description": "Muuda oma kasutajakonto salasõna",
|
||||||
|
"account_basics_password_dialog_title": "Salasõna muutmine",
|
||||||
|
"account_basics_password_dialog_current_password_label": "Senine salasõna",
|
||||||
|
"account_basics_password_dialog_button_submit": "Muuda salasõna",
|
||||||
|
"account_basics_password_dialog_current_password_incorrect": "Salasõna pole korrektne",
|
||||||
|
"account_basics_phone_numbers_title": "Telefoninumbrid",
|
||||||
|
"account_basics_phone_numbers_description": "Kõneteavituste jaoks",
|
||||||
|
"account_basics_tier_title": "Kasutajakonto tüüp",
|
||||||
|
"account_basics_tier_description": "Sinu kasutajakonto õigused",
|
||||||
|
"account_delete_dialog_button_submit": "Kustuta kasutajakonto jäädavalt",
|
||||||
|
"prefs_appearance_theme_system": "Süsteemi kujundus",
|
||||||
|
"prefs_appearance_theme_dark": "Tume kujundus",
|
||||||
|
"prefs_appearance_theme_light": "Hele kujundus",
|
||||||
|
"prefs_reservations_title": "Reserveeritud teemad",
|
||||||
|
"prefs_users_table": "Kasutajate loend",
|
||||||
|
"prefs_users_add_button": "Lisa kasutaja",
|
||||||
|
"prefs_users_edit_button": "Muuda kasutajat",
|
||||||
|
"prefs_users_delete_button": "Kustuta kasutaja",
|
||||||
|
"prefs_users_table_cannot_delete_or_edit": "Sisselogitud kasutajat ei saa kustutada ega muuta",
|
||||||
|
"prefs_users_table_base_url_header": "Teenuse võrguaadress",
|
||||||
|
"prefs_users_dialog_title_add": "Lisa kasutaja",
|
||||||
|
"prefs_users_dialog_title_edit": "Muuda kasutajat",
|
||||||
|
"prefs_users_dialog_base_url_label": "Teenuse võrguaadress, nt. https://ntfy.sh",
|
||||||
|
"prefs_users_dialog_username_label": "Kasutajanimi, nt. kadri",
|
||||||
|
"prefs_notifications_delete_after_three_hours": "Kolme tunni möödumisel",
|
||||||
|
"prefs_notifications_delete_after_three_hours_description": "Teavitused kustutatakse automaatselt kolme tunni möödumisel",
|
||||||
|
"prefs_notifications_delete_after_one_day_description": "Teavitused kustutatakse automaatselt ühe päeva möödumisel",
|
||||||
|
"prefs_notifications_delete_after_one_week_description": "Teavitused kustutatakse automaatselt ühe nädala möödumisel",
|
||||||
|
"prefs_notifications_delete_after_one_month_description": "Teavitused kustutatakse automaatselt ühe kuu möödumisel",
|
||||||
|
"prefs_notifications_delete_after_never_description": "Mitte kunagi ei kustutata teavitusi automaatselt",
|
||||||
|
"prefs_notifications_delete_after_title": "Kustuta teavitused",
|
||||||
|
"publish_dialog_delay_placeholder": "Viivitus teavituse edastamisel, nt. {{unixTimestamp}}, {{relativeTime}} või „{{naturalLanguage}}“ (vaid inglise keeles)",
|
||||||
|
"account_basics_password_dialog_new_password_label": "Uus salasõna",
|
||||||
|
"account_basics_password_dialog_confirm_password_label": "Korda salasõna",
|
||||||
|
"account_basics_phone_numbers_dialog_description": "Kõneteavituse kasutamiseks pead lisama ja kinnitama vähemalt ühe telefoninumbri. Kinnitamist saad teha SMS-i või kõne abil.",
|
||||||
|
"account_basics_phone_numbers_dialog_number_placeholder": "nt. +37256123456",
|
||||||
|
"account_basics_phone_numbers_no_phone_numbers_yet": "Telefoninumbreid veel pole",
|
||||||
|
"account_basics_phone_numbers_copied_to_clipboard": "Telefoninumber on kopeeritud lõikelauale",
|
||||||
|
"account_basics_phone_numbers_dialog_title": "Lisa telefoninumber",
|
||||||
|
"account_basics_phone_numbers_dialog_number_label": "Telefoninumber",
|
||||||
|
"prefs_notifications_delete_after_one_week": "Ühe nädala möödumisel",
|
||||||
|
"prefs_notifications_delete_after_one_day": "Ühe päeva möödumisel",
|
||||||
|
"prefs_notifications_delete_after_one_month": "Ühe kuu möödumisel"
|
||||||
|
}
|
||||||
58
web/public/static/langs/fa.json
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"signup_title": "ایجاد اکانت ntfy",
|
||||||
|
"signup_form_button_submit": "ثبت نام",
|
||||||
|
"signup_already_have_account": "قبلا اکانت دارید؟ وارد بشود",
|
||||||
|
"signup_disabled": "ثبت نام غیرفعال است",
|
||||||
|
"login_title": "ورود به اکانت ntfy",
|
||||||
|
"login_link_signup": "ثبت نام",
|
||||||
|
"login_disabled": "ورود غیرفعال است",
|
||||||
|
"action_bar_show_menu": "نمایش منو",
|
||||||
|
"action_bar_account": "اکانت",
|
||||||
|
"action_bar_reservation_limit_reached": "دسترسی محدود",
|
||||||
|
"action_bar_send_test_notification": "ارسال تستی اعلان",
|
||||||
|
"action_bar_unmute_notifications": "لغو ساکت کردن اعلان ها",
|
||||||
|
"action_bar_unsubscribe": "لغو اشتراک",
|
||||||
|
"action_bar_toggle_mute": "بی صدا/لغو اعلان ها",
|
||||||
|
"common_cancel": "لغو",
|
||||||
|
"common_save": "ذخیره",
|
||||||
|
"common_add": "اضافه کردن",
|
||||||
|
"common_back": "عقب",
|
||||||
|
"common_copy_to_clipboard": "کپی به کلیپ بورد",
|
||||||
|
"signup_form_username": "نام کاربری",
|
||||||
|
"signup_form_password": "کلمه عبور",
|
||||||
|
"signup_form_confirm_password": "تایید پسورد",
|
||||||
|
"signup_form_toggle_password_visibility": "تغییر وضعیت نمایش کلمه عبور",
|
||||||
|
"signup_error_username_taken": "نام کاربری {{username}} قبلا استفاده شده است",
|
||||||
|
"signup_error_creation_limit_reached": "به حد مجاز ایجاد حساب رسیده است",
|
||||||
|
"login_form_button_submit": "ورود",
|
||||||
|
"action_bar_logo_alt": "لوگوی ntfy",
|
||||||
|
"action_bar_settings": "تنظیمات",
|
||||||
|
"action_bar_change_display_name": "تغییر نام نمایشی",
|
||||||
|
"action_bar_reservation_add": "رزرو موضوع",
|
||||||
|
"action_bar_reservation_edit": "تغییر رزرو",
|
||||||
|
"action_bar_reservation_delete": "حذف رزرو",
|
||||||
|
"action_bar_mute_notifications": "ساکت کردن اعلان ها",
|
||||||
|
"action_bar_clear_notifications": "پاک کردن تمام اعلان ها",
|
||||||
|
"action_bar_toggle_action_menu": "گشودن يا بستن فهرست کنش",
|
||||||
|
"action_bar_profile_title": "نمايه",
|
||||||
|
"action_bar_profile_settings": "تنظیمات",
|
||||||
|
"action_bar_profile_logout": "خروج",
|
||||||
|
"action_bar_sign_in": "ورود",
|
||||||
|
"action_bar_sign_up": "ثبت نام",
|
||||||
|
"message_bar_type_message": "یک پیام بنویسید",
|
||||||
|
"message_bar_error_publishing": "خطا در انتظار اعلان",
|
||||||
|
"message_bar_publish": "انتشار پیام",
|
||||||
|
"nav_button_all_notifications": "همه اعلانها",
|
||||||
|
"nav_button_account": "حساب کاربری",
|
||||||
|
"nav_button_settings": "تنظیمات",
|
||||||
|
"nav_button_documentation": "مستندات",
|
||||||
|
"nav_button_publish_message": "انتشار اعلان",
|
||||||
|
"nav_button_muted": "اعلان بیصدا شد",
|
||||||
|
"nav_button_connecting": "در حال اتصال",
|
||||||
|
"nav_upgrade_banner_label": "ارتقا با ntfy پیشرفته",
|
||||||
|
"alert_notification_permission_required_title": "اعلانها غیرفعال هستند",
|
||||||
|
"alert_notification_permission_required_description": "به مرورگر خود اجازه دهید تا اعلانهای دسکتاپ را نمایش دهد",
|
||||||
|
"alert_notification_permission_denied_title": "اعلانها مسدود هستند",
|
||||||
|
"alert_notification_ios_install_required_title": "لازم به نصب نسخه iOS است",
|
||||||
|
"alert_notification_ios_install_required_description": "برای فعال کردن اعلانها در iOS، روی نماد اشتراکگذاری و افزودن به صفحه اصلی کلیک کنید"
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"publish_dialog_message_placeholder": "Kirjoita viesti tähän",
|
"publish_dialog_message_placeholder": "Kirjoita viesti tähän",
|
||||||
"account_upgrade_dialog_tier_features_no_calls": "Ei puheluita",
|
"account_upgrade_dialog_tier_features_no_calls": "Ei puheluita",
|
||||||
"account_upgrade_dialog_billing_contact_email": "Laskutukseen liittyvissä kysymyksissä <Link>contact us</Link> suoraan.",
|
"account_upgrade_dialog_billing_contact_email": "Laskutukseen liittyvissä kysymyksissä <Link>ole yhteydessä</Link> .",
|
||||||
"account_tokens_dialog_title_create": "Luo käyttöoikeustunnus",
|
"account_tokens_dialog_title_create": "Luo käyttöoikeustunnus",
|
||||||
"prefs_reservations_dialog_title_edit": "Muokkaa varattua topikkia",
|
"prefs_reservations_dialog_title_edit": "Muokkaa varattua topikkia",
|
||||||
"account_basics_tier_interval_monthly": "Kuukausittain",
|
"account_basics_tier_interval_monthly": "Kuukausittain",
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"prefs_notifications_min_priority_title": "Vähimmäisprioriteetti",
|
"prefs_notifications_min_priority_title": "Vähimmäisprioriteetti",
|
||||||
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} päivittäisiä puheluja",
|
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} päivittäisiä puheluja",
|
||||||
"account_upgrade_dialog_tier_current_label": "Nykyinen",
|
"account_upgrade_dialog_tier_current_label": "Nykyinen",
|
||||||
"action_bar_account": "Kirjautuminen",
|
"action_bar_account": "Tili",
|
||||||
"publish_dialog_filename_placeholder": "Liitetiedoston nimi",
|
"publish_dialog_filename_placeholder": "Liitetiedoston nimi",
|
||||||
"account_basics_password_dialog_current_password_incorrect": "Salasana virheellinen",
|
"account_basics_password_dialog_current_password_incorrect": "Salasana virheellinen",
|
||||||
"account_tokens_table_token_header": "Token",
|
"account_tokens_table_token_header": "Token",
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"prefs_users_description": "Lisää/poista käyttäjiä suojatuista topikeista täällä. Huomaa, että käyttäjätunnus ja salasana on tallennettu selaimen paikalliseen tallennustilaan.",
|
"prefs_users_description": "Lisää/poista käyttäjiä suojatuista topikeista täällä. Huomaa, että käyttäjätunnus ja salasana on tallennettu selaimen paikalliseen tallennustilaan.",
|
||||||
"account_basics_phone_numbers_dialog_number_label": "Puhelinnumero",
|
"account_basics_phone_numbers_dialog_number_label": "Puhelinnumero",
|
||||||
"subscribe_dialog_subscribe_description": "Aiheet eivät välttämättä ole salasanasuojattuja, joten valitse nimi, jota ei ole helposti arvatavissa. Kun olet tilannut, voit käyttää PUT/POST ilmoituksia.",
|
"subscribe_dialog_subscribe_description": "Aiheet eivät välttämättä ole salasanasuojattuja, joten valitse nimi, jota ei ole helposti arvatavissa. Kun olet tilannut, voit käyttää PUT/POST ilmoituksia.",
|
||||||
"action_bar_logo_alt": "ntfy logo",
|
"action_bar_logo_alt": "ntfy-logo",
|
||||||
"account_basics_password_dialog_button_submit": "Vaihda salasana",
|
"account_basics_password_dialog_button_submit": "Vaihda salasana",
|
||||||
"publish_dialog_emoji_picker_show": "Valitse emoji",
|
"publish_dialog_emoji_picker_show": "Valitse emoji",
|
||||||
"account_basics_username_title": "Käyttäjätunnus",
|
"account_basics_username_title": "Käyttäjätunnus",
|
||||||
@@ -30,10 +30,10 @@
|
|||||||
"account_tokens_dialog_label": "Etiketti, esim. Tutka-ilmoitukset",
|
"account_tokens_dialog_label": "Etiketti, esim. Tutka-ilmoitukset",
|
||||||
"common_add": "Lisää",
|
"common_add": "Lisää",
|
||||||
"account_tokens_table_expires_header": "Vanhenee",
|
"account_tokens_table_expires_header": "Vanhenee",
|
||||||
"account_upgrade_dialog_proration_info": "<strong>Osuus suhde</strong>: Kun päivität maksullisten pakettien välillä, hintaero <strong>veloitetaan välittömästi</strong>. Kun siirryt alemmalle tasolle, saldoa käytetään tulevien laskutuskausien maksamiseen.",
|
"account_upgrade_dialog_proration_info": "<strong>Osuussuhde</strong>: Kun päivität maksullisten pakettien välillä, hintaero <strong>veloitetaan välittömästi</strong>. Kun siirryt alemmalle tasolle, saldoa käytetään tulevien laskutuskausien maksamiseen.",
|
||||||
"prefs_reservations_dialog_access_label": "Oikeudet",
|
"prefs_reservations_dialog_access_label": "Oikeudet",
|
||||||
"account_usage_attachment_storage_title": "Liiteiden säilytys",
|
"account_usage_attachment_storage_title": "Liiteiden säilytys",
|
||||||
"prefs_users_dialog_username_label": "Username, esim pena",
|
"prefs_users_dialog_username_label": "Käyttäjätunnus, esim. pentti",
|
||||||
"message_bar_error_publishing": "Virhe ilmoituksen julkaisemisessa",
|
"message_bar_error_publishing": "Virhe ilmoituksen julkaisemisessa",
|
||||||
"publish_dialog_chip_delay_label": "Viivästytä toimitusta",
|
"publish_dialog_chip_delay_label": "Viivästytä toimitusta",
|
||||||
"account_usage_messages_title": "Julkaistut viestit",
|
"account_usage_messages_title": "Julkaistut viestit",
|
||||||
@@ -42,9 +42,9 @@
|
|||||||
"prefs_reservations_table_not_subscribed": "Ei tilattu",
|
"prefs_reservations_table_not_subscribed": "Ei tilattu",
|
||||||
"publish_dialog_topic_placeholder": "Topikin nimi, esim. erkin_hälyt",
|
"publish_dialog_topic_placeholder": "Topikin nimi, esim. erkin_hälyt",
|
||||||
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} päivittäisiä emaileja",
|
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} päivittäisiä emaileja",
|
||||||
"prefs_notifications_min_priority_max_only": "Vain maksimi prioriteetti",
|
"prefs_notifications_min_priority_max_only": "Vain maksimiprioriteetti",
|
||||||
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} päivittäisiä puheluja",
|
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} päivittäisiä puheluja",
|
||||||
"prefs_notifications_sound_description_some": "Ilmoitukset soittavat {{sound}} äänen saapuessaan",
|
"prefs_notifications_sound_description_some": "Ilmoitukset soittavat {{sound}}-äänen saapuessaan",
|
||||||
"prefs_reservations_edit_button": "Muokkaa topikin oikeuksia",
|
"prefs_reservations_edit_button": "Muokkaa topikin oikeuksia",
|
||||||
"account_basics_phone_numbers_dialog_verify_button_sms": "Lähetä SMS",
|
"account_basics_phone_numbers_dialog_verify_button_sms": "Lähetä SMS",
|
||||||
"account_basics_tier_change_button": "Vaihda",
|
"account_basics_tier_change_button": "Vaihda",
|
||||||
@@ -84,19 +84,19 @@
|
|||||||
"subscribe_dialog_error_user_not_authorized": "Käyttäjää {{username}} ei ole valtuutettu",
|
"subscribe_dialog_error_user_not_authorized": "Käyttäjää {{username}} ei ole valtuutettu",
|
||||||
"prefs_reservations_table_everyone_read_write": "Jokainen voi julkaista ja tilata",
|
"prefs_reservations_table_everyone_read_write": "Jokainen voi julkaista ja tilata",
|
||||||
"prefs_reservations_dialog_title_delete": "Poista topikin varaus",
|
"prefs_reservations_dialog_title_delete": "Poista topikin varaus",
|
||||||
"prefs_users_table": "Käyttäjä taulukko",
|
"prefs_users_table": "Käyttäjätaulukko",
|
||||||
"prefs_reservations_table_topic_header": "Topikki",
|
"prefs_reservations_table_topic_header": "Topikki",
|
||||||
"action_bar_toggle_mute": "Hiljennä/poista hiljennys",
|
"action_bar_toggle_mute": "Mykistä/palauta ilmoitukset",
|
||||||
"reservation_delete_dialog_submit_button": "Poista varaus",
|
"reservation_delete_dialog_submit_button": "Poista varaus",
|
||||||
"account_basics_title": "Tili",
|
"account_basics_title": "Tili",
|
||||||
"nav_button_documentation": "Dokumentointi",
|
"nav_button_documentation": "Dokumentaatio",
|
||||||
"prefs_reservations_limit_reached": "Olet saavuttanut varattujen topikkien rajan.",
|
"prefs_reservations_limit_reached": "Olet saavuttanut varattujen topikkien rajan.",
|
||||||
"account_upgrade_dialog_interval_monthly": "Kuukausittain",
|
"account_upgrade_dialog_interval_monthly": "Kuukausittain",
|
||||||
"prefs_users_add_button": "Lisää käyttäjä",
|
"prefs_users_add_button": "Lisää käyttäjä",
|
||||||
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} päivittäisiä viestejä",
|
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} päivittäisiä viestejä",
|
||||||
"publish_dialog_delay_reset": "Poista viivästetty toimitus",
|
"publish_dialog_delay_reset": "Poista viivästetty toimitus",
|
||||||
"account_basics_phone_numbers_no_phone_numbers_yet": "Ei puhelinnumeroita vielä",
|
"account_basics_phone_numbers_no_phone_numbers_yet": "Ei puhelinnumeroita vielä",
|
||||||
"action_bar_toggle_action_menu": "Avaa/sulje toiminto valikko",
|
"action_bar_toggle_action_menu": "Avaa/sulje toimintovalikko",
|
||||||
"subscribe_dialog_subscribe_button_generate_topic_name": "Luo nimi",
|
"subscribe_dialog_subscribe_button_generate_topic_name": "Luo nimi",
|
||||||
"notifications_list_item": "Ilmoitus",
|
"notifications_list_item": "Ilmoitus",
|
||||||
"prefs_appearance_language_title": "Kieli",
|
"prefs_appearance_language_title": "Kieli",
|
||||||
@@ -116,10 +116,10 @@
|
|||||||
"account_tokens_table_label_header": "Merkki",
|
"account_tokens_table_label_header": "Merkki",
|
||||||
"notifications_attachment_file_document": "muu asiakirja",
|
"notifications_attachment_file_document": "muu asiakirja",
|
||||||
"publish_dialog_button_cancel": "Peruuta",
|
"publish_dialog_button_cancel": "Peruuta",
|
||||||
"account_upgrade_dialog_billing_contact_website": "Laskutukseen liittyvissä kysymyksissä käy <Link>website</Link>.",
|
"account_upgrade_dialog_billing_contact_website": "Laskutukseen liittyvissä kysymyksissä käy <Link>verkkosivustolla</Link>.",
|
||||||
"signup_form_button_submit": "Kirjaudu linkki",
|
"signup_form_button_submit": "Rekisteröidy",
|
||||||
"account_basics_username_admin_tooltip": "Olet pääkäyttäjä",
|
"account_basics_username_admin_tooltip": "Olet pääkäyttäjä",
|
||||||
"prefs_notifications_delete_after_never_description": "Ilmoituksia eivät koskaan poisteta automaattisesti",
|
"prefs_notifications_delete_after_never_description": "Ilmoituksia ei koskaan poisteta automaattisesti",
|
||||||
"account_delete_dialog_description": "Tämä poistaa pysyvästi tilisi, mukaan lukien kaikki palvelimelle tallennetut tiedot. Poistamisen jälkeen käyttäjätunnuksesi on poissa käytöstä 7 päivään. Jos todella haluat jatkaa, vahvista salasanasi alla olevaan kenttään.",
|
"account_delete_dialog_description": "Tämä poistaa pysyvästi tilisi, mukaan lukien kaikki palvelimelle tallennetut tiedot. Poistamisen jälkeen käyttäjätunnuksesi on poissa käytöstä 7 päivään. Jos todella haluat jatkaa, vahvista salasanasi alla olevaan kenttään.",
|
||||||
"publish_dialog_email_reset": "Poista sähköpostin edelleenlähetys",
|
"publish_dialog_email_reset": "Poista sähköpostin edelleenlähetys",
|
||||||
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} varatut topikit",
|
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} varatut topikit",
|
||||||
@@ -139,17 +139,17 @@
|
|||||||
"prefs_reservations_description": "Voit varata topikien nimiä henkilökohtaiseen käyttöön täältä. Aiheen varaaminen antaa sinulle topikin omistajuuden ja voit määrittää topikkiin liittyviä käyttöoikeuksia muille käyttäjille.",
|
"prefs_reservations_description": "Voit varata topikien nimiä henkilökohtaiseen käyttöön täältä. Aiheen varaaminen antaa sinulle topikin omistajuuden ja voit määrittää topikkiin liittyviä käyttöoikeuksia muille käyttäjille.",
|
||||||
"notifications_attachment_copy_url_title": "Kopioi liitteen URL-osoite leikepöydälle",
|
"notifications_attachment_copy_url_title": "Kopioi liitteen URL-osoite leikepöydälle",
|
||||||
"account_usage_title": "Käytössä",
|
"account_usage_title": "Käytössä",
|
||||||
"account_basics_tier_upgrade_button": "Päivitä Pro versioon",
|
"account_basics_tier_upgrade_button": "Päivitä Pro-versioon",
|
||||||
"prefs_users_description_no_sync": "Käyttäjiä ja salasanoja ei ole synkronoitu tiliisi.",
|
"prefs_users_description_no_sync": "Käyttäjiä ja salasanoja ei ole synkronoitu tiliisi.",
|
||||||
"account_tokens_dialog_title_edit": "Muokkaa käyttöoikeustunnusta",
|
"account_tokens_dialog_title_edit": "Muokkaa käyttöoikeustunnusta",
|
||||||
"nav_button_publish_message": "Julkaise ilmoitus",
|
"nav_button_publish_message": "Julkaise ilmoitus",
|
||||||
"prefs_users_table_base_url_header": "Palvelin URL",
|
"prefs_users_table_base_url_header": "Palvelun URL",
|
||||||
"notifications_click_copy_url_title": "Kopioi linkin URL-osoite leikepöydälle",
|
"notifications_click_copy_url_title": "Kopioi linkin URL-osoite leikepöydälle",
|
||||||
"publish_dialog_attach_reset": "Poista liitteen URL-osoite",
|
"publish_dialog_attach_reset": "Poista liitteen URL-osoite",
|
||||||
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} päivittäisiä viestejä",
|
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} päivittäisiä viestejä",
|
||||||
"account_upgrade_dialog_reservations_warning_one": "Valittu taso sallii vähemmän varattuja topikeita kuin nykyinen tasosi. Ennen kuin muutat tasosi, <strong>poista vähintään yksi varaus</strong>. Voit poistaa varauksia <Link>Asetuksista</Link>.",
|
"account_upgrade_dialog_reservations_warning_one": "Valittu taso sallii vähemmän varattuja topikeita kuin nykyinen tasosi. Ennen kuin muutat tasosi, <strong>poista vähintään yksi varaus</strong>. Voit poistaa varauksia <Link>Asetuksista</Link>.",
|
||||||
"common_copy_to_clipboard": "Kopioi leikkelepöydälle",
|
"common_copy_to_clipboard": "Kopioi leikepöydälle",
|
||||||
"alert_not_supported_description": "Selaimesi ei tue ilmoituksia.",
|
"alert_not_supported_description": "Selaimesi ei tue ilmoituksia",
|
||||||
"subscribe_dialog_error_topic_already_reserved": "Topikki on jo varattu",
|
"subscribe_dialog_error_topic_already_reserved": "Topikki on jo varattu",
|
||||||
"message_bar_publish": "Julkaise viesti",
|
"message_bar_publish": "Julkaise viesti",
|
||||||
"alert_grant_description": "Myönnä selaimelle lupa näyttää työpöytäilmoituksia.",
|
"alert_grant_description": "Myönnä selaimelle lupa näyttää työpöytäilmoituksia.",
|
||||||
@@ -160,7 +160,7 @@
|
|||||||
"publish_dialog_priority_low": "Matala tärkeys",
|
"publish_dialog_priority_low": "Matala tärkeys",
|
||||||
"publish_dialog_priority_label": "Prioriteetti",
|
"publish_dialog_priority_label": "Prioriteetti",
|
||||||
"prefs_reservations_delete_button": "Poista topikin oikeudet",
|
"prefs_reservations_delete_button": "Poista topikin oikeudet",
|
||||||
"account_basics_tier_admin_suffix_no_tier": "(e tasoa)",
|
"account_basics_tier_admin_suffix_no_tier": "(ei tasoa)",
|
||||||
"prefs_notifications_delete_after_one_week_description": "Ilmoitukset poistetaan automaattisesti viikon kuluttua",
|
"prefs_notifications_delete_after_one_week_description": "Ilmoitukset poistetaan automaattisesti viikon kuluttua",
|
||||||
"error_boundary_unsupported_indexeddb_description": "Ntfy-verkkosovellus tarvitsee IndexedDB:n toimiakseen, eikä selaimesi tue IndexedDB:tä yksityisessä selaustilassa.<br/><br/>Vaikka tämä on valitettavaa, ntfy-verkon käyttäminen ei myöskään ole kovin järkevää yksityisessä selaustilassa, koska kaikki on tallennettu selaimen tallennustilaan. Voit lukea siitä lisää <githubLink>tästä GitHub-numerosta</githubLink> tai puhua meille <discordLink>Discordissa</discordLink> tai <matrixLink>Matrixissa</matrixLink>.",
|
"error_boundary_unsupported_indexeddb_description": "Ntfy-verkkosovellus tarvitsee IndexedDB:n toimiakseen, eikä selaimesi tue IndexedDB:tä yksityisessä selaustilassa.<br/><br/>Vaikka tämä on valitettavaa, ntfy-verkon käyttäminen ei myöskään ole kovin järkevää yksityisessä selaustilassa, koska kaikki on tallennettu selaimen tallennustilaan. Voit lukea siitä lisää <githubLink>tästä GitHub-numerosta</githubLink> tai puhua meille <discordLink>Discordissa</discordLink> tai <matrixLink>Matrixissa</matrixLink>.",
|
||||||
"subscribe_dialog_subscribe_button_cancel": "Peruuta",
|
"subscribe_dialog_subscribe_button_cancel": "Peruuta",
|
||||||
@@ -170,7 +170,7 @@
|
|||||||
"account_basics_tier_description": "Tilisi taso",
|
"account_basics_tier_description": "Tilisi taso",
|
||||||
"account_basics_phone_numbers_description": "Puheluilmoituksia varten",
|
"account_basics_phone_numbers_description": "Puheluilmoituksia varten",
|
||||||
"prefs_reservations_dialog_title_add": "Varaa topikki",
|
"prefs_reservations_dialog_title_add": "Varaa topikki",
|
||||||
"account_basics_tier_free": "Vapaa",
|
"account_basics_tier_free": "Maksuton",
|
||||||
"account_upgrade_dialog_cancel_warning": "Tämä <strong>peruuttaa tilauksesi</strong> ja alentaa tilisi {{date}}. Tuona päivänä topikit sekä palvelimen välimuistissa olevat viestit <strong>poistetaan</strong>.",
|
"account_upgrade_dialog_cancel_warning": "Tämä <strong>peruuttaa tilauksesi</strong> ja alentaa tilisi {{date}}. Tuona päivänä topikit sekä palvelimen välimuistissa olevat viestit <strong>poistetaan</strong>.",
|
||||||
"notifications_click_copy_url_button": "Kopioi linkki",
|
"notifications_click_copy_url_button": "Kopioi linkki",
|
||||||
"account_basics_tier_admin": "Admin",
|
"account_basics_tier_admin": "Admin",
|
||||||
@@ -179,7 +179,7 @@
|
|||||||
"prefs_notifications_sound_title": "Ilmoitusääni",
|
"prefs_notifications_sound_title": "Ilmoitusääni",
|
||||||
"prefs_notifications_min_priority_default_and_higher": "Oletusprioriteetti ja korkeammat",
|
"prefs_notifications_min_priority_default_and_higher": "Oletusprioriteetti ja korkeammat",
|
||||||
"prefs_reservations_table_access_header": "Oikeudet",
|
"prefs_reservations_table_access_header": "Oikeudet",
|
||||||
"action_bar_show_menu": "Näytä menu",
|
"action_bar_show_menu": "Näytä valikko",
|
||||||
"action_bar_settings": "Asetukset",
|
"action_bar_settings": "Asetukset",
|
||||||
"notifications_copied_to_clipboard": "Kopioitu leikepöydälle",
|
"notifications_copied_to_clipboard": "Kopioitu leikepöydälle",
|
||||||
"account_delete_dialog_button_cancel": "Peruuta",
|
"account_delete_dialog_button_cancel": "Peruuta",
|
||||||
@@ -196,15 +196,15 @@
|
|||||||
"publish_dialog_call_label": "Puhelu",
|
"publish_dialog_call_label": "Puhelu",
|
||||||
"account_usage_calls_title": "Soitetut puhelut",
|
"account_usage_calls_title": "Soitetut puhelut",
|
||||||
"error_boundary_description": "Näin ei selvästikään pitäisi tapahtua. Pahoittelut tästä.<br/>Jos sinulla on hetki aikaa, <githubLink>ilmoita tästä GitHubissa</githubLink> tai ilmoita meille <discordLink>Discordin</discordLink> tai <matrixLink>Matrix</matrixLink> kautta.",
|
"error_boundary_description": "Näin ei selvästikään pitäisi tapahtua. Pahoittelut tästä.<br/>Jos sinulla on hetki aikaa, <githubLink>ilmoita tästä GitHubissa</githubLink> tai ilmoita meille <discordLink>Discordin</discordLink> tai <matrixLink>Matrix</matrixLink> kautta.",
|
||||||
"signup_form_toggle_password_visibility": "Vaihda salasanan näkyvyys",
|
"signup_form_toggle_password_visibility": "Näytä/piilota salasana",
|
||||||
"login_link_signup": "Kirjaudu linkki",
|
"login_link_signup": "Rekisteröidy",
|
||||||
"publish_dialog_message_label": "Viesti",
|
"publish_dialog_message_label": "Viesti",
|
||||||
"publish_dialog_attached_file_title": "Liitetiedosto:",
|
"publish_dialog_attached_file_title": "Liitetiedosto:",
|
||||||
"priority_min": "min",
|
"priority_min": "min",
|
||||||
"action_bar_sign_in": "Kirjaudu sisään",
|
"action_bar_sign_in": "Kirjaudu sisään",
|
||||||
"action_bar_unsubscribe": "Peruuta tilaus",
|
"action_bar_unsubscribe": "Peruuta tilaus",
|
||||||
"account_basics_tier_basic": "Perus",
|
"account_basics_tier_basic": "Perus",
|
||||||
"signup_title": "Lisää ntfy tili",
|
"signup_title": "Luo ntfy-tili",
|
||||||
"prefs_notifications_min_priority_description_any": "Näytetään kaikki ilmoitukset tärkeydestä riippumatta",
|
"prefs_notifications_min_priority_description_any": "Näytetään kaikki ilmoitukset tärkeydestä riippumatta",
|
||||||
"error_boundary_gathering_info": "Kerää lisätietoja…",
|
"error_boundary_gathering_info": "Kerää lisätietoja…",
|
||||||
"publish_dialog_priority_max": "Max. prioriteetti",
|
"publish_dialog_priority_max": "Max. prioriteetti",
|
||||||
@@ -254,7 +254,7 @@
|
|||||||
"publish_dialog_attach_placeholder": "Liitä tiedosto URL-osoitteen mukaan, esim. https://f-droid.org/F-Droid.apk",
|
"publish_dialog_attach_placeholder": "Liitä tiedosto URL-osoitteen mukaan, esim. https://f-droid.org/F-Droid.apk",
|
||||||
"publish_dialog_email_placeholder": "Osoite, johon ilmoitus välitetään, esim. urpo@example.com",
|
"publish_dialog_email_placeholder": "Osoite, johon ilmoitus välitetään, esim. urpo@example.com",
|
||||||
"notifications_attachment_link_expires": "linkki vanhenee {{date}}",
|
"notifications_attachment_link_expires": "linkki vanhenee {{date}}",
|
||||||
"action_bar_send_test_notification": "Lähetä testi ilmoitus",
|
"action_bar_send_test_notification": "Lähetä testi-ilmoitus",
|
||||||
"reservation_delete_dialog_action_keep_title": "Säilytä välimuistissa olevat viestit ja liitteet",
|
"reservation_delete_dialog_action_keep_title": "Säilytä välimuistissa olevat viestit ja liitteet",
|
||||||
"prefs_notifications_sound_no_sound": "Ei ääntä",
|
"prefs_notifications_sound_no_sound": "Ei ääntä",
|
||||||
"account_upgrade_dialog_interval_yearly": "Vuosittain",
|
"account_upgrade_dialog_interval_yearly": "Vuosittain",
|
||||||
@@ -266,7 +266,7 @@
|
|||||||
"alert_not_supported_title": "Ilmoituksia ei tueta",
|
"alert_not_supported_title": "Ilmoituksia ei tueta",
|
||||||
"account_tokens_dialog_button_cancel": "Peruuta",
|
"account_tokens_dialog_button_cancel": "Peruuta",
|
||||||
"subscribe_dialog_error_user_anonymous": "Anonyymi",
|
"subscribe_dialog_error_user_anonymous": "Anonyymi",
|
||||||
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} laskutetaan vuosittain. Tallenna {{save}}.",
|
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} laskutetaan vuosittain. Säästä {{save}}.",
|
||||||
"prefs_notifications_min_priority_high_and_higher": "Korkea prioriteetti ja korkeammat",
|
"prefs_notifications_min_priority_high_and_higher": "Korkea prioriteetti ja korkeammat",
|
||||||
"account_usage_basis_ip_description": "Tämän tilin käyttötilastot ja rajoitukset perustuvat IP-osoitteeseesi, joten ne voidaan jakaa muiden käyttäjien kanssa. Yllä esitetyt rajat ovat likimääräisiä perustuen olemassa oleviin rajoituksiin.",
|
"account_usage_basis_ip_description": "Tämän tilin käyttötilastot ja rajoitukset perustuvat IP-osoitteeseesi, joten ne voidaan jakaa muiden käyttäjien kanssa. Yllä esitetyt rajat ovat likimääräisiä perustuen olemassa oleviin rajoituksiin.",
|
||||||
"publish_dialog_priority_high": "Korkea prioriteetti",
|
"publish_dialog_priority_high": "Korkea prioriteetti",
|
||||||
@@ -285,7 +285,7 @@
|
|||||||
"account_basics_phone_numbers_title": "Puhelinnumerot",
|
"account_basics_phone_numbers_title": "Puhelinnumerot",
|
||||||
"prefs_notifications_delete_after_title": "Poista ilmoitukset",
|
"prefs_notifications_delete_after_title": "Poista ilmoitukset",
|
||||||
"account_upgrade_dialog_interval_yearly_discount_save": "säästä {{discount}}%",
|
"account_upgrade_dialog_interval_yearly_discount_save": "säästä {{discount}}%",
|
||||||
"signup_disabled": "Kirjautuminen estetty",
|
"signup_disabled": "Rekisteröityminen estetty",
|
||||||
"publish_dialog_drop_file_here": "Pudota tiedosto tähän",
|
"publish_dialog_drop_file_here": "Pudota tiedosto tähän",
|
||||||
"prefs_users_dialog_title_edit": "Muokkaa käyttäjää",
|
"prefs_users_dialog_title_edit": "Muokkaa käyttäjää",
|
||||||
"account_basics_password_dialog_current_password_label": "Nykyinen salasana",
|
"account_basics_password_dialog_current_password_label": "Nykyinen salasana",
|
||||||
@@ -295,20 +295,20 @@
|
|||||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} lopullinen tiedostokoko",
|
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} lopullinen tiedostokoko",
|
||||||
"publish_dialog_title_label": "Otsikko",
|
"publish_dialog_title_label": "Otsikko",
|
||||||
"prefs_reservations_table_everyone_write_only": "Minä voin julkaista ja tilata, kaikki voivat julkaista",
|
"prefs_reservations_table_everyone_write_only": "Minä voin julkaista ja tilata, kaikki voivat julkaista",
|
||||||
"prefs_appearance_title": "Näkymä",
|
"prefs_appearance_title": "Ulkoasu",
|
||||||
"publish_dialog_topic_reset": "Resetoi topikki",
|
"publish_dialog_topic_reset": "Resetoi topikki",
|
||||||
"account_tokens_table_cannot_delete_or_edit": "Nykyistä istuntotunnusta ei voi muokata tai poistaa",
|
"account_tokens_table_cannot_delete_or_edit": "Nykyistä istuntotunnusta ei voi muokata tai poistaa",
|
||||||
"notifications_tags": "Tagit",
|
"notifications_tags": "Tagit",
|
||||||
"prefs_notifications_sound_play": "Toista valittu ääni",
|
"prefs_notifications_sound_play": "Toista valittu ääni",
|
||||||
"account_tokens_table_last_access_header": "Viimeinen käyty",
|
"account_tokens_table_last_access_header": "Viimeinen käynti",
|
||||||
"action_bar_profile_logout": "Kirjaudu ulos",
|
"action_bar_profile_logout": "Kirjaudu ulos",
|
||||||
"publish_dialog_attached_file_filename_placeholder": "Liitetiedoston nimi",
|
"publish_dialog_attached_file_filename_placeholder": "Liitetiedoston nimi",
|
||||||
"publish_dialog_priority_default": "Oletusprioriteetti",
|
"publish_dialog_priority_default": "Oletusprioriteetti",
|
||||||
"subscribe_dialog_subscribe_base_url_label": "Palvelimen URL",
|
"subscribe_dialog_subscribe_base_url_label": "Palvelimen URL",
|
||||||
"account_tokens_table_last_origin_tooltip": "Napsauta IP-osoitteesta {{ip}}, etsiäksesi",
|
"account_tokens_table_last_origin_tooltip": "Napsauta IP-osoitteesta {{ip}} etsiäksesi",
|
||||||
"account_usage_reservations_title": "Varatut topikit",
|
"account_usage_reservations_title": "Varatut topikit",
|
||||||
"account_upgrade_dialog_tier_price_per_month": "Kuukausi",
|
"account_upgrade_dialog_tier_price_per_month": "Kuukausi",
|
||||||
"message_bar_show_dialog": "Näytä julkaisu dialogi",
|
"message_bar_show_dialog": "Näytä julkaisudialogi",
|
||||||
"publish_dialog_chip_attach_url_label": "Liitä tiedosto URL-osoitteen mukaan",
|
"publish_dialog_chip_attach_url_label": "Liitä tiedosto URL-osoitteen mukaan",
|
||||||
"account_usage_calls_none": "Tällä tilillä ei voi soittaa puheluita",
|
"account_usage_calls_none": "Tällä tilillä ei voi soittaa puheluita",
|
||||||
"notifications_click_open_button": "Avaa linkki",
|
"notifications_click_open_button": "Avaa linkki",
|
||||||
@@ -331,14 +331,14 @@
|
|||||||
"prefs_notifications_delete_after_one_month_description": "Ilmoitukset poistetaan automaattisesti kuukauden kuluttua",
|
"prefs_notifications_delete_after_one_month_description": "Ilmoitukset poistetaan automaattisesti kuukauden kuluttua",
|
||||||
"common_cancel": "Peruuta",
|
"common_cancel": "Peruuta",
|
||||||
"account_basics_phone_numbers_dialog_verify_button_call": "Soita minulle",
|
"account_basics_phone_numbers_dialog_verify_button_call": "Soita minulle",
|
||||||
"signup_already_have_account": "Onko sinulla jo tili ? Kirjaudu sisään !",
|
"signup_already_have_account": "Onko sinulla jo tili? Kirjaudu sisään!",
|
||||||
"publish_dialog_call_item": "Soita puhelinnumeroon {{number}}",
|
"publish_dialog_call_item": "Soita puhelinnumeroon {{number}}",
|
||||||
"nav_button_account": "Tili",
|
"nav_button_account": "Tili",
|
||||||
"publish_dialog_click_reset": "Poista napsautettava URL-osoite",
|
"publish_dialog_click_reset": "Poista napsautettava URL-osoite",
|
||||||
"login_title": "Kirjaudu sisään ntfy-tilillesi",
|
"login_title": "Kirjaudu sisään ntfy-tilillesi",
|
||||||
"notifications_list": "Ilmoitusluettelo",
|
"notifications_list": "Ilmoitusluettelo",
|
||||||
"common_save": "Tallenna",
|
"common_save": "Tallenna",
|
||||||
"prefs_users_dialog_base_url_label": "Palvelin URL, esim. https://ntfy.sh",
|
"prefs_users_dialog_base_url_label": "Palvelun URL, esim. https://ntfy.sh",
|
||||||
"account_usage_emails_title": "Sähköpostit lähetetty",
|
"account_usage_emails_title": "Sähköpostit lähetetty",
|
||||||
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
|
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
|
||||||
"action_bar_reservation_add": "Varalla oleva aihe",
|
"action_bar_reservation_add": "Varalla oleva aihe",
|
||||||
@@ -349,7 +349,7 @@
|
|||||||
"notifications_priority_x": "Prioriteetti {{priority}}",
|
"notifications_priority_x": "Prioriteetti {{priority}}",
|
||||||
"account_delete_dialog_billing_warning": "Tilin poistaminen peruuttaa myös laskutustilauksesi välittömästi. Et voi enää käyttää laskutuksen hallintapaneelia.",
|
"account_delete_dialog_billing_warning": "Tilin poistaminen peruuttaa myös laskutustilauksesi välittömästi. Et voi enää käyttää laskutuksen hallintapaneelia.",
|
||||||
"prefs_notifications_min_priority_description_max": "Näytä ilmoitukset, jos prioriteetti on 5 (max)",
|
"prefs_notifications_min_priority_description_max": "Näytä ilmoitukset, jos prioriteetti on 5 (max)",
|
||||||
"subscribe_dialog_login_description": "Tämä Topikki on suojattu salasanalla. Anna käyttäjätunnus ja salasana.",
|
"subscribe_dialog_login_description": "Tämä topikki on suojattu salasanalla. Anna käyttäjätunnus ja salasana.",
|
||||||
"account_upgrade_dialog_reservations_warning_other": "Valittu taso sallii vähemmän varattuja topikkeja kuin nykyinen tasosi. Ennen kuin muutat tasosi, <strong>poista vähintään {{count}} varausta</strong>. Voit poistaa varauksia <Link>Asetuksista</Link>.",
|
"account_upgrade_dialog_reservations_warning_other": "Valittu taso sallii vähemmän varattuja topikkeja kuin nykyinen tasosi. Ennen kuin muutat tasosi, <strong>poista vähintään {{count}} varausta</strong>. Voit poistaa varauksia <Link>Asetuksista</Link>.",
|
||||||
"prefs_users_dialog_title_add": "Lisää käyttäjä",
|
"prefs_users_dialog_title_add": "Lisää käyttäjä",
|
||||||
"account_tokens_dialog_button_create": "Luo tunnus",
|
"account_tokens_dialog_button_create": "Luo tunnus",
|
||||||
@@ -360,25 +360,51 @@
|
|||||||
"notifications_actions_not_supported": "Toimintoa ei tueta verkkosovelluksessa",
|
"notifications_actions_not_supported": "Toimintoa ei tueta verkkosovelluksessa",
|
||||||
"notifications_actions_open_url_title": "Siirry osoitteeseen {{url}}",
|
"notifications_actions_open_url_title": "Siirry osoitteeseen {{url}}",
|
||||||
"notifications_none_for_any_title": "Et ole saanut ilmoituksia.",
|
"notifications_none_for_any_title": "Et ole saanut ilmoituksia.",
|
||||||
"notifications_none_for_topic_description": "Jos haluat lähettää ilmoituksia tähän topikkiin, PUT tai POST topikin URL-osoitteeseen.",
|
"notifications_none_for_topic_description": "Jos haluat lähettää ilmoituksia tähän topikkiin, lähetä PUT tai POST topikin URL-osoitteeseen.",
|
||||||
"notifications_none_for_any_description": "Jos haluat lähettää ilmoituksia topikkiin, PUT tai POST topikin URL-osoitteeseen. Tässä on esimerkki yhden topikin käyttämisestä.",
|
"notifications_none_for_any_description": "Jos haluat lähettää ilmoituksia topikkiin, PUT tai POST topikin URL-osoitteeseen. Tässä on esimerkki yhden topikin käyttämisestä.",
|
||||||
"notifications_no_subscriptions_title": "Näyttää siltä, että sinulla ei ole vielä tilauksia.",
|
"notifications_no_subscriptions_title": "Näyttää siltä, että sinulla ei ole vielä tilauksia.",
|
||||||
"notifications_none_for_topic_title": "Et ole vielä saanut ilmoituksia tästä aiheesta.",
|
"notifications_none_for_topic_title": "Et ole vielä saanut ilmoituksia tästä aiheesta.",
|
||||||
"notifications_actions_http_request_title": "Lähetä HTTP {{method}} to {{url}}",
|
"notifications_actions_http_request_title": "Lähetä HTTP {{method}} osoitteeseen {{url}}",
|
||||||
"reserve_dialog_checkbox_label": "Käänteinen aihe ja aseta pääsy",
|
"reserve_dialog_checkbox_label": "Varaa aihe ja aseta pääsy",
|
||||||
"publish_dialog_progress_uploading": "Lähetetään …",
|
"publish_dialog_progress_uploading": "Lähetetään …",
|
||||||
"publish_dialog_title_no_topic": "Julkaise ilmoitus",
|
"publish_dialog_title_no_topic": "Julkaise ilmoitus",
|
||||||
"notifications_example": "Esimerkki",
|
"notifications_example": "Esimerkki",
|
||||||
"notifications_loading": "Ladataan ilmoituksia …",
|
"notifications_loading": "Ladataan ilmoituksia…",
|
||||||
"notifications_no_subscriptions_description": "Klikkaa \"{{linktext}}\" linkkiä luodaksesi tai tilataksesi aihe. Sen jälkeen voit lähettää viestejä PUT tai POST metodeilla ja saat ilmoituksesi täällä.",
|
"notifications_no_subscriptions_description": "Klikkaa \"{{linktext}}\" linkkiä luodaksesi tai tilataksesi aihe. Sen jälkeen voit lähettää viestejä PUT tai POST metodeilla ja saat ilmoituksesi täällä.",
|
||||||
"display_name_dialog_description": "Aseta vaihtoehtoinen nimi aiheelle, joka on näytetty tilaus-listassa. Tämä auttaa tunnistamaan aiheet helpommin, joilla on hankalat nimet.",
|
"display_name_dialog_description": "Aseta vaihtoehtoinen nimi aiheelle, joka on näytetty tilaus-listassa. Tämä auttaa tunnistamaan aiheet helpommin, joilla on hankalat nimet.",
|
||||||
"publish_dialog_message_published": "Ilmoitus julkaistu",
|
"publish_dialog_message_published": "Ilmoitus julkaistu",
|
||||||
"notifications_more_details": "Saadaksesi lisää tietoa, katso <websiteLink>nettisivu</websiteLink> tai <docsLink>documentointi</docsLink>.",
|
"notifications_more_details": "Saadaksesi lisää tietoa, katso <websiteLink>verkkosivusto</websiteLink> tai <docsLink>dokumentaatio</docsLink>.",
|
||||||
"publish_dialog_attachment_limits_quota_reached": "ylittää kiintiön, {{remainingBytes}} jäljellä",
|
"publish_dialog_attachment_limits_quota_reached": "ylittää kiintiön, {{remainingBytes}} jäljellä",
|
||||||
"publish_dialog_title_topic": "Julkaise aiheeseen {{topic}}",
|
"publish_dialog_title_topic": "Julkaise aiheeseen {{topic}}",
|
||||||
"display_name_dialog_placeholder": "Näyttönimi",
|
"display_name_dialog_placeholder": "Näyttönimi",
|
||||||
"publish_dialog_attachment_limits_file_and_quota_reached": "ylittää {{fileSizeLimit}} tiedostokoon rajan ja määrän, {{remainingBytes}} jäljellä",
|
"publish_dialog_attachment_limits_file_and_quota_reached": "ylittää {{fileSizeLimit}} tiedostokoon rajan ja määrän, {{remainingBytes}} jäljellä",
|
||||||
"publish_dialog_attachment_limits_file_reached": "ylittää {{fileSizeLimit}} tiedostokoon rajan",
|
"publish_dialog_attachment_limits_file_reached": "ylittää {{fileSizeLimit}} tiedostokoon rajan",
|
||||||
"publish_dialog_progress_uploading_detail": "Lähetetään {{loaded}}/{{total}} ({{percent}}%) …",
|
"publish_dialog_progress_uploading_detail": "Lähetetään {{loaded}}/{{total}} ({{percent}}%) …",
|
||||||
"display_name_dialog_title": "Vaihda näyttönimi"
|
"display_name_dialog_title": "Vaihda näyttönimi",
|
||||||
|
"action_bar_mute_notifications": "Mykistä ilmoitukset",
|
||||||
|
"action_bar_unmute_notifications": "Poista ilmoitusten mykistys",
|
||||||
|
"alert_notification_permission_required_title": "Ilmoitukset eivät ole käytössä",
|
||||||
|
"alert_notification_permission_required_description": "Anna selaimelle lupa näyttää työpöytäilmoituksia",
|
||||||
|
"alert_notification_permission_required_button": "Myönnä lupa nyt",
|
||||||
|
"alert_notification_permission_denied_title": "Ilmoitukset on estetty",
|
||||||
|
"alert_notification_ios_install_required_title": "iOS-asennus vaaditaan",
|
||||||
|
"publish_dialog_checkbox_markdown": "Muotoile Markdownina",
|
||||||
|
"prefs_notifications_web_push_title": "Taustailmoitukset",
|
||||||
|
"prefs_appearance_theme_system": "Järjestelmä (oletus)",
|
||||||
|
"alert_notification_permission_denied_description": "Ota ilmoitukset uudelleen käyttöön selaimessa",
|
||||||
|
"prefs_appearance_theme_title": "Teema",
|
||||||
|
"prefs_appearance_theme_light": "Vaalea tila",
|
||||||
|
"prefs_notifications_web_push_enabled": "Käytössä palvelimelle {{server}}",
|
||||||
|
"prefs_notifications_web_push_disabled": "Pois käytöstä",
|
||||||
|
"prefs_appearance_theme_dark": "Tumma tila",
|
||||||
|
"error_boundary_button_reload_ntfy": "Lataa ntfy uudelleen",
|
||||||
|
"web_push_subscription_expiring_title": "Ilmoitukset keskeytetään",
|
||||||
|
"web_push_subscription_expiring_body": "Avaa ntfy jatkaaksesi ilmoitusten vastaanottamista",
|
||||||
|
"web_push_unknown_notification_title": "Tuntematon ilmoitus vastaanotettu palvelimelta",
|
||||||
|
"alert_notification_ios_install_required_description": "Napauta Jaa-kuvaketta ja Lisää aloitusnäyttöön ottaaksesi ilmoitukset käyttöön iOS:ssä",
|
||||||
|
"prefs_notifications_web_push_disabled_description": "Ilmoituksia vastaanotetaan, kun verkkosovellus on käynnissä (WebSocket:in kautta)",
|
||||||
|
"web_push_unknown_notification_body": "Voit joutua päivittämään ntfy:n avaamalla verkkosovelluksen",
|
||||||
|
"notifications_actions_failed_notification": "Epäonnistunut toiminto",
|
||||||
|
"subscribe_dialog_subscribe_use_another_background_info": "Ilmoituksia muilta palvelimilta ei vastaanoteta, mikäli verkkosovellus ei ole avoinna",
|
||||||
|
"prefs_notifications_web_push_enabled_description": "Ilmoituksia vastaanotetaan siitä huolimatta, että verkkosovellus ei ole käynnissä (Web Push:n kautta)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"nav_button_all_notifications": "Toutes les notifications",
|
"nav_button_all_notifications": "Toutes les notifications",
|
||||||
"nav_button_settings": "Paramètres",
|
"nav_button_settings": "Paramètres",
|
||||||
"nav_button_documentation": "Documentation",
|
"nav_button_documentation": "Documentation",
|
||||||
"alert_not_supported_description": "Les notifications ne sont pas prises en charge par votre navigateur.",
|
"alert_not_supported_description": "Les notifications ne sont pas prises en charge par votre navigateur",
|
||||||
"notifications_attachment_copy_url_title": "Copier l'URL de la pièce jointe dans le presse-papiers",
|
"notifications_attachment_copy_url_title": "Copier l'URL de la pièce jointe dans le presse-papiers",
|
||||||
"notifications_attachment_open_title": "Aller à {{url}}",
|
"notifications_attachment_open_title": "Aller à {{url}}",
|
||||||
"notifications_attachment_link_expired": "lien de téléchargement expiré",
|
"notifications_attachment_link_expired": "lien de téléchargement expiré",
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
"nav_button_subscribe": "S'abonner au sujet",
|
"nav_button_subscribe": "S'abonner au sujet",
|
||||||
"notifications_no_subscriptions_description": "Cliquez sur le lien « {{linktext}} » pour créer ou vous abonner à un sujet. Après cela, vous pouvez envoyer des messages via PUT ou POST et vous recevrez des notifications ici.",
|
"notifications_no_subscriptions_description": "Cliquez sur le lien « {{linktext}} » pour créer ou vous abonner à un sujet. Après cela, vous pouvez envoyer des messages via PUT ou POST et vous recevrez des notifications ici.",
|
||||||
"alert_notification_permission_required_title": "Les notifications sont désactivées",
|
"alert_notification_permission_required_title": "Les notifications sont désactivées",
|
||||||
"alert_notification_permission_required_description": "Autorisez votre navigateur à afficher les notifications du bureau.",
|
"alert_notification_permission_required_description": "Autorisez votre navigateur à afficher les notifications du bureau",
|
||||||
"alert_notification_permission_required_button": "Accorder maintenant",
|
"alert_notification_permission_required_button": "Accorder maintenant",
|
||||||
"notifications_none_for_any_title": "Vous n'avez reçu aucune notification.",
|
"notifications_none_for_any_title": "Vous n'avez reçu aucune notification.",
|
||||||
"publish_dialog_title_topic": "Publier vers {{topic}}",
|
"publish_dialog_title_topic": "Publier vers {{topic}}",
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
"subscribe_dialog_subscribe_button_subscribe": "S'abonner",
|
"subscribe_dialog_subscribe_button_subscribe": "S'abonner",
|
||||||
"subscribe_dialog_login_description": "Ce sujet est protégé par un mot de passe. Veuillez entrer le nom d'utilisateur et le mot de passe pour vous abonner.",
|
"subscribe_dialog_login_description": "Ce sujet est protégé par un mot de passe. Veuillez entrer le nom d'utilisateur et le mot de passe pour vous abonner.",
|
||||||
"subscribe_dialog_login_username_label": "Nom d'utilisateur, par ex. phil",
|
"subscribe_dialog_login_username_label": "Nom d'utilisateur, par ex. phil",
|
||||||
"subscribe_dialog_login_button_login": "Connexion",
|
"subscribe_dialog_login_button_login": "Se connecter",
|
||||||
"prefs_notifications_sound_title": "Son de notification",
|
"prefs_notifications_sound_title": "Son de notification",
|
||||||
"prefs_notifications_delete_after_never": "Jamais",
|
"prefs_notifications_delete_after_never": "Jamais",
|
||||||
"prefs_users_table_base_url_header": "URL de service",
|
"prefs_users_table_base_url_header": "URL de service",
|
||||||
@@ -194,13 +194,13 @@
|
|||||||
"signup_error_username_taken": "L'identifiant {{username}} est déjà utilisé",
|
"signup_error_username_taken": "L'identifiant {{username}} est déjà utilisé",
|
||||||
"signup_error_creation_limit_reached": "Limite de création de comptes atteinte",
|
"signup_error_creation_limit_reached": "Limite de création de comptes atteinte",
|
||||||
"login_title": "Se connecter à son compte Ntfy",
|
"login_title": "Se connecter à son compte Ntfy",
|
||||||
"login_form_button_submit": "Connexion",
|
"login_form_button_submit": "Se connecter",
|
||||||
"login_link_signup": "S'inscrire",
|
"login_link_signup": "S'inscrire",
|
||||||
"login_disabled": "La connection est désactivée",
|
"login_disabled": "La connection est désactivée",
|
||||||
"action_bar_account": "Compte",
|
"action_bar_account": "Compte",
|
||||||
"action_bar_profile_title": "Profil",
|
"action_bar_profile_title": "Profil",
|
||||||
"action_bar_profile_settings": "Paramètres",
|
"action_bar_profile_settings": "Paramètres",
|
||||||
"action_bar_sign_in": "Connexion",
|
"action_bar_sign_in": "Se connecter",
|
||||||
"action_bar_sign_up": "Inscription",
|
"action_bar_sign_up": "Inscription",
|
||||||
"nav_button_account": "Compte",
|
"nav_button_account": "Compte",
|
||||||
"signup_title": "Créer un compte Ntfy",
|
"signup_title": "Créer un compte Ntfy",
|
||||||
@@ -380,5 +380,28 @@
|
|||||||
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Aucun numéro de téléphone vérifié",
|
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Aucun numéro de téléphone vérifié",
|
||||||
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} sujet réservé",
|
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} sujet réservé",
|
||||||
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} appels journaliers",
|
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} appels journaliers",
|
||||||
"account_usage_calls_title": "Appels téléphoniques passés"
|
"account_usage_calls_title": "Appels téléphoniques passés",
|
||||||
|
"action_bar_mute_notifications": "Désactiver les notifications",
|
||||||
|
"action_bar_unmute_notifications": "Réactiver les notifications",
|
||||||
|
"alert_notification_permission_denied_title": "Les notifications sont bloquées",
|
||||||
|
"alert_notification_permission_denied_description": "Veuillez les réactiver dans votre navigateur",
|
||||||
|
"alert_notification_ios_install_required_description": "Cliquez sur l'icône Partager, puis Sur l'écran d'accueil pour activer les notifications sur iOS",
|
||||||
|
"alert_notification_ios_install_required_title": "Installation iOS nécessaire",
|
||||||
|
"notifications_actions_failed_notification": "Échec de l'action",
|
||||||
|
"publish_dialog_checkbox_markdown": "Formater en Markdown",
|
||||||
|
"subscribe_dialog_subscribe_use_another_background_info": "Les notifications provenant d'autres serveurs ne seront pas reçues tant que l'application web n'est pas ouverte",
|
||||||
|
"prefs_notifications_web_push_title": "Notifications en arrière-plan",
|
||||||
|
"prefs_notifications_web_push_enabled_description": "Les notifications sont reçues même quand l'application web n'est pas en cours d'exécution (via Web Push)",
|
||||||
|
"prefs_notifications_web_push_disabled_description": "Les notifications sont reçues quand l'application web est en cours d'exécution (via WebSocket)",
|
||||||
|
"prefs_notifications_web_push_enabled": "Activé pour {{server}}",
|
||||||
|
"prefs_notifications_web_push_disabled": "Désactivé",
|
||||||
|
"prefs_appearance_theme_title": "Thème",
|
||||||
|
"prefs_appearance_theme_system": "Système (défaut)",
|
||||||
|
"prefs_appearance_theme_dark": "Mode sombre",
|
||||||
|
"prefs_appearance_theme_light": "Mode clair",
|
||||||
|
"error_boundary_button_reload_ntfy": "Recharger ntfy",
|
||||||
|
"web_push_subscription_expiring_title": "Les notifications seront suspendues",
|
||||||
|
"web_push_subscription_expiring_body": "Ouvrez ntfy pour continuer à recevoir les notifications",
|
||||||
|
"web_push_unknown_notification_title": "Notification inconnue reçue du serveur",
|
||||||
|
"web_push_unknown_notification_body": "Il est possible que vous deviez mettre à jour ntfy en ouvrant l'application web"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
"nav_button_muted": "Notificacións acaladas",
|
"nav_button_muted": "Notificacións acaladas",
|
||||||
"nav_button_connecting": "conectando",
|
"nav_button_connecting": "conectando",
|
||||||
"nav_upgrade_banner_label": "Mellorar a ntfy Pro",
|
"nav_upgrade_banner_label": "Mellorar a ntfy Pro",
|
||||||
"alert_not_supported_description": "O teu navegador non ten soporte para notificacións.",
|
"alert_not_supported_description": "O teu navegador non ten soporte para notificacións",
|
||||||
"notifications_priority_x": "Prioridade {{priority}}",
|
"notifications_priority_x": "Prioridade {{priority}}",
|
||||||
"notifications_attachment_link_expires": "a ligazón caduca o {{date}}",
|
"notifications_attachment_link_expires": "a ligazón caduca o {{date}}",
|
||||||
"notifications_attachment_link_expired": "a ligazón de descarga caducou",
|
"notifications_attachment_link_expired": "a ligazón de descarga caducou",
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
"notifications_none_for_topic_title": "Aínda non recibiches ningunha notificación para este tema.",
|
"notifications_none_for_topic_title": "Aínda non recibiches ningunha notificación para este tema.",
|
||||||
"reserve_dialog_checkbox_label": "Reservar tema e configurar acceso",
|
"reserve_dialog_checkbox_label": "Reservar tema e configurar acceso",
|
||||||
"notifications_loading": "Cargando notificacións…",
|
"notifications_loading": "Cargando notificacións…",
|
||||||
"publish_dialog_base_url_placeholder": "URL de servizo, ex. https://exemplo.com",
|
"publish_dialog_base_url_placeholder": "URL do servizo, ex. https://exemplo.com",
|
||||||
"publish_dialog_topic_label": "Nome do tema",
|
"publish_dialog_topic_label": "Nome do tema",
|
||||||
"publish_dialog_topic_placeholder": "Nome do tema, ex. alertas_equipo",
|
"publish_dialog_topic_placeholder": "Nome do tema, ex. alertas_equipo",
|
||||||
"publish_dialog_topic_reset": "Restablecer tema",
|
"publish_dialog_topic_reset": "Restablecer tema",
|
||||||
@@ -172,7 +172,7 @@
|
|||||||
"account_tokens_table_token_header": "Token",
|
"account_tokens_table_token_header": "Token",
|
||||||
"prefs_notifications_delete_after_never": "Nunca",
|
"prefs_notifications_delete_after_never": "Nunca",
|
||||||
"prefs_users_description": "Engadir/eliminar usuarias dos temas protexidos. Ten en conta que as credenciais gárdanse na almacenaxe local do navegador.",
|
"prefs_users_description": "Engadir/eliminar usuarias dos temas protexidos. Ten en conta que as credenciais gárdanse na almacenaxe local do navegador.",
|
||||||
"subscribe_dialog_subscribe_description": "Os temas poderían non estar proxetidos con contrasinal, así que elixe un nome complicado de adiviñar. Unha vez subscrita, podes PUT/POST notificacións.",
|
"subscribe_dialog_subscribe_description": "Os temas poden non estar protexidos con contrasinal, asi que escolle un nome que non sexa fácil de pesquisar. Unha vez suscrito, podes notificar con PUT/POST.",
|
||||||
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "aforro ata un {{discount}}%",
|
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "aforro ata un {{discount}}%",
|
||||||
"account_tokens_dialog_label": "Etiqueta, ex. notificación de Radarr",
|
"account_tokens_dialog_label": "Etiqueta, ex. notificación de Radarr",
|
||||||
"account_tokens_table_expires_header": "Caducidade",
|
"account_tokens_table_expires_header": "Caducidade",
|
||||||
@@ -315,17 +315,17 @@
|
|||||||
"account_basics_password_dialog_current_password_incorrect": "Contrasinal incorrecto",
|
"account_basics_password_dialog_current_password_incorrect": "Contrasinal incorrecto",
|
||||||
"account_basics_phone_numbers_dialog_number_label": "Número de teléfono",
|
"account_basics_phone_numbers_dialog_number_label": "Número de teléfono",
|
||||||
"account_basics_password_dialog_button_submit": "Modificar contrasinal",
|
"account_basics_password_dialog_button_submit": "Modificar contrasinal",
|
||||||
"account_basics_username_title": "Usuario",
|
"account_basics_username_title": "Identificador",
|
||||||
"account_basics_phone_numbers_dialog_check_verification_button": "Código de confirmación",
|
"account_basics_phone_numbers_dialog_check_verification_button": "Código de confirmación",
|
||||||
"account_usage_messages_title": "Mesaxes publicados",
|
"account_usage_messages_title": "Mesaxes publicados",
|
||||||
"account_basics_phone_numbers_dialog_verify_button_sms": "Enviar SMS",
|
"account_basics_phone_numbers_dialog_verify_button_sms": "Enviar SMS",
|
||||||
"account_basics_tier_change_button": "Cambiar",
|
"account_basics_tier_change_button": "Cambiar",
|
||||||
"account_basics_phone_numbers_dialog_description": "Para usar a característica de chamadas de teléfono, vostede debe engadir e verificar ao menos un número de teléfono. A verificación pode ser realizada vía SMS ou a través de chamada.",
|
"account_basics_phone_numbers_dialog_description": "Para usar a característica de chamadas de teléfono, vostede debe engadir e verificar ao menos un número de teléfono. A verificación pode ser realizada vía SMS ou a través de chamada.",
|
||||||
"account_delete_title": "Borrar conta",
|
"account_delete_title": "Eliminar a conta",
|
||||||
"account_delete_dialog_label": "Contrasinal",
|
"account_delete_dialog_label": "Contrasinal",
|
||||||
"account_basics_tier_admin_suffix_with_tier": "(con tier {{tier}})",
|
"account_basics_tier_admin_suffix_with_tier": "(con tier {{tier}})",
|
||||||
"subscribe_dialog_login_username_label": "Nome de usuario, ex. phil",
|
"subscribe_dialog_login_username_label": "Identificador, ex. xoana",
|
||||||
"subscribe_dialog_error_user_not_authorized": "Usuario {{username}} non autorizado",
|
"subscribe_dialog_error_user_not_authorized": "Identificador {{username}} non autorizado",
|
||||||
"account_basics_title": "Conta",
|
"account_basics_title": "Conta",
|
||||||
"account_basics_phone_numbers_no_phone_numbers_yet": "Aínda non hay números de teléfono",
|
"account_basics_phone_numbers_no_phone_numbers_yet": "Aínda non hay números de teléfono",
|
||||||
"subscribe_dialog_subscribe_button_generate_topic_name": "Xerar nome",
|
"subscribe_dialog_subscribe_button_generate_topic_name": "Xerar nome",
|
||||||
@@ -333,9 +333,9 @@
|
|||||||
"subscribe_dialog_subscribe_button_subscribe": "Subscribirse",
|
"subscribe_dialog_subscribe_button_subscribe": "Subscribirse",
|
||||||
"account_basics_phone_numbers_dialog_title": "Engadir número de teléfono",
|
"account_basics_phone_numbers_dialog_title": "Engadir número de teléfono",
|
||||||
"account_basics_username_admin_tooltip": "É vostede Admin",
|
"account_basics_username_admin_tooltip": "É vostede Admin",
|
||||||
"account_delete_dialog_description": "Isto borrará permanentemente a túa conta, incluido todos os datos almacenados no servidor. Despois do borrado, o teu nome de usuario non estará dispoñible durante 7 días. Se realmente queres proceder, por favor confirme co seu contrasinal na caixa inferior.",
|
"account_delete_dialog_description": "Isto borrará permanentemente a conta, incluido todos os datos almacenados no servidor. Despois do borrado, o teu identificador non estará dispoñible durante 7 días. Se realmente queres proceder, por favor confirma co contrasinal na caixa inferior.",
|
||||||
"account_usage_reservations_none": "Non hai temas reservados para esta conta",
|
"account_usage_reservations_none": "Non hai temas reservados para esta conta",
|
||||||
"subscribe_dialog_subscribe_topic_placeholder": "Nome do tema, ex. phil_alertas",
|
"subscribe_dialog_subscribe_topic_placeholder": "Nome do tema, ex. alertas_xoana",
|
||||||
"account_usage_title": "Uso",
|
"account_usage_title": "Uso",
|
||||||
"account_basics_tier_upgrade_button": "Mexorar a Pro",
|
"account_basics_tier_upgrade_button": "Mexorar a Pro",
|
||||||
"subscribe_dialog_error_topic_already_reserved": "Tema xa reservado",
|
"subscribe_dialog_error_topic_already_reserved": "Tema xa reservado",
|
||||||
@@ -351,11 +351,11 @@
|
|||||||
"account_basics_phone_numbers_copied_to_clipboard": "Número de teléfono copiado no portapapeis",
|
"account_basics_phone_numbers_copied_to_clipboard": "Número de teléfono copiado no portapapeis",
|
||||||
"account_basics_tier_title": "Tipo de conta",
|
"account_basics_tier_title": "Tipo de conta",
|
||||||
"account_usage_cannot_create_portal_session": "Non foi posible abrir o portal de pagos",
|
"account_usage_cannot_create_portal_session": "Non foi posible abrir o portal de pagos",
|
||||||
"account_delete_description": "Borrar permanentemente a túa conta",
|
"account_delete_description": "Eliminar a conta de xeito definitivo",
|
||||||
"account_basics_phone_numbers_dialog_number_placeholder": "ex. +1222333444",
|
"account_basics_phone_numbers_dialog_number_placeholder": "ex. +1222333444",
|
||||||
"account_basics_phone_numbers_dialog_code_placeholder": "ex. 123456",
|
"account_basics_phone_numbers_dialog_code_placeholder": "ex. 123456",
|
||||||
"account_basics_tier_manage_billing_button": "Xestionar pagos",
|
"account_basics_tier_manage_billing_button": "Xestionar pagos",
|
||||||
"account_basics_username_description": "Ei, ese eres ti ❤",
|
"account_basics_username_description": "Ei, es ti ❤",
|
||||||
"account_basics_password_dialog_confirm_password_label": "Confirmar contrasinal",
|
"account_basics_password_dialog_confirm_password_label": "Confirmar contrasinal",
|
||||||
"account_basics_tier_interval_yearly": "anual",
|
"account_basics_tier_interval_yearly": "anual",
|
||||||
"account_delete_dialog_button_submit": "Borrar permanentemente a conta",
|
"account_delete_dialog_button_submit": "Borrar permanentemente a conta",
|
||||||
@@ -364,7 +364,7 @@
|
|||||||
"account_basics_password_dialog_new_password_label": "Novo contrasinal",
|
"account_basics_password_dialog_new_password_label": "Novo contrasinal",
|
||||||
"account_usage_of_limit": "de {{limit}}",
|
"account_usage_of_limit": "de {{limit}}",
|
||||||
"subscribe_dialog_error_user_anonymous": "anónimo",
|
"subscribe_dialog_error_user_anonymous": "anónimo",
|
||||||
"account_usage_basis_ip_description": "Estadísticas de uso e límites para esta conta están basados na sua IP, polo que poden estar compartidos con outros usuarios. Os limites mostrados son aproximados, basados nos ratios de limite existentes.",
|
"account_usage_basis_ip_description": "As estatísticas de uso e límites para esta conta están basados na IP, polo que poden estar compartidas con outras usuarias. Os limites mostrados son aproximados, baseados nos límites das taxas existentes.",
|
||||||
"account_basics_password_dialog_title": "Modificar contrasinal",
|
"account_basics_password_dialog_title": "Modificar contrasinal",
|
||||||
"account_usage_limits_reset_daily": "Límite de uso é reiniciado diariamente a medianoite (UTC(",
|
"account_usage_limits_reset_daily": "Límite de uso é reiniciado diariamente a medianoite (UTC(",
|
||||||
"account_usage_unlimited": "Sen límites",
|
"account_usage_unlimited": "Sen límites",
|
||||||
@@ -380,5 +380,31 @@
|
|||||||
"account_basics_phone_numbers_dialog_verify_button_call": "Chámame",
|
"account_basics_phone_numbers_dialog_verify_button_call": "Chámame",
|
||||||
"account_usage_emails_title": "Emails enviados",
|
"account_usage_emails_title": "Emails enviados",
|
||||||
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
|
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
|
||||||
"subscribe_dialog_login_description": "Este tema está protexido por contrasinal. Por favor, introduza o usuario e contrasinal para subscribirse."
|
"subscribe_dialog_login_description": "Este tema está protexido por contrasinal. Por favor, escribe as credenciais para subscribirte.",
|
||||||
|
"action_bar_mute_notifications": "Acalar notificacións",
|
||||||
|
"action_bar_unmute_notifications": "Reactivar notificacións",
|
||||||
|
"alert_notification_permission_required_title": "Notificacións desactivadas",
|
||||||
|
"alert_notification_permission_required_description": "Concederlle permisos ao navegador para mostrar notificacións de escritorio",
|
||||||
|
"alert_notification_permission_required_button": "Conceder",
|
||||||
|
"alert_notification_permission_denied_title": "Notificacións bloqueadas",
|
||||||
|
"alert_notification_permission_denied_description": "Por favor reactívaas no navegador",
|
||||||
|
"alert_notification_ios_install_required_title": "Require instalación iOS",
|
||||||
|
"alert_notification_ios_install_required_description": "Preme na icona Compartir e Engadir a Pantalla de Inicio para activar as notificacións en iOS",
|
||||||
|
"notifications_actions_failed_notification": "Non se puido realizar a acción",
|
||||||
|
"publish_dialog_checkbox_markdown": "Dar formato Markdow",
|
||||||
|
"prefs_notifications_web_push_title": "Notificacións en segundo plano",
|
||||||
|
"prefs_notifications_web_push_enabled_description": "Recíbense notificacións incluso se a app web non está en execución (vía Web Push)",
|
||||||
|
"prefs_notifications_web_push_disabled_description": "Recíbense as notificacións cando a app web está en execución (vía WebSocket)",
|
||||||
|
"prefs_notifications_web_push_enabled": "Activadas para {{server}}",
|
||||||
|
"prefs_notifications_web_push_disabled": "Desactivadas",
|
||||||
|
"prefs_appearance_theme_title": "Decorado",
|
||||||
|
"prefs_appearance_theme_system": "Sistema (por defecto)",
|
||||||
|
"prefs_appearance_theme_dark": "Modo escuro",
|
||||||
|
"prefs_appearance_theme_light": "Modo claro",
|
||||||
|
"error_boundary_button_reload_ntfy": "Recargar ntfy",
|
||||||
|
"web_push_subscription_expiring_title": "Vanse pausar as notificacións",
|
||||||
|
"web_push_subscription_expiring_body": "Abrir ntfy para seguir recibindo notificacións",
|
||||||
|
"web_push_unknown_notification_title": "Recibida unha notificación descoñecida desde o servidor",
|
||||||
|
"web_push_unknown_notification_body": "Poderías ter que actualizar ntfy abrindo a app web",
|
||||||
|
"subscribe_dialog_subscribe_use_another_background_info": "As notificacións procedentes doutros servidores non se van recibir cando a app web estea pechada"
|
||||||
}
|
}
|
||||||
|
|||||||