Compare commits
585 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e7e6e57fe | ||
|
|
0b78d3173d | ||
|
|
92d7e5c58a | ||
|
|
6f170b1ad7 | ||
|
|
6dbe25fcc5 | ||
|
|
74828adcb8 | ||
|
|
3120cd54fe | ||
|
|
b1cafc06eb | ||
|
|
fd66fb33a8 | ||
|
|
5af9d0164b | ||
|
|
049a01d58f | ||
|
|
629af0efc3 | ||
|
|
a1262c2406 | ||
|
|
97dd879597 | ||
|
|
f1321d6140 | ||
|
|
0646f48ca6 | ||
|
|
a50d65393e | ||
|
|
67221b015d | ||
|
|
40aadbad85 | ||
|
|
77ebf306a3 | ||
|
|
94d3924432 | ||
|
|
1235ea5bb5 | ||
|
|
321ed12663 | ||
|
|
265af01f9c | ||
|
|
a9961df4e2 | ||
|
|
8d3f35f4f7 | ||
|
|
2b8ae406a3 | ||
|
|
d78f1a3ff9 | ||
|
|
c500c9c199 | ||
|
|
b2363d2783 | ||
|
|
8aba600fa5 | ||
|
|
18596ecc34 | ||
|
|
420d289d35 | ||
|
|
eebd0f113b | ||
|
|
c4286984ab | ||
|
|
e0d6a0b974 | ||
|
|
71e46860ac | ||
|
|
ce942ffe16 | ||
|
|
e083ef0d6d | ||
|
|
b91fb3f586 | ||
|
|
79356baee1 | ||
|
|
cb6c0b6e45 | ||
|
|
543bc24bfd | ||
|
|
789ff72081 | ||
|
|
5dc4754181 | ||
|
|
eaa64b636a | ||
|
|
1c9cd40d34 | ||
|
|
9c54181ff8 | ||
|
|
d4211441b3 | ||
|
|
3307debacc | ||
|
|
95fd6ecab1 | ||
|
|
84dca41008 | ||
|
|
b3d90f04ac | ||
|
|
c2550dbca9 | ||
|
|
fe11ed3ac7 | ||
|
|
24b5eb3405 | ||
|
|
bc16c49187 | ||
|
|
3438e0bfb0 | ||
|
|
7e9abd2350 | ||
|
|
8f6880d809 | ||
|
|
e0024e59f3 | ||
|
|
b9b604c007 | ||
|
|
be6c30fb0d | ||
|
|
7001543d28 | ||
|
|
bc38c08a5e | ||
|
|
7f49ebb4ec | ||
|
|
3746d2935b | ||
|
|
7b6577d543 | ||
|
|
f6643ebc12 | ||
|
|
fd9ab2704c | ||
|
|
f241003ac6 | ||
|
|
38f7843861 | ||
|
|
25e95ae1a6 | ||
|
|
4c1c5e56ab | ||
|
|
ed29b675ee | ||
|
|
3d501ceaf9 | ||
|
|
c5b2c8c680 | ||
|
|
f29fe22d3d | ||
|
|
2540a0396d | ||
|
|
9fec3f35ff | ||
|
|
679b075ecc | ||
|
|
b1819d4766 | ||
|
|
96b7053884 | ||
|
|
fcbf71dad7 | ||
|
|
aee791a17d | ||
|
|
5b2fe66903 | ||
|
|
f4daa4508f | ||
|
|
755155479a | ||
|
|
978118a400 | ||
|
|
4a91da60dd | ||
|
|
db9ca80b69 | ||
|
|
e147a41f92 | ||
|
|
497f871447 | ||
|
|
ad860afb8b | ||
|
|
b4933a5645 | ||
|
|
46f437126c | ||
|
|
90b85f2956 | ||
|
|
ebfbf7cc8e | ||
|
|
499ac76c43 | ||
|
|
fd7f83378d | ||
|
|
e7b575badc | ||
|
|
a0f2d81337 | ||
|
|
fb6980a81e | ||
|
|
df45459618 | ||
|
|
61b2d92595 | ||
|
|
adda27ec57 | ||
|
|
b92b5b37fb | ||
|
|
18d36e1b30 | ||
|
|
f4cb447f0a | ||
|
|
069617eba0 | ||
|
|
aff193a003 | ||
|
|
eb6a86a009 | ||
|
|
97025fe8ef | ||
|
|
08bb0103e8 | ||
|
|
e02789c70c | ||
|
|
cf7a451198 | ||
|
|
f088498f26 | ||
|
|
bcc20e0aec | ||
|
|
e236214fd5 | ||
|
|
b103caf9d4 | ||
|
|
a43a4aea5e | ||
|
|
4bcbea32ab | ||
|
|
1b96444401 | ||
|
|
651c701b9d | ||
|
|
019e69ec85 | ||
|
|
7470ffde4f | ||
|
|
2361e556e9 | ||
|
|
fea9d10ed2 | ||
|
|
9155c49571 | ||
|
|
baa15110ff | ||
|
|
5fefefc50f | ||
|
|
958b0e0d26 | ||
|
|
49732bcb3d | ||
|
|
ce43daaa73 | ||
|
|
325eca470e | ||
|
|
8988f04fb3 | ||
|
|
83118dfc64 | ||
|
|
29fbf73da0 | ||
|
|
5e1c60091f | ||
|
|
147cc1971b | ||
|
|
4a898f5b89 | ||
|
|
162dc1dbfa | ||
|
|
303cb3f8f8 | ||
|
|
4b9bb0ff2a | ||
|
|
cb247f3317 | ||
|
|
3972b2763d | ||
|
|
e2dd5f3da0 | ||
|
|
0b3173ada9 | ||
|
|
f3174f822f | ||
|
|
37ed7ef7bc | ||
|
|
cc3b9b89bf | ||
|
|
93cacc3a53 | ||
|
|
0234041e1e | ||
|
|
2fb7523d06 | ||
|
|
95e087390f | ||
|
|
0821b8a25f | ||
|
|
e320fef0c3 | ||
|
|
e874f66572 | ||
|
|
72d568db11 | ||
|
|
88e80aa252 | ||
|
|
2b823556b3 | ||
|
|
38441a2bd3 | ||
|
|
93fe19b4ed | ||
|
|
67d0fdd9b6 | ||
|
|
63f3774c41 | ||
|
|
7120dd5a27 | ||
|
|
c44c1aa237 | ||
|
|
5997761051 | ||
|
|
a17c294081 | ||
|
|
78d36a6d1d | ||
|
|
afac9ad5d3 | ||
|
|
2c59fd8bdb | ||
|
|
147774761b | ||
|
|
62cd517223 | ||
|
|
29b6517257 | ||
|
|
8b9cef7044 | ||
|
|
0e021dc1ce | ||
|
|
22c90d557b | ||
|
|
c02f7dd14d | ||
|
|
fb64d03479 | ||
|
|
956e092413 | ||
|
|
9d85cfa062 | ||
|
|
be1ba135e6 | ||
|
|
2d39ae1d1a | ||
|
|
df9fe7f8d0 | ||
|
|
1d6b792197 | ||
|
|
aaa6de9f26 | ||
|
|
536b5d364a | ||
|
|
87f112c9b7 | ||
|
|
cf370bfdda | ||
|
|
0d46bfa76e | ||
|
|
5b8372d260 | ||
|
|
ec72df046f | ||
|
|
947a4c1e74 | ||
|
|
9848bc7429 | ||
|
|
e54aeb357c | ||
|
|
d989ba0ab0 | ||
|
|
838543f489 | ||
|
|
fae5b38f67 | ||
|
|
6c3fe686be | ||
|
|
5dacd6f2c7 | ||
|
|
4ca721bb1f | ||
|
|
5d9702b10b | ||
|
|
85eb9160d8 | ||
|
|
322abf4bdf | ||
|
|
f007232520 | ||
|
|
dfec18be3d | ||
|
|
b7a18bd181 | ||
|
|
ce392de0a8 | ||
|
|
383ae66a48 | ||
|
|
24940f8a3b | ||
|
|
54eae00774 | ||
|
|
1b82beea6e | ||
|
|
cb8b3e54f6 | ||
|
|
d48619a940 | ||
|
|
ca5ec53261 | ||
|
|
819c896d40 | ||
|
|
dd689fd4a6 | ||
|
|
cbc912d1e3 | ||
|
|
16ad94441b | ||
|
|
1672322fc1 | ||
|
|
bc5060b218 | ||
|
|
4edc625331 | ||
|
|
3b29294679 | ||
|
|
511d3f6aaf | ||
|
|
de2ca33700 | ||
|
|
c2382d29a1 | ||
|
|
a70ee81d3b | ||
|
|
bb2f9cbe2b | ||
|
|
e1eca2323e | ||
|
|
9e15a4cfe2 | ||
|
|
e63b521bc9 | ||
|
|
4d6d6f7204 | ||
|
|
e0ad926ce9 | ||
|
|
04e91a1616 | ||
|
|
5014bba0b3 | ||
|
|
eaf3e83e72 | ||
|
|
bddde5c637 | ||
|
|
b15ecd785e | ||
|
|
f8c9945cc4 | ||
|
|
0fc8dee9a9 | ||
|
|
f01576e40d | ||
|
|
ea669c75a3 | ||
|
|
4abd0e290a | ||
|
|
bcda08a01c | ||
|
|
60043f14ea | ||
|
|
d3cfa3456c | ||
|
|
903ba8df4d | ||
|
|
46fcbdb827 | ||
|
|
419bfecd6f | ||
|
|
a9019131cf | ||
|
|
5e0e8e7db0 | ||
|
|
f0f4de2719 | ||
|
|
61d5293ba0 | ||
|
|
fd21d2f4ce | ||
|
|
e6b07e22a8 | ||
|
|
b117c217e4 | ||
|
|
1e823b4f89 | ||
|
|
36e805828e | ||
|
|
b37b3d97ad | ||
|
|
4446795dad | ||
|
|
ed4cc86c5c | ||
|
|
6476978a2e | ||
|
|
23a127d20b | ||
|
|
ae1fb74ac6 | ||
|
|
38c3b1fbf7 | ||
|
|
42c0dbab65 | ||
|
|
97a55babe1 | ||
|
|
c84d10a6df | ||
|
|
f7f72f44a1 | ||
|
|
f54dce4c3f | ||
|
|
cee46044cd | ||
|
|
58e782b475 | ||
|
|
601f01bc49 | ||
|
|
9dc19f1d07 | ||
|
|
4ea1e23361 | ||
|
|
2fb93b1eb7 | ||
|
|
eed3e28790 | ||
|
|
e60e770419 | ||
|
|
62c8cafff9 | ||
|
|
5181acdd7c | ||
|
|
6db2908d69 | ||
|
|
925017f040 | ||
|
|
6935d83ab3 | ||
|
|
54f762558a | ||
|
|
a22fd4db1c | ||
|
|
3f85e0a0c8 | ||
|
|
b0d58a618e | ||
|
|
29a248701f | ||
|
|
ec64b412a8 | ||
|
|
f5f9758a50 | ||
|
|
0d5362f0e4 | ||
|
|
fb7a2455fa | ||
|
|
85b2a674ae | ||
|
|
4277d6e4a6 | ||
|
|
3aa0eb7d1d | ||
|
|
ec3e6e902e | ||
|
|
9d0231ea07 | ||
|
|
08d717afbf | ||
|
|
9e151253e3 | ||
|
|
e4c760f1de | ||
|
|
4c566c9f31 | ||
|
|
a498e43d61 | ||
|
|
613d5d554f | ||
|
|
f6a42e7dcd | ||
|
|
8956837443 | ||
|
|
28975e9433 | ||
|
|
206beb31c4 | ||
|
|
38e61d6a99 | ||
|
|
3c5a10de17 | ||
|
|
99886d7f66 | ||
|
|
04f2535e92 | ||
|
|
d519fd999b | ||
|
|
cbcd0e3f0d | ||
|
|
9bcec02f8c | ||
|
|
88a77cb132 | ||
|
|
10a9aca2a1 | ||
|
|
3e53d8a2c7 | ||
|
|
d8ce68b2cb | ||
|
|
dd6af3b8f2 | ||
|
|
e874f3516e | ||
|
|
bf8077626e | ||
|
|
8532b5b7ea | ||
|
|
ed1673beed | ||
|
|
89316487e3 | ||
|
|
9f358d4793 | ||
|
|
e8953aea3b | ||
|
|
95bd876be2 | ||
|
|
bd6f3ca2e8 | ||
|
|
aee4074792 | ||
|
|
4d6c147f24 | ||
|
|
691a77370e | ||
|
|
d09afd8b60 | ||
|
|
2d26a990a9 | ||
|
|
f134bc6dcd | ||
|
|
50a830c360 | ||
|
|
ae3715222f | ||
|
|
eb841604c7 | ||
|
|
30c8d6b02b | ||
|
|
b840d7d5f4 | ||
|
|
20f835df50 | ||
|
|
bac5e1fa84 | ||
|
|
69d6cdd786 | ||
|
|
5f2ce5e542 | ||
|
|
6acb921098 | ||
|
|
acf6d4370f | ||
|
|
297601d0f2 | ||
|
|
113900d3eb | ||
|
|
b4a824aa38 | ||
|
|
e8569c6008 | ||
|
|
b74defef14 | ||
|
|
ee38d76bc2 | ||
|
|
3334d84861 | ||
|
|
b1089e21f9 | ||
|
|
07b5d9a9df | ||
|
|
9cee8ab888 | ||
|
|
ed9d99fd57 | ||
|
|
edfc1b78a1 | ||
|
|
c1f7bed8d1 | ||
|
|
85f2252a77 | ||
|
|
4e29216b5f | ||
|
|
26fda847ca | ||
|
|
a160da3ad9 | ||
|
|
0080ea5a20 | ||
|
|
fec4864771 | ||
|
|
c40338c146 | ||
|
|
a7d8e69dfd | ||
|
|
5b68915fff | ||
|
|
f3e5961892 | ||
|
|
7de7e0de12 | ||
|
|
727c6268b9 | ||
|
|
50cd50cfdf | ||
|
|
1265e69eee | ||
|
|
d05211648d | ||
|
|
1226a7b70c | ||
|
|
30c2a67869 | ||
|
|
25a4b29ffc | ||
|
|
e578f01e5b | ||
|
|
16047ede61 | ||
|
|
affc79eab0 | ||
|
|
64590343f5 | ||
|
|
87cf765dcc | ||
|
|
b332e1aaea | ||
|
|
eef55c35a8 | ||
|
|
a2c661cbf6 | ||
|
|
9918f4965d | ||
|
|
1fae61e78f | ||
|
|
df2362e1a7 | ||
|
|
8a56b82813 | ||
|
|
6122cf20aa | ||
|
|
18bd3c0e55 | ||
|
|
0ff8e968ca | ||
|
|
ebbc2838ba | ||
|
|
91375b2e8e | ||
|
|
f1d134dfc2 | ||
|
|
cd536e6018 | ||
|
|
3dec7efadb | ||
|
|
27910772f0 | ||
|
|
632c21298f | ||
|
|
e9f3edb76b | ||
|
|
feef15c485 | ||
|
|
cf0f002bfa | ||
|
|
eb2262d06e | ||
|
|
41096ef1b0 | ||
|
|
3c47797bf3 | ||
|
|
a8c9927eab | ||
|
|
8565dc0ff3 | ||
|
|
2b42cea1a3 | ||
|
|
d7f7aa909c | ||
|
|
e5af7fe8d7 | ||
|
|
52fcfdccb2 | ||
|
|
9025e2a082 | ||
|
|
4667377649 | ||
|
|
f459a08f96 | ||
|
|
f542afb37f | ||
|
|
4baf6996c5 | ||
|
|
81da9a2756 | ||
|
|
fa98a16195 | ||
|
|
12b2636155 | ||
|
|
10c89b2e55 | ||
|
|
01d8ea0019 | ||
|
|
c7b790e070 | ||
|
|
b5eb3a40f4 | ||
|
|
ffb6de7d97 | ||
|
|
3ad5ed571d | ||
|
|
ad30c50418 | ||
|
|
f59c58b08f | ||
|
|
86c132f9cd | ||
|
|
0521f19ea4 | ||
|
|
17930caf21 | ||
|
|
d65ca9b10f | ||
|
|
ae3163c5b1 | ||
|
|
887a7c3288 | ||
|
|
f6dee345b7 | ||
|
|
1e16899ae3 | ||
|
|
7475879712 | ||
|
|
997828aa72 | ||
|
|
f6ffb393f8 | ||
|
|
850c6725f5 | ||
|
|
39b1de3320 | ||
|
|
e12995e218 | ||
|
|
5cc0b194d3 | ||
|
|
7845eb0124 | ||
|
|
3fa825b104 | ||
|
|
732537eaba | ||
|
|
a898a2ebe8 | ||
|
|
430f985fca | ||
|
|
ab955d4d1c | ||
|
|
41fd8454cf | ||
|
|
bd865fd55d | ||
|
|
b9e5079399 | ||
|
|
eb0847392c | ||
|
|
17eabed11c | ||
|
|
ad55de784d | ||
|
|
48538d149e | ||
|
|
b60f0afb8f | ||
|
|
8c32f029fb | ||
|
|
5b9391be39 | ||
|
|
a04cf5fcb6 | ||
|
|
9202d85532 | ||
|
|
769e071593 | ||
|
|
c80e4e1aa9 | ||
|
|
f9284a098a | ||
|
|
8283b6be97 | ||
|
|
8a81c8e95b | ||
|
|
670ea67052 | ||
|
|
aaa004847c | ||
|
|
717d6287c8 | ||
|
|
dcfb19bfc9 | ||
|
|
dc0e699fb5 | ||
|
|
1f38a4a531 | ||
|
|
970ca3a68e | ||
|
|
2d7b986c9c | ||
|
|
ce7c8c43b5 | ||
|
|
4a6f4e0044 | ||
|
|
7e3ac9b76b | ||
|
|
3939599014 | ||
|
|
15aed00387 | ||
|
|
d1544991bf | ||
|
|
d24f2d9d46 | ||
|
|
b2c2bd1e4b | ||
|
|
b003d79ae4 | ||
|
|
a52b024807 | ||
|
|
12b83828bd | ||
|
|
96bb357435 | ||
|
|
6a43c1a126 | ||
|
|
4dabc56952 | ||
|
|
5e510a19a1 | ||
|
|
b627a327d1 | ||
|
|
0b38efd761 | ||
|
|
983dec801a | ||
|
|
01eeb71b9d | ||
|
|
6ba1d7b2a5 | ||
|
|
ff202a042b | ||
|
|
af76a2606d | ||
|
|
98b56c2f06 | ||
|
|
b6afa2fd49 | ||
|
|
e1c07228e5 | ||
|
|
a949748d91 | ||
|
|
125fcd85bb | ||
|
|
2abd6a57ee | ||
|
|
35a691a1bc | ||
|
|
2bf6b8bb28 | ||
|
|
cb82e2690c | ||
|
|
ab1f9220a3 | ||
|
|
4c5d40e4c9 | ||
|
|
c33065151e | ||
|
|
ab01d0f04e | ||
|
|
42c3c6eb29 | ||
|
|
da7a1f656f | ||
|
|
63719ca0a0 | ||
|
|
cd27d47f4b | ||
|
|
60c5ccf34b | ||
|
|
d819de2626 | ||
|
|
79cb082879 | ||
|
|
632bf8d0b6 | ||
|
|
5e1d1698ff | ||
|
|
396497fff7 | ||
|
|
94bfe029d5 | ||
|
|
7f736eb93e | ||
|
|
414e283b46 | ||
|
|
a52e628f7b | ||
|
|
3eea97109e | ||
|
|
1950fc518f | ||
|
|
b93d654aca | ||
|
|
91594faf28 | ||
|
|
6c2aa0c3c2 | ||
|
|
db613f81cc | ||
|
|
51769c4094 | ||
|
|
cb768ca3f8 | ||
|
|
433e8e5b99 | ||
|
|
6cb42fbca1 | ||
|
|
406c172230 | ||
|
|
b4fbe81bb4 | ||
|
|
28f211bfef | ||
|
|
891257cce8 | ||
|
|
4cae237b36 | ||
|
|
c684a39191 | ||
|
|
797e4640df | ||
|
|
9684629549 | ||
|
|
a2825880bc | ||
|
|
577cd0fcea | ||
|
|
4b86085a8c | ||
|
|
0ee99e10c8 | ||
|
|
fe96110e6b | ||
|
|
35f173e17c | ||
|
|
87f8af9b97 | ||
|
|
4dd215d3d8 | ||
|
|
5a8818ac92 | ||
|
|
3baa93a0d4 | ||
|
|
72ec2f9988 | ||
|
|
ae3d063c2d | ||
|
|
d0bb27cf0c | ||
|
|
67be8e3ff8 | ||
|
|
4571ba1c24 | ||
|
|
6d601ad141 | ||
|
|
f63b15ba5a | ||
|
|
5c01d13fe3 | ||
|
|
19d2a46457 | ||
|
|
613348d37e | ||
|
|
7d473488de | ||
|
|
6e4b31b4e9 | ||
|
|
88474957a2 | ||
|
|
9dc532de30 | ||
|
|
fe37258bc2 | ||
|
|
5291e9be7f | ||
|
|
6ab02a31a2 | ||
|
|
14d9d120e6 | ||
|
|
f5981b851d | ||
|
|
c357979f11 | ||
|
|
6ee3349cca | ||
|
|
91e6eaab19 | ||
|
|
3973f1e5ed | ||
|
|
15ac5ed23b | ||
|
|
344da326cd | ||
|
|
cacfb704a4 | ||
|
|
040bb53383 | ||
|
|
5cac63bfbe | ||
|
|
8d908fe438 | ||
|
|
7db99d18c7 | ||
|
|
2bb5d6f934 | ||
|
|
bb13011046 | ||
|
|
8cc12e12da | ||
|
|
6e2b300d9e | ||
|
|
1197d72523 |
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
github: [binwiederhier]
|
||||||
39
.github/workflows/build.yaml
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
name: build
|
||||||
|
on: [push, pull_request]
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
-
|
||||||
|
name: Install Go
|
||||||
|
uses: actions/setup-go@v2
|
||||||
|
with:
|
||||||
|
go-version: '1.18.x'
|
||||||
|
-
|
||||||
|
name: Install node
|
||||||
|
uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: '17'
|
||||||
|
-
|
||||||
|
name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
-
|
||||||
|
name: Cache Go and npm modules
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/go/pkg/mod
|
||||||
|
~/go/bin
|
||||||
|
~/.npm
|
||||||
|
web/node_modules
|
||||||
|
key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-ntfy-
|
||||||
|
-
|
||||||
|
name: Install dependencies
|
||||||
|
run: make build-deps-ubuntu
|
||||||
|
-
|
||||||
|
name: Build all the things
|
||||||
|
run: make build
|
||||||
|
-
|
||||||
|
name: Print build results and checksums
|
||||||
|
run: make cli-build-results
|
||||||
36
.github/workflows/docs.yaml
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
name: docs
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
jobs:
|
||||||
|
publish-docs:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
-
|
||||||
|
name: Checkout ntfy code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
-
|
||||||
|
name: Checkout docs pages code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
repository: binwiederhier/ntfy-docs.github.io
|
||||||
|
path: build/ntfy-docs.github.io
|
||||||
|
token: ${{secrets.NTFY_DOCS_PUSH_TOKEN}}
|
||||||
|
# Expires after 1 year, re-generate via
|
||||||
|
# User -> Settings -> Developer options -> Personal Access Tokens -> Fine Grained Token
|
||||||
|
-
|
||||||
|
name: Build docs
|
||||||
|
run: make docs
|
||||||
|
-
|
||||||
|
name: Copy generated docs
|
||||||
|
run: rsync -av --exclude CNAME --delete server/docs/ build/ntfy-docs.github.io/docs/
|
||||||
|
-
|
||||||
|
name: Publish docs
|
||||||
|
run: |
|
||||||
|
cd build/ntfy-docs.github.io
|
||||||
|
git config user.name "GitHub Actions Bot"
|
||||||
|
git config user.email "<>"
|
||||||
|
git add docs/
|
||||||
|
git commit -m "Updated docs"
|
||||||
|
git push origin main
|
||||||
50
.github/workflows/release.yaml
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
name: release
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v[0-9]+.[0-9]+.[0-9]+'
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
-
|
||||||
|
name: Install Go
|
||||||
|
uses: actions/setup-go@v2
|
||||||
|
with:
|
||||||
|
go-version: '1.18.x'
|
||||||
|
-
|
||||||
|
name: Install node
|
||||||
|
uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: '17'
|
||||||
|
-
|
||||||
|
name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
-
|
||||||
|
name: Cache Go and npm modules
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/go/pkg/mod
|
||||||
|
~/go/bin
|
||||||
|
~/.npm
|
||||||
|
web/node_modules
|
||||||
|
key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-ntfy-
|
||||||
|
-
|
||||||
|
name: Docker login
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||||
|
-
|
||||||
|
name: Install dependencies
|
||||||
|
run: make build-deps-ubuntu
|
||||||
|
-
|
||||||
|
name: Build and publish
|
||||||
|
run: make release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
-
|
||||||
|
name: Print build results and checksums
|
||||||
|
run: make cli-build-results
|
||||||
44
.github/workflows/test.yaml
vendored
@@ -4,25 +4,45 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Install Go
|
-
|
||||||
|
name: Install Go
|
||||||
uses: actions/setup-go@v2
|
uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: '1.17.x'
|
go-version: '1.18.x'
|
||||||
- name: Install node
|
-
|
||||||
|
name: Install node
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '17'
|
||||||
- name: Checkout code
|
-
|
||||||
|
name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
- name: Install dependencies
|
-
|
||||||
run: sudo apt update && sudo apt install -y python3-pip curl
|
name: Cache Go and npm modules
|
||||||
- name: Build docs (required for tests)
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/go/pkg/mod
|
||||||
|
~/go/bin
|
||||||
|
~/.npm
|
||||||
|
web/node_modules
|
||||||
|
key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-ntfy-
|
||||||
|
-
|
||||||
|
name: Install dependencies
|
||||||
|
run: make build-deps-ubuntu
|
||||||
|
-
|
||||||
|
name: Build docs (required for tests)
|
||||||
run: make docs
|
run: make docs
|
||||||
- name: Build web app (required for tests)
|
-
|
||||||
|
name: Build web app (required for tests)
|
||||||
run: make web
|
run: make web
|
||||||
- name: Run tests, formatting, vetting and linting
|
-
|
||||||
|
name: Run tests, formatting, vetting and linting
|
||||||
run: make check
|
run: make check
|
||||||
- name: Run coverage
|
-
|
||||||
|
name: Run coverage
|
||||||
run: make coverage
|
run: make coverage
|
||||||
- name: Upload coverage to codecov.io
|
-
|
||||||
|
name: Upload coverage to codecov.io
|
||||||
run: make coverage-upload
|
run: make coverage-upload
|
||||||
|
|||||||
4
.gitignore
vendored
@@ -1,9 +1,13 @@
|
|||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
.idea/
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
server/docs/
|
server/docs/
|
||||||
server/site/
|
server/site/
|
||||||
tools/fbsend/fbsend
|
tools/fbsend/fbsend
|
||||||
playground/
|
playground/
|
||||||
|
secrets/
|
||||||
*.iml
|
*.iml
|
||||||
node_modules/
|
node_modules/
|
||||||
|
.DS_Store
|
||||||
|
|||||||
28
.gitpod.yml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
tasks:
|
||||||
|
- name: docs
|
||||||
|
before: make docs-deps
|
||||||
|
command: mkdocs serve
|
||||||
|
- name: binary
|
||||||
|
before: |
|
||||||
|
npm install --global nodemon
|
||||||
|
make cli-deps-static-sites
|
||||||
|
command: |
|
||||||
|
nodemon --watch './**/*.go' --ext go --signal SIGTERM --exec "CGO_ENABLED=1 go run main.go serve --listen-http :2586 --debug --base-url $(gp url 2586)"
|
||||||
|
openMode: split-right
|
||||||
|
- name: web
|
||||||
|
before: make web-deps
|
||||||
|
command: cd web && npm start
|
||||||
|
openMode: split-right
|
||||||
|
|
||||||
|
vscode:
|
||||||
|
extensions:
|
||||||
|
- golang.go
|
||||||
|
- ms-azuretools.vscode-docker
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- name: docs
|
||||||
|
port: 8000
|
||||||
|
- name: binary
|
||||||
|
port: 2586
|
||||||
|
- name: web
|
||||||
|
port: 3000
|
||||||
@@ -4,7 +4,7 @@ before:
|
|||||||
- go mod tidy
|
- go mod tidy
|
||||||
builds:
|
builds:
|
||||||
-
|
-
|
||||||
id: ntfy_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
|
||||||
@@ -17,7 +17,7 @@ builds:
|
|||||||
post:
|
post:
|
||||||
- upx "{{ .Path }}" # apt install upx
|
- upx "{{ .Path }}" # apt install upx
|
||||||
-
|
-
|
||||||
id: ntfy_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
|
||||||
@@ -28,10 +28,9 @@ builds:
|
|||||||
goos: [linux]
|
goos: [linux]
|
||||||
goarch: [arm]
|
goarch: [arm]
|
||||||
goarm: [6]
|
goarm: [6]
|
||||||
# No "upx", since it causes random core dumps, see
|
# No "upx" for ARM, see https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
|
||||||
# https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
|
|
||||||
-
|
-
|
||||||
id: ntfy_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
|
||||||
@@ -42,10 +41,9 @@ builds:
|
|||||||
goos: [linux]
|
goos: [linux]
|
||||||
goarch: [arm]
|
goarch: [arm]
|
||||||
goarm: [7]
|
goarm: [7]
|
||||||
# No "upx", since it causes random core dumps, see
|
# No "upx" for ARM, see https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
|
||||||
# https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
|
|
||||||
-
|
-
|
||||||
id: ntfy_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
|
||||||
@@ -55,8 +53,28 @@ builds:
|
|||||||
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||||
goos: [linux]
|
goos: [linux]
|
||||||
goarch: [arm64]
|
goarch: [arm64]
|
||||||
# No "upx", since it causes random core dumps, see
|
# No "upx" for ARM, see https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
|
||||||
# https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
|
-
|
||||||
|
id: ntfy_windows_amd64
|
||||||
|
binary: ntfy
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
|
||||||
|
tags: [noserver] # don't include server files
|
||||||
|
ldflags:
|
||||||
|
- "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||||
|
goos: [windows]
|
||||||
|
goarch: [amd64]
|
||||||
|
# No "upx" for Windows to hopefully avoid Virus warnings
|
||||||
|
-
|
||||||
|
id: ntfy_darwin_all
|
||||||
|
binary: ntfy
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
|
||||||
|
tags: [noserver] # don't include server files
|
||||||
|
ldflags:
|
||||||
|
- "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||||
|
goos: [darwin]
|
||||||
|
goarch: [amd64, arm64] # will be combined to "universal binary" (see below)
|
||||||
nfpms:
|
nfpms:
|
||||||
-
|
-
|
||||||
package_name: ntfy
|
package_name: ntfy
|
||||||
@@ -94,6 +112,12 @@ nfpms:
|
|||||||
postremove: "scripts/postrm.sh"
|
postremove: "scripts/postrm.sh"
|
||||||
archives:
|
archives:
|
||||||
-
|
-
|
||||||
|
id: ntfy_linux
|
||||||
|
builds:
|
||||||
|
- ntfy_linux_amd64
|
||||||
|
- ntfy_linux_armv6
|
||||||
|
- ntfy_linux_armv7
|
||||||
|
- ntfy_linux_arm64
|
||||||
wrap_in_directory: true
|
wrap_in_directory: true
|
||||||
files:
|
files:
|
||||||
- LICENSE
|
- LICENSE
|
||||||
@@ -103,8 +127,35 @@ archives:
|
|||||||
- client/client.yml
|
- client/client.yml
|
||||||
- client/ntfy-client.service
|
- client/ntfy-client.service
|
||||||
replacements:
|
replacements:
|
||||||
386: i386
|
|
||||||
amd64: x86_64
|
amd64: x86_64
|
||||||
|
-
|
||||||
|
id: ntfy_windows
|
||||||
|
builds:
|
||||||
|
- ntfy_windows_amd64
|
||||||
|
format: zip
|
||||||
|
wrap_in_directory: true
|
||||||
|
files:
|
||||||
|
- LICENSE
|
||||||
|
- README.md
|
||||||
|
- client/client.yml
|
||||||
|
replacements:
|
||||||
|
amd64: x86_64
|
||||||
|
-
|
||||||
|
id: ntfy_darwin
|
||||||
|
builds:
|
||||||
|
- ntfy_darwin_all
|
||||||
|
wrap_in_directory: true
|
||||||
|
files:
|
||||||
|
- LICENSE
|
||||||
|
- README.md
|
||||||
|
- client/client.yml
|
||||||
|
replacements:
|
||||||
|
darwin: macOS
|
||||||
|
universal_binaries:
|
||||||
|
-
|
||||||
|
id: ntfy_darwin_all
|
||||||
|
replace: true
|
||||||
|
name_template: ntfy
|
||||||
checksum:
|
checksum:
|
||||||
name_template: 'checksums.txt'
|
name_template: 'checksums.txt'
|
||||||
snapshot:
|
snapshot:
|
||||||
|
|||||||
133
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our
|
||||||
|
community include:
|
||||||
|
|
||||||
|
* Demonstrating empathy and kindness toward other people
|
||||||
|
* Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
* Giving and gracefully accepting constructive feedback
|
||||||
|
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
|
and learning from the experience
|
||||||
|
* Focusing on what is best not just for us as individuals, but for the overall
|
||||||
|
community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery, and sexual attention or advances of
|
||||||
|
any kind
|
||||||
|
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or email address,
|
||||||
|
without their explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our standards of
|
||||||
|
acceptable behavior and will take appropriate and fair corrective action in
|
||||||
|
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||||
|
or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit, or reject
|
||||||
|
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||||
|
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||||
|
decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when
|
||||||
|
an individual is officially representing the community in public spaces.
|
||||||
|
Examples of representing our community include using an official e-mail address,
|
||||||
|
posting via an official social media account, or acting as an appointed
|
||||||
|
representative at an online or offline event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
|
reported to the community leaders responsible for enforcement via Discord/Matrix (binwiederhier),
|
||||||
|
or email (ntfy@heckel.io). All complaints will be reviewed and investigated promptly
|
||||||
|
and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in determining
|
||||||
|
the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||||
|
unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders, providing
|
||||||
|
clarity around the nature of the violation and an explanation of why the
|
||||||
|
behavior was inappropriate. A public apology may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series of
|
||||||
|
actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued behavior. No
|
||||||
|
interaction with the people involved, including unsolicited interaction with
|
||||||
|
those enforcing the Code of Conduct, for a specified period of time. This
|
||||||
|
includes avoiding interactions in community spaces as well as external channels
|
||||||
|
like social media. Violating these terms may lead to a temporary or permanent
|
||||||
|
ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards, including
|
||||||
|
sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or public
|
||||||
|
communication with the community for a specified period of time. No public or
|
||||||
|
private interaction with the people involved, including unsolicited interaction
|
||||||
|
with those enforcing the Code of Conduct, is allowed during this period.
|
||||||
|
Violating these terms may lead to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction within the
|
||||||
|
community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||||
|
version 2.1, available at
|
||||||
|
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||||
|
|
||||||
|
Community Impact Guidelines were inspired by
|
||||||
|
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see the FAQ at
|
||||||
|
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
||||||
|
[https://www.contributor-covenant.org/translations][translations].
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||||
|
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||||
|
[FAQ]: https://www.contributor-covenant.org/faq
|
||||||
|
[translations]: https://www.contributor-covenant.org/translations
|
||||||
|
|
||||||
256
Makefile
@@ -1,64 +1,73 @@
|
|||||||
|
MAKEFLAGS := --jobs=1
|
||||||
VERSION := $(shell git describe --tag)
|
VERSION := $(shell git describe --tag)
|
||||||
|
COMMIT := $(shell git rev-parse --short HEAD)
|
||||||
|
|
||||||
.PHONY:
|
.PHONY:
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "Typical commands (more see below):"
|
@echo "Typical commands (more see below):"
|
||||||
@echo " make build - Build web app, documentation and server/client (sloowwww)"
|
@echo " make build - Build web app, documentation and server/client (sloowwww)"
|
||||||
@echo " make server-amd64 - Build server/client binary (amd64, no web app or docs)"
|
@echo " make cli-linux-amd64 - Build server/client binary (amd64, no web app or docs)"
|
||||||
@echo " make install-amd64 - Install ntfy binary to /usr/bin/ntfy (amd64)"
|
@echo " make install-linux-amd64 - Install ntfy binary to /usr/bin/ntfy (amd64)"
|
||||||
@echo " make web - Build the web app"
|
@echo " make web - Build the web app"
|
||||||
@echo " make docs - Build the documentation"
|
@echo " make docs - Build the documentation"
|
||||||
@echo " make check - Run all tests, vetting/formatting checks and linters"
|
@echo " make check - Run all tests, vetting/formatting checks and linters"
|
||||||
@echo
|
@echo
|
||||||
@echo "Build everything:"
|
@echo "Build everything:"
|
||||||
@echo " make build - Build web app, documentation and server/client"
|
@echo " make build - Build web app, documentation and server/client"
|
||||||
@echo " make clean - Clean build/dist folders"
|
@echo " make clean - Clean build/dist folders"
|
||||||
@echo
|
@echo
|
||||||
@echo "Build server & client (not release version):"
|
@echo "Build server & client (using GoReleaser, not release version):"
|
||||||
@echo " make server - Build server & client (all architectures)"
|
@echo " make cli - Build server & client (all architectures)"
|
||||||
@echo " make server-amd64 - Build server & client (amd64 only)"
|
@echo " make cli-linux-amd64 - Build server & client (Linux, amd64 only)"
|
||||||
@echo " make server-armv6 - Build server & client (armv6 only)"
|
@echo " make cli-linux-armv6 - Build server & client (Linux, armv6 only)"
|
||||||
@echo " make server-armv7 - Build server & client (armv7 only)"
|
@echo " make cli-linux-armv7 - Build server & client (Linux, armv7 only)"
|
||||||
@echo " make server-arm64 - Build server & client (arm64 only)"
|
@echo " make cli-linux-arm64 - Build server & client (Linux, arm64 only)"
|
||||||
|
@echo " make cli-windows-amd64 - Build client (Windows, amd64 only)"
|
||||||
|
@echo " make cli-darwin-all - Build client (macOS, arm64+amd64 universal binary)"
|
||||||
|
@echo
|
||||||
|
@echo "Build server & client (without GoReleaser):"
|
||||||
|
@echo " make cli-linux-server - Build client & server (no GoReleaser, current arch, Linux)"
|
||||||
|
@echo " make cli-darwin-server - Build client & server (no GoReleaser, current arch, macOS)"
|
||||||
|
@echo " make cli-client - Build client only (no GoReleaser, current arch, Linux/macOS/Windows)"
|
||||||
@echo
|
@echo
|
||||||
@echo "Build web app:"
|
@echo "Build web app:"
|
||||||
@echo " make web - Build the web app"
|
@echo " make web - Build the web app"
|
||||||
@echo " make web-deps - Install web app dependencies (npm install the universe)"
|
@echo " make web-deps - Install web app dependencies (npm install the universe)"
|
||||||
@echo " make web-build - Actually build the web app"
|
@echo " make web-build - Actually build the web app"
|
||||||
@echo
|
@echo
|
||||||
@echo "Build documentation:"
|
@echo "Build documentation:"
|
||||||
@echo " make docs - Build the documentation"
|
@echo " make docs - Build the documentation"
|
||||||
@echo " make docs-deps - Install Python dependencies (pip3 install)"
|
@echo " make docs-deps - Install Python dependencies (pip3 install)"
|
||||||
@echo " make docs-build - Actually build the documentation"
|
@echo " make docs-build - Actually build the documentation"
|
||||||
@echo
|
@echo
|
||||||
@echo "Test/check:"
|
@echo "Test/check:"
|
||||||
@echo " make test - Run tests"
|
@echo " make test - Run tests"
|
||||||
@echo " make race - Run tests with -race flag"
|
@echo " make race - Run tests with -race flag"
|
||||||
@echo " make coverage - Run tests and show coverage"
|
@echo " make coverage - Run tests and show coverage"
|
||||||
@echo " make coverage-html - Run tests and show coverage (as HTML)"
|
@echo " make coverage-html - Run tests and show coverage (as HTML)"
|
||||||
@echo " make coverage-upload - Upload coverage results to codecov.io"
|
@echo " make coverage-upload - Upload coverage results to codecov.io"
|
||||||
@echo
|
@echo
|
||||||
@echo "Lint/format:"
|
@echo "Lint/format:"
|
||||||
@echo " make fmt - Run 'go fmt'"
|
@echo " make fmt - Run 'go fmt'"
|
||||||
@echo " make fmt-check - Run 'go fmt', but don't change anything"
|
@echo " make fmt-check - Run 'go fmt', but don't change anything"
|
||||||
@echo " make vet - Run 'go vet'"
|
@echo " make vet - Run 'go vet'"
|
||||||
@echo " make lint - Run 'golint'"
|
@echo " make lint - Run 'golint'"
|
||||||
@echo " make staticcheck - Run 'staticcheck'"
|
@echo " make staticcheck - Run 'staticcheck'"
|
||||||
@echo
|
@echo
|
||||||
@echo "Releasing:"
|
@echo "Releasing:"
|
||||||
@echo " make release - Create a release"
|
@echo " make release - Create a release"
|
||||||
@echo " make release-snapshot - Create a test release"
|
@echo " make release-snapshot - Create a test release"
|
||||||
@echo
|
@echo
|
||||||
@echo "Install locally (requires sudo):"
|
@echo "Install locally (requires sudo):"
|
||||||
@echo " make install-amd64 - Copy amd64 binary from dist/ to /usr/bin/ntfy"
|
@echo " make install-linux-amd64 - Copy amd64 binary from dist/ to /usr/bin/ntfy"
|
||||||
@echo " make install-armv6 - Copy armv6 binary from dist/ to /usr/bin/ntfy"
|
@echo " make install-linux-armv6 - Copy armv6 binary from dist/ to /usr/bin/ntfy"
|
||||||
@echo " make install-armv7 - Copy armv7 binary from dist/ to /usr/bin/ntfy"
|
@echo " make install-linux-armv7 - Copy armv7 binary from dist/ to /usr/bin/ntfy"
|
||||||
@echo " make install-arm64 - Copy arm64 binary from dist/ to /usr/bin/ntfy"
|
@echo " make install-linux-arm64 - Copy arm64 binary from dist/ to /usr/bin/ntfy"
|
||||||
@echo " make install-deb-amd64 - Install .deb from dist/ (amd64 only)"
|
@echo " make install-linux-deb-amd64 - Install .deb from dist/ (amd64 only)"
|
||||||
@echo " make install-deb-armv6 - Install .deb from dist/ (armv6 only)"
|
@echo " make install-linux-deb-armv6 - Install .deb from dist/ (armv6 only)"
|
||||||
@echo " make install-deb-armv7 - Install .deb from dist/ (armv7 only)"
|
@echo " make install-linux-deb-armv7 - Install .deb from dist/ (armv7 only)"
|
||||||
@echo " make install-deb-arm64 - Install .deb from dist/ (arm64 only)"
|
@echo " make install-linux-deb-arm64 - Install .deb from dist/ (arm64 only)"
|
||||||
|
|
||||||
|
|
||||||
# Building everything
|
# Building everything
|
||||||
@@ -66,28 +75,52 @@ help:
|
|||||||
clean: .PHONY
|
clean: .PHONY
|
||||||
rm -rf dist build server/docs server/site
|
rm -rf dist build server/docs server/site
|
||||||
|
|
||||||
build: web docs server
|
build: web docs cli
|
||||||
|
|
||||||
|
update: web-deps-update cli-deps-update docs-deps-update
|
||||||
|
docker pull alpine
|
||||||
|
|
||||||
|
# Ubuntu-specific
|
||||||
|
|
||||||
|
build-deps-ubuntu:
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y \
|
||||||
|
curl \
|
||||||
|
gcc-aarch64-linux-gnu \
|
||||||
|
gcc-arm-linux-gnueabi \
|
||||||
|
upx \
|
||||||
|
jq
|
||||||
|
which pip3 || sudo apt install -y python3-pip
|
||||||
|
|
||||||
# Documentation
|
# Documentation
|
||||||
|
|
||||||
docs: docs-deps docs-build
|
docs: docs-deps docs-build
|
||||||
|
|
||||||
|
docs-build: .PHONY
|
||||||
|
@if ! /bin/echo -e "import sys\nif sys.version_info < (3,8):\n exit(1)" | python3; then \
|
||||||
|
if which python3.8; then \
|
||||||
|
echo "python3.8 $(shell which mkdocs) build"; \
|
||||||
|
python3.8 $(shell which mkdocs) build; \
|
||||||
|
else \
|
||||||
|
echo "ERROR: Python version too low. mkdocs-material needs >= 3.8"; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
else \
|
||||||
|
echo "mkdocs build"; \
|
||||||
|
mkdocs build; \
|
||||||
|
fi
|
||||||
|
|
||||||
docs-deps: .PHONY
|
docs-deps: .PHONY
|
||||||
pip3 install -r requirements.txt
|
pip3 install -r requirements.txt
|
||||||
|
|
||||||
docs-build: .PHONY
|
docs-deps-update: .PHONY
|
||||||
mkdocs build
|
pip3 install -r requirements.txt --upgrade
|
||||||
|
|
||||||
|
|
||||||
# Web app
|
# Web app
|
||||||
|
|
||||||
web: web-deps web-build
|
web: web-deps web-build
|
||||||
|
|
||||||
web-deps:
|
|
||||||
cd web && npm install
|
|
||||||
# If this fails for .svg files, optimizes them with svgo
|
|
||||||
|
|
||||||
web-build:
|
web-build:
|
||||||
cd web \
|
cd web \
|
||||||
&& npm run build \
|
&& npm run build \
|
||||||
@@ -98,41 +131,100 @@ web-build:
|
|||||||
../server/site/config.js \
|
../server/site/config.js \
|
||||||
../server/site/asset-manifest.json
|
../server/site/asset-manifest.json
|
||||||
|
|
||||||
|
web-deps:
|
||||||
|
cd web && npm install
|
||||||
|
# If this fails for .svg files, optimize them with svgo
|
||||||
|
|
||||||
|
web-deps-update:
|
||||||
|
cd web && npm update
|
||||||
|
|
||||||
|
|
||||||
# Main server/client build
|
# Main server/client build
|
||||||
|
|
||||||
server: server-deps
|
cli: cli-deps
|
||||||
goreleaser build --snapshot --rm-dist --debug
|
goreleaser build --snapshot --rm-dist
|
||||||
|
|
||||||
server-amd64: server-deps-static-sites
|
cli-linux-amd64: cli-deps-static-sites
|
||||||
goreleaser build --snapshot --rm-dist --debug --id ntfy_amd64
|
goreleaser build --snapshot --rm-dist --id ntfy_linux_amd64
|
||||||
|
|
||||||
server-armv6: server-deps-static-sites server-deps-gcc-armv6-armv7
|
cli-linux-armv6: cli-deps-static-sites cli-deps-gcc-armv6-armv7
|
||||||
goreleaser build --snapshot --rm-dist --debug --id ntfy_armv6
|
goreleaser build --snapshot --rm-dist --id ntfy_linux_armv6
|
||||||
|
|
||||||
server-armv7: server-deps-static-sites server-deps-gcc-armv6-armv7
|
cli-linux-armv7: cli-deps-static-sites cli-deps-gcc-armv6-armv7
|
||||||
goreleaser build --snapshot --rm-dist --debug --id ntfy_armv7
|
goreleaser build --snapshot --rm-dist --id ntfy_linux_armv7
|
||||||
|
|
||||||
server-arm64: server-deps-static-sites server-deps-gcc-arm64
|
cli-linux-arm64: cli-deps-static-sites cli-deps-gcc-arm64
|
||||||
goreleaser build --snapshot --rm-dist --debug --id ntfy_arm64
|
goreleaser build --snapshot --rm-dist --id ntfy_linux_arm64
|
||||||
|
|
||||||
server-deps: server-deps-static-sites server-deps-all server-deps-gcc
|
cli-windows-amd64: cli-deps-static-sites
|
||||||
|
goreleaser build --snapshot --rm-dist --id ntfy_windows_amd64
|
||||||
|
|
||||||
server-deps-gcc: server-deps-gcc-armv6-armv7 server-deps-gcc-arm64
|
cli-darwin-all: cli-deps-static-sites
|
||||||
|
goreleaser build --snapshot --rm-dist --id ntfy_darwin_all
|
||||||
|
|
||||||
server-deps-static-sites:
|
cli-linux-server: cli-deps-static-sites
|
||||||
|
# This is a target to build the CLI (including the server) manually.
|
||||||
|
# Use this for development, if you really don't want to install GoReleaser ...
|
||||||
|
mkdir -p dist/ntfy_linux_server server/docs
|
||||||
|
CGO_ENABLED=1 go build \
|
||||||
|
-o dist/ntfy_linux_server/ntfy \
|
||||||
|
-tags sqlite_omit_load_extension,osusergo,netgo \
|
||||||
|
-ldflags \
|
||||||
|
"-linkmode=external -extldflags=-static -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)"
|
||||||
|
|
||||||
|
cli-darwin-server: cli-deps-static-sites
|
||||||
|
# This is a target to build the CLI (including the server) manually.
|
||||||
|
# Use this for macOS/iOS development, so you have a local server to test with.
|
||||||
|
mkdir -p dist/ntfy_darwin_server server/docs
|
||||||
|
CGO_ENABLED=1 go build \
|
||||||
|
-o dist/ntfy_darwin_server/ntfy \
|
||||||
|
-tags sqlite_omit_load_extension,osusergo,netgo \
|
||||||
|
-ldflags \
|
||||||
|
"-linkmode=external -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)"
|
||||||
|
|
||||||
|
cli-client: cli-deps-static-sites
|
||||||
|
# This is a target to build the CLI (excluding the server) manually. This should work on Linux/macOS/Windows.
|
||||||
|
# Use this for development, if you really don't want to install GoReleaser ...
|
||||||
|
mkdir -p dist/ntfy_client server/docs
|
||||||
|
CGO_ENABLED=0 go build \
|
||||||
|
-o dist/ntfy_client/ntfy \
|
||||||
|
-tags noserver \
|
||||||
|
-ldflags \
|
||||||
|
"-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)"
|
||||||
|
|
||||||
|
cli-deps: cli-deps-static-sites cli-deps-all cli-deps-gcc
|
||||||
|
|
||||||
|
cli-deps-gcc: cli-deps-gcc-armv6-armv7 cli-deps-gcc-arm64
|
||||||
|
|
||||||
|
cli-deps-static-sites:
|
||||||
mkdir -p server/docs server/site
|
mkdir -p server/docs server/site
|
||||||
touch server/docs/index.html server/site/app.html
|
touch server/docs/index.html server/site/app.html
|
||||||
|
|
||||||
server-deps-all:
|
cli-deps-all:
|
||||||
which upx || { echo "ERROR: upx not installed. On Ubuntu, run: apt install upx"; exit 1; }
|
which upx || { echo "ERROR: upx not installed. On Ubuntu, run: apt install upx"; exit 1; }
|
||||||
|
go install github.com/goreleaser/goreleaser@latest
|
||||||
|
|
||||||
server-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; }
|
||||||
|
|
||||||
server-deps-gcc-arm64:
|
cli-deps-gcc-arm64:
|
||||||
which aarch64-linux-gnu-gcc || { echo "ERROR: ARM64 cross compiler not installed. On Ubuntu, run: apt install gcc-aarch64-linux-gnu"; exit 1; }
|
which aarch64-linux-gnu-gcc || { echo "ERROR: ARM64 cross compiler not installed. On Ubuntu, run: apt install gcc-aarch64-linux-gnu"; exit 1; }
|
||||||
|
|
||||||
|
cli-deps-update:
|
||||||
|
go get -u
|
||||||
|
go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||||
|
go install golang.org/x/lint/golint@latest
|
||||||
|
go install github.com/goreleaser/goreleaser@latest
|
||||||
|
|
||||||
|
cli-build-results:
|
||||||
|
cat dist/config.yaml
|
||||||
|
[ -f dist/artifacts.json ] && cat dist/artifacts.json | jq . || true
|
||||||
|
[ -f dist/metadata.json ] && cat dist/metadata.json | jq . || true
|
||||||
|
[ -f dist/checksums.txt ] && cat dist/checksums.txt || true
|
||||||
|
find dist -maxdepth 2 -type f \
|
||||||
|
\( -name '*.deb' -or -name '*.rpm' -or -name '*.zip' -or -name '*.tar.gz' -or -name 'ntfy' \) \
|
||||||
|
-and -not -path 'dist/goreleaserdocker*' \
|
||||||
|
-exec sha256sum {} \;
|
||||||
|
|
||||||
# Test/check targets
|
# Test/check targets
|
||||||
|
|
||||||
@@ -184,13 +276,13 @@ staticcheck: .PHONY
|
|||||||
|
|
||||||
# Releasing targets
|
# Releasing targets
|
||||||
|
|
||||||
release: clean server-deps release-check-tags docs web check
|
release: clean update cli-deps release-checks docs web check
|
||||||
goreleaser release --rm-dist --debug
|
goreleaser release --rm-dist
|
||||||
|
|
||||||
release-snapshot: clean server-deps docs web check
|
release-snapshot: clean update cli-deps docs web check
|
||||||
goreleaser release --snapshot --skip-publish --rm-dist --debug
|
goreleaser release --snapshot --skip-publish --rm-dist
|
||||||
|
|
||||||
release-check-tags:
|
release-checks:
|
||||||
$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-))
|
$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-))
|
||||||
if ! grep -q $(LATEST_TAG) docs/install.md; then\
|
if ! grep -q $(LATEST_TAG) docs/install.md; then\
|
||||||
echo "ERROR: Must update docs/install.md with latest tag first.";\
|
echo "ERROR: Must update docs/install.md with latest tag first.";\
|
||||||
@@ -200,35 +292,39 @@ release-check-tags:
|
|||||||
echo "ERROR: Must update docs/releases.md with latest tag first.";\
|
echo "ERROR: Must update docs/releases.md with latest tag first.";\
|
||||||
exit 1;\
|
exit 1;\
|
||||||
fi
|
fi
|
||||||
|
if [ -n "$(shell git status -s)" ]; then\
|
||||||
|
echo "ERROR: Git repository is in an unclean state.";\
|
||||||
|
exit 1;\
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
# Installing targets
|
# Installing targets
|
||||||
|
|
||||||
install-amd64: remove-binary
|
install-linux-amd64: remove-binary
|
||||||
sudo cp -a dist/ntfy_amd64_linux_amd64_v1/ntfy /usr/bin/ntfy
|
sudo cp -a dist/ntfy_linux_amd64_linux_amd64_v1/ntfy /usr/bin/ntfy
|
||||||
|
|
||||||
install-armv6: remove-binary
|
install-linux-armv6: remove-binary
|
||||||
sudo cp -a dist/ntfy_armv6_linux_arm_6/ntfy /usr/bin/ntfy
|
sudo cp -a dist/ntfy_linux_armv6_linux_arm_6/ntfy /usr/bin/ntfy
|
||||||
|
|
||||||
install-armv7: remove-binary
|
install-linux-armv7: remove-binary
|
||||||
sudo cp -a dist/ntfy_armv7_linux_arm_7/ntfy /usr/bin/ntfy
|
sudo cp -a dist/ntfy_linux_armv7_linux_arm_7/ntfy /usr/bin/ntfy
|
||||||
|
|
||||||
install-arm64: remove-binary
|
install-linux-arm64: remove-binary
|
||||||
sudo cp -a dist/ntfy_arm64_linux_arm64/ntfy /usr/bin/ntfy
|
sudo cp -a dist/ntfy_linux_arm64_linux_arm64/ntfy /usr/bin/ntfy
|
||||||
|
|
||||||
remove-binary:
|
remove-binary:
|
||||||
sudo rm -f /usr/bin/ntfy
|
sudo rm -f /usr/bin/ntfy
|
||||||
|
|
||||||
install-amd64-deb: purge-package
|
install-linux-amd64-deb: purge-package
|
||||||
sudo dpkg -i dist/ntfy_*_linux_amd64.deb
|
sudo dpkg -i dist/ntfy_*_linux_amd64.deb
|
||||||
|
|
||||||
install-armv6-deb: purge-package
|
install-linux-armv6-deb: purge-package
|
||||||
sudo dpkg -i dist/ntfy_*_linux_armv6.deb
|
sudo dpkg -i dist/ntfy_*_linux_armv6.deb
|
||||||
|
|
||||||
install-armv7-deb: purge-package
|
install-linux-armv7-deb: purge-package
|
||||||
sudo dpkg -i dist/ntfy_*_linux_armv7.deb
|
sudo dpkg -i dist/ntfy_*_linux_armv7.deb
|
||||||
|
|
||||||
install-arm64-deb: purge-package
|
install-linux-arm64-deb: purge-package
|
||||||
sudo dpkg -i dist/ntfy_*_linux_arm64.deb
|
sudo dpkg -i dist/ntfy_*_linux_arm64.deb
|
||||||
|
|
||||||
purge-package:
|
purge-package:
|
||||||
|
|||||||
89
README.md
@@ -8,14 +8,17 @@
|
|||||||
[](https://codecov.io/gh/binwiederhier/ntfy)
|
[](https://codecov.io/gh/binwiederhier/ntfy)
|
||||||
[](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://www.reddit.com/r/ntfy/)
|
||||||
[](https://ntfy.statuspage.io/)
|
[](https://ntfy.statuspage.io/)
|
||||||
|
[](https://gitpod.io/#https://github.com/binwiederhier/ntfy)
|
||||||
|
|
||||||
|
|
||||||
**ntfy** (pronounce: *notify*) is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) notification service.
|
**ntfy** (pronounce: *notify*) is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) notification service.
|
||||||
It allows you to **send notifications to your phone or desktop via scripts** from any computer, entirely **without signup or cost**.
|
It allows you to **send notifications to your phone or desktop via scripts** from any computer, entirely **without signup or cost**.
|
||||||
It's also open source (as you can plainly see) if you want to run your own.
|
It's also open source (as you can plainly see) if you want to run your own.
|
||||||
|
|
||||||
I run a free version of it at **[ntfy.sh](https://ntfy.sh)**, and there's an [open source](https://github.com/binwiederhier/ntfy-android) [Android app](https://play.google.com/store/apps/details?id=io.heckel.ntfy)
|
I run a free version of it at **[ntfy.sh](https://ntfy.sh)**. There's also an [open source Android app](https://github.com/binwiederhier/ntfy-android) (see [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) or [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/)), and an [open source iOS app](https://github.com/binwiederhier/ntfy-ios) (see [App Store](https://apps.apple.com/us/app/ntfy/id1625396347)).
|
||||||
too.
|
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src="web/public/static/img/screenshot-curl.png" height="180">
|
<img src="web/public/static/img/screenshot-curl.png" height="180">
|
||||||
@@ -33,27 +36,93 @@ too.
|
|||||||
[Install / Self-hosting](https://ntfy.sh/docs/install/) |
|
[Install / Self-hosting](https://ntfy.sh/docs/install/) |
|
||||||
[Building](https://ntfy.sh/docs/develop/)
|
[Building](https://ntfy.sh/docs/develop/)
|
||||||
|
|
||||||
|
## Chat / forum
|
||||||
|
There are a few ways to get in touch with me and/or the rest of the community. Feel free to use any of these methods. Whatever
|
||||||
|
works best for you:
|
||||||
|
|
||||||
|
* [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
|
||||||
|
* [Reddit r/ntfy](https://www.reddit.com/r/ntfy/) - asynchronous forum (_new as of October 2022_)
|
||||||
|
* [GitHub issues](https://github.com/binwiederhier/ntfy/issues) - questions, features, bugs
|
||||||
|
* [Email](https://heckel.io/about) - reach me directly (_I usually prefer the other methods_)
|
||||||
|
|
||||||
|
## Announcements / beta testers
|
||||||
|
For announcements of new releases and cutting-edge beta versions, please subscribe to the [ntfy.sh/announcements](https://ntfy.sh/announcements)
|
||||||
|
topic. If you'd like to test the iOS app, join [TestFlight](https://testflight.apple.com/join/P1fFnAm9). For Android betas,
|
||||||
|
join Discord/Matrix (I'll eventually make a testing channel in Google Play).
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
I welcome any and all contributions. Just create a PR or an issue. To contribute code, check out
|
I welcome any and all contributions. Just create a PR or an issue. For larger features/ideas, please reach out
|
||||||
the [build instructions](https://ntfy.sh/docs/develop/) for the server and the Android app.
|
on Discord/Matrix first to see if I'd accept them. To contribute code, check out the [build instructions](https://ntfy.sh/docs/develop/)
|
||||||
Or, if you'd like to help translate 🇩🇪 🇺🇸 🇧🇬, you can start immediately in
|
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/).
|
[Hosted Weblate](https://hosted.weblate.org/projects/ntfy/).
|
||||||
|
|
||||||
<a href="https://hosted.weblate.org/engage/ntfy/">
|
<a href="https://hosted.weblate.org/engage/ntfy/">
|
||||||
<img src="https://hosted.weblate.org/widgets/ntfy/-/multi-blue.svg" alt="Translation status" />
|
<img src="https://hosted.weblate.org/widgets/ntfy/-/multi-blue.svg" alt="Translation status" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
## Contact me
|
## Sponsors
|
||||||
You can directly contact me **[on Discord](https://discord.gg/cT7ECsZj9w)** or [on Matrix](https://matrix.to/#/#ntfy:matrix.org)
|
I have just very recently started accepting donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier).
|
||||||
(bridged from Discord), or via the [GitHub issues](https://github.com/binwiederhier/ntfy/issues), or find more contact information
|
I would be humbled if you helped me carry the server and developer account costs. Even small donations are very much
|
||||||
[on my website](https://heckel.io/about).
|
appreciated. A big fat **Thank You** to the folks already sponsoring ntfy:
|
||||||
|
|
||||||
|
<a href="https://github.com/neutralinsomniac"><img src="https://github.com/neutralinsomniac.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/aspyct"><img src="https://github.com/aspyct.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/nickexyz"><img src="https://github.com/nickexyz.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/qcasey"><img src="https://github.com/qcasey.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/mckay115"><img src="https://github.com/mckay115.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/Salamafet"><img src="https://github.com/Salamafet.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/codinghipster"><img src="https://github.com/codinghipster.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/HinFort"><img src="https://github.com/HinFort.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/Lexevolution"><img src="https://github.com/Lexevolution.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/johnnyip"><img src="https://github.com/johnnyip.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/JonDerThan"><img src="https://github.com/JonDerThan.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/12nick12"><img src="https://github.com/12nick12.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/eanplatter"><img src="https://github.com/eanplatter.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/fnoelscher"><img src="https://github.com/fnoelscher.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/bnorick"><img src="https://github.com/bnorick.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/snh"><img src="https://github.com/snh.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/hen-x"><img src="https://github.com/hen-x.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/JamieGoodson"><img src="https://github.com/JamieGoodson.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/cremesk"><img src="https://github.com/cremesk.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/dangowans"><img src="https://github.com/dangowans.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/mnault"><img src="https://github.com/mnault.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/nwithan8"><img src="https://github.com/nwithan8.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/peterleiser"><img src="https://github.com/peterleiser.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/portothree"><img src="https://github.com/portothree.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/finngreig"><img src="https://github.com/finngreig.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/skrollme"><img src="https://github.com/skrollme.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/gergepalfi"><img src="https://github.com/gergepalfi.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/tonyakwei"><img src="https://github.com/tonyakwei.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/crosbyh"><img src="https://github.com/crosbyh.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/mdlnr"><img src="https://github.com/mdlnr.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/p-samuel"><img src="https://github.com/p-samuel.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/zugaldia"><img src="https://github.com/zugaldia.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/NathanSweet"><img src="https://github.com/NathanSweet.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/msdeibel"><img src="https://github.com/msdeibel.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/ksurl"><img src="https://github.com/ksurl.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/CodingTimeDEV"><img src="https://github.com/CodingTimeDEV.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/Terrormixer3000"><img src="https://github.com/Terrormixer3000.png" width="40px" /></a>
|
||||||
|
<a href="https://github.com/voroskoi"><img src="https://github.com/voroskoi.png" width="40px" /></a>
|
||||||
|
|
||||||
|
I'd also like to thank JetBrains for providing their awesome [IntelliJ IDEA](https://www.jetbrains.com/idea/) to me for free,
|
||||||
|
and [DigitalOcean](https://www.digitalocean.com/) for supporting the project with $60/yr:
|
||||||
|
|
||||||
|
<a href="https://www.digitalocean.com/"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="201px"></a>
|
||||||
|
|
||||||
|
## Code of Conduct
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
|
||||||
|
|
||||||
|
**We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.**
|
||||||
|
|
||||||
|
_Please be sure to read the complete [Code of Conduct](CODE_OF_CONDUCT.md)._
|
||||||
|
|
||||||
## License
|
## License
|
||||||
Made with ❤️ by [Philipp C. Heckel](https://heckel.io).
|
Made with ❤️ by [Philipp C. Heckel](https://heckel.io).
|
||||||
The project is dual licensed under the [Apache License 2.0](LICENSE) and the [GPLv2 License](LICENSE.GPLv2).
|
The project is dual licensed under the [Apache License 2.0](LICENSE) and the [GPLv2 License](LICENSE.GPLv2).
|
||||||
|
|
||||||
Third party libraries and resources:
|
Third party libraries and resources:
|
||||||
* [github.com/urfave/cli/v2](https://github.com/urfave/cli/v2) (MIT) is used to drive the CLI
|
* [github.com/urfave/cli](https://github.com/urfave/cli) (MIT) is used to drive the CLI
|
||||||
* [Mixkit sounds](https://mixkit.co/free-sound-effects/notification/) (Mixkit Free License) are used as notification sounds
|
* [Mixkit sounds](https://mixkit.co/free-sound-effects/notification/) (Mixkit Free License) are used as notification sounds
|
||||||
* [Sounds from notificationsounds.com](https://notificationsounds.com) (Creative Commons Attribution) are used as notification sounds
|
* [Sounds from notificationsounds.com](https://notificationsounds.com) (Creative Commons Attribution) are used as notification sounds
|
||||||
* [Roboto Font](https://fonts.google.com/specimen/Roboto) (Apache 2.0) is used as a font in everything web
|
* [Roboto Font](https://fonts.google.com/specimen/Roboto) (Apache 2.0) is used as a font in everything web
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -47,6 +47,7 @@ type Message struct { // TODO combine with server.message
|
|||||||
Priority int
|
Priority int
|
||||||
Tags []string
|
Tags []string
|
||||||
Click string
|
Click string
|
||||||
|
Icon string
|
||||||
Attachment *Attachment
|
Attachment *Attachment
|
||||||
|
|
||||||
// Additional fields
|
// Additional fields
|
||||||
@@ -102,6 +103,7 @@ func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishO
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
log.Debug("%s Publishing message with headers %s", util.ShortTopicURL(topicURL), req.Header)
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -136,6 +138,7 @@ func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, err
|
|||||||
msgChan := make(chan *Message)
|
msgChan := make(chan *Message)
|
||||||
errChan := make(chan error)
|
errChan := make(chan error)
|
||||||
topicURL := c.expandTopicURL(topic)
|
topicURL := c.expandTopicURL(topic)
|
||||||
|
log.Debug("%s Polling from topic", util.ShortTopicURL(topicURL))
|
||||||
options = append(options, WithPoll())
|
options = append(options, WithPoll())
|
||||||
go func() {
|
go func() {
|
||||||
err := performSubscribeRequest(ctx, msgChan, topicURL, "", options...)
|
err := performSubscribeRequest(ctx, msgChan, topicURL, "", options...)
|
||||||
@@ -161,16 +164,18 @@ func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, err
|
|||||||
// The method returns a unique subscriptionID that can be used in Unsubscribe.
|
// The method returns a unique subscriptionID that can be used in Unsubscribe.
|
||||||
//
|
//
|
||||||
// Example:
|
// Example:
|
||||||
// c := client.New(client.NewConfig())
|
//
|
||||||
// subscriptionID := c.Subscribe("mytopic")
|
// c := client.New(client.NewConfig())
|
||||||
// for m := range c.Messages {
|
// subscriptionID := c.Subscribe("mytopic")
|
||||||
// fmt.Printf("New message: %s", m.Message)
|
// for m := range c.Messages {
|
||||||
// }
|
// fmt.Printf("New message: %s", m.Message)
|
||||||
|
// }
|
||||||
func (c *Client) Subscribe(topic string, options ...SubscribeOption) string {
|
func (c *Client) Subscribe(topic string, options ...SubscribeOption) string {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
subscriptionID := util.RandomString(10)
|
subscriptionID := util.RandomString(10)
|
||||||
topicURL := c.expandTopicURL(topic)
|
topicURL := c.expandTopicURL(topic)
|
||||||
|
log.Debug("%s Subscribing to topic", util.ShortTopicURL(topicURL))
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
c.subscriptions[subscriptionID] = &subscription{
|
c.subscriptions[subscriptionID] = &subscription{
|
||||||
ID: subscriptionID,
|
ID: subscriptionID,
|
||||||
@@ -226,11 +231,11 @@ func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicUR
|
|||||||
// TODO The retry logic is crude and may lose messages. It should record the last message like the
|
// TODO The retry logic is crude and may lose messages. It should record the last message like the
|
||||||
// Android client, use since=, and do incremental backoff too
|
// Android client, use since=, and do incremental backoff too
|
||||||
if err := performSubscribeRequest(ctx, msgChan, topicURL, subcriptionID, options...); err != nil {
|
if err := performSubscribeRequest(ctx, msgChan, topicURL, subcriptionID, options...); err != nil {
|
||||||
log.Printf("Connection to %s failed: %s", topicURL, err.Error())
|
log.Warn("%s Connection failed: %s", util.ShortTopicURL(topicURL), err.Error())
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
log.Printf("Connection to %s exited", topicURL)
|
log.Info("%s Connection exited", util.ShortTopicURL(topicURL))
|
||||||
return
|
return
|
||||||
case <-time.After(10 * time.Second): // TODO Add incremental backoff
|
case <-time.After(10 * time.Second): // TODO Add incremental backoff
|
||||||
}
|
}
|
||||||
@@ -238,7 +243,9 @@ func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicUR
|
|||||||
}
|
}
|
||||||
|
|
||||||
func performSubscribeRequest(ctx context.Context, msgChan chan *Message, topicURL string, subscriptionID string, options ...SubscribeOption) error {
|
func performSubscribeRequest(ctx context.Context, msgChan chan *Message, topicURL string, subscriptionID string, options ...SubscribeOption) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/json", topicURL), nil)
|
streamURL := fmt.Sprintf("%s/json", topicURL)
|
||||||
|
log.Debug("%s Listening to %s", util.ShortTopicURL(topicURL), streamURL)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, streamURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -261,10 +268,12 @@ func performSubscribeRequest(ctx context.Context, msgChan chan *Message, topicUR
|
|||||||
}
|
}
|
||||||
scanner := bufio.NewScanner(resp.Body)
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
m, err := toMessage(scanner.Text(), topicURL, subscriptionID)
|
messageJSON := scanner.Text()
|
||||||
|
m, err := toMessage(messageJSON, topicURL, subscriptionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
log.Trace("%s Message received: %s", util.ShortTopicURL(topicURL), messageJSON)
|
||||||
if m.Event == MessageEvent {
|
if m.Event == MessageEvent {
|
||||||
msgChan <- m
|
msgChan <- m
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,16 @@
|
|||||||
#
|
#
|
||||||
# default-host: https://ntfy.sh
|
# default-host: https://ntfy.sh
|
||||||
|
|
||||||
|
# Default username and password will be used with "ntfy publish" if no credentials are provided on command line
|
||||||
|
# Default username and password will be used with "ntfy subscribe" if no credentials are provided in subscription below
|
||||||
|
# For an empty password, use empty double-quotes ("")
|
||||||
|
#
|
||||||
|
# default-user:
|
||||||
|
# default-password:
|
||||||
|
|
||||||
|
# Default command will execute after "ntfy subscribe" receives a message if no command is provided in subscription below
|
||||||
|
# default-command:
|
||||||
|
|
||||||
# Subscriptions to topics and their actions. This option is primarily used by the systemd service,
|
# Subscriptions to topics and their actions. This option is primarily used by the systemd service,
|
||||||
# or if you cann "ntfy subscribe --from-config" directly.
|
# or if you cann "ntfy subscribe --from-config" directly.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -12,11 +12,14 @@ const (
|
|||||||
|
|
||||||
// Config is the config struct for a Client
|
// Config is the config struct for a Client
|
||||||
type Config struct {
|
type Config struct {
|
||||||
DefaultHost string `yaml:"default-host"`
|
DefaultHost string `yaml:"default-host"`
|
||||||
Subscribe []struct {
|
DefaultUser string `yaml:"default-user"`
|
||||||
|
DefaultPassword *string `yaml:"default-password"`
|
||||||
|
DefaultCommand string `yaml:"default-command"`
|
||||||
|
Subscribe []struct {
|
||||||
Topic string `yaml:"topic"`
|
Topic string `yaml:"topic"`
|
||||||
User string `yaml:"user"`
|
User string `yaml:"user"`
|
||||||
Password string `yaml:"password"`
|
Password *string `yaml:"password"`
|
||||||
Command string `yaml:"command"`
|
Command string `yaml:"command"`
|
||||||
If map[string]string `yaml:"if"`
|
If map[string]string `yaml:"if"`
|
||||||
} `yaml:"subscribe"`
|
} `yaml:"subscribe"`
|
||||||
@@ -25,8 +28,11 @@ type Config struct {
|
|||||||
// NewConfig creates a new Config struct for a Client
|
// NewConfig creates a new Config struct for a Client
|
||||||
func NewConfig() *Config {
|
func NewConfig() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
DefaultHost: DefaultBaseURL,
|
DefaultHost: DefaultBaseURL,
|
||||||
Subscribe: nil,
|
DefaultUser: "",
|
||||||
|
DefaultPassword: nil,
|
||||||
|
DefaultCommand: "",
|
||||||
|
Subscribe: nil,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ func TestConfig_Load(t *testing.T) {
|
|||||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||||
require.Nil(t, os.WriteFile(filename, []byte(`
|
require.Nil(t, os.WriteFile(filename, []byte(`
|
||||||
default-host: http://localhost
|
default-host: http://localhost
|
||||||
|
default-user: philipp
|
||||||
|
default-password: mypass
|
||||||
|
default-command: 'echo "Got the message: $message"'
|
||||||
subscribe:
|
subscribe:
|
||||||
- topic: no-command-with-auth
|
- topic: no-command-with-auth
|
||||||
user: phil
|
user: phil
|
||||||
@@ -22,19 +25,94 @@ subscribe:
|
|||||||
command: notify-send -i /usr/share/ntfy/logo.png "Important" "$m"
|
command: notify-send -i /usr/share/ntfy/logo.png "Important" "$m"
|
||||||
if:
|
if:
|
||||||
priority: high,urgent
|
priority: high,urgent
|
||||||
|
- topic: defaults
|
||||||
`), 0600))
|
`), 0600))
|
||||||
|
|
||||||
conf, err := client.LoadConfig(filename)
|
conf, err := client.LoadConfig(filename)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, "http://localhost", conf.DefaultHost)
|
require.Equal(t, "http://localhost", conf.DefaultHost)
|
||||||
require.Equal(t, 3, len(conf.Subscribe))
|
require.Equal(t, "philipp", conf.DefaultUser)
|
||||||
|
require.Equal(t, "mypass", *conf.DefaultPassword)
|
||||||
|
require.Equal(t, `echo "Got the message: $message"`, conf.DefaultCommand)
|
||||||
|
require.Equal(t, 4, len(conf.Subscribe))
|
||||||
require.Equal(t, "no-command-with-auth", conf.Subscribe[0].Topic)
|
require.Equal(t, "no-command-with-auth", conf.Subscribe[0].Topic)
|
||||||
require.Equal(t, "", conf.Subscribe[0].Command)
|
require.Equal(t, "", conf.Subscribe[0].Command)
|
||||||
require.Equal(t, "phil", conf.Subscribe[0].User)
|
require.Equal(t, "phil", conf.Subscribe[0].User)
|
||||||
require.Equal(t, "mypass", conf.Subscribe[0].Password)
|
require.Equal(t, "mypass", *conf.Subscribe[0].Password)
|
||||||
require.Equal(t, "echo-this", conf.Subscribe[1].Topic)
|
require.Equal(t, "echo-this", conf.Subscribe[1].Topic)
|
||||||
require.Equal(t, `echo "Message received: $message"`, conf.Subscribe[1].Command)
|
require.Equal(t, `echo "Message received: $message"`, conf.Subscribe[1].Command)
|
||||||
require.Equal(t, "alerts", conf.Subscribe[2].Topic)
|
require.Equal(t, "alerts", conf.Subscribe[2].Topic)
|
||||||
require.Equal(t, `notify-send -i /usr/share/ntfy/logo.png "Important" "$m"`, conf.Subscribe[2].Command)
|
require.Equal(t, `notify-send -i /usr/share/ntfy/logo.png "Important" "$m"`, conf.Subscribe[2].Command)
|
||||||
require.Equal(t, "high,urgent", conf.Subscribe[2].If["priority"])
|
require.Equal(t, "high,urgent", conf.Subscribe[2].If["priority"])
|
||||||
|
require.Equal(t, "defaults", conf.Subscribe[3].Topic)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfig_EmptyPassword(t *testing.T) {
|
||||||
|
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||||
|
require.Nil(t, os.WriteFile(filename, []byte(`
|
||||||
|
default-host: http://localhost
|
||||||
|
default-user: philipp
|
||||||
|
default-password: ""
|
||||||
|
subscribe:
|
||||||
|
- topic: no-command-with-auth
|
||||||
|
user: phil
|
||||||
|
password: ""
|
||||||
|
`), 0600))
|
||||||
|
|
||||||
|
conf, err := client.LoadConfig(filename)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "http://localhost", conf.DefaultHost)
|
||||||
|
require.Equal(t, "philipp", conf.DefaultUser)
|
||||||
|
require.Equal(t, "", *conf.DefaultPassword)
|
||||||
|
require.Equal(t, 1, len(conf.Subscribe))
|
||||||
|
require.Equal(t, "no-command-with-auth", conf.Subscribe[0].Topic)
|
||||||
|
require.Equal(t, "", conf.Subscribe[0].Command)
|
||||||
|
require.Equal(t, "phil", conf.Subscribe[0].User)
|
||||||
|
require.Equal(t, "", *conf.Subscribe[0].Password)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfig_NullPassword(t *testing.T) {
|
||||||
|
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||||
|
require.Nil(t, os.WriteFile(filename, []byte(`
|
||||||
|
default-host: http://localhost
|
||||||
|
default-user: philipp
|
||||||
|
default-password: ~
|
||||||
|
subscribe:
|
||||||
|
- topic: no-command-with-auth
|
||||||
|
user: phil
|
||||||
|
password: ~
|
||||||
|
`), 0600))
|
||||||
|
|
||||||
|
conf, err := client.LoadConfig(filename)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "http://localhost", conf.DefaultHost)
|
||||||
|
require.Equal(t, "philipp", conf.DefaultUser)
|
||||||
|
require.Nil(t, conf.DefaultPassword)
|
||||||
|
require.Equal(t, 1, len(conf.Subscribe))
|
||||||
|
require.Equal(t, "no-command-with-auth", conf.Subscribe[0].Topic)
|
||||||
|
require.Equal(t, "", conf.Subscribe[0].Command)
|
||||||
|
require.Equal(t, "phil", conf.Subscribe[0].User)
|
||||||
|
require.Nil(t, conf.Subscribe[0].Password)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfig_NoPassword(t *testing.T) {
|
||||||
|
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||||
|
require.Nil(t, os.WriteFile(filename, []byte(`
|
||||||
|
default-host: http://localhost
|
||||||
|
default-user: philipp
|
||||||
|
subscribe:
|
||||||
|
- topic: no-command-with-auth
|
||||||
|
user: phil
|
||||||
|
`), 0600))
|
||||||
|
|
||||||
|
conf, err := client.LoadConfig(filename)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "http://localhost", conf.DefaultHost)
|
||||||
|
require.Equal(t, "philipp", conf.DefaultUser)
|
||||||
|
require.Nil(t, conf.DefaultPassword)
|
||||||
|
require.Equal(t, 1, len(conf.Subscribe))
|
||||||
|
require.Equal(t, "no-command-with-auth", conf.Subscribe[0].Topic)
|
||||||
|
require.Equal(t, "", conf.Subscribe[0].Command)
|
||||||
|
require.Equal(t, "phil", conf.Subscribe[0].User)
|
||||||
|
require.Nil(t, conf.Subscribe[0].Password)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ func WithClick(url string) PublishOption {
|
|||||||
return WithHeader("X-Click", url)
|
return WithHeader("X-Click", url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithIcon makes the notification use the given URL as its icon
|
||||||
|
func WithIcon(icon string) PublishOption {
|
||||||
|
return WithHeader("X-Icon", icon)
|
||||||
|
}
|
||||||
|
|
||||||
// WithActions adds custom user actions to the notification. The value can be either a JSON array or the
|
// WithActions adds custom user actions to the notification. The value can be either a JSON array or the
|
||||||
// simple format definition. See https://ntfy.sh/docs/publish/#action-buttons for details.
|
// simple format definition. See https://ntfy.sh/docs/publish/#action-buttons for details.
|
||||||
func WithActions(value string) PublishOption {
|
func WithActions(value string) PublishOption {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build !noserver
|
||||||
|
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -8,12 +10,16 @@ import (
|
|||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands = append(commands, cmdAccess)
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
userEveryone = "everyone"
|
userEveryone = "everyone"
|
||||||
)
|
)
|
||||||
|
|
||||||
var flagsAccess = append(
|
var flagsAccess = append(
|
||||||
userCommandFlags(),
|
flagsUser,
|
||||||
&cli.BoolFlag{Name: "reset", Aliases: []string{"r"}, Usage: "reset access for user (and topic)"},
|
&cli.BoolFlag{Name: "reset", Aliases: []string{"r"}, Usage: "reset access for user (and topic)"},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,7 +28,7 @@ var cmdAccess = &cli.Command{
|
|||||||
Usage: "Grant/revoke access to a topic, or show access",
|
Usage: "Grant/revoke access to a topic, or show access",
|
||||||
UsageText: "ntfy access [USERNAME [TOPIC [PERMISSION]]]",
|
UsageText: "ntfy access [USERNAME [TOPIC [PERMISSION]]]",
|
||||||
Flags: flagsAccess,
|
Flags: flagsAccess,
|
||||||
Before: initConfigFileInputSource("config", flagsAccess),
|
Before: initConfigFileInputSourceFunc("config", flagsAccess, initLogFunc),
|
||||||
Action: execUserAccess,
|
Action: execUserAccess,
|
||||||
Category: categoryServer,
|
Category: categoryServer,
|
||||||
Description: `Manage the access control list for the ntfy server.
|
Description: `Manage the access control list for the ntfy server.
|
||||||
@@ -91,11 +97,11 @@ func execUserAccess(c *cli.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func changeAccess(c *cli.Context, manager auth.Manager, username string, topic string, perms string) error {
|
func changeAccess(c *cli.Context, manager auth.Manager, username string, topic string, perms string) error {
|
||||||
if !util.InStringList([]string{"", "read-write", "rw", "read-only", "read", "ro", "write-only", "write", "wo", "none", "deny"}, perms) {
|
if !util.Contains([]string{"", "read-write", "rw", "read-only", "read", "ro", "write-only", "write", "wo", "none", "deny"}, perms) {
|
||||||
return errors.New("permission must be one of: read-write, read-only, write-only, or deny (or the aliases: read, ro, write, wo, none)")
|
return errors.New("permission must be one of: read-write, read-only, write-only, or deny (or the aliases: read, ro, write, wo, none)")
|
||||||
}
|
}
|
||||||
read := util.InStringList([]string{"read-write", "rw", "read-only", "read", "ro"}, perms)
|
read := util.Contains([]string{"read-write", "rw", "read-only", "read", "ro"}, perms)
|
||||||
write := util.InStringList([]string{"read-write", "rw", "write-only", "write", "wo"}, perms)
|
write := util.Contains([]string{"read-write", "rw", "write-only", "write", "wo"}, perms)
|
||||||
user, err := manager.User(username)
|
user, err := manager.User(username)
|
||||||
if err == auth.ErrNotFound {
|
if err == auth.ErrNotFound {
|
||||||
return fmt.Errorf("user %s does not exist", username)
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
|
|||||||
56
cmd/app.go
@@ -2,23 +2,26 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"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/util"
|
"heckel.io/ntfy/log"
|
||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
defaultClientRootConfigFile = "/etc/ntfy/client.yml"
|
|
||||||
defaultClientUserConfigFile = "~/.config/ntfy/client.yml"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
categoryClient = "Client commands"
|
categoryClient = "Client commands"
|
||||||
categoryServer = "Server commands"
|
categoryServer = "Server commands"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var commands = make([]*cli.Command, 0)
|
||||||
|
|
||||||
|
var flagsDefault = []cli.Flag{
|
||||||
|
&cli.BoolFlag{Name: "debug", Aliases: []string{"d"}, EnvVars: []string{"NTFY_DEBUG"}, Usage: "enable debug logging"},
|
||||||
|
&cli.BoolFlag{Name: "trace", EnvVars: []string{"NTFY_TRACE"}, Usage: "enable tracing (very verbose, be careful)"},
|
||||||
|
&cli.BoolFlag{Name: "no-log-dates", Aliases: []string{"no_log_dates"}, EnvVars: []string{"NTFY_NO_LOG_DATES"}, Usage: "disable the date/time prefix"},
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "log-level", Aliases: []string{"log_level"}, Value: log.InfoLevel.String(), EnvVars: []string{"NTFY_LOG_LEVEL"}, Usage: "set log level"}),
|
||||||
|
}
|
||||||
|
|
||||||
// New creates a new CLI application
|
// New creates a new CLI application
|
||||||
func New() *cli.App {
|
func New() *cli.App {
|
||||||
return &cli.App{
|
return &cli.App{
|
||||||
@@ -30,33 +33,22 @@ func New() *cli.App {
|
|||||||
Reader: os.Stdin,
|
Reader: os.Stdin,
|
||||||
Writer: os.Stdout,
|
Writer: os.Stdout,
|
||||||
ErrWriter: os.Stderr,
|
ErrWriter: os.Stderr,
|
||||||
Commands: []*cli.Command{
|
Commands: commands,
|
||||||
// Server commands
|
Flags: flagsDefault,
|
||||||
cmdServe,
|
Before: initLogFunc,
|
||||||
cmdUser,
|
|
||||||
cmdAccess,
|
|
||||||
|
|
||||||
// Client commands
|
|
||||||
cmdPublish,
|
|
||||||
cmdSubscribe,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// initConfigFileInputSource is like altsrc.InitInputSourceWithContext and altsrc.NewYamlSourceFromFlagFunc, but checks
|
func initLogFunc(c *cli.Context) error {
|
||||||
// if the config flag is exists and only loads it if it does. If the flag is set and the file exists, it fails.
|
if c.Bool("trace") {
|
||||||
func initConfigFileInputSource(configFlag string, flags []cli.Flag) cli.BeforeFunc {
|
log.SetLevel(log.TraceLevel)
|
||||||
return func(context *cli.Context) error {
|
} else if c.Bool("debug") {
|
||||||
configFile := context.String(configFlag)
|
log.SetLevel(log.DebugLevel)
|
||||||
if context.IsSet(configFlag) && !util.FileExists(configFile) {
|
} else {
|
||||||
return fmt.Errorf("config file %s does not exist", configFile)
|
log.SetLevel(log.ToLevel(c.String("log-level")))
|
||||||
} else if !context.IsSet(configFlag) && !util.FileExists(configFile) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
inputSource, err := altsrc.NewYamlSourceFromFile(configFile)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return altsrc.ApplyInputSourceValues(context, inputSource, flags)
|
|
||||||
}
|
}
|
||||||
|
if c.Bool("no-log-dates") {
|
||||||
|
log.DisableDates()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
60
cmd/config_loader.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
"github.com/urfave/cli/v2/altsrc"
|
||||||
|
"gopkg.in/yaml.v2"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// initConfigFileInputSourceFunc is like altsrc.InitInputSourceWithContext and altsrc.NewYamlSourceFromFlagFunc, but checks
|
||||||
|
// if the config flag is exists and only loads it if it does. If the flag is set and the file exists, it fails.
|
||||||
|
func initConfigFileInputSourceFunc(configFlag string, flags []cli.Flag, next cli.BeforeFunc) cli.BeforeFunc {
|
||||||
|
return func(context *cli.Context) error {
|
||||||
|
configFile := context.String(configFlag)
|
||||||
|
if context.IsSet(configFlag) && !util.FileExists(configFile) {
|
||||||
|
return fmt.Errorf("config file %s does not exist", configFile)
|
||||||
|
} else if !context.IsSet(configFlag) && !util.FileExists(configFile) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
inputSource, err := newYamlSourceFromFile(configFile, flags)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := altsrc.ApplyInputSourceValues(context, inputSource, flags); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if next != nil {
|
||||||
|
if err := next(context); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newYamlSourceFromFile creates a new Yaml InputSourceContext from a filepath.
|
||||||
|
//
|
||||||
|
// This function also maps aliases, so a .yml file can contain short options, or options with underscores
|
||||||
|
// instead of dashes. See https://github.com/binwiederhier/ntfy/issues/255.
|
||||||
|
func newYamlSourceFromFile(file string, flags []cli.Flag) (altsrc.InputSourceContext, error) {
|
||||||
|
var rawConfig map[any]any
|
||||||
|
b, err := os.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal(b, &rawConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, f := range flags {
|
||||||
|
flagName := f.Names()[0]
|
||||||
|
for _, flagAlias := range f.Names()[1:] {
|
||||||
|
if _, ok := rawConfig[flagAlias]; ok {
|
||||||
|
rawConfig[flagName] = rawConfig[flagAlias]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return altsrc.NewMapInputSource(file, rawConfig), nil
|
||||||
|
}
|
||||||
38
cmd/config_loader_test.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewYamlSourceFromFile(t *testing.T) {
|
||||||
|
filename := filepath.Join(t.TempDir(), "server.yml")
|
||||||
|
contents := `
|
||||||
|
# Normal options
|
||||||
|
listen-https: ":10443"
|
||||||
|
|
||||||
|
# Note the underscore!
|
||||||
|
listen_http: ":1080"
|
||||||
|
|
||||||
|
# OMG this is allowed now ...
|
||||||
|
K: /some/file.pem
|
||||||
|
`
|
||||||
|
require.Nil(t, os.WriteFile(filename, []byte(contents), 0600))
|
||||||
|
|
||||||
|
ctx, err := newYamlSourceFromFile(filename, flagsServe)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
listenHTTPS, err := ctx.String("listen-https")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, ":10443", listenHTTPS)
|
||||||
|
|
||||||
|
listenHTTP, err := ctx.String("listen-http") // No underscore!
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, ":1080", listenHTTP)
|
||||||
|
|
||||||
|
keyFile, err := ctx.String("key-file") // Long option!
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "/some/file.pem", keyFile)
|
||||||
|
}
|
||||||
198
cmd/publish.go
@@ -5,38 +5,55 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
"heckel.io/ntfy/client"
|
"heckel.io/ntfy/client"
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands = append(commands, cmdPublish)
|
||||||
|
}
|
||||||
|
|
||||||
|
var flagsPublish = append(
|
||||||
|
flagsDefault,
|
||||||
|
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG"}, Usage: "client config file"},
|
||||||
|
&cli.StringFlag{Name: "title", Aliases: []string{"t"}, EnvVars: []string{"NTFY_TITLE"}, Usage: "message title"},
|
||||||
|
&cli.StringFlag{Name: "message", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MESSAGE"}, Usage: "message body"},
|
||||||
|
&cli.StringFlag{Name: "priority", Aliases: []string{"p"}, EnvVars: []string{"NTFY_PRIORITY"}, Usage: "priority of the message (1=min, 2=low, 3=default, 4=high, 5=max)"},
|
||||||
|
&cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, EnvVars: []string{"NTFY_TAGS"}, Usage: "comma separated list of tags and emojis"},
|
||||||
|
&cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, EnvVars: []string{"NTFY_DELAY"}, Usage: "delay/schedule message"},
|
||||||
|
&cli.StringFlag{Name: "click", Aliases: []string{"U"}, EnvVars: []string{"NTFY_CLICK"}, Usage: "URL to open when notification is clicked"},
|
||||||
|
&cli.StringFlag{Name: "icon", Aliases: []string{"i"}, EnvVars: []string{"NTFY_ICON"}, Usage: "URL to use as notification icon"},
|
||||||
|
&cli.StringFlag{Name: "actions", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ACTIONS"}, Usage: "actions JSON array or simple definition"},
|
||||||
|
&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"},
|
||||||
|
&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"},
|
||||||
|
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"},
|
||||||
|
&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"},
|
||||||
|
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
|
||||||
|
&cli.IntFlag{Name: "wait-pid", Aliases: []string{"wait_pid", "pid"}, EnvVars: []string{"NTFY_WAIT_PID"}, Usage: "wait until PID exits before publishing"},
|
||||||
|
&cli.BoolFlag{Name: "wait-cmd", Aliases: []string{"wait_cmd", "cmd", "done"}, EnvVars: []string{"NTFY_WAIT_CMD"}, Usage: "run command and wait until it finishes before publishing"},
|
||||||
|
&cli.BoolFlag{Name: "no-cache", Aliases: []string{"no_cache", "C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"},
|
||||||
|
&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"no_firebase", "F"}, EnvVars: []string{"NTFY_NO_FIREBASE"}, Usage: "do not forward message to Firebase"},
|
||||||
|
&cli.BoolFlag{Name: "env-topic", Aliases: []string{"env_topic", "P"}, EnvVars: []string{"NTFY_ENV_TOPIC"}, Usage: "use topic from NTFY_TOPIC env variable"},
|
||||||
|
&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, EnvVars: []string{"NTFY_QUIET"}, Usage: "do not print message"},
|
||||||
)
|
)
|
||||||
|
|
||||||
var cmdPublish = &cli.Command{
|
var cmdPublish = &cli.Command{
|
||||||
Name: "publish",
|
Name: "publish",
|
||||||
Aliases: []string{"pub", "send", "trigger"},
|
Aliases: []string{"pub", "send", "trigger"},
|
||||||
Usage: "Send message via a ntfy server",
|
Usage: "Send message via a ntfy server",
|
||||||
UsageText: "ntfy send [OPTIONS..] TOPIC [MESSAGE]\n NTFY_TOPIC=.. ntfy send [OPTIONS..] -P [MESSAGE]",
|
UsageText: `ntfy publish [OPTIONS..] TOPIC [MESSAGE...]
|
||||||
Action: execPublish,
|
ntfy publish [OPTIONS..] --wait-cmd COMMAND...
|
||||||
Category: categoryClient,
|
NTFY_TOPIC=.. ntfy publish [OPTIONS..] [MESSAGE...]`,
|
||||||
Flags: []cli.Flag{
|
Action: execPublish,
|
||||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG"}, Usage: "client config file"},
|
Category: categoryClient,
|
||||||
&cli.StringFlag{Name: "title", Aliases: []string{"t"}, EnvVars: []string{"NTFY_TITLE"}, Usage: "message title"},
|
Flags: flagsPublish,
|
||||||
&cli.StringFlag{Name: "priority", Aliases: []string{"p"}, EnvVars: []string{"NTFY_PRIORITY"}, Usage: "priority of the message (1=min, 2=low, 3=default, 4=high, 5=max)"},
|
Before: initLogFunc,
|
||||||
&cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, EnvVars: []string{"NTFY_TAGS"}, Usage: "comma separated list of tags and emojis"},
|
|
||||||
&cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, EnvVars: []string{"NTFY_DELAY"}, Usage: "delay/schedule message"},
|
|
||||||
&cli.StringFlag{Name: "click", Aliases: []string{"U"}, EnvVars: []string{"NTFY_CLICK"}, Usage: "URL to open when notification is clicked"},
|
|
||||||
&cli.StringFlag{Name: "actions", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ACTIONS"}, Usage: "actions JSON array or simple definition"},
|
|
||||||
&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"},
|
|
||||||
&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"},
|
|
||||||
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"},
|
|
||||||
&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"},
|
|
||||||
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
|
|
||||||
&cli.BoolFlag{Name: "no-cache", Aliases: []string{"C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"},
|
|
||||||
&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"F"}, EnvVars: []string{"NTFY_NO_FIREBASE"}, Usage: "do not forward message to Firebase"},
|
|
||||||
&cli.BoolFlag{Name: "env-topic", Aliases: []string{"P"}, EnvVars: []string{"NTFY_ENV_TOPIC"}, Usage: "use topic from NTFY_TOPIC env variable"},
|
|
||||||
&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, EnvVars: []string{"NTFY_QUIET"}, Usage: "do print message"},
|
|
||||||
},
|
|
||||||
Description: `Publish a message to a ntfy server.
|
Description: `Publish a message to a ntfy server.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
@@ -48,19 +65,21 @@ Examples:
|
|||||||
ntfy pub --at=8:30am delayed_topic Laterzz # Send message at 8:30am
|
ntfy pub --at=8:30am delayed_topic Laterzz # Send message at 8:30am
|
||||||
ntfy pub -e phil@example.com alerts 'App is down!' # Also send email to phil@example.com
|
ntfy pub -e phil@example.com alerts 'App is down!' # Also send email to phil@example.com
|
||||||
ntfy pub --click="https://reddit.com" redd 'New msg' # Opens Reddit when notification is clicked
|
ntfy pub --click="https://reddit.com" redd 'New msg' # Opens Reddit when notification is clicked
|
||||||
|
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
|
||||||
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-cmd mytopic rsync -av ./ /tmp/a # Run command and publish after it completes
|
||||||
NTFY_USER=phil:mypass ntfy pub secret Psst # Use env variables to set username/password
|
NTFY_USER=phil:mypass ntfy pub secret Psst # Use env variables to set username/password
|
||||||
NTFY_TOPIC=mytopic ntfy pub -P "some message"" # Use NTFY_TOPIC variable as topic
|
NTFY_TOPIC=mytopic ntfy pub "some message" # Use NTFY_TOPIC variable as topic
|
||||||
cat flower.jpg | ntfy pub --file=- flowers 'Nice!' # Same as above, send image.jpg as attachment
|
cat flower.jpg | ntfy pub --file=- flowers 'Nice!' # Same as above, send image.jpg as attachment
|
||||||
ntfy trigger mywebhook # Sending without message, useful for webhooks
|
ntfy trigger mywebhook # Sending without message, useful for webhooks
|
||||||
|
|
||||||
Please also check out the docs on publishing messages. Especially for the --tags and --delay options,
|
Please also check out the docs on publishing messages. Especially for the --tags and --delay options,
|
||||||
it has incredibly useful information: https://ntfy.sh/docs/publish/.
|
it has incredibly useful information: https://ntfy.sh/docs/publish/.
|
||||||
|
|
||||||
The default config file for all client commands is /etc/ntfy/client.yml (if root user),
|
` + clientCommandDescriptionSuffix,
|
||||||
or ~/.config/ntfy/client.yml for all other users.`,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func execPublish(c *cli.Context) error {
|
func execPublish(c *cli.Context) error {
|
||||||
@@ -73,6 +92,7 @@ func execPublish(c *cli.Context) error {
|
|||||||
tags := c.String("tags")
|
tags := c.String("tags")
|
||||||
delay := c.String("delay")
|
delay := c.String("delay")
|
||||||
click := c.String("click")
|
click := c.String("click")
|
||||||
|
icon := c.String("icon")
|
||||||
actions := c.String("actions")
|
actions := c.String("actions")
|
||||||
attach := c.String("attach")
|
attach := c.String("attach")
|
||||||
filename := c.String("filename")
|
filename := c.String("filename")
|
||||||
@@ -81,22 +101,11 @@ func execPublish(c *cli.Context) error {
|
|||||||
user := c.String("user")
|
user := c.String("user")
|
||||||
noCache := c.Bool("no-cache")
|
noCache := c.Bool("no-cache")
|
||||||
noFirebase := c.Bool("no-firebase")
|
noFirebase := c.Bool("no-firebase")
|
||||||
envTopic := c.Bool("env-topic")
|
|
||||||
quiet := c.Bool("quiet")
|
quiet := c.Bool("quiet")
|
||||||
var topic, message string
|
pid := c.Int("wait-pid")
|
||||||
if envTopic {
|
topic, message, command, err := parseTopicMessageCommand(c)
|
||||||
topic = os.Getenv("NTFY_TOPIC")
|
if err != nil {
|
||||||
if c.NArg() > 0 {
|
return err
|
||||||
message = strings.Join(c.Args().Slice(), " ")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if c.NArg() < 1 {
|
|
||||||
return errors.New("must specify topic, type 'ntfy publish --help' for help")
|
|
||||||
}
|
|
||||||
topic = c.Args().Get(0)
|
|
||||||
if c.NArg() > 1 {
|
|
||||||
message = strings.Join(c.Args().Slice()[1:], " ")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
var options []client.PublishOption
|
var options []client.PublishOption
|
||||||
if title != "" {
|
if title != "" {
|
||||||
@@ -114,6 +123,9 @@ func execPublish(c *cli.Context) error {
|
|||||||
if click != "" {
|
if click != "" {
|
||||||
options = append(options, client.WithClick(click))
|
options = append(options, client.WithClick(click))
|
||||||
}
|
}
|
||||||
|
if icon != "" {
|
||||||
|
options = append(options, client.WithIcon(icon))
|
||||||
|
}
|
||||||
if actions != "" {
|
if actions != "" {
|
||||||
options = append(options, client.WithActions(strings.ReplaceAll(actions, "\n", " ")))
|
options = append(options, client.WithActions(strings.ReplaceAll(actions, "\n", " ")))
|
||||||
}
|
}
|
||||||
@@ -148,6 +160,23 @@ func execPublish(c *cli.Context) error {
|
|||||||
fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20))
|
fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20))
|
||||||
}
|
}
|
||||||
options = append(options, client.WithBasicAuth(user, pass))
|
options = append(options, client.WithBasicAuth(user, pass))
|
||||||
|
} else if conf.DefaultUser != "" && conf.DefaultPassword != nil {
|
||||||
|
options = append(options, client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword))
|
||||||
|
}
|
||||||
|
if pid > 0 {
|
||||||
|
newMessage, err := waitForProcess(pid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if message == "" {
|
||||||
|
message = newMessage
|
||||||
|
}
|
||||||
|
} else if len(command) > 0 {
|
||||||
|
newMessage, err := runAndWaitForCommand(command)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if message == "" {
|
||||||
|
message = newMessage
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var body io.Reader
|
var body io.Reader
|
||||||
if file == "" {
|
if file == "" {
|
||||||
@@ -181,3 +210,88 @@ func execPublish(c *cli.Context) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseTopicMessageCommand reads the topic and the remaining arguments from the context.
|
||||||
|
|
||||||
|
// There are a few cases to consider:
|
||||||
|
//
|
||||||
|
// ntfy publish <topic> [<message>]
|
||||||
|
// ntfy publish --wait-cmd <topic> <command>
|
||||||
|
// NTFY_TOPIC=.. ntfy publish [<message>]
|
||||||
|
// NTFY_TOPIC=.. ntfy publish --wait-cmd <command>
|
||||||
|
func parseTopicMessageCommand(c *cli.Context) (topic string, message string, command []string, err error) {
|
||||||
|
var args []string
|
||||||
|
topic, args, err = parseTopicAndArgs(c)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.Bool("wait-cmd") {
|
||||||
|
if len(args) == 0 {
|
||||||
|
err = errors.New("must specify command when --wait-cmd is passed, type 'ntfy publish --help' for help")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
command = args
|
||||||
|
} else {
|
||||||
|
message = strings.Join(args, " ")
|
||||||
|
}
|
||||||
|
if c.String("message") != "" {
|
||||||
|
message = c.String("message")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTopicAndArgs(c *cli.Context) (topic string, args []string, err error) {
|
||||||
|
envTopic := os.Getenv("NTFY_TOPIC")
|
||||||
|
if envTopic != "" {
|
||||||
|
topic = envTopic
|
||||||
|
return topic, remainingArgs(c, 0), nil
|
||||||
|
}
|
||||||
|
if c.NArg() < 1 {
|
||||||
|
return "", nil, errors.New("must specify topic, type 'ntfy publish --help' for help")
|
||||||
|
}
|
||||||
|
return c.Args().Get(0), remainingArgs(c, 1), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func remainingArgs(c *cli.Context, fromIndex int) []string {
|
||||||
|
if c.NArg() > fromIndex {
|
||||||
|
return c.Args().Slice()[fromIndex:]
|
||||||
|
}
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForProcess(pid int) (message string, err error) {
|
||||||
|
if !processExists(pid) {
|
||||||
|
return "", fmt.Errorf("process with PID %d not running", pid)
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
log.Debug("Waiting for process with PID %d to exit", pid)
|
||||||
|
for processExists(pid) {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
runtime := time.Since(start).Round(time.Millisecond)
|
||||||
|
log.Debug("Process with PID %d exited after %s", pid, runtime)
|
||||||
|
return fmt.Sprintf("Process with PID %d exited after %s", pid, runtime), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAndWaitForCommand(command []string) (message string, err error) {
|
||||||
|
prettyCmd := util.QuoteCommand(command)
|
||||||
|
log.Debug("Running command: %s", prettyCmd)
|
||||||
|
start := time.Now()
|
||||||
|
cmd := exec.Command(command[0], command[1:]...)
|
||||||
|
if log.IsTrace() {
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
}
|
||||||
|
err = cmd.Run()
|
||||||
|
runtime := time.Since(start).Round(time.Millisecond)
|
||||||
|
if err != nil {
|
||||||
|
if exitError, ok := err.(*exec.ExitError); ok {
|
||||||
|
log.Debug("Command failed after %s (exit code %d): %s", runtime, exitError.ExitCode(), prettyCmd)
|
||||||
|
return fmt.Sprintf("Command failed after %s (exit code %d): %s", runtime, exitError.ExitCode(), prettyCmd), nil
|
||||||
|
}
|
||||||
|
// Hard fail when command does not exist or could not be properly launched
|
||||||
|
return "", fmt.Errorf("command failed: %s, error: %s", prettyCmd, err.Error())
|
||||||
|
}
|
||||||
|
log.Debug("Command succeeded after %s: %s", runtime, prettyCmd)
|
||||||
|
return fmt.Sprintf("Command succeeded after %s: %s", runtime, prettyCmd), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"heckel.io/ntfy/test"
|
"heckel.io/ntfy/test"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) {
|
func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) {
|
||||||
@@ -13,6 +17,7 @@ func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) {
|
|||||||
|
|
||||||
app, _, _, _ := newTestApp()
|
app, _, _, _ := newTestApp()
|
||||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "ntfytest", "ntfy unit test " + testMessage}))
|
require.Nil(t, app.Run([]string{"ntfy", "publish", "ntfytest", "ntfy unit test " + testMessage}))
|
||||||
|
time.Sleep(3 * time.Second) // Since #502, ntfy.sh writes messages to the cache asynchronously, after a timeout of ~1.5s
|
||||||
|
|
||||||
app2, _, stdout, _ := newTestApp()
|
app2, _, stdout, _ := newTestApp()
|
||||||
require.Nil(t, app2.Run([]string{"ntfy", "subscribe", "--poll", "ntfytest"}))
|
require.Nil(t, app2.Run([]string{"ntfy", "subscribe", "--poll", "ntfytest"}))
|
||||||
@@ -48,6 +53,7 @@ func TestCLI_Publish_All_The_Things(t *testing.T) {
|
|||||||
"--tags", "tag1,tag2",
|
"--tags", "tag1,tag2",
|
||||||
// No --delay, --email
|
// No --delay, --email
|
||||||
"--click", "https://ntfy.sh",
|
"--click", "https://ntfy.sh",
|
||||||
|
"--icon", "https://ntfy.sh/static/img/ntfy.png",
|
||||||
"--attach", "https://f-droid.org/F-Droid.apk",
|
"--attach", "https://f-droid.org/F-Droid.apk",
|
||||||
"--filename", "fdroid.apk",
|
"--filename", "fdroid.apk",
|
||||||
"--no-cache",
|
"--no-cache",
|
||||||
@@ -69,4 +75,68 @@ func TestCLI_Publish_All_The_Things(t *testing.T) {
|
|||||||
require.Equal(t, "", m.Attachment.Owner)
|
require.Equal(t, "", m.Attachment.Owner)
|
||||||
require.Equal(t, int64(0), m.Attachment.Expires)
|
require.Equal(t, int64(0), m.Attachment.Expires)
|
||||||
require.Equal(t, "", m.Attachment.Type)
|
require.Equal(t, "", m.Attachment.Type)
|
||||||
|
require.Equal(t, "https://ntfy.sh/static/img/ntfy.png", m.Icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_Publish_Wait_PID_And_Cmd(t *testing.T) {
|
||||||
|
s, port := test.StartServer(t)
|
||||||
|
defer test.StopServer(t, s, port)
|
||||||
|
topic := fmt.Sprintf("http://127.0.0.1:%d/mytopic", port)
|
||||||
|
|
||||||
|
// Test: sleep 0.5
|
||||||
|
sleep := exec.Command("sleep", "0.5")
|
||||||
|
require.Nil(t, sleep.Start())
|
||||||
|
go sleep.Wait() // Must be called to release resources
|
||||||
|
start := time.Now()
|
||||||
|
app, _, stdout, _ := newTestApp()
|
||||||
|
require.Nil(t, app.Run([]string{"ntfy", "publish", "--wait-pid", strconv.Itoa(sleep.Process.Pid), topic}))
|
||||||
|
m := toMessage(t, stdout.String())
|
||||||
|
require.True(t, time.Since(start) >= 500*time.Millisecond)
|
||||||
|
require.Regexp(t, `Process with PID \d+ exited after `, m.Message)
|
||||||
|
|
||||||
|
// Test: PID does not exist
|
||||||
|
app, _, _, _ = newTestApp()
|
||||||
|
err := app.Run([]string{"ntfy", "publish", "--wait-pid", "1234567", topic})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Equal(t, "process with PID 1234567 not running", err.Error())
|
||||||
|
|
||||||
|
// Test: Successful command (exit 0)
|
||||||
|
start = time.Now()
|
||||||
|
app, _, stdout, _ = newTestApp()
|
||||||
|
require.Nil(t, app.Run([]string{"ntfy", "publish", "--wait-cmd", topic, "sleep", "0.5"}))
|
||||||
|
m = toMessage(t, stdout.String())
|
||||||
|
require.True(t, time.Since(start) >= 500*time.Millisecond)
|
||||||
|
require.Contains(t, m.Message, `Command succeeded after `)
|
||||||
|
require.Contains(t, m.Message, `: sleep 0.5`)
|
||||||
|
|
||||||
|
// Test: Failing command (exit 1)
|
||||||
|
app, _, stdout, _ = newTestApp()
|
||||||
|
require.Nil(t, app.Run([]string{"ntfy", "publish", "--wait-cmd", topic, "/bin/false", "false doesn't care about its args"}))
|
||||||
|
m = toMessage(t, stdout.String())
|
||||||
|
require.Contains(t, m.Message, `Command failed after `)
|
||||||
|
require.Contains(t, m.Message, `(exit code 1): /bin/false "false doesn't care about its args"`, m.Message)
|
||||||
|
|
||||||
|
// Test: Non-existing command (hard fail!)
|
||||||
|
app, _, _, _ = newTestApp()
|
||||||
|
err = app.Run([]string{"ntfy", "publish", "--wait-cmd", topic, "does-not-exist-no-really", "really though"})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Equal(t, `command failed: does-not-exist-no-really "really though", error: exec: "does-not-exist-no-really": executable file not found in $PATH`, err.Error())
|
||||||
|
|
||||||
|
// Tests with NTFY_TOPIC set ////
|
||||||
|
require.Nil(t, os.Setenv("NTFY_TOPIC", topic))
|
||||||
|
|
||||||
|
// Test: Successful command with NTFY_TOPIC
|
||||||
|
app, _, stdout, _ = newTestApp()
|
||||||
|
require.Nil(t, app.Run([]string{"ntfy", "publish", "--env-topic", "--cmd", "echo", "hi there"}))
|
||||||
|
m = toMessage(t, stdout.String())
|
||||||
|
require.Equal(t, "mytopic", m.Topic)
|
||||||
|
|
||||||
|
// Test: Successful --wait-pid with NTFY_TOPIC
|
||||||
|
sleep = exec.Command("sleep", "0.2")
|
||||||
|
require.Nil(t, sleep.Start())
|
||||||
|
go sleep.Wait() // Must be called to release resources
|
||||||
|
app, _, stdout, _ = newTestApp()
|
||||||
|
require.Nil(t, app.Run([]string{"ntfy", "publish", "--env-topic", "--wait-pid", strconv.Itoa(sleep.Process.Pid)}))
|
||||||
|
m = toMessage(t, stdout.String())
|
||||||
|
require.Regexp(t, `Process with PID \d+ exited after .+ms`, m.Message)
|
||||||
}
|
}
|
||||||
|
|||||||
11
cmd/publish_unix.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
//go:build darwin || linux || dragonfly || freebsd || netbsd || openbsd
|
||||||
|
// +build darwin linux dragonfly freebsd netbsd openbsd
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import "syscall"
|
||||||
|
|
||||||
|
func processExists(pid int) bool {
|
||||||
|
err := syscall.Kill(pid, syscall.Signal(0))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
10
cmd/publish_windows.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func processExists(pid int) bool {
|
||||||
|
_, err := os.FindProcess(pid)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
214
cmd/serve.go
@@ -1,58 +1,81 @@
|
|||||||
|
//go:build !noserver
|
||||||
|
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"math"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
|
|
||||||
"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/server"
|
"heckel.io/ntfy/server"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"log"
|
|
||||||
"math"
|
|
||||||
"net"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var flagsServe = []cli.Flag{
|
func init() {
|
||||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/server.yml", DefaultText: "/etc/ntfy/server.yml", Usage: "config file"},
|
commands = append(commands, cmdServe)
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"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{"l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used to as HTTP listen address"}),
|
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used to as HTTPS listen address"}),
|
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-unix", Aliases: []string{"U"}, EnvVars: []string{"NTFY_LISTEN_UNIX"}, Usage: "listen on unix socket path"}),
|
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "key-file", Aliases: []string{"K"}, EnvVars: []string{"NTFY_KEY_FILE"}, Usage: "private key file, if listen-https is set"}),
|
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}),
|
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"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{"C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}),
|
|
||||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: server.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}),
|
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
|
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"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", EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}),
|
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, DefaultText: "5G", Usage: "limit of the on-disk attachment cache"}),
|
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, DefaultText: "15M", Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}),
|
|
||||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "attachment-expiry-duration", Aliases: []string{"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.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
|
|
||||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}),
|
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "app", Usage: "sets web root to landing page (home) or web app (app)"}),
|
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}),
|
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}),
|
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}),
|
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-from", EnvVars: []string{"NTFY_SMTP_SENDER_FROM"}, Usage: "SMTP sender address (if e-mail sending is enabled)"}),
|
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}),
|
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}),
|
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}),
|
|
||||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
|
|
||||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "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", EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}),
|
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "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", 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", 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-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-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", EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
|
|
||||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultServerConfigFile = "/etc/ntfy/server.yml"
|
||||||
|
)
|
||||||
|
|
||||||
|
var flagsServe = append(
|
||||||
|
flagsDefault,
|
||||||
|
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: 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: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used to 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 to as HTTPS listen address"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-unix", Aliases: []string{"listen_unix", "U"}, EnvVars: []string{"NTFY_LISTEN_UNIX"}, Usage: "listen on unix socket path"}),
|
||||||
|
altsrc.NewIntFlag(&cli.IntFlag{Name: "listen-unix-mode", Aliases: []string{"listen_unix_mode"}, EnvVars: []string{"NTFY_LISTEN_UNIX_MODE"}, DefaultText: "system default", Usage: "file permissions of unix socket, e.g. 0700"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "key-file", Aliases: []string{"key_file", "K"}, EnvVars: []string{"NTFY_KEY_FILE"}, Usage: "private key 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: "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.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-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-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-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-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.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.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.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: "web-root", Aliases: []string{"web_root"}, EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "app", Usage: "sets web root to landing page (home), web app (app) or disabled (disable)"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-base-url", Aliases: []string{"upstream_base_url"}, EnvVars: []string{"NTFY_UPSTREAM_BASE_URL"}, Value: "", Usage: "forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", Aliases: []string{"smtp_sender_addr"}, EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", Aliases: []string{"smtp_sender_user"}, EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", Aliases: []string{"smtp_sender_pass"}, EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-from", Aliases: []string{"smtp_sender_from"}, EnvVars: []string{"NTFY_SMTP_SENDER_FROM"}, Usage: "SMTP sender address (if e-mail sending is enabled)"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", Aliases: []string{"smtp_server_listen"}, EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", Aliases: []string{"smtp_server_domain"}, EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", Aliases: []string{"smtp_server_addr_prefix"}, EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}),
|
||||||
|
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.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.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.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-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-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.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)"}),
|
||||||
|
)
|
||||||
|
|
||||||
var cmdServe = &cli.Command{
|
var cmdServe = &cli.Command{
|
||||||
Name: "serve",
|
Name: "serve",
|
||||||
Usage: "Run the ntfy server",
|
Usage: "Run the ntfy server",
|
||||||
@@ -60,7 +83,7 @@ var cmdServe = &cli.Command{
|
|||||||
Action: execServe,
|
Action: execServe,
|
||||||
Category: categoryServer,
|
Category: categoryServer,
|
||||||
Flags: flagsServe,
|
Flags: flagsServe,
|
||||||
Before: initConfigFileInputSource("config", flagsServe),
|
Before: initConfigFileInputSourceFunc("config", flagsServe, initLogFunc),
|
||||||
Description: `Run the ntfy server and listen for incoming requests
|
Description: `Run the ntfy server and listen for incoming requests
|
||||||
|
|
||||||
The command will load the configuration from /etc/ntfy/server.yml. Config options can
|
The command will load the configuration from /etc/ntfy/server.yml. Config options can
|
||||||
@@ -77,15 +100,20 @@ func execServe(c *cli.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Read all the options
|
// Read all the options
|
||||||
|
config := c.String("config")
|
||||||
baseURL := c.String("base-url")
|
baseURL := 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")
|
||||||
|
listenUnixMode := c.Int("listen-unix-mode")
|
||||||
keyFile := c.String("key-file")
|
keyFile := c.String("key-file")
|
||||||
certFile := c.String("cert-file")
|
certFile := c.String("cert-file")
|
||||||
firebaseKeyFile := c.String("firebase-key-file")
|
firebaseKeyFile := c.String("firebase-key-file")
|
||||||
cacheFile := c.String("cache-file")
|
cacheFile := c.String("cache-file")
|
||||||
cacheDuration := c.Duration("cache-duration")
|
cacheDuration := c.Duration("cache-duration")
|
||||||
|
cacheStartupQueries := c.String("cache-startup-queries")
|
||||||
|
cacheBatchSize := c.Int("cache-batch-size")
|
||||||
|
cacheBatchTimeout := c.Duration("cache-batch-timeout")
|
||||||
authFile := c.String("auth-file")
|
authFile := c.String("auth-file")
|
||||||
authDefaultAccess := c.String("auth-default-access")
|
authDefaultAccess := c.String("auth-default-access")
|
||||||
attachmentCacheDir := c.String("attachment-cache-dir")
|
attachmentCacheDir := c.String("attachment-cache-dir")
|
||||||
@@ -95,6 +123,7 @@ func execServe(c *cli.Context) error {
|
|||||||
keepaliveInterval := c.Duration("keepalive-interval")
|
keepaliveInterval := c.Duration("keepalive-interval")
|
||||||
managerInterval := c.Duration("manager-interval")
|
managerInterval := c.Duration("manager-interval")
|
||||||
webRoot := c.String("web-root")
|
webRoot := c.String("web-root")
|
||||||
|
upstreamBaseURL := c.String("upstream-base-url")
|
||||||
smtpSenderAddr := c.String("smtp-sender-addr")
|
smtpSenderAddr := c.String("smtp-sender-addr")
|
||||||
smtpSenderUser := c.String("smtp-sender-user")
|
smtpSenderUser := c.String("smtp-sender-user")
|
||||||
smtpSenderPass := c.String("smtp-sender-pass")
|
smtpSenderPass := c.String("smtp-sender-pass")
|
||||||
@@ -136,14 +165,26 @@ func execServe(c *cli.Context) error {
|
|||||||
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 != "" && !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
|
||||||
return errors.New("if set, base-url must start with http:// or https://")
|
return errors.New("if set, base-url must start with http:// or https://")
|
||||||
} else if !util.InStringList([]string{"read-write", "read-only", "write-only", "deny-all"}, authDefaultAccess) {
|
} else if baseURL != "" && strings.HasSuffix(baseURL, "/") {
|
||||||
|
return errors.New("if set, base-url must not end with a slash (/)")
|
||||||
|
} else if !util.Contains([]string{"read-write", "read-only", "write-only", "deny-all"}, authDefaultAccess) {
|
||||||
return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
|
return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
|
||||||
} else if !util.InStringList([]string{"app", "home"}, webRoot) {
|
} else if !util.Contains([]string{"app", "home", "disable"}, webRoot) {
|
||||||
return errors.New("if set, web-root must be 'home' or 'app'")
|
return errors.New("if set, web-root must be 'home' or 'app'")
|
||||||
|
} 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://")
|
||||||
|
} else if upstreamBaseURL != "" && strings.HasSuffix(upstreamBaseURL, "/") {
|
||||||
|
return errors.New("if set, upstream-base-url must not end with a slash (/)")
|
||||||
|
} else if upstreamBaseURL != "" && baseURL == "" {
|
||||||
|
return errors.New("if upstream-base-url is set, base-url must also be set")
|
||||||
|
} else if upstreamBaseURL != "" && baseURL != "" && baseURL == upstreamBaseURL {
|
||||||
|
return errors.New("base-url and upstream-base-url cannot be identical, you'll likely want to set upstream-base-url to https://ntfy.sh, see https://ntfy.sh/docs/config/#ios-instant-notifications")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default auth permissions
|
|
||||||
webRootIsApp := webRoot == "app"
|
webRootIsApp := webRoot == "app"
|
||||||
|
enableWeb := webRoot != "disable"
|
||||||
|
|
||||||
|
// Default auth permissions
|
||||||
authDefaultRead := authDefaultAccess == "read-write" || authDefaultAccess == "read-only"
|
authDefaultRead := authDefaultAccess == "read-write" || authDefaultAccess == "read-only"
|
||||||
authDefaultWrite := authDefaultAccess == "read-write" || authDefaultAccess == "write-only"
|
authDefaultWrite := authDefaultAccess == "read-write" || authDefaultAccess == "write-only"
|
||||||
|
|
||||||
@@ -173,16 +214,14 @@ func execServe(c *cli.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve hosts
|
// Resolve hosts
|
||||||
visitorRequestLimitExemptIPs := make([]string, 0)
|
visitorRequestLimitExemptIPs := make([]netip.Prefix, 0)
|
||||||
for _, host := range visitorRequestLimitExemptHosts {
|
for _, host := range visitorRequestLimitExemptHosts {
|
||||||
ips, err := net.LookupIP(host)
|
ips, err := parseIPHostPrefix(host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("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
|
||||||
}
|
}
|
||||||
for _, ip := range ips {
|
visitorRequestLimitExemptIPs = append(visitorRequestLimitExemptIPs, ips...)
|
||||||
visitorRequestLimitExemptIPs = append(visitorRequestLimitExemptIPs, ip.String())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run server
|
// Run server
|
||||||
@@ -191,11 +230,15 @@ func execServe(c *cli.Context) error {
|
|||||||
conf.ListenHTTP = listenHTTP
|
conf.ListenHTTP = listenHTTP
|
||||||
conf.ListenHTTPS = listenHTTPS
|
conf.ListenHTTPS = listenHTTPS
|
||||||
conf.ListenUnix = listenUnix
|
conf.ListenUnix = listenUnix
|
||||||
|
conf.ListenUnixMode = fs.FileMode(listenUnixMode)
|
||||||
conf.KeyFile = keyFile
|
conf.KeyFile = keyFile
|
||||||
conf.CertFile = certFile
|
conf.CertFile = certFile
|
||||||
conf.FirebaseKeyFile = firebaseKeyFile
|
conf.FirebaseKeyFile = firebaseKeyFile
|
||||||
conf.CacheFile = cacheFile
|
conf.CacheFile = cacheFile
|
||||||
conf.CacheDuration = cacheDuration
|
conf.CacheDuration = cacheDuration
|
||||||
|
conf.CacheStartupQueries = cacheStartupQueries
|
||||||
|
conf.CacheBatchSize = cacheBatchSize
|
||||||
|
conf.CacheBatchTimeout = cacheBatchTimeout
|
||||||
conf.AuthFile = authFile
|
conf.AuthFile = authFile
|
||||||
conf.AuthDefaultRead = authDefaultRead
|
conf.AuthDefaultRead = authDefaultRead
|
||||||
conf.AuthDefaultWrite = authDefaultWrite
|
conf.AuthDefaultWrite = authDefaultWrite
|
||||||
@@ -206,6 +249,7 @@ func execServe(c *cli.Context) error {
|
|||||||
conf.KeepaliveInterval = keepaliveInterval
|
conf.KeepaliveInterval = keepaliveInterval
|
||||||
conf.ManagerInterval = managerInterval
|
conf.ManagerInterval = managerInterval
|
||||||
conf.WebRootIsApp = webRootIsApp
|
conf.WebRootIsApp = webRootIsApp
|
||||||
|
conf.UpstreamBaseURL = upstreamBaseURL
|
||||||
conf.SMTPSenderAddr = smtpSenderAddr
|
conf.SMTPSenderAddr = smtpSenderAddr
|
||||||
conf.SMTPSenderUser = smtpSenderUser
|
conf.SMTPSenderUser = smtpSenderUser
|
||||||
conf.SMTPSenderPass = smtpSenderPass
|
conf.SMTPSenderPass = smtpSenderPass
|
||||||
@@ -223,14 +267,20 @@ func execServe(c *cli.Context) error {
|
|||||||
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
|
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
|
||||||
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
|
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
|
||||||
conf.BehindProxy = behindProxy
|
conf.BehindProxy = behindProxy
|
||||||
|
conf.EnableWeb = enableWeb
|
||||||
|
conf.Version = c.App.Version
|
||||||
|
|
||||||
|
// Set up hot-reloading of config
|
||||||
|
go sigHandlerConfigReload(config)
|
||||||
|
|
||||||
|
// Run server
|
||||||
s, err := server.New(conf)
|
s, err := server.New(conf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalln(err)
|
log.Fatal(err)
|
||||||
|
} else if err := s.Run(); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := s.Run(); err != nil {
|
log.Info("Exiting.")
|
||||||
log.Fatalln(err)
|
|
||||||
}
|
|
||||||
log.Printf("Exiting.")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,3 +294,53 @@ func parseSize(s string, defaultValue int64) (v int64, err error) {
|
|||||||
}
|
}
|
||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sigHandlerConfigReload(config string) {
|
||||||
|
sigs := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigs, syscall.SIGHUP)
|
||||||
|
for range sigs {
|
||||||
|
log.Info("Partially hot reloading configuration ...")
|
||||||
|
inputSource, err := newYamlSourceFromFile(config, flagsServe)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("Hot reload failed: %s", err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
reloadLogLevel(inputSource)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) {
|
||||||
|
// Try parsing as prefix, e.g. 10.0.1.0/24
|
||||||
|
prefix, err := netip.ParsePrefix(host)
|
||||||
|
if err == nil {
|
||||||
|
prefixes = append(prefixes, prefix.Masked())
|
||||||
|
return prefixes, nil
|
||||||
|
}
|
||||||
|
// Not a prefix, parse as host or IP (LookupHost passes through an IP as is)
|
||||||
|
ips, err := net.LookupHost(host)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, ipStr := range ips {
|
||||||
|
ip, err := netip.ParseAddr(ipStr)
|
||||||
|
if err == nil {
|
||||||
|
prefix, err := ip.Prefix(ip.BitLen())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s successfully parsed but unable to make prefix: %s", ip.String(), err.Error())
|
||||||
|
}
|
||||||
|
prefixes = append(prefixes, prefix.Masked())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func reloadLogLevel(inputSource altsrc.InputSourceContext) {
|
||||||
|
newLevelStr, err := inputSource.String("log-level")
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("Cannot load log level: %s", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newLevel := log.ToLevel(newLevelStr)
|
||||||
|
log.SetLevel(newLevel)
|
||||||
|
log.Info("Log level is %s", newLevel.String())
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,17 +2,19 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/gorilla/websocket"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"heckel.io/ntfy/client"
|
|
||||||
"heckel.io/ntfy/test"
|
|
||||||
"heckel.io/ntfy/util"
|
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"heckel.io/ntfy/client"
|
||||||
|
"heckel.io/ntfy/test"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -70,6 +72,22 @@ func TestCLI_Serve_WebSocket(t *testing.T) {
|
|||||||
require.Equal(t, "mytopic", m.Topic)
|
require.Equal(t, "mytopic", m.Topic)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIP_Host_Parsing(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"1.1.1.1": "1.1.1.1/32",
|
||||||
|
"fd00::1234": "fd00::1234/128",
|
||||||
|
"192.168.0.3/24": "192.168.0.0/24",
|
||||||
|
"10.1.2.3/8": "10.0.0.0/8",
|
||||||
|
"201:be93::4a6/21": "201:b800::/21",
|
||||||
|
}
|
||||||
|
for q, expectedAnswer := range cases {
|
||||||
|
ips, err := parseIPHostPrefix(q)
|
||||||
|
require.Nil(t, err)
|
||||||
|
assert.Equal(t, 1, len(ips))
|
||||||
|
assert.Equal(t, expectedAnswer, ips[0].String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newEmptyFile(t *testing.T) string {
|
func newEmptyFile(t *testing.T) string {
|
||||||
filename := filepath.Join(t.TempDir(), "empty")
|
filename := filepath.Join(t.TempDir(), "empty")
|
||||||
require.Nil(t, os.WriteFile(filename, []byte{}, 0600))
|
require.Nil(t, os.WriteFile(filename, []byte{}, 0600))
|
||||||
|
|||||||
144
cmd/subscribe.go
@@ -5,14 +5,36 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
"heckel.io/ntfy/client"
|
"heckel.io/ntfy/client"
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"os/user"
|
"os/user"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands = append(commands, cmdSubscribe)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
clientRootConfigFileUnixAbsolute = "/etc/ntfy/client.yml"
|
||||||
|
clientUserConfigFileUnixRelative = "ntfy/client.yml"
|
||||||
|
clientUserConfigFileWindowsRelative = "ntfy\\client.yml"
|
||||||
|
)
|
||||||
|
|
||||||
|
var flagsSubscribe = append(
|
||||||
|
flagsDefault,
|
||||||
|
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
|
||||||
|
&cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"},
|
||||||
|
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
|
||||||
|
&cli.BoolFlag{Name: "from-config", Aliases: []string{"from_config", "C"}, Usage: "read subscriptions from config file (service mode)"},
|
||||||
|
&cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"},
|
||||||
|
&cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"},
|
||||||
|
)
|
||||||
|
|
||||||
var cmdSubscribe = &cli.Command{
|
var cmdSubscribe = &cli.Command{
|
||||||
Name: "subscribe",
|
Name: "subscribe",
|
||||||
Aliases: []string{"sub"},
|
Aliases: []string{"sub"},
|
||||||
@@ -20,15 +42,8 @@ var cmdSubscribe = &cli.Command{
|
|||||||
UsageText: "ntfy subscribe [OPTIONS..] [TOPIC]",
|
UsageText: "ntfy subscribe [OPTIONS..] [TOPIC]",
|
||||||
Action: execSubscribe,
|
Action: execSubscribe,
|
||||||
Category: categoryClient,
|
Category: categoryClient,
|
||||||
Flags: []cli.Flag{
|
Flags: flagsSubscribe,
|
||||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
|
Before: initLogFunc,
|
||||||
&cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"},
|
|
||||||
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, Usage: "username[:password] used to auth against the server"},
|
|
||||||
&cli.BoolFlag{Name: "from-config", Aliases: []string{"C"}, Usage: "read subscriptions from config file (service mode)"},
|
|
||||||
&cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"},
|
|
||||||
&cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"},
|
|
||||||
&cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}, Usage: "print verbose output"},
|
|
||||||
},
|
|
||||||
Description: `Subscribe to a topic from a ntfy server, and either print or execute a command for
|
Description: `Subscribe to a topic from a ntfy server, and either print or execute a command for
|
||||||
every arriving message. There are 3 modes in which the command can be run:
|
every arriving message. There are 3 modes in which the command can be run:
|
||||||
|
|
||||||
@@ -60,19 +75,17 @@ ntfy subscribe TOPIC COMMAND
|
|||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
ntfy sub mytopic 'notify-send "$m"' # Execute command for incoming messages
|
ntfy sub mytopic 'notify-send "$m"' # Execute command for incoming messages
|
||||||
ntfy sub topic1 /my/script.sh # Execute script for incoming messages
|
ntfy sub topic1 myscript.sh # Execute script for incoming messages
|
||||||
|
|
||||||
ntfy subscribe --from-config
|
ntfy subscribe --from-config
|
||||||
Service mode (used in ntfy-client.service). This reads the config file (/etc/ntfy/client.yml
|
Service mode (used in ntfy-client.service). This reads the config file and sets up
|
||||||
or ~/.config/ntfy/client.yml) and sets up subscriptions for every topic in the "subscribe:"
|
subscriptions for every topic in the "subscribe:" block (see config file).
|
||||||
block (see config file).
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
ntfy sub --from-config # Read topics from config file
|
ntfy sub --from-config # Read topics from config file
|
||||||
ntfy sub --config=/my/client.yml --from-config # Read topics from alternate config file
|
ntfy sub --config=myclient.yml --from-config # Read topics from alternate config file
|
||||||
|
|
||||||
The default config file for all client commands is /etc/ntfy/client.yml (if root user),
|
` + clientCommandDescriptionSuffix,
|
||||||
or ~/.config/ntfy/client.yml for all other users.`,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func execSubscribe(c *cli.Context) error {
|
func execSubscribe(c *cli.Context) error {
|
||||||
@@ -156,28 +169,47 @@ func doPollSingle(c *cli.Context, cl *client.Client, topic, command string, opti
|
|||||||
}
|
}
|
||||||
|
|
||||||
func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error {
|
func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error {
|
||||||
commands := make(map[string]string) // Subscription ID -> command
|
cmds := make(map[string]string) // Subscription ID -> command
|
||||||
for _, s := range conf.Subscribe { // May be nil
|
for _, s := range conf.Subscribe { // May be nil
|
||||||
topicOptions := append(make([]client.SubscribeOption, 0), options...)
|
topicOptions := append(make([]client.SubscribeOption, 0), options...)
|
||||||
for filter, value := range s.If {
|
for filter, value := range s.If {
|
||||||
topicOptions = append(topicOptions, client.WithFilter(filter, value))
|
topicOptions = append(topicOptions, client.WithFilter(filter, value))
|
||||||
}
|
}
|
||||||
if s.User != "" && s.Password != "" {
|
var user string
|
||||||
topicOptions = append(topicOptions, client.WithBasicAuth(s.User, s.Password))
|
var password *string
|
||||||
|
if s.User != "" {
|
||||||
|
user = s.User
|
||||||
|
} else if conf.DefaultUser != "" {
|
||||||
|
user = conf.DefaultUser
|
||||||
|
}
|
||||||
|
if s.Password != nil {
|
||||||
|
password = s.Password
|
||||||
|
} else if conf.DefaultPassword != nil {
|
||||||
|
password = conf.DefaultPassword
|
||||||
|
}
|
||||||
|
if user != "" && password != nil {
|
||||||
|
topicOptions = append(topicOptions, client.WithBasicAuth(user, *password))
|
||||||
}
|
}
|
||||||
subscriptionID := cl.Subscribe(s.Topic, topicOptions...)
|
subscriptionID := cl.Subscribe(s.Topic, topicOptions...)
|
||||||
commands[subscriptionID] = s.Command
|
if s.Command != "" {
|
||||||
|
cmds[subscriptionID] = s.Command
|
||||||
|
} else if conf.DefaultCommand != "" {
|
||||||
|
cmds[subscriptionID] = conf.DefaultCommand
|
||||||
|
} else {
|
||||||
|
cmds[subscriptionID] = ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if topic != "" {
|
if topic != "" {
|
||||||
subscriptionID := cl.Subscribe(topic, options...)
|
subscriptionID := cl.Subscribe(topic, options...)
|
||||||
commands[subscriptionID] = command
|
cmds[subscriptionID] = command
|
||||||
}
|
}
|
||||||
for m := range cl.Messages {
|
for m := range cl.Messages {
|
||||||
command, ok := commands[m.SubscriptionID]
|
cmd, ok := cmds[m.SubscriptionID]
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
printMessageOrRunCommand(c, m, command)
|
log.Debug("%s Dispatching received message: %s", logMessagePrefix(m), m.Raw)
|
||||||
|
printMessageOrRunCommand(c, m, cmd)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -186,27 +218,27 @@ func printMessageOrRunCommand(c *cli.Context, m *client.Message, command string)
|
|||||||
if command != "" {
|
if command != "" {
|
||||||
runCommand(c, command, m)
|
runCommand(c, command, m)
|
||||||
} else {
|
} else {
|
||||||
|
log.Debug("%s Printing raw message", logMessagePrefix(m))
|
||||||
fmt.Fprintln(c.App.Writer, m.Raw)
|
fmt.Fprintln(c.App.Writer, m.Raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func runCommand(c *cli.Context, command string, m *client.Message) {
|
func runCommand(c *cli.Context, command string, m *client.Message) {
|
||||||
if err := runCommandInternal(c, command, m); err != nil {
|
if err := runCommandInternal(c, command, m); err != nil {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "Command failed: %s\n", err.Error())
|
log.Warn("%s Command failed: %s", logMessagePrefix(m), err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func runCommandInternal(c *cli.Context, command string, m *client.Message) error {
|
func runCommandInternal(c *cli.Context, script string, m *client.Message) error {
|
||||||
scriptFile, err := createTmpScript(command)
|
scriptFile := fmt.Sprintf("%s/ntfy-subscribe-%s.%s", os.TempDir(), util.RandomString(10), scriptExt)
|
||||||
if err != nil {
|
log.Debug("%s Running command '%s' via temporary script %s", logMessagePrefix(m), script, scriptFile)
|
||||||
|
script = scriptHeader + script
|
||||||
|
if err := os.WriteFile(scriptFile, []byte(script), 0700); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer os.Remove(scriptFile)
|
defer os.Remove(scriptFile)
|
||||||
verbose := c.Bool("verbose")
|
log.Debug("%s Executing script %s", logMessagePrefix(m), scriptFile)
|
||||||
if verbose {
|
cmd := exec.Command(scriptLauncher[0], append(scriptLauncher[1:], scriptFile)...)
|
||||||
log.Printf("[%s] Executing: %s (for message: %s)", util.ShortTopicURL(m.TopicURL), command, m.Raw)
|
|
||||||
}
|
|
||||||
cmd := exec.Command("sh", "-c", scriptFile)
|
|
||||||
cmd.Stdin = c.App.Reader
|
cmd.Stdin = c.App.Reader
|
||||||
cmd.Stdout = c.App.Writer
|
cmd.Stdout = c.App.Writer
|
||||||
cmd.Stderr = c.App.ErrWriter
|
cmd.Stderr = c.App.ErrWriter
|
||||||
@@ -214,17 +246,8 @@ func runCommandInternal(c *cli.Context, command string, m *client.Message) error
|
|||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
func createTmpScript(command string) (string, error) {
|
|
||||||
scriptFile := fmt.Sprintf("%s/ntfy-subscribe-%s.sh.tmp", os.TempDir(), util.RandomString(10))
|
|
||||||
script := fmt.Sprintf("#!/bin/sh\n%s", command)
|
|
||||||
if err := os.WriteFile(scriptFile, []byte(script), 0700); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return scriptFile, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func envVars(m *client.Message) []string {
|
func envVars(m *client.Message) []string {
|
||||||
env := os.Environ()
|
env := make([]string, 0)
|
||||||
env = append(env, envVar(m.ID, "NTFY_ID", "id")...)
|
env = append(env, envVar(m.ID, "NTFY_ID", "id")...)
|
||||||
env = append(env, envVar(m.Topic, "NTFY_TOPIC", "topic")...)
|
env = append(env, envVar(m.Topic, "NTFY_TOPIC", "topic")...)
|
||||||
env = append(env, envVar(fmt.Sprintf("%d", m.Time), "NTFY_TIME", "time")...)
|
env = append(env, envVar(fmt.Sprintf("%d", m.Time), "NTFY_TIME", "time")...)
|
||||||
@@ -233,7 +256,11 @@ func envVars(m *client.Message) []string {
|
|||||||
env = append(env, envVar(fmt.Sprintf("%d", m.Priority), "NTFY_PRIORITY", "priority", "prio", "p")...)
|
env = append(env, envVar(fmt.Sprintf("%d", m.Priority), "NTFY_PRIORITY", "priority", "prio", "p")...)
|
||||||
env = append(env, envVar(strings.Join(m.Tags, ","), "NTFY_TAGS", "tags", "tag", "ta")...)
|
env = append(env, envVar(strings.Join(m.Tags, ","), "NTFY_TAGS", "tags", "tag", "ta")...)
|
||||||
env = append(env, envVar(m.Raw, "NTFY_RAW", "raw")...)
|
env = append(env, envVar(m.Raw, "NTFY_RAW", "raw")...)
|
||||||
return env
|
sort.Strings(env)
|
||||||
|
if log.IsTrace() {
|
||||||
|
log.Trace("%s With environment:\n%s", logMessagePrefix(m), strings.Join(env, "\n"))
|
||||||
|
}
|
||||||
|
return append(os.Environ(), env...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func envVar(value string, vars ...string) []string {
|
func envVar(value string, vars ...string) []string {
|
||||||
@@ -249,13 +276,30 @@ func loadConfig(c *cli.Context) (*client.Config, error) {
|
|||||||
if filename != "" {
|
if filename != "" {
|
||||||
return client.LoadConfig(filename)
|
return client.LoadConfig(filename)
|
||||||
}
|
}
|
||||||
u, _ := user.Current()
|
configFile := defaultClientConfigFile()
|
||||||
configFile := defaultClientRootConfigFile
|
|
||||||
if u.Uid != "0" {
|
|
||||||
configFile = util.ExpandHome(defaultClientUserConfigFile)
|
|
||||||
}
|
|
||||||
if s, _ := os.Stat(configFile); s != nil {
|
if s, _ := os.Stat(configFile); s != nil {
|
||||||
return client.LoadConfig(configFile)
|
return client.LoadConfig(configFile)
|
||||||
}
|
}
|
||||||
return client.NewConfig(), nil
|
return client.NewConfig(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//lint:ignore U1000 Conditionally used in different builds
|
||||||
|
func defaultClientConfigFileUnix() string {
|
||||||
|
u, _ := user.Current()
|
||||||
|
configFile := clientRootConfigFileUnixAbsolute
|
||||||
|
if u.Uid != "0" {
|
||||||
|
homeDir, _ := os.UserConfigDir()
|
||||||
|
return filepath.Join(homeDir, clientUserConfigFileUnixRelative)
|
||||||
|
}
|
||||||
|
return configFile
|
||||||
|
}
|
||||||
|
|
||||||
|
//lint:ignore U1000 Conditionally used in different builds
|
||||||
|
func defaultClientConfigFileWindows() string {
|
||||||
|
homeDir, _ := os.UserConfigDir()
|
||||||
|
return filepath.Join(homeDir, clientUserConfigFileWindowsRelative)
|
||||||
|
}
|
||||||
|
|
||||||
|
func logMessagePrefix(m *client.Message) string {
|
||||||
|
return fmt.Sprintf("%s/%s", util.ShortTopicURL(m.TopicURL), m.ID)
|
||||||
|
}
|
||||||
|
|||||||
16
cmd/subscribe_darwin.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
const (
|
||||||
|
scriptExt = "sh"
|
||||||
|
scriptHeader = "#!/bin/sh\n"
|
||||||
|
clientCommandDescriptionSuffix = `The default config file for all client commands is /etc/ntfy/client.yml (if root user),
|
||||||
|
or "~/Library/Application Support/ntfy/client.yml" for all other users.`
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
scriptLauncher = []string{"sh", "-c"}
|
||||||
|
)
|
||||||
|
|
||||||
|
func defaultClientConfigFile() string {
|
||||||
|
return defaultClientConfigFileUnix()
|
||||||
|
}
|
||||||
19
cmd/subscribe_unix.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
//go:build linux || dragonfly || freebsd || netbsd || openbsd
|
||||||
|
// +build linux dragonfly freebsd netbsd openbsd
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
const (
|
||||||
|
scriptExt = "sh"
|
||||||
|
scriptHeader = "#!/bin/sh\n"
|
||||||
|
clientCommandDescriptionSuffix = `The default config file for all client commands is /etc/ntfy/client.yml (if root user),
|
||||||
|
or ~/.config/ntfy/client.yml for all other users.`
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
scriptLauncher = []string{"sh", "-c"}
|
||||||
|
)
|
||||||
|
|
||||||
|
func defaultClientConfigFile() string {
|
||||||
|
return defaultClientConfigFileUnix()
|
||||||
|
}
|
||||||
15
cmd/subscribe_windows.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
const (
|
||||||
|
scriptExt = "bat"
|
||||||
|
scriptHeader = ""
|
||||||
|
clientCommandDescriptionSuffix = `The default config file for all client commands is %AppData%\ntfy\client.yml.`
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
scriptLauncher = []string{"cmd.exe", "/Q", "/C"}
|
||||||
|
)
|
||||||
|
|
||||||
|
func defaultClientConfigFile() string {
|
||||||
|
return defaultClientConfigFileWindows()
|
||||||
|
}
|
||||||
86
cmd/user.go
@@ -1,30 +1,44 @@
|
|||||||
|
//go:build !noserver
|
||||||
|
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"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/auth"
|
"heckel.io/ntfy/auth"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var flagsUser = userCommandFlags()
|
func init() {
|
||||||
|
commands = append(commands, cmdUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
var flagsUser = append(
|
||||||
|
flagsDefault,
|
||||||
|
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, Usage: "config file"},
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
|
||||||
|
)
|
||||||
|
|
||||||
var cmdUser = &cli.Command{
|
var cmdUser = &cli.Command{
|
||||||
Name: "user",
|
Name: "user",
|
||||||
Usage: "Manage/show users",
|
Usage: "Manage/show users",
|
||||||
UsageText: "ntfy user [list|add|remove|change-pass|change-role] ...",
|
UsageText: "ntfy user [list|add|remove|change-pass|change-role] ...",
|
||||||
Flags: flagsUser,
|
Flags: flagsUser,
|
||||||
Before: initConfigFileInputSource("config", flagsUser),
|
Before: initConfigFileInputSourceFunc("config", flagsUser, initLogFunc),
|
||||||
Category: categoryServer,
|
Category: categoryServer,
|
||||||
Subcommands: []*cli.Command{
|
Subcommands: []*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",
|
UsageText: "ntfy user add [--role=admin|user] USERNAME\nNTFY_PASSWORD=... 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(auth.RoleUser), Usage: "user role"},
|
&cli.StringFlag{Name: "role", Aliases: []string{"r"}, Value: string(auth.RoleUser), Usage: "user role"},
|
||||||
@@ -36,8 +50,12 @@ 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)
|
||||||
|
|
||||||
|
You may set the NTFY_PASSWORD environment variable to pass the password. This is useful if
|
||||||
|
you are creating users via scripts.
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -56,7 +74,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",
|
UsageText: "ntfy user change-pass USERNAME\nNTFY_PASSWORD=... 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.
|
||||||
|
|
||||||
@@ -64,7 +82,12 @@ The new password will be read from STDIN, and it'll be confirmed by typing
|
|||||||
it twice.
|
it twice.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
ntfy user change-pass phil
|
ntfy user change-pass phil
|
||||||
|
NTFY_PASSWORD=.. ntfy user change-pass phil
|
||||||
|
|
||||||
|
You may set the NTFY_PASSWORD environment variable to pass the new password. This is
|
||||||
|
useful if you are updating users via scripts.
|
||||||
|
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -113,18 +136,24 @@ The command allows you to add/remove/change users in the ntfy user database, as
|
|||||||
passwords or roles.
|
passwords or roles.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
ntfy user list # Shows list of users (alias: 'ntfy access')
|
ntfy user list # Shows list of users (alias: 'ntfy access')
|
||||||
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_PASSWORD=... ntfy user add phil # As above, using env variable to set password (for scripts)
|
||||||
ntfy user del phil # Delete user phil
|
ntfy user add --role=admin phil # Add admin user phil
|
||||||
ntfy user change-pass phil # Change password for user phil
|
ntfy user del phil # Delete user phil
|
||||||
ntfy user change-role phil admin # Make user phil an admin
|
ntfy user change-pass phil # Change password for user phil
|
||||||
|
NTFY_PASSWORD=.. ntfy user change-pass phil # As above, using env variable to set password (for scripts)
|
||||||
|
ntfy user change-role phil admin # Make user phil an admin
|
||||||
|
|
||||||
|
For the 'ntfy user add' and 'ntfy user change-pass' commands, you may set the NTFY_PASSWORD environment
|
||||||
|
variable to pass the new password. This is useful if you are creating/updating users via scripts.
|
||||||
`,
|
`,
|
||||||
}
|
}
|
||||||
|
|
||||||
func execUserAdd(c *cli.Context) error {
|
func execUserAdd(c *cli.Context) error {
|
||||||
username := c.Args().Get(0)
|
username := c.Args().Get(0)
|
||||||
role := auth.Role(c.String("role"))
|
role := auth.Role(c.String("role"))
|
||||||
|
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 {
|
} else if username == userEveryone {
|
||||||
@@ -139,9 +168,13 @@ func execUserAdd(c *cli.Context) error {
|
|||||||
if user, _ := manager.User(username); user != nil {
|
if user, _ := manager.User(username); user != nil {
|
||||||
return fmt.Errorf("user %s already exists", username)
|
return fmt.Errorf("user %s already exists", username)
|
||||||
}
|
}
|
||||||
password, err := readPasswordAndConfirm(c)
|
if password == "" {
|
||||||
if err != nil {
|
p, err := readPasswordAndConfirm(c)
|
||||||
return err
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
password = p
|
||||||
}
|
}
|
||||||
if err := manager.AddUser(username, password, role); err != nil {
|
if err := manager.AddUser(username, password, role); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -173,6 +206,7 @@ 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")
|
||||||
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 {
|
} else if username == userEveryone {
|
||||||
@@ -185,9 +219,11 @@ func execUserChangePass(c *cli.Context) error {
|
|||||||
if _, err := manager.User(username); err == auth.ErrNotFound {
|
if _, err := manager.User(username); err == auth.ErrNotFound {
|
||||||
return fmt.Errorf("user %s does not exist", username)
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
}
|
}
|
||||||
password, err := readPasswordAndConfirm(c)
|
if password == "" {
|
||||||
if err != nil {
|
password, err = readPasswordAndConfirm(c)
|
||||||
return err
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := manager.ChangePassword(username, password); err != nil {
|
if err := manager.ChangePassword(username, password); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -237,7 +273,7 @@ func createAuthManager(c *cli.Context) (auth.Manager, error) {
|
|||||||
return nil, errors.New("option auth-file not set; auth is unconfigured for this server")
|
return nil, errors.New("option auth-file not set; auth is unconfigured for this server")
|
||||||
} else if !util.FileExists(authFile) {
|
} else if !util.FileExists(authFile) {
|
||||||
return nil, errors.New("auth-file does not exist; please start the server at least once to create it")
|
return nil, errors.New("auth-file does not exist; please start the server at least once to create it")
|
||||||
} else if !util.InStringList([]string{"read-write", "read-only", "write-only", "deny-all"}, authDefaultAccess) {
|
} else if !util.Contains([]string{"read-write", "read-only", "write-only", "deny-all"}, authDefaultAccess) {
|
||||||
return nil, errors.New("if set, auth-default-access must start set to 'read-write', 'read-only' or 'deny-all'")
|
return nil, errors.New("if set, auth-default-access must start set to 'read-write', 'read-only' or 'deny-all'")
|
||||||
}
|
}
|
||||||
authDefaultRead := authDefaultAccess == "read-write" || authDefaultAccess == "read-only"
|
authDefaultRead := authDefaultAccess == "read-write" || authDefaultAccess == "read-only"
|
||||||
@@ -262,11 +298,3 @@ func readPasswordAndConfirm(c *cli.Context) (string, error) {
|
|||||||
}
|
}
|
||||||
return string(password), nil
|
return string(password), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func userCommandFlags() []cli.Flag {
|
|
||||||
return []cli.Flag{
|
|
||||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/server.yml", DefaultText: "/etc/ntfy/server.yml", Usage: "config file"},
|
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
|
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
17
docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
version: "2.1"
|
||||||
|
services:
|
||||||
|
ntfy:
|
||||||
|
image: binwiederhier/ntfy
|
||||||
|
container_name: ntfy
|
||||||
|
command:
|
||||||
|
- serve
|
||||||
|
environment:
|
||||||
|
- TZ=UTC # optional: Change to your desired timezone
|
||||||
|
user: UID:GID # optional: Set custom user/group or uid/gid
|
||||||
|
volumes:
|
||||||
|
- /var/cache/ntfy:/var/cache/ntfy
|
||||||
|
- /etc/ntfy:/etc/ntfy
|
||||||
|
ports:
|
||||||
|
- 80:80
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
382
docs/config.md
@@ -227,7 +227,7 @@ The easiest way to configure a private instance is to set `auth-default-access`
|
|||||||
|
|
||||||
=== "/etc/ntfy/server.yml"
|
=== "/etc/ntfy/server.yml"
|
||||||
``` yaml
|
``` yaml
|
||||||
auth-file "/var/lib/ntfy/user.db"
|
auth-file: "/var/lib/ntfy/user.db"
|
||||||
auth-default-access: "deny-all"
|
auth-default-access: "deny-all"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -309,6 +309,25 @@ with the given username/password. Be sure to use HTTPS to avoid eavesdropping an
|
|||||||
]));
|
]));
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Example: UnifiedPush
|
||||||
|
[UnifiedPush](https://unifiedpush.org) requires that the [application server](https://unifiedpush.org/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.
|
||||||
|
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.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
=== "Prefix"
|
||||||
|
```
|
||||||
|
$ ntfy access '*' 'up*' write-only
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Explicitly"
|
||||||
|
```
|
||||||
|
$ ntfy access '*' upYzMtZGZiYTY5 write-only
|
||||||
|
```
|
||||||
|
|
||||||
## E-mail notifications
|
## E-mail notifications
|
||||||
To allow forwarding messages via e-mail, you can configure an **SMTP server for outgoing messages**. Once configured,
|
To allow forwarding messages via e-mail, you can configure an **SMTP server for outgoing messages**. Once configured,
|
||||||
you can set the `X-Email` header to [send messages via e-mail](publish.md#e-mail-notifications) (e.g.
|
you can set the `X-Email` header to [send messages via e-mail](publish.md#e-mail-notifications) (e.g.
|
||||||
@@ -441,8 +460,15 @@ by forwarding the `Connection` and `Upgrade` headers accordingly.
|
|||||||
In this example, ntfy runs on `:2586` and we proxy traffic to it. We also redirect HTTP to HTTPS for GET requests against a topic
|
In this example, ntfy runs on `:2586` and we proxy traffic to it. We also redirect HTTP to HTTPS for GET requests against a topic
|
||||||
or the root domain:
|
or the root domain:
|
||||||
|
|
||||||
=== "nginx (/etc/nginx/sites-*/ntfy)"
|
=== "nginx (convenient)"
|
||||||
```
|
```
|
||||||
|
# /etc/nginx/sites-*/ntfy
|
||||||
|
#
|
||||||
|
# This config allows insecure HTTP POST/PUT requests against topics to allow a short curl syntax (without -L
|
||||||
|
# and "https://" prefix). It also disables output buffering, which has worked well for the ntfy.sh server.
|
||||||
|
#
|
||||||
|
# This is how ntfy.sh is configured.
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name ntfy.sh;
|
server_name ntfy.sh;
|
||||||
@@ -486,7 +512,7 @@ or the root domain:
|
|||||||
server_name ntfy.sh;
|
server_name ntfy.sh;
|
||||||
|
|
||||||
ssl_session_cache builtin:1000 shared:SSL:10m;
|
ssl_session_cache builtin:1000 shared:SSL:10m;
|
||||||
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
ssl_ciphers HIGH:!aNULL:!eNULL:!EXPORT:!CAMELLIA:!DES:!MD5:!PSK:!RC4;
|
ssl_ciphers HIGH:!aNULL:!eNULL:!EXPORT:!CAMELLIA:!DES:!MD5:!PSK:!RC4;
|
||||||
ssl_prefer_server_ciphers on;
|
ssl_prefer_server_ciphers on;
|
||||||
|
|
||||||
@@ -515,8 +541,70 @@ or the root domain:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "Apache2 (/etc/apache2/sites-*/ntfy.conf)"
|
=== "nginx (more secure)"
|
||||||
```
|
```
|
||||||
|
# /etc/nginx/sites-*/ntfy
|
||||||
|
#
|
||||||
|
# This config requires the use of the -L flag in curl to redirect to HTTPS, and it keeps nginx output buffering
|
||||||
|
# enabled. While recommended, I have had issues with that in the past.
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name ntfy.sh;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 302 https://$http_host$request_uri$is_args$query_string;
|
||||||
|
|
||||||
|
proxy_pass http://127.0.0.1:2586;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
||||||
|
proxy_connect_timeout 3m;
|
||||||
|
proxy_send_timeout 3m;
|
||||||
|
proxy_read_timeout 3m;
|
||||||
|
|
||||||
|
client_max_body_size 20m; # Must be >= attachment-file-size-limit in /etc/ntfy/server.yml
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name ntfy.sh;
|
||||||
|
|
||||||
|
ssl_session_cache builtin:1000 shared:SSL:10m;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!eNULL:!EXPORT:!CAMELLIA:!DES:!MD5:!PSK:!RC4;
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/ntfy.sh/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/ntfy.sh/privkey.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:2586;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
||||||
|
proxy_connect_timeout 3m;
|
||||||
|
proxy_send_timeout 3m;
|
||||||
|
proxy_read_timeout 3m;
|
||||||
|
|
||||||
|
client_max_body_size 20m; # Must be >= attachment-file-size-limit in /etc/ntfy/server.yml
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Apache2"
|
||||||
|
```
|
||||||
|
# /etc/apache2/sites-*/ntfy.conf
|
||||||
|
|
||||||
<VirtualHost *:80>
|
<VirtualHost *:80>
|
||||||
ServerName ntfy.sh
|
ServerName ntfy.sh
|
||||||
|
|
||||||
@@ -618,6 +706,47 @@ Example:
|
|||||||
firebase-key-file: "/etc/ntfy/ntfy-sh-firebase-adminsdk-ahnce-9f4d6f14b5.json"
|
firebase-key-file: "/etc/ntfy/ntfy-sh-firebase-adminsdk-ahnce-9f4d6f14b5.json"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## iOS instant notifications
|
||||||
|
Unlike Android, iOS heavily restricts background processing, which sadly makes it impossible to implement instant
|
||||||
|
push notifications without a central server.
|
||||||
|
|
||||||
|
To still support instant notifications on iOS through your self-hosted ntfy server, you have to forward so called `poll_request`
|
||||||
|
messages to the main ntfy.sh server (or any upstream server that's APNS/Firebase connected, if you build your own iOS app),
|
||||||
|
which will then forward it to Firebase/APNS.
|
||||||
|
|
||||||
|
To configure it, simply set `upstream-base-url` like so:
|
||||||
|
|
||||||
|
``` yaml
|
||||||
|
upstream-base-url: "https://ntfy.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
If set, all incoming messages will publish a poll request to the configured upstream server, containing
|
||||||
|
the message ID of the original message, instructing the iOS app to poll this server for the actual message contents.
|
||||||
|
|
||||||
|
If `upstream-base-url` is not set, notifications will still eventually get to your device, but delivery can take hours,
|
||||||
|
depending on the state of the phone. If you are using your phone, it shouldn't take more than 20-30 minutes though.
|
||||||
|
|
||||||
|
In case you're curious, here's an example of the entire flow:
|
||||||
|
|
||||||
|
- In the iOS app, you subscribe to `https://ntfy.example.com/mytopic`
|
||||||
|
- The app subscribes to the Firebase topic `6de73be8dfb7d69e...` (the SHA256 of the topic URL)
|
||||||
|
- When you publish a message to `https://ntfy.example.com/mytopic`, your ntfy server will publish a
|
||||||
|
poll request to `https://ntfy.sh/6de73be8dfb7d69e...`. The request from your server to the upstream server
|
||||||
|
contains only the message ID (in the `X-Poll-ID` header), and the SHA256 checksum of the topic URL (as upstream topic).
|
||||||
|
- The ntfy.sh server publishes the poll request message to Firebase, which forwards it to APNS, which forwards it to your iOS device
|
||||||
|
- Your iOS device receives the poll request, and fetches the actual message from your server, and then displays it
|
||||||
|
|
||||||
|
Here's an example of what the self-hosted server forwards to the upstream server. The request is equivalent to this curl:
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -X POST -H "X-Poll-ID: s4PdJozxM8na" https://ntfy.sh/6de73be8dfb7d69e32fb2c00c23fe7adbd8b5504406e3068c273aa24cef4055b
|
||||||
|
{"id":"4HsClFEuCIcs","time":1654087955,"event":"poll_request","topic":"6de73be8dfb7d69e32fb2c00c23fe7adbd8b5504406e3068c273aa24cef4055b","message":"New message","poll_id":"s4PdJozxM8na"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that the self-hosted server literally sends the message `New message` for every message, even if your message
|
||||||
|
may be `Some other message`. This is so that if iOS cannot talk to the self-hosted server (in time, or at all),
|
||||||
|
it'll show `New message` as a popup.
|
||||||
|
|
||||||
## 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.
|
||||||
@@ -671,6 +800,23 @@ are enabled):
|
|||||||
* `visitor-email-limit-burst` is the initial bucket of emails each visitor has. This defaults to 16.
|
* `visitor-email-limit-burst` is the initial bucket of emails each visitor has. This defaults to 16.
|
||||||
* `visitor-email-limit-replenish` is the rate at which the bucket is refilled (one email per x). Defaults to 1h.
|
* `visitor-email-limit-replenish` is the rate at which the bucket is refilled (one email per x). Defaults to 1h.
|
||||||
|
|
||||||
|
### Firebase limits
|
||||||
|
If [Firebase is configured](#firebase-fcm), all messages are also published to a Firebase topic (unless `Firebase: no`
|
||||||
|
is set). Firebase enforces [its own limits](https://firebase.google.com/docs/cloud-messaging/concept-options#topics_throttling)
|
||||||
|
on how many messages can be published. Unfortunately these limits are a little vague and can change depending on the time
|
||||||
|
of day. In practice, I have only ever observed `429 Quota exceeded` responses from Firebase if **too many messages are published to
|
||||||
|
the same topic**.
|
||||||
|
|
||||||
|
In ntfy, if Firebase responds with a 429 after publishing to a topic, the visitor (= IP address) who published the message
|
||||||
|
is **banned from publishing to Firebase for 10 minutes** (not configurable). Because publishing to Firebase happens asynchronously,
|
||||||
|
there is no indication of the user that this has happened. Non-Firebase subscribers (WebSocket or HTTP stream) are not affected.
|
||||||
|
After the 10 minutes are up, messages forwarding to Firebase is resumed for this visitor.
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
## 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,
|
||||||
if it's not behind a proxy, the ntfy server can keep about **as many connections as the open file limit allows**.
|
if it's not behind a proxy, the ntfy server can keep about **as many connections as the open file limit allows**.
|
||||||
@@ -679,6 +825,29 @@ out [this discussion on Reddit](https://www.reddit.com/r/golang/comments/r9u4ee/
|
|||||||
|
|
||||||
Depending on *how you run it*, here are a few limits that are relevant:
|
Depending on *how you run it*, here are a few limits that are relevant:
|
||||||
|
|
||||||
|
### Message cache
|
||||||
|
By default, the [message cache](#message-cache) (defined by `cache-file`) uses the SQLite default settings, which means it
|
||||||
|
syncs to disk on every write. For personal servers, this is perfectly adequate. For larger installations, such as ntfy.sh,
|
||||||
|
the [write-ahead log (WAL)](https://sqlite.org/wal.html) should be enabled, and the sync mode should be adjusted.
|
||||||
|
See [this article](https://phiresky.github.io/blog/2020/sqlite-performance-tuning/) for details.
|
||||||
|
|
||||||
|
In addition to that, for very high load servers (such as ntfy.sh), it may be beneficial to write messages to the cache
|
||||||
|
in batches, and asynchronously. This can be enabled with the `cache-batch-size` and `cache-batch-timeout`. If you start
|
||||||
|
seeing `database locked` messages in the logs, you should probably enable that.
|
||||||
|
|
||||||
|
Here's how ntfy.sh has been tuned in the `server.yml` file:
|
||||||
|
|
||||||
|
``` yaml
|
||||||
|
cache-batch-size: 25
|
||||||
|
cache-batch-timeout: "1s"
|
||||||
|
cache-startup-queries: |
|
||||||
|
pragma journal_mode = WAL;
|
||||||
|
pragma synchronous = normal;
|
||||||
|
pragma temp_store = memory;
|
||||||
|
pragma busy_timeout = 15000;
|
||||||
|
vacuum;
|
||||||
|
```
|
||||||
|
|
||||||
### For systemd services
|
### For systemd services
|
||||||
If you're running ntfy in a systemd service (e.g. for .deb/.rpm packages), the main limiting factor is the
|
If you're running ntfy in a systemd service (e.g. for .deb/.rpm packages), the main limiting factor is the
|
||||||
`LimitNOFILE` setting in the systemd unit. The default open files limit for `ntfy.service` is 10,000. You can override it
|
`LimitNOFILE` setting in the systemd unit. The default open files limit for `ntfy.service` is 10,000. You can override it
|
||||||
@@ -736,8 +905,24 @@ and [here](https://easyengine.io/tutorials/nginx/block-wp-login-php-bruteforce-a
|
|||||||
|
|
||||||
=== "/etc/nginx/nginx.conf"
|
=== "/etc/nginx/nginx.conf"
|
||||||
```
|
```
|
||||||
|
# Rate limit all IP addresses
|
||||||
http {
|
http {
|
||||||
limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;
|
limit_req_zone $binary_remote_addr zone=one:10m rate=45r/m;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Alternatively, whitelist certain IP addresses
|
||||||
|
http {
|
||||||
|
geo $limited {
|
||||||
|
default 1;
|
||||||
|
116.203.112.46/32 0;
|
||||||
|
132.226.42.65/32 0;
|
||||||
|
...
|
||||||
|
}
|
||||||
|
map $limited $limitkey {
|
||||||
|
1 $binary_remote_addr;
|
||||||
|
0 "";
|
||||||
|
}
|
||||||
|
limit_req_zone $limitkey zone=one:10m rate=45r/m;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -766,52 +951,82 @@ and [here](https://easyengine.io/tutorials/nginx/block-wp-login-php-bruteforce-a
|
|||||||
action = iptables-multiport[name=ReqLimit, port="http,https", protocol=tcp]
|
action = iptables-multiport[name=ReqLimit, port="http,https", protocol=tcp]
|
||||||
logpath = /var/log/nginx/error.log
|
logpath = /var/log/nginx/error.log
|
||||||
findtime = 600
|
findtime = 600
|
||||||
bantime = 7200
|
bantime = 14400
|
||||||
maxretry = 10
|
maxretry = 10
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Debugging/tracing
|
||||||
|
If something's not working right, you can debug/trace through what the ntfy server is doing by setting the `log-level`
|
||||||
|
to `DEBUG` or `TRACE`. The `DEBUG` setting will output information about each published message, but not the message
|
||||||
|
contents. The `TRACE` setting will also print the message contents.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
Both options are very verbose and should only be enabled in production for short periods of time. Otherwise,
|
||||||
|
you're going to run out of disk space pretty quickly.
|
||||||
|
|
||||||
|
You can also hot-reload the `log-level` by sending the `SIGHUP` signal to the process after editing the `server.yml` file.
|
||||||
|
You can do so by calling `systemctl reload ntfy` (if ntfy is running inside systemd), or by calling `kill -HUP $(pidof ntfy)`.
|
||||||
|
If successful, you'll see something like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ ntfy serve
|
||||||
|
2022/06/02 10:29:28 INFO Listening on :2586[http] :1025[smtp], log level is INFO
|
||||||
|
2022/06/02 10:29:34 INFO Partially hot reloading configuration ...
|
||||||
|
2022/06/02 10:29:34 INFO Log level is TRACE
|
||||||
|
```
|
||||||
|
|
||||||
## Config options
|
## Config options
|
||||||
Each config option can be set in the config file `/etc/ntfy/server.yml` (e.g. `listen-http: :80`) or as a
|
Each config option can be set in the config file `/etc/ntfy/server.yml` (e.g. `listen-http: :80`) or as a
|
||||||
CLI option (e.g. `--listen-http :80`. Here's a list of all available options. Alternatively, you can set an environment
|
CLI option (e.g. `--listen-http :80`. Here's a list of all available options. Alternatively, you can set an environment
|
||||||
variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
||||||
|
|
||||||
| Config option | Env variable | Format | Default | Description |
|
!!! info
|
||||||
|--------------------------------------------|-------------------------------------------------|-----------------------------------------------------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
All config options can also be defined in the `server.yml` file using underscores instead of dashes, e.g.
|
||||||
| `base-url` | `NTFY_BASE_URL` | *URL* | - | Public facing base URL of the service (e.g. `https://ntfy.sh`) |
|
`cache_duration` and `cache-duration` are both supported. This is to support stricter YAML parsers that do
|
||||||
| `listen-http` | `NTFY_LISTEN_HTTP` | `[host]:port` | `:80` | Listen address for the HTTP web server |
|
not support dashes.
|
||||||
| `listen-https` | `NTFY_LISTEN_HTTPS` | `[host]:port` | - | Listen address for the HTTPS web server. If set, you also need to set `key-file` and `cert-file`. |
|
|
||||||
| `listen-unix` | `NTFY_LISTEN_UNIX` | *filename* | - | Path to a Unix socket to listen on |
|
| Config option | Env variable | Format | Default | Description |
|
||||||
| `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. |
|
| `base-url` | `NTFY_BASE_URL` | *URL* | - | Public facing base URL of the service (e.g. `https://ntfy.sh`) |
|
||||||
| `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). |
|
| `listen-http` | `NTFY_LISTEN_HTTP` | `[host]:port` | `:80` | Listen address for the HTTP web server |
|
||||||
| `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). |
|
| `listen-https` | `NTFY_LISTEN_HTTPS` | `[host]:port` | - | Listen address for the HTTPS web server. If set, you also need to set `key-file` and `cert-file`. |
|
||||||
| `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. |
|
| `listen-unix` | `NTFY_LISTEN_UNIX` | *filename* | - | Path to a Unix socket to listen on |
|
||||||
| `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). |
|
| `listen-unix-mode` | `NTFY_LISTEN_UNIX_MODE` | *file mode* | *system default* | File mode of the Unix socket, e.g. 0700 or 0777 |
|
||||||
| `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`. |
|
| `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. |
|
||||||
| `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. |
|
| `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. |
|
||||||
| `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory* | - | Cache directory for attached files. To enable attachments, this has to be 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). |
|
||||||
| `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. |
|
| `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). |
|
||||||
| `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. |
|
| `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. |
|
||||||
| `attachment-expiry-duration` | `NTFY_ATTACHMENT_EXPIRY_DURATION` | *duration* | 3h | Duration after which uploaded attachments will be deleted (e.g. 3h, 20h). Strongly affects `visitor-attachment-total-size-limit`. |
|
| `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) |
|
||||||
| `smtp-sender-addr` | `NTFY_SMTP_SENDER_ADDR` | `host:port` | - | SMTP server address to allow email sending |
|
| `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) |
|
||||||
| `smtp-sender-user` | `NTFY_SMTP_SENDER_USER` | *string* | - | SMTP user; only used if e-mail sending is enabled |
|
| `cache-batch-timeout` | `NTFY_CACHE_BATCH_TIMEOUT` | *duration* | 0s | Timeout for batched async writes to the message cache (if zero, writes are synchronous) |
|
||||||
| `smtp-sender-pass` | `NTFY_SMTP_SENDER_PASS` | *string* | - | SMTP password; only used if e-mail sending is enabled |
|
| `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). |
|
||||||
| `smtp-sender-from` | `NTFY_SMTP_SENDER_FROM` | *e-mail address* | - | SMTP sender e-mail address; only used if e-mail sending is enabled |
|
| `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`. |
|
||||||
| `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` |
|
| `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. |
|
||||||
| `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` |
|
| `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory* | - | Cache directory for attached files. To enable attachments, this has to be set. |
|
||||||
| `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | `[ip]:port` | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` |
|
| `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. |
|
||||||
| `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. |
|
| `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. |
|
||||||
| `manager-interval` | `$NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. |
|
| `attachment-expiry-duration` | `NTFY_ATTACHMENT_EXPIRY_DURATION` | *duration* | 3h | Duration after which uploaded attachments will be deleted (e.g. 3h, 20h). Strongly affects `visitor-attachment-total-size-limit`. |
|
||||||
| `web-root` | `NTFY_WEB_ROOT` | `app` or `home` | `app` | Sets web root to landing page (home) or web app (app) |
|
| `smtp-sender-addr` | `NTFY_SMTP_SENDER_ADDR` | `host:port` | - | SMTP server address to allow email sending |
|
||||||
| `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. |
|
| `smtp-sender-user` | `NTFY_SMTP_SENDER_USER` | *string* | - | SMTP user; only used if e-mail sending is enabled |
|
||||||
| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
|
| `smtp-sender-pass` | `NTFY_SMTP_SENDER_PASS` | *string* | - | SMTP password; only used if e-mail sending is enabled |
|
||||||
| `visitor-attachment-total-size-limit` | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 100M | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`. |
|
| `smtp-sender-from` | `NTFY_SMTP_SENDER_FROM` | *e-mail address* | - | SMTP sender e-mail address; only used if e-mail sending is enabled |
|
||||||
| `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size* | 500M | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding. |
|
| `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` |
|
||||||
| `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 |
|
| `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` |
|
||||||
| `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 |
|
| `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | *string* | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` |
|
||||||
| `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 |
|
| `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. |
|
||||||
| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Rate limiting:Initial limit of e-mails per visitor |
|
| `manager-interval` | `NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. |
|
||||||
| `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled |
|
| `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 |
|
||||||
|
| `visitor-attachment-total-size-limit` | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 100M | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`. |
|
||||||
|
| `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size* | 500M | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding. |
|
||||||
|
| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Rate limiting:Initial limit of e-mails per visitor |
|
||||||
|
| `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled |
|
||||||
|
| `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has |
|
||||||
|
| `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 5s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled |
|
||||||
|
| `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting |
|
||||||
|
| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
|
||||||
|
| `web-root` | `NTFY_WEB_ROOT` | `app`, `home` or `disable` | `app` | Sets web root to landing page (home), web app (app) or disables the web app entirely (disable) |
|
||||||
|
|
||||||
The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.
|
The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.
|
||||||
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.
|
||||||
@@ -839,42 +1054,49 @@ DESCRIPTION:
|
|||||||
ntfy serve --listen-http :8080 # Starts server with alternate port
|
ntfy serve --listen-http :8080 # Starts server with alternate port
|
||||||
|
|
||||||
OPTIONS:
|
OPTIONS:
|
||||||
--config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
|
--attachment-cache-dir value, --attachment_cache_dir value cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR]
|
||||||
--base-url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
|
--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]
|
||||||
--listen-http value, -l value ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
|
--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]
|
||||||
--listen-https value, -L value ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS]
|
--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]
|
||||||
--listen-unix value, -U value listen on unix socket path [$NTFY_LISTEN_UNIX]
|
--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]
|
||||||
--key-file value, -K value private key file, if listen-https is set [$NTFY_KEY_FILE]
|
--auth-file value, --auth_file value, -H value auth database file used for access control [$NTFY_AUTH_FILE]
|
||||||
--cert-file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE]
|
--base-url value, --base_url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
|
||||||
--firebase-key-file value, -F value Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE]
|
--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]
|
||||||
--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, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION]
|
--cache-file value, --cache_file value, -C value cache file used for message caching [$NTFY_CACHE_FILE]
|
||||||
--auth-file value, -H value auth database file used for access control [$NTFY_AUTH_FILE]
|
--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]
|
||||||
--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]
|
--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]
|
||||||
--attachment-cache-dir value cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR]
|
--cache-startup-queries value, --cache_startup_queries value queries run when the cache database is initialized [$NTFY_CACHE_STARTUP_QUERIES]
|
||||||
--attachment-total-size-limit value, -A value limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
--cert-file value, --cert_file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE]
|
||||||
--attachment-file-size-limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
|
--config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
|
||||||
--attachment-expiry-duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION]
|
--debug, -d enable debug logging (default: false) [$NTFY_DEBUG]
|
||||||
--keepalive-interval value, -k value interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL]
|
--firebase-key-file value, --firebase_key_file value, -F value Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE]
|
||||||
--manager-interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
|
--global-topic-limit value, --global_topic_limit value, -T value total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT]
|
||||||
--web-root value sets web root to landing page (home) or web app (app) (default: "app") [$NTFY_WEB_ROOT]
|
--keepalive-interval value, --keepalive_interval value, -k value interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL]
|
||||||
--smtp-sender-addr value SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR]
|
--key-file value, --key_file value, -K value private key file, if listen-https is set [$NTFY_KEY_FILE]
|
||||||
--smtp-sender-user value SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER]
|
--listen-http value, --listen_http value, -l value ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
|
||||||
--smtp-sender-pass value SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS]
|
--listen-https value, --listen_https value, -L value ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS]
|
||||||
--smtp-sender-from value SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM]
|
--listen-unix value, --listen_unix value, -U value listen on unix socket path [$NTFY_LISTEN_UNIX]
|
||||||
--smtp-server-listen value SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN]
|
--log-level value, --log_level value set log level (default: "INFO") [$NTFY_LOG_LEVEL]
|
||||||
--smtp-server-domain value SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN]
|
--manager-interval value, --manager_interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
|
||||||
--smtp-server-addr-prefix value SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX]
|
--no-log-dates, --no_log_dates disable the date/time prefix (default: false) [$NTFY_NO_LOG_DATES]
|
||||||
--global-topic-limit value, -T value total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT]
|
--smtp-sender-addr value, --smtp_sender_addr value SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR]
|
||||||
--visitor-subscription-limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
|
--smtp-sender-from value, --smtp_sender_from value SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM]
|
||||||
--visitor-attachment-total-size-limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
--smtp-sender-pass value, --smtp_sender_pass value SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS]
|
||||||
--visitor-attachment-daily-bandwidth-limit value total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT]
|
--smtp-sender-user value, --smtp_sender_user value SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER]
|
||||||
--visitor-request-limit-burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
|
--smtp-server-addr-prefix value, --smtp_server_addr_prefix value SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX]
|
||||||
--visitor-request-limit-replenish value interval at which burst limit is replenished (one per x) (default: 5s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
|
--smtp-server-domain value, --smtp_server_domain value SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN]
|
||||||
--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]
|
--smtp-server-listen value, --smtp_server_listen value SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN]
|
||||||
--visitor-email-limit-burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]
|
--trace enable tracing (very verbose, be careful) (default: false) [$NTFY_TRACE]
|
||||||
--visitor-email-limit-replenish value interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
|
--upstream-base-url value, --upstream_base_url value forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers [$NTFY_UPSTREAM_BASE_URL]
|
||||||
--behind-proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
|
--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]
|
||||||
--help, -h show help (default: false)
|
--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-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-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-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-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-subscription-limit value, --visitor_subscription_limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
|
||||||
|
--web-root value, --web_root value sets web root to landing page (home), web app (app) or disabled (disable) (default: "app") [$NTFY_WEB_ROOT]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,44 @@
|
|||||||
# Deprecation notices
|
# Deprecation notices
|
||||||
This page is used to list deprecation notices for ntfy. Deprecated commands and options will be
|
This page is used to list deprecation notices for ntfy. Deprecated commands and options will be
|
||||||
**removed after ~3 months** from the time they were deprecated.
|
**removed after 1-3 months** from the time they were deprecated. How long the feature is deprecated
|
||||||
|
before the behavior is changed depends on the severity of the change, and how prominent the feature is.
|
||||||
|
|
||||||
## Active deprecations
|
## Active deprecations
|
||||||
|
_No active deprecations_
|
||||||
### Android app: WebSockets will become the default connection protocol
|
|
||||||
> Active since 2022-03-13, behavior will change in **June 2022**
|
|
||||||
|
|
||||||
In future versions of the Android app, instant delivery connections and connections to self-hosted servers will
|
|
||||||
be using the WebSockets protocol. This potentially requires [configuration changes in your proxy](https://ntfy.sh/docs/config/#nginxapache2caddy).
|
|
||||||
|
|
||||||
Due to [reports of varying battery consumption](https://github.com/binwiederhier/ntfy/issues/190) (which entirely
|
|
||||||
seems to depend on the phone), JSON HTTP stream support will not be removed. Instead, I'll just flip the default to
|
|
||||||
WebSocket in June.
|
|
||||||
|
|
||||||
### Android app: Using `since=<timestamp>` instead of `since=<id>`
|
|
||||||
> Active since 2022-02-27, behavior will change in **May 2022**
|
|
||||||
|
|
||||||
In about 3 months, the Android app will start using `since=<id>` instead of `since=<timestamp>`, which means that it will
|
|
||||||
not work with servers older than v1.16.0 anymore. This is to simplify handling of deduplication in the Android app.
|
|
||||||
|
|
||||||
The `since=<timestamp>` endpoint will continue to work. This is merely a notice that the Android app behavior will change.
|
|
||||||
|
|
||||||
## Previous deprecations
|
## Previous deprecations
|
||||||
|
|
||||||
|
### ntfy CLI: `ntfy publish --env-topic` will be removed
|
||||||
|
> Active since 2022-06-20, behavior changed with v1.30.1
|
||||||
|
|
||||||
|
The `ntfy publish --env-topic` option will be removed. It'll still be possible to specify a topic via the
|
||||||
|
`NTFY_TOPIC` environment variable, but it won't be necessary anymore to specify the `--env-topic` flag.
|
||||||
|
|
||||||
|
=== "Before"
|
||||||
|
```
|
||||||
|
$ NTFY_TOPIC=mytopic ntfy publish --env-topic "this is the message"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "After"
|
||||||
|
```
|
||||||
|
$ NTFY_TOPIC=mytopic ntfy publish "this is the message"
|
||||||
|
```
|
||||||
|
|
||||||
|
### <del>Android app: WebSockets will become the default connection protocol</del>
|
||||||
|
> Active since 2022-03-13, behavior will not change (deprecation removed 2022-06-20)
|
||||||
|
|
||||||
|
Instant delivery connections and connections to self-hosted servers in the Android app were going to switch
|
||||||
|
to use the WebSockets protocol by default. It was decided to keep JSON stream as the most compatible default
|
||||||
|
and add a notice banner in the Android app instead.
|
||||||
|
|
||||||
|
### Android app: Using `since=<timestamp>` instead of `since=<id>`
|
||||||
|
> Active since 2022-02-27, behavior changed with v1.14.0
|
||||||
|
|
||||||
|
The Android app started using `since=<id>` instead of `since=<timestamp>`, which means as of Android app v1.14.0,
|
||||||
|
it will not work with servers older than v1.16.0 anymore. This is to simplify handling of deduplication in the Android app.
|
||||||
|
|
||||||
|
The `since=<timestamp>` endpoint will continue to work. This is merely a notice that the Android app behavior will change.
|
||||||
|
|
||||||
### Running server via `ntfy` (instead of `ntfy serve`)
|
### Running server via `ntfy` (instead of `ntfy serve`)
|
||||||
> Deprecated 2021-12-17, behavior changed with v1.10.0
|
> Deprecated 2021-12-17, behavior changed with v1.10.0
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ Build related:
|
|||||||
The `web/` and `docs/` folder are the sources for web app and documentation. During the build process,
|
The `web/` and `docs/` folder are the sources for web app and documentation. During the build process,
|
||||||
the generated output is copied to `server/site` (web app and landing page) and `server/docs` (documentation).
|
the generated output is copied to `server/site` (web app and landing page) and `server/docs` (documentation).
|
||||||
|
|
||||||
|
### Build/test on Gitpod
|
||||||
|
To get a quick working development environment you can use [Gitpod](https://gitpod.io), an in-browser IDE
|
||||||
|
that makes it easy to develop ntfy without having to set up a desktop IDE. For any real development,
|
||||||
|
I do suggest a proper IDE like [IntelliJ IDEA](https://www.jetbrains.com/idea/).
|
||||||
|
|
||||||
|
[](https://gitpod.io/#https://github.com/binwiederhier/ntfy)
|
||||||
|
|
||||||
### Build requirements
|
### Build requirements
|
||||||
|
|
||||||
* [Go](https://go.dev/) (required for main server)
|
* [Go](https://go.dev/) (required for main server)
|
||||||
@@ -58,8 +65,8 @@ These steps **assume Ubuntu**. Steps may vary on different Linux distributions.
|
|||||||
|
|
||||||
First, install [Go](https://go.dev/) (see [official instructions](https://go.dev/doc/install)):
|
First, install [Go](https://go.dev/) (see [official instructions](https://go.dev/doc/install)):
|
||||||
``` shell
|
``` shell
|
||||||
wget https://go.dev/dl/go1.18.linux-amd64.tar.gz
|
wget https://go.dev/dl/go1.19.1.linux-amd64.tar.gz
|
||||||
rm -rf /usr/local/go && tar -C /usr/local -xzf go1.18.linux-amd64.tar.gz
|
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.19.1.linux-amd64.tar.gz
|
||||||
export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin
|
export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin
|
||||||
go version # verifies that it worked
|
go version # verifies that it worked
|
||||||
```
|
```
|
||||||
@@ -72,7 +79,7 @@ goreleaser -v # verifies that it worked
|
|||||||
|
|
||||||
Install [nodejs](https://nodejs.org/en/) (see [official instructions](https://nodejs.org/en/download/package-manager/)):
|
Install [nodejs](https://nodejs.org/en/) (see [official instructions](https://nodejs.org/en/download/package-manager/)):
|
||||||
``` shell
|
``` shell
|
||||||
curl -fsSL https://deb.nodesource.com/setup_17.x | sudo -E bash -
|
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||||
sudo apt-get install -y nodejs
|
sudo apt-get install -y nodejs
|
||||||
npm -v # verifies that it worked
|
npm -v # verifies that it worked
|
||||||
```
|
```
|
||||||
@@ -112,15 +119,15 @@ by typing `make`:
|
|||||||
$ make
|
$ make
|
||||||
Typical commands (more see below):
|
Typical commands (more see below):
|
||||||
make build - Build web app, documentation and server/client (sloowwww)
|
make build - Build web app, documentation and server/client (sloowwww)
|
||||||
make server-amd64 - Build server/client binary (amd64, no web app or docs)
|
make cli-linux-amd64 - Build server/client binary (amd64, no web app or docs)
|
||||||
make install-amd64 - Install ntfy binary to /usr/bin/ntfy (amd64)
|
make install-linux-amd64 - Install ntfy binary to /usr/bin/ntfy (amd64)
|
||||||
make web - Build the web app
|
make web - Build the web app
|
||||||
make docs - Build the documentation
|
make docs - Build the documentation
|
||||||
make check - Run all tests, vetting/formatting checks and linters
|
make check - Run all tests, vetting/formatting checks and linters
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
If you want to build the **ntfy binary including web app and docs for all supported architectures** (amd64, armv7, and amd64),
|
If you want to build the **ntfy binary including web app and docs for all supported architectures** (amd64, armv7, and arm64),
|
||||||
you can simply run `make build`:
|
you can simply run `make build`:
|
||||||
|
|
||||||
``` shell
|
``` shell
|
||||||
@@ -158,47 +165,52 @@ $ make release-snapshot
|
|||||||
During development, you may want to be more picky and build only certain things. Here are a few examples.
|
During development, you may want to be more picky and build only certain things. Here are a few examples.
|
||||||
|
|
||||||
### Build the ntfy binary
|
### Build the ntfy binary
|
||||||
To build only the `ntfy` binary **without the web app or documentation**, use the `make server-...` targets:
|
To build only the `ntfy` binary **without the web app or documentation**, use the `make cli-...` targets:
|
||||||
|
|
||||||
``` shell
|
``` shell
|
||||||
$ make
|
$ make
|
||||||
Build server & client (not release version):
|
Build server & client (using GoReleaser, not release version):
|
||||||
make server - Build server & client (all architectures)
|
make cli - Build server & client (all architectures)
|
||||||
make server-amd64 - Build server & client (amd64 only)
|
make cli-linux-amd64 - Build server & client (Linux, amd64 only)
|
||||||
make server-armv7 - Build server & client (armv7 only)
|
make cli-linux-armv6 - Build server & client (Linux, armv6 only)
|
||||||
make server-arm64 - Build server & client (arm64 only)
|
make cli-linux-armv7 - Build server & client (Linux, armv7 only)
|
||||||
|
make cli-linux-arm64 - Build server & client (Linux, arm64 only)
|
||||||
|
make cli-windows-amd64 - Build client (Windows, amd64 only)
|
||||||
|
make cli-darwin-all - Build client (macOS, arm64+amd64 universal binary)
|
||||||
```
|
```
|
||||||
|
|
||||||
So if you're on an amd64/x86_64-based machine, you may just want to run `make server-amd64` during testing. On a modern
|
So if you're on an amd64/x86_64-based machine, you may just want to run `make cli-linux-amd64` during testing. On a modern
|
||||||
system, this shouldn't take longer than 5-10 seconds. I often combine it with `install-amd64` so I can run the binary
|
system, this shouldn't take longer than 5-10 seconds. I often combine it with `install-linux-amd64` so I can run the binary
|
||||||
right away:
|
right away:
|
||||||
|
|
||||||
``` shell
|
``` shell
|
||||||
$ make server-amd64 install-amd64
|
$ make cli-linux-amd64 install-linux-amd64
|
||||||
$ ntfy serve
|
$ ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
**During development of the main app, you can also just use `go run main.go`**, as long as you run
|
**During development of the main app, you can also just use `go run main.go`**, as long as you run
|
||||||
`make server-deps-static-sites`at least once and `CGO_ENABLED=1`:
|
`make cli-deps-static-sites`at least once and `CGO_ENABLED=1`:
|
||||||
|
|
||||||
``` shell
|
``` shell
|
||||||
$ export CGO_ENABLED=1
|
$ export CGO_ENABLED=1
|
||||||
$ make server-deps-static-sites
|
$ make cli-deps-static-sites
|
||||||
$ go run main.go serve
|
$ go run main.go serve
|
||||||
2022/03/18 08:43:55 Listening on :2586[http]
|
2022/03/18 08:43:55 Listening on :2586[http]
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
If you don't run `server-deps-static-sites`, you may see an error *`pattern ...: no matching files found`*:
|
If you don't run `cli-deps-static-sites`, you may see an error *`pattern ...: no matching files found`*:
|
||||||
```
|
```
|
||||||
$ go run main.go serve
|
$ go run main.go serve
|
||||||
server/server.go:85:13: pattern docs: no matching files found
|
server/server.go:85:13: pattern docs: no matching files found
|
||||||
```
|
```
|
||||||
|
|
||||||
This is because we use `go:embed` to embed the documentation and web app, so the Go code expects files to be
|
This is because we use `go:embed` to embed the documentation and web app, so the Go code expects files to be
|
||||||
present at `server/docs` and `server/site`. If they are not, you'll see the above error. The `server-deps-static-sites`
|
present at `server/docs` and `server/site`. If they are not, you'll see the above error. The `cli-deps-static-sites`
|
||||||
target creates dummy files that ensures that you'll be able to build.
|
target creates dummy files that ensure that you'll be able to build.
|
||||||
|
|
||||||
|
While not officially supported (or released), you can build and run the server **on macOS** as well. Simply run
|
||||||
|
`make cli-darwin-server` to build a binary, or `go run main.go serve` (see above) to run it.
|
||||||
|
|
||||||
### Build the web app
|
### Build the web app
|
||||||
The sources for the web app live in `web/`. As long as you have `npm` installed (see above), building the web app
|
The sources for the web app live in `web/`. As long as you have `npm` installed (see above), building the web app
|
||||||
@@ -210,7 +222,7 @@ $ make web
|
|||||||
```
|
```
|
||||||
|
|
||||||
This will build the web app using Create React App and then **copy the production build to the `server/site` folder**, so
|
This will build the web app using Create React App and then **copy the production build to the `server/site` folder**, so
|
||||||
that when you `make server` (or `make server-amd64`, ...), you will have the web app included in the `ntfy` binary.
|
that when you `make cli` (or `make cli-linux-amd64`, ...), you will have the web app included in the `ntfy` binary.
|
||||||
|
|
||||||
If you're developing on the web app, it's best to just `cd web` and run `npm start` manually. This will open your browser
|
If you're developing on the web app, it's best to just `cd web` and run `npm start` manually. This will open your browser
|
||||||
at `http://127.0.0.1:3000` with the web app, and as you edit the source files, they will be recompiled and the browser
|
at `http://127.0.0.1:3000` with the web app, and as you edit the source files, they will be recompiled and the browser
|
||||||
@@ -282,9 +294,13 @@ Then either follow the steps for building with or without Firebase.
|
|||||||
I do build the ntfy Android app using IntelliJ IDEA (Android Studio), so I don't know if these Gradle commands will
|
I do build the ntfy Android app using IntelliJ IDEA (Android Studio), so I don't know if these Gradle commands will
|
||||||
work without issues. Please give me feedback if it does/doesn't work for you.
|
work without issues. Please give me feedback if it does/doesn't work for you.
|
||||||
|
|
||||||
Without Firebase, you may want to still change the default `app_base_url` in [strings.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/strings.xml)
|
Without Firebase, you may want to still change the default `app_base_url` in [values.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/values.xml)
|
||||||
if you're self-hosting the server. Then run:
|
if you're self-hosting the server. Then run:
|
||||||
```
|
```
|
||||||
|
# Remove Google dependencies (FCM)
|
||||||
|
sed -i -e '/google-services/d' build.gradle
|
||||||
|
sed -i -e '/google-services/d' app/build.gradle
|
||||||
|
|
||||||
# To build an unsigned .apk (app/build/outputs/apk/fdroid/*.apk)
|
# To build an unsigned .apk (app/build/outputs/apk/fdroid/*.apk)
|
||||||
./gradlew assembleFdroidRelease
|
./gradlew assembleFdroidRelease
|
||||||
|
|
||||||
@@ -301,7 +317,7 @@ To build your own version with Firebase, you must:
|
|||||||
|
|
||||||
* Create a Firebase/FCM account
|
* Create a Firebase/FCM account
|
||||||
* Place your account file at `app/google-services.json`
|
* Place your account file at `app/google-services.json`
|
||||||
* And change `app_base_url` in [strings.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/strings.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/*.apk)
|
||||||
@@ -310,3 +326,9 @@ To build your own version with Firebase, you must:
|
|||||||
# To build a bundle .aab (app/play/release/*.aab)
|
# To build a bundle .aab (app/play/release/*.aab)
|
||||||
./gradlew bundlePlayRelease
|
./gradlew bundlePlayRelease
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## iOS app
|
||||||
|
The ntfy iOS app source code is available [on GitHub](https://github.com/binwiederhier/ntfy-ios).
|
||||||
|
|
||||||
|
!!! info
|
||||||
|
I haven't had time to move the build instructions here. Please check out the repository instead.
|
||||||
|
|||||||
258
docs/examples.md
@@ -4,7 +4,14 @@ There are a million ways to use ntfy, but here are some inspirations. I try to c
|
|||||||
<a href="https://github.com/binwiederhier/ntfy/tree/main/examples">examples on GitHub</a>, so be sure to check
|
<a href="https://github.com/binwiederhier/ntfy/tree/main/examples">examples on GitHub</a>, so be sure to check
|
||||||
those out, too.
|
those out, too.
|
||||||
|
|
||||||
## A long process is done: backups, copying data, pipelines, ...
|
!!! info
|
||||||
|
Many of these examples were contributed by ntfy users. If you have other examples of how you use ntfy, please
|
||||||
|
[create a pull request](https://github.com/binwiederhier/ntfy/pulls), and I'll happily include it. Also note, that
|
||||||
|
I cannot guarantee that all of these examples are functional. Many of them I have not tried myself.
|
||||||
|
|
||||||
|
## Cronjobs
|
||||||
|
ntfy is perfect for any kind of cronjobs or just when long processes are done (backups, pipelines, rsync copy commands, ...).
|
||||||
|
|
||||||
I started adding notifications pretty much all of my scripts. Typically, I just chain the <tt>curl</tt> call
|
I started adding notifications pretty much all of my scripts. Typically, I just chain the <tt>curl</tt> call
|
||||||
directly to the command I'm running. The following example will either send <i>Laptop backup succeeded</i>
|
directly to the command I'm running. The following example will either send <i>Laptop backup succeeded</i>
|
||||||
or ⚠️ <i>Laptop backup failed</i> directly to my phone:
|
or ⚠️ <i>Laptop backup failed</i> directly to my phone:
|
||||||
@@ -16,6 +23,15 @@ rsync -a root@laptop /backups/laptop \
|
|||||||
|| curl -H tags:warning -H prio:high -d "Laptop backup failed" ntfy.sh/backups
|
|| curl -H tags:warning -H prio:high -d "Laptop backup failed" ntfy.sh/backups
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Here's one for the history books. I desperately want the `github.com/ntfy` organization, but all my tickets with
|
||||||
|
GitHub have been hopeless. In case it ever becomes available, I want to know immediately.
|
||||||
|
|
||||||
|
``` cron
|
||||||
|
# Check github/ntfy user
|
||||||
|
*/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
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
## 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
|
||||||
effective.
|
effective.
|
||||||
@@ -37,11 +53,7 @@ if [ -n "$avail" ]; then
|
|||||||
fi
|
fi
|
||||||
```
|
```
|
||||||
|
|
||||||
## Server-sent messages in your web app
|
## SSH login alerts
|
||||||
Just as you can [subscribe to topics in the Web UI](subscribe/web.md), you can use ntfy in your own
|
|
||||||
web application. Check out the <a href="/example.html">live example</a> or just look the source of this page.
|
|
||||||
|
|
||||||
## Notify on SSH login
|
|
||||||
Years ago my home server was broken into. That shook me hard, so every time someone logs into any machine that I
|
Years ago my home server was broken into. That shook me hard, so every time someone logs into any machine that I
|
||||||
own, I now message myself. Here's an example of how to use <a href="https://en.wikipedia.org/wiki/Linux_PAM">PAM</a>
|
own, I now message myself. Here's an example of how to use <a href="https://en.wikipedia.org/wiki/Linux_PAM">PAM</a>
|
||||||
to notify yourself on SSH login.
|
to notify yourself on SSH login.
|
||||||
@@ -89,7 +101,7 @@ It looked something like this:
|
|||||||
You can easily integrate ntfy into Ansible, Salt, or Puppet to notify you when runs are done or are highstated.
|
You can easily integrate ntfy into Ansible, Salt, or Puppet to notify you when runs are done or are highstated.
|
||||||
One of my co-workers uses the following Ansible task to let him know when things are done:
|
One of my co-workers uses the following Ansible task to let him know when things are done:
|
||||||
|
|
||||||
```yml
|
``` yaml
|
||||||
- name: Send ntfy.sh update
|
- name: Send ntfy.sh update
|
||||||
uri:
|
uri:
|
||||||
url: "https://ntfy.sh/{{ ntfy_channel }}"
|
url: "https://ntfy.sh/{{ ntfy_channel }}"
|
||||||
@@ -97,11 +109,38 @@ One of my co-workers uses the following Ansible task to let him know when things
|
|||||||
body: "{{ inventory_hostname }} reseeding complete"
|
body: "{{ inventory_hostname }} reseeding complete"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Watchtower notifications (shoutrrr)
|
There's also a dedicated Ansible action plugin (one which runs on the Ansible controller) called
|
||||||
You can use `shoutrrr` generic webhook support to send watchtower notifications to your ntfy topic.
|
[ansible-ntfy](https://github.com/jpmens/ansible-ntfy). The following task posts a message
|
||||||
|
to ntfy at its default URL (`attrs` and other attributes are optional):
|
||||||
|
|
||||||
|
``` yaml
|
||||||
|
- name: "Notify ntfy that we're done"
|
||||||
|
ntfy:
|
||||||
|
msg: "deployment on {{ inventory_hostname }} is complete. 🐄"
|
||||||
|
attrs:
|
||||||
|
tags: [ heavy_check_mark ]
|
||||||
|
priority: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
## GitHub Actions
|
||||||
|
You can send a message during a workflow run with curl. Here is an example sending info about the repo, commit and job status.
|
||||||
|
``` yaml
|
||||||
|
- name: Actions Ntfy
|
||||||
|
run: |
|
||||||
|
curl \
|
||||||
|
-u ${{ secrets.NTFY_CRED }} \
|
||||||
|
-H "Title: Title here" \
|
||||||
|
-H "Content-Type: text/plain" \
|
||||||
|
-d $'Repo: ${{ github.repository }}\nCommit: ${{ github.sha }}\nRef: ${{ github.ref }}\nStatus: ${{ job.status}}' \
|
||||||
|
${{ secrets.NTFY_URL }}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Watchtower (shoutrrr)
|
||||||
|
You can use [shoutrrr](https://github.com/containrrr/shoutrrr) generic webhook support to send
|
||||||
|
[Watchtower](https://github.com/containrrr/watchtower/) notifications to your ntfy topic.
|
||||||
|
|
||||||
Example docker-compose.yml:
|
Example docker-compose.yml:
|
||||||
```yml
|
``` yaml
|
||||||
services:
|
services:
|
||||||
watchtower:
|
watchtower:
|
||||||
image: containrrr/watchtower
|
image: containrrr/watchtower
|
||||||
@@ -115,28 +154,17 @@ Or, if you only want to send notifications using shoutrrr:
|
|||||||
shoutrrr send -u "generic+https://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage"
|
shoutrrr send -u "generic+https://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Random cronjobs
|
## Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd
|
||||||
Alright, here's one for the history books. I desperately want the `github.com/ntfy` organization, but all my tickets with
|
|
||||||
GitHub have been hopeless. In case it ever becomes available, I want to know immediately.
|
|
||||||
|
|
||||||
``` cron
|
|
||||||
# Check github/ntfy user
|
|
||||||
*/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
|
|
||||||
~
|
|
||||||
```
|
|
||||||
|
|
||||||
## Download notifications (Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd)
|
|
||||||
It's possible to use custom scripts for all the *arr services, plus SABnzbd. Notifications for downloads, warnings, grabs etc.
|
It's possible to use custom scripts for all the *arr services, plus SABnzbd. Notifications for downloads, warnings, grabs etc.
|
||||||
Some simple bash scripts to achieve this are kindly provided in [nickexyz's repository](https://github.com/nickexyz/ntfy-shellscripts).
|
Some simple bash scripts to achieve this are kindly provided in [nickexyz's repository](https://github.com/nickexyz/ntfy-shellscripts).
|
||||||
|
|
||||||
## Node-RED
|
## Node-RED
|
||||||
You can use the HTTP request node to send messages with [Node-RED](https://nodered.org), some examples:
|
You can use the HTTP request node to send messages with [Node-RED](https://nodered.org), some examples:
|
||||||
|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Example: Send a message (click to expand)</summary>
|
<summary>Example: Send a message (click to expand)</summary>
|
||||||
|
|
||||||
```
|
``` json
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": "c956e688cc74ad8e",
|
"id": "c956e688cc74ad8e",
|
||||||
@@ -225,7 +253,7 @@ You can use the HTTP request node to send messages with [Node-RED](https://noder
|
|||||||
<details>
|
<details>
|
||||||
<summary>Example: Send a picture (click to expand)</summary>
|
<summary>Example: Send a picture (click to expand)</summary>
|
||||||
|
|
||||||
```
|
``` json
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": "d135a13eadeb9d6d",
|
"id": "d135a13eadeb9d6d",
|
||||||
@@ -339,10 +367,23 @@ You can use the HTTP request node to send messages with [Node-RED](https://noder
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Gatus service health check
|
## Gatus
|
||||||
|
To use ntfy with [Gatus](https://github.com/TwiN/gatus), you can use the `ntfy` alerting provider like so:
|
||||||
|
|
||||||
An example for a custom alert with <a href="https://github.com/TwiN/gatus">Gatus</a>
|
```yaml
|
||||||
|
alerting:
|
||||||
|
ntfy:
|
||||||
|
url: "https://ntfy.sh"
|
||||||
|
topic: "YOUR_NTFY_TOPIC"
|
||||||
|
priority: 3
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For more information on using ntfy with Gatus, refer to [Configuring ntfy alerts](https://github.com/TwiN/gatus#configuring-ntfy-alerts).
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Alternative: Using the custom alerting provider</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
alerting:
|
alerting:
|
||||||
custom:
|
custom:
|
||||||
url: "https://ntfy.sh"
|
url: "https://ntfy.sh"
|
||||||
@@ -366,3 +407,168 @@ alerting:
|
|||||||
TRIGGERED: "warning"
|
TRIGGERED: "warning"
|
||||||
RESOLVED: "white_check_mark"
|
RESOLVED: "white_check_mark"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
|
||||||
|
## Jellyseerr/Overseerr webhook
|
||||||
|
Here is an example for [jellyseerr](https://github.com/Fallenbagel/jellyseerr)/[overseerr](https://overseerr.dev/) webhook
|
||||||
|
JSON payload. Remember to change the `https://requests.example.com` to your jellyseerr/overseerr URL.
|
||||||
|
|
||||||
|
``` json
|
||||||
|
{
|
||||||
|
"topic": "requests",
|
||||||
|
"title": "{{event}}",
|
||||||
|
"message": "{{subject}}\n{{message}}\n\nRequested by: {{requestedBy_username}}\n\nStatus: {{media_status}}\nRequest Id: {{request_id}}",
|
||||||
|
"priority": 4,
|
||||||
|
"attach": "{{image}}",
|
||||||
|
"click": "https://requests.example.com/{{media_type}}/{{media_tmdbid}}"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Home Assistant
|
||||||
|
Here is an example for the configuration.yml file to setup a REST notify component.
|
||||||
|
Since Home Assistant is going to POST JSON, you need to specify the root of your ntfy resource.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
notify:
|
||||||
|
- name: ntfy
|
||||||
|
platform: rest
|
||||||
|
method: POST_JSON
|
||||||
|
data:
|
||||||
|
topic: YOUR_NTFY_TOPIC
|
||||||
|
title_param_name: title
|
||||||
|
message_param_name: message
|
||||||
|
resource: https://ntfy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
If you need to authenticate to your ntfy resource, define the authentication, username and password as below:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
notify:
|
||||||
|
- name: ntfy
|
||||||
|
platform: rest
|
||||||
|
method: POST_JSON
|
||||||
|
authentication: basic
|
||||||
|
username: YOUR_USERNAME
|
||||||
|
password: YOUR_PASSWORD
|
||||||
|
data:
|
||||||
|
topic: YOUR_NTFY_TOPIC
|
||||||
|
title_param_name: title
|
||||||
|
message_param_name: message
|
||||||
|
resource: https://ntfy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
If you need to add any other [ntfy specific parameters](https://ntfy.sh/docs/publish/#publish-as-json) such as priority, tags, etc., add them to the `data` array in the example yml. For example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
notify:
|
||||||
|
- name: ntfy
|
||||||
|
platform: rest
|
||||||
|
method: POST_JSON
|
||||||
|
data:
|
||||||
|
topic: YOUR_NTFY_TOPIC
|
||||||
|
priority: 4
|
||||||
|
title_param_name: title
|
||||||
|
message_param_name: message
|
||||||
|
resource: https://ntfy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uptime Kuma
|
||||||
|
Go to your [Uptime Kuma](https://github.com/louislam/uptime-kuma) Settings > Notifications, click on **Setup Notification**.
|
||||||
|
Then set your desired **title** (e.g. "Uptime Kuma"), **ntfy topic**, **Server URL** and **priority (1-5)**:
|
||||||
|
|
||||||
|
<div id="uptimekuma-screenshots" class="screenshots">
|
||||||
|
<a href="../static/img/uptimekuma-settings.png"><img src="../static/img/uptimekuma-settings.png"/></a>
|
||||||
|
<a href="../static/img/uptimekuma-setup.png"><img src="../static/img/uptimekuma-setup.png"/></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
You can now test the notifications and apply them to monitors:
|
||||||
|
|
||||||
|
<div id="uptimekuma-monitor-screenshots" class="screenshots">
|
||||||
|
<a href="../static/img/uptimekuma-ios-test.jpg"><img src="../static/img/uptimekuma-ios-test.jpg"/></a>
|
||||||
|
<a href="../static/img/uptimekuma-ios-down.jpg"><img src="../static/img/uptimekuma-ios-down.jpg"/></a>
|
||||||
|
<a href="../static/img/uptimekuma-ios-up.jpg"><img src="../static/img/uptimekuma-ios-up.jpg"/></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## UptimeRobot
|
||||||
|
Go to your [UptimeRobot](https://github.com/uptimerobot) My Settings > Alert Contacts > Add Alert Contact
|
||||||
|
Select **Alert Contact Type** = Webhook. Then set your desired **Friendly Name** (e.g. "ntfy-sh-UP"), **URL to Notify**, **POST value** and select checkbox **Send as JSON (application/json)**. Make sure to send the JSON POST request to ntfy.domain.com without the topic name in the url and include the "topic" name in the JSON body.
|
||||||
|
|
||||||
|
<div id="uptimerobot-monitor-setup" class="screenshots">
|
||||||
|
<a href="../static/img/uptimerobot-setup.jpg"><img src="../static/img/uptimerobot-setup.jpg"/></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
``` json
|
||||||
|
{
|
||||||
|
"topic":"myTopic",
|
||||||
|
"title": "*monitorFriendlyName* *alertTypeFriendlyName*",
|
||||||
|
"message": "*alertDetails*",
|
||||||
|
"tags": ["green_circle"],
|
||||||
|
"priority": 3,
|
||||||
|
"click": https://uptimerobot.com/dashboard#*monitorID*
|
||||||
|
}
|
||||||
|
```
|
||||||
|
You can create two Alert Contacts each with a different icon and priority, for example:
|
||||||
|
|
||||||
|
``` json
|
||||||
|
{
|
||||||
|
"topic":"myTopic",
|
||||||
|
"title": "*monitorFriendlyName* *alertTypeFriendlyName*",
|
||||||
|
"message": "*alertDetails*",
|
||||||
|
"tags": ["red_circle"],
|
||||||
|
"priority": 3,
|
||||||
|
"click": https://uptimerobot.com/dashboard#*monitorID*
|
||||||
|
}
|
||||||
|
```
|
||||||
|
You can now add the created Alerts Contact(s) to the monitor(s) and test the notifications:
|
||||||
|
|
||||||
|
<div id="uptimerobot-monitor-screenshots" class="screenshots">
|
||||||
|
<a href="../static/img/uptimerobot-test.jpg"><img src="../static/img/uptimerobot-test.jpg"/></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
## Apprise
|
||||||
|
ntfy is integrated natively into [Apprise](https://github.com/caronc/apprise) (also check out the
|
||||||
|
[Apprise/ntfy wiki page](https://github.com/caronc/apprise/wiki/Notify_ntfy)).
|
||||||
|
|
||||||
|
You can use it like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
apprise -vv -t "Test Message Title" -b "Test Message Body" \
|
||||||
|
ntfy://mytopic
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with your own server like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
apprise -vv -t "Test Message Title" -b "Test Message Body" \
|
||||||
|
ntfy://ntfy.example.com/mytopic
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Rundeck
|
||||||
|
Rundeck by default sends only HTML email which is not processed by ntfy SMTP server. Append following configurations to
|
||||||
|
[rundeck-config.properties](https://docs.rundeck.com/docs/administration/configuration/config-file-reference.html) :
|
||||||
|
|
||||||
|
```
|
||||||
|
# Template
|
||||||
|
rundeck.mail.template.file=/path/to/template.html
|
||||||
|
rundeck.mail.template.log.formatted=false
|
||||||
|
```
|
||||||
|
|
||||||
|
Example `template.html`:
|
||||||
|
```html
|
||||||
|
<div>Execution ${execution.id} was <b>${execution.status}</b></div>
|
||||||
|
<ul>
|
||||||
|
<li><a href="${execution.href}">Execution result</a></li>
|
||||||
|
<li><a href="${job.href}">Job</a></li>
|
||||||
|
<li><a href="${execution.projectHref}">Project: ${execution.project}</a></li>
|
||||||
|
<li><a href="${rundeck.href}">Rundeck</a></li>
|
||||||
|
</ul>
|
||||||
|
```
|
||||||
|
|
||||||
|
Add notification on Rundeck (attachment type must be: `Attached as file to email`):
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
|||||||
64
docs/faq.md
@@ -4,27 +4,35 @@
|
|||||||
Who knows. I didn't do a lot of research before making this. It was fun making it.
|
Who knows. I didn't do a lot of research before making this. It was fun making it.
|
||||||
|
|
||||||
## Can I use this in my app? Will it stay free?
|
## Can I use this in my app? Will it stay free?
|
||||||
Yes. As long as you don't abuse it, it'll be available and free of charge. I do not plan on monetizing
|
Yes. As long as you don't abuse it, it'll be available and free of charge. While I will always allow usage of the ntfy.sh
|
||||||
the service.
|
server without signup and free of charge, I may also offer paid plans in the future.
|
||||||
|
|
||||||
## What are the uptime guarantees?
|
## What are the uptime guarantees?
|
||||||
Best effort.
|
Best effort.
|
||||||
|
|
||||||
|
ntfy currently runs on a single DigitalOcean droplet, without any scale out strategy or redundancies. When the time comes,
|
||||||
|
I'll add scale out features, but for now it is what it is.
|
||||||
|
|
||||||
|
In the first year of its life, and to this day (Dec'22), ntfy had **no outages** that I can remember. Other than short
|
||||||
|
blips and some HTTP 500 spikes, it has been rock solid.
|
||||||
|
|
||||||
|
There is a [status page](https://ntfy.statuspage.io/) which is updated based on some automated checks via the amazingly
|
||||||
|
awesome [healthchecks.io](https://healthchecks.io/) (_no affiliation, just a fan_).
|
||||||
|
|
||||||
## What happens if there are multiple subscribers to the same topic?
|
## What happens if there are multiple subscribers to the same topic?
|
||||||
As per usual with pub-sub, all subscribers receive notifications if they are
|
As per usual with pub-sub, all subscribers receive notifications if they are subscribed to a topic.
|
||||||
subscribed to a topic.
|
|
||||||
|
|
||||||
## Will you know what topics exist, can you spy on me?
|
## Will you know what topics exist, can you spy on me?
|
||||||
If you don't trust me or your messages are sensitive, run your own server. It's <a href="https://github.com/binwiederhier/ntfy">open source</a>.
|
If you don't trust me or your messages are sensitive, run your own server. It's open source.
|
||||||
That said, the logs do not contain any topic names or other details about you.
|
That said, the logs do contain topic names and IP addresses, but I don't use them for anything other than
|
||||||
Messages are cached for the duration configured in `server.yml` (12h by default) to facilitate service restarts, message polling and to overcome
|
troubleshooting and rate limiting. Messages are cached for the duration configured in `server.yml` (12h by default)
|
||||||
client network disruptions.
|
to facilitate service restarts, message polling and to overcome client network disruptions.
|
||||||
|
|
||||||
## Can I self-host it?
|
## Can I self-host it?
|
||||||
Yes. The server (including this Web UI) can be self-hosted, and the Android app supports adding topics from
|
Yes. The server (including this Web UI) can be self-hosted, and the Android/iOS app supports adding topics from
|
||||||
your own server as well. Check out the [install instructions](install.md).
|
your own server as well. Check out the [install instructions](install.md).
|
||||||
|
|
||||||
## Why is Firebase used?
|
## Is Firebase used?
|
||||||
In addition to caching messages locally and delivering them to long-polling subscribers, all messages are also
|
In addition to caching messages locally and delivering them to long-polling subscribers, all messages are also
|
||||||
published to Firebase Cloud Messaging (FCM) (if `FirebaseKeyFile` is set, which it is on ntfy.sh). This
|
published to Firebase Cloud Messaging (FCM) (if `FirebaseKeyFile` is set, which it is on ntfy.sh). This
|
||||||
is to facilitate notifications on Android.
|
is to facilitate notifications on Android.
|
||||||
@@ -34,16 +42,36 @@ of the app and [self-host your own ntfy server](install.md).
|
|||||||
|
|
||||||
## How much battery does the Android app use?
|
## How much battery does the Android app use?
|
||||||
If you use the ntfy.sh server, and you don't use the [instant delivery](subscribe/phone.md#instant-delivery) feature,
|
If you use the ntfy.sh server, and you don't use the [instant delivery](subscribe/phone.md#instant-delivery) feature,
|
||||||
the Android app uses no additional battery, since Firebase Cloud Messaging (FCM) is used. If you use your own server,
|
the Android/iOS app uses no additional battery, since Firebase Cloud Messaging (FCM) is used. If you use your own server,
|
||||||
or you use *instant delivery*, the app has to maintain a constant connection to the server, which consumes about 0-1% of
|
or you use *instant delivery* (Android only), the app has to maintain a constant connection to the server, which consumes
|
||||||
battery in 17h of use (on my phone). There has been a ton of testing and improvement around this. I think it's pretty
|
about 0-1% of battery in 17h of use (on my phone). There has been a ton of testing and improvement around this. I think it's pretty
|
||||||
decent now.
|
decent now.
|
||||||
|
|
||||||
## What is instant delivery?
|
## What is instant delivery?
|
||||||
[Instant delivery](subscribe/phone.md#instant-delivery) is a feature in the Android app. If turned on, the app maintains a constant connection to the
|
[Instant delivery](subscribe/phone.md#instant-delivery) is a feature in the Android app. If turned on, the app maintains a constant connection to the
|
||||||
server and listens for incoming notifications. This consumes <a href="#battery-usage">additional battery</a>,
|
server and listens for incoming notifications. This consumes additional battery (see above),
|
||||||
but delivers notifications instantly.
|
but delivers notifications instantly.
|
||||||
|
|
||||||
## Why is there no iOS app (yet)?
|
## Can you implement feature X?
|
||||||
I don't have an iPhone or a Mac, so I didn't make an iOS app yet. It'd be awesome if
|
Yes, maybe. Check out [existing GitHub issues](https://github.com/binwiederhier/ntfy/issues) to see if somebody else had
|
||||||
<a href="https://github.com/binwiederhier/ntfy/issues/4">someone else could help out</a>.
|
the same idea before you, or file a new issue. I'll likely get back to you within a few days.
|
||||||
|
|
||||||
|
## I'm having issues with iOS, can you help? The iOS app is behind compared to the Android app, can you fix that?
|
||||||
|
The iOS is very bare bones and quite frankly a little buggy. I wanted to get something out the door to make the iOS users
|
||||||
|
happy, but halfway through I got frustrated with iOS development and paused development. I will eventually get back to
|
||||||
|
it, or hopefully, somebody else will come along and help out. Please review the [known issues](known-issues.md) for details.
|
||||||
|
|
||||||
|
## Can I disable the web app? Can I protect it with a login screen?
|
||||||
|
The web app is a static website without a backend (other than the ntfy API). All data is stored locally in the browser
|
||||||
|
cache and local storage. That means it does not need to be protected with a login screen, and it poses no additional
|
||||||
|
security risk. So technically, it does not need to be disabled.
|
||||||
|
|
||||||
|
However, if you still want to disable it, you can do so with the `web-root: disable` option in the `server.yml` file.
|
||||||
|
|
||||||
|
Think of the ntfy web app like an Android/iOS app. It is freely available and accessible to anyone, yet useless without
|
||||||
|
a proper backend. So as long as you secure your backend with ACLs, exposing the ntfy web app to the Internet is harmless.
|
||||||
|
|
||||||
|
## Where can I donate?
|
||||||
|
I have just very recently started accepting donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier).
|
||||||
|
I would be humbled if you helped me carry the server and developer account costs. Even small donations are very much
|
||||||
|
appreciated.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ or POST requests. I use it to notify myself when scripts fail, or long-running c
|
|||||||
## 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 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 src="../../static/img/badge-fdroid.png"></a>
|
||||||
<a href="https://github.com/binwiederhier/ntfy/issues/4"><img src="../../static/img/badge-appstore.png"></a>
|
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img 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 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
|
||||||
@@ -83,7 +83,7 @@ This will create a notification that looks like this:
|
|||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
That's it. You're all set. Go play and read the rest of the docs. I highly recommend reading at least the page on
|
That's it. You're all set. Go play and read the rest of the docs. I highly recommend reading at least the page on
|
||||||
[publishing messages](publish.md), as well as the detailed page on the [Android app](subscribe/phone.md).
|
[publishing messages](publish.md), as well as the detailed page on the [Android/iOS app](subscribe/phone.md).
|
||||||
|
|
||||||
Here's another video showing the entire process:
|
Here's another video showing the entire process:
|
||||||
|
|
||||||
|
|||||||
434
docs/install.md
@@ -13,50 +13,50 @@ The ntfy server comes as a statically linked binary and is shipped as tarball, d
|
|||||||
We support amd64, armv7 and arm64.
|
We support amd64, armv7 and arm64.
|
||||||
|
|
||||||
1. Install ntfy using one of the methods described below
|
1. Install ntfy using one of the methods described below
|
||||||
2. Then (optionally) edit `/etc/ntfy/server.yml` for the server (see [configuration](config.md) or [sample server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml))
|
2. Then (optionally) edit `/etc/ntfy/server.yml` for the server (Linux only, see [configuration](config.md) or [sample server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml))
|
||||||
3. Or (optionally) create/edit `~/.config/ntfy/client.yml` (or `/etc/ntfy/client.yml`, see [sample client.yml](https://github.com/binwiederhier/ntfy/blob/main/client/client.yml))
|
3. Or (optionally) create/edit `~/.config/ntfy/client.yml` (or `/etc/ntfy/client.yml`, see [sample client.yml](https://github.com/binwiederhier/ntfy/blob/main/client/client.yml))
|
||||||
|
|
||||||
To run the ntfy server, then just run `ntfy serve` (or `systemctl start ntfy` when using the deb/rpm).
|
To run the ntfy server, then just run `ntfy serve` (or `systemctl start ntfy` when using the deb/rpm).
|
||||||
To send messages, use `ntfy publish`. To subscribe to topics, use `ntfy subscribe` (see [subscribing via CLI][subscribe/cli.md]
|
To send messages, use `ntfy publish`. To subscribe to topics, use `ntfy subscribe` (see [subscribing via CLI](subscribe/cli.md)
|
||||||
for details).
|
for details).
|
||||||
|
|
||||||
## Binaries and packages
|
## Linux binaries
|
||||||
Please check out the [releases page](https://github.com/binwiederhier/ntfy/releases) for binaries and
|
Please check out the [releases page](https://github.com/binwiederhier/ntfy/releases) for binaries and
|
||||||
deb/rpm packages.
|
deb/rpm packages.
|
||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_x86_64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_x86_64.tar.gz
|
||||||
tar zxvf ntfy_1.21.2_linux_x86_64.tar.gz
|
tar zxvf ntfy_1.30.1_linux_x86_64.tar.gz
|
||||||
sudo cp -a ntfy_1.21.2_linux_x86_64/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_1.30.1_linux_x86_64/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.21.2_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_1.30.1_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_armv6.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv6.tar.gz
|
||||||
tar zxvf ntfy_1.21.2_linux_armv6.tar.gz
|
tar zxvf ntfy_1.30.1_linux_armv6.tar.gz
|
||||||
sudo cp -a ntfy_1.21.2_linux_armv6/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_1.30.1_linux_armv6/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.21.2_linux_armv6/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_1.30.1_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/v1.21.2/ntfy_1.21.2_linux_armv7.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv7.tar.gz
|
||||||
tar zxvf ntfy_1.21.2_linux_armv7.tar.gz
|
tar zxvf ntfy_1.30.1_linux_armv7.tar.gz
|
||||||
sudo cp -a ntfy_1.21.2_linux_armv7/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_1.30.1_linux_armv7/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.21.2_linux_armv7/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_1.30.1_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/v1.21.2/ntfy_1.21.2_linux_arm64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_arm64.tar.gz
|
||||||
tar zxvf ntfy_1.21.2_linux_arm64.tar.gz
|
tar zxvf ntfy_1.30.1_linux_arm64.tar.gz
|
||||||
sudo cp -a ntfy_1.21.2_linux_arm64/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_1.30.1_linux_arm64/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.21.2_linux_arm64/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_1.30.1_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -65,9 +65,10 @@ Installation via Debian repository:
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
curl -sSL https://archive.heckel.io/apt/pubkey.txt | sudo apt-key add -
|
sudo mkdir -p /etc/apt/keyrings
|
||||||
|
curl -fsSL https://archive.heckel.io/apt/pubkey.txt | sudo gpg --dearmor -o /etc/apt/keyrings/archive.heckel.io.gpg
|
||||||
sudo apt install apt-transport-https
|
sudo apt install apt-transport-https
|
||||||
sudo sh -c "echo 'deb [arch=amd64] https://archive.heckel.io/apt debian main' \
|
sudo sh -c "echo 'deb [arch=amd64 signed-by=/etc/apt/keyrings/archive.heckel.io.gpg] https://archive.heckel.io/apt debian main' \
|
||||||
> /etc/apt/sources.list.d/archive.heckel.io.list"
|
> /etc/apt/sources.list.d/archive.heckel.io.list"
|
||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt install ntfy
|
sudo apt install ntfy
|
||||||
@@ -77,10 +78,11 @@ Installation via Debian repository:
|
|||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
curl -sSL https://archive.heckel.io/apt/pubkey.txt | sudo apt-key add -
|
sudo mkdir -p /etc/apt/keyrings
|
||||||
|
curl -fsSL https://archive.heckel.io/apt/pubkey.txt | sudo gpg --dearmor -o /etc/apt/keyrings/archive.heckel.io.gpg
|
||||||
sudo apt install apt-transport-https
|
sudo apt install apt-transport-https
|
||||||
sudo sh -c "echo 'deb [arch=armhf] https://archive.heckel.io/apt debian main' \
|
sudo sh -c "echo 'deb [arch=armhf signed-by=/etc/apt/keyrings/archive.heckel.io.gpg] https://archive.heckel.io/apt debian main' \
|
||||||
> /etc/apt/sources.list.d/archive.heckel.io.list"
|
> /etc/apt/sources.list.d/archive.heckel.io.list"
|
||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt install ntfy
|
sudo apt install ntfy
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
@@ -89,10 +91,11 @@ Installation via Debian repository:
|
|||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
curl -sSL https://archive.heckel.io/apt/pubkey.txt | sudo apt-key add -
|
sudo mkdir -p /etc/apt/keyrings
|
||||||
|
curl -fsSL https://archive.heckel.io/apt/pubkey.txt | sudo gpg --dearmor -o /etc/apt/keyrings/archive.heckel.io.gpg
|
||||||
sudo apt install apt-transport-https
|
sudo apt install apt-transport-https
|
||||||
sudo sh -c "echo 'deb [arch=arm64] https://archive.heckel.io/apt debian main' \
|
sudo sh -c "echo 'deb [arch=arm64 signed-by=/etc/apt/keyrings/archive.heckel.io.gpg] https://archive.heckel.io/apt debian main' \
|
||||||
> /etc/apt/sources.list.d/archive.heckel.io.list"
|
> /etc/apt/sources.list.d/archive.heckel.io.list"
|
||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt install ntfy
|
sudo apt install ntfy
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
@@ -103,7 +106,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_amd64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_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
|
||||||
@@ -111,7 +114,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_armv6.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_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
|
||||||
@@ -119,7 +122,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_armv7.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_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
|
||||||
@@ -127,7 +130,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_arm64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_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
|
||||||
@@ -137,28 +140,28 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_amd64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_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/v1.21.2/ntfy_1.21.2_linux_armv6.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_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/v1.21.2/ntfy_1.21.2_linux_armv7.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_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/v1.21.2/ntfy_1.21.2_linux_arm64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_arm64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
@@ -176,6 +179,52 @@ cd ntfysh-bin
|
|||||||
makepkg -si
|
makepkg -si
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## NixOS / Nix
|
||||||
|
ntfy is packaged in nixpkgs as `ntfy-sh`. It can be installed by adding the package name to the configuration file and calling `nixos-rebuild`. Alternatively, the following command can be used to install ntfy in the current user environment:
|
||||||
|
```
|
||||||
|
nix-env -iA ntfy-sh
|
||||||
|
```
|
||||||
|
|
||||||
|
NixOS also supports [declarative setup of the ntfy server](https://search.nixos.org/options?channel=unstable&show=services.ntfy-sh.enable&from=0&size=50&sort=relevance&type=packages&query=ntfy).
|
||||||
|
|
||||||
|
## macOS
|
||||||
|
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
|
||||||
|
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_macOS_all.tar.gz),
|
||||||
|
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
|
||||||
|
|
||||||
|
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
|
||||||
|
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -L https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_macOS_all.tar.gz > ntfy_1.30.1_macOS_all.tar.gz
|
||||||
|
tar zxvf ntfy_1.30.1_macOS_all.tar.gz
|
||||||
|
sudo cp -a ntfy_1.30.1_macOS_all/ntfy /usr/local/bin/ntfy
|
||||||
|
mkdir ~/Library/Application\ Support/ntfy
|
||||||
|
cp ntfy_1.30.1_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||||
|
ntfy --help
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! info
|
||||||
|
There is a [GitHub issue](https://github.com/binwiederhier/ntfy/issues/286) about making ntfy installable via
|
||||||
|
[Homebrew](https://brew.sh/). I'll eventually get to that, but I'd also love if somebody else stepped up to do it.
|
||||||
|
Also, you can build and run the ntfy server on macOS as well, though I don't officially support that.
|
||||||
|
Check out the [build instructions](develop.md) for details.
|
||||||
|
|
||||||
|
## Windows
|
||||||
|
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
|
||||||
|
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_windows_x86_64.zip),
|
||||||
|
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
|
||||||
|
|
||||||
|
The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
|
||||||
|
|
||||||
|
Also available in [Scoop's](https://scoop.sh) Main repository:
|
||||||
|
|
||||||
|
`scoop install ntfy`
|
||||||
|
|
||||||
|
!!! info
|
||||||
|
There is currently no installer for Windows, and the binary is not signed. If this is desired, please create a
|
||||||
|
[GitHub issue](https://github.com/binwiederhier/ntfy/issues) to let me know.
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv6, armv7 and arm64. It should
|
The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv6, armv7 and arm64. It should
|
||||||
be pretty straight forward to use.
|
be pretty straight forward to use.
|
||||||
@@ -184,6 +233,11 @@ The server exposes its web UI and the API on port 80, so you need to expose that
|
|||||||
[message cache](config.md#message-cache), you also need to map a volume to `/var/cache/ntfy`. To change other settings,
|
[message cache](config.md#message-cache), you also need to map a volume to `/var/cache/ntfy`. To change other settings,
|
||||||
you should map `/etc/ntfy`, so you can edit `/etc/ntfy/server.yml`.
|
you should map `/etc/ntfy`, so you can edit `/etc/ntfy/server.yml`.
|
||||||
|
|
||||||
|
!!! info
|
||||||
|
Note that the Docker image **does not contain a `/etc/ntfy/server.yml` file**. If you'd like to use a config file,
|
||||||
|
please manually create one outside the image and map it as a volume, e.g. via `-v /etc/ntfy:/etc/ntfy`. You may
|
||||||
|
use the [`server.yml` file on GitHub](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml) as a template.
|
||||||
|
|
||||||
Basic usage (no cache or additional config):
|
Basic usage (no cache or additional config):
|
||||||
```
|
```
|
||||||
docker run -p 80:80 -it binwiederhier/ntfy serve
|
docker run -p 80:80 -it binwiederhier/ntfy serve
|
||||||
@@ -196,21 +250,23 @@ docker run \
|
|||||||
-p 80:80 \
|
-p 80:80 \
|
||||||
-it \
|
-it \
|
||||||
binwiederhier/ntfy \
|
binwiederhier/ntfy \
|
||||||
--cache-file /var/cache/ntfy/cache.db \
|
serve \
|
||||||
serve
|
--cache-file /var/cache/ntfy/cache.db
|
||||||
```
|
```
|
||||||
|
|
||||||
With other config options (configured via `/etc/ntfy/server.yml`, see [configuration](config.md) for details):
|
With other config options, timezone, and non-root user (configured via `/etc/ntfy/server.yml`, see [configuration](config.md) for details):
|
||||||
```bash
|
```bash
|
||||||
docker run \
|
docker run \
|
||||||
-v /etc/ntfy:/etc/ntfy \
|
-v /etc/ntfy:/etc/ntfy \
|
||||||
|
-e TZ=UTC \
|
||||||
-p 80:80 \
|
-p 80:80 \
|
||||||
|
-u UID:GID \
|
||||||
-it \
|
-it \
|
||||||
binwiederhier/ntfy \
|
binwiederhier/ntfy \
|
||||||
serve
|
serve
|
||||||
```
|
```
|
||||||
|
|
||||||
Using docker-compose:
|
Using docker-compose with non-root user:
|
||||||
```yaml
|
```yaml
|
||||||
version: "2.1"
|
version: "2.1"
|
||||||
|
|
||||||
@@ -220,6 +276,9 @@ services:
|
|||||||
container_name: ntfy
|
container_name: ntfy
|
||||||
command:
|
command:
|
||||||
- serve
|
- serve
|
||||||
|
environment:
|
||||||
|
- TZ=UTC # optional: set desired timezone
|
||||||
|
user: UID:GID # optional: replace with your own user/group or uid/gid
|
||||||
volumes:
|
volumes:
|
||||||
- /var/cache/ntfy:/var/cache/ntfy
|
- /var/cache/ntfy:/var/cache/ntfy
|
||||||
- /etc/ntfy:/etc/ntfy
|
- /etc/ntfy:/etc/ntfy
|
||||||
@@ -228,6 +287,8 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If using a non-root user when running the docker version, be sure to chown the server.yml, user.db, and cache.db files to the same uid/gid.
|
||||||
|
|
||||||
Alternatively, you may wish to build a customized Docker image that can be run with fewer command-line arguments and without delivering the configuration file separately.
|
Alternatively, you may wish to build a customized Docker image that can be run with fewer command-line arguments and without delivering the configuration file separately.
|
||||||
```
|
```
|
||||||
FROM binwiederhier/ntfy
|
FROM binwiederhier/ntfy
|
||||||
@@ -235,3 +296,298 @@ COPY server.yml /etc/ntfy/server.yml
|
|||||||
ENTRYPOINT ["ntfy", "serve"]
|
ENTRYPOINT ["ntfy", "serve"]
|
||||||
```
|
```
|
||||||
This image can be pushed to a container registry and shipped independently. All that's needed when running it is mapping ntfy's port to a host port.
|
This image can be pushed to a container registry and shipped independently. All that's needed when running it is mapping ntfy's port to a host port.
|
||||||
|
|
||||||
|
## Kubernetes
|
||||||
|
|
||||||
|
The setup for Kubernetes is very similar to that for Docker, and requires a fairly minimal deployment or pod definition to function. There
|
||||||
|
are a few options to mix and match, including a deployment without a cache file, a stateful set with a persistent cache, and a standalone
|
||||||
|
unmanned pod.
|
||||||
|
|
||||||
|
|
||||||
|
=== "deployment"
|
||||||
|
```yaml
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: ntfy
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: ntfy
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: ntfy
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: ntfy
|
||||||
|
image: binwiederhier/ntfy
|
||||||
|
args: ["serve"]
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: "128Mi"
|
||||||
|
cpu: "500m"
|
||||||
|
ports:
|
||||||
|
- containerPort: 80
|
||||||
|
name: http
|
||||||
|
volumeMounts:
|
||||||
|
- name: config
|
||||||
|
mountPath: "/etc/ntfy"
|
||||||
|
readOnly: true
|
||||||
|
volumes:
|
||||||
|
- name: config
|
||||||
|
configMap:
|
||||||
|
name: ntfy
|
||||||
|
---
|
||||||
|
# Basic service for port 80
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: ntfy
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: ntfy
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 80
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "stateful set"
|
||||||
|
```yaml
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
name: ntfy
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: ntfy
|
||||||
|
serviceName: ntfy
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: ntfy
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: ntfy
|
||||||
|
image: binwiederhier/ntfy
|
||||||
|
args: ["serve", "--cache-file /var/cache/ntfy/cache.db"]
|
||||||
|
ports:
|
||||||
|
- containerPort: 80
|
||||||
|
name: http
|
||||||
|
volumeMounts:
|
||||||
|
- name: config
|
||||||
|
mountPath: "/etc/ntfy"
|
||||||
|
readOnly: true
|
||||||
|
volumes:
|
||||||
|
- name: config
|
||||||
|
configMap:
|
||||||
|
name: ntfy
|
||||||
|
volumeClaimTemplates:
|
||||||
|
- metadata:
|
||||||
|
name: cache
|
||||||
|
spec:
|
||||||
|
accessModes: [ "ReadWriteOnce" ]
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 1Gi
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "pod"
|
||||||
|
```yaml
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Pod
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: ntfy
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: ntfy
|
||||||
|
image: binwiederhier/ntfy
|
||||||
|
args: ["serve"]
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: "128Mi"
|
||||||
|
cpu: "500m"
|
||||||
|
ports:
|
||||||
|
- containerPort: 80
|
||||||
|
name: http
|
||||||
|
volumeMounts:
|
||||||
|
- name: config
|
||||||
|
mountPath: "/etc/ntfy"
|
||||||
|
readOnly: true
|
||||||
|
volumes:
|
||||||
|
- name: config
|
||||||
|
configMap:
|
||||||
|
name: ntfy
|
||||||
|
```
|
||||||
|
|
||||||
|
Configuration is relatively straightforward. As an example, a minimal configuration is provided.
|
||||||
|
|
||||||
|
=== "resource definition"
|
||||||
|
```yaml
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: ntfy
|
||||||
|
data:
|
||||||
|
server.yml: |
|
||||||
|
# Template: https://github.com/binwiederhier/ntfy/blob/main/server/server.yml
|
||||||
|
base-url: https://ntfy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "from-file"
|
||||||
|
```bash
|
||||||
|
kubectl create configmap ntfy --from-file=server.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Kustomize
|
||||||
|
|
||||||
|
ntfy can be deployed in a Kubernetes cluster with [Kustomize](https://github.com/kubernetes-sigs/kustomize), a tool used
|
||||||
|
to customize Kubernetes objects using a `kustomization.yaml` file.
|
||||||
|
|
||||||
|
1. Create new folder - `ntfy`
|
||||||
|
2. Add all files listed below
|
||||||
|
1. `kustomization.yaml` - stores all configmaps and resources used in a deployment
|
||||||
|
2. `ntfy-deployment.yaml` - define deployment type and its parameters
|
||||||
|
3. `ntfy-pvc.yaml` - describes how [persistent volumes](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) will be created
|
||||||
|
4. `ntfy-svc.yaml` - expose application to the internal kubernetes network
|
||||||
|
5. `ntfy-ingress.yaml` - expose service to outside the network using [ingress controller](https://kubernetes.io/docs/concepts/services-networking/ingress-controllers/)
|
||||||
|
6. `server.yaml` - simple server configuration
|
||||||
|
3. Replace **TESTNAMESPACE** within `kustomization.yaml` with designated namespace
|
||||||
|
4. Replace **ntfy.test** within `ntfy-ingress.yaml` with desired DNS name
|
||||||
|
5. Apply configuration to cluster set in current context:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl apply -k /ntfy
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "kustomization.yaml"
|
||||||
|
```yaml
|
||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
resources:
|
||||||
|
- ntfy-deployment.yaml # deployment definition
|
||||||
|
- ntfy-svc.yaml # service connecting pods to cluster network
|
||||||
|
- ntfy-pvc.yaml # pvc used to store cache and attachment
|
||||||
|
- ntfy-ingress.yaml # ingress definition
|
||||||
|
configMapGenerator: # will parse config from raw config to configmap,it allows for dynamic reload of application if additional app is deployed ie https://github.com/stakater/Reloader
|
||||||
|
- name: server-config
|
||||||
|
files:
|
||||||
|
- server.yml
|
||||||
|
namespace: TESTNAMESPACE # select namespace for whole application
|
||||||
|
```
|
||||||
|
=== "ntfy-deployment.yaml"
|
||||||
|
```yaml
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: ntfy-deployment
|
||||||
|
labels:
|
||||||
|
app: ntfy-deployment
|
||||||
|
spec:
|
||||||
|
revisionHistoryLimit: 1
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: ntfy-pod
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: ntfy-pod
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: ntfy
|
||||||
|
image: binwiederhier/ntfy:v1.28.0 # set deployed version
|
||||||
|
args: ["serve"]
|
||||||
|
env: #example of adjustments made in environmental variables
|
||||||
|
- name: TZ # set timezone
|
||||||
|
value: XXXXXXX
|
||||||
|
- name: NTFY_DEBUG # enable/disable debug
|
||||||
|
value: "false"
|
||||||
|
- name: NTFY_LOG_LEVEL # adjust log level
|
||||||
|
value: INFO
|
||||||
|
- name: NTFY_BASE_URL # add base url
|
||||||
|
value: XXXXXXXXXX
|
||||||
|
ports:
|
||||||
|
- containerPort: 80
|
||||||
|
name: http-ntfy
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 300Mi
|
||||||
|
cpu: 200m
|
||||||
|
requests:
|
||||||
|
cpu: 150m
|
||||||
|
memory: 150Mi
|
||||||
|
volumeMounts:
|
||||||
|
- mountPath: /etc/ntfy/server.yml
|
||||||
|
subPath: server.yml
|
||||||
|
name: config-volume # generated vie configMapGenerator from kustomization file
|
||||||
|
- mountPath: /var/cache/ntfy
|
||||||
|
name: cache-volume #cache volume mounted to persistent volume
|
||||||
|
volumes:
|
||||||
|
- name: config-volume
|
||||||
|
configMap: # uses configmap generator to parse server.yml to configmap
|
||||||
|
name: server-config
|
||||||
|
- name: cache-volume
|
||||||
|
persistentVolumeClaim: # stores /cache/ntfy in defined pv
|
||||||
|
claimName: ntfy-pvc
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "ntfy-pvc.yaml"
|
||||||
|
```yaml
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: ntfy-pvc
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
storageClassName: local-path # adjust storage if needed
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 1Gi
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "ntfy-svc.yaml"
|
||||||
|
```yaml
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: ntfy-svc
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
selector:
|
||||||
|
app: ntfy-pod
|
||||||
|
ports:
|
||||||
|
- name: http-ntfy-out
|
||||||
|
protocol: TCP
|
||||||
|
port: 80
|
||||||
|
targetPort: http-ntfy
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "ntfy-ingress.yaml"
|
||||||
|
```yaml
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: ntfy-ingress
|
||||||
|
spec:
|
||||||
|
rules:
|
||||||
|
- host: ntfy.test #select own
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: ntfy-svc
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "server.yml"
|
||||||
|
```yaml
|
||||||
|
cache-file: "/var/cache/ntfy/cache.db"
|
||||||
|
attachment-cache-dir: "/var/cache/ntfy/attachments"
|
||||||
|
```
|
||||||
|
|||||||
146
docs/integrations.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# Integrations + community projects
|
||||||
|
|
||||||
|
There are quite a few projects that work with ntfy, integrate ntfy, or have been built around ntfy. It's super exciting to see what you guys have come up with. Feel free to [create a pull request on GitHub](https://github.com/binwiederhier/ntfy/issues) to add your own project here.
|
||||||
|
|
||||||
|
I've added a ⭐ to projects or posts that have a significant following, or had a lot of interaction by the community.
|
||||||
|
|
||||||
|
## Public ntfy servers
|
||||||
|
|
||||||
|
Here's a list of public ntfy servers. As of right now, there is only one official server. The others are provided by the
|
||||||
|
ntfy community. Thanks to everyone running a public server. **You guys rock!**
|
||||||
|
|
||||||
|
| URL | Country |
|
||||||
|
|---------------------------------------------------|--------------------|
|
||||||
|
| [ntfy.sh](https://ntfy.sh/) (*Official*) | 🇺🇸 United States |
|
||||||
|
| [ntfy.tedomum.net](https://ntfy.tedomum.net/) | 🇫🇷 France |
|
||||||
|
| [ntfy.jae.fi](https://ntfy.jae.fi/) | 🇫🇮 Finland |
|
||||||
|
| [ntfy.adminforge.de](https://ntfy.adminforge.de/) | 🇩🇪 Germany |
|
||||||
|
| [ntfy.envs.net](https://ntfy.envs.net) | 🇩🇪 Germany |
|
||||||
|
|
||||||
|
Please be aware that **server operators can log your messages**. The project also cannot guarantee the reliability
|
||||||
|
and uptime of third party servers, so use of each server is **at your own discretion**.
|
||||||
|
|
||||||
|
## Official integrations
|
||||||
|
|
||||||
|
- [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
|
||||||
|
- [Uptime Kuma](https://uptime.kuma.pet/) ⭐ - A self-hosted monitoring tool
|
||||||
|
- [Robusta](https://docs.robusta.dev/master/catalog/sinks/webhook.html) ⭐ - open source platform for Kubernetes troubleshooting
|
||||||
|
- [borgmatic](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#third-party-monitoring-services) ⭐ - configuration-driven backup software for servers and workstations
|
||||||
|
- [Radarr](https://radarr.video/) ⭐ - Movie collection manager for Usenet and BitTorrent users
|
||||||
|
- [Sonarr](https://sonarr.tv/) ⭐ - PVR for Usenet and BitTorrent users
|
||||||
|
- [Gatus](https://gatus.io/) ⭐ - Automated service health dashboard
|
||||||
|
- [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
|
||||||
|
- [Platypush](https://docs.platypush.tech/platypush/plugins/ntfy.html) - Automation platform aimed to run on any device that can run Python
|
||||||
|
|
||||||
|
## [UnifiedPush](https://unifiedpush.org/users/apps/) integrations
|
||||||
|
|
||||||
|
- [Element](https://f-droid.org/packages/im.vector.app/) ⭐ - Matrix client
|
||||||
|
- [SchildiChat](https://schildi.chat/android/) ⭐ - Matrix client
|
||||||
|
- [Tusky](https://tusky.app/) ⭐ - Fediverse client
|
||||||
|
- [Fedilab](https://fedilab.app/) - Fediverse client
|
||||||
|
- [FindMyDevice](https://gitlab.com/Nulide/findmydevice/) - Find your Device with an SMS or online with the help of FMDServer
|
||||||
|
- [Tox Push Message App](https://github.com/zoff99/tox_push_msg_app) - Tox Push Message App
|
||||||
|
|
||||||
|
## Libraries
|
||||||
|
|
||||||
|
- [ntfy-php-library](https://github.com/VerifiedJoseph/ntfy-php-library) - PHP library for sending messages using a ntfy server (PHP)
|
||||||
|
- [ntfy-notifier](https://github.com/DAcodedBEAT/ntfy-notifier) - Symfony Notifier integration for ntfy (PHP)
|
||||||
|
- [ntfpy](https://github.com/Nevalicjus/ntfpy) - API Wrapper for ntfy.sh (Python)
|
||||||
|
- [pyntfy](https://github.com/DP44/pyntfy) - A module for interacting with ntfy notifications (Python)
|
||||||
|
- [vntfy](https://github.com/lmangani/vntfy) - Barebone V client for ntfy (V)
|
||||||
|
- [ntfy-middleman](https://github.com/nachotp/ntfy-middleman) - Wraps APIs and send notifications using ntfy.sh on schedule (Python)
|
||||||
|
- [ntfy-dotnet](https://github.com/nwithan8/ntfy-dotnet) - .NET client library to interact with a ntfy server (C# / .NET)
|
||||||
|
- [node-ntfy-publish](https://github.com/cityssm/node-ntfy-publish) - A Node package to publish notifications to an ntfy server (Node)
|
||||||
|
- [ntfy](https://github.com/jonocarroll/ntfy) - Wraps the ntfy API with pipe-friendly tooling (R)
|
||||||
|
|
||||||
|
## CLIs + GUIs
|
||||||
|
|
||||||
|
- [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 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
|
||||||
|
- [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
|
||||||
|
- [ntfy.py](https://github.com/ioqy/ntfy-client-python) - ntfy.py is a simple nfty.sh client for sending notifications
|
||||||
|
|
||||||
|
## Projects + scripts
|
||||||
|
|
||||||
|
- [Grafana-to-ntfy](https://github.com/kittyandrew/grafana-to-ntfy) - Grafana-to-ntfy alerts channel (Rust)
|
||||||
|
- [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)
|
||||||
|
- [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)
|
||||||
|
- [backup-projects](https://gist.github.com/anthonyaxenov/826ba65abbabd5b00196bc3e6af76002) - Stupidly simple backup script for own projects (Shell)
|
||||||
|
- [grav-plugin-whistleblower](https://github.com/Himmlisch-Studios/grav-plugin-whistleblower) - Grav CMS plugin to get notifications via ntfy (PHP)
|
||||||
|
- [ntfy-server-status](https://github.com/filip2cz/ntfy-server-status) - Checking if server is online and reporting through ntfy (C)
|
||||||
|
- [borg-based backup](https://github.com/davidhi7/backup) - Simple borg-based backup script with notifications based on ntfy.sh or Discord webhooks (Python/Shell)
|
||||||
|
- [ntfy.sh *arr script](https://github.com/agent-squirrel/nfty-arr-script) - Quick and hacky script to get sonarr/radarr to notify the ntfy.sh service (Shell)
|
||||||
|
- [siteeagle](https://github.com/tpanum/siteeagle) - A small Python script to monitor websites and notify changes (Python)
|
||||||
|
- [send_to_phone](https://github.com/whipped-cream/send_to_phone) - Scripts to upload a file to Transfer.sh and ping ntfy with the download link (Python)
|
||||||
|
- [ntfy Discord bot](https://github.com/R0dn3yS/ntfy-bot) - WIP ntfy discord bot (TypeScript)
|
||||||
|
- [ntfy Discord bot](https://github.com/binwiederhier/ntfy-bot) - ntfy Discord bot (Go)
|
||||||
|
- [Bettarr Notifications](https://github.com/NiNiyas/Bettarr-Notifications) - Better Notifications for Sonarr and Radarr (Python)
|
||||||
|
- [Notify me the intruders](https://github.com/nothingbutlucas/notify_me_the_intruders) - Notify you if they are intruders or new connections on your network (Shell)
|
||||||
|
- [Send GitHub Action to ntfy](https://github.com/NiNiyas/ntfy-action) - Send GitHub Action workflow notifications to ntfy (JS)
|
||||||
|
- [ntfy alertmanager bridge](https://github.com/aTable/ntfy_alertmanager_bridge) - Basic alertmanager bridge to ntfy (JS)
|
||||||
|
- [ntfy-alertmanager](https://hub.xenrox.net/~xenrox/ntfy-alertmanager) - A bridge between ntfy and Alertmanager (Go)
|
||||||
|
- [alertmanager-ntfy](https://github.com/pinpox/alertmanager-ntfy) - Relay prometheus alertmanager alerts to ntfy (Go)
|
||||||
|
- [restreamchat2ntfy](https://github.com/kurohuku7/restreamchat2ntfy) - Send restream.io chat to ntfy to check on the Meta Quest (JS)
|
||||||
|
- [k8s-ntfy-deployment-service](https://github.com/Christian42/k8s-ntfy-deployment-service) - Automatic Kubernetes (k8s) ntfy deployment
|
||||||
|
- [huginn-global-entry-notif](https://github.com/kylezoa/huginn-global-entry-notif) - Checks CBP API for available appointments with Huginn (JSON)
|
||||||
|
- [ntfyer](https://github.com/KikyTokamuro/ntfyer) - Sending various information to your ntfy topic by time (TypeScript)
|
||||||
|
- [git-simple-notifier](https://github.com/plamenjm/git-simple-notifier) - Script running git-log, checking for new repositories (Shell)
|
||||||
|
- [ntfy-to-slack](https://github.com/ozskywalker/ntfy-to-slack) - Tool to subscribe to a ntfy topic and send the messages to a Slack webhook (Go)
|
||||||
|
- [ansible-ntfy](https://github.com/jpmens/ansible-ntfy) - Ansible action plugin to post JSON messages to ntfy (Python)
|
||||||
|
- [ntfy-notification-channel](https://github.com/wijourdil/ntfy-notification-channel) - Laravel Notification channel for ntfy (PHP)
|
||||||
|
- [ntfy_on_a_chip](https://github.com/gergepalfi/ntfy_on_a_chip) - ESP8266 and ESP32 client code to communicate with ntfy
|
||||||
|
- [ntfy-sdk](https://github.com/yukibtc/ntfy-sdk) - ntfy client library to send notifications (Rust)
|
||||||
|
- [ntfy_ynh](https://github.com/YunoHost-Apps/ntfy_ynh) - ntfy app for YunoHost
|
||||||
|
- [drone-ntfy](https://github.com/Clortox/drone-ntfy) - Drone.io plugin for sending ntfy notifications from a pipeline
|
||||||
|
|
||||||
|
## Blog + forum posts
|
||||||
|
|
||||||
|
- [ntfy setup instructions](https://docs.benjamin-altpeter.de/network/vms/1001029-ntfy/) - benjamin-altpeter.de - 12/2022
|
||||||
|
- [Ntfy Self-Hosted Push Notifications](https://lachlanlife.net/posts/2022-12-ntfy/) - lachlanlife.net - 12/2022
|
||||||
|
- [ntfy.sh](https://paramdeo.com/til/ntfy-sh) - paramdeo.com - 11/2022
|
||||||
|
- [Using ntfy to warn me when my computer is discharging](https://ulysseszh.github.io/programming/2022/11/28/ntfy-warn-discharge.html) - ulysseszh.github.io - 11/2022
|
||||||
|
- [ntfy - Push Notification Service](https://dizzytech.de/posts/ntfy/) - dizzytech.de - 11/2022
|
||||||
|
- [Console #132](https://console.substack.com/p/console-132) ⭐ - console.substack.com - 11/2022
|
||||||
|
- [MeshCentral - Ntfy Push Notifications ](https://www.youtube.com/watch?v=wyE4rtUd4Bg) - youtube.com - 11/2022
|
||||||
|
- [Changelog | Tracking layoffs, tech worker demand still high, ntfy, ...](https://changelog.com/news/tracking-layoffs-tech-worker-demand-still-high-ntfy-devenv-markdoc-mike-bifulco-Y1jW) ⭐ - changelog.com - 11/2022
|
||||||
|
- [Pointer | Issue #367](https://www.pointer.io/archives/a9495a2a6f/) - pointer.io - 11/2022
|
||||||
|
- [Envie Push Notifications por POST (de graça e sem cadastro)](https://www.tabnews.com.br/filipedeschamps/envie-push-notifications-por-post-de-graca-e-sem-cadastro) - tabnews.com.br - 11/2022
|
||||||
|
- [Push Notifications for KDE](https://volkerkrause.eu/2022/11/12/kde-unifiedpush-push-notifications.html) - volkerkrause.eu - 11/2022
|
||||||
|
- [TLDR Newsletter Daily Update 2022-11-09](https://tldr.tech/tech/newsletter/2022-11-09) ⭐ - tldr.tech - 11/2022
|
||||||
|
- [Ntfy.sh – Send push notifications to your phone via PUT/POST](https://news.ycombinator.com/item?id=33517944) ⭐ - news.ycombinator.com - 11/2022
|
||||||
|
- [Ntfy et Jeedom : un plugin](https://lunarok-domotique.com/2022/11/ntfy-et-jeedom/) - lunarok-domotique.com - 11/2022
|
||||||
|
- [Crea tu propio servidor de notificaciones con Ntfy](https://blog.parravidales.es/crea-tu-propio-servidor-de-notificaciones-con-ntfy/) - blog.parravidales.es - 11/2022
|
||||||
|
- [Zero-cost push notifications to your phone or desktop via PUT/POST ](https://lobste.rs/s/41dq13/zero_cost_push_notifications_your_phone) - lobste.rs - 10/2022
|
||||||
|
- [A nifty push notification system: ntfy](https://jpmens.net/2022/10/30/a-nifty-push-notification-system-ntfy/) - jpmens.net - 10/2022
|
||||||
|
- [Alarmanlage der dritten Art (YouTube video)](https://www.youtube.com/watch?v=altb5QLHbaU&feature=youtu.be) - youtube.com - 10/2022
|
||||||
|
- [Neue Services: Ntfy, TikTok und RustDesk](https://adminforge.de/tools/neue-services-ntfy-tiktok-und-rustdesk/) - adminforge.de - 9/2022
|
||||||
|
- [Ntfy, le service de notifications qu’il vous faut](https://www.cachem.fr/ntfy-le-service-de-notifications-quil-vous-faut/) - cachem.fr - 9/2022
|
||||||
|
- [NAS Synology et notifications avec ntfy](https://www.cachem.fr/synology-notifications-ntfy/) - cachem.fr - 9/2022
|
||||||
|
- [Self hosted Mobile Push Notifications using NTFY | Thejesh GN](https://thejeshgn.com/2022/08/23/self-hosted-mobile-push-notifications-using-ntfy/) - thejeshgn.com - 8/2022
|
||||||
|
- [Fedora Magazine | 4 cool new projects to try in Copr](https://fedoramagazine.org/4-cool-new-projects-to-try-in-copr-for-august-2022/) - fedoramagazine.org - 8/2022
|
||||||
|
- [Docker로 오픈소스 푸시알람 프로젝트 ntfy.sh 설치 및 사용하기.(Feat. Uptimekuma)](https://svrforum.com/svr/398979) - svrforum.com - 8/2022
|
||||||
|
- [Easy notifications from R](https://sometimesir.com/posts/easy-notifications-from-r/) - sometimesir.com - 6/2022
|
||||||
|
- [ntfy is finally coming to iOS, and Matrix/UnifiedPush gateway support](https://www.reddit.com/r/selfhosted/comments/vdzvxi/ntfy_is_finally_coming_to_ios_with_full/) ⭐ - reddit.com - 6/2022
|
||||||
|
- [Install guide (with Docker)](https://chowdera.com/2022/150/202205301257379077.html) - chowdera.com - 5/2022
|
||||||
|
- [无需注册的通知服务ntfy](https://blog.csdn.net/wbsu2004/article/details/125040247) - blog.csdn.net - 5/2022
|
||||||
|
- [Updated review post (Jan-Lukas Else)](https://jlelse.blog/thoughts/2022/04/ntfy) - jlelse.blog - 4/2022
|
||||||
|
- [Using ntfy and Tasker together](https://lachlanlife.net/posts/2022-04-tasker-ntfy/) - lachlanlife.net - 4/2022
|
||||||
|
- [Reddit feature update post](https://www.reddit.com/r/selfhosted/comments/uetlso/ntfy_is_a_tool_to_send_push_notifications_to_your/) ⭐ - reddit.com - 4/2022
|
||||||
|
- [無料で簡単に通知の送受信ができつつオープンソースでセルフホストも可能な「ntfy」を使ってみた](https://gigazine.net/news/20220404-ntfy-push-notification/) - gigazine.net - 4/2022
|
||||||
|
- [Pocketmags ntfy review](https://pocketmags.com/us/linux-format-magazine/march-2022/articles/1104187/ntfy) - pocketmags.com - 3/2022
|
||||||
|
- [Reddit web app release post](https://www.reddit.com/r/selfhosted/comments/tc0p0u/say_hello_to_the_brand_new_ntfysh_web_app_push/) ⭐ - reddit.com- 3/2022
|
||||||
|
- [Lemmy post (Jakob)](https://lemmy.eus/post/15541) - lemmy.eus - 1/2022
|
||||||
|
- [Reddit UnifiedPush release post](https://www.reddit.com/r/selfhosted/comments/s5jylf/my_open_source_notification_android_app_and/) ⭐ - reddit.com - 1/2022
|
||||||
|
- [ntfy: send notifications from your computer to your phone](https://rs1.es/tutorials/2022/01/19/ntfy-send-notifications-phone.html) - rs1.es - 1/2022
|
||||||
|
- [Short ntfy review (Jan-Lukas Else)](https://jlelse.blog/links/2021/12/ntfy-sh) - jlelse.blog - 12/2021
|
||||||
|
- [Free MacroDroid webhook alternative (FrameXX)](https://www.macrodroidforum.com/index.php?threads/ntfy-sh-free-macrodroid-webhook-alternative.1505/) - macrodroidforum.com - 12/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
|
||||||
|
- [Reddit selfhostable post](https://www.reddit.com/r/selfhosted/comments/qxlsm9/my_open_source_notification_android_app_and/) ⭐ - reddit.com - 11/2021
|
||||||
28
docs/known-issues.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Known issues
|
||||||
|
This is an incomplete list of known issues with the ntfy server, Android app, and iOS app. You can find a complete
|
||||||
|
list [on GitHub](https://github.com/binwiederhier/ntfy/labels/%F0%9F%AA%B2%20bug), but I thought it may be helpful
|
||||||
|
to have the prominent ones here to link to.
|
||||||
|
|
||||||
|
## iOS app not refreshing (see [#267](https://github.com/binwiederhier/ntfy/issues/267))
|
||||||
|
For some (many?) users, the iOS app is not refreshing the view when new notifications come in. Until you manually
|
||||||
|
swipe down, you do not see the newly arrived messages, even though the popup appeared before.
|
||||||
|
|
||||||
|
This is caused by some weirdness between the Notification Service Extension (NSE), SwiftUI and Core Data. I am entirely
|
||||||
|
clueless on how to fix it, sadly, as it is ephemeral and now clear to me what is causing it.
|
||||||
|
|
||||||
|
Please send experienced iOS developers my way to help me figure this out.
|
||||||
|
|
||||||
|
## iOS app not receiving notifications (anymore)
|
||||||
|
If notifications do not show up at all anymore, there are a few causes for it (that I know of):
|
||||||
|
|
||||||
|
**Firebase+APNS are being weird and buggy**:
|
||||||
|
If this is the case, usually it helps to **remove the topic/subscription and re-add it**. That will force Firebase to
|
||||||
|
re-subscribe to the Firebase topic.
|
||||||
|
|
||||||
|
**Self-hosted only: No `upstream-base-url` set, or `base-url` mismatch**:
|
||||||
|
To make self-hosted servers work with the iOS
|
||||||
|
app, I had to do some horrible things (see [iOS instant notifications](config.md#ios-instant-notifications) for details).
|
||||||
|
Be sure that in your selfhosted server:
|
||||||
|
|
||||||
|
* Set `upstream-base-url: "https://ntfy.sh"` (**not your own hostname!**)
|
||||||
|
* Ensure that the URL you set in `base-url` **matches exactly** what you set the Default Server in iOS to
|
||||||
@@ -8,5 +8,5 @@ any outside service. All data is exclusively used to make the service function p
|
|||||||
I use is Firebase Cloud Messaging (FCM) service, which is required to provide instant Android notifications (see
|
I use is Firebase Cloud Messaging (FCM) service, which is required to provide instant Android notifications (see
|
||||||
[FAQ](faq.md) for details). To avoid FCM altogether, download the F-Droid version.
|
[FAQ](faq.md) for details). To avoid FCM altogether, download the F-Droid version.
|
||||||
|
|
||||||
The web server does not log or otherwise store request paths, remote IP addresses or even topics or messages,
|
For debugging purposes, the ntfy server may temporarily log request paths, remote IP addresses or even topics
|
||||||
aside from a short on-disk cache to support service restarts.
|
or messages, though typically this is turned off.
|
||||||
|
|||||||
449
docs/publish.md
@@ -38,7 +38,7 @@ Here's an example showing how to publish a simple message using a POST request:
|
|||||||
|
|
||||||
=== "PowerShell"
|
=== "PowerShell"
|
||||||
``` powershell
|
``` powershell
|
||||||
Invoke-RestMethod -Method 'Post' -Uri https://ntfy.sh/topic -Body "Backup successful 😀" -UseBasicParsing
|
Invoke-RestMethod -Method 'Post' -Uri https://ntfy.sh/mytopic -Body "Backup successful" -UseBasicParsing
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "Python"
|
=== "Python"
|
||||||
@@ -296,6 +296,8 @@ an [external image attachment](#attach-file-from-a-url) and [email publishing](#
|
|||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
## Message title
|
## Message title
|
||||||
|
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||||
|
|
||||||
The notification title is typically set to the topic short URL (e.g. `ntfy.sh/mytopic`). To override the title,
|
The notification title is typically set to the topic short URL (e.g. `ntfy.sh/mytopic`). To override the title,
|
||||||
you can set the `X-Title` header (or any of its aliases: `Title`, `ti`, or `t`).
|
you can set the `X-Title` header (or any of its aliases: `Title`, `ti`, or `t`).
|
||||||
|
|
||||||
@@ -372,7 +374,9 @@ you can set the `X-Title` header (or any of its aliases: `Title`, `ti`, or `t`).
|
|||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
## Message priority
|
## Message priority
|
||||||
All messages have a priority, which defines how urgently your phone notifies you. You can set custom
|
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||||
|
|
||||||
|
All messages have a priority, which defines how urgently your phone notifies you. On Android, you can set custom
|
||||||
notification sounds and vibration patterns on your phone to map to these priorities (see [Android config](subscribe/phone.md)).
|
notification sounds and vibration patterns on your phone to map to these priorities (see [Android config](subscribe/phone.md)).
|
||||||
|
|
||||||
The following priorities exist:
|
The following priorities exist:
|
||||||
@@ -460,6 +464,8 @@ You can set the priority with the header `X-Priority` (or any of its aliases: `P
|
|||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
## Tags & emojis 🥳 🎉
|
## Tags & emojis 🥳 🎉
|
||||||
|
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||||
|
|
||||||
You can tag messages with emojis and other relevant strings:
|
You can tag messages with emojis and other relevant strings:
|
||||||
|
|
||||||
* **Emojis**: If a tag matches an [emoji short code](emojis.md), it'll be converted to an emoji and prepended
|
* **Emojis**: If a tag matches an [emoji short code](emojis.md), it'll be converted to an emoji and prepended
|
||||||
@@ -579,6 +585,8 @@ them with a comma, e.g. `tag1,tag2,tag3`.
|
|||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
## Scheduled delivery
|
## Scheduled delivery
|
||||||
|
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||||
|
|
||||||
You can delay the delivery of messages and let ntfy send them at a later date. This can be used to send yourself
|
You can delay the delivery of messages and let ntfy send them at a later date. This can be used to send yourself
|
||||||
reminders or even to execute commands at a later date (if your subscriber acts on messages).
|
reminders or even to execute commands at a later date (if your subscriber acts on messages).
|
||||||
|
|
||||||
@@ -679,6 +687,8 @@ Here are a few examples (assuming today's date is **12/10/2021, 9am, Eastern Tim
|
|||||||
</tr></table>
|
</tr></table>
|
||||||
|
|
||||||
## Webhooks (publish via GET)
|
## Webhooks (publish via GET)
|
||||||
|
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||||
|
|
||||||
In addition to using PUT/POST, you can also send to topics via simple HTTP GET requests. This makes it easy to use
|
In addition to using PUT/POST, you can also send to topics via simple HTTP GET requests. This makes it easy to use
|
||||||
a ntfy topic as a [webhook](https://en.wikipedia.org/wiki/Webhook), or if your client has limited HTTP support (e.g.
|
a ntfy topic as a [webhook](https://en.wikipedia.org/wiki/Webhook), or if your client has limited HTTP support (e.g.
|
||||||
like the [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) Android app).
|
like the [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) Android app).
|
||||||
@@ -782,6 +792,8 @@ Here's an example with a custom message, tags and a priority:
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Publish as JSON
|
## Publish as JSON
|
||||||
|
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||||
|
|
||||||
For some integrations with other tools (e.g. [Jellyfin](https://jellyfin.org/), [overseerr](https://overseerr.dev/)),
|
For some integrations with other tools (e.g. [Jellyfin](https://jellyfin.org/), [overseerr](https://overseerr.dev/)),
|
||||||
adding custom headers to HTTP requests may be tricky or impossible, so ntfy also allows publishing the entire message
|
adding custom headers to HTTP requests may be tricky or impossible, so ntfy also allows publishing the entire message
|
||||||
as JSON in the request body.
|
as JSON in the request body.
|
||||||
@@ -873,16 +885,22 @@ is the only required one:
|
|||||||
``` powershell
|
``` powershell
|
||||||
$uri = "https://ntfy.sh"
|
$uri = "https://ntfy.sh"
|
||||||
$body = @{
|
$body = @{
|
||||||
"topic"="powershell"
|
topic = "mytopic"
|
||||||
"title"="Low disk space alert"
|
title = "Low disk space alert"
|
||||||
"message"="Disk space is low at 5.1 GB"
|
message = "Disk space is low at 5.1 GB"
|
||||||
"priority"=4
|
priority = 4
|
||||||
"attach"="https://filesrv.lan/space.jpg"
|
attach = "https://filesrv.lan/space.jpg"
|
||||||
"filename"="diskspace.jpg"
|
filename = "diskspace.jpg"
|
||||||
"tags"=@("warning","cd")
|
tags = @("warning", "cd")
|
||||||
"click"= "https://homecamera.lan/xasds1h2xsSsa/"
|
click = "https://homecamera.lan/xasds1h2xsSsa/"
|
||||||
"actions"=@[@{ "action"="view", "label"="Admin panel", "url"="https://filesrv.lan/admin" }]
|
actions = @(
|
||||||
} | ConvertTo-Json
|
@{
|
||||||
|
action = "view"
|
||||||
|
label = "Admin panel"
|
||||||
|
url = "https://filesrv.lan/admin"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} | ConvertTo-Json
|
||||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing
|
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -943,6 +961,8 @@ all the supported fields:
|
|||||||
| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications |
|
| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications |
|
||||||
|
|
||||||
## Action buttons
|
## Action buttons
|
||||||
|
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||||
|
|
||||||
You can add action buttons to notifications to allow yourself to react to a notification directly. This is incredibly
|
You can add action buttons to notifications to allow yourself to react to a notification directly. This is incredibly
|
||||||
useful and has countless applications.
|
useful and has countless applications.
|
||||||
|
|
||||||
@@ -953,7 +973,7 @@ As of today, the following actions are supported:
|
|||||||
|
|
||||||
* [`view`](#open-websiteapp): Opens a website or app when the action button is tapped
|
* [`view`](#open-websiteapp): Opens a website or app when the action button is tapped
|
||||||
* [`broadcast`](#send-android-broadcast): Sends an [Android broadcast](https://developer.android.com/guide/components/broadcasts) intent
|
* [`broadcast`](#send-android-broadcast): Sends an [Android broadcast](https://developer.android.com/guide/components/broadcasts) intent
|
||||||
when the action button is tapped
|
when the action button is tapped (only supported on Android)
|
||||||
* [`http`](#send-http-request): Sends HTTP POST/GET/PUT request when the action button is tapped
|
* [`http`](#send-http-request): Sends HTTP POST/GET/PUT request when the action button is tapped
|
||||||
|
|
||||||
Here's an example of what that a notification with actions can look like:
|
Here's an example of what that a notification with actions can look like:
|
||||||
@@ -1146,7 +1166,7 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
topic: "myhome",
|
topic: "myhome",
|
||||||
message": "You left the house. Turn down the A/C?",
|
message: "You left the house. Turn down the A/C?",
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
action: "view",
|
action: "view",
|
||||||
@@ -1196,20 +1216,20 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
|
|||||||
``` powershell
|
``` powershell
|
||||||
$uri = "https://ntfy.sh"
|
$uri = "https://ntfy.sh"
|
||||||
$body = @{
|
$body = @{
|
||||||
"topic"="myhome"
|
topic = "myhome"
|
||||||
"message"="You left the house. Turn down the A/C?"
|
message = "You left the house. Turn down the A/C?"
|
||||||
"actions"=@(
|
actions = @(
|
||||||
@{
|
@{
|
||||||
"action"="view"
|
action = "view"
|
||||||
"label"="Open portal"
|
label = "Open portal"
|
||||||
"url"="https://home.nest.com/"
|
url = "https://home.nest.com/"
|
||||||
"clear"=true
|
clear = $true
|
||||||
},
|
},
|
||||||
@{
|
@{
|
||||||
"action"="http",
|
action = "http"
|
||||||
"label"="Turn down"
|
label = "Turn down"
|
||||||
"url"="https://api.nest.com/"
|
url = "https://api.nest.com/"
|
||||||
"body"="{\"temperature\": 65}"
|
body = '{"temperature": 65}'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} | ConvertTo-Json
|
} | ConvertTo-Json
|
||||||
@@ -1276,6 +1296,8 @@ The required/optional fields for each action depend on the type of the action it
|
|||||||
for details.
|
for details.
|
||||||
|
|
||||||
### Open website/app
|
### Open website/app
|
||||||
|
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||||
|
|
||||||
The `view` action **opens a website or app when the action button is tapped**, e.g. a browser, a Google Maps location, or
|
The `view` action **opens a website or app when the action button is tapped**, e.g. a browser, a Google Maps location, or
|
||||||
even a deep link into Twitter or a show ntfy topic. How exactly the action is handled depends on how Android and your
|
even a deep link into Twitter or a show ntfy topic. How exactly the action is handled depends on how Android and your
|
||||||
desktop browser treat the links. Normally it'll just open a link in the browser.
|
desktop browser treat the links. Normally it'll just open a link in the browser.
|
||||||
@@ -1294,7 +1316,7 @@ Here's an example using the [`X-Actions` header](#using-a-header):
|
|||||||
=== "Command line (curl)"
|
=== "Command line (curl)"
|
||||||
```
|
```
|
||||||
curl \
|
curl \
|
||||||
-d "Somebody retweetet your tweet." \
|
-d "Somebody retweeted your tweet." \
|
||||||
-H "Actions: view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392" \
|
-H "Actions: view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392" \
|
||||||
ntfy.sh/myhome
|
ntfy.sh/myhome
|
||||||
```
|
```
|
||||||
@@ -1304,7 +1326,7 @@ Here's an example using the [`X-Actions` header](#using-a-header):
|
|||||||
ntfy publish \
|
ntfy publish \
|
||||||
--actions="view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392" \
|
--actions="view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392" \
|
||||||
myhome \
|
myhome \
|
||||||
"Somebody retweetet your tweet."
|
"Somebody retweeted your tweet."
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "HTTP"
|
=== "HTTP"
|
||||||
@@ -1313,14 +1335,14 @@ Here's an example using the [`X-Actions` header](#using-a-header):
|
|||||||
Host: ntfy.sh
|
Host: ntfy.sh
|
||||||
Actions: view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392
|
Actions: view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392
|
||||||
|
|
||||||
Somebody retweetet your tweet.
|
Somebody retweeted your tweet.
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "JavaScript"
|
=== "JavaScript"
|
||||||
``` javascript
|
``` javascript
|
||||||
fetch('https://ntfy.sh/myhome', {
|
fetch('https://ntfy.sh/myhome', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: 'Somebody retweetet your tweet.',
|
body: 'Somebody retweeted your tweet.',
|
||||||
headers: {
|
headers: {
|
||||||
'Actions': 'view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392'
|
'Actions': 'view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392'
|
||||||
}
|
}
|
||||||
@@ -1329,7 +1351,7 @@ Here's an example using the [`X-Actions` header](#using-a-header):
|
|||||||
|
|
||||||
=== "Go"
|
=== "Go"
|
||||||
``` go
|
``` go
|
||||||
req, _ := http.NewRequest("POST", "https://ntfy.sh/myhome", strings.NewReader("Somebody retweetet your tweet."))
|
req, _ := http.NewRequest("POST", "https://ntfy.sh/myhome", strings.NewReader("Somebody retweeted your tweet."))
|
||||||
req.Header.Set("Actions", "view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392")
|
req.Header.Set("Actions", "view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392")
|
||||||
http.DefaultClient.Do(req)
|
http.DefaultClient.Do(req)
|
||||||
```
|
```
|
||||||
@@ -1338,14 +1360,14 @@ Here's an example using the [`X-Actions` header](#using-a-header):
|
|||||||
``` powershell
|
``` powershell
|
||||||
$uri = "https://ntfy.sh/myhome"
|
$uri = "https://ntfy.sh/myhome"
|
||||||
$headers = @{ Actions="view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392" }
|
$headers = @{ Actions="view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392" }
|
||||||
$body = "Somebody retweetet your tweet."
|
$body = "Somebody retweeted your tweet."
|
||||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "Python"
|
=== "Python"
|
||||||
``` python
|
``` python
|
||||||
requests.post("https://ntfy.sh/myhome",
|
requests.post("https://ntfy.sh/myhome",
|
||||||
data="Somebody retweetet your tweet.",
|
data="Somebody retweeted your tweet.",
|
||||||
headers={ "Actions": "view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392" })
|
headers={ "Actions": "view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392" })
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1357,7 +1379,7 @@ Here's an example using the [`X-Actions` header](#using-a-header):
|
|||||||
'header' =>
|
'header' =>
|
||||||
"Content-Type: text/plain\r\n" .
|
"Content-Type: text/plain\r\n" .
|
||||||
"Actions: view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392",
|
"Actions: view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392",
|
||||||
'content' => 'Somebody retweetet your tweet.'
|
'content' => 'Somebody retweeted your tweet.'
|
||||||
]
|
]
|
||||||
]));
|
]));
|
||||||
```
|
```
|
||||||
@@ -1369,7 +1391,7 @@ And the same example using [JSON publishing](#publish-as-json):
|
|||||||
curl ntfy.sh \
|
curl ntfy.sh \
|
||||||
-d '{
|
-d '{
|
||||||
"topic": "myhome",
|
"topic": "myhome",
|
||||||
"message": "Somebody retweetet your tweet.",
|
"message": "Somebody retweeted your tweet.",
|
||||||
"actions": [
|
"actions": [
|
||||||
{
|
{
|
||||||
"action": "view",
|
"action": "view",
|
||||||
@@ -1391,7 +1413,7 @@ And the same example using [JSON publishing](#publish-as-json):
|
|||||||
}
|
}
|
||||||
]' \
|
]' \
|
||||||
myhome \
|
myhome \
|
||||||
"Somebody retweetet your tweet."
|
"Somebody retweeted your tweet."
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "HTTP"
|
=== "HTTP"
|
||||||
@@ -1401,7 +1423,7 @@ And the same example using [JSON publishing](#publish-as-json):
|
|||||||
|
|
||||||
{
|
{
|
||||||
"topic": "myhome",
|
"topic": "myhome",
|
||||||
"message": "Somebody retweetet your tweet.",
|
"message": "Somebody retweeted your tweet.",
|
||||||
"actions": [
|
"actions": [
|
||||||
{
|
{
|
||||||
"action": "view",
|
"action": "view",
|
||||||
@@ -1418,7 +1440,7 @@ And the same example using [JSON publishing](#publish-as-json):
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
topic: "myhome",
|
topic: "myhome",
|
||||||
message": "Somebody retweetet your tweet.",
|
message": "Somebody retweeted your tweet.",
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
action: "view",
|
action: "view",
|
||||||
@@ -1437,7 +1459,7 @@ And the same example using [JSON publishing](#publish-as-json):
|
|||||||
|
|
||||||
body := `{
|
body := `{
|
||||||
"topic": "myhome",
|
"topic": "myhome",
|
||||||
"message": "Somebody retweetet your tweet.",
|
"message": "Somebody retweeted your tweet.",
|
||||||
"actions": [
|
"actions": [
|
||||||
{
|
{
|
||||||
"action": "view",
|
"action": "view",
|
||||||
@@ -1454,9 +1476,9 @@ And the same example using [JSON publishing](#publish-as-json):
|
|||||||
``` powershell
|
``` powershell
|
||||||
$uri = "https://ntfy.sh"
|
$uri = "https://ntfy.sh"
|
||||||
$body = @{
|
$body = @{
|
||||||
"topic"="myhome"
|
topic = "myhome"
|
||||||
"message"="Somebody retweetet your tweet."
|
message = "Somebody retweeted your tweet."
|
||||||
"actions"=@(
|
actions = @(
|
||||||
@{
|
@{
|
||||||
"action"="view"
|
"action"="view"
|
||||||
"label"="Open Twitter"
|
"label"="Open Twitter"
|
||||||
@@ -1472,7 +1494,7 @@ And the same example using [JSON publishing](#publish-as-json):
|
|||||||
requests.post("https://ntfy.sh/",
|
requests.post("https://ntfy.sh/",
|
||||||
data=json.dumps({
|
data=json.dumps({
|
||||||
"topic": "myhome",
|
"topic": "myhome",
|
||||||
"message": "Somebody retweetet your tweet.",
|
"message": "Somebody retweeted your tweet.",
|
||||||
"actions": [
|
"actions": [
|
||||||
{
|
{
|
||||||
"action": "view",
|
"action": "view",
|
||||||
@@ -1492,7 +1514,7 @@ And the same example using [JSON publishing](#publish-as-json):
|
|||||||
'header' => "Content-Type: application/json",
|
'header' => "Content-Type: application/json",
|
||||||
'content' => json_encode([
|
'content' => json_encode([
|
||||||
"topic": "myhome",
|
"topic": "myhome",
|
||||||
"message": "Somebody retweetet your tweet.",
|
"message": "Somebody retweeted your tweet.",
|
||||||
"actions": [
|
"actions": [
|
||||||
[
|
[
|
||||||
"action": "view",
|
"action": "view",
|
||||||
@@ -1515,6 +1537,8 @@ The `view` action supports the following fields:
|
|||||||
| `clear` | -️ | *boolean* | `false` | `true` | Clear notification after action button is tapped |
|
| `clear` | -️ | *boolean* | `false` | `true` | Clear notification after action button is tapped |
|
||||||
|
|
||||||
### Send Android broadcast
|
### Send Android broadcast
|
||||||
|
_Supported on:_ :material-android:
|
||||||
|
|
||||||
The `broadcast` action **sends an [Android broadcast](https://developer.android.com/guide/components/broadcasts) intent
|
The `broadcast` action **sends an [Android broadcast](https://developer.android.com/guide/components/broadcasts) intent
|
||||||
when the action button is tapped**. This allows integration into automation apps such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
when the action button is tapped**. This allows integration into automation apps such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
||||||
or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm), which basically means
|
or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm), which basically means
|
||||||
@@ -1707,21 +1731,24 @@ And the same example using [JSON publishing](#publish-as-json):
|
|||||||
|
|
||||||
=== "PowerShell"
|
=== "PowerShell"
|
||||||
``` powershell
|
``` powershell
|
||||||
|
# Powershell requires the 'Depth' argument to equal 3 here to expand 'Extras',
|
||||||
|
# otherwise it will read System.Collections.Hashtable in the returned JSON
|
||||||
|
|
||||||
$uri = "https://ntfy.sh"
|
$uri = "https://ntfy.sh"
|
||||||
$body = @{
|
$body = @{
|
||||||
"topic"="wifey"
|
topic = "wifey"
|
||||||
"message"="Your wife requested you send a picture of yourself."
|
message = "Your wife requested you send a picture of yourself."
|
||||||
"actions"=@(
|
actions = @(
|
||||||
@{
|
@{
|
||||||
"action"="broadcast"
|
action = "broadcast"
|
||||||
"label"="Take picture"
|
label = "Take picture"
|
||||||
"extras"=@{
|
extras = @{
|
||||||
"cmd"="pic"
|
cmd ="pic"
|
||||||
"camera"="front"
|
camera = "front"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} | ConvertTo-Json
|
} | ConvertTo-Json -Depth 3
|
||||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing
|
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1779,6 +1806,8 @@ The `broadcast` action supports the following fields:
|
|||||||
| `clear` | -️ | *boolean* | `false` | `true` | Clear notification after action button is tapped |
|
| `clear` | -️ | *boolean* | `false` | `true` | Clear notification after action button is tapped |
|
||||||
|
|
||||||
### Send HTTP request
|
### Send HTTP request
|
||||||
|
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||||
|
|
||||||
The `http` action **sends a HTTP request when the action button is tapped**. You can use this to trigger REST APIs
|
The `http` action **sends a HTTP request when the action button is tapped**. You can use this to trigger REST APIs
|
||||||
for whatever systems you have, e.g. opening the garage door, or turning on/off lights.
|
for whatever systems you have, e.g. opening the garage door, or turning on/off lights.
|
||||||
|
|
||||||
@@ -1791,14 +1820,14 @@ Here's an example using the [`X-Actions` header](#using-a-header):
|
|||||||
```
|
```
|
||||||
curl \
|
curl \
|
||||||
-d "Garage door has been open for 15 minutes. Close it?" \
|
-d "Garage door has been open for 15 minutes. Close it?" \
|
||||||
-H "Actions: http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" \
|
-H "Actions: http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" \
|
||||||
ntfy.sh/myhome
|
ntfy.sh/myhome
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "ntfy CLI"
|
=== "ntfy CLI"
|
||||||
```
|
```
|
||||||
ntfy publish \
|
ntfy publish \
|
||||||
--actions="http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" \
|
--actions="http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" \
|
||||||
myhome \
|
myhome \
|
||||||
"Garage door has been open for 15 minutes. Close it?"
|
"Garage door has been open for 15 minutes. Close it?"
|
||||||
```
|
```
|
||||||
@@ -1807,7 +1836,7 @@ Here's an example using the [`X-Actions` header](#using-a-header):
|
|||||||
``` http
|
``` http
|
||||||
POST /myhome HTTP/1.1
|
POST /myhome HTTP/1.1
|
||||||
Host: ntfy.sh
|
Host: ntfy.sh
|
||||||
Actions: http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={"action": "close"}
|
Actions: http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={"action": "close"}
|
||||||
|
|
||||||
Garage door has been open for 15 minutes. Close it?
|
Garage door has been open for 15 minutes. Close it?
|
||||||
```
|
```
|
||||||
@@ -1818,7 +1847,7 @@ Here's an example using the [`X-Actions` header](#using-a-header):
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: 'Garage door has been open for 15 minutes. Close it?',
|
body: 'Garage door has been open for 15 minutes. Close it?',
|
||||||
headers: {
|
headers: {
|
||||||
'Actions': 'http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}'
|
'Actions': 'http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
@@ -1826,14 +1855,14 @@ Here's an example using the [`X-Actions` header](#using-a-header):
|
|||||||
=== "Go"
|
=== "Go"
|
||||||
``` go
|
``` go
|
||||||
req, _ := http.NewRequest("POST", "https://ntfy.sh/myhome", strings.NewReader("Garage door has been open for 15 minutes. Close it?"))
|
req, _ := http.NewRequest("POST", "https://ntfy.sh/myhome", strings.NewReader("Garage door has been open for 15 minutes. Close it?"))
|
||||||
req.Header.Set("Actions", "http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}")
|
req.Header.Set("Actions", "http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}")
|
||||||
http.DefaultClient.Do(req)
|
http.DefaultClient.Do(req)
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "PowerShell"
|
=== "PowerShell"
|
||||||
``` powershell
|
``` powershell
|
||||||
$uri = "https://ntfy.sh/myhome"
|
$uri = "https://ntfy.sh/myhome"
|
||||||
$headers = @{ Actions="http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" }
|
$headers = @{ Actions="http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" }
|
||||||
$body = "Garage door has been open for 15 minutes. Close it?"
|
$body = "Garage door has been open for 15 minutes. Close it?"
|
||||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
||||||
```
|
```
|
||||||
@@ -1842,7 +1871,7 @@ Here's an example using the [`X-Actions` header](#using-a-header):
|
|||||||
``` python
|
``` python
|
||||||
requests.post("https://ntfy.sh/myhome",
|
requests.post("https://ntfy.sh/myhome",
|
||||||
data="Garage door has been open for 15 minutes. Close it?",
|
data="Garage door has been open for 15 minutes. Close it?",
|
||||||
headers={ "Actions": "http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" })
|
headers={ "Actions": "http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" })
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "PHP"
|
=== "PHP"
|
||||||
@@ -1852,7 +1881,7 @@ Here's an example using the [`X-Actions` header](#using-a-header):
|
|||||||
'method' => 'POST',
|
'method' => 'POST',
|
||||||
'header' =>
|
'header' =>
|
||||||
"Content-Type: text/plain\r\n" .
|
"Content-Type: text/plain\r\n" .
|
||||||
"Actions: http, Cloor door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}",
|
"Actions: http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}",
|
||||||
'content' => 'Garage door has been open for 15 minutes. Close it?'
|
'content' => 'Garage door has been open for 15 minutes. Close it?'
|
||||||
]
|
]
|
||||||
]));
|
]));
|
||||||
@@ -1973,24 +2002,26 @@ And the same example using [JSON publishing](#publish-as-json):
|
|||||||
|
|
||||||
=== "PowerShell"
|
=== "PowerShell"
|
||||||
``` powershell
|
``` powershell
|
||||||
|
# Powershell requires the 'Depth' argument to equal 3 here to expand 'headers',
|
||||||
|
# otherwise it will read System.Collections.Hashtable in the returned JSON
|
||||||
|
|
||||||
$uri = "https://ntfy.sh"
|
$uri = "https://ntfy.sh"
|
||||||
$body = @{
|
$body = @{
|
||||||
"topic"="myhome"
|
topic = "myhome"
|
||||||
"message"="Garage door has been open for 15 minutes. Close it?"
|
message = "Garage door has been open for 15 minutes. Close it?"
|
||||||
"actions"=@(
|
actions = @(
|
||||||
@{
|
@{
|
||||||
"action"="http",
|
action = "http"
|
||||||
"label"="Close door"
|
label = "Close door"
|
||||||
"url"="https://api.mygarage.lan/"
|
url = "https://api.mygarage.lan/"
|
||||||
"method"="PUT"
|
method = "PUT"
|
||||||
"headers"=@{
|
headers = @{
|
||||||
"Authorization"="Bearer zAzsx1sk.."
|
Authorization = "Bearer zAzsx1sk.."
|
||||||
}
|
}
|
||||||
"body"="{\"action\": \"close\"}"
|
body = '{"action": "close"}'
|
||||||
}
|
}
|
||||||
}
|
|
||||||
)
|
)
|
||||||
} | ConvertTo-Json
|
} | ConvertTo-Json -Depth 3
|
||||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing
|
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -2055,6 +2086,8 @@ The `http` action supports the following fields:
|
|||||||
| `clear` | -️ | *boolean* | `false` | `true` | Clear notification after HTTP request succeeds. If the request fails, the notification is not cleared. |
|
| `clear` | -️ | *boolean* | `false` | `true` | Clear notification after HTTP request succeeds. If the request fails, the notification is not cleared. |
|
||||||
|
|
||||||
## Click action
|
## Click action
|
||||||
|
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||||
|
|
||||||
You can define which URL to open when a notification is clicked. This may be useful if your notification is related
|
You can define which URL to open when a notification is clicked. This may be useful if your notification is related
|
||||||
to a Zabbix alert or a transaction that you'd like to provide the deep-link for. Tapping the notification will open
|
to a Zabbix alert or a transaction that you'd like to provide the deep-link for. Tapping the notification will open
|
||||||
the web browser (or the app) and open the website.
|
the web browser (or the app) and open the website.
|
||||||
@@ -2143,6 +2176,8 @@ Here's an example that will open Reddit when the notification is clicked:
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Attachments
|
## Attachments
|
||||||
|
_Supported on:_ :material-android: :material-firefox:
|
||||||
|
|
||||||
You can **send images and other files to your phone** as attachments to a notification. The attachments are then downloaded
|
You can **send images and other files to your phone** as attachments to a notification. The attachments are then downloaded
|
||||||
onto your phone (depending on size and setting automatically), and can be used from the Downloads folder.
|
onto your phone (depending on size and setting automatically), and can be used from the Downloads folder.
|
||||||
|
|
||||||
@@ -2185,8 +2220,8 @@ Here's an example showing how to upload an image:
|
|||||||
Host: ntfy.sh
|
Host: ntfy.sh
|
||||||
Filename: flower.jpg
|
Filename: flower.jpg
|
||||||
Content-Type: 52312
|
Content-Type: 52312
|
||||||
|
|
||||||
<binary JPEG data>
|
(binary JPEG data)
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "JavaScript"
|
=== "JavaScript"
|
||||||
@@ -2314,7 +2349,115 @@ Here's an example showing how to attach an APK file:
|
|||||||
<figcaption>File attachment sent from an external URL</figcaption>
|
<figcaption>File attachment sent from an external URL</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
|
## Icons
|
||||||
|
_Supported on:_ :material-android:
|
||||||
|
|
||||||
|
You can include an icon that will appear next to the text of the notification. Simply pass the `X-Icon` header or query
|
||||||
|
parameter (or its alias `Icon`) to specify the URL that the icon is located at. The client will automatically download
|
||||||
|
the icon (unless it is already cached locally, and less than 24 hours old), and show it in the notification. Icons are
|
||||||
|
cached locally in the client until the notification is deleted. **Only JPEG and PNG images are supported at this time**.
|
||||||
|
|
||||||
|
Here's an example showing how to include an icon:
|
||||||
|
|
||||||
|
=== "Command line (curl)"
|
||||||
|
```
|
||||||
|
curl \
|
||||||
|
-H "Icon: https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png" \
|
||||||
|
-H "Title: Kodi: Resuming Playback" \
|
||||||
|
-H "Tags: arrow_forward" \
|
||||||
|
-d "The Wire, S01E01" \
|
||||||
|
ntfy.sh/tvshows
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "ntfy CLI"
|
||||||
|
```
|
||||||
|
ntfy publish \
|
||||||
|
--icon="https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png" \
|
||||||
|
--title="Kodi: Resuming Playback" \
|
||||||
|
--tags="arrow_forward" \
|
||||||
|
tvshows \
|
||||||
|
"The Wire, S01E01"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "HTTP"
|
||||||
|
``` http
|
||||||
|
POST /tvshows HTTP/1.1
|
||||||
|
Host: ntfy.sh
|
||||||
|
Icon: https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png
|
||||||
|
Tags: arrow_forward
|
||||||
|
Title: Kodi: Resuming Playback
|
||||||
|
|
||||||
|
The Wire, S01E01
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "JavaScript"
|
||||||
|
``` javascript
|
||||||
|
fetch('https://ntfy.sh/tvshows', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Icon': 'https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png',
|
||||||
|
'Title': 'Kodi: Resuming Playback',
|
||||||
|
'Tags': 'arrow_forward'
|
||||||
|
},
|
||||||
|
body: "The Wire, S01E01"
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Go"
|
||||||
|
``` go
|
||||||
|
req, _ := http.NewRequest("POST", "https://ntfy.sh/tvshows", strings.NewReader("The Wire, S01E01"))
|
||||||
|
req.Header.Set("Icon", "https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png")
|
||||||
|
req.Header.Set("Tags", "arrow_forward")
|
||||||
|
req.Header.Set("Title", "Kodi: Resuming Playback")
|
||||||
|
http.DefaultClient.Do(req)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "PowerShell"
|
||||||
|
``` powershell
|
||||||
|
$uri = "https://ntfy.sh/tvshows"
|
||||||
|
$headers = @{ Title"="Kodi: Resuming Playback"
|
||||||
|
Tags="arrow_forward"
|
||||||
|
Icon="https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png" }
|
||||||
|
$body = "The Wire, S01E01"
|
||||||
|
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Python"
|
||||||
|
``` python
|
||||||
|
requests.post("https://ntfy.sh/tvshows",
|
||||||
|
data="The Wire, S01E01",
|
||||||
|
headers={
|
||||||
|
"Title": "Kodi: Resuming Playback",
|
||||||
|
"Tags": "arrow_forward",
|
||||||
|
"Icon": "https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png"
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "PHP"
|
||||||
|
``` php-inline
|
||||||
|
file_get_contents('https://ntfy.sh/tvshows', false, stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'method' => 'PUT',
|
||||||
|
'header' =>
|
||||||
|
"Content-Type: text/plain\r\n" . // Does not matter
|
||||||
|
"Title: Kodi: Resuming Playback\r\n" .
|
||||||
|
"Tags: arrow_forward\r\n" .
|
||||||
|
"Icon: https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png",
|
||||||
|
],
|
||||||
|
'content' => "The Wire, S01E01"
|
||||||
|
]));
|
||||||
|
```
|
||||||
|
|
||||||
|
Here's an example of how it will look on Android:
|
||||||
|
|
||||||
|
<figure markdown>
|
||||||
|
{ width=500 }
|
||||||
|
<figcaption>Custom icon from an external URL</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
## E-mail notifications
|
## E-mail notifications
|
||||||
|
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||||
|
|
||||||
You can forward messages to e-mail by specifying an address in the header. This can be useful for messages that
|
You can forward messages to e-mail by specifying an address in the header. This can be useful for messages that
|
||||||
you'd like to persist longer, or to blast-notify yourself on all possible channels.
|
you'd like to persist longer, or to blast-notify yourself on all possible channels.
|
||||||
|
|
||||||
@@ -2425,6 +2568,8 @@ Here's what that looks like in Google Mail:
|
|||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
## E-mail publishing
|
## E-mail publishing
|
||||||
|
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||||
|
|
||||||
You can publish messages to a topic via e-mail, i.e. by sending an email to a specific address. For instance, you can
|
You can publish messages to a topic via e-mail, i.e. by sending an email to a specific address. For instance, you can
|
||||||
publish a message to the topic `sometopic` by sending an e-mail to `ntfy-sometopic@ntfy.sh`. This is useful for e-mail
|
publish a message to the topic `sometopic` by sending an e-mail to `ntfy-sometopic@ntfy.sh`. This is useful for e-mail
|
||||||
based integrations such as for statuspage.io (though these days most services also support webhooks and HTTP calls).
|
based integrations such as for statuspage.io (though these days most services also support webhooks and HTTP calls).
|
||||||
@@ -2451,16 +2596,23 @@ title `You've Got Mail` to topic `sometopic` (see [ntfy.sh/sometopic](https://nt
|
|||||||
### 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
|
||||||
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 use [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication)
|
To publish/subscribe to protected topics, you can:
|
||||||
with a valid username/password. For your self-hosted server, **be sure to use HTTPS to avoid eavesdropping** and exposing
|
|
||||||
your password.
|
|
||||||
|
|
||||||
Here's a simple example:
|
* Use [basic auth](#basic-auth), e.g. `Authorization: Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk`
|
||||||
|
* or use the [`auth` query parameter](#query-param), e.g. `?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw`
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
Base64 only encodes username and password. It **is not encrypting it**. For your self-hosted server,
|
||||||
|
**be sure to use HTTPS to avoid eavesdropping** and exposing your password.
|
||||||
|
|
||||||
|
#### Basic auth
|
||||||
|
Here's an example using [Basic auth](https://en.wikipedia.org/wiki/Basic_access_authentication), with a user `testuser`
|
||||||
|
and password `fakepassword`:
|
||||||
|
|
||||||
=== "Command line (curl)"
|
=== "Command line (curl)"
|
||||||
```
|
```
|
||||||
curl \
|
curl \
|
||||||
-u phil:mypass \
|
-u testuser:fakepassword \
|
||||||
-d "Look ma, with auth" \
|
-d "Look ma, with auth" \
|
||||||
https://ntfy.example.com/mysecrets
|
https://ntfy.example.com/mysecrets
|
||||||
```
|
```
|
||||||
@@ -2468,7 +2620,7 @@ Here's a simple example:
|
|||||||
=== "ntfy CLI"
|
=== "ntfy CLI"
|
||||||
```
|
```
|
||||||
ntfy publish \
|
ntfy publish \
|
||||||
-u phil:mypass \
|
-u testuser:fakepassword \
|
||||||
ntfy.example.com/mysecrets \
|
ntfy.example.com/mysecrets \
|
||||||
"Look ma, with auth"
|
"Look ma, with auth"
|
||||||
```
|
```
|
||||||
@@ -2477,7 +2629,7 @@ Here's a simple example:
|
|||||||
``` http
|
``` http
|
||||||
POST /mysecrets HTTP/1.1
|
POST /mysecrets HTTP/1.1
|
||||||
Host: ntfy.example.com
|
Host: ntfy.example.com
|
||||||
Authorization: Basic cGhpbDpteXBhc3M=
|
Authorization: Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk
|
||||||
|
|
||||||
Look ma, with auth
|
Look ma, with auth
|
||||||
```
|
```
|
||||||
@@ -2488,7 +2640,7 @@ Here's a simple example:
|
|||||||
method: 'POST', // PUT works too
|
method: 'POST', // PUT works too
|
||||||
body: 'Look ma, with auth',
|
body: 'Look ma, with auth',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': 'Basic cGhpbDpteXBhc3M='
|
'Authorization': 'Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
@@ -2497,16 +2649,18 @@ Here's a simple example:
|
|||||||
``` go
|
``` go
|
||||||
req, _ := http.NewRequest("POST", "https://ntfy.example.com/mysecrets",
|
req, _ := http.NewRequest("POST", "https://ntfy.example.com/mysecrets",
|
||||||
strings.NewReader("Look ma, with auth"))
|
strings.NewReader("Look ma, with auth"))
|
||||||
req.Header.Set("Authorization", "Basic cGhpbDpteXBhc3M=")
|
req.Header.Set("Authorization", "Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk")
|
||||||
http.DefaultClient.Do(req)
|
http.DefaultClient.Do(req)
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "PowerShell"
|
=== "PowerShell"
|
||||||
``` powershell
|
``` powershell
|
||||||
$uri = "https://ntfy.example.com/mysecrets"
|
$uri = "https://ntfy.example.com/mysecrets"
|
||||||
$headers = @{ Authorization="Basic cGhpbDpteXBhc3M=" }
|
$credentials = 'testuser:fakepassword'
|
||||||
$body = "Look ma, with auth"
|
$encodedCredentials = [convert]::ToBase64String([text.Encoding]::UTF8.GetBytes($credentials))
|
||||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -Headers $headers -UseBasicParsing
|
$headers = @{Authorization="Basic $encodedCredentials"}
|
||||||
|
$message = "Look ma, with auth"
|
||||||
|
Invoke-RestMethod -Uri $uri -Body $message -Headers $headers -Method "Post" -UseBasicParsing
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "Python"
|
=== "Python"
|
||||||
@@ -2514,7 +2668,7 @@ Here's a simple example:
|
|||||||
requests.post("https://ntfy.example.com/mysecrets",
|
requests.post("https://ntfy.example.com/mysecrets",
|
||||||
data="Look ma, with auth",
|
data="Look ma, with auth",
|
||||||
headers={
|
headers={
|
||||||
"Authorization": "Basic cGhpbDpteXBhc3M="
|
"Authorization": "Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk"
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -2525,12 +2679,113 @@ Here's a simple example:
|
|||||||
'method' => 'POST', // PUT also works
|
'method' => 'POST', // PUT also works
|
||||||
'header' =>
|
'header' =>
|
||||||
'Content-Type: text/plain\r\n' .
|
'Content-Type: text/plain\r\n' .
|
||||||
'Authorization: Basic cGhpbDpteXBhc3M=',
|
'Authorization: Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk',
|
||||||
'content' => 'Look ma, with auth'
|
'content' => 'Look ma, with auth'
|
||||||
]
|
]
|
||||||
]));
|
]));
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To generate the `Authorization` header, use **standard base64** to encode the colon-separated `<username>:<password>`
|
||||||
|
and prepend the word `Basic`, i.e. `Authorization: Basic base64(<username>:<password>)`. Here's some pseudo-code that
|
||||||
|
hopefully explains it better:
|
||||||
|
|
||||||
|
```
|
||||||
|
username = "testuser"
|
||||||
|
password = "fakepassword"
|
||||||
|
authHeader = "Basic " + base64(username + ":" + password) // -> Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk
|
||||||
|
```
|
||||||
|
|
||||||
|
The following command will generate the appropriate value for you on *nix systems:
|
||||||
|
|
||||||
|
```
|
||||||
|
echo "Basic $(echo -n 'testuser:fakepassword' | base64)"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Query param
|
||||||
|
Here's an example using the `auth` query parameter:
|
||||||
|
|
||||||
|
=== "Command line (curl)"
|
||||||
|
```
|
||||||
|
curl \
|
||||||
|
-d "Look ma, with auth" \
|
||||||
|
"https://ntfy.example.com/mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "ntfy CLI"
|
||||||
|
```
|
||||||
|
ntfy publish \
|
||||||
|
-u testuser:fakepassword \
|
||||||
|
ntfy.example.com/mysecrets \
|
||||||
|
"Look ma, with auth"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "HTTP"
|
||||||
|
``` http
|
||||||
|
POST /mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw HTTP/1.1
|
||||||
|
Host: ntfy.example.com
|
||||||
|
|
||||||
|
Look ma, with auth
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "JavaScript"
|
||||||
|
``` javascript
|
||||||
|
fetch('https://ntfy.example.com/mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw', {
|
||||||
|
method: 'POST', // PUT works too
|
||||||
|
body: 'Look ma, with auth'
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Go"
|
||||||
|
``` go
|
||||||
|
req, _ := http.NewRequest("POST", "https://ntfy.example.com/mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw",
|
||||||
|
strings.NewReader("Look ma, with auth"))
|
||||||
|
http.DefaultClient.Do(req)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "PowerShell"
|
||||||
|
``` powershell
|
||||||
|
$uri = "https://ntfy.example.com/mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw"
|
||||||
|
$message = "Look ma, with auth"
|
||||||
|
Invoke-RestMethod -Uri $uri -Body $message -Method "Post" -UseBasicParsing
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Python"
|
||||||
|
``` python
|
||||||
|
requests.post("https://ntfy.example.com/mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw",
|
||||||
|
data="Look ma, with auth"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "PHP"
|
||||||
|
``` php-inline
|
||||||
|
file_get_contents('https://ntfy.example.com/mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw', false, stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'method' => 'POST', // PUT also works
|
||||||
|
'header' => 'Content-Type: text/plain',
|
||||||
|
'content' => 'Look ma, with auth'
|
||||||
|
]
|
||||||
|
]));
|
||||||
|
```
|
||||||
|
|
||||||
|
To generate the value of the `auth` parameter, encode the value of the `Authorization` header (see anove) using
|
||||||
|
**raw base64 encoding** (like base64, but strip any trailing `=`). Here's some pseudo-code that hopefully
|
||||||
|
explains it better:
|
||||||
|
|
||||||
|
```
|
||||||
|
username = "testuser"
|
||||||
|
password = "fakepassword"
|
||||||
|
authHeader = "Basic " + base64(username + ":" + password) // -> Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk
|
||||||
|
authParam = base64_raw(authHeader) // -> QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw (no trailing =)
|
||||||
|
|
||||||
|
// If your language does not have a function to encode raw base64, simply use normal base64
|
||||||
|
// and REMOVE TRAILING "=" characters.
|
||||||
|
```
|
||||||
|
|
||||||
|
The following command will generate the appropriate value for you on *nix systems:
|
||||||
|
|
||||||
|
```
|
||||||
|
echo -n "Basic `echo -n 'testuser:fakepassword' | base64`" | base64 | tr -d '='
|
||||||
|
```
|
||||||
|
|
||||||
### Message caching
|
### Message caching
|
||||||
!!! info
|
!!! info
|
||||||
If `Cache: no` is used, messages will only be delivered to connected subscribers, and won't be re-delivered if a
|
If `Cache: no` is used, messages will only be delivered to connected subscribers, and won't be re-delivered if a
|
||||||
@@ -2705,6 +2960,22 @@ parameter (or any of its aliases `unifiedpush` or `up`) to `1` to [disable Fireb
|
|||||||
option is mostly equivalent to `Firebase: no`, but was introduced to allow future flexibility. The flag additionally
|
option is mostly equivalent to `Firebase: no`, but was introduced to allow future flexibility. The flag additionally
|
||||||
enables auto-detection of the message encoding. If the message is binary, it'll be encoded as base64.
|
enables auto-detection of the message encoding. If the message is binary, it'll be encoded as base64.
|
||||||
|
|
||||||
|
### Matrix Gateway
|
||||||
|
The ntfy server implements a [Matrix Push Gateway](https://spec.matrix.org/v1.2/push-gateway-api/) (in combination with
|
||||||
|
[UnifiedPush](https://unifiedpush.org) as the [Provider Push Protocol](https://unifiedpush.org/developers/gateway/)). This makes it easier to integrate
|
||||||
|
with self-hosted [Matrix](https://matrix.org/) servers (such as [synapse](https://github.com/matrix-org/synapse)), since
|
||||||
|
you don't have to set up a separate push proxy (such as [common-proxies](https://github.com/UnifiedPush/common-proxies)).
|
||||||
|
|
||||||
|
In short, ntfy accepts Matrix messages on the `/_matrix/push/v1/notify` endpoint (see [Push Gateway API](https://spec.matrix.org/v1.2/push-gateway-api/)),
|
||||||
|
and forwards them to the ntfy topic defined in the `pushkey` of the message. The message will then be forwarded to the
|
||||||
|
ntfy Android app, and passed on to the Matrix client there.
|
||||||
|
|
||||||
|
There is a nice diagram in the [Push Gateway docs](https://spec.matrix.org/v1.2/push-gateway-api/). In this diagram, the
|
||||||
|
ntfy server plays the role of the Push Gateway, as well as the Push Provider. UnifiedPush is the Provider Push Protocol.
|
||||||
|
|
||||||
|
!!! info
|
||||||
|
This is not a generic Matrix Push Gateway. It only works in combination with UnifiedPush and ntfy.
|
||||||
|
|
||||||
## Public topics
|
## Public topics
|
||||||
Obviously all topics on ntfy.sh are public, but there are a few designated topics that are used in examples, and topics
|
Obviously all topics on ntfy.sh are public, but there are a few designated topics that are used in examples, and topics
|
||||||
that you can use to try out what [authentication and access control](#authentication) looks like.
|
that you can use to try out what [authentication and access control](#authentication) looks like.
|
||||||
@@ -2747,9 +3018,11 @@ and can be passed as **HTTP headers** or **query parameters in the URL**. They a
|
|||||||
| `X-Actions` | `Actions`, `Action` | JSON array or short format of [user actions](#action-buttons) |
|
| `X-Actions` | `Actions`, `Action` | JSON array or short format of [user actions](#action-buttons) |
|
||||||
| `X-Click` | `Click` | URL to open when [notification is clicked](#click-action) |
|
| `X-Click` | `Click` | URL to open when [notification is clicked](#click-action) |
|
||||||
| `X-Attach` | `Attach`, `a` | URL to send as an [attachment](#attachments), as an alternative to PUT/POST-ing an attachment |
|
| `X-Attach` | `Attach`, `a` | URL to send as an [attachment](#attachments), as an alternative to PUT/POST-ing an attachment |
|
||||||
|
| `X-Icon` | `Icon` | URL to use as notification [icon](#icons) |
|
||||||
| `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments) filename, as it appears in the client |
|
| `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments) filename, as it appears in the client |
|
||||||
| `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications) |
|
| `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications) |
|
||||||
| `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) |
|
| `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) |
|
||||||
| `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) |
|
| `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) |
|
||||||
| `X-UnifiedPush` | `UnifiedPush`, `up` | [UnifiedPush](#unifiedpush) publish option, only to be used by UnifiedPush apps |
|
| `X-UnifiedPush` | `UnifiedPush`, `up` | [UnifiedPush](#unifiedpush) publish option, only to be used by UnifiedPush apps |
|
||||||
|
| `X-Poll-ID` | `Poll-ID` | Internal parameter, used for [iOS push notifications](config.md#ios-instant-notifications) |
|
||||||
| `Authorization` | - | If supported by the server, you can [login to access](#authentication) protected topics |
|
| `Authorization` | - | If supported by the server, you can [login to access](#authentication) protected topics |
|
||||||
|
|||||||
447
docs/releases.md
@@ -2,15 +2,412 @@
|
|||||||
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 v1.30.1
|
||||||
|
Released December 23, 2022 🎅
|
||||||
## ntfy Android app v1.13.0 (UNRELEASED)
|
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
|
||||||
* Cards in notification detail view ([#175](https://github.com/binwiederhier/ntfy/issues/224), thanks to [@cmeis](https://github.com/cmeis) for reporting)
|
* Web: Generate random topic name button ([#453](https://github.com/binwiederhier/ntfy/issues/453), thanks to [@yardenshoham](https://github.com/yardenshoham))
|
||||||
|
* Add [Gitpod config](https://github.com/binwiederhier/ntfy/blob/main/.gitpod.yml) ([#540](https://github.com/binwiederhier/ntfy/pull/540), thanks to [@yardenshoham](https://github.com/yardenshoham))
|
||||||
|
|
||||||
**Bugs:**
|
**Bug fixes + maintenance:**
|
||||||
|
|
||||||
|
* Remove `--env-topic` option from `ntfy publish` as per [deprecation](deprecations.md) (no ticket)
|
||||||
|
* Prepared statements for message cache writes ([#542](https://github.com/binwiederhier/ntfy/pull/542), thanks to [@nicois](https://github.com/nicois))
|
||||||
|
* Do not warn about invalid IP address when behind proxy in unix socket mode (relates to [#552](https://github.com/binwiederhier/ntfy/issues/552))
|
||||||
|
|
||||||
|
## ntfy Android app v1.16.0
|
||||||
|
Released December 11, 2022
|
||||||
|
|
||||||
|
This is a feature and platform/dependency upgrade release. You can now have per-subscription notification settings
|
||||||
|
(including sounds, DND, etc.), and you can make notifications continue ringing until they are dismissed. There's also
|
||||||
|
support for thematic/adaptive launcher icon for Android 13.
|
||||||
|
|
||||||
|
There are a few more Android 13 specific things, as well as many bug fixes: No more crashes from large images, no more
|
||||||
|
opening the wrong subscription, and we also fixed the icon color issue.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Custom per-subscription notification settings incl. sounds, DND, etc. ([#6](https://github.com/binwiederhier/ntfy/issues/6), thanks to [@doits](https://github.com/doits))
|
||||||
|
* Insistent notifications that ring until dismissed ([#417](https://github.com/binwiederhier/ntfy/issues/417), thanks to [@danmed](https://github.com/danmed) for reporting)
|
||||||
|
* Add thematic/adaptive launcher icon ([#513](https://github.com/binwiederhier/ntfy/issues/513), thanks to [@daedric7](https://github.com/daedric7) for reporting)
|
||||||
|
|
||||||
|
**Bug fixes + maintenance:**
|
||||||
|
|
||||||
|
* Upgrade Android dependencies and build toolchain to SDK 33 (no ticket)
|
||||||
|
* Simplify F-Droid build: Disable tasks for Google Services ([#516](https://github.com/binwiederhier/ntfy/issues/516), thanks to [@markosopcic](https://github.com/markosopcic))
|
||||||
|
* Android 13: Ask for permission to post notifications ([#508](https://github.com/binwiederhier/ntfy/issues/508))
|
||||||
|
* Android 13: Do not allow swiping away the foreground notification ([#521](https://github.com/binwiederhier/ntfy/issues/521), thanks to [@alexhorner](https://github.com/alexhorner) for reporting)
|
||||||
|
* Android 5 (SDK 21): Fix crash on unsubscribing ([#528](https://github.com/binwiederhier/ntfy/issues/528), thanks to Roger M.)
|
||||||
|
* Remove timestamp when copying message text ([#471](https://github.com/binwiederhier/ntfy/issues/471), thanks to [@wunter8](https://github.com/wunter8))
|
||||||
|
* Fix auto-delete if some icons do not exist anymore ([#506](https://github.com/binwiederhier/ntfy/issues/506))
|
||||||
|
* Fix notification icon color ([#480](https://github.com/binwiederhier/ntfy/issues/480), thanks to [@s-h-a-r-d](https://github.com/s-h-a-r-d) for reporting)
|
||||||
|
* Fix topics do not re-subscribe to Firebase after restoring from backup ([#511](https://github.com/binwiederhier/ntfy/issues/511))
|
||||||
|
* Fix crashes from large images ([#474](https://github.com/binwiederhier/ntfy/issues/474), thanks to [@daedric7](https://github.com/daedric7) for reporting)
|
||||||
|
* Fix notification click opens wrong subscription ([#261](https://github.com/binwiederhier/ntfy/issues/261), thanks to [@SMAW](https://github.com/SMAW) for reporting)
|
||||||
|
* Fix Firebase-only "link expired" issue ([#529](https://github.com/binwiederhier/ntfy/issues/529))
|
||||||
|
* Remove "Install .apk" feature in Google Play variant due to policy change ([#531](https://github.com/binwiederhier/ntfy/issues/531))
|
||||||
|
* Add donate button (no ticket)
|
||||||
|
|
||||||
|
**Additional translations:**
|
||||||
|
|
||||||
|
* Korean (thanks to [@YJSofta0f97461d82447ac](https://hosted.weblate.org/user/YJSofta0f97461d82447ac/))
|
||||||
|
* Portuguese (thanks to [@victormagalhaess](https://hosted.weblate.org/user/victormagalhaess/))
|
||||||
|
|
||||||
|
## ntfy server v1.29.1
|
||||||
|
Released November 17, 2022
|
||||||
|
|
||||||
|
This is mostly a bugfix release to address the high load on ntfy.sh. There are now two new options that allow
|
||||||
|
synchronous batch-writing of messages to the cache. This avoids database locking, and subsequent pileups of waiting
|
||||||
|
requests.
|
||||||
|
|
||||||
|
**Bug fixes:**
|
||||||
|
|
||||||
|
* High-load servers: Allow asynchronous batch-writing of messages to cache via `cache-batch-*` options ([#498](https://github.com/binwiederhier/ntfy/issues/498)/[#502](https://github.com/binwiederhier/ntfy/pull/502))
|
||||||
|
* Sender column in cache.db shows invalid IP ([#503](https://github.com/binwiederhier/ntfy/issues/503))
|
||||||
|
|
||||||
|
**Documentation:**
|
||||||
|
|
||||||
|
* GitHub Actions example ([#492](https://github.com/binwiederhier/ntfy/pull/492), thanks to [@ksurl](https://github.com/ksurl))
|
||||||
|
* UnifiedPush ACL clarification ([#497](https://github.com/binwiederhier/ntfy/issues/497), thanks to [@bt90](https://github.com/bt90))
|
||||||
|
* Install instructions for Kustomize ([#463](https://github.com/binwiederhier/ntfy/pull/463), thanks to [@l-maciej](https://github.com/l-maciej))
|
||||||
|
|
||||||
|
**Other things:**
|
||||||
|
|
||||||
|
* Put ntfy.sh docs on GitHub pages to reduce AWS outbound traffic cost ([#491](https://github.com/binwiederhier/ntfy/issues/491))
|
||||||
|
* The ntfy.sh server hardware was upgraded to a bigger box. If you'd like to help out carrying the server cost, **[sponsorships and donations](https://github.com/sponsors/binwiederhier)** 💸 would be very much appreciated
|
||||||
|
|
||||||
|
## ntfy server v1.29.0
|
||||||
|
Released November 12, 2022
|
||||||
|
|
||||||
|
This release adds the ability to add rate limit exemptions for IP ranges instead of just specific IP addresses. It also fixes
|
||||||
|
a few bugs in the web app and the CLI and adds lots of new examples and install instructions.
|
||||||
|
|
||||||
|
Thanks to [some love on HN](https://news.ycombinator.com/item?id=33517944), we got so many new ntfy users trying out ntfy
|
||||||
|
and joining the [chat rooms](https://github.com/binwiederhier/ntfy#chat--forum). **Welcome to the ntfy community to all of you!**
|
||||||
|
We also got a ton of new **[sponsors and donations](https://github.com/sponsors/binwiederhier)** 💸, which is amazing. I'd like to thank
|
||||||
|
all of you for believing in the project, and for helping me pay the server cost. The HN spike increased the AWS cost quite a bit.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Allow IP CIDRs in `visitor-request-limit-exempt-hosts` ([#423](https://github.com/binwiederhier/ntfy/issues/423), thanks to [@karmanyaahm](https://github.com/karmanyaahm))
|
||||||
|
|
||||||
|
**Bug fixes + maintenance:**
|
||||||
|
|
||||||
|
* Subscriptions can now have a display name ([#370](https://github.com/binwiederhier/ntfy/issues/370), thanks to [@tfheen](https://github.com/tfheen) for reporting)
|
||||||
|
* Bump Go version to Go 18.x ([#422](https://github.com/binwiederhier/ntfy/issues/422))
|
||||||
|
* Web: Strip trailing slash when subscribing ([#428](https://github.com/binwiederhier/ntfy/issues/428), thanks to [@raining1123](https://github.com/raining1123) for reporting, and [@wunter8](https://github.com/wunter8) for fixing)
|
||||||
|
* Web: Strip trailing slash after server URL in publish dialog ([#441](https://github.com/binwiederhier/ntfy/issues/441), thanks to [@wunter8](https://github.com/wunter8))
|
||||||
|
* Allow empty passwords in `client.yml` ([#374](https://github.com/binwiederhier/ntfy/issues/374), thanks to [@cyqsimon](https://github.com/cyqsimon) for reporting, and [@wunter8](https://github.com/wunter8) for fixing)
|
||||||
|
* `ntfy pub` will now use default username and password from `client.yml` ([#431](https://github.com/binwiederhier/ntfy/issues/431), thanks to [@wunter8](https://github.com/wunter8) for fixing)
|
||||||
|
* Make `ntfy sub` work with `NTFY_USER` env variable ([#447](https://github.com/binwiederhier/ntfy/pull/447), thanks to [SuperSandro2000](https://github.com/SuperSandro2000))
|
||||||
|
* Web: Disallow GET/HEAD requests with body in actions ([#468](https://github.com/binwiederhier/ntfy/issues/468), thanks to [@ollien](https://github.com/ollien))
|
||||||
|
|
||||||
|
**Documentation:**
|
||||||
|
|
||||||
|
* Updated developer docs, bump nodejs and go version ([#414](https://github.com/binwiederhier/ntfy/issues/414), thanks to [@YJSoft](https://github.com/YJSoft) for reporting)
|
||||||
|
* Officially document `?auth=..` query parameter ([#433](https://github.com/binwiederhier/ntfy/pull/433), thanks to [@wunter8](https://github.com/wunter8))
|
||||||
|
* Added Rundeck example ([#427](https://github.com/binwiederhier/ntfy/pull/427), thanks to [@demogorgonz](https://github.com/demogorgonz))
|
||||||
|
* Fix Debian installation instructions ([#237](https://github.com/binwiederhier/ntfy/issues/237), thanks to [@Joeharrison94](https://github.com/Joeharrison94) for reporting)
|
||||||
|
* Updated [example](https://ntfy.sh/docs/examples/#gatus) with official [Gatus](https://github.com/TwiN/gatus) integration (thanks to [@TwiN](https://github.com/TwiN))
|
||||||
|
* Added [Kubernetes install instructions](https://ntfy.sh/docs/install/#kubernetes) ([#452](https://github.com/binwiederhier/ntfy/pull/452), thanks to [@gmemstr](https://github.com/gmemstr))
|
||||||
|
* Added [additional NixOS links for self-hosting](https://ntfy.sh/docs/install/#nixos-nix) ([#462](https://github.com/binwiederhier/ntfy/pull/462), thanks to [@wamserma](https://github.com/wamserma))
|
||||||
|
* Added additional [more secure nginx config example](https://ntfy.sh/docs/config/#nginxapache2caddy) ([#451](https://github.com/binwiederhier/ntfy/pull/451), thanks to [SuperSandro2000](https://github.com/SuperSandro2000))
|
||||||
|
* Minor fixes in the config table ([#470](https://github.com/binwiederhier/ntfy/pull/470), thanks to [snh](https://github.com/snh))
|
||||||
|
* Fix broken link ([#476](https://github.com/binwiederhier/ntfy/pull/476), thanks to [@shuuji3](https://github.com/shuuji3))
|
||||||
|
|
||||||
|
**Additional translations:**
|
||||||
|
|
||||||
|
* Korean (thanks to [@YJSofta0f97461d82447ac](https://hosted.weblate.org/user/YJSofta0f97461d82447ac/))
|
||||||
|
|
||||||
|
**Sponsorships:**:
|
||||||
|
|
||||||
|
Thank you to the amazing folks who decided to [sponsor ntfy](https://github.com/sponsors/binwiederhier). Thank you for
|
||||||
|
helping carry the cost of the public server and developer licenses, and more importantly: Thank you for believing in ntfy!
|
||||||
|
You guys rock!
|
||||||
|
|
||||||
|
A list of all the sponsors can be found in the [README](https://github.com/binwiederhier/ntfy/blob/main/README.md).
|
||||||
|
|
||||||
|
## ntfy Android app v1.14.0
|
||||||
|
Released September 27, 2022
|
||||||
|
|
||||||
|
This release adds the ability to set a custom icon to each notification, as well as a display name to subscriptions. We
|
||||||
|
also moved the action buttons in the detail view to a more logical place, fixed a bunch of bugs, and added four more
|
||||||
|
languages. Hurray!
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Subscriptions can now have a display name ([#313](https://github.com/binwiederhier/ntfy/issues/313), thanks to [@wunter8](https://github.com/wunter8))
|
||||||
|
* Display name for UnifiedPush subscriptions ([#355](https://github.com/binwiederhier/ntfy/issues/355), thanks to [@wunter8](https://github.com/wunter8))
|
||||||
|
* Polling is now done with `since=<id>` API, which makes deduping easier ([#165](https://github.com/binwiederhier/ntfy/issues/165))
|
||||||
|
* Turned JSON stream deprecation banner into "Use WebSockets" banner (no ticket)
|
||||||
|
* Move action buttons in notification cards ([#236](https://github.com/binwiederhier/ntfy/issues/236), thanks to [@wunter8](https://github.com/wunter8))
|
||||||
|
* Icons can be set for each individual notification ([#126](https://github.com/binwiederhier/ntfy/issues/126), thanks to [@wunter8](https://github.com/wunter8))
|
||||||
|
|
||||||
|
**Bug fixes:**
|
||||||
|
|
||||||
|
* Long-click selecting of notifications doesn't scroll to the top anymore ([#235](https://github.com/binwiederhier/ntfy/issues/235), thanks to [@wunter8](https://github.com/wunter8))
|
||||||
|
* Add attachment and click URL extras to MESSAGE_RECEIVED broadcast ([#329](https://github.com/binwiederhier/ntfy/issues/329), thanks to [@wunter8](https://github.com/wunter8))
|
||||||
|
* Accessibility: Clear/choose service URL button in base URL dropdown now has a label ([#292](https://github.com/binwiederhier/ntfy/issues/292), thanks to [@mhameed](https://github.com/mhameed) for reporting)
|
||||||
|
|
||||||
|
**Additional translations:**
|
||||||
|
|
||||||
|
* Italian (thanks to [@Genio2003](https://hosted.weblate.org/user/Genio2003/))
|
||||||
|
* Dutch (thanks to [@SchoNie](https://hosted.weblate.org/user/SchoNie/))
|
||||||
|
* Ukranian (thanks to [@v.kopitsa](https://hosted.weblate.org/user/v.kopitsa/))
|
||||||
|
* Polish (thanks to [@Namax0r](https://hosted.weblate.org/user/Namax0r/))
|
||||||
|
|
||||||
|
Thank you to [@wunter8](https://github.com/wunter8) for proactively picking up some Android tickets, and fixing them! You rock!
|
||||||
|
|
||||||
|
## ntfy server v1.28.0
|
||||||
|
Released September 27, 2022
|
||||||
|
|
||||||
|
This release primarily adds icon support for the Android app, and adds a display name to subscriptions in the web app.
|
||||||
|
Aside from that, we fixed a few random bugs, most importantly the `Priority` header bug that allows the use behind
|
||||||
|
Cloudflare. We also added a ton of documentation. Most prominently, an [integrations + projects page](https://ntfy.sh/docs/integrations/).
|
||||||
|
|
||||||
|
As of now, I also have started accepting **[donations and sponsorships](https://github.com/sponsors/binwiederhier)** 💸.
|
||||||
|
I would be very humbled if you consider donating.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Subscription display name for the web app ([#348](https://github.com/binwiederhier/ntfy/pull/348))
|
||||||
|
* Allow setting socket permissions via `--listen-unix-mode` ([#356](https://github.com/binwiederhier/ntfy/pull/356), thanks to [@koro666](https://github.com/koro666))
|
||||||
|
* Icons can be set for each individual notification ([#126](https://github.com/binwiederhier/ntfy/issues/126), thanks to [@wunter8](https://github.com/wunter8))
|
||||||
|
* CLI: Allow default username/password in `client.yml` ([#372](https://github.com/binwiederhier/ntfy/pull/372), thanks to [@wunter8](https://github.com/wunter8))
|
||||||
|
* Build support for other Unix systems ([#393](https://github.com/binwiederhier/ntfy/pull/393), thanks to [@la-ninpre](https://github.com/la-ninpre))
|
||||||
|
|
||||||
|
**Bug fixes:**
|
||||||
|
|
||||||
|
* `ntfy user` commands don't work with `auth_file` but works with `auth-file` ([#344](https://github.com/binwiederhier/ntfy/issues/344), thanks to [@Histalek](https://github.com/Histalek) for reporting)
|
||||||
|
* Ignore new draft HTTP `Priority` header ([#351](https://github.com/binwiederhier/ntfy/issues/351), thanks to [@ksurl](https://github.com/ksurl) for reporting)
|
||||||
|
* Delete expired attachments based on mod time instead of DB entry to avoid races (no ticket)
|
||||||
|
* Better logging for Matrix push key errors ([#384](https://github.com/binwiederhier/ntfy/pull/384), thanks to [@christophehenry](https://github.com/christophehenry))
|
||||||
|
* Web: Switched "Pop" and "Pop Swoosh" sounds ([#352](https://github.com/binwiederhier/ntfy/issues/352), thanks to [@coma-toast](https://github.com/coma-toast) for reporting)
|
||||||
|
|
||||||
|
**Documentation:**
|
||||||
|
|
||||||
|
* Added [integrations + projects page](https://ntfy.sh/docs/integrations/) (**so many integrations, whoa!**)
|
||||||
|
* Added example for [UptimeRobot](https://ntfy.sh/docs/examples/#uptimerobot)
|
||||||
|
* Fix some PowerShell publish docs ([#345](https://github.com/binwiederhier/ntfy/pull/345), thanks to [@noahpeltier](https://github.com/noahpeltier))
|
||||||
|
* Clarified Docker install instructions ([#361](https://github.com/binwiederhier/ntfy/issues/361), thanks to [@barart](https://github.com/barart) for reporting)
|
||||||
|
* Mismatched quotation marks ([#392](https://github.com/binwiederhier/ntfy/pull/392)], thanks to [@connorlanigan](https://github.com/connorlanigan))
|
||||||
|
|
||||||
|
**Additional translations:**
|
||||||
|
|
||||||
|
* Ukranian (thanks to [@v.kopitsa](https://hosted.weblate.org/user/v.kopitsa/))
|
||||||
|
* Polish (thanks to [@Namax0r](https://hosted.weblate.org/user/Namax0r/))
|
||||||
|
|
||||||
|
## ntfy server v1.27.2
|
||||||
|
Released June 23, 2022
|
||||||
|
|
||||||
|
This release brings two new CLI options to wait for a command to finish, or for a PID to exit. It also adds more detail
|
||||||
|
to trace debug output. Aside from other bugs, it fixes a performance issue that occurred in large installations every
|
||||||
|
minute or so, due to competing stats gathering (personal installations will likely be unaffected by this).
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Add `cache-startup-queries` option to allow custom [SQLite performance tuning](config.md#wal-for-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)
|
||||||
|
* 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))
|
||||||
|
|
||||||
|
**Bug fixes:**
|
||||||
|
|
||||||
|
* Fix slow requests due to excessive locking ([#338](https://github.com/binwiederhier/ntfy/issues/338))
|
||||||
|
* Return HTTP 500 for `GET /_matrix/push/v1/notify` when `base-url` is not configured (no ticket)
|
||||||
|
* Disallow setting `upstream-base-url` to the same value as `base-url` ([#334](https://github.com/binwiederhier/ntfy/issues/334), thanks to [@oester](https://github.com/oester) for reporting)
|
||||||
|
* Fix `since=<id>` implementation for multiple topics ([#336](https://github.com/binwiederhier/ntfy/issues/336), thanks to [@karmanyaahm](https://github.com/karmanyaahm) for reporting)
|
||||||
|
* Simple parsing in `Actions` header now supports settings Android `intent=` key ([#341](https://github.com/binwiederhier/ntfy/pull/341), thanks to [@wunter8](https://github.com/wunter8))
|
||||||
|
|
||||||
|
**Deprecations:**
|
||||||
|
|
||||||
|
* The `ntfy publish --env-topic` option is deprecated as of now (see [deprecations](deprecations.md) for details)
|
||||||
|
|
||||||
|
## ntfy server v1.26.0
|
||||||
|
Released June 16, 2022
|
||||||
|
|
||||||
|
This release adds a Matrix Push Gateway directly into ntfy, to make self-hosting a Matrix server easier. The Windows
|
||||||
|
CLI is now available via Scoop, and ntfy is now natively supported in Uptime Kuma.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* ntfy now is a [Matrix Push Gateway](https://spec.matrix.org/v1.2/push-gateway-api/) (in combination with [UnifiedPush](https://unifiedpush.org) as the [Provider Push Protocol](https://unifiedpush.org/developers/gateway/), [#319](https://github.com/binwiederhier/ntfy/issues/319)/[#326](https://github.com/binwiederhier/ntfy/pull/326), thanks to [@MayeulC](https://github.com/MayeulC) for reporting)
|
||||||
|
* Windows CLI is now available via [Scoop](https://scoop.sh) ([ScoopInstaller#3594](https://github.com/ScoopInstaller/Main/pull/3594), [#311](https://github.com/binwiederhier/ntfy/pull/311), [#269](https://github.com/binwiederhier/ntfy/issues/269), thanks to [@kzshantonu](https://github.com/kzshantonu))
|
||||||
|
* [Uptime Kuma](https://github.com/louislam/uptime-kuma) now allows publishing to ntfy ([uptime-kuma#1674](https://github.com/louislam/uptime-kuma/pull/1674), thanks to [@philippdormann](https://github.com/philippdormann))
|
||||||
|
* Display ntfy version in `ntfy serve` command ([#314](https://github.com/binwiederhier/ntfy/issues/314), thanks to [@poblabs](https://github.com/poblabs))
|
||||||
|
|
||||||
|
**Bug fixes:**
|
||||||
|
|
||||||
|
* Web app: Show "notifications not supported" alert on HTTP ([#323](https://github.com/binwiederhier/ntfy/issues/323), thanks to [@milksteakjellybeans](https://github.com/milksteakjellybeans) for reporting)
|
||||||
|
* Use last address in `X-Forwarded-For` header as visitor address ([#328](https://github.com/binwiederhier/ntfy/issues/328))
|
||||||
|
|
||||||
|
**Documentation**
|
||||||
|
|
||||||
|
* Added [example](examples.md) for [Uptime Kuma](https://github.com/louislam/uptime-kuma) integration ([#315](https://github.com/binwiederhier/ntfy/pull/315), thanks to [@philippdormann](https://github.com/philippdormann))
|
||||||
|
* Fix Docker install instructions ([#320](https://github.com/binwiederhier/ntfy/issues/320), thanks to [@milksteakjellybeans](https://github.com/milksteakjellybeans) for reporting)
|
||||||
|
* Add clarifying comments to base-url ([#322](https://github.com/binwiederhier/ntfy/issues/322), thanks to [@milksteakjellybeans](https://github.com/milksteakjellybeans) for reporting)
|
||||||
|
* Update FAQ for iOS app ([#321](https://github.com/binwiederhier/ntfy/issues/321), thanks to [@milksteakjellybeans](https://github.com/milksteakjellybeans) for reporting)
|
||||||
|
|
||||||
|
## ntfy iOS app v1.2
|
||||||
|
Released June 16, 2022
|
||||||
|
|
||||||
|
This release adds support for authentication/authorization for self-hosted servers. It also allows you to
|
||||||
|
set your server as the default server for new topics.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Support for auth and user management ([#277](https://github.com/binwiederhier/ntfy/issues/277))
|
||||||
|
* Ability to add default server ([#295](https://github.com/binwiederhier/ntfy/issues/295))
|
||||||
|
|
||||||
|
**Bug fixes:**
|
||||||
|
|
||||||
|
* Add validation for selfhosted server URL ([#290](https://github.com/binwiederhier/ntfy/issues/290))
|
||||||
|
|
||||||
|
## ntfy server v1.25.2
|
||||||
|
Released June 2, 2022
|
||||||
|
|
||||||
|
This release adds the ability to set a log level to facilitate easier debugging of live systems. It also solves a
|
||||||
|
production problem with a few over-users that resulted in Firebase quota problems (only applying to the over-users).
|
||||||
|
We now block visitors from using Firebase if they trigger a quota exceeded response.
|
||||||
|
|
||||||
|
On top of that, we updated the Firebase SDK and are now building the release in GitHub Actions. We've also got two
|
||||||
|
more translations: Chinese/Simplified and Dutch.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Advanced logging, with different log levels and hot reloading of the log level ([#284](https://github.com/binwiederhier/ntfy/pull/284))
|
||||||
|
|
||||||
|
**Bugs**:
|
||||||
|
|
||||||
|
* Respect Firebase "quota exceeded" response for topics, block Firebase publishing for user for 10min ([#289](https://github.com/binwiederhier/ntfy/issues/289))
|
||||||
|
* Fix documentation header blue header due to mkdocs-material theme update (no ticket)
|
||||||
|
|
||||||
|
**Maintenance:**
|
||||||
|
|
||||||
|
* Upgrade Firebase Admin SDK to 4.x ([#274](https://github.com/binwiederhier/ntfy/issues/274))
|
||||||
|
* CI: Build from pipeline instead of locally ([#36](https://github.com/binwiederhier/ntfy/issues/36))
|
||||||
|
|
||||||
|
**Documentation**:
|
||||||
|
|
||||||
|
* ⚠️ [Privacy policy](privacy.md) updated to reflect additional debug/tracing feature (no ticket)
|
||||||
|
* [Examples](examples.md) for [Home Assistant](https://www.home-assistant.io/) ([#282](https://github.com/binwiederhier/ntfy/pull/282), thanks to [@poblabs](https://github.com/poblabs))
|
||||||
|
* Install instructions for [NixOS/Nix](https://ntfy.sh/docs/install/#nixos-nix) ([#282](https://github.com/binwiederhier/ntfy/pull/282), thanks to [@arjan-s](https://github.com/arjan-s))
|
||||||
|
* Clarify `poll_request` wording for [iOS push notifications](https://ntfy.sh/docs/config/#ios-instant-notifications) ([#300](https://github.com/binwiederhier/ntfy/issues/300), thanks to [@prabirshrestha](https://github.com/prabirshrestha) for reporting)
|
||||||
|
* Example for using ntfy with docker-compose.yml without root privileges ([#304](https://github.com/binwiederhier/ntfy/pull/304), thanks to [@ksurl](https://github.com/ksurl))
|
||||||
|
|
||||||
|
**Additional translations:**
|
||||||
|
|
||||||
|
* Chinese/Simplified (thanks to [@yufei.im](https://hosted.weblate.org/user/yufei.im/))
|
||||||
|
* Dutch (thanks to [@SchoNie](https://hosted.weblate.org/user/SchoNie/))
|
||||||
|
|
||||||
|
## ntfy iOS app v1.1
|
||||||
|
Released May 31, 2022
|
||||||
|
|
||||||
|
In this release of the iOS app, we add message priorities (mapped to iOS interruption levels), tags and emojis,
|
||||||
|
action buttons to open websites or perform HTTP requests (in the notification and the detail view), a custom click
|
||||||
|
action when the notification is tapped, and various other fixes.
|
||||||
|
|
||||||
|
It also adds support for self-hosted servers (albeit not supporting auth yet). The self-hosted server needs to be
|
||||||
|
configured to forward poll requests to upstream ntfy.sh for push notifications to work (see [iOS push notifications](https://ntfy.sh/docs/config/#ios-instant-notifications)
|
||||||
|
for details).
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* [Message priority](https://ntfy.sh/docs/publish/#message-priority) support (no ticket)
|
||||||
|
* [Tags/emojis](https://ntfy.sh/docs/publish/#tags-emojis) support (no ticket)
|
||||||
|
* [Action buttons](https://ntfy.sh/docs/publish/#action-buttons) support (no ticket)
|
||||||
|
* [Click action](https://ntfy.sh/docs/publish/#click-action) support (no ticket)
|
||||||
|
* Open topic when notification clicked (no ticket)
|
||||||
|
* Notification now makes a sound and vibrates (no ticket)
|
||||||
|
* Cancel notifications when navigating to topic (no ticket)
|
||||||
|
* iOS 14.0 support (no ticket, [PR#1](https://github.com/binwiederhier/ntfy-ios/pull/1), thanks to [@callum-99](https://github.com/callum-99))
|
||||||
|
|
||||||
|
**Bug fixes:**
|
||||||
|
|
||||||
|
* iOS UI not always updating properly ([#267](https://github.com/binwiederhier/ntfy/issues/267))
|
||||||
|
|
||||||
|
## ntfy server v1.24.0
|
||||||
|
Released May 28, 2022
|
||||||
|
|
||||||
|
This release of the ntfy server brings supporting features for the ntfy iOS app. Most importantly, it
|
||||||
|
enables support for self-hosted servers in combination with the iOS app. This is to overcome the restrictive
|
||||||
|
Apple development environment.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Regularly send Firebase keepalive messages to ~poll topic to support self-hosted servers (no ticket)
|
||||||
|
* Add subscribe filter to query exact messages by ID (no ticket)
|
||||||
|
* Support for `poll_request` messages to support [iOS push notifications](https://ntfy.sh/docs/config/#ios-instant-notifications) for self-hosted servers (no ticket)
|
||||||
|
|
||||||
|
**Bug fixes:**
|
||||||
|
|
||||||
|
* Support emails without `Content-Type` ([#265](https://github.com/binwiederhier/ntfy/issues/265), thanks to [@dmbonsall](https://github.com/dmbonsall))
|
||||||
|
|
||||||
|
**Additional translations:**
|
||||||
|
|
||||||
|
* Italian (thanks to [@Genio2003](https://hosted.weblate.org/user/Genio2003/))
|
||||||
|
|
||||||
|
## ntfy iOS app v1.0
|
||||||
|
Released May 25, 2022
|
||||||
|
|
||||||
|
This is the first version of the ntfy iOS app. It supports only ntfy.sh (no selfhosted servers) and only messages + title
|
||||||
|
(no priority, tags, attachments, ...). I'll rapidly add (hopefully) most of the other ntfy features, and then I'll focus
|
||||||
|
on self-hosted servers.
|
||||||
|
|
||||||
|
The app is now available in the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).
|
||||||
|
|
||||||
|
**Tickets:**
|
||||||
|
|
||||||
|
* iOS app ([#4](https://github.com/binwiederhier/ntfy/issues/4), see also: [TestFlight summary](https://github.com/binwiederhier/ntfy/issues/4#issuecomment-1133767150))
|
||||||
|
|
||||||
|
**Thanks:**
|
||||||
|
|
||||||
|
* Thank you to all the testers who tried out the app. You guys gave me the confidence that it's ready to release (albeit with
|
||||||
|
some known issues which will be addressed in follow-up releases).
|
||||||
|
|
||||||
|
## ntfy server v1.23.0
|
||||||
|
Released May 21, 2022
|
||||||
|
|
||||||
|
This release ships a CLI for Windows and macOS, as well as the ability to disable the web app entirely. On top of that,
|
||||||
|
it adds support for APNs, the iOS messaging service. This is needed for the (soon to be released) iOS app.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* [Windows](https://ntfy.sh/docs/install/#windows) and [macOS](https://ntfy.sh/docs/install/#macos) builds for the [ntfy CLI](https://ntfy.sh/docs/subscribe/cli/) ([#112](https://github.com/binwiederhier/ntfy/issues/112))
|
||||||
|
* Ability to disable the web app entirely ([#238](https://github.com/binwiederhier/ntfy/issues/238)/[#249](https://github.com/binwiederhier/ntfy/pull/249), thanks to [@Curid](https://github.com/Curid))
|
||||||
|
* Add APNs config to Firebase messages to support [iOS app](https://github.com/binwiederhier/ntfy/issues/4) ([#247](https://github.com/binwiederhier/ntfy/pull/247), thanks to [@Copephobia](https://github.com/Copephobia))
|
||||||
|
|
||||||
|
**Bug fixes:**
|
||||||
|
|
||||||
|
* Support underscores in server.yml config options ([#255](https://github.com/binwiederhier/ntfy/issues/255), thanks to [@ajdelgado](https://github.com/ajdelgado))
|
||||||
|
* Force MAKEFLAGS to --jobs=1 in `Makefile` ([#257](https://github.com/binwiederhier/ntfy/pull/257), thanks to [@oddlama](https://github.com/oddlama))
|
||||||
|
|
||||||
|
**Documentation:**
|
||||||
|
|
||||||
|
* Typo in install instructions ([#252](https://github.com/binwiederhier/ntfy/pull/252)/[#251](https://github.com/binwiederhier/ntfy/issues/251), thanks to [@oddlama](https://github.com/oddlama))
|
||||||
|
* Fix typo in private server example ([#262](https://github.com/binwiederhier/ntfy/pull/262), thanks to [@MayeulC](https://github.com/MayeulC))
|
||||||
|
* [Examples](examples.md) for [jellyseerr](https://github.com/Fallenbagel/jellyseerr)/[overseerr](https://overseerr.dev/) ([#264](https://github.com/binwiederhier/ntfy/pull/264), thanks to [@Fallenbagel](https://github.com/Fallenbagel))
|
||||||
|
|
||||||
|
**Additional translations:**
|
||||||
|
|
||||||
|
* Portuguese/Brazil (thanks to [@tiagotriques](https://hosted.weblate.org/user/tiagotriques/) and [@pireshenrique22](https://hosted.weblate.org/user/pireshenrique22/))
|
||||||
|
|
||||||
|
Thank you to the many translators, who helped translate the new strings so quickly. I am humbled and amazed by your help.
|
||||||
|
|
||||||
|
## ntfy Android app v1.13.0
|
||||||
|
Released May 11, 2022
|
||||||
|
|
||||||
|
This release brings a slightly altered design for the detail view, featuring a card layout to make notifications more easily
|
||||||
|
distinguishable from one another. It also ships per-topic settings that allow overriding minimum priority, auto delete threshold
|
||||||
|
and custom icons. Aside from that, we've got tons of bug fixes as usual.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Per-subscription settings, custom subscription icons ([#155](https://github.com/binwiederhier/ntfy/issues/155), thanks to [@mztiq](https://github.com/mztiq) for reporting)
|
||||||
|
* Cards in notification detail view ([#175](https://github.com/binwiederhier/ntfy/issues/175), thanks to [@cmeis](https://github.com/cmeis) for reporting)
|
||||||
|
|
||||||
|
**Bug fixes:**
|
||||||
|
|
||||||
* Accurate naming of "mute notifications" from "pause notifications" ([#224](https://github.com/binwiederhier/ntfy/issues/224), thanks to [@shadow00](https://github.com/shadow00) for reporting)
|
* Accurate naming of "mute notifications" from "pause notifications" ([#224](https://github.com/binwiederhier/ntfy/issues/224), thanks to [@shadow00](https://github.com/shadow00) for reporting)
|
||||||
* Make messages with links selectable ([#226](https://github.com/binwiederhier/ntfy/issues/226), thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting)
|
* Make messages with links selectable ([#226](https://github.com/binwiederhier/ntfy/issues/226), thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting)
|
||||||
@@ -18,41 +415,53 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
|||||||
* Fix app icon on old Android versions ([#128](https://github.com/binwiederhier/ntfy/issues/128), thanks to [@shadow00](https://github.com/shadow00) for reporting)
|
* Fix app icon on old Android versions ([#128](https://github.com/binwiederhier/ntfy/issues/128), thanks to [@shadow00](https://github.com/shadow00) for reporting)
|
||||||
* Fix races in UnifiedPush registration ([#230](https://github.com/binwiederhier/ntfy/issues/230), thanks to @Jakob for reporting)
|
* Fix races in UnifiedPush registration ([#230](https://github.com/binwiederhier/ntfy/issues/230), thanks to @Jakob for reporting)
|
||||||
* Prevent view action from crashing the app ([#233](https://github.com/binwiederhier/ntfy/issues/233))
|
* Prevent view action from crashing the app ([#233](https://github.com/binwiederhier/ntfy/issues/233))
|
||||||
|
* Prevent long topic names and icons from overlapping ([#240](https://github.com/binwiederhier/ntfy/issues/240), thanks to [@cmeis](https://github.com/cmeis) for reporting)
|
||||||
|
|
||||||
**Thanks for testing:**
|
**Additional translations:**
|
||||||
|
|
||||||
Thanks to [@cmeis](https://github.com/cmeis), [@StoyanDimitrov](https://github.com/StoyanDimitrov), [@Fallenbagel](https://github.com/Fallenbagel) for testing, and
|
* Dutch (*incomplete*, thanks to [@diony](https://hosted.weblate.org/user/diony/))
|
||||||
to [@Joeharrison94](https://github.com/Joeharrison94) for the input.
|
|
||||||
|
|
||||||
## ntfy server v1.22.0 (UNRELEASED)
|
**Thank you:**
|
||||||
|
|
||||||
|
Thanks to [@cmeis](https://github.com/cmeis), [@StoyanDimitrov](https://github.com/StoyanDimitrov), [@Fallenbagel](https://github.com/Fallenbagel) for testing, and
|
||||||
|
to [@Joeharrison94](https://github.com/Joeharrison94) for the input. And thank you very much to all the translators for catching up so quickly.
|
||||||
|
|
||||||
|
## ntfy server v1.22.0
|
||||||
|
Released May 7, 2022
|
||||||
|
|
||||||
|
This release makes the web app more accessible to people with disabilities, and introduces a "mark as read" icon in the web app.
|
||||||
|
It also fixes a curious bug with WebSockets and Apache and makes the notification sounds in the web app a little quieter.
|
||||||
|
|
||||||
|
We've also improved the documentation a little and added translations for three more languages.
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
|
||||||
* Better parsing of the user actions, allowing quotes (no ticket)
|
|
||||||
* Make web app more accessible ([#217](https://github.com/binwiederhier/ntfy/issues/217))
|
* Make web app more accessible ([#217](https://github.com/binwiederhier/ntfy/issues/217))
|
||||||
|
* Better parsing of the user actions, allowing quotes (no ticket)
|
||||||
|
* Add "mark as read" icon button to notification ([#243](https://github.com/binwiederhier/ntfy/pull/243), thanks to [@wunter8](https://github.com/wunter8))
|
||||||
|
|
||||||
**Bugs:**
|
**Bug fixes:**
|
||||||
|
|
||||||
* `Upgrade` header check is now case in-sensitive ([#228](https://github.com/binwiederhier/ntfy/issues/228), thanks to [@wunter8](https://github.com/wunter8) for finding it)
|
* `Upgrade` header check is now case in-sensitive ([#228](https://github.com/binwiederhier/ntfy/issues/228), thanks to [@wunter8](https://github.com/wunter8) for finding it)
|
||||||
* Made web app sounds quieter ([#222](https://github.com/binwiederhier/ntfy/issues/222))
|
* Made web app sounds quieter ([#222](https://github.com/binwiederhier/ntfy/issues/222))
|
||||||
* Add "private browsing"-specific error message for Firefox/Safari ([#208](https://github.com/binwiederhier/ntfy/issues/208), thanks to [@julianfoad](https://github.com/julianfoad) for reporting)
|
* Add "private browsing"-specific error message for Firefox/Safari ([#208](https://github.com/binwiederhier/ntfy/issues/208), thanks to [@julianfoad](https://github.com/julianfoad) for reporting)
|
||||||
|
|
||||||
**Documentation:**
|
**Documentation:**
|
||||||
|
|
||||||
* Improved caddy configuration (no ticket, thanks to @Stnby)
|
* Improved caddy configuration (no ticket, thanks to @Stnby)
|
||||||
* Additional multi-line examples on the [publish page](https://ntfy.sh/docs/publish/) ([#234](https://github.com/binwiederhier/ntfy/pull/234), thanks to [@aTable](https://github.com/aTable))
|
* Additional multi-line examples on the [publish page](https://ntfy.sh/docs/publish/) ([#234](https://github.com/binwiederhier/ntfy/pull/234), thanks to [@aTable](https://github.com/aTable))
|
||||||
|
* Fixed PowerShell auth example to use UTF-8 ([#242](https://github.com/binwiederhier/ntfy/pull/242), thanks to [@SMAW](https://github.com/SMAW))
|
||||||
|
|
||||||
**Additional translations:**
|
**Additional translations:**
|
||||||
|
|
||||||
* Czech (thanks to [@waclaw66](https://hosted.weblate.org/user/waclaw66/))
|
* Czech (thanks to [@waclaw66](https://hosted.weblate.org/user/waclaw66/))
|
||||||
* French (thanks to [@nathanaelhoun](https://hosted.weblate.org/user/nathanaelhoun/))
|
* French (thanks to [@nathanaelhoun](https://hosted.weblate.org/user/nathanaelhoun/))
|
||||||
|
* Hungarian (thanks to [@agocsdaniel](https://hosted.weblate.org/user/agocsdaniel/))
|
||||||
|
|
||||||
**Thanks for testing:**
|
**Thanks for testing:**
|
||||||
|
|
||||||
Thanks to [@wunter8](https://github.com/wunter8) for testing.
|
Thanks to [@wunter8](https://github.com/wunter8) for testing.
|
||||||
|
|
||||||
-->
|
|
||||||
|
|
||||||
## ntfy Android app v1.12.0
|
## ntfy Android app v1.12.0
|
||||||
Released Apr 25, 2022
|
Released Apr 25, 2022
|
||||||
|
|
||||||
@@ -73,7 +482,7 @@ languages and fixed a ton of bugs.
|
|||||||
thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting)
|
thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting)
|
||||||
* Channel settings option to configure DND override, sounds, etc. ([#91](https://github.com/binwiederhier/ntfy/issues/91))
|
* Channel settings option to configure DND override, sounds, etc. ([#91](https://github.com/binwiederhier/ntfy/issues/91))
|
||||||
|
|
||||||
**Bugs:**
|
**Bug fixes:**
|
||||||
|
|
||||||
* Validate URLs when changing default server and server in user management ([#193](https://github.com/binwiederhier/ntfy/issues/193),
|
* Validate URLs when changing default server and server in user management ([#193](https://github.com/binwiederhier/ntfy/issues/193),
|
||||||
thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting)
|
thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting)
|
||||||
@@ -114,7 +523,7 @@ Limited support is available in the web app.
|
|||||||
* Added ARMv6 build ([#200](https://github.com/binwiederhier/ntfy/issues/200), thanks to [@jcrubioa](https://github.com/jcrubioa) for reporting)
|
* Added ARMv6 build ([#200](https://github.com/binwiederhier/ntfy/issues/200), thanks to [@jcrubioa](https://github.com/jcrubioa) for reporting)
|
||||||
* Web app internationalization support 🇧🇬 🇩🇪 🇺🇸 🌎 ([#189](https://github.com/binwiederhier/ntfy/issues/189))
|
* Web app internationalization support 🇧🇬 🇩🇪 🇺🇸 🌎 ([#189](https://github.com/binwiederhier/ntfy/issues/189))
|
||||||
|
|
||||||
**Bugs:**
|
**Bug fixes:**
|
||||||
|
|
||||||
* Web app: English language strings fixes, additional descriptions for settings ([#203](https://github.com/binwiederhier/ntfy/issues/203), thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov))
|
* Web app: English language strings fixes, additional descriptions for settings ([#203](https://github.com/binwiederhier/ntfy/issues/203), thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov))
|
||||||
* Web app: Show error message snackbar when sending test notification fails ([#205](https://github.com/binwiederhier/ntfy/issues/205), thanks to [@cmeis](https://github.com/cmeis))
|
* Web app: Show error message snackbar when sending test notification fails ([#205](https://github.com/binwiederhier/ntfy/issues/205), thanks to [@cmeis](https://github.com/cmeis))
|
||||||
@@ -154,7 +563,7 @@ Released Apr 7, 2022
|
|||||||
* Translations to different languages ([#188](https://github.com/binwiederhier/ntfy/issues/188), thanks to
|
* Translations to different languages ([#188](https://github.com/binwiederhier/ntfy/issues/188), thanks to
|
||||||
[@StoyanDimitrov](https://github.com/StoyanDimitrov) for initiating things)
|
[@StoyanDimitrov](https://github.com/StoyanDimitrov) for initiating things)
|
||||||
|
|
||||||
**Bugs:**
|
**Bug fixes:**
|
||||||
|
|
||||||
* IllegalStateException: Failed to build unique file ([#177](https://github.com/binwiederhier/ntfy/issues/177), thanks to [@Fallenbagel](https://github.com/Fallenbagel) for reporting)
|
* IllegalStateException: Failed to build unique file ([#177](https://github.com/binwiederhier/ntfy/issues/177), thanks to [@Fallenbagel](https://github.com/Fallenbagel) for reporting)
|
||||||
* SQLiteConstraintException: Crash during UP registration ([#185](https://github.com/binwiederhier/ntfy/issues/185))
|
* SQLiteConstraintException: Crash during UP registration ([#185](https://github.com/binwiederhier/ntfy/issues/185))
|
||||||
@@ -188,7 +597,7 @@ Released Apr 6, 2022
|
|||||||
|
|
||||||
* Added message bar and publish dialog ([#196](https://github.com/binwiederhier/ntfy/issues/196))
|
* Added message bar and publish dialog ([#196](https://github.com/binwiederhier/ntfy/issues/196))
|
||||||
|
|
||||||
**Bugs:**
|
**Bug fixes:**
|
||||||
|
|
||||||
* Added `EXPOSE 80/tcp` to Dockerfile to support auto-discovery in [Traefik](https://traefik.io/) ([#195](https://github.com/binwiederhier/ntfy/issues/195), thanks to [@s-h-a-r-d](https://github.com/s-h-a-r-d))
|
* Added `EXPOSE 80/tcp` to Dockerfile to support auto-discovery in [Traefik](https://traefik.io/) ([#195](https://github.com/binwiederhier/ntfy/issues/195), thanks to [@s-h-a-r-d](https://github.com/s-h-a-r-d))
|
||||||
|
|
||||||
@@ -204,7 +613,7 @@ Released Apr 6, 2022
|
|||||||
## ntfy server v1.19.0
|
## ntfy server v1.19.0
|
||||||
Released Mar 30, 2022
|
Released Mar 30, 2022
|
||||||
|
|
||||||
**Bugs:**
|
**Bug fixes:**
|
||||||
|
|
||||||
* Do not pack binary with `upx` for armv7/arm64 due to `illegal instruction` errors ([#191](https://github.com/binwiederhier/ntfy/issues/191), thanks to [@iexos](https://github.com/iexos))
|
* Do not pack binary with `upx` for armv7/arm64 due to `illegal instruction` errors ([#191](https://github.com/binwiederhier/ntfy/issues/191), thanks to [@iexos](https://github.com/iexos))
|
||||||
* Do not allow comma in topic name in publish via GET endpoint (no ticket)
|
* Do not allow comma in topic name in publish via GET endpoint (no ticket)
|
||||||
|
|||||||
9
docs/static/css/extra.css
vendored
@@ -1,4 +1,4 @@
|
|||||||
:root {
|
:root > * {
|
||||||
--md-primary-fg-color: #338574;
|
--md-primary-fg-color: #338574;
|
||||||
--md-primary-fg-color--light: #338574;
|
--md-primary-fg-color--light: #338574;
|
||||||
--md-primary-fg-color--dark: #338574;
|
--md-primary-fg-color--dark: #338574;
|
||||||
@@ -8,8 +8,8 @@
|
|||||||
width: unset !important;
|
width: unset !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-sidebar {
|
.md-header__topic:first-child {
|
||||||
width: 12.5rem !important;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-typeset h4 {
|
.md-typeset h4 {
|
||||||
@@ -60,7 +60,8 @@ figure video {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.screenshots img {
|
.screenshots img {
|
||||||
height: 230px;
|
max-height: 230px;
|
||||||
|
max-width: 300px;
|
||||||
margin: 3px;
|
margin: 3px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
filter: drop-shadow(2px 2px 2px #ddd);
|
filter: drop-shadow(2px 2px 2px #ddd);
|
||||||
|
|||||||
BIN
docs/static/img/android-screenshot-icon.png
vendored
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
docs/static/img/rundeck.png
vendored
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
docs/static/img/uptimekuma-ios-down.jpg
vendored
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
docs/static/img/uptimekuma-ios-test.jpg
vendored
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
docs/static/img/uptimekuma-ios-up.jpg
vendored
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
docs/static/img/uptimekuma-settings.png
vendored
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
docs/static/img/uptimekuma-setup.png
vendored
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
docs/static/img/uptimerobot-setup.jpg
vendored
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
docs/static/img/uptimerobot-test.jpg
vendored
Normal file
|
After Width: | Height: | Size: 27 KiB |
8
docs/static/js/extra.js
vendored
@@ -1,8 +1,8 @@
|
|||||||
// 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 savedTab = localStorage.getItem('savedTab')
|
const savedCodeTab = localStorage.getItem('savedTab')
|
||||||
const tabs = document.querySelectorAll(".tabbed-set > input")
|
const codeTabs = document.querySelectorAll(".tabbed-set > input")
|
||||||
for (const tab of tabs) {
|
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
|
||||||
@@ -25,7 +25,7 @@ for (const tab of tabs) {
|
|||||||
// Select saved tab
|
// Select saved tab
|
||||||
const current = document.querySelector(`label[for=${tab.id}]`)
|
const current = document.querySelector(`label[for=${tab.id}]`)
|
||||||
const labelContent = current.innerHTML
|
const labelContent = current.innerHTML
|
||||||
if (savedTab === labelContent) {
|
if (savedCodeTab === labelContent) {
|
||||||
tab.checked = true
|
tab.checked = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ recommended way to subscribe to a topic**. The notable exception is JavaScript,
|
|||||||
### Subscribe as SSE stream
|
### Subscribe as SSE stream
|
||||||
Using [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) in JavaScript, you can consume
|
Using [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) in JavaScript, you can consume
|
||||||
notifications via a [Server-Sent Events (SSE)](https://en.wikipedia.org/wiki/Server-sent_events) stream. It's incredibly
|
notifications via a [Server-Sent Events (SSE)](https://en.wikipedia.org/wiki/Server-sent_events) stream. It's incredibly
|
||||||
easy to use. Here's what it looks like. You may also want to check out the [live example](/example.html).
|
easy to use. Here's what it looks like. You may also want to check out the [full example on GitHub](https://github.com/binwiederhier/ntfy/tree/main/examples/web-example-eventsource).
|
||||||
|
|
||||||
=== "Command line (curl)"
|
=== "Command line (curl)"
|
||||||
```
|
```
|
||||||
@@ -267,7 +267,7 @@ curl -s "ntfy.sh/mytopic/json?poll=1&sched=1"
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Filter messages
|
### Filter messages
|
||||||
You can filter which messages are returned based on the well-known message fields `message`, `title`, `priority` and
|
You can filter which messages are returned based on the well-known message fields `id`, `message`, `title`, `priority` and
|
||||||
`tags`. Here's an example that only returns messages of high or urgent priority that contains the both tags
|
`tags`. Here's an example that only returns messages of high or urgent priority that contains the both tags
|
||||||
"zfs-error" and "error". Note that the `priority` filter is a logical OR and the `tags` filter is a logical AND.
|
"zfs-error" and "error". Note that the `priority` filter is a logical OR and the `tags` filter is a logical AND.
|
||||||
|
|
||||||
@@ -280,12 +280,13 @@ $ curl "ntfy.sh/alerts/json?priority=high&tags=zfs-error"
|
|||||||
|
|
||||||
Available filters (all case-insensitive):
|
Available filters (all case-insensitive):
|
||||||
|
|
||||||
| Filter variable | Alias | Example | Description |
|
| Filter variable | Alias | Example | Description |
|
||||||
|-----------------|---------------------------|------------------------------------|-------------------------------------------------------------------------|
|
|-----------------|---------------------------|-----------------------------------------------|-------------------------------------------------------------------------|
|
||||||
| `message` | `X-Message`, `m` | `ntfy.sh/mytopic?message=lalala` | Only return messages that match this exact message string |
|
| `id` | `X-ID` | `ntfy.sh/mytopic/json?poll=1&id=pbkiz8SD7ZxG` | Only return messages that match this exact message ID |
|
||||||
| `title` | `X-Title`, `t` | `ntfy.sh/mytopic?title=some+title` | Only return messages that match this exact title string |
|
| `message` | `X-Message`, `m` | `ntfy.sh/mytopic/json?message=lalala` | Only return messages that match this exact message string |
|
||||||
| `priority` | `X-Priority`, `prio`, `p` | `ntfy.sh/mytopic?p=high,urgent` | Only return messages that match *any priority listed* (comma-separated) |
|
| `title` | `X-Title`, `t` | `ntfy.sh/mytopic/json?title=some+title` | Only return messages that match this exact title string |
|
||||||
| `tags` | `X-Tags`, `tag`, `ta` | `ntfy.sh/mytopic?tags=error,alert` | Only return messages that match *all listed tags* (comma-separated) |
|
| `priority` | `X-Priority`, `prio`, `p` | `ntfy.sh/mytopic/json?p=high,urgent` | Only return messages that match *any priority listed* (comma-separated) |
|
||||||
|
| `tags` | `X-Tags`, `tag`, `ta` | `ntfy.sh/mytopic?/jsontags=error,alert` | Only return messages that match *all listed tags* (comma-separated) |
|
||||||
|
|
||||||
### Subscribe to multiple topics
|
### Subscribe to multiple topics
|
||||||
It's possible to subscribe to multiple topics in one HTTP call by providing a comma-separated list of topics
|
It's possible to subscribe to multiple topics in one HTTP call by providing a comma-separated list of topics
|
||||||
@@ -301,13 +302,12 @@ $ curl -s ntfy.sh/mytopic1,mytopic2/json
|
|||||||
### 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
|
||||||
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 use [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication)
|
To publish/subscribe to protected topics, you can:
|
||||||
with a valid username/password. For your self-hosted server, **be sure to use HTTPS to avoid eavesdropping** and exposing
|
|
||||||
your password.
|
|
||||||
|
|
||||||
```
|
* Use [basic auth](../publish.md#basic-auth), e.g. `Authorization: Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk`
|
||||||
curl -u phil:mypass -s "https://ntfy.example.com/mytopic/json"
|
* 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.
|
||||||
|
|
||||||
## JSON message format
|
## JSON message format
|
||||||
Both the [`/json` endpoint](#subscribe-as-json-stream) and the [`/sse` endpoint](#subscribe-as-sse-stream) return a JSON
|
Both the [`/json` endpoint](#subscribe-as-json-stream) and the [`/sse` endpoint](#subscribe-as-sse-stream) return a JSON
|
||||||
@@ -315,18 +315,19 @@ format of the message. It's very straight forward:
|
|||||||
|
|
||||||
**Message**:
|
**Message**:
|
||||||
|
|
||||||
| Field | Required | Type | Example | Description |
|
| Field | Required | Type | Example | Description |
|
||||||
|--------------|----------|---------------------------------------------------|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------|
|
|--------------|----------|---------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
|
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
|
||||||
| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp |
|
| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp |
|
||||||
| `event` | ✔️ | `open`, `keepalive`, `message`, or `poll_request` | `message` | Message type, typically you'd be only interested in `message` |
|
| `event` | ✔️ | `open`, `keepalive`, `message`, or `poll_request` | `message` | Message type, typically you'd be only interested in `message` |
|
||||||
| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events |
|
| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events |
|
||||||
| `message` | - | *string* | `Some message` | Message body; always present in `message` events |
|
| `message` | - | *string* | `Some message` | Message body; always present in `message` events |
|
||||||
| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/<topic>` |
|
| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/<topic>` |
|
||||||
| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis |
|
| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis |
|
||||||
| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
||||||
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) |
|
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) |
|
||||||
| `attachment` | - | *JSON object* | *see below* | Details about an attachment (name, URL, size, ...) |
|
| `actions` | - | *JSON array* | *see [actions buttons](../publish.md#action-buttons)* | [Action buttons](../publish.md#action-buttons) that can be displayed in the notification |
|
||||||
|
| `attachment` | - | *JSON object* | *see below* | Details about an attachment (name, URL, size, ...) |
|
||||||
|
|
||||||
**Attachment** (part of the message, see [attachments](../publish.md#attachments) for details):
|
**Attachment** (part of the message, see [attachments](../publish.md#attachments) for details):
|
||||||
|
|
||||||
@@ -416,6 +417,7 @@ and can be passed as **HTTP headers** or **query parameters in the URL**. They a
|
|||||||
| `poll` | `X-Poll`, `po` | Return cached messages and close connection |
|
| `poll` | `X-Poll`, `po` | Return cached messages and close connection |
|
||||||
| `since` | `X-Since`, `si` | Return cached messages since timestamp, duration or message ID |
|
| `since` | `X-Since`, `si` | Return cached messages since timestamp, duration or message ID |
|
||||||
| `scheduled` | `X-Scheduled`, `sched` | Include scheduled/delayed messages in message list |
|
| `scheduled` | `X-Scheduled`, `sched` | Include scheduled/delayed messages in message list |
|
||||||
|
| `id` | `X-ID` | Filter: Only return messages that match this exact message ID |
|
||||||
| `message` | `X-Message`, `m` | Filter: Only return messages that match this exact message string |
|
| `message` | `X-Message`, `m` | Filter: Only return messages that match this exact message string |
|
||||||
| `title` | `X-Title`, `t` | Filter: Only return messages that match this exact title string |
|
| `title` | `X-Title`, `t` | Filter: Only return messages that match this exact title string |
|
||||||
| `priority` | `X-Priority`, `prio`, `p` | Filter: Only return messages that match *any priority listed* (comma-separated) |
|
| `priority` | `X-Priority`, `prio`, `p` | Filter: Only return messages that match *any priority listed* (comma-separated) |
|
||||||
|
|||||||
@@ -56,6 +56,71 @@ quick ones:
|
|||||||
ntfy pub mywebhook
|
ntfy pub mywebhook
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Attaching a local file
|
||||||
|
You can easily upload and attach a local file to a notification:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ ntfy pub --file README.md mytopic | jq .
|
||||||
|
{
|
||||||
|
"id": "meIlClVLABJQ",
|
||||||
|
"time": 1655825460,
|
||||||
|
"event": "message",
|
||||||
|
"topic": "mytopic",
|
||||||
|
"message": "You received a file: README.md",
|
||||||
|
"attachment": {
|
||||||
|
"name": "README.md",
|
||||||
|
"type": "text/plain; charset=utf-8",
|
||||||
|
"size": 2892,
|
||||||
|
"expires": 1655836260,
|
||||||
|
"url": "https://ntfy.sh/file/meIlClVLABJQ.txt"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wait for PID/command
|
||||||
|
If you have a long-running command and want to **publish a notification when the command completes**,
|
||||||
|
you may wrap it with `ntfy publish --wait-cmd` (aliases: `--cmd`, `--done`). Or, if you forgot to wrap it, and the
|
||||||
|
command is already running, you can wait for the process to complete with `ntfy publish --wait-pid` (alias: `--pid`).
|
||||||
|
|
||||||
|
Run a command and wait for it to complete (here: `rsync ...`):
|
||||||
|
|
||||||
|
```
|
||||||
|
$ ntfy pub --wait-cmd mytopic rsync -av ./ root@example.com:/backups/ | jq .
|
||||||
|
{
|
||||||
|
"id": "Re0rWXZQM8WB",
|
||||||
|
"time": 1655825624,
|
||||||
|
"event": "message",
|
||||||
|
"topic": "mytopic",
|
||||||
|
"message": "Command succeeded after 56.553s: rsync -av ./ root@example.com:/backups/"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or, if you already started the long-running process and want to wait for it using its process ID (PID), you can do this:
|
||||||
|
|
||||||
|
=== "Using a PID directly"
|
||||||
|
```
|
||||||
|
$ ntfy pub --wait-pid 8458 mytopic | jq .
|
||||||
|
{
|
||||||
|
"id": "orM6hJKNYkWb",
|
||||||
|
"time": 1655825827,
|
||||||
|
"event": "message",
|
||||||
|
"topic": "mytopic",
|
||||||
|
"message": "Process with PID 8458 exited after 2.003s"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Using a `pidof`"
|
||||||
|
```
|
||||||
|
$ ntfy pub --wait-pid $(pidof rsync) mytopic | jq .
|
||||||
|
{
|
||||||
|
"id": "orM6hJKNYkWb",
|
||||||
|
"time": 1655825827,
|
||||||
|
"event": "message",
|
||||||
|
"topic": "mytopic",
|
||||||
|
"message": "Process with PID 8458 exited after 2.003s"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Subscribe to topics
|
## Subscribe to topics
|
||||||
You can subscribe to topics using `ntfy subscribe`. Depending on how it is called, this command
|
You can subscribe to topics using `ntfy subscribe`. Depending on how it is called, this command
|
||||||
will either print or execute a command for every arriving message. There are a few different ways
|
will either print or execute a command for every arriving message. There are a few different ways
|
||||||
@@ -123,7 +188,7 @@ which will read the `subscribe` config from the config file. Please also check o
|
|||||||
|
|
||||||
Here's an example config file that subscribes to three different topics, executing a different command for each of them:
|
Here's an example config file that subscribes to three different topics, executing a different command for each of them:
|
||||||
|
|
||||||
=== "~/.config/ntfy/client.yml"
|
=== "~/.config/ntfy/client.yml (Linux)"
|
||||||
```yaml
|
```yaml
|
||||||
subscribe:
|
subscribe:
|
||||||
- topic: echo-this
|
- topic: echo-this
|
||||||
@@ -145,12 +210,42 @@ Here's an example config file that subscribes to three different topics, executi
|
|||||||
fi
|
fi
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
=== "~/Library/Application Support/ntfy/client.yml (macOS)"
|
||||||
|
```yaml
|
||||||
|
subscribe:
|
||||||
|
- topic: echo-this
|
||||||
|
command: 'echo "Message received: $message"'
|
||||||
|
- topic: alerts
|
||||||
|
command: osascript -e "display notification \"$message\""
|
||||||
|
if:
|
||||||
|
priority: high,urgent
|
||||||
|
- topic: calc
|
||||||
|
command: open -a Calculator
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "%AppData%\ntfy\client.yml (Windows)"
|
||||||
|
```yaml
|
||||||
|
subscribe:
|
||||||
|
- topic: echo-this
|
||||||
|
command: 'echo Message received: %message%'
|
||||||
|
- topic: alerts
|
||||||
|
command: |
|
||||||
|
notifu /m "%NTFY_MESSAGE%"
|
||||||
|
exit 0
|
||||||
|
if:
|
||||||
|
priority: high,urgent
|
||||||
|
- topic: calc
|
||||||
|
command: calc
|
||||||
|
```
|
||||||
|
|
||||||
In this example, when `ntfy subscribe --from-config` is executed:
|
In this example, when `ntfy subscribe --from-config` is executed:
|
||||||
|
|
||||||
* Messages to `echo-this` simply echos to standard out
|
* Messages to `echo-this` simply echos to standard out
|
||||||
* Messages to `alerts` display as desktop notification for high priority messages using [notify-send](https://manpages.ubuntu.com/manpages/focal/man1/notify-send.1.html)
|
* Messages to `alerts` display as desktop notification for high priority messages using [notify-send](https://manpages.ubuntu.com/manpages/focal/man1/notify-send.1.html) (Linux),
|
||||||
* Messages to `calc` open the gnome calculator 😀 (*because, why not*)
|
[notifu](https://www.paralint.com/projects/notifu/) (Windows) or `osascript` (macOS)
|
||||||
* Messages to `print-temp` execute an inline script and print the CPU temperature
|
* Messages to `calc` open the calculator 😀 (*because, why not*)
|
||||||
|
* Messages to `print-temp` execute an inline script and print the CPU temperature (Linux version only)
|
||||||
|
|
||||||
I hope this shows how powerful this command is. Here's a short video that demonstrates the above example:
|
I hope this shows how powerful this command is. Here's a short video that demonstrates the above example:
|
||||||
|
|
||||||
@@ -159,6 +254,14 @@ I hope this shows how powerful this command is. Here's a short video that demons
|
|||||||
<figcaption>Execute all the things</figcaption>
|
<figcaption>Execute all the things</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
|
If most (or all) of your subscription usernames, passwords, and commands are the same, you can specify a `default-user`, `default-password`, and `default-command` at the top of the
|
||||||
|
`client.yml`. If a subscription does not specify a username/password to use or does not have a command, the defaults will be used, otherwise, the subscription settings will
|
||||||
|
override the defaults.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
Because the `default-user` and `default-password` will be sent for each topic that does not have its own username/password (even if the topic does not 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 service (see [ntfy-client.service](https://github.com/binwiederhier/ntfy/blob/main/client/ntfy-client.service))
|
||||||
to subscribe to multiple topics just like in the example above. The service is automatically installed (but not started)
|
to subscribe to multiple topics just like in the example above. The service is automatically installed (but not started)
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
# Subscribe from your phone
|
# Subscribe from your phone
|
||||||
You can use the [ntfy Android App](https://play.google.com/store/apps/details?id=io.heckel.ntfy) to receive
|
You can use the ntfy [Android App](https://play.google.com/store/apps/details?id=io.heckel.ntfy) or [iOS app](https://apps.apple.com/us/app/ntfy/id1625396347)
|
||||||
notifications directly on your phone. Just like the server, this app is also [open source](https://github.com/binwiederhier/ntfy-android).
|
to receive notifications directly on your phone. Just like the server, this app is also open source, and the code is available
|
||||||
Since I don't have an iPhone or a Mac, I didn't make an iOS app yet. I'd be awesome if [someone else could help out](https://github.com/binwiederhier/ntfy/issues/4).
|
on GitHub ([Android](https://github.com/binwiederhier/ntfy-android), [iOS](https://github.com/binwiederhier/ntfy-ios)). Feel free to
|
||||||
|
contribute, or [build your own](../develop.md).
|
||||||
|
|
||||||
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="../../static/img/badge-googleplay.png"></a>
|
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="../../static/img/badge-googleplay.png"></a>
|
||||||
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="../../static/img/badge-fdroid.png"></a>
|
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="../../static/img/badge-fdroid.png"></a>
|
||||||
|
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img src="../../static/img/badge-appstore.png"></a>
|
||||||
|
|
||||||
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
|
||||||
the F-Droid flavor does not use Firebase.
|
the F-Droid flavor does not use Firebase. The iOS app can be downloaded from the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
A picture is worth a thousand words. Here are a few screenshots showing what the app looks like. It's all pretty
|
A picture is worth a thousand words. Here are a few screenshots showing what the app looks like. It's all pretty
|
||||||
@@ -31,6 +33,8 @@ If those screenshots are still not enough, here's a video:
|
|||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
## Message priority
|
## Message priority
|
||||||
|
_Supported on:_ :material-android: :material-apple:
|
||||||
|
|
||||||
When you [publish messages](../publish.md#message-priority) to a topic, you can **define a priority**. This priority defines
|
When you [publish messages](../publish.md#message-priority) to a topic, you can **define a priority**. This priority defines
|
||||||
how urgently Android will notify you about the notification, and whether they make a sound and/or vibrate.
|
how urgently Android will notify you about the notification, and whether they make a sound and/or vibrate.
|
||||||
|
|
||||||
@@ -59,6 +63,8 @@ setting, and other settings such as popover or notification dot:
|
|||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
## Instant delivery
|
## Instant delivery
|
||||||
|
_Supported on:_ :material-android:
|
||||||
|
|
||||||
Instant delivery allows you to receive messages on your phone instantly, **even when your phone is in doze mode**, i.e.
|
Instant delivery allows you to receive messages on your phone instantly, **even when your phone is in doze mode**, i.e.
|
||||||
when the screen turns off, and you leave it on the desk for a while. This is achieved with a foreground service, which
|
when the screen turns off, and you leave it on the desk for a while. This is achieved with a foreground service, which
|
||||||
you'll see as a permanent notification that looks like this:
|
you'll see as a permanent notification that looks like this:
|
||||||
@@ -89,6 +95,8 @@ The ntfy Android app uses Firebase only for the main host `ntfy.sh`, and only in
|
|||||||
It won't use Firebase for any self-hosted servers, and not at all in the the F-Droid flavor.
|
It won't use Firebase for any self-hosted servers, and not at all in the the F-Droid flavor.
|
||||||
|
|
||||||
## Share to topic
|
## Share to topic
|
||||||
|
_Supported on:_ :material-android:
|
||||||
|
|
||||||
You can share files to a topic using Android's "Share" feature. This works in almost any app that supports sharing files
|
You can share files to a topic using Android's "Share" feature. This works in almost any app that supports sharing files
|
||||||
or text, and it's useful for sending yourself links, files or other things. The feature remembers a few of the last topics
|
or text, and it's useful for sending yourself links, files or other things. The feature remembers a few of the last topics
|
||||||
you shared content to and lists them at the bottom.
|
you shared content to and lists them at the bottom.
|
||||||
@@ -101,6 +109,8 @@ The feature is pretty self-explanatory, and one picture says more than a thousan
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
## ntfy:// links
|
## ntfy:// links
|
||||||
|
_Supported on:_ :material-android:
|
||||||
|
|
||||||
The ntfy Android app supports deep linking directly to topics. This is useful when integrating with [automation apps](#automation-apps)
|
The ntfy Android app supports deep linking directly to topics. This is useful when integrating with [automation apps](#automation-apps)
|
||||||
such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm),
|
such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm),
|
||||||
or to simply directly link to a topic from a mobile website.
|
or to simply directly link to a topic from a mobile website.
|
||||||
@@ -119,6 +129,8 @@ or to simply directly link to a topic from a mobile website.
|
|||||||
## Integrations
|
## Integrations
|
||||||
|
|
||||||
### UnifiedPush
|
### UnifiedPush
|
||||||
|
_Supported on:_ :material-android:
|
||||||
|
|
||||||
[UnifiedPush](https://unifiedpush.org) is a standard for receiving push notifications without using the Google-owned
|
[UnifiedPush](https://unifiedpush.org) is a standard for receiving push notifications without using the Google-owned
|
||||||
[Firebase Cloud Messaging (FCM)](https://firebase.google.com/docs/cloud-messaging) service. It puts push notifications
|
[Firebase Cloud Messaging (FCM)](https://firebase.google.com/docs/cloud-messaging) service. It puts push notifications
|
||||||
in the control of the user. ntfy can act as a **UnifiedPush distributor**, forwarding messages to apps that support it.
|
in the control of the user. ntfy can act as a **UnifiedPush distributor**, forwarding messages to apps that support it.
|
||||||
@@ -134,6 +146,8 @@ to handle messages. Here's an example with [FluffyChat](https://fluffychat.im/):
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
### Automation apps
|
### Automation apps
|
||||||
|
_Supported on:_ :material-android:
|
||||||
|
|
||||||
The ntfy Android app integrates nicely with automation apps such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
The ntfy Android app integrates nicely with automation apps such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
||||||
or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm). Using Android intents, you can
|
or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm). Using Android intents, you can
|
||||||
**react to incoming messages**, as well as **send messages**.
|
**react to incoming messages**, as well as **send messages**.
|
||||||
@@ -166,21 +180,27 @@ notification popups:
|
|||||||
|
|
||||||
Here's a list of extras you can access. Most likely, you'll want to filter for `topic` and react on `message`:
|
Here's a list of extras you can access. Most likely, you'll want to filter for `topic` and react on `message`:
|
||||||
|
|
||||||
| Extra name | Type | Example | Description |
|
| Extra name | Type | Example | Description |
|
||||||
|-----------------|------------------------------|--------------------|------------------------------------------------------------------------------------|
|
|----------------------|------------------------------|------------------------------------------|------------------------------------------------------------------------------------|
|
||||||
| `id` | *String* | `bP8dMjO8ig` | Randomly chosen message identifier (likely not very useful for task automation) |
|
| `id` | *String* | `bP8dMjO8ig` | Randomly chosen message identifier (likely not very useful for task automation) |
|
||||||
| `base_url` | *String* | `https://ntfy.sh` | Root URL of the ntfy server this message came from |
|
| `base_url` | *String* | `https://ntfy.sh` | Root URL of the ntfy server this message came from |
|
||||||
| `topic` ❤️ | *String* | `mytopic` | Topic name; **you'll likely want to filter for a specific topic** |
|
| `topic` ❤️ | *String* | `mytopic` | Topic name; **you'll likely want to filter for a specific topic** |
|
||||||
| `muted` | *Boolean* | `true` | Indicates whether the subscription was muted in the app |
|
| `muted` | *Boolean* | `true` | Indicates whether the subscription was muted in the app |
|
||||||
| `muted_str` | *String (`true` or `false`)* | `true` | Same as `muted`, but as string `true` or `false` |
|
| `muted_str` | *String (`true` or `false`)* | `true` | Same as `muted`, but as string `true` or `false` |
|
||||||
| `time` | *Int* | `1635528741` | Message date time, as Unix time stamp |
|
| `time` | *Int* | `1635528741` | Message date time, as Unix time stamp |
|
||||||
| `title` | *String* | `Some title` | Message [title](../publish.md#message-title); may be empty if not set |
|
| `title` | *String* | `Some title` | Message [title](../publish.md#message-title); may be empty if not set |
|
||||||
| `message` ❤️ | *String* | `Some message` | Message body; **this is likely what you're interested in** |
|
| `message` ❤️ | *String* | `Some message` | Message body; **this is likely what you're interested in** |
|
||||||
| `message_bytes` | *ByteArray* | `(binary data)` | Message body as binary data |
|
| `message_bytes` | *ByteArray* | `(binary data)` | Message body as binary data |
|
||||||
| `encoding`️ | *String* | - | Message encoding (empty or "base64") |
|
| `encoding`️ | *String* | - | Message encoding (empty or "base64") |
|
||||||
| `tags` | *String* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
|
| `tags` | *String* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
|
||||||
| `tags_map` | *String* | `0=tag1,1=tag2,..` | Map of tags to make it easier to map first, second, ... tag |
|
| `tags_map` | *String* | `0=tag1,1=tag2,..` | Map of tags to make it easier to map first, second, ... tag |
|
||||||
| `priority` | *Int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
| `priority` | *Int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
||||||
|
| `click` | *String* | `https://google.com` | [Click action](../publish.md#click-action) URL, or empty if not set |
|
||||||
|
| `attachment_name` | *String* | `attachment.jpg` | Filename of the attachment; may be empty if not set |
|
||||||
|
| `attachment_type` | *String* | `image/jpeg` | Mime type of the attachment; may be empty if not set |
|
||||||
|
| `attachment_size` | *Long* | `9923111` | Size in bytes of the attachment; may be zero if not set |
|
||||||
|
| `attachment_expires` | *Long* | `1655514244` | Expiry date as Unix timestamp of the attachment URL; may be zero if not set |
|
||||||
|
| `attachment_url` | *String* | `https://ntfy.sh/file/afUbjadfl7ErP.jpg` | URL of the attachment; may be empty if not set |
|
||||||
|
|
||||||
#### Send messages using intents
|
#### Send messages using intents
|
||||||
To send messages from other apps (such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
To send messages from other apps (such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
||||||
@@ -210,9 +230,3 @@ The following intent extras are supported when for the intent with the `io.hecke
|
|||||||
| `message` ❤️ | ✔ | *String* | `Some message` | Message body; **you must set this** |
|
| `message` ❤️ | ✔ | *String* | `Some message` | Message body; **you must set this** |
|
||||||
| `tags` | - | *String* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
|
| `tags` | - | *String* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
|
||||||
| `priority` | - | *String or Int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
| `priority` | - | *String or Int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
||||||
|
|
||||||
## iPhone/iOS
|
|
||||||
I almost feel devious for putting the *Download on the App Store* button on this page. Currently, there is no iOS app
|
|
||||||
for ntfy, but it's in the works. You can track the status on GitHub.
|
|
||||||
|
|
||||||
<a href="https://github.com/binwiederhier/ntfy/issues/4"><img src="../../static/img/badge-appstore.png"></a>
|
|
||||||
|
|||||||
74
go.mod
@@ -1,53 +1,61 @@
|
|||||||
module heckel.io/ntfy
|
module heckel.io/ntfy
|
||||||
|
|
||||||
go 1.17
|
go 1.18
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go/firestore v1.6.1 // indirect
|
cloud.google.com/go/firestore v1.9.0 // indirect
|
||||||
cloud.google.com/go/storage v1.22.0 // indirect
|
cloud.google.com/go/storage v1.28.1 // indirect
|
||||||
firebase.google.com/go v3.13.0+incompatible
|
github.com/BurntSushi/toml v1.2.1 // indirect
|
||||||
github.com/BurntSushi/toml v1.1.0 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
|
|
||||||
github.com/emersion/go-smtp v0.15.0
|
github.com/emersion/go-smtp v0.15.0
|
||||||
github.com/gabriel-vasile/mimetype v1.4.0
|
github.com/gabriel-vasile/mimetype v1.4.1
|
||||||
github.com/gorilla/websocket v1.5.0
|
github.com/gorilla/websocket v1.5.0
|
||||||
github.com/mattn/go-sqlite3 v1.14.12
|
github.com/mattn/go-sqlite3 v1.14.16
|
||||||
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6
|
github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8
|
||||||
github.com/stretchr/testify v1.7.0
|
github.com/stretchr/testify v1.8.1
|
||||||
github.com/urfave/cli/v2 v2.4.7
|
github.com/urfave/cli/v2 v2.23.7
|
||||||
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4
|
golang.org/x/crypto v0.4.0
|
||||||
golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 // indirect
|
golang.org/x/oauth2 v0.3.0 // indirect
|
||||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
|
golang.org/x/sync v0.1.0
|
||||||
golang.org/x/term v0.0.0-20220411215600-e5f449aeb171
|
golang.org/x/term v0.3.0
|
||||||
golang.org/x/time v0.0.0-20220411224347-583f2d630306
|
golang.org/x/time v0.3.0
|
||||||
google.golang.org/api v0.75.0
|
google.golang.org/api v0.105.0
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require github.com/pkg/errors v0.9.1 // indirect
|
require github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
|
||||||
|
require firebase.google.com/go/v4 v4.10.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go v0.101.0 // indirect
|
cloud.google.com/go v0.107.0 // indirect
|
||||||
cloud.google.com/go/compute v1.6.1 // indirect
|
cloud.google.com/go/compute v1.14.0 // indirect
|
||||||
cloud.google.com/go/iam v0.3.0 // indirect
|
cloud.google.com/go/compute/metadata v0.2.3 // indirect
|
||||||
|
cloud.google.com/go/iam v0.9.0 // indirect
|
||||||
|
cloud.google.com/go/longrunning v0.3.0 // indirect
|
||||||
github.com/AlekSi/pointer v1.2.0 // indirect
|
github.com/AlekSi/pointer v1.2.0 // indirect
|
||||||
|
github.com/MicahParks/keyfunc v1.9.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac // indirect
|
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead // indirect
|
||||||
|
github.com/golang-jwt/jwt/v4 v4.4.3 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
github.com/golang/protobuf v1.5.2 // indirect
|
github.com/golang/protobuf v1.5.2 // indirect
|
||||||
github.com/google/go-cmp v0.5.7 // indirect
|
github.com/google/go-cmp v0.5.9 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.3.0 // indirect
|
github.com/google/uuid v1.3.0 // indirect
|
||||||
github.com/googleapis/go-type-adapters v1.0.0 // indirect
|
github.com/googleapis/enterprise-certificate-proxy v0.2.1 // indirect
|
||||||
|
github.com/googleapis/gax-go/v2 v2.7.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
go.opencensus.io v0.23.0 // indirect
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||||
golang.org/x/net v0.0.0-20220421235706-1d1ef9303861 // indirect
|
go.opencensus.io v0.24.0 // indirect
|
||||||
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 // indirect
|
golang.org/x/net v0.4.0 // indirect
|
||||||
golang.org/x/text v0.3.7 // indirect
|
golang.org/x/sys v0.3.0 // indirect
|
||||||
golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f // indirect
|
golang.org/x/text v0.5.0 // indirect
|
||||||
|
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
|
||||||
google.golang.org/appengine v1.6.7 // indirect
|
google.golang.org/appengine v1.6.7 // indirect
|
||||||
google.golang.org/genproto v0.0.0-20220422154200-b37d22cd5731 // indirect
|
google.golang.org/appengine/v2 v2.0.2 // indirect
|
||||||
google.golang.org/grpc v1.46.0 // indirect
|
google.golang.org/genproto v0.0.0-20221207170731-23e4bf6bdc37 // indirect
|
||||||
google.golang.org/protobuf v1.28.0 // indirect
|
google.golang.org/grpc v1.51.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
|
google.golang.org/protobuf v1.28.1 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
681
go.sum
@@ -1,657 +1,213 @@
|
|||||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
cloud.google.com/go v0.107.0 h1:qkj22L7bgkl6vIeZDlOY2po43Mx/TIa2Wsa7VR+PEww=
|
||||||
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
cloud.google.com/go v0.107.0/go.mod h1:wpc2eNrD7hXUTy8EKS10jkxpZBjASrORK7goS+3YX2I=
|
||||||
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
|
cloud.google.com/go/compute v1.13.0 h1:AYrLkB8NPdDRslNp4Jxmzrhdr03fUAIDbiGFjLWowoU=
|
||||||
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
cloud.google.com/go/compute v1.13.0/go.mod h1:5aPTS0cUNMIc1CE546K+Th6weJUNQErARyZtRXDJ8GE=
|
||||||
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
|
cloud.google.com/go/compute v1.14.0 h1:hfm2+FfxVmnRlh6LpB7cg1ZNU+5edAHmW679JePztk0=
|
||||||
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
|
cloud.google.com/go/compute v1.14.0/go.mod h1:YfLtxrj9sU4Yxv+sXzZkyPjEyPBZfXHUvjxega5vAdo=
|
||||||
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
|
cloud.google.com/go/compute/metadata v0.2.2 h1:aWKAjYaBaOSrpKl57+jnS/3fJRQnxL7TvR/u1VVbt6k=
|
||||||
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
|
cloud.google.com/go/compute/metadata v0.2.2/go.mod h1:jgHgmJd2RKBGzXqF5LR2EZMGxBkeanZ9wwa75XHJgOM=
|
||||||
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
|
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
|
||||||
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
|
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
|
||||||
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
|
cloud.google.com/go/firestore v1.9.0 h1:IBlRyxgGySXu5VuW0RgGFlTtLukSnNkpDiEOMkQkmpA=
|
||||||
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
|
cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE=
|
||||||
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
|
cloud.google.com/go/iam v0.7.0 h1:k4MuwOsS7zGJJ+QfZ5vBK8SgHBAvYN/23BWsiihJ1vs=
|
||||||
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
|
cloud.google.com/go/iam v0.7.0/go.mod h1:H5Br8wRaDGNc8XP3keLc4unfUUZeyH3Sfl9XpQEYOeg=
|
||||||
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
|
cloud.google.com/go/iam v0.9.0 h1:bK6Or6mxhuL8lnj1i9j0yMo2wE/IeTO2cWlfUrf/TZs=
|
||||||
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
|
cloud.google.com/go/iam v0.9.0/go.mod h1:nXAECrMt2qHpF6RZUZseteD6QyanL68reN4OXPw0UWM=
|
||||||
cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg=
|
cloud.google.com/go/longrunning v0.3.0 h1:NjljC+FYPV3uh5/OwWT6pVU+doBqMg2x/rZlE+CamDs=
|
||||||
cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8=
|
cloud.google.com/go/longrunning v0.3.0/go.mod h1:qth9Y41RRSUE69rDcOn6DdK3HfQfsUI0YSmW3iIlLJc=
|
||||||
cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0=
|
cloud.google.com/go/storage v1.28.1 h1:F5QDG5ChchaAVQhINh24U99OWHURqrW8OmQcGKXcbgI=
|
||||||
cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY=
|
cloud.google.com/go/storage v1.28.1/go.mod h1:Qnisd4CqDdo6BGs2AD5LLnEsmSQ80wQ5ogcBBKhU86Y=
|
||||||
cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM=
|
firebase.google.com/go/v4 v4.10.0 h1:dgK/8uwfJbzc5LZK/GyRRfIkZEDObN9q0kgEXsjlXN4=
|
||||||
cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY=
|
firebase.google.com/go/v4 v4.10.0/go.mod h1:m0gLwPY9fxKggizzglgCNWOGnFnVPifLpqZzo5u3e/A=
|
||||||
cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ=
|
|
||||||
cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
|
|
||||||
cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
|
|
||||||
cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
|
|
||||||
cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
|
|
||||||
cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A=
|
|
||||||
cloud.google.com/go v0.101.0 h1:g+LL+JvpvdyGtcaD2xw2mSByE/6F9s471eJSoaysM84=
|
|
||||||
cloud.google.com/go v0.101.0/go.mod h1:hEiddgDb77jDQ+I80tURYNJEnuwPzFU8awCFFRLKjW0=
|
|
||||||
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
|
||||||
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
|
||||||
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
|
||||||
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
|
|
||||||
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
|
|
||||||
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
|
|
||||||
cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow=
|
|
||||||
cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM=
|
|
||||||
cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M=
|
|
||||||
cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s=
|
|
||||||
cloud.google.com/go/compute v1.6.1 h1:2sMmt8prCn7DPaG4Pmh0N3Inmc8cT8ae5k1M6VJ9Wqc=
|
|
||||||
cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU=
|
|
||||||
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
|
||||||
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
|
||||||
cloud.google.com/go/firestore v1.6.1 h1:8rBq3zRjnHx8UtBvaOWqBB1xq9jH6/wltfQLlTMh2Fw=
|
|
||||||
cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
|
|
||||||
cloud.google.com/go/iam v0.3.0 h1:exkAomrVUuzx9kWFI1wm3KI0uoDeUFPB4kKGzx6x+Gc=
|
|
||||||
cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY=
|
|
||||||
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
|
||||||
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
|
||||||
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
|
|
||||||
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
|
|
||||||
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
|
||||||
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
|
|
||||||
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
|
||||||
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
|
||||||
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
|
||||||
cloud.google.com/go/storage v1.22.0 h1:NUV0NNp9nkBuW66BFRLuMgldN60C57ET3dhbwLIYio8=
|
|
||||||
cloud.google.com/go/storage v1.22.0/go.mod h1:GbaLEoMqbVm6sx3Z0R++gSiBlgMv6yUi2q1DeGFKQgE=
|
|
||||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
|
||||||
firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=
|
|
||||||
firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=
|
|
||||||
github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8=
|
github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8=
|
||||||
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 v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I=
|
github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
|
||||||
github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
github.com/MicahParks/keyfunc v1.7.0 h1:LBd4tBj6FwGs2S4GXniQbgrG0PXzIldyGDKWch8slhg=
|
||||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
github.com/MicahParks/keyfunc v1.7.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
|
||||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
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/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
|
||||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
|
||||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
|
||||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
|
||||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
|
||||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||||
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
|
||||||
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
|
|
||||||
github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
|
||||||
github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
|
||||||
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
|
||||||
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
|
||||||
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU=
|
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
|
||||||
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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/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-20211008083017-0b9dcfb154ac h1:tn/OQ2PmwQ0XFVgAHfjlLyqMewry25Rz7jWnVoh4Ggs=
|
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y=
|
||||||
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8=
|
github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8=
|
||||||
github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
github.com/emersion/go-smtp v0.15.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.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
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.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
|
|
||||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
|
||||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
|
||||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
|
|
||||||
github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
|
|
||||||
github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE=
|
|
||||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.0 h1:Cn9dkdYsMIu56tGho+fqzh7XmvY2YyGU0FnbhiOsEro=
|
github.com/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkFtf/dnN7Q=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.0/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmxRtOJlERCzSmRvr8=
|
github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M=
|
||||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU=
|
||||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
|
||||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
|
||||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
|
||||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
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/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
|
||||||
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
|
|
||||||
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
|
||||||
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
|
||||||
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
|
||||||
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
|
||||||
github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8=
|
|
||||||
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
|
||||||
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
|
||||||
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
|
|
||||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
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.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.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-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.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
|
||||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
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.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
|
|
||||||
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
|
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
|
||||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||||
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
|
||||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
|
||||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
|
||||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
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.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.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.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.4.1/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.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/google/go-cmp v0.5.2/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.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.4/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.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||||
github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o=
|
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
|
github.com/google/martian/v3 v3.2.1 h1:d8MncMlErDFTwQGBK1xhv026j9kqhvw1Qv9IbWT1VLQ=
|
||||||
github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
|
|
||||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
|
||||||
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
|
||||||
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
|
||||||
github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
|
|
||||||
github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
|
|
||||||
github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
|
|
||||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
|
||||||
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
|
||||||
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
|
||||||
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
|
||||||
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
|
||||||
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
|
||||||
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
|
||||||
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
|
||||||
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
|
||||||
github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
|
||||||
github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
|
||||||
github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
|
||||||
github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
|
||||||
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
|
||||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
|
||||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
|
github.com/googleapis/enterprise-certificate-proxy v0.2.0 h1:y8Yozv7SZtlU//QXbezB6QkpuE6jMD2/gfzk4AftXjs=
|
||||||
github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
|
github.com/googleapis/enterprise-certificate-proxy v0.2.0/go.mod h1:8C0jb7/mgJe/9KK8Lm7X9ctZC2t60YyIpYEI16jx0Qg=
|
||||||
github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM=
|
github.com/googleapis/enterprise-certificate-proxy v0.2.1 h1:RY7tHKZcRlk788d5WSo/e83gOyyy742E8GSs771ySpg=
|
||||||
github.com/googleapis/gax-go/v2 v2.3.0 h1:nRJtk3y8Fm770D42QV6T90ZnvFZyk7agSo3Q+Z9p3WI=
|
github.com/googleapis/enterprise-certificate-proxy v0.2.1/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k=
|
||||||
github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM=
|
github.com/googleapis/gax-go/v2 v2.7.0 h1:IcsPKeInNvYi7eqSaDjiZqDDKu5rsmunY0Y1YupQSSQ=
|
||||||
github.com/googleapis/go-type-adapters v1.0.0 h1:9XdMn+d/G57qq1s8dNc5IesGCXHf6V2HZ2JwRxfA2tA=
|
github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8=
|
||||||
github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4=
|
|
||||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
|
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
|
||||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
|
||||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
|
||||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
|
||||||
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
|
||||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|
||||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.12 h1:TJ1bhYJPV44phC+IMu1u2K/i5RriLTPe+yc68XDJ1Z0=
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
|
||||||
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6 h1:oDSPaYiL2dbjcArLrFS8ANtwgJMyOLzvQCZon+XmFsk=
|
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6 h1:oDSPaYiL2dbjcArLrFS8ANtwgJMyOLzvQCZon+XmFsk=
|
||||||
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6/go.mod h1:DPucAeQGDPUzYUt+NaWw6qsF5SFapWWToxEiVDh2aV0=
|
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6/go.mod h1:DPucAeQGDPUzYUt+NaWw6qsF5SFapWWToxEiVDh2aV0=
|
||||||
|
github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8 h1:0uFGkScHef2Xd8g74BMHU1jFcnKEm0PzrPn4CluQ9FI=
|
||||||
|
github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8/go.mod h1:T0THb4kP9D3NNqlvCwIG4GyUioTAzEhB4RNVzig/43E=
|
||||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
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_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
|
||||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
|
||||||
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/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
|
||||||
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.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/urfave/cli/v2 v2.23.6 h1:iWmtKD+prGo1nKUtLO0Wg4z9esfBM4rAV4QRLQiEmJ4=
|
||||||
github.com/urfave/cli/v2 v2.4.7 h1:nUgKLTC/InVYwUx26HZUBGIBZaptiW97W8vVlhuYawo=
|
github.com/urfave/cli/v2 v2.23.6/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
|
||||||
github.com/urfave/cli/v2 v2.4.7/go.mod h1:oDzoM7pVwz6wHn5ogWgFUU1s4VJayeQS+aEZDqXIEJs=
|
github.com/urfave/cli/v2 v2.23.7 h1:YHDQ46s3VghFHFf1DdF+Sh7H4RqhcM+t0TmZRJx4oJY=
|
||||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/urfave/cli/v2 v2.23.7/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
||||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
|
||||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
|
||||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
|
||||||
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
|
||||||
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
|
||||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
|
||||||
go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M=
|
|
||||||
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
|
|
||||||
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
|
|
||||||
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-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
|
||||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 h1:kUhD7nTDoI3fVd9G4ORWrbV5NY0liEs/Jg2pv5f+bBA=
|
golang.org/x/crypto v0.3.0 h1:a06MkbcxBrEFc0w0QIZWXrH/9cCX6KJyWbBOIwAn+7A=
|
||||||
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
|
||||||
|
golang.org/x/crypto v0.4.0 h1:UVQgzMY87xqpKNgb+kDsll2Igd33HszWHFLmpaRMq/8=
|
||||||
|
golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
|
||||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
|
||||||
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
|
|
||||||
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
|
|
||||||
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
|
||||||
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
|
||||||
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
|
||||||
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
|
||||||
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
|
||||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
|
||||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
|
||||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
|
||||||
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
|
||||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
|
||||||
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
|
|
||||||
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
|
||||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
|
||||||
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
|
||||||
golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
|
||||||
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
|
|
||||||
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
|
||||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
|
||||||
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
|
||||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
|
||||||
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|
||||||
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|
||||||
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|
||||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
|
||||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
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-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
|
||||||
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
|
||||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
|
||||||
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
|
||||||
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
|
||||||
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
|
||||||
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
|
||||||
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
|
||||||
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
|
||||||
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
|
||||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
|
||||||
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
|
||||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20220708220712-1185a9018129/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU=
|
||||||
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
|
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
||||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU=
|
||||||
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
|
||||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
|
||||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
|
||||||
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
|
||||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
|
||||||
golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
|
||||||
golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
|
||||||
golang.org/x/net v0.0.0-20220421235706-1d1ef9303861 h1:yssD99+7tqHWO5Gwh81phT+67hg+KttniBr6UnEXOY8=
|
|
||||||
golang.org/x/net v0.0.0-20220421235706-1d1ef9303861/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.2.0 h1:GtQkldQ9m7yvzCL1V+LrYow3Khe0eJH0w7RbX/VbaIU=
|
||||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.2.0/go.mod h1:Cwn6afJ8jrQwYMxQDTpISoXmXW9I6qF6vDeuuoX3Ibs=
|
||||||
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.3.0 h1:6l90koy8/LaBLmLu8jpHeHexzMwEita0zFfYlggy2F8=
|
||||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.3.0/go.mod h1:rQrIauxkUhJ6CuwEXwymO2/eh4xz2ZWF1nBkcxS+tGk=
|
||||||
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 h1:OSnWWcOd/CtWQC2cYSBgbTSJv3ciqd8r54ySIW2y3RE=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/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-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||||
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
|
|
||||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/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-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
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=
|
||||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
|
||||||
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 h1:xHms4gcpe1YE7A3yIllJXP16CMAGuqwO2lX1mTyyRRc=
|
|
||||||
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
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.0.0-20220411215600-e5f449aeb171 h1:EH1Deb8WZJ0xc0WK//leUHXcX9aLE5SymusoTmMZye8=
|
golang.org/x/term v0.2.0 h1:z85xZCsEl7bi/KwbNADeBYoOP0++7W1ipu+aGnpwzRM=
|
||||||
golang.org/x/term v0.0.0-20220411215600-e5f449aeb171/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
||||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI=
|
||||||
|
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
|
||||||
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.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
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.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
|
||||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
|
||||||
golang.org/x/time v0.0.0-20220411224347-583f2d630306 h1:+gHMid33q6pen7kv9xvT+JRinntgeXO2AeZVd0AWD3w=
|
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/time v0.0.0-20220411224347-583f2d630306/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
|
||||||
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-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-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-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
|
||||||
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
|
||||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
|
||||||
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
|
||||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
|
||||||
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
|
||||||
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
|
||||||
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/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.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
|
||||||
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
|
||||||
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
|
|
||||||
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
|
||||||
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
|
||||||
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
|
||||||
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
|
||||||
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
|
||||||
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
|
||||||
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
|
||||||
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
|
|
||||||
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
|
||||||
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
|
||||||
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
|
||||||
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
|
||||||
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
|
||||||
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
|
||||||
golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
|
||||||
golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
|
||||||
golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
|
||||||
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/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-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
|
||||||
golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f h1:GGU+dLjvlC3qDwqYgL6UgRmHXhOOgns0bZu2Ty5mm6U=
|
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
|
||||||
golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
google.golang.org/api v0.103.0 h1:9yuVqlu2JCvcLg9p8S3fcFLZij8EPSyvODIY1rkMizQ=
|
||||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
google.golang.org/api v0.103.0/go.mod h1:hGtW6nK1AC+d9si/UBhw8Xli+QMOf6xyNAyJw4qU9w0=
|
||||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
google.golang.org/api v0.105.0 h1:t6P9Jj+6XTn4U9I2wycQai6Q/Kz7iOT+QzjJ3G2V4x8=
|
||||||
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
google.golang.org/api v0.105.0/go.mod h1:qh7eD5FJks5+BcE+cjBIm6Gz8vioK7EHvnlniqXBnqI=
|
||||||
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
|
||||||
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
|
||||||
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
|
||||||
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
|
||||||
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
|
||||||
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
|
||||||
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
|
||||||
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
|
||||||
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
|
||||||
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
|
||||||
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
|
||||||
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
|
|
||||||
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
|
|
||||||
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
|
|
||||||
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
|
|
||||||
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
|
|
||||||
google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU=
|
|
||||||
google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94=
|
|
||||||
google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo=
|
|
||||||
google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4=
|
|
||||||
google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw=
|
|
||||||
google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU=
|
|
||||||
google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k=
|
|
||||||
google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
|
|
||||||
google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
|
|
||||||
google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI=
|
|
||||||
google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU=
|
|
||||||
google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
|
|
||||||
google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo=
|
|
||||||
google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g=
|
|
||||||
google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA=
|
|
||||||
google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8=
|
|
||||||
google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs=
|
|
||||||
google.golang.org/api v0.75.0 h1:0AYh/ae6l9TDUvIQrDw5QRpM100P6oHgD+o3dYHMzJg=
|
|
||||||
google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA=
|
|
||||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
|
||||||
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
|
||||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
|
||||||
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
|
||||||
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
|
google.golang.org/appengine/v2 v2.0.2 h1:MSqyWy2shDLwG7chbwBJ5uMyw6SNqJzhJHNDwYB0Akk=
|
||||||
|
google.golang.org/appengine/v2 v2.0.2/go.mod h1:PkgRUWz4o1XOvbqtWTkBtCitEJ5Tp4HoVEdMMYQR/8E=
|
||||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
|
||||||
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
|
||||||
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
|
||||||
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
|
||||||
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
|
||||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
|
|
||||||
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
|
||||||
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
|
||||||
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
|
||||||
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
|
||||||
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
|
||||||
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
|
||||||
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
|
|
||||||
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
|
||||||
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
|
||||||
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
|
||||||
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
|
||||||
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
|
||||||
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
|
||||||
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
|
||||||
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
|
||||||
google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
|
||||||
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
|
|
||||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||||
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
|
google.golang.org/genproto v0.0.0-20221202195650-67e5cbc046fd h1:OjndDrsik+Gt+e6fs45z9AxiewiKyLKYpA45W5Kpkks=
|
||||||
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
google.golang.org/genproto v0.0.0-20221202195650-67e5cbc046fd/go.mod h1:cTsE614GARnxrLsqKREzmNYJACSWWpAWdNMwnD7c2BE=
|
||||||
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
google.golang.org/genproto v0.0.0-20221207170731-23e4bf6bdc37 h1:jmIfw8+gSvXcZSgaFAGyInDXeWzUhvYH57G/5GKMn70=
|
||||||
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
google.golang.org/genproto v0.0.0-20221207170731-23e4bf6bdc37/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
|
||||||
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
|
||||||
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
|
||||||
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
|
||||||
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
|
||||||
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
|
||||||
google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
|
||||||
google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
|
||||||
google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
|
||||||
google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
|
||||||
google.golang.org/genproto v0.0.0-20210329143202-679c6ae281ee/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
|
|
||||||
google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
|
|
||||||
google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
|
|
||||||
google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
|
|
||||||
google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
|
|
||||||
google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
|
|
||||||
google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24=
|
|
||||||
google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
|
|
||||||
google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
|
|
||||||
google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
|
|
||||||
google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
|
|
||||||
google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w=
|
|
||||||
google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
|
|
||||||
google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
|
|
||||||
google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
|
|
||||||
google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
|
|
||||||
google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
|
|
||||||
google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
|
||||||
google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
|
||||||
google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
|
||||||
google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
|
||||||
google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
|
||||||
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
|
||||||
google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
|
||||||
google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
|
||||||
google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
|
||||||
google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
|
||||||
google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
|
||||||
google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
|
||||||
google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
|
||||||
google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E=
|
|
||||||
google.golang.org/genproto v0.0.0-20220405205423-9d709892a2bf/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
|
||||||
google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
|
||||||
google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
|
||||||
google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
|
||||||
google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
|
||||||
google.golang.org/genproto v0.0.0-20220422154200-b37d22cd5731 h1:nquqdM9+ps0JZcIiI70+tqoaIFS5Ql4ZuK8UXnz3HfE=
|
|
||||||
google.golang.org/genproto v0.0.0-20220422154200-b37d22cd5731/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
|
||||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
|
||||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
|
||||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
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.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||||
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
|
||||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
|
||||||
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
|
|
||||||
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
|
|
||||||
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
|
||||||
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
|
||||||
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
|
||||||
google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
|
|
||||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||||
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
|
google.golang.org/grpc v1.51.0 h1:E1eGv1FTqoLIdnBCZufiSHgKjlqG6fKFf6pPWtMTh8U=
|
||||||
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
google.golang.org/grpc v1.51.0/go.mod h1:wgNDFcnuBGmxLKI/qn4T+m5BtEBYXJPvibbUPsAIPww=
|
||||||
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
|
||||||
google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
|
||||||
google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
|
|
||||||
google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
|
|
||||||
google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
|
|
||||||
google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
|
|
||||||
google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
|
|
||||||
google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
|
|
||||||
google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
|
|
||||||
google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
|
|
||||||
google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
|
|
||||||
google.golang.org/grpc v1.46.0 h1:oCjezcn6g6A75TGoKYBPgKmVBLexhYLM6MebdrPApP8=
|
|
||||||
google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
|
|
||||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
|
|
||||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
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-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 v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
@@ -660,30 +216,17 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
|
|||||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
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.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.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
|
|
||||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
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.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
|
||||||
google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
|
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||||
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
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-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
|
||||||
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/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-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
|
||||||
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
|
||||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
|
||||||
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
|
||||||
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
|
||||||
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
|
||||||
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
|
||||||
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
|
|
||||||
|
|||||||
129
log/log.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package log
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Level is a well-known log level, as defined below
|
||||||
|
type Level int
|
||||||
|
|
||||||
|
// Well known log levels
|
||||||
|
const (
|
||||||
|
TraceLevel Level = iota
|
||||||
|
DebugLevel
|
||||||
|
InfoLevel
|
||||||
|
WarnLevel
|
||||||
|
ErrorLevel
|
||||||
|
)
|
||||||
|
|
||||||
|
func (l Level) String() string {
|
||||||
|
switch l {
|
||||||
|
case TraceLevel:
|
||||||
|
return "TRACE"
|
||||||
|
case DebugLevel:
|
||||||
|
return "DEBUG"
|
||||||
|
case InfoLevel:
|
||||||
|
return "INFO"
|
||||||
|
case WarnLevel:
|
||||||
|
return "WARN"
|
||||||
|
case ErrorLevel:
|
||||||
|
return "ERROR"
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
level = InfoLevel
|
||||||
|
mu = &sync.Mutex{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Trace prints the given message, if the current log level is TRACE
|
||||||
|
func Trace(message string, v ...any) {
|
||||||
|
logIf(TraceLevel, message, v...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug prints the given message, if the current log level is DEBUG or lower
|
||||||
|
func Debug(message string, v ...any) {
|
||||||
|
logIf(DebugLevel, message, v...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info prints the given message, if the current log level is INFO or lower
|
||||||
|
func Info(message string, v ...any) {
|
||||||
|
logIf(InfoLevel, message, v...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn prints the given message, if the current log level is WARN or lower
|
||||||
|
func Warn(message string, v ...any) {
|
||||||
|
logIf(WarnLevel, message, v...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error prints the given message, if the current log level is ERROR or lower
|
||||||
|
func Error(message string, v ...any) {
|
||||||
|
logIf(ErrorLevel, message, v...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fatal prints the given message, and exits the program
|
||||||
|
func Fatal(v ...any) {
|
||||||
|
log.Fatalln(v...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentLevel returns the current log level
|
||||||
|
func CurrentLevel() Level {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
return level
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLevel sets a new log level
|
||||||
|
func SetLevel(newLevel Level) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
level = newLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisableDates disables the date/time prefix
|
||||||
|
func DisableDates() {
|
||||||
|
log.SetFlags(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToLevel converts a string to a Level. It returns InfoLevel if the string
|
||||||
|
// does not match any known log levels.
|
||||||
|
func ToLevel(s string) Level {
|
||||||
|
switch strings.ToUpper(s) {
|
||||||
|
case "TRACE":
|
||||||
|
return TraceLevel
|
||||||
|
case "DEBUG":
|
||||||
|
return DebugLevel
|
||||||
|
case "INFO":
|
||||||
|
return InfoLevel
|
||||||
|
case "WARN", "WARNING":
|
||||||
|
return WarnLevel
|
||||||
|
case "ERROR":
|
||||||
|
return ErrorLevel
|
||||||
|
default:
|
||||||
|
return InfoLevel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loggable returns true if the given log level is lower or equal to the current log level
|
||||||
|
func Loggable(l Level) bool {
|
||||||
|
return CurrentLevel() <= l
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTrace returns true if the current log level is TraceLevel
|
||||||
|
func IsTrace() bool {
|
||||||
|
return Loggable(TraceLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDebug returns true if the current log level is DebugLevel or below
|
||||||
|
func IsDebug() bool {
|
||||||
|
return Loggable(DebugLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func logIf(l Level, message string, v ...any) {
|
||||||
|
if CurrentLevel() <= l {
|
||||||
|
log.Printf(l.String()+" "+message, v...)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,9 @@ markdown_extensions:
|
|||||||
custom_checkbox: true
|
custom_checkbox: true
|
||||||
- attr_list
|
- attr_list
|
||||||
- md_in_html
|
- md_in_html
|
||||||
|
- pymdownx.emoji:
|
||||||
|
emoji_index: !!python/name:materialx.emoji.twemoji
|
||||||
|
emoji_generator: !!python/name:materialx.emoji.to_svg
|
||||||
|
|
||||||
plugins:
|
plugins:
|
||||||
- search
|
- search
|
||||||
@@ -82,9 +85,11 @@ nav:
|
|||||||
- "Other things":
|
- "Other things":
|
||||||
- "FAQs": faq.md
|
- "FAQs": faq.md
|
||||||
- "Examples": examples.md
|
- "Examples": examples.md
|
||||||
|
- "Integrations + projects": integrations.md
|
||||||
- "Release notes": releases.md
|
- "Release notes": releases.md
|
||||||
- "Deprecation notices": deprecations.md
|
|
||||||
- "Emojis 🥳 🎉": emojis.md
|
- "Emojis 🥳 🎉": emojis.md
|
||||||
|
- "Known issues": known-issues.md
|
||||||
|
- "Deprecation notices": deprecations.md
|
||||||
- "Development": develop.md
|
- "Development": develop.md
|
||||||
- "Privacy policy": privacy.md
|
- "Privacy policy": privacy.md
|
||||||
|
|
||||||
|
|||||||
@@ -60,13 +60,13 @@ func parseActions(s string) (actions []*action, err error) {
|
|||||||
return nil, fmt.Errorf("only %d actions allowed", actionsMax)
|
return nil, fmt.Errorf("only %d actions allowed", actionsMax)
|
||||||
}
|
}
|
||||||
for _, action := range actions {
|
for _, action := range actions {
|
||||||
if !util.InStringList(actionsAll, action.Action) {
|
if !util.Contains(actionsAll, action.Action) {
|
||||||
return nil, fmt.Errorf("parameter 'action' cannot be '%s', valid values are 'view', 'broadcast' and 'http'", action.Action)
|
return nil, fmt.Errorf("parameter 'action' cannot be '%s', valid values are 'view', 'broadcast' and 'http'", action.Action)
|
||||||
} else if action.Label == "" {
|
} else if action.Label == "" {
|
||||||
return nil, fmt.Errorf("parameter 'label' is required")
|
return nil, fmt.Errorf("parameter 'label' is required")
|
||||||
} else if util.InStringList(actionsWithURL, action.Action) && action.URL == "" {
|
} else if util.Contains(actionsWithURL, action.Action) && action.URL == "" {
|
||||||
return nil, fmt.Errorf("parameter 'url' is required for action '%s'", action.Action)
|
return nil, fmt.Errorf("parameter 'url' is required for action '%s'", action.Action)
|
||||||
} else if action.Action == actionHTTP && util.InStringList([]string{"GET", "HEAD"}, action.Method) && action.Body != "" {
|
} else if action.Action == actionHTTP && util.Contains([]string{"GET", "HEAD"}, action.Method) && action.Body != "" {
|
||||||
return nil, fmt.Errorf("parameter 'body' cannot be set if method is %s", action.Method)
|
return nil, fmt.Errorf("parameter 'body' cannot be set if method is %s", action.Method)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,7 +87,8 @@ func parseActionsFromJSON(s string) ([]*action, error) {
|
|||||||
// https://ntfy.sh/docs/publish/#action-buttons), into an array of actions.
|
// https://ntfy.sh/docs/publish/#action-buttons), into an array of actions.
|
||||||
//
|
//
|
||||||
// It can parse an actions string like this:
|
// It can parse an actions string like this:
|
||||||
// view, "Look ma, commas and \"quotes\" too", url=https://..; action=broadcast, ...
|
//
|
||||||
|
// view, "Look ma, commas and \"quotes\" too", url=https://..; action=broadcast, ...
|
||||||
//
|
//
|
||||||
// It works by advancing the position ("pos") through the input string ("input").
|
// It works by advancing the position ("pos") through the input string ("input").
|
||||||
//
|
//
|
||||||
@@ -96,10 +97,11 @@ func parseActionsFromJSON(s string) ([]*action, error) {
|
|||||||
// though it does not use state functions at all.
|
// though it does not use state functions at all.
|
||||||
//
|
//
|
||||||
// Other resources:
|
// Other resources:
|
||||||
// https://adampresley.github.io/2015/04/12/writing-a-lexer-and-parser-in-go-part-1.html
|
//
|
||||||
// https://github.com/adampresley/sample-ini-parser/blob/master/services/lexer/lexer/Lexer.go
|
// https://adampresley.github.io/2015/04/12/writing-a-lexer-and-parser-in-go-part-1.html
|
||||||
// https://github.com/benbjohnson/sql-parser/blob/master/scanner.go
|
// https://github.com/adampresley/sample-ini-parser/blob/master/services/lexer/lexer/Lexer.go
|
||||||
// https://blog.gopheracademy.com/advent-2014/parsers-lexers/
|
// https://github.com/benbjohnson/sql-parser/blob/master/scanner.go
|
||||||
|
// https://blog.gopheracademy.com/advent-2014/parsers-lexers/
|
||||||
func parseActionsFromSimple(s string) ([]*action, error) {
|
func parseActionsFromSimple(s string) ([]*action, error) {
|
||||||
if !utf8.ValidString(s) {
|
if !utf8.ValidString(s) {
|
||||||
return nil, errors.New("invalid utf-8 string")
|
return nil, errors.New("invalid utf-8 string")
|
||||||
@@ -154,7 +156,7 @@ func populateAction(newAction *action, section int, key, value string) error {
|
|||||||
key = "action"
|
key = "action"
|
||||||
} else if key == "" && section == 1 {
|
} else if key == "" && section == 1 {
|
||||||
key = "label"
|
key = "label"
|
||||||
} else if key == "" && section == 2 && util.InStringList(actionsWithURL, newAction.Action) {
|
} else if key == "" && section == 2 && util.Contains(actionsWithURL, newAction.Action) {
|
||||||
key = "url"
|
key = "url"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +178,7 @@ func populateAction(newAction *action, section int, key, value string) error {
|
|||||||
newAction.Label = value
|
newAction.Label = value
|
||||||
case "clear":
|
case "clear":
|
||||||
lvalue := strings.ToLower(value)
|
lvalue := strings.ToLower(value)
|
||||||
if !util.InStringList([]string{"true", "yes", "1", "false", "no", "0"}, lvalue) {
|
if !util.Contains([]string{"true", "yes", "1", "false", "no", "0"}, lvalue) {
|
||||||
return fmt.Errorf("parameter 'clear' cannot be '%s', only boolean values are allowed (true/yes/1/false/no/0)", value)
|
return fmt.Errorf("parameter 'clear' cannot be '%s', only boolean values are allowed (true/yes/1/false/no/0)", value)
|
||||||
}
|
}
|
||||||
newAction.Clear = lvalue == "true" || lvalue == "yes" || lvalue == "1"
|
newAction.Clear = lvalue == "true" || lvalue == "yes" || lvalue == "1"
|
||||||
@@ -186,6 +188,8 @@ func populateAction(newAction *action, section int, key, value string) error {
|
|||||||
newAction.Method = value
|
newAction.Method = value
|
||||||
case "body":
|
case "body":
|
||||||
newAction.Body = value
|
newAction.Body = value
|
||||||
|
case "intent":
|
||||||
|
newAction.Intent = value
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("key '%s' unknown", key)
|
return fmt.Errorf("key '%s' unknown", key)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,14 @@ func TestParseActions(t *testing.T) {
|
|||||||
require.Equal(t, "some command", actions[0].Extras["command"])
|
require.Equal(t, "some command", actions[0].Extras["command"])
|
||||||
require.Equal(t, "a parameter", actions[0].Extras["some_param"])
|
require.Equal(t, "a parameter", actions[0].Extras["some_param"])
|
||||||
|
|
||||||
|
// Broadcast action with intent
|
||||||
|
actions, err = parseActions("action=broadcast, label=Do a thing, intent=io.heckel.ntfy.TEST_INTENT")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(actions))
|
||||||
|
require.Equal(t, "broadcast", actions[0].Action)
|
||||||
|
require.Equal(t, "Do a thing", actions[0].Label)
|
||||||
|
require.Equal(t, "io.heckel.ntfy.TEST_INTENT", actions[0].Intent)
|
||||||
|
|
||||||
// Headers with dashes
|
// Headers with dashes
|
||||||
actions, err = parseActions("action=http, label=Send request, url=http://example.com, method=GET, headers.Content-Type=application/json, headers.Authorization=Basic sdasffsf")
|
actions, err = parseActions("action=http, label=Send request, url=http://example.com, method=GET, headers.Content-Type=application/json, headers.Authorization=Basic sdasffsf")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"net/netip"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Defines default config settings (excluding limits, see below)
|
// Defines default config settings (excluding limits, see below)
|
||||||
const (
|
const (
|
||||||
DefaultListenHTTP = ":80"
|
DefaultListenHTTP = ":80"
|
||||||
DefaultCacheDuration = 12 * time.Hour
|
DefaultCacheDuration = 12 * time.Hour
|
||||||
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
|
||||||
DefaultAtSenderInterval = 10 * time.Second
|
DefaultDelayedSenderInterval = 10 * time.Second
|
||||||
DefaultMinDelay = 10 * time.Second
|
DefaultMinDelay = 10 * time.Second
|
||||||
DefaultMaxDelay = 3 * 24 * time.Hour
|
DefaultMaxDelay = 3 * 24 * time.Hour
|
||||||
DefaultFirebaseKeepaliveInterval = 3 * time.Hour // 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)
|
||||||
|
DefaultFirebaseQuotaExceededPenaltyDuration = 10 * time.Minute // Time that over-users are locked out of Firebase if it returns "quota exceeded"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Defines all global and per-visitor limits
|
// Defines all global and per-visitor limits
|
||||||
@@ -50,11 +54,15 @@ type Config struct {
|
|||||||
ListenHTTP string
|
ListenHTTP string
|
||||||
ListenHTTPS string
|
ListenHTTPS string
|
||||||
ListenUnix string
|
ListenUnix string
|
||||||
|
ListenUnixMode fs.FileMode
|
||||||
KeyFile string
|
KeyFile string
|
||||||
CertFile string
|
CertFile string
|
||||||
FirebaseKeyFile string
|
FirebaseKeyFile string
|
||||||
CacheFile string
|
CacheFile string
|
||||||
CacheDuration time.Duration
|
CacheDuration time.Duration
|
||||||
|
CacheStartupQueries string
|
||||||
|
CacheBatchSize int
|
||||||
|
CacheBatchTimeout time.Duration
|
||||||
AuthFile string
|
AuthFile string
|
||||||
AuthDefaultRead bool
|
AuthDefaultRead bool
|
||||||
AuthDefaultWrite bool
|
AuthDefaultWrite bool
|
||||||
@@ -65,8 +73,11 @@ type Config struct {
|
|||||||
KeepaliveInterval time.Duration
|
KeepaliveInterval time.Duration
|
||||||
ManagerInterval time.Duration
|
ManagerInterval time.Duration
|
||||||
WebRootIsApp bool
|
WebRootIsApp bool
|
||||||
AtSenderInterval time.Duration
|
DelayedSenderInterval time.Duration
|
||||||
FirebaseKeepaliveInterval time.Duration
|
FirebaseKeepaliveInterval time.Duration
|
||||||
|
FirebasePollInterval time.Duration
|
||||||
|
FirebaseQuotaExceededPenaltyDuration time.Duration
|
||||||
|
UpstreamBaseURL string
|
||||||
SMTPSenderAddr string
|
SMTPSenderAddr string
|
||||||
SMTPSenderUser string
|
SMTPSenderUser string
|
||||||
SMTPSenderPass string
|
SMTPSenderPass string
|
||||||
@@ -84,10 +95,12 @@ type Config struct {
|
|||||||
VisitorAttachmentDailyBandwidthLimit int
|
VisitorAttachmentDailyBandwidthLimit int
|
||||||
VisitorRequestLimitBurst int
|
VisitorRequestLimitBurst int
|
||||||
VisitorRequestLimitReplenish time.Duration
|
VisitorRequestLimitReplenish time.Duration
|
||||||
VisitorRequestExemptIPAddrs []string
|
VisitorRequestExemptIPAddrs []netip.Prefix
|
||||||
VisitorEmailLimitBurst int
|
VisitorEmailLimitBurst int
|
||||||
VisitorEmailLimitReplenish time.Duration
|
VisitorEmailLimitReplenish time.Duration
|
||||||
BehindProxy bool
|
BehindProxy bool
|
||||||
|
EnableWeb bool
|
||||||
|
Version string // injected by App
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConfig instantiates a default new server config
|
// NewConfig instantiates a default new server config
|
||||||
@@ -97,11 +110,14 @@ func NewConfig() *Config {
|
|||||||
ListenHTTP: DefaultListenHTTP,
|
ListenHTTP: DefaultListenHTTP,
|
||||||
ListenHTTPS: "",
|
ListenHTTPS: "",
|
||||||
ListenUnix: "",
|
ListenUnix: "",
|
||||||
|
ListenUnixMode: 0,
|
||||||
KeyFile: "",
|
KeyFile: "",
|
||||||
CertFile: "",
|
CertFile: "",
|
||||||
FirebaseKeyFile: "",
|
FirebaseKeyFile: "",
|
||||||
CacheFile: "",
|
CacheFile: "",
|
||||||
CacheDuration: DefaultCacheDuration,
|
CacheDuration: DefaultCacheDuration,
|
||||||
|
CacheBatchSize: 0,
|
||||||
|
CacheBatchTimeout: 0,
|
||||||
AuthFile: "",
|
AuthFile: "",
|
||||||
AuthDefaultRead: true,
|
AuthDefaultRead: true,
|
||||||
AuthDefaultWrite: true,
|
AuthDefaultWrite: true,
|
||||||
@@ -114,17 +130,21 @@ func NewConfig() *Config {
|
|||||||
MessageLimit: DefaultMessageLengthLimit,
|
MessageLimit: DefaultMessageLengthLimit,
|
||||||
MinDelay: DefaultMinDelay,
|
MinDelay: DefaultMinDelay,
|
||||||
MaxDelay: DefaultMaxDelay,
|
MaxDelay: DefaultMaxDelay,
|
||||||
AtSenderInterval: DefaultAtSenderInterval,
|
DelayedSenderInterval: DefaultDelayedSenderInterval,
|
||||||
FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval,
|
FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval,
|
||||||
|
FirebasePollInterval: DefaultFirebasePollInterval,
|
||||||
|
FirebaseQuotaExceededPenaltyDuration: DefaultFirebaseQuotaExceededPenaltyDuration,
|
||||||
TotalTopicLimit: DefaultTotalTopicLimit,
|
TotalTopicLimit: DefaultTotalTopicLimit,
|
||||||
VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit,
|
VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit,
|
||||||
VisitorAttachmentTotalSizeLimit: DefaultVisitorAttachmentTotalSizeLimit,
|
VisitorAttachmentTotalSizeLimit: DefaultVisitorAttachmentTotalSizeLimit,
|
||||||
VisitorAttachmentDailyBandwidthLimit: DefaultVisitorAttachmentDailyBandwidthLimit,
|
VisitorAttachmentDailyBandwidthLimit: DefaultVisitorAttachmentDailyBandwidthLimit,
|
||||||
VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst,
|
VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst,
|
||||||
VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish,
|
VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish,
|
||||||
VisitorRequestExemptIPAddrs: make([]string, 0),
|
VisitorRequestExemptIPAddrs: make([]netip.Prefix, 0),
|
||||||
VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst,
|
VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst,
|
||||||
VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish,
|
VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish,
|
||||||
BehindProxy: false,
|
BehindProxy: false,
|
||||||
|
EnableWeb: true,
|
||||||
|
Version: "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func (e errHTTP) JSON() string {
|
|||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
func wrapErrHTTP(err *errHTTP, message string, args ...interface{}) *errHTTP {
|
func wrapErrHTTP(err *errHTTP, message string, args ...any) *errHTTP {
|
||||||
return &errHTTP{
|
return &errHTTP{
|
||||||
Code: err.Code,
|
Code: err.Code,
|
||||||
HTTPCode: err.HTTPCode,
|
HTTPCode: err.HTTPCode,
|
||||||
@@ -50,10 +50,14 @@ var (
|
|||||||
errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets"}
|
errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets"}
|
||||||
errHTTPBadRequestJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json"}
|
errHTTPBadRequestJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json"}
|
||||||
errHTTPBadRequestActionsInvalid = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions invalid", "https://ntfy.sh/docs/publish/#action-buttons"}
|
errHTTPBadRequestActionsInvalid = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions invalid", "https://ntfy.sh/docs/publish/#action-buttons"}
|
||||||
|
errHTTPBadRequestMatrixMessageInvalid = &errHTTP{40019, http.StatusBadRequest, "invalid request: Matrix JSON invalid", "https://ntfy.sh/docs/publish/#matrix-gateway"}
|
||||||
|
errHTTPBadRequestMatrixPushkeyBaseURLMismatch = &errHTTP{40020, http.StatusBadRequest, "invalid request: push key must be prefixed with base URL", "https://ntfy.sh/docs/publish/#matrix-gateway"}
|
||||||
|
errHTTPBadRequestIconURLInvalid = &errHTTP{40021, http.StatusBadRequest, "invalid request: icon URL is invalid", "https://ntfy.sh/docs/publish/#icons"}
|
||||||
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
|
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
|
||||||
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"}
|
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"}
|
||||||
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"}
|
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"}
|
||||||
errHTTPEntityTooLargeAttachmentTooLarge = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"}
|
errHTTPEntityTooLargeAttachmentTooLarge = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
|
errHTTPEntityTooLargeMatrixRequestTooLarge = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", ""}
|
||||||
errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
|
errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
|
errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
|
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
@@ -61,4 +65,5 @@ var (
|
|||||||
errHTTPTooManyRequestsAttachmentBandwidthLimit = &errHTTP{42905, http.StatusTooManyRequests, "too many requests: daily bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"}
|
errHTTPTooManyRequestsAttachmentBandwidthLimit = &errHTTP{42905, http.StatusTooManyRequests, "too many requests: daily bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""}
|
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""}
|
||||||
errHTTPInternalErrorInvalidFilePath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid file path", ""}
|
errHTTPInternalErrorInvalidFilePath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid file path", ""}
|
||||||
|
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/"}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>ntfy.sh: EventSource Example</title>
|
|
||||||
<meta name="robots" content="noindex, nofollow" />
|
|
||||||
<style>
|
|
||||||
body { font-size: 1.2em; line-height: 130%; }
|
|
||||||
#events { font-family: monospace; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>ntfy.sh: EventSource Example</h1>
|
|
||||||
<p>
|
|
||||||
This is an example showing how to use <a href="https://ntfy.sh">ntfy.sh</a> with
|
|
||||||
<a href="https://developer.mozilla.org/en-US/docs/Web/API/EventSource">EventSource</a>.<br/>
|
|
||||||
This example doesn't need a server. You can just save the HTML page and run it from anywhere.
|
|
||||||
</p>
|
|
||||||
<button id="publishButton">Send test notification</button>
|
|
||||||
<p><b>Log:</b></p>
|
|
||||||
<div id="events"></div>
|
|
||||||
|
|
||||||
<script type="text/javascript">
|
|
||||||
const publishURL = `https://ntfy.sh/example`;
|
|
||||||
const subscribeURL = `https://ntfy.sh/example/sse`;
|
|
||||||
const events = document.getElementById('events');
|
|
||||||
const eventSource = new EventSource(subscribeURL);
|
|
||||||
|
|
||||||
// Publish button
|
|
||||||
document.getElementById("publishButton").onclick = () => {
|
|
||||||
fetch(publishURL, {
|
|
||||||
method: 'POST', // works with PUT as well, though that sends an OPTIONS request too!
|
|
||||||
body: `It is ${new Date().toString()}. This is a test.`
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
// Incoming events
|
|
||||||
eventSource.onopen = () => {
|
|
||||||
let event = document.createElement('div');
|
|
||||||
event.innerHTML = `EventSource connected to ${subscribeURL}`;
|
|
||||||
events.appendChild(event);
|
|
||||||
};
|
|
||||||
eventSource.onerror = (e) => {
|
|
||||||
let event = document.createElement('div');
|
|
||||||
event.innerHTML = `EventSource error: Failed to connect to ${subscribeURL}`;
|
|
||||||
events.appendChild(event);
|
|
||||||
};
|
|
||||||
eventSource.onmessage = (e) => {
|
|
||||||
let event = document.createElement('div');
|
|
||||||
event.innerHTML = e.data;
|
|
||||||
events.appendChild(event);
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -2,16 +2,18 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
fileIDRegex = regexp.MustCompile(`^[-_A-Za-z0-9]+$`)
|
fileIDRegex = regexp.MustCompile(fmt.Sprintf(`^[-_A-Za-z0-9]{%d}$`, messageIDLength))
|
||||||
errInvalidFileID = errors.New("invalid file ID")
|
errInvalidFileID = errors.New("invalid file ID")
|
||||||
errFileExists = errors.New("file exists")
|
errFileExists = errors.New("file exists")
|
||||||
)
|
)
|
||||||
@@ -88,6 +90,25 @@ func (c *fileCache) Remove(ids ...string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expired returns a list of file IDs for expired files
|
||||||
|
func (c *fileCache) Expired(olderThan time.Time) ([]string, error) {
|
||||||
|
entries, err := os.ReadDir(c.dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var ids []string
|
||||||
|
for _, e := range entries {
|
||||||
|
info, err := e.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if info.ModTime().Before(olderThan) && fileIDRegex.MatchString(e.Name()) {
|
||||||
|
ids = append(ids, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *fileCache) Size() int64 {
|
func (c *fileCache) Size() int64 {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -16,10 +17,10 @@ var (
|
|||||||
|
|
||||||
func TestFileCache_Write_Success(t *testing.T) {
|
func TestFileCache_Write_Success(t *testing.T) {
|
||||||
dir, c := newTestFileCache(t)
|
dir, c := newTestFileCache(t)
|
||||||
size, err := c.Write("abc", strings.NewReader("normal file"), util.NewFixedLimiter(999))
|
size, err := c.Write("abcdefghijkl", strings.NewReader("normal file"), util.NewFixedLimiter(999))
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, int64(11), size)
|
require.Equal(t, int64(11), size)
|
||||||
require.Equal(t, "normal file", readFile(t, dir+"/abc"))
|
require.Equal(t, "normal file", readFile(t, dir+"/abcdefghijkl"))
|
||||||
require.Equal(t, int64(11), c.Size())
|
require.Equal(t, int64(11), c.Size())
|
||||||
require.Equal(t, int64(10229), c.Remaining())
|
require.Equal(t, int64(10229), c.Remaining())
|
||||||
}
|
}
|
||||||
@@ -27,18 +28,18 @@ func TestFileCache_Write_Success(t *testing.T) {
|
|||||||
func TestFileCache_Write_Remove_Success(t *testing.T) {
|
func TestFileCache_Write_Remove_Success(t *testing.T) {
|
||||||
dir, c := newTestFileCache(t) // max = 10k (10240), each = 1k (1024)
|
dir, c := newTestFileCache(t) // max = 10k (10240), each = 1k (1024)
|
||||||
for i := 0; i < 10; i++ { // 10x999 = 9990
|
for i := 0; i < 10; i++ { // 10x999 = 9990
|
||||||
size, err := c.Write(fmt.Sprintf("abc%d", i), bytes.NewReader(make([]byte, 999)))
|
size, err := c.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(make([]byte, 999)))
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, int64(999), size)
|
require.Equal(t, int64(999), size)
|
||||||
}
|
}
|
||||||
require.Equal(t, int64(9990), c.Size())
|
require.Equal(t, int64(9990), c.Size())
|
||||||
require.Equal(t, int64(250), c.Remaining())
|
require.Equal(t, int64(250), c.Remaining())
|
||||||
require.FileExists(t, dir+"/abc1")
|
require.FileExists(t, dir+"/abcdefghijk1")
|
||||||
require.FileExists(t, dir+"/abc5")
|
require.FileExists(t, dir+"/abcdefghijk5")
|
||||||
|
|
||||||
require.Nil(t, c.Remove("abc1", "abc5"))
|
require.Nil(t, c.Remove("abcdefghijk1", "abcdefghijk5"))
|
||||||
require.NoFileExists(t, dir+"/abc1")
|
require.NoFileExists(t, dir+"/abcdefghijk1")
|
||||||
require.NoFileExists(t, dir+"/abc5")
|
require.NoFileExists(t, dir+"/abcdefghijk5")
|
||||||
require.Equal(t, int64(7992), c.Size())
|
require.Equal(t, int64(7992), c.Size())
|
||||||
require.Equal(t, int64(2248), c.Remaining())
|
require.Equal(t, int64(2248), c.Remaining())
|
||||||
}
|
}
|
||||||
@@ -46,27 +47,50 @@ func TestFileCache_Write_Remove_Success(t *testing.T) {
|
|||||||
func TestFileCache_Write_FailedTotalSizeLimit(t *testing.T) {
|
func TestFileCache_Write_FailedTotalSizeLimit(t *testing.T) {
|
||||||
dir, c := newTestFileCache(t)
|
dir, c := newTestFileCache(t)
|
||||||
for i := 0; i < 10; i++ {
|
for i := 0; i < 10; i++ {
|
||||||
size, err := c.Write(fmt.Sprintf("abc%d", i), bytes.NewReader(oneKilobyteArray))
|
size, err := c.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(oneKilobyteArray))
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, int64(1024), size)
|
require.Equal(t, int64(1024), size)
|
||||||
}
|
}
|
||||||
_, err := c.Write("abc11", bytes.NewReader(oneKilobyteArray))
|
_, err := c.Write("abcdefghijkX", bytes.NewReader(oneKilobyteArray))
|
||||||
require.Equal(t, util.ErrLimitReached, err)
|
require.Equal(t, util.ErrLimitReached, err)
|
||||||
require.NoFileExists(t, dir+"/abc11")
|
require.NoFileExists(t, dir+"/abcdefghijkX")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileCache_Write_FailedFileSizeLimit(t *testing.T) {
|
func TestFileCache_Write_FailedFileSizeLimit(t *testing.T) {
|
||||||
dir, c := newTestFileCache(t)
|
dir, c := newTestFileCache(t)
|
||||||
_, err := c.Write("abc", bytes.NewReader(make([]byte, 1025)))
|
_, err := c.Write("abcdefghijkl", bytes.NewReader(make([]byte, 1025)))
|
||||||
require.Equal(t, util.ErrLimitReached, err)
|
require.Equal(t, util.ErrLimitReached, err)
|
||||||
require.NoFileExists(t, dir+"/abc")
|
require.NoFileExists(t, dir+"/abcdefghijkl")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileCache_Write_FailedAdditionalLimiter(t *testing.T) {
|
func TestFileCache_Write_FailedAdditionalLimiter(t *testing.T) {
|
||||||
dir, c := newTestFileCache(t)
|
dir, c := newTestFileCache(t)
|
||||||
_, err := c.Write("abc", bytes.NewReader(make([]byte, 1001)), util.NewFixedLimiter(1000))
|
_, err := c.Write("abcdefghijkl", bytes.NewReader(make([]byte, 1001)), util.NewFixedLimiter(1000))
|
||||||
require.Equal(t, util.ErrLimitReached, err)
|
require.Equal(t, util.ErrLimitReached, err)
|
||||||
require.NoFileExists(t, dir+"/abc")
|
require.NoFileExists(t, dir+"/abcdefghijkl")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileCache_RemoveExpired(t *testing.T) {
|
||||||
|
dir, c := newTestFileCache(t)
|
||||||
|
_, err := c.Write("abcdefghijkl", bytes.NewReader(make([]byte, 1001)))
|
||||||
|
require.Nil(t, err)
|
||||||
|
_, err = c.Write("notdeleted12", bytes.NewReader(make([]byte, 1001)))
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
modTime := time.Now().Add(-1 * 4 * time.Hour)
|
||||||
|
require.Nil(t, os.Chtimes(dir+"/abcdefghijkl", modTime, modTime))
|
||||||
|
|
||||||
|
olderThan := time.Now().Add(-1 * 3 * time.Hour)
|
||||||
|
ids, err := c.Expired(olderThan)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, []string{"abcdefghijkl"}, ids)
|
||||||
|
require.Nil(t, c.Remove(ids...))
|
||||||
|
require.NoFileExists(t, dir+"/abcdefghijkl")
|
||||||
|
require.FileExists(t, dir+"/notdeleted12")
|
||||||
|
|
||||||
|
ids, err = c.Expired(olderThan)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Empty(t, ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestFileCache(t *testing.T) (dir string, cache *fileCache) {
|
func newTestFileCache(t *testing.T) (dir string, cache *fileCache) {
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
"net/netip"
|
||||||
"heckel.io/ntfy/util"
|
|
||||||
"log"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -30,67 +32,68 @@ const (
|
|||||||
priority INT NOT NULL,
|
priority INT NOT NULL,
|
||||||
tags TEXT NOT NULL,
|
tags TEXT NOT NULL,
|
||||||
click TEXT NOT NULL,
|
click TEXT NOT NULL,
|
||||||
|
icon TEXT NOT NULL,
|
||||||
actions TEXT NOT NULL,
|
actions TEXT NOT NULL,
|
||||||
attachment_name TEXT NOT NULL,
|
attachment_name TEXT NOT NULL,
|
||||||
attachment_type TEXT NOT NULL,
|
attachment_type TEXT NOT NULL,
|
||||||
attachment_size INT NOT NULL,
|
attachment_size INT NOT NULL,
|
||||||
attachment_expires INT NOT NULL,
|
attachment_expires INT NOT NULL,
|
||||||
attachment_url TEXT NOT NULL,
|
attachment_url TEXT NOT NULL,
|
||||||
attachment_owner TEXT NOT NULL,
|
sender TEXT NOT NULL,
|
||||||
encoding TEXT NOT NULL,
|
encoding TEXT NOT NULL,
|
||||||
published INT NOT NULL
|
published INT NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid);
|
CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
|
||||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||||
COMMIT;
|
COMMIT;
|
||||||
`
|
`
|
||||||
insertMessageQuery = `
|
insertMessageQuery = `
|
||||||
INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published)
|
INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, published)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
pruneMessagesQuery = `DELETE FROM messages WHERE time < ? AND published = 1`
|
pruneMessagesQuery = `DELETE FROM messages WHERE time < ? AND published = 1`
|
||||||
selectRowIDFromMessageID = `SELECT id FROM messages WHERE topic = ? AND mid = ?`
|
selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
|
||||||
selectMessagesSinceTimeQuery = `
|
selectMessagesSinceTimeQuery = `
|
||||||
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
SELECT mid, time, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND time >= ? AND published = 1
|
WHERE topic = ? AND time >= ? AND published = 1
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
selectMessagesSinceTimeIncludeScheduledQuery = `
|
selectMessagesSinceTimeIncludeScheduledQuery = `
|
||||||
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
SELECT mid, time, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND time >= ?
|
WHERE topic = ? AND time >= ?
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
selectMessagesSinceIDQuery = `
|
selectMessagesSinceIDQuery = `
|
||||||
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
SELECT mid, time, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND id > ? AND published = 1
|
WHERE topic = ? AND id > ? AND published = 1
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
selectMessagesSinceIDIncludeScheduledQuery = `
|
selectMessagesSinceIDIncludeScheduledQuery = `
|
||||||
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
SELECT mid, time, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND (id > ? OR published = 0)
|
WHERE topic = ? AND (id > ? OR published = 0)
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
selectMessagesDueQuery = `
|
selectMessagesDueQuery = `
|
||||||
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
SELECT mid, time, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE time <= ? AND published = 0
|
WHERE time <= ? AND published = 0
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?`
|
updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?`
|
||||||
selectMessagesCountQuery = `SELECT COUNT(*) FROM messages`
|
selectMessagesCountQuery = `SELECT COUNT(*) FROM messages`
|
||||||
selectMessageCountForTopicQuery = `SELECT COUNT(*) FROM messages WHERE topic = ?`
|
selectMessageCountPerTopicQuery = `SELECT topic, COUNT(*) FROM messages GROUP BY topic`
|
||||||
selectTopicsQuery = `SELECT topic FROM messages GROUP BY topic`
|
selectTopicsQuery = `SELECT topic FROM messages GROUP BY topic`
|
||||||
selectAttachmentsSizeQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE attachment_owner = ? AND attachment_expires >= ?`
|
selectAttachmentsSizeQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE sender = ? AND attachment_expires >= ?`
|
||||||
selectAttachmentsExpiredQuery = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires < ?`
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Schema management queries
|
// Schema management queries
|
||||||
const (
|
const (
|
||||||
currentSchemaVersion = 6
|
currentSchemaVersion = 9
|
||||||
createSchemaVersionTableQuery = `
|
createSchemaVersionTableQuery = `
|
||||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||||
id INT PRIMARY KEY,
|
id INT PRIMARY KEY,
|
||||||
@@ -173,37 +176,60 @@ const (
|
|||||||
migrate5To6AlterMessagesTableQuery = `
|
migrate5To6AlterMessagesTableQuery = `
|
||||||
ALTER TABLE messages ADD COLUMN actions TEXT NOT NULL DEFAULT('');
|
ALTER TABLE messages ADD COLUMN actions TEXT NOT NULL DEFAULT('');
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// 6 -> 7
|
||||||
|
migrate6To7AlterMessagesTableQuery = `
|
||||||
|
ALTER TABLE messages RENAME COLUMN attachment_owner TO sender;
|
||||||
|
`
|
||||||
|
|
||||||
|
// 7 -> 8
|
||||||
|
migrate7To8AlterMessagesTableQuery = `
|
||||||
|
ALTER TABLE messages ADD COLUMN icon TEXT NOT NULL DEFAULT('');
|
||||||
|
`
|
||||||
|
|
||||||
|
// 8 -> 9
|
||||||
|
migrate8To9AlterMessagesTableQuery = `
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
|
||||||
|
`
|
||||||
)
|
)
|
||||||
|
|
||||||
type messageCache struct {
|
type messageCache struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
nop bool
|
queue *util.BatchingQueue[*message]
|
||||||
|
nop bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// newSqliteCache creates a SQLite file-backed cache
|
// newSqliteCache creates a SQLite file-backed cache
|
||||||
func newSqliteCache(filename string, nop bool) (*messageCache, error) {
|
func newSqliteCache(filename, startupQueries string, batchSize int, batchTimeout time.Duration, nop bool) (*messageCache, error) {
|
||||||
db, err := sql.Open("sqlite3", filename)
|
db, err := sql.Open("sqlite3", filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := setupCacheDB(db); err != nil {
|
if err := setupCacheDB(db, startupQueries); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &messageCache{
|
var queue *util.BatchingQueue[*message]
|
||||||
db: db,
|
if batchSize > 0 || batchTimeout > 0 {
|
||||||
nop: nop,
|
queue = util.NewBatchingQueue[*message](batchSize, batchTimeout)
|
||||||
}, nil
|
}
|
||||||
|
cache := &messageCache{
|
||||||
|
db: db,
|
||||||
|
queue: queue,
|
||||||
|
nop: nop,
|
||||||
|
}
|
||||||
|
go cache.processMessageBatches()
|
||||||
|
return cache, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// newMemCache creates an in-memory cache
|
// newMemCache creates an in-memory cache
|
||||||
func newMemCache() (*messageCache, error) {
|
func newMemCache() (*messageCache, error) {
|
||||||
return newSqliteCache(createMemoryFilename(), false)
|
return newSqliteCache(createMemoryFilename(), "", 0, 0, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// newNopCache creates an in-memory cache that discards all messages;
|
// newNopCache creates an in-memory cache that discards all messages;
|
||||||
// it is always empty and can be used if caching is entirely disabled
|
// it is always empty and can be used if caching is entirely disabled
|
||||||
func newNopCache() (*messageCache, error) {
|
func newNopCache() (*messageCache, error) {
|
||||||
return newSqliteCache(createMemoryFilename(), true)
|
return newSqliteCache(createMemoryFilename(), "", 0, 0, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// createMemoryFilename creates a unique memory filename to use for the SQLite backend.
|
// createMemoryFilename creates a unique memory filename to use for the SQLite backend.
|
||||||
@@ -216,54 +242,93 @@ func createMemoryFilename() string {
|
|||||||
return fmt.Sprintf("file:%s?mode=memory&cache=shared", util.RandomString(10))
|
return fmt.Sprintf("file:%s?mode=memory&cache=shared", util.RandomString(10))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddMessage stores a message to the message cache synchronously, or queues it to be stored at a later date asyncronously.
|
||||||
|
// The message is queued only if "batchSize" or "batchTimeout" are passed to the constructor.
|
||||||
func (c *messageCache) AddMessage(m *message) error {
|
func (c *messageCache) AddMessage(m *message) error {
|
||||||
if m.Event != messageEvent {
|
if c.queue != nil {
|
||||||
return errUnexpectedMessageType
|
c.queue.Enqueue(m)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
return c.addMessages([]*message{m})
|
||||||
|
}
|
||||||
|
|
||||||
|
// addMessages synchronously stores a match of messages. If the database is locked, the transaction waits until
|
||||||
|
// SQLite's busy_timeout is exceeded before erroring out.
|
||||||
|
func (c *messageCache) addMessages(ms []*message) error {
|
||||||
if c.nop {
|
if c.nop {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
published := m.Time <= time.Now().Unix()
|
if len(ms) == 0 {
|
||||||
tags := strings.Join(m.Tags, ",")
|
return nil
|
||||||
var attachmentName, attachmentType, attachmentURL, attachmentOwner string
|
|
||||||
var attachmentSize, attachmentExpires int64
|
|
||||||
if m.Attachment != nil {
|
|
||||||
attachmentName = m.Attachment.Name
|
|
||||||
attachmentType = m.Attachment.Type
|
|
||||||
attachmentSize = m.Attachment.Size
|
|
||||||
attachmentExpires = m.Attachment.Expires
|
|
||||||
attachmentURL = m.Attachment.URL
|
|
||||||
attachmentOwner = m.Attachment.Owner
|
|
||||||
}
|
}
|
||||||
var actionsStr string
|
start := time.Now()
|
||||||
if len(m.Actions) > 0 {
|
tx, err := c.db.Begin()
|
||||||
actionsBytes, err := json.Marshal(m.Actions)
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
stmt, err := tx.Prepare(insertMessageQuery)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
for _, m := range ms {
|
||||||
|
if m.Event != messageEvent {
|
||||||
|
return errUnexpectedMessageType
|
||||||
|
}
|
||||||
|
published := m.Time <= time.Now().Unix()
|
||||||
|
tags := strings.Join(m.Tags, ",")
|
||||||
|
var attachmentName, attachmentType, attachmentURL string
|
||||||
|
var attachmentSize, attachmentExpires int64
|
||||||
|
if m.Attachment != nil {
|
||||||
|
attachmentName = m.Attachment.Name
|
||||||
|
attachmentType = m.Attachment.Type
|
||||||
|
attachmentSize = m.Attachment.Size
|
||||||
|
attachmentExpires = m.Attachment.Expires
|
||||||
|
attachmentURL = m.Attachment.URL
|
||||||
|
}
|
||||||
|
var actionsStr string
|
||||||
|
if len(m.Actions) > 0 {
|
||||||
|
actionsBytes, err := json.Marshal(m.Actions)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
actionsStr = string(actionsBytes)
|
||||||
|
}
|
||||||
|
var sender string
|
||||||
|
if m.Sender.IsValid() {
|
||||||
|
sender = m.Sender.String()
|
||||||
|
}
|
||||||
|
_, err := stmt.Exec(
|
||||||
|
m.ID,
|
||||||
|
m.Time,
|
||||||
|
m.Topic,
|
||||||
|
m.Message,
|
||||||
|
m.Title,
|
||||||
|
m.Priority,
|
||||||
|
tags,
|
||||||
|
m.Click,
|
||||||
|
m.Icon,
|
||||||
|
actionsStr,
|
||||||
|
attachmentName,
|
||||||
|
attachmentType,
|
||||||
|
attachmentSize,
|
||||||
|
attachmentExpires,
|
||||||
|
attachmentURL,
|
||||||
|
sender,
|
||||||
|
m.Encoding,
|
||||||
|
published,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
actionsStr = string(actionsBytes)
|
|
||||||
}
|
}
|
||||||
_, err := c.db.Exec(
|
if err := tx.Commit(); err != nil {
|
||||||
insertMessageQuery,
|
log.Error("Cache: Writing %d message(s) failed (took %v)", len(ms), time.Since(start))
|
||||||
m.ID,
|
return err
|
||||||
m.Time,
|
}
|
||||||
m.Topic,
|
log.Debug("Cache: Wrote %d message(s) in %v", len(ms), time.Since(start))
|
||||||
m.Message,
|
return nil
|
||||||
m.Title,
|
|
||||||
m.Priority,
|
|
||||||
tags,
|
|
||||||
m.Click,
|
|
||||||
actionsStr,
|
|
||||||
attachmentName,
|
|
||||||
attachmentType,
|
|
||||||
attachmentSize,
|
|
||||||
attachmentExpires,
|
|
||||||
attachmentURL,
|
|
||||||
attachmentOwner,
|
|
||||||
m.Encoding,
|
|
||||||
published,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) Messages(topic string, since sinceMarker, scheduled bool) ([]*message, error) {
|
func (c *messageCache) Messages(topic string, since sinceMarker, scheduled bool) ([]*message, error) {
|
||||||
@@ -290,7 +355,7 @@ func (c *messageCache) messagesSinceTime(topic string, since sinceMarker, schedu
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) messagesSinceID(topic string, since sinceMarker, scheduled bool) ([]*message, error) {
|
func (c *messageCache) messagesSinceID(topic string, since sinceMarker, scheduled bool) ([]*message, error) {
|
||||||
idrows, err := c.db.Query(selectRowIDFromMessageID, topic, since.ID())
|
idrows, err := c.db.Query(selectRowIDFromMessageID, since.ID())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -328,22 +393,24 @@ func (c *messageCache) MarkPublished(m *message) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) MessageCount(topic string) (int, error) {
|
func (c *messageCache) MessageCounts() (map[string]int, error) {
|
||||||
rows, err := c.db.Query(selectMessageCountForTopicQuery, topic)
|
rows, err := c.db.Query(selectMessageCountPerTopicQuery)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
var topic string
|
||||||
var count int
|
var count int
|
||||||
if !rows.Next() {
|
counts := make(map[string]int)
|
||||||
return 0, errors.New("no rows found")
|
for rows.Next() {
|
||||||
|
if err := rows.Scan(&topic, &count); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
counts[topic] = count
|
||||||
}
|
}
|
||||||
if err := rows.Scan(&count); err != nil {
|
return counts, nil
|
||||||
return 0, err
|
|
||||||
} else if err := rows.Err(); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return count, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) Topics() (map[string]*topic, error) {
|
func (c *messageCache) Topics() (map[string]*topic, error) {
|
||||||
@@ -367,12 +434,16 @@ func (c *messageCache) Topics() (map[string]*topic, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) Prune(olderThan time.Time) error {
|
func (c *messageCache) Prune(olderThan time.Time) error {
|
||||||
_, err := c.db.Exec(pruneMessagesQuery, olderThan.Unix())
|
start := time.Now()
|
||||||
return err
|
if _, err := c.db.Exec(pruneMessagesQuery, olderThan.Unix()); err != nil {
|
||||||
|
log.Warn("Cache: Pruning failed (after %v): %s", time.Since(start), err.Error())
|
||||||
|
}
|
||||||
|
log.Debug("Cache: Pruning successful (took %v)", time.Since(start))
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) AttachmentBytesUsed(owner string) (int64, error) {
|
func (c *messageCache) AttachmentBytesUsed(sender string) (int64, error) {
|
||||||
rows, err := c.db.Query(selectAttachmentsSizeQuery, owner, time.Now().Unix())
|
rows, err := c.db.Query(selectAttachmentsSizeQuery, sender, time.Now().Unix())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -389,24 +460,15 @@ func (c *messageCache) AttachmentBytesUsed(owner string) (int64, error) {
|
|||||||
return size, nil
|
return size, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) AttachmentsExpired() ([]string, error) {
|
func (c *messageCache) processMessageBatches() {
|
||||||
rows, err := c.db.Query(selectAttachmentsExpiredQuery, time.Now().Unix())
|
if c.queue == nil {
|
||||||
if err != nil {
|
return
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
for messages := range c.queue.Dequeue() {
|
||||||
ids := make([]string, 0)
|
if err := c.addMessages(messages); err != nil {
|
||||||
for rows.Next() {
|
log.Error("Cache: %s", err.Error())
|
||||||
var id string
|
|
||||||
if err := rows.Scan(&id); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return ids, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func readMessages(rows *sql.Rows) ([]*message, error) {
|
func readMessages(rows *sql.Rows) ([]*message, error) {
|
||||||
@@ -415,7 +477,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var timestamp, attachmentSize, attachmentExpires int64
|
var timestamp, attachmentSize, attachmentExpires int64
|
||||||
var priority int
|
var priority int
|
||||||
var id, topic, msg, title, tagsStr, click, actionsStr, attachmentName, attachmentType, attachmentURL, attachmentOwner, encoding string
|
var id, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, encoding string
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&id,
|
&id,
|
||||||
×tamp,
|
×tamp,
|
||||||
@@ -425,13 +487,14 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
|||||||
&priority,
|
&priority,
|
||||||
&tagsStr,
|
&tagsStr,
|
||||||
&click,
|
&click,
|
||||||
|
&icon,
|
||||||
&actionsStr,
|
&actionsStr,
|
||||||
&attachmentName,
|
&attachmentName,
|
||||||
&attachmentType,
|
&attachmentType,
|
||||||
&attachmentSize,
|
&attachmentSize,
|
||||||
&attachmentExpires,
|
&attachmentExpires,
|
||||||
&attachmentURL,
|
&attachmentURL,
|
||||||
&attachmentOwner,
|
&sender,
|
||||||
&encoding,
|
&encoding,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -447,6 +510,10 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
senderIP, err := netip.ParseAddr(sender)
|
||||||
|
if err != nil {
|
||||||
|
senderIP = netip.Addr{} // if no IP stored in database, return invalid address
|
||||||
|
}
|
||||||
var att *attachment
|
var att *attachment
|
||||||
if attachmentName != "" && attachmentURL != "" {
|
if attachmentName != "" && attachmentURL != "" {
|
||||||
att = &attachment{
|
att = &attachment{
|
||||||
@@ -455,7 +522,6 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
|||||||
Size: attachmentSize,
|
Size: attachmentSize,
|
||||||
Expires: attachmentExpires,
|
Expires: attachmentExpires,
|
||||||
URL: attachmentURL,
|
URL: attachmentURL,
|
||||||
Owner: attachmentOwner,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
messages = append(messages, &message{
|
messages = append(messages, &message{
|
||||||
@@ -468,8 +534,10 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
|||||||
Priority: priority,
|
Priority: priority,
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
Click: click,
|
Click: click,
|
||||||
|
Icon: icon,
|
||||||
Actions: actions,
|
Actions: actions,
|
||||||
Attachment: att,
|
Attachment: att,
|
||||||
|
Sender: senderIP, // Must parse assuming database must be correct
|
||||||
Encoding: encoding,
|
Encoding: encoding,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -479,7 +547,14 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
|||||||
return messages, nil
|
return messages, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupCacheDB(db *sql.DB) error {
|
func setupCacheDB(db *sql.DB, startupQueries string) error {
|
||||||
|
// Run startup queries
|
||||||
|
if startupQueries != "" {
|
||||||
|
if _, err := db.Exec(startupQueries); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If 'messages' table does not exist, this must be a new database
|
// If 'messages' table does not exist, this must be a new database
|
||||||
rowsMC, err := db.Query(selectMessagesCountQuery)
|
rowsMC, err := db.Query(selectMessagesCountQuery)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -516,6 +591,12 @@ func setupCacheDB(db *sql.DB) error {
|
|||||||
return migrateFrom4(db)
|
return migrateFrom4(db)
|
||||||
} else if schemaVersion == 5 {
|
} else if schemaVersion == 5 {
|
||||||
return migrateFrom5(db)
|
return migrateFrom5(db)
|
||||||
|
} else if schemaVersion == 6 {
|
||||||
|
return migrateFrom6(db)
|
||||||
|
} else if schemaVersion == 7 {
|
||||||
|
return migrateFrom7(db)
|
||||||
|
} else if schemaVersion == 8 {
|
||||||
|
return migrateFrom8(db)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("unexpected schema version found: %d", schemaVersion)
|
return fmt.Errorf("unexpected schema version found: %d", schemaVersion)
|
||||||
}
|
}
|
||||||
@@ -534,7 +615,7 @@ func setupNewCacheDB(db *sql.DB) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom0(db *sql.DB) error {
|
func migrateFrom0(db *sql.DB) error {
|
||||||
log.Print("Migrating cache database schema: from 0 to 1")
|
log.Info("Migrating cache database schema: from 0 to 1")
|
||||||
if _, err := db.Exec(migrate0To1AlterMessagesTableQuery); err != nil {
|
if _, err := db.Exec(migrate0To1AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -548,7 +629,7 @@ func migrateFrom0(db *sql.DB) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom1(db *sql.DB) error {
|
func migrateFrom1(db *sql.DB) error {
|
||||||
log.Print("Migrating cache database schema: from 1 to 2")
|
log.Info("Migrating cache database schema: from 1 to 2")
|
||||||
if _, err := db.Exec(migrate1To2AlterMessagesTableQuery); err != nil {
|
if _, err := db.Exec(migrate1To2AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -559,7 +640,7 @@ func migrateFrom1(db *sql.DB) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom2(db *sql.DB) error {
|
func migrateFrom2(db *sql.DB) error {
|
||||||
log.Print("Migrating cache database schema: from 2 to 3")
|
log.Info("Migrating cache database schema: from 2 to 3")
|
||||||
if _, err := db.Exec(migrate2To3AlterMessagesTableQuery); err != nil {
|
if _, err := db.Exec(migrate2To3AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -570,7 +651,7 @@ func migrateFrom2(db *sql.DB) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom3(db *sql.DB) error {
|
func migrateFrom3(db *sql.DB) error {
|
||||||
log.Print("Migrating cache database schema: from 3 to 4")
|
log.Info("Migrating cache database schema: from 3 to 4")
|
||||||
if _, err := db.Exec(migrate3To4AlterMessagesTableQuery); err != nil {
|
if _, err := db.Exec(migrate3To4AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -581,7 +662,7 @@ func migrateFrom3(db *sql.DB) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom4(db *sql.DB) error {
|
func migrateFrom4(db *sql.DB) error {
|
||||||
log.Print("Migrating cache database schema: from 4 to 5")
|
log.Info("Migrating cache database schema: from 4 to 5")
|
||||||
if _, err := db.Exec(migrate4To5AlterMessagesTableQuery); err != nil {
|
if _, err := db.Exec(migrate4To5AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -592,12 +673,45 @@ func migrateFrom4(db *sql.DB) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom5(db *sql.DB) error {
|
func migrateFrom5(db *sql.DB) error {
|
||||||
log.Print("Migrating cache database schema: from 5 to 6")
|
log.Info("Migrating cache database schema: from 5 to 6")
|
||||||
if _, err := db.Exec(migrate5To6AlterMessagesTableQuery); err != nil {
|
if _, err := db.Exec(migrate5To6AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := db.Exec(updateSchemaVersion, 6); err != nil {
|
if _, err := db.Exec(updateSchemaVersion, 6); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return migrateFrom6(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateFrom6(db *sql.DB) error {
|
||||||
|
log.Info("Migrating cache database schema: from 6 to 7")
|
||||||
|
if _, err := db.Exec(migrate6To7AlterMessagesTableQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(updateSchemaVersion, 7); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return migrateFrom7(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateFrom7(db *sql.DB) error {
|
||||||
|
log.Info("Migrating cache database schema: from 7 to 8")
|
||||||
|
if _, err := db.Exec(migrate7To8AlterMessagesTableQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(updateSchemaVersion, 8); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return migrateFrom8(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateFrom8(db *sql.DB) error {
|
||||||
|
log.Info("Migrating cache database schema: from 8 to 9")
|
||||||
|
if _, err := db.Exec(migrate8To9AlterMessagesTableQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(updateSchemaVersion, 9); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil // Update this when a new version is added
|
return nil // Update this when a new version is added
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,17 @@ package server
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/stretchr/testify/assert"
|
"net/netip"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
exampleIP1234 = netip.MustParseAddr("1.2.3.4")
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSqliteCache_Messages(t *testing.T) {
|
func TestSqliteCache_Messages(t *testing.T) {
|
||||||
@@ -34,9 +40,9 @@ func testCacheMessages(t *testing.T, c *messageCache) {
|
|||||||
require.Equal(t, errUnexpectedMessageType, c.AddMessage(newOpenMessage("example"))) // These should not be added!
|
require.Equal(t, errUnexpectedMessageType, c.AddMessage(newOpenMessage("example"))) // These should not be added!
|
||||||
|
|
||||||
// mytopic: count
|
// mytopic: count
|
||||||
count, err := c.MessageCount("mytopic")
|
counts, err := c.MessageCounts()
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, 2, count)
|
require.Equal(t, 2, counts["mytopic"])
|
||||||
|
|
||||||
// mytopic: since all
|
// mytopic: since all
|
||||||
messages, _ := c.Messages("mytopic", sinceAllMessages, false)
|
messages, _ := c.Messages("mytopic", sinceAllMessages, false)
|
||||||
@@ -66,18 +72,18 @@ func testCacheMessages(t *testing.T, c *messageCache) {
|
|||||||
require.Equal(t, "my other message", messages[0].Message)
|
require.Equal(t, "my other message", messages[0].Message)
|
||||||
|
|
||||||
// example: count
|
// example: count
|
||||||
count, err = c.MessageCount("example")
|
counts, err = c.MessageCounts()
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, 1, count)
|
require.Equal(t, 1, counts["example"])
|
||||||
|
|
||||||
// example: since all
|
// example: since all
|
||||||
messages, _ = c.Messages("example", sinceAllMessages, false)
|
messages, _ = c.Messages("example", sinceAllMessages, false)
|
||||||
require.Equal(t, "my example message", messages[0].Message)
|
require.Equal(t, "my example message", messages[0].Message)
|
||||||
|
|
||||||
// non-existing: count
|
// non-existing: count
|
||||||
count, err = c.MessageCount("doesnotexist")
|
counts, err = c.MessageCounts()
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, 0, count)
|
require.Equal(t, 0, counts["doesnotexist"])
|
||||||
|
|
||||||
// non-existing: since all
|
// non-existing: since all
|
||||||
messages, _ = c.Messages("doesnotexist", sinceAllMessages, false)
|
messages, _ = c.Messages("doesnotexist", sinceAllMessages, false)
|
||||||
@@ -255,13 +261,13 @@ func testCachePrune(t *testing.T, c *messageCache) {
|
|||||||
require.Nil(t, c.AddMessage(m3))
|
require.Nil(t, c.AddMessage(m3))
|
||||||
require.Nil(t, c.Prune(time.Unix(2, 0)))
|
require.Nil(t, c.Prune(time.Unix(2, 0)))
|
||||||
|
|
||||||
count, err := c.MessageCount("mytopic")
|
counts, err := c.MessageCounts()
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, 1, count)
|
require.Equal(t, 1, counts["mytopic"])
|
||||||
|
|
||||||
count, err = c.MessageCount("another_topic")
|
counts, err = c.MessageCounts()
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, 0, count)
|
require.Equal(t, 0, counts["another_topic"])
|
||||||
|
|
||||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
@@ -281,39 +287,39 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
|
|||||||
expires1 := time.Now().Add(-4 * time.Hour).Unix()
|
expires1 := time.Now().Add(-4 * time.Hour).Unix()
|
||||||
m := newDefaultMessage("mytopic", "flower for you")
|
m := newDefaultMessage("mytopic", "flower for you")
|
||||||
m.ID = "m1"
|
m.ID = "m1"
|
||||||
|
m.Sender = exampleIP1234
|
||||||
m.Attachment = &attachment{
|
m.Attachment = &attachment{
|
||||||
Name: "flower.jpg",
|
Name: "flower.jpg",
|
||||||
Type: "image/jpeg",
|
Type: "image/jpeg",
|
||||||
Size: 5000,
|
Size: 5000,
|
||||||
Expires: expires1,
|
Expires: expires1,
|
||||||
URL: "https://ntfy.sh/file/AbDeFgJhal.jpg",
|
URL: "https://ntfy.sh/file/AbDeFgJhal.jpg",
|
||||||
Owner: "1.2.3.4",
|
|
||||||
}
|
}
|
||||||
require.Nil(t, c.AddMessage(m))
|
require.Nil(t, c.AddMessage(m))
|
||||||
|
|
||||||
expires2 := time.Now().Add(2 * time.Hour).Unix() // Future
|
expires2 := time.Now().Add(2 * time.Hour).Unix() // Future
|
||||||
m = newDefaultMessage("mytopic", "sending you a car")
|
m = newDefaultMessage("mytopic", "sending you a car")
|
||||||
m.ID = "m2"
|
m.ID = "m2"
|
||||||
|
m.Sender = exampleIP1234
|
||||||
m.Attachment = &attachment{
|
m.Attachment = &attachment{
|
||||||
Name: "car.jpg",
|
Name: "car.jpg",
|
||||||
Type: "image/jpeg",
|
Type: "image/jpeg",
|
||||||
Size: 10000,
|
Size: 10000,
|
||||||
Expires: expires2,
|
Expires: expires2,
|
||||||
URL: "https://ntfy.sh/file/aCaRURL.jpg",
|
URL: "https://ntfy.sh/file/aCaRURL.jpg",
|
||||||
Owner: "1.2.3.4",
|
|
||||||
}
|
}
|
||||||
require.Nil(t, c.AddMessage(m))
|
require.Nil(t, c.AddMessage(m))
|
||||||
|
|
||||||
expires3 := time.Now().Add(1 * time.Hour).Unix() // Future
|
expires3 := time.Now().Add(1 * time.Hour).Unix() // Future
|
||||||
m = newDefaultMessage("another-topic", "sending you another car")
|
m = newDefaultMessage("another-topic", "sending you another car")
|
||||||
m.ID = "m3"
|
m.ID = "m3"
|
||||||
|
m.Sender = exampleIP1234
|
||||||
m.Attachment = &attachment{
|
m.Attachment = &attachment{
|
||||||
Name: "another-car.jpg",
|
Name: "another-car.jpg",
|
||||||
Type: "image/jpeg",
|
Type: "image/jpeg",
|
||||||
Size: 20000,
|
Size: 20000,
|
||||||
Expires: expires3,
|
Expires: expires3,
|
||||||
URL: "https://ntfy.sh/file/zakaDHFW.jpg",
|
URL: "https://ntfy.sh/file/zakaDHFW.jpg",
|
||||||
Owner: "1.2.3.4",
|
|
||||||
}
|
}
|
||||||
require.Nil(t, c.AddMessage(m))
|
require.Nil(t, c.AddMessage(m))
|
||||||
|
|
||||||
@@ -327,7 +333,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
|
|||||||
require.Equal(t, int64(5000), messages[0].Attachment.Size)
|
require.Equal(t, int64(5000), messages[0].Attachment.Size)
|
||||||
require.Equal(t, expires1, messages[0].Attachment.Expires)
|
require.Equal(t, expires1, messages[0].Attachment.Expires)
|
||||||
require.Equal(t, "https://ntfy.sh/file/AbDeFgJhal.jpg", messages[0].Attachment.URL)
|
require.Equal(t, "https://ntfy.sh/file/AbDeFgJhal.jpg", messages[0].Attachment.URL)
|
||||||
require.Equal(t, "1.2.3.4", messages[0].Attachment.Owner)
|
require.Equal(t, "1.2.3.4", messages[0].Sender.String())
|
||||||
|
|
||||||
require.Equal(t, "sending you a car", messages[1].Message)
|
require.Equal(t, "sending you a car", messages[1].Message)
|
||||||
require.Equal(t, "car.jpg", messages[1].Attachment.Name)
|
require.Equal(t, "car.jpg", messages[1].Attachment.Name)
|
||||||
@@ -335,7 +341,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
|
|||||||
require.Equal(t, int64(10000), messages[1].Attachment.Size)
|
require.Equal(t, int64(10000), messages[1].Attachment.Size)
|
||||||
require.Equal(t, expires2, messages[1].Attachment.Expires)
|
require.Equal(t, expires2, messages[1].Attachment.Expires)
|
||||||
require.Equal(t, "https://ntfy.sh/file/aCaRURL.jpg", messages[1].Attachment.URL)
|
require.Equal(t, "https://ntfy.sh/file/aCaRURL.jpg", messages[1].Attachment.URL)
|
||||||
require.Equal(t, "1.2.3.4", messages[1].Attachment.Owner)
|
require.Equal(t, "1.2.3.4", messages[1].Sender.String())
|
||||||
|
|
||||||
size, err := c.AttachmentBytesUsed("1.2.3.4")
|
size, err := c.AttachmentBytesUsed("1.2.3.4")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
@@ -344,10 +350,6 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
|
|||||||
size, err = c.AttachmentBytesUsed("5.6.7.8")
|
size, err = c.AttachmentBytesUsed("5.6.7.8")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, int64(0), size)
|
require.Equal(t, int64(0), size)
|
||||||
|
|
||||||
ids, err := c.AttachmentsExpired()
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, []string{"m1"}, ids)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSqliteCache_Migration_From0(t *testing.T) {
|
func TestSqliteCache_Migration_From0(t *testing.T) {
|
||||||
@@ -378,7 +380,7 @@ func TestSqliteCache_Migration_From0(t *testing.T) {
|
|||||||
require.Nil(t, db.Close())
|
require.Nil(t, db.Close())
|
||||||
|
|
||||||
// Create cache to trigger migration
|
// Create cache to trigger migration
|
||||||
c := newSqliteTestCacheFromFile(t, filename)
|
c := newSqliteTestCacheFromFile(t, filename, "")
|
||||||
checkSchemaVersion(t, c.db)
|
checkSchemaVersion(t, c.db)
|
||||||
|
|
||||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||||
@@ -424,7 +426,7 @@ func TestSqliteCache_Migration_From1(t *testing.T) {
|
|||||||
require.Nil(t, db.Close())
|
require.Nil(t, db.Close())
|
||||||
|
|
||||||
// Create cache to trigger migration
|
// Create cache to trigger migration
|
||||||
c := newSqliteTestCacheFromFile(t, filename)
|
c := newSqliteTestCacheFromFile(t, filename, "")
|
||||||
checkSchemaVersion(t, c.db)
|
checkSchemaVersion(t, c.db)
|
||||||
|
|
||||||
// Add delayed message
|
// Add delayed message
|
||||||
@@ -443,6 +445,60 @@ func TestSqliteCache_Migration_From1(t *testing.T) {
|
|||||||
require.Equal(t, 11, len(messages))
|
require.Equal(t, 11, len(messages))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSqliteCache_StartupQueries_WAL(t *testing.T) {
|
||||||
|
filename := newSqliteTestCacheFile(t)
|
||||||
|
startupQueries := `pragma journal_mode = WAL;
|
||||||
|
pragma synchronous = normal;
|
||||||
|
pragma temp_store = memory;`
|
||||||
|
db, err := newSqliteCache(filename, startupQueries, 0, 0, false)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Nil(t, db.AddMessage(newDefaultMessage("mytopic", "some message")))
|
||||||
|
require.FileExists(t, filename)
|
||||||
|
require.FileExists(t, filename+"-wal")
|
||||||
|
require.FileExists(t, filename+"-shm")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqliteCache_StartupQueries_None(t *testing.T) {
|
||||||
|
filename := newSqliteTestCacheFile(t)
|
||||||
|
startupQueries := ""
|
||||||
|
db, err := newSqliteCache(filename, startupQueries, 0, 0, false)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Nil(t, db.AddMessage(newDefaultMessage("mytopic", "some message")))
|
||||||
|
require.FileExists(t, filename)
|
||||||
|
require.NoFileExists(t, filename+"-wal")
|
||||||
|
require.NoFileExists(t, filename+"-shm")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqliteCache_StartupQueries_Fail(t *testing.T) {
|
||||||
|
filename := newSqliteTestCacheFile(t)
|
||||||
|
startupQueries := `xx error`
|
||||||
|
_, err := newSqliteCache(filename, startupQueries, 0, 0, false)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqliteCache_Sender(t *testing.T) {
|
||||||
|
testSender(t, newSqliteTestCache(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemCache_Sender(t *testing.T) {
|
||||||
|
testSender(t, newMemTestCache(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSender(t *testing.T, c *messageCache) {
|
||||||
|
m1 := newDefaultMessage("mytopic", "mymessage")
|
||||||
|
m1.Sender = netip.MustParseAddr("1.2.3.4")
|
||||||
|
require.Nil(t, c.AddMessage(m1))
|
||||||
|
|
||||||
|
m2 := newDefaultMessage("mytopic", "mymessage without sender")
|
||||||
|
require.Nil(t, c.AddMessage(m2))
|
||||||
|
|
||||||
|
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 2, len(messages))
|
||||||
|
require.Equal(t, messages[0].Sender, netip.MustParseAddr("1.2.3.4"))
|
||||||
|
require.Equal(t, messages[1].Sender, netip.Addr{})
|
||||||
|
}
|
||||||
|
|
||||||
func checkSchemaVersion(t *testing.T, db *sql.DB) {
|
func checkSchemaVersion(t *testing.T, db *sql.DB) {
|
||||||
rows, err := db.Query(`SELECT version FROM schemaVersion`)
|
rows, err := db.Query(`SELECT version FROM schemaVersion`)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
@@ -468,7 +524,7 @@ func TestMemCache_NopCache(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newSqliteTestCache(t *testing.T) *messageCache {
|
func newSqliteTestCache(t *testing.T) *messageCache {
|
||||||
c, err := newSqliteCache(newSqliteTestCacheFile(t), false)
|
c, err := newSqliteCache(newSqliteTestCacheFile(t), "", 0, 0, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -479,8 +535,8 @@ func newSqliteTestCacheFile(t *testing.T) string {
|
|||||||
return filepath.Join(t.TempDir(), "cache.db")
|
return filepath.Join(t.TempDir(), "cache.db")
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSqliteTestCacheFromFile(t *testing.T, filename string) *messageCache {
|
func newSqliteTestCacheFromFile(t *testing.T, filename, startupQueries string) *messageCache {
|
||||||
c, err := newSqliteCache(filename, false)
|
c, err := newSqliteCache(filename, startupQueries, 0, 0, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ After=network.target
|
|||||||
[Service]
|
[Service]
|
||||||
User=ntfy
|
User=ntfy
|
||||||
Group=ntfy
|
Group=ntfy
|
||||||
ExecStart=/usr/bin/ntfy serve
|
ExecStart=/usr/bin/ntfy serve --no-log-dates
|
||||||
|
ExecReload=/bin/kill --signal HUP $MAINPID
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||||
LimitNOFILE=10000
|
LimitNOFILE=10000
|
||||||
|
|||||||
656
server/server.go
@@ -1,7 +1,15 @@
|
|||||||
# ntfy server config file
|
# ntfy server config file
|
||||||
|
#
|
||||||
|
# Please refer to the documentation at https://ntfy.sh/docs/config/ for details.
|
||||||
|
# All options also support underscores (_) instead of dashes (-) to comply with the YAML spec.
|
||||||
|
|
||||||
# Public facing base URL of the service (e.g. https://ntfy.sh or https://ntfy.example.com)
|
# Public facing base URL of the service (e.g. https://ntfy.sh or https://ntfy.example.com)
|
||||||
# This setting is currently only used by the attachments and e-mail sending feature (outgoing mail only).
|
#
|
||||||
|
# This setting is required for any of the following features:
|
||||||
|
# - attachments (to return a download URL)
|
||||||
|
# - e-mail sending (for the topic URL in the email footer)
|
||||||
|
# - iOS push notifications for self-hosted servers (to calculate the Firebase poll_request topic)
|
||||||
|
# - Matrix Push Gateway (to validate that the pushkey is correct)
|
||||||
#
|
#
|
||||||
# base-url:
|
# base-url:
|
||||||
|
|
||||||
@@ -18,6 +26,7 @@
|
|||||||
# This can be useful to avoid port issues on local systems, and to simplify permissions.
|
# This can be useful to avoid port issues on local systems, and to simplify permissions.
|
||||||
#
|
#
|
||||||
# listen-unix: <socket-path>
|
# listen-unix: <socket-path>
|
||||||
|
# listen-unix-mode: <linux permissions, e.g. 0700>
|
||||||
|
|
||||||
# Path to the private key & cert file for the HTTPS web server. Not used if "listen-https" is not set.
|
# Path to the private key & cert file for the HTTPS web server. Not used if "listen-https" is not set.
|
||||||
#
|
#
|
||||||
@@ -29,14 +38,28 @@
|
|||||||
#
|
#
|
||||||
# firebase-key-file: <filename>
|
# firebase-key-file: <filename>
|
||||||
|
|
||||||
# If set, messages are cached in a local SQLite database instead of only in-memory. This
|
# If "cache-file" is set, messages are cached in a local SQLite database instead of only in-memory.
|
||||||
# allows for service restarts without losing messages in support of the since= parameter.
|
# This allows for service restarts without losing messages in support of the since= parameter.
|
||||||
#
|
#
|
||||||
# The "cache-duration" parameter defines the duration for which messages will be buffered
|
# The "cache-duration" parameter defines the duration for which messages will be buffered
|
||||||
# before they are deleted. This is required to support the "since=..." and "poll=1" parameter.
|
# before they are deleted. This is required to support the "since=..." and "poll=1" parameter.
|
||||||
# To disable the cache entirely (on-disk/in-memory), set "cache-duration" to 0.
|
# To disable the cache entirely (on-disk/in-memory), set "cache-duration" to 0.
|
||||||
# The cache file is created automatically, provided that the correct permissions are set.
|
# The cache file is created automatically, provided that the correct permissions are set.
|
||||||
#
|
#
|
||||||
|
# The "cache-startup-queries" parameter allows you to run commands when the database is initialized,
|
||||||
|
# e.g. to enable WAL mode (see https://phiresky.github.io/blog/2020/sqlite-performance-tuning/)).
|
||||||
|
# Example:
|
||||||
|
# cache-startup-queries: |
|
||||||
|
# pragma journal_mode = WAL;
|
||||||
|
# pragma synchronous = normal;
|
||||||
|
# pragma temp_store = memory;
|
||||||
|
# pragma busy_timeout = 15000;
|
||||||
|
# vacuum;
|
||||||
|
#
|
||||||
|
# The "cache-batch-size" and "cache-batch-timeout" parameter allow enabling async batch writing
|
||||||
|
# of messages. If set, messages will be queued and written to the database in batches of the given
|
||||||
|
# size, or after the given timeout. This is only required for high volume servers.
|
||||||
|
#
|
||||||
# Debian/RPM package users:
|
# Debian/RPM package users:
|
||||||
# Use /var/cache/ntfy/cache.db as cache file to avoid permission issues. The package
|
# Use /var/cache/ntfy/cache.db as cache file to avoid permission issues. The package
|
||||||
# creates this folder for you.
|
# creates this folder for you.
|
||||||
@@ -47,6 +70,9 @@
|
|||||||
#
|
#
|
||||||
# cache-file: <filename>
|
# cache-file: <filename>
|
||||||
# cache-duration: "12h"
|
# cache-duration: "12h"
|
||||||
|
# cache-startup-queries:
|
||||||
|
# cache-batch-size: 0
|
||||||
|
# cache-batch-timeout: "0ms"
|
||||||
|
|
||||||
# If set, access to the ntfy server and API can be controlled on a granular level using
|
# If set, access to the ntfy server and API can be controlled on a granular level using
|
||||||
# the 'ntfy user' and 'ntfy access' commands. See the --help pages for details, or check the docs.
|
# the 'ntfy user' and 'ntfy access' commands. See the --help pages for details, or check the docs.
|
||||||
@@ -127,10 +153,23 @@
|
|||||||
# manager-interval: "1m"
|
# manager-interval: "1m"
|
||||||
|
|
||||||
# Defines if the root route (/) is pointing to the landing page (as on ntfy.sh) or the
|
# Defines if the root route (/) is pointing to the landing page (as on ntfy.sh) or the
|
||||||
# web app. If you self-host, you don't want to change this. Can be "app" (default) or "home".
|
# web app. If you self-host, you don't want to change this.
|
||||||
|
# Can be "app" (default), "home" or "disable" to disable the web app entirely.
|
||||||
#
|
#
|
||||||
# web-root: app
|
# web-root: app
|
||||||
|
|
||||||
|
# Server URL of a Firebase/APNS-connected ntfy server (likely "https://ntfy.sh").
|
||||||
|
#
|
||||||
|
# iOS users:
|
||||||
|
# If you use the iOS ntfy app, you MUST configure this to receive timely notifications. You'll like want this:
|
||||||
|
# upstream-base-url: "https://ntfy.sh"
|
||||||
|
#
|
||||||
|
# If set, all incoming messages will publish a "poll_request" message to the configured upstream server, containing
|
||||||
|
# the message ID of the original message, instructing the iOS app to poll this server for the actual message contents.
|
||||||
|
# This is to prevent the upstream server and Firebase/APNS from being able to read the message.
|
||||||
|
#
|
||||||
|
# upstream-base-url:
|
||||||
|
|
||||||
# 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
|
||||||
@@ -142,8 +181,9 @@
|
|||||||
# Rate limiting: Allowed GET/PUT/POST requests per second, per visitor:
|
# Rate limiting: Allowed GET/PUT/POST requests per second, per visitor:
|
||||||
# - visitor-request-limit-burst is the initial bucket of requests each visitor has
|
# - visitor-request-limit-burst is the initial bucket of requests each visitor has
|
||||||
# - visitor-request-limit-replenish is the rate at which the bucket is refilled
|
# - visitor-request-limit-replenish is the rate at which the bucket is refilled
|
||||||
# - visitor-request-limit-exempt-hosts is a comma-separated list of hostnames and IPs to be
|
# - visitor-request-limit-exempt-hosts is a comma-separated list of hostnames, IPs or CIDRs to be
|
||||||
# exempt from request rate limiting; hostnames are resolved at the time the server is started
|
# exempt from request rate limiting. Hostnames are resolved at the time the server is started.
|
||||||
|
# Example: "1.2.3.4,ntfy.example.com,8.7.6.0/24"
|
||||||
#
|
#
|
||||||
# visitor-request-limit-burst: 60
|
# visitor-request-limit-burst: 60
|
||||||
# visitor-request-limit-replenish: "5s"
|
# visitor-request-limit-replenish: "5s"
|
||||||
@@ -162,3 +202,11 @@
|
|||||||
#
|
#
|
||||||
# visitor-attachment-total-size-limit: "100M"
|
# visitor-attachment-total-size-limit: "100M"
|
||||||
# visitor-attachment-daily-bandwidth-limit: "500M"
|
# visitor-attachment-daily-bandwidth-limit: "500M"
|
||||||
|
|
||||||
|
# Log level, can be TRACE, DEBUG, INFO, WARN or ERROR
|
||||||
|
# This option can be hot-reloaded by calling "kill -HUP $pid" or "systemctl reload ntfy".
|
||||||
|
#
|
||||||
|
# Be aware that DEBUG (and particularly TRACE) can be VERY CHATTY. Only turn them on for
|
||||||
|
# debugging purposes, or your disk will fill up quickly.
|
||||||
|
#
|
||||||
|
# log-level: INFO
|
||||||
|
|||||||
@@ -3,18 +3,197 @@ package server
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
firebase "firebase.google.com/go"
|
"errors"
|
||||||
"firebase.google.com/go/messaging"
|
firebase "firebase.google.com/go/v4"
|
||||||
|
"firebase.google.com/go/v4/messaging"
|
||||||
"fmt"
|
"fmt"
|
||||||
"google.golang.org/api/option"
|
"google.golang.org/api/option"
|
||||||
"heckel.io/ntfy/auth"
|
"heckel.io/ntfy/auth"
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
fcmMessageLimit = 4000
|
fcmMessageLimit = 4000
|
||||||
|
fcmApnsBodyMessageLimit = 100
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errFirebaseQuotaExceeded = errors.New("quota exceeded for Firebase messages to topic")
|
||||||
|
errFirebaseTemporarilyBanned = errors.New("visitor temporarily banned from using Firebase")
|
||||||
|
)
|
||||||
|
|
||||||
|
// firebaseClient is a generic client that formats and sends messages to Firebase.
|
||||||
|
// The actual Firebase implementation is implemented in firebaseSenderImpl, to make it testable.
|
||||||
|
type firebaseClient struct {
|
||||||
|
sender firebaseSender
|
||||||
|
auther auth.Auther
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFirebaseClient(sender firebaseSender, auther auth.Auther) *firebaseClient {
|
||||||
|
return &firebaseClient{
|
||||||
|
sender: sender,
|
||||||
|
auther: auther,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *firebaseClient) Send(v *visitor, m *message) error {
|
||||||
|
if err := v.FirebaseAllowed(); err != nil {
|
||||||
|
return errFirebaseTemporarilyBanned
|
||||||
|
}
|
||||||
|
fbm, err := toFirebaseMessage(m, c.auther)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if log.IsTrace() {
|
||||||
|
log.Trace("%s Firebase message: %s", logMessagePrefix(v, m), util.MaybeMarshalJSON(fbm))
|
||||||
|
}
|
||||||
|
err = c.sender.Send(fbm)
|
||||||
|
if err == errFirebaseQuotaExceeded {
|
||||||
|
log.Warn("%s Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor", logMessagePrefix(v, m))
|
||||||
|
v.FirebaseTemporarilyDeny()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// firebaseSender is an interface that represents a client that can send to Firebase Cloud Messaging.
|
||||||
|
// In tests, this can be implemented with a mock.
|
||||||
|
type firebaseSender interface {
|
||||||
|
// Send sends a message to Firebase, or returns an error. It returns errFirebaseQuotaExceeded
|
||||||
|
// if a rate limit has reached.
|
||||||
|
Send(m *messaging.Message) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// firebaseSenderImpl is a firebaseSender that actually talks to Firebase
|
||||||
|
type firebaseSenderImpl struct {
|
||||||
|
client *messaging.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFirebaseSender(credentialsFile string) (*firebaseSenderImpl, error) {
|
||||||
|
fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(credentialsFile))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
client, err := fb.Messaging(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &firebaseSenderImpl{
|
||||||
|
client: client,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *firebaseSenderImpl) Send(m *messaging.Message) error {
|
||||||
|
_, err := c.client.Send(context.Background(), m)
|
||||||
|
if err != nil && messaging.IsQuotaExceeded(err) {
|
||||||
|
return errFirebaseQuotaExceeded
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// toFirebaseMessage converts a message to a Firebase message.
|
||||||
|
//
|
||||||
|
// Normal messages ("message"):
|
||||||
|
// - For Android, we can receive data messages from Firebase and process them as code, so we just send all fields
|
||||||
|
// in the "data" attribute. In the Android app, we then turn those into a notification and display it.
|
||||||
|
// - On iOS, we are not allowed to receive data-only messages, so we build messages with an "alert" (with title and
|
||||||
|
// message), and still send the rest of the data along in the "aps" attribute. We can then locally modify the
|
||||||
|
// message in the Notification Service Extension.
|
||||||
|
//
|
||||||
|
// Keepalive messages ("keepalive"):
|
||||||
|
// - On Android, we subscribe to the "~control" topic, which is used to restart the foreground service (if it died,
|
||||||
|
// e.g. after an app update). We send these keepalive messages regularly (see Config.FirebaseKeepaliveInterval).
|
||||||
|
// - On iOS, we subscribe to the "~poll" topic, which is used to poll all topics regularly. This is because iOS
|
||||||
|
// does not allow any background or scheduled activity at all.
|
||||||
|
//
|
||||||
|
// Poll request messages ("poll_request"):
|
||||||
|
// - Normal messages are turned into poll request messages if anonymous users are not allowed to read the message.
|
||||||
|
// On Android, this will trigger the app to poll the topic and thereby displaying new messages.
|
||||||
|
// - If UpstreamBaseURL is set, messages are forwarded as poll requests to an upstream server and then forwarded
|
||||||
|
// to Firebase here. This is mainly for iOS to support self-hosted servers.
|
||||||
|
func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, error) {
|
||||||
|
var data map[string]string // Mostly matches https://ntfy.sh/docs/subscribe/api/#json-message-format
|
||||||
|
var apnsConfig *messaging.APNSConfig
|
||||||
|
switch m.Event {
|
||||||
|
case keepaliveEvent, openEvent:
|
||||||
|
data = map[string]string{
|
||||||
|
"id": m.ID,
|
||||||
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
|
"event": m.Event,
|
||||||
|
"topic": m.Topic,
|
||||||
|
}
|
||||||
|
apnsConfig = createAPNSBackgroundConfig(data)
|
||||||
|
case pollRequestEvent:
|
||||||
|
data = map[string]string{
|
||||||
|
"id": m.ID,
|
||||||
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
|
"event": m.Event,
|
||||||
|
"topic": m.Topic,
|
||||||
|
"message": m.Message,
|
||||||
|
"poll_id": m.PollID,
|
||||||
|
}
|
||||||
|
apnsConfig = createAPNSAlertConfig(m, data)
|
||||||
|
case messageEvent:
|
||||||
|
allowForward := true
|
||||||
|
if auther != nil {
|
||||||
|
allowForward = auther.Authorize(nil, m.Topic, auth.PermissionRead) == nil
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
"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.
|
||||||
|
data = map[string]string{
|
||||||
|
"id": m.ID,
|
||||||
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
|
"event": pollRequestEvent,
|
||||||
|
"topic": m.Topic,
|
||||||
|
}
|
||||||
|
// TODO Handle APNS?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var androidConfig *messaging.AndroidConfig
|
||||||
|
if m.Priority >= 4 {
|
||||||
|
androidConfig = &messaging.AndroidConfig{
|
||||||
|
Priority: "high",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maybeTruncateFCMMessage(&messaging.Message{
|
||||||
|
Topic: m.Topic,
|
||||||
|
Data: data,
|
||||||
|
Android: androidConfig,
|
||||||
|
APNS: apnsConfig,
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
// maybeTruncateFCMMessage performs best-effort truncation of FCM messages.
|
// maybeTruncateFCMMessage performs best-effort truncation of FCM messages.
|
||||||
// The docs say the limit is 4000 characters, but during testing it wasn't quite clear
|
// The docs say the limit is 4000 characters, but during testing it wasn't quite clear
|
||||||
// what fields matter; so we're just capping the serialized JSON to 4000 bytes.
|
// what fields matter; so we're just capping the serialized JSON to 4000 bytes.
|
||||||
@@ -34,87 +213,62 @@ func maybeTruncateFCMMessage(m *messaging.Message) *messaging.Message {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
func createFirebaseSubscriber(credentialsFile string, auther auth.Auther) (subscriber, error) {
|
// createAPNSAlertConfig creates an APNS config for iOS notifications that show up as an alert (only relevant for iOS).
|
||||||
fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(credentialsFile))
|
// We must set the Alert struct ("alert"), and we need to set MutableContent ("mutable-content"), so the Notification Service
|
||||||
if err != nil {
|
// Extension in iOS can modify the message.
|
||||||
return nil, err
|
func createAPNSAlertConfig(m *message, data map[string]string) *messaging.APNSConfig {
|
||||||
|
apnsData := make(map[string]any)
|
||||||
|
for k, v := range data {
|
||||||
|
apnsData[k] = v
|
||||||
}
|
}
|
||||||
msg, err := fb.Messaging(context.Background())
|
return &messaging.APNSConfig{
|
||||||
if err != nil {
|
Payload: &messaging.APNSPayload{
|
||||||
return nil, err
|
CustomData: apnsData,
|
||||||
|
Aps: &messaging.Aps{
|
||||||
|
MutableContent: true,
|
||||||
|
Alert: &messaging.ApsAlert{
|
||||||
|
Title: m.Title,
|
||||||
|
Body: maybeTruncateAPNSBodyMessage(m.Message),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
return func(m *message) error {
|
|
||||||
fbm, err := toFirebaseMessage(m, auther)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = msg.Send(context.Background(), fbm)
|
|
||||||
return err
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, error) {
|
// createAPNSBackgroundConfig creates an APNS config for a silent background message (only relevant for iOS). Apple only
|
||||||
var data map[string]string // Mostly matches https://ntfy.sh/docs/subscribe/api/#json-message-format
|
// allows us to send 2-3 of these notifications per hour, and delivery not guaranteed. We use this only for the ~poll
|
||||||
switch m.Event {
|
// topic, which triggers the iOS app to poll all topics for changes.
|
||||||
case keepaliveEvent, openEvent:
|
//
|
||||||
data = map[string]string{
|
// See https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/pushing_background_updates_to_your_app
|
||||||
"id": m.ID,
|
func createAPNSBackgroundConfig(data map[string]string) *messaging.APNSConfig {
|
||||||
"time": fmt.Sprintf("%d", m.Time),
|
apnsData := make(map[string]any)
|
||||||
"event": m.Event,
|
for k, v := range data {
|
||||||
"topic": m.Topic,
|
apnsData[k] = v
|
||||||
}
|
|
||||||
case messageEvent:
|
|
||||||
allowForward := true
|
|
||||||
if auther != nil {
|
|
||||||
allowForward = auther.Authorize(nil, m.Topic, auth.PermissionRead) == nil
|
|
||||||
}
|
|
||||||
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,
|
|
||||||
"title": m.Title,
|
|
||||||
"message": m.Message,
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
} 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.
|
|
||||||
data = map[string]string{
|
|
||||||
"id": m.ID,
|
|
||||||
"time": fmt.Sprintf("%d", m.Time),
|
|
||||||
"event": pollRequestEvent,
|
|
||||||
"topic": m.Topic,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
var androidConfig *messaging.AndroidConfig
|
return &messaging.APNSConfig{
|
||||||
if m.Priority >= 4 {
|
Headers: map[string]string{
|
||||||
androidConfig = &messaging.AndroidConfig{
|
"apns-push-type": "background",
|
||||||
Priority: "high",
|
"apns-priority": "5",
|
||||||
}
|
},
|
||||||
|
Payload: &messaging.APNSPayload{
|
||||||
|
Aps: &messaging.Aps{
|
||||||
|
ContentAvailable: true,
|
||||||
|
},
|
||||||
|
CustomData: apnsData,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
return maybeTruncateFCMMessage(&messaging.Message{
|
}
|
||||||
Topic: m.Topic,
|
|
||||||
Data: data,
|
// maybeTruncateAPNSBodyMessage truncates the body for APNS.
|
||||||
Android: androidConfig,
|
//
|
||||||
}), nil
|
// The "body" of the push notification can contain the entire message, which would count doubly for the overall length
|
||||||
|
// of the APNS payload. I set a limit of 100 characters before truncating the notification "body" with ellipsis.
|
||||||
|
// The message would not be changed (unless truncated for being too long). Note: if the payload is too large (>4KB),
|
||||||
|
// APNS will simply reject / discard the notification, meaning it will never arrive on the iOS device.
|
||||||
|
func maybeTruncateAPNSBodyMessage(s string) string {
|
||||||
|
if len(s) >= fcmApnsBodyMessageLimit {
|
||||||
|
over := len(s) - fcmApnsBodyMessageLimit + 3 // len("...")
|
||||||
|
return s[:len(s)-over] + "..."
|
||||||
|
}
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ package server
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"firebase.google.com/go/messaging"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"firebase.google.com/go/v4/messaging"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"heckel.io/ntfy/auth"
|
"heckel.io/ntfy/auth"
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type testAuther struct {
|
type testAuther struct {
|
||||||
@@ -26,12 +29,58 @@ func (t testAuther) Authorize(_ *auth.User, _ string, _ auth.Permission) error {
|
|||||||
return errors.New("unauthorized")
|
return errors.New("unauthorized")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type testFirebaseSender struct {
|
||||||
|
allowed int
|
||||||
|
messages []*messaging.Message
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestFirebaseSender(allowed int) *testFirebaseSender {
|
||||||
|
return &testFirebaseSender{
|
||||||
|
allowed: allowed,
|
||||||
|
messages: make([]*messaging.Message, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testFirebaseSender) Send(m *messaging.Message) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if len(s.messages)+1 > s.allowed {
|
||||||
|
return errFirebaseQuotaExceeded
|
||||||
|
}
|
||||||
|
s.messages = append(s.messages, m)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testFirebaseSender) Messages() []*messaging.Message {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return append(make([]*messaging.Message, 0), s.messages...)
|
||||||
|
}
|
||||||
|
|
||||||
func TestToFirebaseMessage_Keepalive(t *testing.T) {
|
func TestToFirebaseMessage_Keepalive(t *testing.T) {
|
||||||
m := newKeepaliveMessage("mytopic")
|
m := newKeepaliveMessage("mytopic")
|
||||||
fbm, err := toFirebaseMessage(m, nil)
|
fbm, err := toFirebaseMessage(m, nil)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, "mytopic", fbm.Topic)
|
require.Equal(t, "mytopic", fbm.Topic)
|
||||||
require.Nil(t, fbm.Android)
|
require.Nil(t, fbm.Android)
|
||||||
|
require.Equal(t, &messaging.APNSConfig{
|
||||||
|
Headers: map[string]string{
|
||||||
|
"apns-push-type": "background",
|
||||||
|
"apns-priority": "5",
|
||||||
|
},
|
||||||
|
Payload: &messaging.APNSPayload{
|
||||||
|
Aps: &messaging.Aps{
|
||||||
|
ContentAvailable: true,
|
||||||
|
},
|
||||||
|
CustomData: map[string]any{
|
||||||
|
"id": m.ID,
|
||||||
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
|
"event": m.Event,
|
||||||
|
"topic": m.Topic,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, fbm.APNS)
|
||||||
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),
|
||||||
@@ -46,6 +95,23 @@ func TestToFirebaseMessage_Open(t *testing.T) {
|
|||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, "mytopic", fbm.Topic)
|
require.Equal(t, "mytopic", fbm.Topic)
|
||||||
require.Nil(t, fbm.Android)
|
require.Nil(t, fbm.Android)
|
||||||
|
require.Equal(t, &messaging.APNSConfig{
|
||||||
|
Headers: map[string]string{
|
||||||
|
"apns-push-type": "background",
|
||||||
|
"apns-priority": "5",
|
||||||
|
},
|
||||||
|
Payload: &messaging.APNSPayload{
|
||||||
|
Aps: &messaging.Aps{
|
||||||
|
ContentAvailable: true,
|
||||||
|
},
|
||||||
|
CustomData: map[string]any{
|
||||||
|
"id": m.ID,
|
||||||
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
|
"event": m.Event,
|
||||||
|
"topic": m.Topic,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, fbm.APNS)
|
||||||
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),
|
||||||
@@ -59,14 +125,33 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
|
|||||||
m.Priority = 4
|
m.Priority = 4
|
||||||
m.Tags = []string{"tag 1", "tag2"}
|
m.Tags = []string{"tag 1", "tag2"}
|
||||||
m.Click = "https://google.com"
|
m.Click = "https://google.com"
|
||||||
|
m.Icon = "https://ntfy.sh/static/img/ntfy.png"
|
||||||
m.Title = "some title"
|
m.Title = "some title"
|
||||||
|
m.Actions = []*action{
|
||||||
|
{
|
||||||
|
ID: "123",
|
||||||
|
Action: "view",
|
||||||
|
Label: "Open page",
|
||||||
|
Clear: true,
|
||||||
|
URL: "https://ntfy.sh",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "456",
|
||||||
|
Action: "http",
|
||||||
|
Label: "Close door",
|
||||||
|
URL: "https://door.com/close",
|
||||||
|
Method: "PUT",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"really": "yes",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
m.Attachment = &attachment{
|
m.Attachment = &attachment{
|
||||||
Name: "some file.jpg",
|
Name: "some file.jpg",
|
||||||
Type: "image/jpeg",
|
Type: "image/jpeg",
|
||||||
Size: 12345,
|
Size: 12345,
|
||||||
Expires: 98765543,
|
Expires: 98765543,
|
||||||
URL: "https://example.com/file.jpg",
|
URL: "https://example.com/file.jpg",
|
||||||
Owner: "some-owner",
|
|
||||||
}
|
}
|
||||||
fbm, err := toFirebaseMessage(m, &testAuther{Allow: true})
|
fbm, err := toFirebaseMessage(m, &testAuther{Allow: true})
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
@@ -74,6 +159,36 @@ func TestToFirebaseMessage_Message_Normal_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, &messaging.APNSConfig{
|
||||||
|
Payload: &messaging.APNSPayload{
|
||||||
|
Aps: &messaging.Aps{
|
||||||
|
MutableContent: true,
|
||||||
|
Alert: &messaging.ApsAlert{
|
||||||
|
Title: "some title",
|
||||||
|
Body: "this is a message",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CustomData: map[string]any{
|
||||||
|
"id": m.ID,
|
||||||
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
|
"event": "message",
|
||||||
|
"topic": "mytopic",
|
||||||
|
"priority": "4",
|
||||||
|
"tags": strings.Join(m.Tags, ","),
|
||||||
|
"click": "https://google.com",
|
||||||
|
"icon": "https://ntfy.sh/static/img/ntfy.png",
|
||||||
|
"title": "some title",
|
||||||
|
"message": "this is a message",
|
||||||
|
"actions": `[{"id":"123","action":"view","label":"Open page","clear":true,"url":"https://ntfy.sh"},{"id":"456","action":"http","label":"Close door","clear":false,"url":"https://door.com/close","method":"PUT","headers":{"really":"yes"}}]`,
|
||||||
|
"encoding": "",
|
||||||
|
"attachment_name": "some file.jpg",
|
||||||
|
"attachment_type": "image/jpeg",
|
||||||
|
"attachment_size": "12345",
|
||||||
|
"attachment_expires": "98765543",
|
||||||
|
"attachment_url": "https://example.com/file.jpg",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, fbm.APNS)
|
||||||
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),
|
||||||
@@ -82,8 +197,10 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
|
|||||||
"priority": "4",
|
"priority": "4",
|
||||||
"tags": strings.Join(m.Tags, ","),
|
"tags": strings.Join(m.Tags, ","),
|
||||||
"click": "https://google.com",
|
"click": "https://google.com",
|
||||||
|
"icon": "https://ntfy.sh/static/img/ntfy.png",
|
||||||
"title": "some title",
|
"title": "some title",
|
||||||
"message": "this is a message",
|
"message": "this is a message",
|
||||||
|
"actions": `[{"id":"123","action":"view","label":"Open page","clear":true,"url":"https://ntfy.sh"},{"id":"456","action":"http","label":"Close door","clear":false,"url":"https://door.com/close","method":"PUT","headers":{"really":"yes"}}]`,
|
||||||
"encoding": "",
|
"encoding": "",
|
||||||
"attachment_name": "some file.jpg",
|
"attachment_name": "some file.jpg",
|
||||||
"attachment_type": "image/jpeg",
|
"attachment_type": "image/jpeg",
|
||||||
@@ -112,6 +229,41 @@ func TestToFirebaseMessage_Message_Normal_Not_Allowed(t *testing.T) {
|
|||||||
}, fbm.Data)
|
}, fbm.Data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestToFirebaseMessage_PollRequest(t *testing.T) {
|
||||||
|
m := newPollRequestMessage("mytopic", "fOv6k1QbCzo6")
|
||||||
|
fbm, err := toFirebaseMessage(m, nil)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "mytopic", fbm.Topic)
|
||||||
|
require.Nil(t, fbm.Android)
|
||||||
|
require.Equal(t, &messaging.APNSConfig{
|
||||||
|
Payload: &messaging.APNSPayload{
|
||||||
|
Aps: &messaging.Aps{
|
||||||
|
MutableContent: true,
|
||||||
|
Alert: &messaging.ApsAlert{
|
||||||
|
Title: "",
|
||||||
|
Body: "New message",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CustomData: map[string]any{
|
||||||
|
"id": m.ID,
|
||||||
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
|
"event": "poll_request",
|
||||||
|
"topic": "mytopic",
|
||||||
|
"message": "New message",
|
||||||
|
"poll_id": "fOv6k1QbCzo6",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, fbm.APNS)
|
||||||
|
require.Equal(t, map[string]string{
|
||||||
|
"id": m.ID,
|
||||||
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
|
"event": "poll_request",
|
||||||
|
"topic": "mytopic",
|
||||||
|
"message": "New message",
|
||||||
|
"poll_id": "fOv6k1QbCzo6",
|
||||||
|
}, fbm.Data)
|
||||||
|
}
|
||||||
|
|
||||||
func TestMaybeTruncateFCMMessage(t *testing.T) {
|
func TestMaybeTruncateFCMMessage(t *testing.T) {
|
||||||
origMessage := strings.Repeat("this is a long string", 300)
|
origMessage := strings.Repeat("this is a long string", 300)
|
||||||
origFCMMessage := &messaging.Message{
|
origFCMMessage := &messaging.Message{
|
||||||
@@ -168,3 +320,22 @@ func TestMaybeTruncateFCMMessage_NotTooLong(t *testing.T) {
|
|||||||
require.Equal(t, len(serializedOrigFCMMessage), len(serializedNotTruncatedFCMMessage))
|
require.Equal(t, len(serializedOrigFCMMessage), len(serializedNotTruncatedFCMMessage))
|
||||||
require.Equal(t, "", notTruncatedFCMMessage.Data["truncated"])
|
require.Equal(t, "", notTruncatedFCMMessage.Data["truncated"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestToFirebaseSender_Abuse(t *testing.T) {
|
||||||
|
sender := &testFirebaseSender{allowed: 2}
|
||||||
|
client := newFirebaseClient(sender, &testAuther{})
|
||||||
|
visitor := newVisitor(newTestConfig(t), newMemTestCache(t), netip.MustParseAddr("1.2.3.4"))
|
||||||
|
|
||||||
|
require.Nil(t, client.Send(visitor, &message{Topic: "mytopic"}))
|
||||||
|
require.Equal(t, 1, len(sender.Messages()))
|
||||||
|
|
||||||
|
require.Nil(t, client.Send(visitor, &message{Topic: "mytopic"}))
|
||||||
|
require.Equal(t, 2, len(sender.Messages()))
|
||||||
|
|
||||||
|
require.Equal(t, errFirebaseQuotaExceeded, client.Send(visitor, &message{Topic: "mytopic"}))
|
||||||
|
require.Equal(t, 2, len(sender.Messages()))
|
||||||
|
|
||||||
|
sender.messages = make([]*messaging.Message, 0) // Reset to test that time limit is working
|
||||||
|
require.Equal(t, errFirebaseTemporarilyBanned, client.Send(visitor, &message{Topic: "mytopic"}))
|
||||||
|
require.Equal(t, 0, len(sender.Messages()))
|
||||||
|
}
|
||||||
|
|||||||
174
server/server_matrix.go
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Matrix Push Gateway / UnifiedPush / ntfy integration:
|
||||||
|
//
|
||||||
|
// ntfy implements a Matrix Push Gateway (as defined in https://spec.matrix.org/v1.2/push-gateway-api/),
|
||||||
|
// in combination with UnifiedPush as the Provider Push Protocol (as defined in https://unifiedpush.org/developers/gateway/).
|
||||||
|
//
|
||||||
|
// In the picture below, ntfy is the Push Gateway (mostly in this file), as well as the Push Provider (ntfy's
|
||||||
|
// main functionality). UnifiedPush is the Provider Push Protocol, as implemented by the ntfy server and the
|
||||||
|
// ntfy Android app.
|
||||||
|
//
|
||||||
|
// +--------------------+ +-------------------+
|
||||||
|
// Matrix HTTP | | | |
|
||||||
|
// Notification Protocol | App Developer | | Device Vendor |
|
||||||
|
// | | | |
|
||||||
|
// +-------------------+ | +----------------+ | | +---------------+ |
|
||||||
|
// | | | | | | | | | |
|
||||||
|
// | Matrix homeserver +-----> Push Gateway +------> Push Provider | |
|
||||||
|
// | | | | | | | | | |
|
||||||
|
// +-^-----------------+ | +----------------+ | | +----+----------+ |
|
||||||
|
// | | | | | |
|
||||||
|
// Matrix | | | | | |
|
||||||
|
// Client/Server API + | | | | |
|
||||||
|
// | | +--------------------+ +-------------------+
|
||||||
|
// | +--+-+ |
|
||||||
|
// | | <-------------------------------------------+
|
||||||
|
// +---+ |
|
||||||
|
// | | Provider Push Protocol
|
||||||
|
// +----+
|
||||||
|
//
|
||||||
|
// Mobile Device or Client
|
||||||
|
//
|
||||||
|
|
||||||
|
// matrixRequest represents a Matrix message, as it is sent to a Push Gateway (as per
|
||||||
|
// this spec: https://spec.matrix.org/v1.2/push-gateway-api/).
|
||||||
|
//
|
||||||
|
// From the message, we only require the "pushkey", as it represents our target topic URL.
|
||||||
|
// A message may look like this (excerpt):
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// "notification": {
|
||||||
|
// "devices": [
|
||||||
|
// {
|
||||||
|
// "pushkey": "https://ntfy.sh/upDAHJKFFDFD?up=1",
|
||||||
|
// ...
|
||||||
|
// }
|
||||||
|
// ]
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
type matrixRequest struct {
|
||||||
|
Notification *struct {
|
||||||
|
Devices []*struct {
|
||||||
|
PushKey string `json:"pushkey"`
|
||||||
|
} `json:"devices"`
|
||||||
|
} `json:"notification"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// matrixResponse represents the response to a Matrix push gateway message, as defined
|
||||||
|
// in the spec (https://spec.matrix.org/v1.2/push-gateway-api/).
|
||||||
|
type matrixResponse struct {
|
||||||
|
Rejected []string `json:"rejected"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// errMatrix represents an error when handing Matrix gateway messages
|
||||||
|
type errMatrix struct {
|
||||||
|
pushKey string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e errMatrix) Error() string {
|
||||||
|
if e.err != nil {
|
||||||
|
return fmt.Sprintf("message with push key %s rejected: %s", e.pushKey, e.err.Error())
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("message with push key %s rejected", e.pushKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// matrixPushKeyHeader is a header that's used internally to pass the Matrix push key (from the matrixRequest)
|
||||||
|
// along with the request. The push key is only used if an error occurs down the line.
|
||||||
|
matrixPushKeyHeader = "X-Matrix-Pushkey"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newRequestFromMatrixJSON reads the request body as a Matrix JSON message, parses the "pushkey", and creates a new
|
||||||
|
// HTTP request that looks like a normal ntfy request from it.
|
||||||
|
//
|
||||||
|
// It basically converts a Matrix push gatewqy request:
|
||||||
|
//
|
||||||
|
// POST /_matrix/push/v1/notify HTTP/1.1
|
||||||
|
// { "notification": { "devices": [ { "pushkey": "https://ntfy.sh/upDAHJKFFDFD?up=1", ... } ] } }
|
||||||
|
//
|
||||||
|
// to a ntfy request, looking like this:
|
||||||
|
//
|
||||||
|
// POST /upDAHJKFFDFD?up=1 HTTP/1.1
|
||||||
|
// { "notification": { "devices": [ { "pushkey": "https://ntfy.sh/upDAHJKFFDFD?up=1", ... } ] } }
|
||||||
|
func newRequestFromMatrixJSON(r *http.Request, baseURL string, messageLimit int) (*http.Request, error) {
|
||||||
|
if baseURL == "" {
|
||||||
|
return nil, errHTTPInternalErrorMissingBaseURL
|
||||||
|
}
|
||||||
|
body, err := util.Peek(r.Body, messageLimit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
if body.LimitReached {
|
||||||
|
return nil, errHTTPEntityTooLargeMatrixRequestTooLarge
|
||||||
|
}
|
||||||
|
var m matrixRequest
|
||||||
|
if err := json.Unmarshal(body.PeekedBytes, &m); err != nil {
|
||||||
|
return nil, errHTTPBadRequestMatrixMessageInvalid
|
||||||
|
} else if m.Notification == nil || len(m.Notification.Devices) == 0 || m.Notification.Devices[0].PushKey == "" {
|
||||||
|
return nil, errHTTPBadRequestMatrixMessageInvalid
|
||||||
|
}
|
||||||
|
pushKey := m.Notification.Devices[0].PushKey // We ignore other devices for now, see discussion in #316
|
||||||
|
if !strings.HasPrefix(pushKey, baseURL+"/") {
|
||||||
|
return nil, &errMatrix{pushKey: pushKey, err: wrapErrHTTP(errHTTPBadRequestMatrixPushkeyBaseURLMismatch, "received push key: %s, configured base URL: %s", pushKey, baseURL)}
|
||||||
|
}
|
||||||
|
newRequest, err := http.NewRequest(http.MethodPost, pushKey, io.NopCloser(bytes.NewReader(body.PeekedBytes)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, &errMatrix{pushKey: pushKey, err: err}
|
||||||
|
}
|
||||||
|
newRequest.RemoteAddr = r.RemoteAddr // Not strictly necessary, since visitor was already extracted
|
||||||
|
if r.Header.Get("X-Forwarded-For") != "" {
|
||||||
|
newRequest.Header.Set("X-Forwarded-For", r.Header.Get("X-Forwarded-For"))
|
||||||
|
}
|
||||||
|
newRequest.Header.Set(matrixPushKeyHeader, pushKey)
|
||||||
|
return newRequest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeMatrixDiscoveryResponse writes the UnifiedPush Matrix Gateway Discovery response to the given http.ResponseWriter,
|
||||||
|
// as per the spec (https://unifiedpush.org/developers/gateway/).
|
||||||
|
func writeMatrixDiscoveryResponse(w http.ResponseWriter) error {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, err := io.WriteString(w, `{"unifiedpush":{"gateway":"matrix"}}`+"\n")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeMatrixError logs and writes the errMatrix to the given http.ResponseWriter as a matrixResponse
|
||||||
|
func writeMatrixError(w http.ResponseWriter, r *http.Request, v *visitor, err *errMatrix) error {
|
||||||
|
log.Debug("%s Matrix gateway error: %s", logHTTPPrefix(v, r), err.Error())
|
||||||
|
return writeMatrixResponse(w, err.pushKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeMatrixSuccess writes a successful matrixResponse (no rejected push key) to the given http.ResponseWriter
|
||||||
|
func writeMatrixSuccess(w http.ResponseWriter) error {
|
||||||
|
return writeMatrixResponse(w, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeMatrixResponse writes a matrixResponse to the given http.ResponseWriter, as defined in
|
||||||
|
// the spec (https://spec.matrix.org/v1.2/push-gateway-api/)
|
||||||
|
func writeMatrixResponse(w http.ResponseWriter, rejectedPushKey string) error {
|
||||||
|
rejected := make([]string, 0)
|
||||||
|
if rejectedPushKey != "" {
|
||||||
|
rejected = append(rejected, rejectedPushKey)
|
||||||
|
}
|
||||||
|
response := &matrixResponse{
|
||||||
|
Rejected: rejected,
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
86
server/server_matrix_test.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMatrix_NewRequestFromMatrixJSON_Success(t *testing.T) {
|
||||||
|
baseURL := "https://ntfy.sh"
|
||||||
|
maxLength := 4096
|
||||||
|
body := `{"notification":{"content":{"body":"I'm floating in a most peculiar way.","msgtype":"m.text"},"counts":{"missed_calls":1,"unread":2},"devices":[{"app_id":"org.matrix.matrixConsole.ios","data":{},"pushkey":"https://ntfy.sh/upABCDEFGHI?up=1","pushkey_ts":12345678,"tweaks":{"sound":"bing"}}],"event_id":"$3957tyerfgewrf384","prio":"high","room_alias":"#exampleroom:matrix.org","room_id":"!slw48wfj34rtnrf:example.com","room_name":"Mission Control","sender":"@exampleuser:matrix.org","sender_display_name":"Major Tom","type":"m.room.message"}}`
|
||||||
|
r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body))
|
||||||
|
newRequest, err := newRequestFromMatrixJSON(r, baseURL, maxLength)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "POST", newRequest.Method)
|
||||||
|
require.Equal(t, "https://ntfy.sh/upABCDEFGHI?up=1", newRequest.URL.String())
|
||||||
|
require.Equal(t, "https://ntfy.sh/upABCDEFGHI?up=1", newRequest.Header.Get("X-Matrix-Pushkey"))
|
||||||
|
require.Equal(t, body, readAll(t, newRequest.Body))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatrix_NewRequestFromMatrixJSON_TooLarge(t *testing.T) {
|
||||||
|
baseURL := "https://ntfy.sh"
|
||||||
|
maxLength := 10 // Small
|
||||||
|
body := `{"notification":{"content":{"body":"I'm floating in a most peculiar way.","msgtype":"m.text"},"counts":{"missed_calls":1,"unread":2},"devices":[{"app_id":"org.matrix.matrixConsole.ios","data":{},"pushkey":"https://ntfy.sh/upABCDEFGHI?up=1","pushkey_ts":12345678,"tweaks":{"sound":"bing"}}],"event_id":"$3957tyerfgewrf384","prio":"high","room_alias":"#exampleroom:matrix.org","room_id":"!slw48wfj34rtnrf:example.com","room_name":"Mission Control","sender":"@exampleuser:matrix.org","sender_display_name":"Major Tom","type":"m.room.message"}}`
|
||||||
|
r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body))
|
||||||
|
_, err := newRequestFromMatrixJSON(r, baseURL, maxLength)
|
||||||
|
require.Equal(t, errHTTPEntityTooLargeMatrixRequestTooLarge, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatrix_NewRequestFromMatrixJSON_InvalidJSON(t *testing.T) {
|
||||||
|
baseURL := "https://ntfy.sh"
|
||||||
|
maxLength := 4096
|
||||||
|
body := `this is not json`
|
||||||
|
r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body))
|
||||||
|
_, err := newRequestFromMatrixJSON(r, baseURL, maxLength)
|
||||||
|
require.Equal(t, errHTTPBadRequestMatrixMessageInvalid, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatrix_NewRequestFromMatrixJSON_NotAMatrixMessage(t *testing.T) {
|
||||||
|
baseURL := "https://ntfy.sh"
|
||||||
|
maxLength := 4096
|
||||||
|
body := `{"message":"this is not a matrix message, but valid json"}`
|
||||||
|
r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body))
|
||||||
|
_, err := newRequestFromMatrixJSON(r, baseURL, maxLength)
|
||||||
|
require.Equal(t, errHTTPBadRequestMatrixMessageInvalid, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatrix_NewRequestFromMatrixJSON_MismatchingPushKey(t *testing.T) {
|
||||||
|
baseURL := "https://ntfy.sh" // Mismatch!
|
||||||
|
maxLength := 4096
|
||||||
|
body := `{"notification":{"content":{"body":"I'm floating in a most peculiar way.","msgtype":"m.text"},"counts":{"missed_calls":1,"unread":2},"devices":[{"app_id":"org.matrix.matrixConsole.ios","data":{},"pushkey":"https://ntfy.example.com/upABCDEFGHI?up=1","pushkey_ts":12345678,"tweaks":{"sound":"bing"}}],"event_id":"$3957tyerfgewrf384","prio":"high","room_alias":"#exampleroom:matrix.org","room_id":"!slw48wfj34rtnrf:example.com","room_name":"Mission Control","sender":"@exampleuser:matrix.org","sender_display_name":"Major Tom","type":"m.room.message"}}`
|
||||||
|
r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body))
|
||||||
|
_, err := newRequestFromMatrixJSON(r, baseURL, maxLength)
|
||||||
|
matrixErr, ok := err.(*errMatrix)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, "invalid request: push key must be prefixed with base URL, received push key: https://ntfy.example.com/upABCDEFGHI?up=1, configured base URL: https://ntfy.sh", matrixErr.err.Error())
|
||||||
|
require.Equal(t, "https://ntfy.example.com/upABCDEFGHI?up=1", matrixErr.pushKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatrix_WriteMatrixDiscoveryResponse(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
require.Nil(t, writeMatrixDiscoveryResponse(w))
|
||||||
|
require.Equal(t, 200, w.Result().StatusCode)
|
||||||
|
require.Equal(t, `{"unifiedpush":{"gateway":"matrix"}}`+"\n", w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatrix_WriteMatrixError(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", nil)
|
||||||
|
v := newVisitor(newTestConfig(t), nil, netip.MustParseAddr("1.2.3.4"))
|
||||||
|
require.Nil(t, writeMatrixError(w, r, v, &errMatrix{"https://ntfy.example.com/upABCDEFGHI?up=1", errHTTPBadRequestMatrixPushkeyBaseURLMismatch}))
|
||||||
|
require.Equal(t, 200, w.Result().StatusCode)
|
||||||
|
require.Equal(t, `{"rejected":["https://ntfy.example.com/upABCDEFGHI?up=1"]}`+"\n", w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatrix_WriteMatrixSuccess(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
require.Nil(t, writeMatrixSuccess(w))
|
||||||
|
require.Equal(t, 200, w.Result().StatusCode)
|
||||||
|
require.Equal(t, `{"rejected":[]}`+"\n", w.Body.String())
|
||||||
|
}
|
||||||
@@ -6,18 +6,23 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/stretchr/testify/require"
|
"io"
|
||||||
"heckel.io/ntfy/auth"
|
"log"
|
||||||
"heckel.io/ntfy/util"
|
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"net/netip"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"heckel.io/ntfy/auth"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestServer_PublishAndPoll(t *testing.T) {
|
func TestServer_PublishAndPoll(t *testing.T) {
|
||||||
@@ -54,6 +59,23 @@ func TestServer_PublishAndPoll(t *testing.T) {
|
|||||||
require.Equal(t, "my second message", lines[1]) // \n -> " "
|
require.Equal(t, "my second message", lines[1]) // \n -> " "
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishWithFirebase(t *testing.T) {
|
||||||
|
sender := newTestFirebaseSender(10)
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true})
|
||||||
|
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "my first message", nil)
|
||||||
|
msg1 := toMessage(t, response.Body.String())
|
||||||
|
require.NotEmpty(t, msg1.ID)
|
||||||
|
require.Equal(t, "my first message", msg1.Message)
|
||||||
|
|
||||||
|
time.Sleep(100 * time.Millisecond) // Firebase publishing happens
|
||||||
|
require.Equal(t, 1, len(sender.Messages()))
|
||||||
|
require.Equal(t, "my first message", sender.Messages()[0].Data["message"])
|
||||||
|
require.Equal(t, "my first message", sender.Messages()[0].APNS.Payload.Aps.Alert.Body)
|
||||||
|
require.Equal(t, "my first message", sender.Messages()[0].APNS.Payload.CustomData["message"])
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_SubscribeOpenAndKeepalive(t *testing.T) {
|
func TestServer_SubscribeOpenAndKeepalive(t *testing.T) {
|
||||||
c := newTestConfig(t)
|
c := newTestConfig(t)
|
||||||
c.KeepaliveInterval = time.Second
|
c.KeepaliveInterval = time.Second
|
||||||
@@ -156,10 +178,34 @@ func TestServer_StaticSites(t *testing.T) {
|
|||||||
require.Equal(t, 301, rr.Code)
|
require.Equal(t, 301, rr.Code)
|
||||||
|
|
||||||
// Docs test removed, it was failing annoyingly.
|
// Docs test removed, it was failing annoyingly.
|
||||||
|
}
|
||||||
|
|
||||||
rr = request(t, s, "GET", "/example.html", "", nil)
|
func TestServer_WebEnabled(t *testing.T) {
|
||||||
|
conf := newTestConfig(t)
|
||||||
|
conf.EnableWeb = false
|
||||||
|
s := newTestServer(t, conf)
|
||||||
|
|
||||||
|
rr := request(t, s, "GET", "/", "", nil)
|
||||||
|
require.Equal(t, 404, rr.Code)
|
||||||
|
|
||||||
|
rr = request(t, s, "GET", "/config.js", "", nil)
|
||||||
|
require.Equal(t, 404, rr.Code)
|
||||||
|
|
||||||
|
rr = request(t, s, "GET", "/static/css/home.css", "", nil)
|
||||||
|
require.Equal(t, 404, rr.Code)
|
||||||
|
|
||||||
|
conf2 := newTestConfig(t)
|
||||||
|
conf2.EnableWeb = true
|
||||||
|
s2 := newTestServer(t, conf2)
|
||||||
|
|
||||||
|
rr = request(t, s2, "GET", "/", "", nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
rr = request(t, s2, "GET", "/config.js", "", nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
rr = request(t, s2, "GET", "/static/css/home.css", "", nil)
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
require.Contains(t, rr.Body.String(), "</html>")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_PublishLargeMessage(t *testing.T) {
|
func TestServer_PublishLargeMessage(t *testing.T) {
|
||||||
@@ -229,7 +275,7 @@ func TestServer_PublishNoCache(t *testing.T) {
|
|||||||
func TestServer_PublishAt(t *testing.T) {
|
func TestServer_PublishAt(t *testing.T) {
|
||||||
c := newTestConfig(t)
|
c := newTestConfig(t)
|
||||||
c.MinDelay = time.Second
|
c.MinDelay = time.Second
|
||||||
c.AtSenderInterval = 100 * time.Millisecond
|
c.DelayedSenderInterval = 100 * time.Millisecond
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{
|
response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{
|
||||||
@@ -248,6 +294,13 @@ func TestServer_PublishAt(t *testing.T) {
|
|||||||
messages = toMessages(t, response.Body.String())
|
messages = toMessages(t, response.Body.String())
|
||||||
require.Equal(t, 1, len(messages))
|
require.Equal(t, 1, len(messages))
|
||||||
require.Equal(t, "a message", messages[0].Message)
|
require.Equal(t, "a message", messages[0].Message)
|
||||||
|
require.Equal(t, netip.Addr{}, messages[0].Sender) // Never return the sender!
|
||||||
|
|
||||||
|
messages, err := s.messageCache.Messages("mytopic", sinceAllMessages, true)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(messages))
|
||||||
|
require.Equal(t, "a message", messages[0].Message)
|
||||||
|
require.Equal(t, "9.9.9.9", messages[0].Sender.String()) // It's stored in the DB though!
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_PublishAtWithCacheError(t *testing.T) {
|
func TestServer_PublishAtWithCacheError(t *testing.T) {
|
||||||
@@ -390,6 +443,53 @@ func TestServer_PublishAndPollSince(t *testing.T) {
|
|||||||
require.Equal(t, 40008, toHTTPError(t, response.Body.String()).Code)
|
require.Equal(t, 40008, toHTTPError(t, response.Body.String()).Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newMessageWithTimestamp(topic, message string, timestamp int64) *message {
|
||||||
|
m := newDefaultMessage(topic, message)
|
||||||
|
m.Time = timestamp
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PollSinceID_MultipleTopics(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
|
require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 1", 1655740277)))
|
||||||
|
markerMessage := newMessageWithTimestamp("mytopic2", "test 2", 1655740283)
|
||||||
|
require.Nil(t, s.messageCache.AddMessage(markerMessage))
|
||||||
|
require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 3", 1655740289)))
|
||||||
|
require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic2", "test 4", 1655740293)))
|
||||||
|
require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 5", 1655740297)))
|
||||||
|
require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic2", "test 6", 1655740303)))
|
||||||
|
|
||||||
|
response := request(t, s, "GET", fmt.Sprintf("/mytopic1,mytopic2/json?poll=1&since=%s", markerMessage.ID), "", nil)
|
||||||
|
messages := toMessages(t, response.Body.String())
|
||||||
|
require.Equal(t, 4, len(messages))
|
||||||
|
require.Equal(t, "test 3", messages[0].Message)
|
||||||
|
require.Equal(t, "mytopic1", messages[0].Topic)
|
||||||
|
require.Equal(t, "test 4", messages[1].Message)
|
||||||
|
require.Equal(t, "mytopic2", messages[1].Topic)
|
||||||
|
require.Equal(t, "test 5", messages[2].Message)
|
||||||
|
require.Equal(t, "mytopic1", messages[2].Topic)
|
||||||
|
require.Equal(t, "test 6", messages[3].Message)
|
||||||
|
require.Equal(t, "mytopic2", messages[3].Topic)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PollSinceID_MultipleTopics_IDDoesNotMatch(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
|
require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 3", 1655740289)))
|
||||||
|
require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic2", "test 4", 1655740293)))
|
||||||
|
require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 5", 1655740297)))
|
||||||
|
require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic2", "test 6", 1655740303)))
|
||||||
|
|
||||||
|
response := request(t, s, "GET", "/mytopic1,mytopic2/json?poll=1&since=NoMatchForID", "", nil)
|
||||||
|
messages := toMessages(t, response.Body.String())
|
||||||
|
require.Equal(t, 4, len(messages))
|
||||||
|
require.Equal(t, "test 3", messages[0].Message)
|
||||||
|
require.Equal(t, "test 4", messages[1].Message)
|
||||||
|
require.Equal(t, "test 5", messages[2].Message)
|
||||||
|
require.Equal(t, "test 6", messages[3].Message)
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_PublishViaGET(t *testing.T) {
|
func TestServer_PublishViaGET(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
@@ -419,29 +519,9 @@ func TestServer_PublishMessageInHeaderWithNewlines(t *testing.T) {
|
|||||||
require.Equal(t, "Line 1\nLine 2", msg.Message) // \\n -> \n !
|
require.Equal(t, "Line 1\nLine 2", msg.Message) // \\n -> \n !
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_PublishFirebase(t *testing.T) {
|
|
||||||
// This is unfortunately not much of a test, since it merely fires the messages towards Firebase,
|
|
||||||
// but cannot re-read them. There is no way from Go to read the messages back, or even get an error back.
|
|
||||||
// I tried everything. I already had written the test, and it increases the code coverage, so I'll leave it ... :shrug: ...
|
|
||||||
|
|
||||||
c := newTestConfig(t)
|
|
||||||
c.FirebaseKeyFile = firebaseServiceAccountFile(t) // May skip the test!
|
|
||||||
s := newTestServer(t, c)
|
|
||||||
|
|
||||||
// Normal message
|
|
||||||
response := request(t, s, "PUT", "/mytopic", "This is a message for firebase", nil)
|
|
||||||
msg := toMessage(t, response.Body.String())
|
|
||||||
require.NotEmpty(t, msg.ID)
|
|
||||||
|
|
||||||
// Keepalive message
|
|
||||||
require.Nil(t, s.firebase(newKeepaliveMessage(firebaseControlTopic)))
|
|
||||||
|
|
||||||
time.Sleep(500 * time.Millisecond) // Time for sends
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServer_PublishInvalidTopic(t *testing.T) {
|
func TestServer_PublishInvalidTopic(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
s.mailer = &testMailer{}
|
s.smtpSender = &testMailer{}
|
||||||
response := request(t, s, "PUT", "/docs", "fail", nil)
|
response := request(t, s, "PUT", "/docs", "fail", nil)
|
||||||
require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code)
|
require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code)
|
||||||
}
|
}
|
||||||
@@ -707,13 +787,17 @@ type testMailer struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *testMailer) Send(from, to string, m *message) error {
|
func (t *testMailer) Send(v *visitor, m *message, to string) error {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
defer t.mu.Unlock()
|
defer t.mu.Unlock()
|
||||||
t.count++
|
t.count++
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *testMailer) Counts() (total int64, success int64, failure int64) {
|
||||||
|
return 0, 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
func (t *testMailer) Count() int {
|
func (t *testMailer) Count() int {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
defer t.mu.Unlock()
|
defer t.mu.Unlock()
|
||||||
@@ -732,7 +816,7 @@ func TestServer_PublishTooRequests_Defaults(t *testing.T) {
|
|||||||
|
|
||||||
func TestServer_PublishTooRequests_Defaults_ExemptHosts(t *testing.T) {
|
func TestServer_PublishTooRequests_Defaults_ExemptHosts(t *testing.T) {
|
||||||
c := newTestConfig(t)
|
c := newTestConfig(t)
|
||||||
c.VisitorRequestExemptIPAddrs = []string{"9.9.9.9"} // see request()
|
c.VisitorRequestExemptIPAddrs = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request()
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
for i := 0; i < 65; i++ { // > 60
|
for i := 0; i < 65; i++ { // > 60
|
||||||
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil)
|
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil)
|
||||||
@@ -752,14 +836,14 @@ func TestServer_PublishTooRequests_ShortReplenish(t *testing.T) {
|
|||||||
response := request(t, s, "PUT", "/mytopic", "message", nil)
|
response := request(t, s, "PUT", "/mytopic", "message", nil)
|
||||||
require.Equal(t, 429, response.Code)
|
require.Equal(t, 429, response.Code)
|
||||||
|
|
||||||
time.Sleep(510 * time.Millisecond)
|
time.Sleep(520 * time.Millisecond)
|
||||||
response = request(t, s, "PUT", "/mytopic", "message", nil)
|
response = request(t, s, "PUT", "/mytopic", "message", nil)
|
||||||
require.Equal(t, 200, response.Code)
|
require.Equal(t, 200, response.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_PublishTooManyEmails_Defaults(t *testing.T) {
|
func TestServer_PublishTooManyEmails_Defaults(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
s.mailer = &testMailer{}
|
s.smtpSender = &testMailer{}
|
||||||
for i := 0; i < 16; i++ {
|
for i := 0; i < 16; i++ {
|
||||||
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
|
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
|
||||||
"E-Mail": "test@example.com",
|
"E-Mail": "test@example.com",
|
||||||
@@ -776,7 +860,7 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) {
|
|||||||
c := newTestConfig(t)
|
c := newTestConfig(t)
|
||||||
c.VisitorEmailLimitReplenish = 500 * time.Millisecond
|
c.VisitorEmailLimitReplenish = 500 * time.Millisecond
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
s.mailer = &testMailer{}
|
s.smtpSender = &testMailer{}
|
||||||
for i := 0; i < 16; i++ {
|
for i := 0; i < 16; i++ {
|
||||||
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
|
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
|
||||||
"E-Mail": "test@example.com",
|
"E-Mail": "test@example.com",
|
||||||
@@ -802,7 +886,7 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) {
|
|||||||
|
|
||||||
func TestServer_PublishDelayedEmail_Fail(t *testing.T) {
|
func TestServer_PublishDelayedEmail_Fail(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
s.mailer = &testMailer{}
|
s.smtpSender = &testMailer{}
|
||||||
response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{
|
response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{
|
||||||
"E-Mail": "test@example.com",
|
"E-Mail": "test@example.com",
|
||||||
"Delay": "20 min",
|
"Delay": "20 min",
|
||||||
@@ -876,6 +960,70 @@ func TestServer_PublishUnifiedPushText(t *testing.T) {
|
|||||||
require.Equal(t, "this is a unifiedpush text message", m.Message)
|
require.Equal(t, "this is a unifiedpush text message", m.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_MatrixGateway_Discovery_Success(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
response := request(t, s, "GET", "/_matrix/push/v1/notify", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
require.Equal(t, `{"unifiedpush":{"gateway":"matrix"}}`+"\n", response.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_MatrixGateway_Discovery_Failure_Unconfigured(t *testing.T) {
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.BaseURL = ""
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
response := request(t, s, "GET", "/_matrix/push/v1/notify", "", nil)
|
||||||
|
require.Equal(t, 500, response.Code)
|
||||||
|
err := toHTTPError(t, response.Body.String())
|
||||||
|
require.Equal(t, 50003, err.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_MatrixGateway_Push_Success(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}`
|
||||||
|
response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
require.Equal(t, `{"rejected":[]}`+"\n", response.Body.String())
|
||||||
|
|
||||||
|
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
m := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, notification, m.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_MatrixGateway_Push_Failure_InvalidPushkey(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
notification := `{"notification":{"devices":[{"pushkey":"http://wrong-base-url.com/mytopic?up=1"}]}}`
|
||||||
|
response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
require.Equal(t, `{"rejected":["http://wrong-base-url.com/mytopic?up=1"]}`+"\n", response.Body.String())
|
||||||
|
|
||||||
|
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
require.Equal(t, "", response.Body.String()) // Empty!
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_MatrixGateway_Push_Failure_EverythingIsWrong(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
notification := `{"message":"this is not really a Matrix message"}`
|
||||||
|
response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
|
||||||
|
require.Equal(t, 400, response.Code)
|
||||||
|
err := toHTTPError(t, response.Body.String())
|
||||||
|
require.Equal(t, 40019, err.Code)
|
||||||
|
require.Equal(t, 400, err.HTTPCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_MatrixGateway_Push_Failure_Unconfigured(t *testing.T) {
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.BaseURL = ""
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}`
|
||||||
|
response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
|
||||||
|
require.Equal(t, 500, response.Code)
|
||||||
|
err := toHTTPError(t, response.Body.String())
|
||||||
|
require.Equal(t, 50003, err.Code)
|
||||||
|
require.Equal(t, 500, err.HTTPCode)
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_PublishActions_AndPoll(t *testing.T) {
|
func TestServer_PublishActions_AndPoll(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
response := request(t, s, "PUT", "/mytopic", "my message", map[string]string{
|
response := request(t, s, "PUT", "/mytopic", "my message", map[string]string{
|
||||||
@@ -900,7 +1048,7 @@ func TestServer_PublishAsJSON(t *testing.T) {
|
|||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
body := `{"topic":"mytopic","message":"A message","title":"a title\nwith lines","tags":["tag1","tag 2"],` +
|
body := `{"topic":"mytopic","message":"A message","title":"a title\nwith lines","tags":["tag1","tag 2"],` +
|
||||||
`"not-a-thing":"ok", "attach":"http://google.com","filename":"google.pdf", "click":"http://ntfy.sh","priority":4,` +
|
`"not-a-thing":"ok", "attach":"http://google.com","filename":"google.pdf", "click":"http://ntfy.sh","priority":4,` +
|
||||||
`"delay":"30min"}`
|
`"icon":"https://ntfy.sh/static/img/ntfy.png", "delay":"30min"}`
|
||||||
response := request(t, s, "PUT", "/", body, nil)
|
response := request(t, s, "PUT", "/", body, nil)
|
||||||
require.Equal(t, 200, response.Code)
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
@@ -912,6 +1060,8 @@ func TestServer_PublishAsJSON(t *testing.T) {
|
|||||||
require.Equal(t, "http://google.com", m.Attachment.URL)
|
require.Equal(t, "http://google.com", m.Attachment.URL)
|
||||||
require.Equal(t, "google.pdf", m.Attachment.Name)
|
require.Equal(t, "google.pdf", m.Attachment.Name)
|
||||||
require.Equal(t, "http://ntfy.sh", m.Click)
|
require.Equal(t, "http://ntfy.sh", m.Click)
|
||||||
|
require.Equal(t, "https://ntfy.sh/static/img/ntfy.png", m.Icon)
|
||||||
|
|
||||||
require.Equal(t, 4, m.Priority)
|
require.Equal(t, 4, m.Priority)
|
||||||
require.True(t, m.Time > time.Now().Unix()+29*60)
|
require.True(t, m.Time > time.Now().Unix()+29*60)
|
||||||
require.True(t, m.Time < time.Now().Unix()+31*60)
|
require.True(t, m.Time < time.Now().Unix()+31*60)
|
||||||
@@ -920,10 +1070,11 @@ func TestServer_PublishAsJSON(t *testing.T) {
|
|||||||
func TestServer_PublishAsJSON_WithEmail(t *testing.T) {
|
func TestServer_PublishAsJSON_WithEmail(t *testing.T) {
|
||||||
mailer := &testMailer{}
|
mailer := &testMailer{}
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
s.mailer = mailer
|
s.smtpSender = mailer
|
||||||
body := `{"topic":"mytopic","message":"A message","email":"phil@example.com"}`
|
body := `{"topic":"mytopic","message":"A message","email":"phil@example.com"}`
|
||||||
response := request(t, s, "PUT", "/", body, nil)
|
response := request(t, s, "PUT", "/", body, nil)
|
||||||
require.Equal(t, 200, response.Code)
|
require.Equal(t, 200, response.Code)
|
||||||
|
time.Sleep(100 * time.Millisecond) // E-Mail publishing happens in a Go routine
|
||||||
|
|
||||||
m := toMessage(t, response.Body.String())
|
m := toMessage(t, response.Body.String())
|
||||||
require.Equal(t, "mytopic", m.Topic)
|
require.Equal(t, "mytopic", m.Topic)
|
||||||
@@ -983,15 +1134,22 @@ func TestServer_PublishAttachment(t *testing.T) {
|
|||||||
require.Equal(t, int64(5000), msg.Attachment.Size)
|
require.Equal(t, int64(5000), msg.Attachment.Size)
|
||||||
require.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(179*time.Minute).Unix()) // Almost 3 hours
|
require.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(179*time.Minute).Unix()) // Almost 3 hours
|
||||||
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
||||||
require.Equal(t, "", msg.Attachment.Owner) // Should never be returned
|
require.Equal(t, netip.Addr{}, msg.Sender) // Should never be returned
|
||||||
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID))
|
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID))
|
||||||
|
|
||||||
|
// GET
|
||||||
path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345")
|
path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345")
|
||||||
response = request(t, s, "GET", path, "", nil)
|
response = request(t, s, "GET", path, "", nil)
|
||||||
require.Equal(t, 200, response.Code)
|
require.Equal(t, 200, response.Code)
|
||||||
require.Equal(t, "5000", response.Header().Get("Content-Length"))
|
require.Equal(t, "5000", response.Header().Get("Content-Length"))
|
||||||
require.Equal(t, content, response.Body.String())
|
require.Equal(t, content, response.Body.String())
|
||||||
|
|
||||||
|
// HEAD
|
||||||
|
response = request(t, s, "HEAD", path, "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
require.Equal(t, "5000", response.Header().Get("Content-Length"))
|
||||||
|
require.Equal(t, "", response.Body.String())
|
||||||
|
|
||||||
// Slightly unrelated cross-test: make sure we add an owner for internal attachments
|
// Slightly unrelated cross-test: make sure we add an owner for internal attachments
|
||||||
size, err := s.messageCache.AttachmentBytesUsed("9.9.9.9") // See request()
|
size, err := s.messageCache.AttachmentBytesUsed("9.9.9.9") // See request()
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
@@ -1012,7 +1170,7 @@ func TestServer_PublishAttachmentShortWithFilename(t *testing.T) {
|
|||||||
require.Equal(t, int64(21), msg.Attachment.Size)
|
require.Equal(t, int64(21), msg.Attachment.Size)
|
||||||
require.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(3*time.Hour).Unix())
|
require.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(3*time.Hour).Unix())
|
||||||
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
||||||
require.Equal(t, "", msg.Attachment.Owner) // Should never be returned
|
require.Equal(t, netip.Addr{}, msg.Sender) // Should never be returned
|
||||||
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID))
|
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID))
|
||||||
|
|
||||||
path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345")
|
path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345")
|
||||||
@@ -1039,7 +1197,7 @@ func TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) {
|
|||||||
require.Equal(t, "", msg.Attachment.Type)
|
require.Equal(t, "", msg.Attachment.Type)
|
||||||
require.Equal(t, int64(0), msg.Attachment.Size)
|
require.Equal(t, int64(0), msg.Attachment.Size)
|
||||||
require.Equal(t, int64(0), msg.Attachment.Expires)
|
require.Equal(t, int64(0), msg.Attachment.Expires)
|
||||||
require.Equal(t, "", msg.Attachment.Owner)
|
require.Equal(t, netip.Addr{}, msg.Sender)
|
||||||
|
|
||||||
// Slightly unrelated cross-test: make sure we don't add an owner for external attachments
|
// Slightly unrelated cross-test: make sure we don't add an owner for external attachments
|
||||||
size, err := s.messageCache.AttachmentBytesUsed("127.0.0.1")
|
size, err := s.messageCache.AttachmentBytesUsed("127.0.0.1")
|
||||||
@@ -1060,7 +1218,7 @@ func TestServer_PublishAttachmentExternalWithFilename(t *testing.T) {
|
|||||||
require.Equal(t, "", msg.Attachment.Type)
|
require.Equal(t, "", msg.Attachment.Type)
|
||||||
require.Equal(t, int64(0), msg.Attachment.Size)
|
require.Equal(t, int64(0), msg.Attachment.Size)
|
||||||
require.Equal(t, int64(0), msg.Attachment.Expires)
|
require.Equal(t, int64(0), msg.Attachment.Expires)
|
||||||
require.Equal(t, "", msg.Attachment.Owner)
|
require.Equal(t, netip.Addr{}, msg.Sender)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_PublishAttachmentBadURL(t *testing.T) {
|
func TestServer_PublishAttachmentBadURL(t *testing.T) {
|
||||||
@@ -1227,6 +1385,84 @@ func TestServer_PublishAttachmentUserStats(t *testing.T) {
|
|||||||
require.Equal(t, int64(1001), stats.VisitorAttachmentBytesRemaining)
|
require.Equal(t, int64(1001), stats.VisitorAttachmentBytesRemaining)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_Visitor_XForwardedFor_None(t *testing.T) {
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.BehindProxy = true
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||||
|
r.RemoteAddr = "8.9.10.11"
|
||||||
|
r.Header.Set("X-Forwarded-For", " ") // Spaces, not empty!
|
||||||
|
v := s.visitor(r)
|
||||||
|
require.Equal(t, "8.9.10.11", v.ip.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_Visitor_XForwardedFor_Single(t *testing.T) {
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.BehindProxy = true
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||||
|
r.RemoteAddr = "8.9.10.11"
|
||||||
|
r.Header.Set("X-Forwarded-For", "1.1.1.1")
|
||||||
|
v := s.visitor(r)
|
||||||
|
require.Equal(t, "1.1.1.1", v.ip.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_Visitor_XForwardedFor_Multiple(t *testing.T) {
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.BehindProxy = true
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||||
|
r.RemoteAddr = "8.9.10.11"
|
||||||
|
r.Header.Set("X-Forwarded-For", "1.2.3.4 , 2.4.4.2,234.5.2.1 ")
|
||||||
|
v := s.visitor(r)
|
||||||
|
require.Equal(t, "234.5.2.1", v.ip.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) {
|
||||||
|
count := 50000
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.TotalTopicLimit = 50001
|
||||||
|
c.CacheStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;"
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
|
// Add lots of messages
|
||||||
|
log.Printf("Adding %d messages", count)
|
||||||
|
start := time.Now()
|
||||||
|
messages := make([]*message, 0)
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
topicID := fmt.Sprintf("topic%d", i)
|
||||||
|
_, err := s.topicsFromIDs(topicID) // Add topic to internal s.topics array
|
||||||
|
require.Nil(t, err)
|
||||||
|
messages = append(messages, newDefaultMessage(topicID, "some message"))
|
||||||
|
}
|
||||||
|
require.Nil(t, s.messageCache.addMessages(messages))
|
||||||
|
log.Printf("Done: Adding %d messages; took %s", count, time.Since(start).Round(time.Millisecond))
|
||||||
|
|
||||||
|
// Update stats
|
||||||
|
statsChan := make(chan bool)
|
||||||
|
go func() {
|
||||||
|
log.Printf("Updating stats")
|
||||||
|
start := time.Now()
|
||||||
|
s.updateStatsAndPrune()
|
||||||
|
log.Printf("Done: Updating stats; took %s", time.Since(start).Round(time.Millisecond))
|
||||||
|
statsChan <- true
|
||||||
|
}()
|
||||||
|
time.Sleep(50 * time.Millisecond) // Make sure it starts first
|
||||||
|
|
||||||
|
// Publish message (during stats update)
|
||||||
|
log.Printf("Publishing message")
|
||||||
|
start = time.Now()
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "some body", nil)
|
||||||
|
m := toMessage(t, response.Body.String())
|
||||||
|
assert.Equal(t, "some body", m.Message)
|
||||||
|
assert.True(t, time.Since(start) < 100*time.Millisecond)
|
||||||
|
log.Printf("Done: Publishing message; took %s", time.Since(start).Round(time.Millisecond))
|
||||||
|
|
||||||
|
// Wait for all goroutines
|
||||||
|
<-statsChan
|
||||||
|
log.Printf("Done: Waiting for all locks")
|
||||||
|
}
|
||||||
|
|
||||||
func newTestConfig(t *testing.T) *Config {
|
func newTestConfig(t *testing.T) *Config {
|
||||||
conf := NewConfig()
|
conf := NewConfig()
|
||||||
conf.BaseURL = "http://127.0.0.1:12345"
|
conf.BaseURL = "http://127.0.0.1:12345"
|
||||||
@@ -1298,18 +1534,14 @@ func toHTTPError(t *testing.T, s string) *errHTTP {
|
|||||||
return &e
|
return &e
|
||||||
}
|
}
|
||||||
|
|
||||||
func firebaseServiceAccountFile(t *testing.T) string {
|
|
||||||
if os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE") != "" {
|
|
||||||
return os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE")
|
|
||||||
} else if os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT") != "" {
|
|
||||||
filename := filepath.Join(t.TempDir(), "firebase.json")
|
|
||||||
require.NotNil(t, os.WriteFile(filename, []byte(os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT")), 0600))
|
|
||||||
return filename
|
|
||||||
}
|
|
||||||
t.SkipNow()
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func basicAuth(s string) string {
|
func basicAuth(s string) string {
|
||||||
return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(s)))
|
return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(s)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func readAll(t *testing.T, rc io.ReadCloser) string {
|
||||||
|
b, err := io.ReadAll(rc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,33 +4,62 @@ import (
|
|||||||
_ "embed" // required by go:embed
|
_ "embed" // required by go:embed
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"mime"
|
"mime"
|
||||||
"net"
|
"net"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mailer interface {
|
type mailer interface {
|
||||||
Send(from, to string, m *message) error
|
Send(v *visitor, m *message, to string) error
|
||||||
|
Counts() (total int64, success int64, failure int64)
|
||||||
}
|
}
|
||||||
|
|
||||||
type smtpSender struct {
|
type smtpSender struct {
|
||||||
config *Config
|
config *Config
|
||||||
|
success int64
|
||||||
|
failure int64
|
||||||
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *smtpSender) Send(senderIP, to string, m *message) error {
|
func (s *smtpSender) Send(v *visitor, m *message, to string) error {
|
||||||
host, _, err := net.SplitHostPort(s.config.SMTPSenderAddr)
|
return s.withCount(v, m, func() error {
|
||||||
|
host, _, err := net.SplitHostPort(s.config.SMTPSenderAddr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
message, err := formatMail(s.config.BaseURL, v.ip.String(), s.config.SMTPSenderFrom, to, m)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
auth := smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host)
|
||||||
|
log.Debug("%s Sending mail: via=%s, user=%s, pass=***, to=%s", logMessagePrefix(v, m), s.config.SMTPSenderAddr, s.config.SMTPSenderUser, to)
|
||||||
|
log.Trace("%s Mail body: %s", logMessagePrefix(v, m), message)
|
||||||
|
return smtp.SendMail(s.config.SMTPSenderAddr, auth, s.config.SMTPSenderFrom, []string{to}, []byte(message))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *smtpSender) Counts() (total int64, success int64, failure int64) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.success + s.failure, s.success, s.failure
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *smtpSender) withCount(v *visitor, m *message, fn func() error) error {
|
||||||
|
err := fn()
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
log.Debug("%s Sending mail failed: %s", logMessagePrefix(v, m), err.Error())
|
||||||
|
s.failure++
|
||||||
|
} else {
|
||||||
|
s.success++
|
||||||
}
|
}
|
||||||
message, err := formatMail(s.config.BaseURL, senderIP, s.config.SMTPSenderFrom, to, m)
|
return err
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
auth := smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host)
|
|
||||||
return smtp.SendMail(s.config.SMTPSenderAddr, auth, s.config.SMTPSenderFrom, []string{to}, []byte(message))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatMail(baseURL, senderIP, from, to string, m *message) (string, error) {
|
func formatMail(baseURL, senderIP, from, to string, m *message) (string, error) {
|
||||||
@@ -108,7 +137,7 @@ func toEmojis(tags []string) (emojisOut []string, tagsOut []string, err error) {
|
|||||||
nextTag:
|
nextTag:
|
||||||
for _, t := range tags { // TODO Super inefficient; we should just create a .json file with a map
|
for _, t := range tags { // TODO Super inefficient; we should just create a .json file with a map
|
||||||
for _, e := range emojis {
|
for _, e := range emojis {
|
||||||
if util.InStringList(e.Aliases, t) {
|
if util.Contains(e.Aliases, t) {
|
||||||
emojisOut = append(emojisOut, e.Emoji)
|
emojisOut = append(emojisOut, e.Emoji)
|
||||||
continue nextTag
|
continue nextTag
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,15 @@ package server
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"github.com/emersion/go-smtp"
|
"github.com/emersion/go-smtp"
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -23,49 +28,55 @@ var (
|
|||||||
// smtpBackend implements SMTP server methods.
|
// smtpBackend implements SMTP server methods.
|
||||||
type smtpBackend struct {
|
type smtpBackend struct {
|
||||||
config *Config
|
config *Config
|
||||||
sub subscriber
|
handler func(http.ResponseWriter, *http.Request)
|
||||||
success int64
|
success int64
|
||||||
failure int64
|
failure int64
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMailBackend(conf *Config, sub subscriber) *smtpBackend {
|
func newMailBackend(conf *Config, handler func(http.ResponseWriter, *http.Request)) *smtpBackend {
|
||||||
return &smtpBackend{
|
return &smtpBackend{
|
||||||
config: conf,
|
config: conf,
|
||||||
sub: sub,
|
handler: handler,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *smtpBackend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) {
|
func (b *smtpBackend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) {
|
||||||
return &smtpSession{backend: b}, nil
|
log.Debug("%s Incoming mail, login with user %s", logSMTPPrefix(state), username)
|
||||||
|
return &smtpSession{backend: b, state: state}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *smtpBackend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) {
|
func (b *smtpBackend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) {
|
||||||
return &smtpSession{backend: b}, nil
|
log.Debug("%s Incoming mail, anonymous login", logSMTPPrefix(state))
|
||||||
|
return &smtpSession{backend: b, state: state}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *smtpBackend) Counts() (success int64, failure int64) {
|
func (b *smtpBackend) Counts() (total int64, success int64, failure int64) {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
return b.success, b.failure
|
return b.success + b.failure, b.success, b.failure
|
||||||
}
|
}
|
||||||
|
|
||||||
// smtpSession is returned after EHLO.
|
// smtpSession is returned after EHLO.
|
||||||
type smtpSession struct {
|
type smtpSession struct {
|
||||||
backend *smtpBackend
|
backend *smtpBackend
|
||||||
|
state *smtp.ConnectionState
|
||||||
topic string
|
topic string
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *smtpSession) AuthPlain(username, password string) error {
|
func (s *smtpSession) AuthPlain(username, password string) error {
|
||||||
|
log.Debug("%s AUTH PLAIN (with username %s)", logSMTPPrefix(s.state), username)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *smtpSession) Mail(from string, opts smtp.MailOptions) error {
|
func (s *smtpSession) Mail(from string, opts smtp.MailOptions) error {
|
||||||
|
log.Debug("%s MAIL FROM: %s (with options: %#v)", logSMTPPrefix(s.state), from, opts)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *smtpSession) Rcpt(to string) error {
|
func (s *smtpSession) Rcpt(to string) error {
|
||||||
|
log.Debug("%s RCPT TO: %s", logSMTPPrefix(s.state), to)
|
||||||
return s.withFailCount(func() error {
|
return s.withFailCount(func() error {
|
||||||
conf := s.backend.config
|
conf := s.backend.config
|
||||||
addressList, err := mail.ParseAddressList(to)
|
addressList, err := mail.ParseAddressList(to)
|
||||||
@@ -102,6 +113,11 @@ func (s *smtpSession) Data(r io.Reader) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if log.IsTrace() {
|
||||||
|
log.Trace("%s DATA: %s", logSMTPPrefix(s.state), string(b))
|
||||||
|
} else if log.IsDebug() {
|
||||||
|
log.Debug("%s DATA: %d byte(s)", logSMTPPrefix(s.state), len(b))
|
||||||
|
}
|
||||||
msg, err := mail.ReadMessage(bytes.NewReader(b))
|
msg, err := mail.ReadMessage(bytes.NewReader(b))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -128,7 +144,7 @@ func (s *smtpSession) Data(r io.Reader) error {
|
|||||||
m.Message = m.Title // Flip them, this makes more sense
|
m.Message = m.Title // Flip them, this makes more sense
|
||||||
m.Title = ""
|
m.Title = ""
|
||||||
}
|
}
|
||||||
if err := s.backend.sub(m); err != nil {
|
if err := s.publishMessage(m); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.backend.mu.Lock()
|
s.backend.mu.Lock()
|
||||||
@@ -138,6 +154,33 @@ func (s *smtpSession) Data(r io.Reader) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *smtpSession) publishMessage(m *message) error {
|
||||||
|
// Extract remote address (for rate limiting)
|
||||||
|
remoteAddr, _, err := net.SplitHostPort(s.state.RemoteAddr.String())
|
||||||
|
if err != nil {
|
||||||
|
remoteAddr = s.state.RemoteAddr.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call HTTP handler with fake HTTP request
|
||||||
|
url := fmt.Sprintf("%s/%s", s.backend.config.BaseURL, m.Topic)
|
||||||
|
req, err := http.NewRequest("POST", url, strings.NewReader(m.Message))
|
||||||
|
req.RequestURI = "/" + m.Topic // just for the logs
|
||||||
|
req.RemoteAddr = remoteAddr // rate limiting!!
|
||||||
|
req.Header.Set("X-Forwarded-For", remoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if m.Title != "" {
|
||||||
|
req.Header.Set("Title", m.Title)
|
||||||
|
}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
s.backend.handler(rr, req)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
return errors.New("error: " + rr.Body.String())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *smtpSession) Reset() {
|
func (s *smtpSession) Reset() {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.topic = ""
|
s.topic = ""
|
||||||
@@ -153,43 +196,56 @@ func (s *smtpSession) withFailCount(fn func() error) error {
|
|||||||
s.backend.mu.Lock()
|
s.backend.mu.Lock()
|
||||||
defer s.backend.mu.Unlock()
|
defer s.backend.mu.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Almost all of these errors are parse errors, and user input errors.
|
||||||
|
// We do not want to spam the log with WARN messages.
|
||||||
|
log.Debug("%s Incoming mail error: %s", logSMTPPrefix(s.state), err.Error())
|
||||||
s.backend.failure++
|
s.backend.failure++
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func readMailBody(msg *mail.Message) (string, error) {
|
func readMailBody(msg *mail.Message) (string, error) {
|
||||||
|
if msg.Header.Get("Content-Type") == "" {
|
||||||
|
return readPlainTextMailBody(msg)
|
||||||
|
}
|
||||||
contentType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
|
contentType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if contentType == "text/plain" {
|
if contentType == "text/plain" {
|
||||||
body, err := io.ReadAll(msg.Body)
|
return readPlainTextMailBody(msg)
|
||||||
|
} else if strings.HasPrefix(contentType, "multipart/") {
|
||||||
|
return readMultipartMailBody(msg, params)
|
||||||
|
}
|
||||||
|
return "", errUnsupportedContentType
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPlainTextMailBody(msg *mail.Message) (string, error) {
|
||||||
|
body, err := io.ReadAll(msg.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(body), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readMultipartMailBody(msg *mail.Message, params map[string]string) (string, error) {
|
||||||
|
mr := multipart.NewReader(msg.Body, params["boundary"])
|
||||||
|
for {
|
||||||
|
part, err := mr.NextPart()
|
||||||
|
if err != nil { // may be io.EOF
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
partContentType, _, err := mime.ParseMediaType(part.Header.Get("Content-Type"))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if partContentType != "text/plain" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(part)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return string(body), nil
|
return string(body), nil
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(contentType, "multipart/") {
|
|
||||||
mr := multipart.NewReader(msg.Body, params["boundary"])
|
|
||||||
for {
|
|
||||||
part, err := mr.NextPart()
|
|
||||||
if err != nil { // may be io.EOF
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
partContentType, _, err := mime.ParseMediaType(part.Header.Get("Content-Type"))
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if partContentType != "text/plain" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
body, err := io.ReadAll(part)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(body), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", errUnsupportedContentType
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package server
|
|||||||
import (
|
import (
|
||||||
"github.com/emersion/go-smtp"
|
"github.com/emersion/go-smtp"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -27,13 +29,12 @@ Content-Type: text/html; charset="UTF-8"
|
|||||||
<div dir="ltr">what's up<br clear="all"><div><br></div></div>
|
<div dir="ltr">what's up<br clear="all"><div><br></div></div>
|
||||||
|
|
||||||
--000000000000f3320b05d42915c9--`
|
--000000000000f3320b05d42915c9--`
|
||||||
_, backend := newTestBackend(t, func(m *message) error {
|
_, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
require.Equal(t, "mytopic", m.Topic)
|
require.Equal(t, "/mytopic", r.URL.Path)
|
||||||
require.Equal(t, "and one more", m.Title)
|
require.Equal(t, "and one more", r.Header.Get("Title"))
|
||||||
require.Equal(t, "what's up", m.Message)
|
require.Equal(t, "what's up", readAll(t, r.Body))
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
session, _ := backend.AnonymousLogin(nil)
|
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
|
||||||
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||||
require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh"))
|
require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh"))
|
||||||
require.Nil(t, session.Data(strings.NewReader(email)))
|
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||||
@@ -59,13 +60,12 @@ Content-Type: text/html; charset="UTF-8"
|
|||||||
<div dir="ltr"><br></div>
|
<div dir="ltr"><br></div>
|
||||||
|
|
||||||
--000000000000bcf4a405d429f8d4--`
|
--000000000000bcf4a405d429f8d4--`
|
||||||
_, backend := newTestBackend(t, func(m *message) error {
|
_, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
require.Equal(t, "emailtest", m.Topic)
|
require.Equal(t, "/emailtest", r.URL.Path)
|
||||||
require.Equal(t, "", m.Title) // We flipped message and body
|
require.Equal(t, "", r.Header.Get("Title")) // We flipped message and body
|
||||||
require.Equal(t, "This email has a subject but no body", m.Message)
|
require.Equal(t, "This email has a subject but no body", readAll(t, r.Body))
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
session, _ := backend.AnonymousLogin(nil)
|
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
|
||||||
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||||
require.Nil(t, session.Rcpt("ntfy-emailtest@ntfy.sh"))
|
require.Nil(t, session.Rcpt("ntfy-emailtest@ntfy.sh"))
|
||||||
require.Nil(t, session.Data(strings.NewReader(email)))
|
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||||
@@ -81,14 +81,30 @@ Content-Type: text/plain; charset="UTF-8"
|
|||||||
|
|
||||||
what's up
|
what's up
|
||||||
`
|
`
|
||||||
conf, backend := newTestBackend(t, func(m *message) error {
|
conf, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
require.Equal(t, "mytopic", m.Topic)
|
require.Equal(t, "/mytopic", r.URL.Path)
|
||||||
require.Equal(t, "and one more", m.Title)
|
require.Equal(t, "and one more", r.Header.Get("Title"))
|
||||||
require.Equal(t, "what's up", m.Message)
|
require.Equal(t, "what's up", readAll(t, r.Body))
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
conf.SMTPServerAddrPrefix = ""
|
conf.SMTPServerAddrPrefix = ""
|
||||||
session, _ := backend.AnonymousLogin(nil)
|
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
|
||||||
|
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||||
|
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
|
||||||
|
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmtpBackend_Plaintext_No_ContentType(t *testing.T) {
|
||||||
|
email := `Subject: Very short mail
|
||||||
|
|
||||||
|
what's up
|
||||||
|
`
|
||||||
|
conf, backend := newTestBackend(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, "what's up", readAll(t, r.Body))
|
||||||
|
})
|
||||||
|
conf.SMTPServerAddrPrefix = ""
|
||||||
|
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
|
||||||
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||||
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
|
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
|
||||||
require.Nil(t, session.Data(strings.NewReader(email)))
|
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||||
@@ -103,11 +119,10 @@ Content-Type: text/plain; charset="UTF-8"
|
|||||||
|
|
||||||
what's up
|
what's up
|
||||||
`
|
`
|
||||||
_, backend := newTestBackend(t, func(m *message) error {
|
_, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
require.Equal(t, "Three santas 🎅🎅🎅", m.Title)
|
require.Equal(t, "Three santas 🎅🎅🎅", r.Header.Get("Title"))
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
session, _ := backend.AnonymousLogin(nil)
|
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
|
||||||
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||||
require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh"))
|
require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh"))
|
||||||
require.Nil(t, session.Data(strings.NewReader(email)))
|
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||||
@@ -122,7 +137,7 @@ To: mytopic@ntfy.sh
|
|||||||
Content-Type: text/plain; charset="UTF-8"
|
Content-Type: text/plain; charset="UTF-8"
|
||||||
|
|
||||||
you know this is a string.
|
you know this is a string.
|
||||||
it's a long string.
|
it's a long string.
|
||||||
it's supposed to be longer than the max message length
|
it's supposed to be longer than the max message length
|
||||||
which is 4096 bytes,
|
which is 4096 bytes,
|
||||||
it used to be 512 bytes, but I increased that for the UnifiedPush support
|
it used to be 512 bytes, but I increased that for the UnifiedPush support
|
||||||
@@ -186,9 +201,9 @@ BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
|||||||
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
||||||
that should do it
|
that should do it
|
||||||
`
|
`
|
||||||
conf, backend := newTestBackend(t, func(m *message) error {
|
conf, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
expected := `you know this is a string.
|
expected := `you know this is a string.
|
||||||
it's a long string.
|
it's a long string.
|
||||||
it's supposed to be longer than the max message length
|
it's supposed to be longer than the max message length
|
||||||
which is 4096 bytes,
|
which is 4096 bytes,
|
||||||
it used to be 512 bytes, but I increased that for the UnifiedPush support
|
it used to be 512 bytes, but I increased that for the UnifiedPush support
|
||||||
@@ -248,13 +263,12 @@ AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
|||||||
......................................................................
|
......................................................................
|
||||||
......................................................................
|
......................................................................
|
||||||
and with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
and with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
||||||
BBBBBBBBBBBBBBBBBBBBBBBB`
|
BBBBBBBBBBBBBBBBBBBBBBBBB`
|
||||||
require.Equal(t, 4096, len(expected)) // Sanity check
|
require.Equal(t, 4096, len(expected)) // Sanity check
|
||||||
require.Equal(t, expected, m.Message)
|
require.Equal(t, expected, readAll(t, r.Body))
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
conf.SMTPServerAddrPrefix = ""
|
conf.SMTPServerAddrPrefix = ""
|
||||||
session, _ := backend.AnonymousLogin(nil)
|
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
|
||||||
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||||
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
|
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
|
||||||
require.Nil(t, session.Data(strings.NewReader(email)))
|
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||||
@@ -270,21 +284,33 @@ Content-Type: text/SOMETHINGELSE
|
|||||||
|
|
||||||
what's up
|
what's up
|
||||||
`
|
`
|
||||||
conf, backend := newTestBackend(t, func(m *message) error {
|
conf, backend := newTestBackend(t, func(http.ResponseWriter, *http.Request) {
|
||||||
return nil
|
// Nothing.
|
||||||
})
|
})
|
||||||
conf.SMTPServerAddrPrefix = ""
|
conf.SMTPServerAddrPrefix = ""
|
||||||
session, _ := backend.Login(nil, "user", "pass")
|
session, _ := backend.Login(fakeConnState(t, "1.2.3.4"), "user", "pass")
|
||||||
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||||
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
|
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
|
||||||
require.Equal(t, errUnsupportedContentType, session.Data(strings.NewReader(email)))
|
require.Equal(t, errUnsupportedContentType, session.Data(strings.NewReader(email)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestBackend(t *testing.T, sub subscriber) (*Config, *smtpBackend) {
|
func newTestBackend(t *testing.T, handler func(http.ResponseWriter, *http.Request)) (*Config, *smtpBackend) {
|
||||||
conf := newTestConfig(t)
|
conf := newTestConfig(t)
|
||||||
conf.SMTPServerListen = ":25"
|
conf.SMTPServerListen = ":25"
|
||||||
conf.SMTPServerDomain = "ntfy.sh"
|
conf.SMTPServerDomain = "ntfy.sh"
|
||||||
conf.SMTPServerAddrPrefix = "ntfy-"
|
conf.SMTPServerAddrPrefix = "ntfy-"
|
||||||
backend := newMailBackend(conf, sub)
|
backend := newMailBackend(conf, handler)
|
||||||
return conf, backend
|
return conf, backend
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fakeConnState(t *testing.T, remoteAddr string) *smtp.ConnectionState {
|
||||||
|
ip, err := net.ResolveIPAddr("ip", remoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return &smtp.ConnectionState{
|
||||||
|
Hostname: "myhostname",
|
||||||
|
LocalAddr: ip,
|
||||||
|
RemoteAddr: ip,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"heckel.io/ntfy/log"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
@@ -15,7 +15,7 @@ type topic struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// subscriber is a function that is called for every new message on a topic
|
// subscriber is a function that is called for every new message on a topic
|
||||||
type subscriber func(msg *message) error
|
type subscriber func(v *visitor, msg *message) error
|
||||||
|
|
||||||
// newTopic creates a new topic
|
// newTopic creates a new topic
|
||||||
func newTopic(id string) *topic {
|
func newTopic(id string) *topic {
|
||||||
@@ -42,22 +42,43 @@ func (t *topic) Unsubscribe(id int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Publish asynchronously publishes to all subscribers
|
// Publish asynchronously publishes to all subscribers
|
||||||
func (t *topic) Publish(m *message) error {
|
func (t *topic) Publish(v *visitor, m *message) error {
|
||||||
go func() {
|
go func() {
|
||||||
t.mu.Lock()
|
// We want to lock the topic as short as possible, so we make a shallow copy of the
|
||||||
defer t.mu.Unlock()
|
// subscribers map here. Actually sending out the messages then doesn't have to lock.
|
||||||
for _, s := range t.subscribers {
|
subscribers := t.subscribersCopy()
|
||||||
if err := s(m); err != nil {
|
if len(subscribers) > 0 {
|
||||||
log.Printf("error publishing message to subscriber")
|
log.Debug("%s Forwarding to %d subscriber(s)", logMessagePrefix(v, m), len(subscribers))
|
||||||
|
for _, s := range subscribers {
|
||||||
|
// We call the subscriber functions in their own Go routines because they are blocking, and
|
||||||
|
// we don't want individual slow subscribers to be able to block others.
|
||||||
|
go func(s subscriber) {
|
||||||
|
if err := s(v, m); err != nil {
|
||||||
|
log.Warn("%s Error forwarding to subscriber", logMessagePrefix(v, m))
|
||||||
|
}
|
||||||
|
}(s)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
log.Trace("%s No stream or WebSocket subscribers, not forwarding", logMessagePrefix(v, m))
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribers returns the number of subscribers to this topic
|
// SubscribersCount returns the number of subscribers to this topic
|
||||||
func (t *topic) Subscribers() int {
|
func (t *topic) SubscribersCount() int {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
defer t.mu.Unlock()
|
defer t.mu.Unlock()
|
||||||
return len(t.subscribers)
|
return len(t.subscribers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// subscribersCopy returns a shallow copy of the subscribers map
|
||||||
|
func (t *topic) subscribersCopy() map[int]subscriber {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
subscribers := make(map[int]subscriber)
|
||||||
|
for k, v := range t.subscribers {
|
||||||
|
subscribers[k] = v
|
||||||
|
}
|
||||||
|
return subscribers
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"heckel.io/ntfy/util"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// List of possible events
|
// List of possible events
|
||||||
@@ -24,13 +26,16 @@ type message struct {
|
|||||||
Time int64 `json:"time"` // Unix time in seconds
|
Time int64 `json:"time"` // Unix time in seconds
|
||||||
Event string `json:"event"` // One of the above
|
Event string `json:"event"` // One of the above
|
||||||
Topic string `json:"topic"`
|
Topic string `json:"topic"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
Priority int `json:"priority,omitempty"`
|
Priority int `json:"priority,omitempty"`
|
||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
Click string `json:"click,omitempty"`
|
Click string `json:"click,omitempty"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
Actions []*action `json:"actions,omitempty"`
|
Actions []*action `json:"actions,omitempty"`
|
||||||
Attachment *attachment `json:"attachment,omitempty"`
|
Attachment *attachment `json:"attachment,omitempty"`
|
||||||
Title string `json:"title,omitempty"`
|
PollID string `json:"poll_id,omitempty"`
|
||||||
Message string `json:"message,omitempty"`
|
Sender netip.Addr `json:"-"` // IP address of uploader, used for rate limiting
|
||||||
Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes
|
Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +45,6 @@ type attachment struct {
|
|||||||
Size int64 `json:"size,omitempty"`
|
Size int64 `json:"size,omitempty"`
|
||||||
Expires int64 `json:"expires,omitempty"`
|
Expires int64 `json:"expires,omitempty"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Owner string `json:"-"` // IP address of uploader, used for rate limiting
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type action struct {
|
type action struct {
|
||||||
@@ -71,6 +75,7 @@ type publishMessage struct {
|
|||||||
Priority int `json:"priority"`
|
Priority int `json:"priority"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
Click string `json:"click"`
|
Click string `json:"click"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
Actions []action `json:"actions"`
|
Actions []action `json:"actions"`
|
||||||
Attach string `json:"attach"`
|
Attach string `json:"attach"`
|
||||||
Filename string `json:"filename"`
|
Filename string `json:"filename"`
|
||||||
@@ -84,14 +89,11 @@ type messageEncoder func(msg *message) (string, error)
|
|||||||
// newMessage creates a new message with the current timestamp
|
// newMessage creates a new message with the current timestamp
|
||||||
func newMessage(event, topic, msg string) *message {
|
func newMessage(event, topic, msg string) *message {
|
||||||
return &message{
|
return &message{
|
||||||
ID: util.RandomString(messageIDLength),
|
ID: util.RandomString(messageIDLength),
|
||||||
Time: time.Now().Unix(),
|
Time: time.Now().Unix(),
|
||||||
Event: event,
|
Event: event,
|
||||||
Topic: topic,
|
Topic: topic,
|
||||||
Priority: 0,
|
Message: msg,
|
||||||
Tags: nil,
|
|
||||||
Title: "",
|
|
||||||
Message: msg,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +112,13 @@ func newDefaultMessage(topic, msg string) *message {
|
|||||||
return newMessage(messageEvent, topic, msg)
|
return newMessage(messageEvent, topic, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newPollRequestMessage is a convenience method to create a poll request message
|
||||||
|
func newPollRequestMessage(topic, pollID string) *message {
|
||||||
|
m := newMessage(pollRequestEvent, topic, newMessageBody)
|
||||||
|
m.PollID = pollID
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
func validMessageID(s string) bool {
|
func validMessageID(s string) bool {
|
||||||
return util.ValidRandomString(s, messageIDLength)
|
return util.ValidRandomString(s, messageIDLength)
|
||||||
}
|
}
|
||||||
@@ -153,6 +162,7 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type queryFilter struct {
|
type queryFilter struct {
|
||||||
|
ID string
|
||||||
Message string
|
Message string
|
||||||
Title string
|
Title string
|
||||||
Tags []string
|
Tags []string
|
||||||
@@ -160,6 +170,7 @@ type queryFilter struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parseQueryFilters(r *http.Request) (*queryFilter, error) {
|
func parseQueryFilters(r *http.Request) (*queryFilter, error) {
|
||||||
|
idFilter := readParam(r, "x-id", "id")
|
||||||
messageFilter := readParam(r, "x-message", "message", "m")
|
messageFilter := readParam(r, "x-message", "message", "m")
|
||||||
titleFilter := readParam(r, "x-title", "title", "t")
|
titleFilter := readParam(r, "x-title", "title", "t")
|
||||||
tagsFilter := util.SplitNoEmpty(readParam(r, "x-tags", "tags", "tag", "ta"), ",")
|
tagsFilter := util.SplitNoEmpty(readParam(r, "x-tags", "tags", "tag", "ta"), ",")
|
||||||
@@ -167,11 +178,12 @@ func parseQueryFilters(r *http.Request) (*queryFilter, error) {
|
|||||||
for _, p := range util.SplitNoEmpty(readParam(r, "x-priority", "priority", "prio", "p"), ",") {
|
for _, p := range util.SplitNoEmpty(readParam(r, "x-priority", "priority", "prio", "p"), ",") {
|
||||||
priority, err := util.ParsePriority(p)
|
priority, err := util.ParsePriority(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, errHTTPBadRequestPriorityInvalid
|
||||||
}
|
}
|
||||||
priorityFilter = append(priorityFilter, priority)
|
priorityFilter = append(priorityFilter, priority)
|
||||||
}
|
}
|
||||||
return &queryFilter{
|
return &queryFilter{
|
||||||
|
ID: idFilter,
|
||||||
Message: messageFilter,
|
Message: messageFilter,
|
||||||
Title: titleFilter,
|
Title: titleFilter,
|
||||||
Tags: tagsFilter,
|
Tags: tagsFilter,
|
||||||
@@ -182,21 +194,21 @@ func parseQueryFilters(r *http.Request) (*queryFilter, error) {
|
|||||||
func (q *queryFilter) Pass(msg *message) bool {
|
func (q *queryFilter) Pass(msg *message) bool {
|
||||||
if msg.Event != messageEvent {
|
if msg.Event != messageEvent {
|
||||||
return true // filters only apply to messages
|
return true // filters only apply to messages
|
||||||
}
|
} else if q.ID != "" && msg.ID != q.ID {
|
||||||
if q.Message != "" && msg.Message != q.Message {
|
|
||||||
return false
|
return false
|
||||||
}
|
} else if q.Message != "" && msg.Message != q.Message {
|
||||||
if q.Title != "" && msg.Title != q.Title {
|
return false
|
||||||
|
} else if q.Title != "" && msg.Title != q.Title {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
messagePriority := msg.Priority
|
messagePriority := msg.Priority
|
||||||
if messagePriority == 0 {
|
if messagePriority == 0 {
|
||||||
messagePriority = 3 // For query filters, default priority (3) is the same as "not set" (0)
|
messagePriority = 3 // For query filters, default priority (3) is the same as "not set" (0)
|
||||||
}
|
}
|
||||||
if len(q.Priority) > 0 && !util.InIntList(q.Priority, messagePriority) {
|
if len(q.Priority) > 0 && !util.Contains(q.Priority, messagePriority) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if len(q.Tags) > 0 && !util.InStringListAll(msg.Tags, q.Tags) {
|
if len(q.Tags) > 0 && !util.ContainsAll(msg.Tags, q.Tags) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/emersion/go-smtp"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
|
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
|
||||||
@@ -40,3 +44,48 @@ func readQueryParam(r *http.Request, names ...string) string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func logMessagePrefix(v *visitor, m *message) string {
|
||||||
|
return fmt.Sprintf("%s/%s/%s", v.ip, m.Topic, m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func logHTTPPrefix(v *visitor, r *http.Request) string {
|
||||||
|
requestURI := r.RequestURI
|
||||||
|
if requestURI == "" {
|
||||||
|
requestURI = r.URL.Path
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s HTTP %s %s", v.ip, r.Method, requestURI)
|
||||||
|
}
|
||||||
|
|
||||||
|
func logSMTPPrefix(state *smtp.ConnectionState) string {
|
||||||
|
return fmt.Sprintf("%s/%s SMTP", state.Hostname, state.RemoteAddr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderHTTPRequest(r *http.Request) string {
|
||||||
|
peekLimit := 4096
|
||||||
|
lines := fmt.Sprintf("%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto)
|
||||||
|
for key, values := range r.Header {
|
||||||
|
for _, value := range values {
|
||||||
|
lines += fmt.Sprintf("%s: %s\n", key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines += "\n"
|
||||||
|
body, err := util.Peek(r.Body, peekLimit)
|
||||||
|
if err != nil {
|
||||||
|
lines = fmt.Sprintf("(could not read body: %s)\n", err.Error())
|
||||||
|
} else if utf8.Valid(body.PeekedBytes) {
|
||||||
|
lines += string(body.PeekedBytes)
|
||||||
|
if body.LimitReached {
|
||||||
|
lines += fmt.Sprintf(" ... (peeked %d bytes)", peekLimit)
|
||||||
|
}
|
||||||
|
lines += "\n"
|
||||||
|
} else {
|
||||||
|
if body.LimitReached {
|
||||||
|
lines += fmt.Sprintf("(peeked bytes not UTF-8, peek limit of %d bytes reached, hex: %x ...)\n", peekLimit, body.PeekedBytes)
|
||||||
|
} else {
|
||||||
|
lines += fmt.Sprintf("(peeked bytes not UTF-8, %d bytes, hex: %x)\n", len(body.PeekedBytes), body.PeekedBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.Body = body // Important: Reset body, so it can be re-read
|
||||||
|
return strings.TrimSpace(lines)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,3 +31,47 @@ func TestReadBoolParam(t *testing.T) {
|
|||||||
require.Equal(t, false, up)
|
require.Equal(t, false, up)
|
||||||
require.Equal(t, true, firebase)
|
require.Equal(t, true, firebase)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRenderHTTPRequest_ValidShort(t *testing.T) {
|
||||||
|
r, _ := http.NewRequest("POST", "http://ntfy.sh/mytopic?p=2", strings.NewReader("some message"))
|
||||||
|
r.Header.Set("Title", "A title")
|
||||||
|
expected := `POST /mytopic?p=2 HTTP/1.1
|
||||||
|
Title: A title
|
||||||
|
|
||||||
|
some message`
|
||||||
|
require.Equal(t, expected, renderHTTPRequest(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderHTTPRequest_ValidLong(t *testing.T) {
|
||||||
|
body := strings.Repeat("a", 5000)
|
||||||
|
r, _ := http.NewRequest("POST", "http://ntfy.sh/mytopic?p=2", strings.NewReader(body))
|
||||||
|
r.Header.Set("Accept", "*/*")
|
||||||
|
expected := `POST /mytopic?p=2 HTTP/1.1
|
||||||
|
Accept: */*
|
||||||
|
|
||||||
|
` + strings.Repeat("a", 4096) + " ... (peeked 4096 bytes)"
|
||||||
|
require.Equal(t, expected, renderHTTPRequest(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderHTTPRequest_InvalidShort(t *testing.T) {
|
||||||
|
body := []byte{0xc3, 0x28}
|
||||||
|
r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", bytes.NewReader(body))
|
||||||
|
r.Header.Set("Accept", "*/*")
|
||||||
|
expected := `GET /mytopic/json?since=all HTTP/1.1
|
||||||
|
Accept: */*
|
||||||
|
|
||||||
|
(peeked bytes not UTF-8, 2 bytes, hex: c328)`
|
||||||
|
require.Equal(t, expected, renderHTTPRequest(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderHTTPRequest_InvalidLong(t *testing.T) {
|
||||||
|
body := make([]byte, 5000)
|
||||||
|
rand.Read(body)
|
||||||
|
r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", bytes.NewReader(body))
|
||||||
|
r.Header.Set("Accept", "*/*")
|
||||||
|
expected := `GET /mytopic/json?since=all HTTP/1.1
|
||||||
|
Accept: */*
|
||||||
|
|
||||||
|
(peeked bytes not UTF-8, peek limit of 4096 bytes reached, hex: ` + fmt.Sprintf("%x", body[:4096]) + ` ...)`
|
||||||
|
require.Equal(t, expected, renderHTTPRequest(r))
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"golang.org/x/time/rate"
|
"net/netip"
|
||||||
"heckel.io/ntfy/util"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -23,11 +25,12 @@ var (
|
|||||||
type visitor struct {
|
type visitor struct {
|
||||||
config *Config
|
config *Config
|
||||||
messageCache *messageCache
|
messageCache *messageCache
|
||||||
ip string
|
ip netip.Addr
|
||||||
requests *rate.Limiter
|
requests *rate.Limiter
|
||||||
emails *rate.Limiter
|
emails *rate.Limiter
|
||||||
subscriptions util.Limiter
|
subscriptions util.Limiter
|
||||||
bandwidth util.Limiter
|
bandwidth util.Limiter
|
||||||
|
firebase time.Time // Next allowed Firebase message
|
||||||
seen time.Time
|
seen time.Time
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
@@ -39,7 +42,7 @@ type visitorStats struct {
|
|||||||
VisitorAttachmentBytesRemaining int64 `json:"visitorAttachmentBytesRemaining"`
|
VisitorAttachmentBytesRemaining int64 `json:"visitorAttachmentBytesRemaining"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func newVisitor(conf *Config, messageCache *messageCache, ip string) *visitor {
|
func newVisitor(conf *Config, messageCache *messageCache, ip netip.Addr) *visitor {
|
||||||
return &visitor{
|
return &visitor{
|
||||||
config: conf,
|
config: conf,
|
||||||
messageCache: messageCache,
|
messageCache: messageCache,
|
||||||
@@ -48,14 +51,11 @@ func newVisitor(conf *Config, messageCache *messageCache, ip string) *visitor {
|
|||||||
emails: rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst),
|
emails: rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst),
|
||||||
subscriptions: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)),
|
subscriptions: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)),
|
||||||
bandwidth: util.NewBytesLimiter(conf.VisitorAttachmentDailyBandwidthLimit, 24*time.Hour),
|
bandwidth: util.NewBytesLimiter(conf.VisitorAttachmentDailyBandwidthLimit, 24*time.Hour),
|
||||||
|
firebase: time.Unix(0, 0),
|
||||||
seen: time.Now(),
|
seen: time.Now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *visitor) IP() string {
|
|
||||||
return v.ip
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *visitor) RequestAllowed() error {
|
func (v *visitor) RequestAllowed() error {
|
||||||
if !v.requests.Allow() {
|
if !v.requests.Allow() {
|
||||||
return errVisitorLimitReached
|
return errVisitorLimitReached
|
||||||
@@ -63,6 +63,21 @@ func (v *visitor) RequestAllowed() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v *visitor) FirebaseAllowed() error {
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
if time.Now().Before(v.firebase) {
|
||||||
|
return errVisitorLimitReached
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *visitor) FirebaseTemporarilyDeny() {
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
v.firebase = time.Now().Add(v.config.FirebaseQuotaExceededPenaltyDuration)
|
||||||
|
}
|
||||||
|
|
||||||
func (v *visitor) EmailAllowed() error {
|
func (v *visitor) EmailAllowed() error {
|
||||||
if !v.emails.Allow() {
|
if !v.emails.Allow() {
|
||||||
return errVisitorLimitReached
|
return errVisitorLimitReached
|
||||||
@@ -102,7 +117,7 @@ func (v *visitor) Stale() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (v *visitor) Stats() (*visitorStats, error) {
|
func (v *visitor) Stats() (*visitorStats, error) {
|
||||||
attachmentsBytesUsed, err := v.messageCache.AttachmentBytesUsed(v.ip)
|
attachmentsBytesUsed, err := v.messageCache.AttachmentBytesUsed(v.ip.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
firebase "firebase.google.com/go"
|
firebase "firebase.google.com/go/v4"
|
||||||
"firebase.google.com/go/messaging"
|
"firebase.google.com/go/v4/messaging"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"google.golang.org/api/option"
|
"google.golang.org/api/option"
|
||||||
|
|||||||
86
util/batching_queue.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BatchingQueue is a queue that creates batches of the enqueued elements based on a
|
||||||
|
// max batch size and a batch timeout.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// q := NewBatchingQueue[int](2, 500 * time.Millisecond)
|
||||||
|
// go func() {
|
||||||
|
// for batch := range q.Dequeue() {
|
||||||
|
// fmt.Println(batch)
|
||||||
|
// }
|
||||||
|
// }()
|
||||||
|
// q.Enqueue(1)
|
||||||
|
// q.Enqueue(2)
|
||||||
|
// q.Enqueue(3)
|
||||||
|
// time.Sleep(time.Second)
|
||||||
|
//
|
||||||
|
// This example will emit batch [1, 2] immediately (because the batch size is 2), and
|
||||||
|
// a batch [3] after 500ms.
|
||||||
|
type BatchingQueue[T any] struct {
|
||||||
|
batchSize int
|
||||||
|
timeout time.Duration
|
||||||
|
in []T
|
||||||
|
out chan []T
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBatchingQueue creates a new BatchingQueue
|
||||||
|
func NewBatchingQueue[T any](batchSize int, timeout time.Duration) *BatchingQueue[T] {
|
||||||
|
q := &BatchingQueue[T]{
|
||||||
|
batchSize: batchSize,
|
||||||
|
timeout: timeout,
|
||||||
|
in: make([]T, 0),
|
||||||
|
out: make(chan []T),
|
||||||
|
}
|
||||||
|
go q.timeoutTicker()
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue enqueues an element to the queue. If the configured batch size is reached,
|
||||||
|
// the batch will be emitted immediately.
|
||||||
|
func (q *BatchingQueue[T]) Enqueue(element T) {
|
||||||
|
q.mu.Lock()
|
||||||
|
q.in = append(q.in, element)
|
||||||
|
var elements []T
|
||||||
|
if len(q.in) == q.batchSize {
|
||||||
|
elements = q.dequeueAll()
|
||||||
|
}
|
||||||
|
q.mu.Unlock()
|
||||||
|
if len(elements) > 0 {
|
||||||
|
q.out <- elements
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dequeue returns a channel emitting batches of elements
|
||||||
|
func (q *BatchingQueue[T]) Dequeue() <-chan []T {
|
||||||
|
return q.out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *BatchingQueue[T]) dequeueAll() []T {
|
||||||
|
elements := make([]T, len(q.in))
|
||||||
|
copy(elements, q.in)
|
||||||
|
q.in = q.in[:0]
|
||||||
|
return elements
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *BatchingQueue[T]) timeoutTicker() {
|
||||||
|
if q.timeout == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(q.timeout)
|
||||||
|
for range ticker.C {
|
||||||
|
q.mu.Lock()
|
||||||
|
elements := q.dequeueAll()
|
||||||
|
q.mu.Unlock()
|
||||||
|
if len(elements) > 0 {
|
||||||
|
q.out <- elements
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
util/batching_queue_test.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package util_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
|
"math/rand"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBatchingQueue_InfTimeout(t *testing.T) {
|
||||||
|
q := util.NewBatchingQueue[int](25, 1*time.Hour)
|
||||||
|
batches, total := make([][]int, 0), 0
|
||||||
|
var mu sync.Mutex
|
||||||
|
go func() {
|
||||||
|
for batch := range q.Dequeue() {
|
||||||
|
mu.Lock()
|
||||||
|
batches = append(batches, batch)
|
||||||
|
total += len(batch)
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
for i := 0; i < 101; i++ {
|
||||||
|
go q.Enqueue(i)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
mu.Lock()
|
||||||
|
require.Equal(t, 100, total) // One is missing, stuck in the last batch!
|
||||||
|
require.Equal(t, 4, len(batches))
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchingQueue_WithTimeout(t *testing.T) {
|
||||||
|
q := util.NewBatchingQueue[int](25, 100*time.Millisecond)
|
||||||
|
batches, total := make([][]int, 0), 0
|
||||||
|
var mu sync.Mutex
|
||||||
|
go func() {
|
||||||
|
for batch := range q.Dequeue() {
|
||||||
|
mu.Lock()
|
||||||
|
batches = append(batches, batch)
|
||||||
|
total += len(batch)
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
for i := 0; i < 101; i++ {
|
||||||
|
go func(i int) {
|
||||||
|
time.Sleep(time.Duration(rand.Intn(700)) * time.Millisecond)
|
||||||
|
q.Enqueue(i)
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
mu.Lock()
|
||||||
|
require.Equal(t, 101, total)
|
||||||
|
require.True(t, len(batches) > 4) // 101/25
|
||||||
|
require.True(t, len(batches) < 21)
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
@@ -44,7 +44,7 @@ func TestSniffWriter_WriteUnknownMimeType(t *testing.T) {
|
|||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
sw := NewContentTypeWriter(rr, "")
|
sw := NewContentTypeWriter(rr, "")
|
||||||
randomBytes := make([]byte, 199)
|
randomBytes := make([]byte, 199)
|
||||||
rand.Read(randomBytes)
|
rand.Read(randomBytes[5:]) // Start at an offset; the test kept failing randomly because it hit random magic strings
|
||||||
sw.Write(randomBytes)
|
sw.Write(randomBytes)
|
||||||
require.Equal(t, "application/octet-stream", rr.Header().Get("Content-Type"))
|
require.Equal(t, "application/octet-stream", rr.Header().Get("Content-Type"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,14 +11,13 @@ import (
|
|||||||
// CachingEmbedFS is a wrapper around embed.FS that allows setting a ModTime, so that the
|
// CachingEmbedFS is a wrapper around embed.FS that allows setting a ModTime, so that the
|
||||||
// default static file server can send 304s back. It can be used like this:
|
// default static file server can send 304s back. It can be used like this:
|
||||||
//
|
//
|
||||||
// var (
|
// var (
|
||||||
// //go:embed docs
|
// //go:embed docs
|
||||||
// docsStaticFs embed.FS
|
// docsStaticFs embed.FS
|
||||||
// docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs}
|
// docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs}
|
||||||
// )
|
// )
|
||||||
//
|
|
||||||
// http.FileServer(http.FS(docsStaticCached)).ServeHTTP(w, r)
|
|
||||||
//
|
//
|
||||||
|
// http.FileServer(http.FS(docsStaticCached)).ServeHTTP(w, r)
|
||||||
type CachingEmbedFS struct {
|
type CachingEmbedFS struct {
|
||||||
ModTime time.Time
|
ModTime time.Time
|
||||||
FS embed.FS
|
FS embed.FS
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package util
|
|||||||
import (
|
import (
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -31,8 +30,8 @@ func Gzip(next http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var gzPool = sync.Pool{
|
var gzPool = sync.Pool{
|
||||||
New: func() interface{} {
|
New: func() any {
|
||||||
w := gzip.NewWriter(ioutil.Discard)
|
w := gzip.NewWriter(io.Discard)
|
||||||
return w
|
return w
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ type PeekedReadCloser struct {
|
|||||||
closed bool
|
closed bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Peek reads the underlying ReadCloser into memory up until the limit and returns a PeekedReadCloser
|
// Peek reads the underlying ReadCloser into memory up until the limit and returns a PeekedReadCloser.
|
||||||
|
// It does not return an error if limit is reached. Instead, LimitReached will be set to true.
|
||||||
func Peek(underlying io.ReadCloser, limit int) (*PeekedReadCloser, error) {
|
func Peek(underlying io.ReadCloser, limit int) (*PeekedReadCloser, error) {
|
||||||
if underlying == nil {
|
if underlying == nil {
|
||||||
underlying = io.NopCloser(strings.NewReader(""))
|
underlying = io.NopCloser(strings.NewReader(""))
|
||||||
|
|||||||
122
util/util.go
@@ -2,18 +2,21 @@ package util
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/gabriel-vasile/mimetype"
|
|
||||||
"golang.org/x/term"
|
|
||||||
"io"
|
"io"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gabriel-vasile/mimetype"
|
||||||
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -25,6 +28,7 @@ var (
|
|||||||
randomMutex = sync.Mutex{}
|
randomMutex = sync.Mutex{}
|
||||||
sizeStrRegex = regexp.MustCompile(`(?i)^(\d+)([gmkb])?$`)
|
sizeStrRegex = regexp.MustCompile(`(?i)^(\d+)([gmkb])?$`)
|
||||||
errInvalidPriority = errors.New("invalid priority")
|
errInvalidPriority = errors.New("invalid priority")
|
||||||
|
noQuotesRegex = regexp.MustCompile(`^[-_./:@a-zA-Z0-9]+$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
// FileExists checks if a file exists, and returns true if it does
|
// FileExists checks if a file exists, and returns true if it does
|
||||||
@@ -33,8 +37,8 @@ func FileExists(filename string) bool {
|
|||||||
return stat != nil
|
return stat != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// InStringList returns true if needle is contained in haystack
|
// Contains returns true if needle is contained in haystack
|
||||||
func InStringList(haystack []string, needle string) bool {
|
func Contains[T comparable](haystack []T, needle T) bool {
|
||||||
for _, s := range haystack {
|
for _, s := range haystack {
|
||||||
if s == needle {
|
if s == needle {
|
||||||
return true
|
return true
|
||||||
@@ -43,8 +47,18 @@ func InStringList(haystack []string, needle string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// InStringListAll returns true if all needles are contained in haystack
|
// ContainsIP returns true if any one of the of prefixes contains the ip.
|
||||||
func InStringListAll(haystack []string, needles []string) bool {
|
func ContainsIP(haystack []netip.Prefix, needle netip.Addr) bool {
|
||||||
|
for _, s := range haystack {
|
||||||
|
if s.Contains(needle) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContainsAll returns true if all needles are contained in haystack
|
||||||
|
func ContainsAll[T comparable](haystack []T, needles []T) bool {
|
||||||
matches := 0
|
matches := 0
|
||||||
for _, s := range haystack {
|
for _, s := range haystack {
|
||||||
for _, needle := range needles {
|
for _, needle := range needles {
|
||||||
@@ -56,16 +70,6 @@ func InStringListAll(haystack []string, needles []string) bool {
|
|||||||
return matches == len(needles)
|
return matches == len(needles)
|
||||||
}
|
}
|
||||||
|
|
||||||
// InIntList returns true if needle is contained in haystack
|
|
||||||
func InIntList(haystack []int, needle int) bool {
|
|
||||||
for _, s := range haystack {
|
|
||||||
if s == needle {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// SplitNoEmpty splits a string using strings.Split, but filters out empty strings
|
// SplitNoEmpty splits a string using strings.Split, but filters out empty strings
|
||||||
func SplitNoEmpty(s string, sep string) []string {
|
func SplitNoEmpty(s string, sep string) []string {
|
||||||
res := make([]string, 0)
|
res := make([]string, 0)
|
||||||
@@ -87,6 +91,14 @@ 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
|
||||||
|
func LastString(s []string, def string) string {
|
||||||
|
if len(s) == 0 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return s[len(s)-1]
|
||||||
|
}
|
||||||
|
|
||||||
// RandomString returns a random string with a given length
|
// RandomString returns a random string with a given length
|
||||||
func RandomString(length int) string {
|
func RandomString(length int) string {
|
||||||
randomMutex.Lock() // Who would have thought that random.Intn() is not thread-safe?!
|
randomMutex.Lock() // Who would have thought that random.Intn() is not thread-safe?!
|
||||||
@@ -111,41 +123,10 @@ func ValidRandomString(s string, length int) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// DurationToHuman converts a duration to a human-readable format
|
|
||||||
func DurationToHuman(d time.Duration) (str string) {
|
|
||||||
if d == 0 {
|
|
||||||
return "0"
|
|
||||||
}
|
|
||||||
|
|
||||||
d = d.Round(time.Second)
|
|
||||||
days := d / time.Hour / 24
|
|
||||||
if days > 0 {
|
|
||||||
str += fmt.Sprintf("%dd", days)
|
|
||||||
}
|
|
||||||
d -= days * time.Hour * 24
|
|
||||||
|
|
||||||
hours := d / time.Hour
|
|
||||||
if hours > 0 {
|
|
||||||
str += fmt.Sprintf("%dh", hours)
|
|
||||||
}
|
|
||||||
d -= hours * time.Hour
|
|
||||||
|
|
||||||
minutes := d / time.Minute
|
|
||||||
if minutes > 0 {
|
|
||||||
str += fmt.Sprintf("%dm", minutes)
|
|
||||||
}
|
|
||||||
d -= minutes * time.Minute
|
|
||||||
|
|
||||||
seconds := d / time.Second
|
|
||||||
if seconds > 0 {
|
|
||||||
str += fmt.Sprintf("%ds", seconds)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParsePriority parses a priority string into its equivalent integer value
|
// ParsePriority parses a priority string into its equivalent integer value
|
||||||
func ParsePriority(priority string) (int, error) {
|
func ParsePriority(priority string) (int, error) {
|
||||||
switch strings.TrimSpace(strings.ToLower(priority)) {
|
p := strings.TrimSpace(strings.ToLower(priority))
|
||||||
|
switch p {
|
||||||
case "":
|
case "":
|
||||||
return 0, nil
|
return 0, nil
|
||||||
case "1", "min":
|
case "1", "min":
|
||||||
@@ -159,6 +140,11 @@ func ParsePriority(priority string) (int, error) {
|
|||||||
case "5", "max", "urgent":
|
case "5", "max", "urgent":
|
||||||
return 5, nil
|
return 5, nil
|
||||||
default:
|
default:
|
||||||
|
// Ignore new HTTP Priority header (see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-priority)
|
||||||
|
// Cloudflare adds this to requests when forwarding to the backend (ntfy), so we just ignore it.
|
||||||
|
if strings.HasPrefix(p, "u=") {
|
||||||
|
return 3, nil
|
||||||
|
}
|
||||||
return 0, errInvalidPriority
|
return 0, errInvalidPriority
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,11 +169,6 @@ func PriorityString(priority int) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExpandHome replaces "~" with the user's home directory
|
|
||||||
func ExpandHome(path string) string {
|
|
||||||
return os.ExpandEnv(strings.ReplaceAll(path, "~", "$HOME"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShortTopicURL shortens the topic URL to be human-friendly, removing the http:// or https://
|
// ShortTopicURL shortens the topic URL to be human-friendly, removing the http:// or https://
|
||||||
func ShortTopicURL(s string) string {
|
func ShortTopicURL(s string) string {
|
||||||
return strings.TrimPrefix(strings.TrimPrefix(s, "https://"), "http://")
|
return strings.TrimPrefix(strings.TrimPrefix(s, "https://"), "http://")
|
||||||
@@ -269,3 +250,36 @@ func ReadPassword(in io.Reader) ([]byte, error) {
|
|||||||
func BasicAuth(user, pass string) string {
|
func BasicAuth(user, pass string) string {
|
||||||
return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", user, pass))))
|
return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", user, pass))))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MaybeMarshalJSON returns a JSON string of the given object, or "<cannot serialize>" if serialization failed.
|
||||||
|
// This is useful for logging purposes where a failure doesn't matter that much.
|
||||||
|
func MaybeMarshalJSON(v any) string {
|
||||||
|
jsonBytes, err := json.MarshalIndent(v, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return "<cannot serialize>"
|
||||||
|
}
|
||||||
|
if len(jsonBytes) > 5000 {
|
||||||
|
return string(jsonBytes)[:5000]
|
||||||
|
}
|
||||||
|
return string(jsonBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuoteCommand combines a command array to a string, quoting arguments that need quoting.
|
||||||
|
// This function is naive, and sometimes wrong. It is only meant for lo pretty-printing a command.
|
||||||
|
//
|
||||||
|
// Warning: Never use this function with the intent to run the resulting command.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// []string{"ls", "-al", "Document Folder"} -> ls -al "Document Folder"
|
||||||
|
func QuoteCommand(command []string) string {
|
||||||
|
var quoted []string
|
||||||
|
for _, c := range command {
|
||||||
|
if noQuotesRegex.MatchString(c) {
|
||||||
|
quoted = append(quoted, c)
|
||||||
|
} else {
|
||||||
|
quoted = append(quoted, fmt.Sprintf(`"%s"`, c))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(quoted, " ")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,38 +1,14 @@
|
|||||||
package util
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/stretchr/testify/require"
|
"net/netip"
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDurationToHuman_SevenDays(t *testing.T) {
|
|
||||||
d := 7 * 24 * time.Hour
|
|
||||||
require.Equal(t, "7d", DurationToHuman(d))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDurationToHuman_MoreThanOneDay(t *testing.T) {
|
|
||||||
d := 49 * time.Hour
|
|
||||||
require.Equal(t, "2d1h", DurationToHuman(d))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDurationToHuman_LessThanOneDay(t *testing.T) {
|
|
||||||
d := 17*time.Hour + 15*time.Minute
|
|
||||||
require.Equal(t, "17h15m", DurationToHuman(d))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDurationToHuman_TenOfThings(t *testing.T) {
|
|
||||||
d := 10*time.Hour + 10*time.Minute + 10*time.Second
|
|
||||||
require.Equal(t, "10h10m10s", DurationToHuman(d))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDurationToHuman_Zero(t *testing.T) {
|
|
||||||
require.Equal(t, "0", DurationToHuman(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRandomString(t *testing.T) {
|
func TestRandomString(t *testing.T) {
|
||||||
s1 := RandomString(10)
|
s1 := RandomString(10)
|
||||||
s2 := RandomString(10)
|
s2 := RandomString(10)
|
||||||
@@ -45,27 +21,34 @@ func TestRandomString(t *testing.T) {
|
|||||||
|
|
||||||
func TestFileExists(t *testing.T) {
|
func TestFileExists(t *testing.T) {
|
||||||
filename := filepath.Join(t.TempDir(), "somefile.txt")
|
filename := filepath.Join(t.TempDir(), "somefile.txt")
|
||||||
require.Nil(t, ioutil.WriteFile(filename, []byte{0x25, 0x86}, 0600))
|
require.Nil(t, os.WriteFile(filename, []byte{0x25, 0x86}, 0600))
|
||||||
require.True(t, FileExists(filename))
|
require.True(t, FileExists(filename))
|
||||||
require.False(t, FileExists(filename+".doesnotexist"))
|
require.False(t, FileExists(filename+".doesnotexist"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInStringList(t *testing.T) {
|
func TestInStringList(t *testing.T) {
|
||||||
s := []string{"one", "two"}
|
s := []string{"one", "two"}
|
||||||
require.True(t, InStringList(s, "two"))
|
require.True(t, Contains(s, "two"))
|
||||||
require.False(t, InStringList(s, "three"))
|
require.False(t, Contains(s, "three"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInStringListAll(t *testing.T) {
|
func TestInStringListAll(t *testing.T) {
|
||||||
s := []string{"one", "two", "three", "four"}
|
s := []string{"one", "two", "three", "four"}
|
||||||
require.True(t, InStringListAll(s, []string{"two", "four"}))
|
require.True(t, ContainsAll(s, []string{"two", "four"}))
|
||||||
require.False(t, InStringListAll(s, []string{"three", "five"}))
|
require.False(t, ContainsAll(s, []string{"three", "five"}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInIntList(t *testing.T) {
|
func TestContains(t *testing.T) {
|
||||||
s := []int{1, 2}
|
s := []int{1, 2}
|
||||||
require.True(t, InIntList(s, 2))
|
require.True(t, Contains(s, 2))
|
||||||
require.False(t, InIntList(s, 3))
|
require.False(t, Contains(s, 3))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContainsIP(t *testing.T) {
|
||||||
|
require.True(t, ContainsIP([]netip.Prefix{netip.MustParsePrefix("fd00::/8"), netip.MustParsePrefix("1.1.0.0/16")}, netip.MustParseAddr("1.1.1.1")))
|
||||||
|
require.True(t, ContainsIP([]netip.Prefix{netip.MustParsePrefix("fd00::/8"), netip.MustParsePrefix("1.1.0.0/16")}, netip.MustParseAddr("fd12:1234:5678::9876")))
|
||||||
|
require.False(t, ContainsIP([]netip.Prefix{netip.MustParsePrefix("fd00::/8"), netip.MustParsePrefix("1.1.0.0/16")}, netip.MustParseAddr("1.2.0.1")))
|
||||||
|
require.False(t, ContainsIP([]netip.Prefix{netip.MustParsePrefix("fd00::/8"), netip.MustParsePrefix("1.1.0.0/16")}, netip.MustParseAddr("fc00::1")))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSplitNoEmpty(t *testing.T) {
|
func TestSplitNoEmpty(t *testing.T) {
|
||||||
@@ -75,14 +58,6 @@ func TestSplitNoEmpty(t *testing.T) {
|
|||||||
require.Equal(t, []string{"tag1", "tag2"}, SplitNoEmpty("tag1,tag2,", ","))
|
require.Equal(t, []string{"tag1", "tag2"}, SplitNoEmpty("tag1,tag2,", ","))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExpandHome_WithTilde(t *testing.T) {
|
|
||||||
require.Equal(t, os.Getenv("HOME")+"/this/is/a/path", ExpandHome("~/this/is/a/path"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExpandHome_NoTilde(t *testing.T) {
|
|
||||||
require.Equal(t, "/this/is/an/absolute/path", ExpandHome("/this/is/an/absolute/path"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParsePriority(t *testing.T) {
|
func TestParsePriority(t *testing.T) {
|
||||||
priorities := []string{"", "1", "2", "3", "4", "5", "min", "LOW", " default ", "HIgh", "max", "urgent"}
|
priorities := []string{"", "1", "2", "3", "4", "5", "min", "LOW", " default ", "HIgh", "max", "urgent"}
|
||||||
expected := []int{0, 1, 2, 3, 4, 5, 1, 2, 3, 4, 5, 5}
|
expected := []int{0, 1, 2, 3, 4, 5, 1, 2, 3, 4, 5, 5}
|
||||||
@@ -94,13 +69,22 @@ func TestParsePriority(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParsePriority_Invalid(t *testing.T) {
|
func TestParsePriority_Invalid(t *testing.T) {
|
||||||
priorities := []string{"-1", "6", "aa", "-"}
|
priorities := []string{"-1", "6", "aa", "-", "o=1"}
|
||||||
for _, priority := range priorities {
|
for _, priority := range priorities {
|
||||||
_, err := ParsePriority(priority)
|
_, err := ParsePriority(priority)
|
||||||
require.Equal(t, errInvalidPriority, err)
|
require.Equal(t, errInvalidPriority, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParsePriority_HTTPSpecPriority(t *testing.T) {
|
||||||
|
priorities := []string{"u=1", "u=3", "u=7, i"} // see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-priority
|
||||||
|
for _, priority := range priorities {
|
||||||
|
actual, err := ParsePriority(priority)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 3, actual) // Always expect 3!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPriorityString(t *testing.T) {
|
func TestPriorityString(t *testing.T) {
|
||||||
priorities := []int{0, 1, 2, 3, 4, 5}
|
priorities := []int{0, 1, 2, 3, 4, 5}
|
||||||
expected := []string{"default", "min", "low", "default", "high", "max"}
|
expected := []string{"default", "min", "low", "default", "high", "max"}
|
||||||
@@ -166,3 +150,14 @@ func TestSplitKV(t *testing.T) {
|
|||||||
require.Equal(t, "mykey", key)
|
require.Equal(t, "mykey", key)
|
||||||
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) {
|
||||||
|
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, `/home/sweet/home "Äöü this is a test" "\a\b"`, QuoteCommand([]string{"/home/sweet/home", "Äöü this is a test", "\\a\\b"}))
|
||||||
|
}
|
||||||
|
|||||||
15094
web/package-lock.json
generated
@@ -1,35 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "ntfy",
|
"name": "ntfy",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"url": "https://github.com/binwiederhier/ntfy",
|
|
||||||
"author": "Philipp C. Heckel <philipp.heckel@gmail.com>",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
"start-electron": "concurrently \"BROWSER=none npm start\" \"wait-on http://localhost:3000 && electron .\"",
|
|
||||||
"build": "react-scripts build",
|
"build": "react-scripts build",
|
||||||
"build-electron": "react-scripts build --em.main=build/electron.js && electron-builder",
|
|
||||||
"test": "react-scripts test",
|
"test": "react-scripts test",
|
||||||
"eject": "react-scripts eject"
|
"eject": "react-scripts eject"
|
||||||
},
|
},
|
||||||
"main": "public/electron.js",
|
|
||||||
"homepage": "./",
|
|
||||||
"build": {
|
|
||||||
"appId": "io.heckel.ntfy",
|
|
||||||
"files": [
|
|
||||||
"build/**/*",
|
|
||||||
"node_modules/**/*",
|
|
||||||
"public/**/*"
|
|
||||||
],
|
|
||||||
"directories":{
|
|
||||||
"buildResources": "assets"
|
|
||||||
},
|
|
||||||
"linux": {
|
|
||||||
"target": [
|
|
||||||
"AppImage"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.8.2",
|
"@emotion/react": "^11.8.2",
|
||||||
"@emotion/styled": "^11.8.1",
|
"@emotion/styled": "^11.8.1",
|
||||||
@@ -37,7 +15,6 @@
|
|||||||
"@mui/material": "latest",
|
"@mui/material": "latest",
|
||||||
"dexie": "^3.2.1",
|
"dexie": "^3.2.1",
|
||||||
"dexie-react-hooks": "^1.1.1",
|
"dexie-react-hooks": "^1.1.1",
|
||||||
"electron-is-dev": "^2.0.0",
|
|
||||||
"i18next": "^21.6.14",
|
"i18next": "^21.6.14",
|
||||||
"i18next-browser-languagedetector": "^6.1.4",
|
"i18next-browser-languagedetector": "^6.1.4",
|
||||||
"i18next-http-backend": "^1.4.0",
|
"i18next-http-backend": "^1.4.0",
|
||||||
@@ -51,12 +28,6 @@
|
|||||||
"stacktrace-gps": "^3.0.4",
|
"stacktrace-gps": "^3.0.4",
|
||||||
"stacktrace-js": "^2.0.2"
|
"stacktrace-js": "^2.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
|
||||||
"concurrently": "^7.1.0",
|
|
||||||
"electron": "^18.2.0",
|
|
||||||
"electron-builder": "^23.0.3",
|
|
||||||
"wait-on": "^6.0.1"
|
|
||||||
},
|
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"production": [
|
"production": [
|
||||||
">0.2%",
|
">0.2%",
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
const { app, BrowserWindow, Tray, Menu, nativeImage } = require('electron');
|
|
||||||
const isDev = require('electron-is-dev');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
let mainWindow;
|
|
||||||
|
|
||||||
const createWindow = () => {
|
|
||||||
mainWindow = new BrowserWindow({width: 900, height: 680});
|
|
||||||
mainWindow.loadURL(isDev ? 'http://localhost:3000' : `file://${path.join(__dirname, '../build/index.html')}`);
|
|
||||||
mainWindow.on('closed', () => mainWindow = null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const createTray = () => {
|
|
||||||
const icon = nativeImage.createFromDataURL('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACQAAAAkCAYAAADhAJiYAAAAAXNSR0IArs4c6QAAAAlwSFlzAAALEwAACxMBAJqcGAAAAVlpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDUuNC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgICAgICAgICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNvbS90aWZmLzEuMC8iPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KTMInWQAACsZJREFUWAmtWFlsXFcZ/u82++Jt7IyT2Em6ZFHTpAtWIzspEgjEUhA8VNAiIYEQUvuABBIUwUMkQIVKPCIoEiABLShISEBbhFJwIGRpIKRpbNeJ7bh2HHvssR3PPnPnLnzfmRlju6EQqUc+c++c8y/fv54z1uQOh+/7Glh0TD59TE/TND7lnfa4/64OKsM071QoeZpA/y9WWvk/B4XCC06TUC+Xyw8HTXNQ1+Ww6PpOrMebewXxvBueJ6/XHOdMJBL5J9Y97m2R0SS/wweE6JxkGx5dilWr1S/7dXsEa2o4+LyFmcFcaL5zbX3Y9gh5hpeWYpSB9XV5/H678V89BGYDXnHJlCsWn4gHrGc1K9CXxferOdvPOOKUfF8cH7nUyCtklQZXih/VNNlmirk3GdBSoIcRswW7/vVkLPYi5W2Uze8bh7J+4wLfh4dViFx5/nmrUi7/MhGNvrCkBfpeWqnW/7BUdadqntQ8zwr6vhUV34xpYnDynWvcmwQNaclDXsqgLMqkocPDw7fNx7d5qIX+/PmJxKGD6VdDkeh7ztyqOFfrokGCEWiiZ1mp0uITnuKAosaT7+pNxMYTyefutcQfbA+b1XLpH5fnF97/yD335Fu6mqTqsclDINBVmI4fDxw80KPAvJSt1MZtMcLiGxYUu83p4UkgnJZlqcl3LAj3WnTkIS9lUBYNPJjueVWgg7qocyOgliFqjZsg8gq5tRdiieQTf1gq15Y8CUbRZtyWOzZwc8lEqS3PTCtgqd13ieO68BQ2uNl64tXAewktrFuX2mPdkWAxn3sxnmx7sqUTJGqso8MGS9tbXFz8DMH8bblUX3T9QARVi8RV8qljfcJy0zRlaf6mzHEuzEtmekqCoZB4rqp0OmudHtUnlEWZlE0d1EWd1N3EozourcO65pw4eTIZQTW9VazJtbqvw9XwKVFQMsKDBuNhtp4uvGGFI+IDgKnpMjYyIis3ZsQMBIR7pONsIaMsyqRs6ohY1rPUSd3EQFDqo+kdZ3Fh4aupbdu+99uFQr2A1CBs4uEAjZjIFUMHi4dVxMXzCdCXQj4vBrwVCofl0ulTcv/DAxJJJBUPc8mpoyI2JDw7bFyT+ifTcSubyXytJ51+roWBxwG9Q73WWjZ7eSUU3//nXM0NI+x0PBGrTSgsLS9JFuFxHFrvSqIrJV279gi6tjiVspTza3JjZhY+0CQZj0mlWJSeHTslCro6eFqymCcVVN77kkGjs1p4sy2VOoSlOrFwT+XR+PjkgGaZ+ycKVbRTYUdVrmaImCvzk1dlFCEJdHRJ284+ie/ol0h7p7jFvExcvCCXzp2Rqem3pAMAiqWS6JGYhFI9Mjo6KjevXVUyKEuFHrKpY6JQ8TXT3D8+OTkAHBw6o6LCFo9ag3o4JtlCyTHEt5AxKvS6YUi5kJeZG3Py0NAxlLcJ9xti+K7Mjo/JfGZRuvv6Ze+9+yWEhDZAvzg3JyhX2d6/S7q6e+TimdOS7ElLKBZDwqvmj6rztayr1fVI1IoXi4PAcYZY1tPEEO1wEVlXgRFBDcmIXTqJsS+XyhKLJ5A/OpIVXXptWUYv/UvaenfIocEhMQ2EzHHErlXFCgQl3paU1eVl6QAY8sQTCSmVihKJx1V/ogvgIYF/pACdcMBhqONoHhF88/2d+bojyA6cRvje2IdFjoSjUSnBS8hgyS9lZOzKFdmPxO3o6gQIGzwuDn1dVSCtCKPy1pZXlATXqUsVYMLRmKo87vP4Y1ioqwCdCegmMYx3W/VPn8RrSDwwIMMbcEjkYo29JZVOy+ybI7K4eksODx1VSqvligpReSVLgySM/FI5h2q062jNyL3s7FtoAyGJIlx1225UmwJF6aJRJ3XzHXO9bWvsJa3jQFlBJkz6iuXdu32HzM7MyP0PPNgAU6ko4Qzp6b+flr8MD9OYJg9CwtzL5+T65ITs2bsP3mGxN/ZbBcOn0sk20gAkLQ+huXpFi8vkoY9AoyDjxTR1mbo6Ltt275HpN0dlNxQE40mVM8Ajjxx9VAGhAvQR1akZFCq799ADysMuQqOxh2FNmamEaz51ItGLfFD9+oUJoZkLowHoFA2mljUacqOMflKuVmHpfmnfvlMuvXZeStmMBIMhcWEdjgFJtrUjXI0KchAuAg0ilxLJNoRVBxhIBm0TjjKAuqjTqTs3CQZ6QUUMGFW7eiWMUg6w+yo8YMW7DqtqlZLkUDV2ISfd29KyDwk9MjYmMyOXxQIIKuShqo4VGFNBEgeDQYqVam5N5tEePFQgURIUBCsd1EWd1XrtDUUMLARD9bKaK5ytQ2Gb75g8WMiEP6VkfnZGevv6UF1vSBW5E0PFDAweFRvlfun8WVmamhDNrkmweQ0pwaPt6M4m8mgKTTFXqcrV0ZH1FKBg6qAu6qTuJiCV1Cp2Q0NDr9Uq5Ym+oMEDlSewsoRwrVBEaij7AJ4s7zrOpumxEdm15y6558GHJVe1Zezy6zJx6aJkpq5JFB4z6zVZmBiX1VWUP0IY4CFMYcpQdZ3xqIs6oftCE5DHKwd0q/tzOV8svdDb3nk8VnG9qmgQC0ZURz8Ur91alXgSByZ6ES9kZZTr/PR16UOCh+7dq0CWyyXJ4xqCQ0nKt9YQSlPue2gAeYZzD7yNLk0wmqAreb2WYSxAJ8Dget64wxtEBlDaqVOn/K5dB67t6+t5MhoMJuc8w8UPKiQ9CQR9JK5czhZAQxPt7TKF3OiAIisUViAD2Lg5d0P2HDgoKeRaW0enyqVwBJcO5fFG5dqa7h406qaeX8384uTZL5w9+UqxhYHFp0YLIYA9ddfu3T+4UJF6Rg+YAc9D0+RoIGP1ULhpWspr10evyK7+ftWTrk9PS/++A9KZSm26cih2mMOErem6n/ZsZwA2TM/MPHXs2LEftnSTbh0Q36mIIbx44cLvOnu3f+xUwbWLmoHTCUlF6g2jBQo/GnFrnGNqSHdvr+rIKGMW1KahwEBdzHft98aNwMr8zd8/NDDwccihc0hLi3GubRjY0Bm6H19fPvnZI4c/fHd7PJ2peXYZ+WQ26JufZELjQ6lbAQtnWre0d3apY8TFIdtAo+Qri6mupsB49lBMC+QXF0YefObZT8j0eKWlswVjEyCCOXHihPGb575VCvVuf3lvetsH9rXF0rla3cnhpoIGjgsUPhR3I4TMKYJQV1Z6WO02aEjHa5mNe3OPW3OPRHVrbXFh9Ocvv/KR1372owx1Pf3005uc35Ddgtd8rsf06IdS5777zZ+mUqmPzjm6TPpmvayZOq4LyATeCzkanmiy4qEuC/yXiO8CSMRzvLs1x9phepLNZl868sy3Pyen/5hd1/EfRvWmuvSWNeaRS/RkPDI4+NjE1NSXEoXlpaNB1zqo20abi59/vu/UfM2pie7WUDVq8l3wTwnskeZ+zTbIQ17KoCzKpGzq2KqX32/roRbh8ePHdUzl0s9/5Rv9n/7go19MxCKfCkZiu3V06wrO5gocxL7Dgd/IEobEMH6rejg+auXidL5Y/vWv/vTX53/y/e/MkGajTH7fOt4RUJOY1df4RdtY6ICFRzqTySOhUOA+3Ai3o31H1ZbnlXBruFmt2iMrudy5xx9//BzWV7nXDBGN2xpjbt/5oGUEdhtO3iD47xZOvm8a5CHvpsV38wsUaMwBWsz3rbK5xr0mzdv2t9Jv/f5vhsF4J+Q63IUAAAAASUVORK5CYII=');
|
|
||||||
const tray = new Tray(icon);
|
|
||||||
|
|
||||||
const contextMenu = Menu.buildFromTemplate([
|
|
||||||
{ label: 'Quit' }
|
|
||||||
]);
|
|
||||||
|
|
||||||
tray.setContextMenu(contextMenu);
|
|
||||||
tray.setToolTip('This is my application');
|
|
||||||
tray.setTitle('This is my title');
|
|
||||||
}
|
|
||||||
|
|
||||||
app.on('ready', () => {
|
|
||||||
createWindow();
|
|
||||||
createTray();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
|
||||||
if (process.platform !== 'darwin') {
|
|
||||||
app.quit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
app.on('activate', () => {
|
|
||||||
if (mainWindow === null) {
|
|
||||||
createWindow();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
<p>
|
<p>
|
||||||
<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 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 src="static/img/badge-fdroid.png"></a>
|
||||||
<a href="https://github.com/binwiederhier/ntfy/issues/4"><img src="static/img/badge-appstore.png"></a>
|
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img src="static/img/badge-appstore.png"></a>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Here's a video showing the app in action:
|
Here's a video showing the app in action:
|
||||||
|
|||||||