Compare commits
1633 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e91f07a081 | ||
|
|
7d96be6fb3 | ||
|
|
46c798c71a | ||
|
|
037a51a9d0 | ||
|
|
4596e4bcab | ||
|
|
9b30ada880 | ||
|
|
96d711e19e | ||
|
|
5af5565fb1 | ||
|
|
29c9551548 | ||
|
|
23c5d4e345 | ||
|
|
ff5bf4acd0 | ||
|
|
34c42c55f6 | ||
|
|
07e5b28868 | ||
|
|
06a0654a5a | ||
|
|
8cc23117fe | ||
|
|
f8c4f20a8f | ||
|
|
8053e992e4 | ||
|
|
9db96140e2 | ||
|
|
502d0a0abd | ||
|
|
80b0a94f7e | ||
|
|
338cab1660 | ||
|
|
b8836d674a | ||
|
|
c6a96d19e2 | ||
|
|
bcb24aecd3 | ||
|
|
d72ae47d1f | ||
|
|
a5d2fc172b | ||
|
|
bbab81a1a2 | ||
|
|
78a1ca81e3 | ||
|
|
f090d1313e | ||
|
|
afa4efa140 | ||
|
|
d2b88005f0 | ||
|
|
9eb1f6a186 | ||
|
|
2d8d5b3b95 | ||
|
|
844f4a3931 | ||
|
|
8aaec62d7f | ||
|
|
d97c3d2afc | ||
|
|
29ddd2a4b5 | ||
|
|
73069ae9a0 | ||
|
|
05d7c65e42 | ||
|
|
d11d7b13e6 | ||
|
|
14285a95e5 | ||
|
|
c3ec809727 | ||
|
|
e72a2703db | ||
|
|
e20fd0f84f | ||
|
|
6989643a49 | ||
|
|
ca9fed7b67 | ||
|
|
358b344916 | ||
|
|
b51294dc2c | ||
|
|
bb3fe4f830 | ||
|
|
84d5fde24b | ||
|
|
fe731d43cd | ||
|
|
835dad9eba | ||
|
|
77eb898528 | ||
|
|
ad9f8a5400 | ||
|
|
ceba7503a4 | ||
|
|
754b456320 | ||
|
|
6903e1677d | ||
|
|
8de26a7fdf | ||
|
|
6d672a7a71 | ||
|
|
d7b7bea701 | ||
|
|
b1916b5066 | ||
|
|
13a90172c2 | ||
|
|
394bca0ca6 | ||
|
|
c2af85b894 | ||
|
|
8ebc70261f | ||
|
|
390e8d18c7 | ||
|
|
284d992fb8 | ||
|
|
e808cace29 | ||
|
|
762dc8449c | ||
|
|
385bb5634d | ||
|
|
1aaa82b631 | ||
|
|
e0bc2f13f0 | ||
|
|
6ab974e50f | ||
|
|
75217bf61b | ||
|
|
2ee2395bd0 | ||
|
|
db7baf73c0 | ||
|
|
c6bfdd45be | ||
|
|
f953302c27 | ||
|
|
b69b4490bb | ||
|
|
92d9c28a70 | ||
|
|
fd6e470f3c | ||
|
|
6f312dad07 | ||
|
|
bd2dc5376c | ||
|
|
823963b934 | ||
|
|
d30c5acf0d | ||
|
|
961b62ad87 | ||
|
|
3f0cc828f2 | ||
|
|
394a30784b | ||
|
|
d887e41cf7 | ||
|
|
2565802721 | ||
|
|
d4a044366d | ||
|
|
9370acbcfe | ||
|
|
e5e8003ee0 | ||
|
|
3777feae8f | ||
|
|
2783a52cad | ||
|
|
3f754f2d02 | ||
|
|
ee97e1110d | ||
|
|
758eb3f371 | ||
|
|
1797dec2ba | ||
|
|
25be5b47e4 | ||
|
|
bc0e72e3ef | ||
|
|
0b854286f5 | ||
|
|
e633a40ef1 | ||
|
|
fc75937072 | ||
|
|
5e0d8ab9f8 | ||
|
|
323ce6274a | ||
|
|
79281fdd21 | ||
|
|
e7d58ccdf2 | ||
|
|
0328ba2a32 | ||
|
|
477c9d3ed5 | ||
|
|
e44f0ef6e7 | ||
|
|
6f4b260035 | ||
|
|
bb7a751e58 | ||
|
|
97c9266cc8 | ||
|
|
a139a3df89 | ||
|
|
346d8d7967 | ||
|
|
3eeeac2c13 | ||
|
|
94f6d2d5b5 | ||
|
|
1c4420bca8 | ||
|
|
ecff7258ba | ||
|
|
72d4f67524 | ||
|
|
1ce92714c4 | ||
|
|
1c6c2cf332 | ||
|
|
9d42ee9391 | ||
|
|
b62204054f | ||
|
|
166dc6b4fa | ||
|
|
02a1e99db2 | ||
|
|
250637cf92 | ||
|
|
b46de7402d | ||
|
|
9334a94886 | ||
|
|
9b9aa4306a | ||
|
|
90db1283dd | ||
|
|
8cc00a6ac6 | ||
|
|
315034c8cd | ||
|
|
23ac9d44a1 | ||
|
|
70db2f994c | ||
|
|
64b3c3c2fa | ||
|
|
983afb2b45 | ||
|
|
4d22ccc7f6 | ||
|
|
cd3429842b | ||
|
|
d89df315e4 | ||
|
|
fe3a225f8f | ||
|
|
f862341997 | ||
|
|
8ca08ce868 | ||
|
|
ba46630138 | ||
|
|
a3087047b6 | ||
|
|
217ca81b17 | ||
|
|
7edcebad1f | ||
|
|
0af3e29ce1 | ||
|
|
dd6462de13 | ||
|
|
52f18d048c | ||
|
|
c522ee1dd8 | ||
|
|
33e3f7ae46 | ||
|
|
87f9f88e32 | ||
|
|
0fe1e109ed | ||
|
|
90b04417cf | ||
|
|
221004af39 | ||
|
|
c3f6077f95 | ||
|
|
4f9227f100 | ||
|
|
ae6f649a06 | ||
|
|
26f9eddfc4 | ||
|
|
00879d11d3 | ||
|
|
f1bcc26cfe | ||
|
|
0967414f79 | ||
|
|
f4772b0c75 | ||
|
|
8215b66db3 | ||
|
|
d0a98afc49 | ||
|
|
da3a5681d9 | ||
|
|
f7f343fe55 | ||
|
|
0606fbe60a | ||
|
|
b2bedafae7 | ||
|
|
c108e8d856 | ||
|
|
5b5509d07c | ||
|
|
0d7aba9487 | ||
|
|
fbbfa2bbc1 | ||
|
|
2f5cfab01c | ||
|
|
70cd267ff5 | ||
|
|
d5052d79e6 | ||
|
|
a372eb99b7 | ||
|
|
199933b752 | ||
|
|
45928ddc47 | ||
|
|
bfc3983d06 | ||
|
|
2329695a47 | ||
|
|
ab1dbb04bd | ||
|
|
1fe19e41fb | ||
|
|
a47ac2a5b5 | ||
|
|
8eae44ea61 | ||
|
|
57e1104afb | ||
|
|
ede957973b | ||
|
|
697c09e146 | ||
|
|
ab59d81d08 | ||
|
|
c8d3b665f5 | ||
|
|
422ad0cc5d | ||
|
|
0c3d832c5f | ||
|
|
483410c4a2 | ||
|
|
bdeec4d297 | ||
|
|
21b27b5dbe | ||
|
|
29340e7e24 | ||
|
|
4ab450309f | ||
|
|
2ac63c4327 | ||
|
|
c31b9236a1 | ||
|
|
1da4187405 | ||
|
|
41282e2c73 | ||
|
|
3d40acc26b | ||
|
|
f7ed0eb4e7 | ||
|
|
9eadaf4c3a | ||
|
|
ce7d447f16 | ||
|
|
ef9d6d9f6c | ||
|
|
0e4044b747 | ||
|
|
bc3d897d7a | ||
|
|
1655f584f9 | ||
|
|
07afaf961d | ||
|
|
2b2a1eca9c | ||
|
|
3dd964f42c | ||
|
|
44aa7f4053 | ||
|
|
965fc2016d | ||
|
|
fd470702ab | ||
|
|
d17d86da95 | ||
|
|
f8a70c6025 | ||
|
|
587cc48b24 | ||
|
|
0c430c37bc | ||
|
|
273b911ccf | ||
|
|
a51228b374 | ||
|
|
568b336913 | ||
|
|
ab5fc36fb7 | ||
|
|
ff78ecc195 | ||
|
|
bf2acbf617 | ||
|
|
f18b98d75b | ||
|
|
16c5c74923 | ||
|
|
3586fc90ca | ||
|
|
67b45455b8 | ||
|
|
d92d1ad974 | ||
|
|
0177016fbc | ||
|
|
36685e9df9 | ||
|
|
61f403bff4 | ||
|
|
83d7dd99e8 | ||
|
|
224eae2d2d | ||
|
|
cf6997797e | ||
|
|
33e75375fd | ||
|
|
b0540c1162 | ||
|
|
4093a8ea5b | ||
|
|
e892b994c3 | ||
|
|
5f75e98861 | ||
|
|
e9b05e8ed7 | ||
|
|
1edcc239e5 | ||
|
|
61d09cf033 | ||
|
|
227ea8ecc5 | ||
|
|
7e4fb3caed | ||
|
|
152dfbbb54 | ||
|
|
c3f29bdc41 | ||
|
|
fb727fc84a | ||
|
|
9377c265a8 | ||
|
|
59b59fda98 | ||
|
|
96439ac41f | ||
|
|
c9a5d00b89 | ||
|
|
9efc1ec4f6 | ||
|
|
85fc16b016 | ||
|
|
5287fa1c94 | ||
|
|
1c54be3581 | ||
|
|
484fd91452 | ||
|
|
9ff3bb0c87 | ||
|
|
38e7801b41 | ||
|
|
7fb6f794e5 | ||
|
|
df68b0cb43 | ||
|
|
ca49fd1161 | ||
|
|
bb3f17ada2 | ||
|
|
d18c61f0da | ||
|
|
92cfc04024 | ||
|
|
2d0ce79011 | ||
|
|
c6e091a754 | ||
|
|
c8c16eb8e6 | ||
|
|
0e1082b09c | ||
|
|
c815b183d4 | ||
|
|
a95d1f9200 | ||
|
|
b8e976f4f6 | ||
|
|
6c51b7558a | ||
|
|
c4e4cc5aa7 | ||
|
|
5e90ff7db0 | ||
|
|
6451762508 | ||
|
|
fda90c217f | ||
|
|
94066c24dc | ||
|
|
76d46ec646 | ||
|
|
b9badee6db | ||
|
|
c6b64df662 | ||
|
|
e90f52f375 | ||
|
|
ca68494203 | ||
|
|
396e61cdb3 | ||
|
|
dfaab8c386 | ||
|
|
0df3e3e4f5 | ||
|
|
f2f5a06be1 | ||
|
|
8d7ff4d7db | ||
|
|
7c5b9c0e62 | ||
|
|
6bfe4a9779 | ||
|
|
fb2fa4c478 | ||
|
|
28b654ae27 | ||
|
|
9f052bdf8b | ||
|
|
5472c8513f | ||
|
|
c028ec9083 | ||
|
|
31a87935a5 | ||
|
|
80292f1f4d | ||
|
|
d686e1ee77 | ||
|
|
66cf54e458 | ||
|
|
610adb062b | ||
|
|
70aa384bc3 | ||
|
|
355424c0da | ||
|
|
9b118e8085 | ||
|
|
9e20ee35e1 | ||
|
|
0d4ef18358 | ||
|
|
8bde80a3d2 | ||
|
|
bed60b71ff | ||
|
|
cc309e87e9 | ||
|
|
9131d3d521 | ||
|
|
6b4971786f | ||
|
|
1f010acb30 | ||
|
|
8bf64d8723 | ||
|
|
73b0161ff7 | ||
|
|
4cbf1f5371 | ||
|
|
e5a33523d9 | ||
|
|
224c54b1a2 | ||
|
|
020f561ad4 | ||
|
|
669d269fd9 | ||
|
|
b026e45189 | ||
|
|
7e38419cdb | ||
|
|
cfcc3793c5 | ||
|
|
5724bdf436 | ||
|
|
432cc2003e | ||
|
|
79f9e78c37 | ||
|
|
d8dd4c92bf | ||
|
|
057c4a3239 | ||
|
|
dc77efc31a | ||
|
|
e6bb5f484c | ||
|
|
bcb22d8d4c | ||
|
|
b37cf02a6e | ||
|
|
7706bd9845 | ||
|
|
b17a7cfa95 | ||
|
|
e1a4a74905 | ||
|
|
3ac315a9e7 | ||
|
|
fb3e47386c | ||
|
|
aea8a6d04b | ||
|
|
e449f0bda4 | ||
|
|
ff3cb6c5cc | ||
|
|
2b4f7ab56f | ||
|
|
f5a8216be6 | ||
|
|
19324ab232 | ||
|
|
bf96d21d67 | ||
|
|
2f0fdf1252 | ||
|
|
d44a11325d | ||
|
|
a32e8abc12 | ||
|
|
3779b4a923 | ||
|
|
9738e4a225 | ||
|
|
0905016b1f | ||
|
|
e3b39f670f | ||
|
|
9b54f63eb1 | ||
|
|
b5158adb51 | ||
|
|
7cc8c81bd8 | ||
|
|
27bd79febf | ||
|
|
5d6051c490 | ||
|
|
a6641980c2 | ||
|
|
5f8ecfaf81 | ||
|
|
af4175a5bc | ||
|
|
8f5ca5220e | ||
|
|
8da46afab4 | ||
|
|
0885951a67 | ||
|
|
180a7df1e7 | ||
|
|
07cdf2bc7a | ||
|
|
259293f9b3 | ||
|
|
ef8f7c9884 | ||
|
|
b516f99394 | ||
|
|
b10b0f8a6a | ||
|
|
4ad1099e9f | ||
|
|
4f5e40e161 | ||
|
|
d717bf39ac | ||
|
|
c12ecb9f21 | ||
|
|
00af52411c | ||
|
|
f4c54a1643 | ||
|
|
40ba143a63 | ||
|
|
0e36ac84d8 | ||
|
|
92d563371c | ||
|
|
e596834096 | ||
|
|
000bf27c87 | ||
|
|
b77920bb4b | ||
|
|
16c14bf709 | ||
|
|
62140ec001 | ||
|
|
ccc2dd1128 | ||
|
|
9e9caee639 | ||
|
|
22c66203a0 | ||
|
|
facf4684ae | ||
|
|
810a29ea72 | ||
|
|
c874a641df | ||
|
|
a036814d98 | ||
|
|
2624897efe | ||
|
|
df6f53a161 | ||
|
|
03312559a7 | ||
|
|
3ab352e253 | ||
|
|
b941551fff | ||
|
|
593e0748a8 | ||
|
|
236254d907 | ||
|
|
1771cb3fdb | ||
|
|
eecd689ad5 | ||
|
|
3e48c86ee9 | ||
|
|
471775ae49 | ||
|
|
a278297f28 | ||
|
|
38a1193523 | ||
|
|
3d84bdf77b | ||
|
|
8668143127 | ||
|
|
0d537c8a24 | ||
|
|
bce71cb196 | ||
|
|
e82a2e518c | ||
|
|
954d919361 | ||
|
|
295bad59bb | ||
|
|
804ee3b298 | ||
|
|
9c082a8331 | ||
|
|
88abd8872d | ||
|
|
c66a9851cc | ||
|
|
75c07221ef | ||
|
|
f443e643ee | ||
|
|
b82794df05 | ||
|
|
14f3571e67 | ||
|
|
5a7cedce95 | ||
|
|
5310b1d48e | ||
|
|
167656b38e | ||
|
|
5d81f875cb | ||
|
|
6ae200e338 | ||
|
|
ab6b902fb5 | ||
|
|
9f423b01ef | ||
|
|
c863c86f4c | ||
|
|
5b14c76e54 | ||
|
|
31a3bb7cd6 | ||
|
|
45b97c7054 | ||
|
|
2bd27a5d0b | ||
|
|
cff8f88920 | ||
|
|
87f5479662 | ||
|
|
4e51a715c1 | ||
|
|
3bd6518309 | ||
|
|
f945fb4cdd | ||
|
|
7cff44b647 | ||
|
|
cead305a9a | ||
|
|
4092f7fd51 | ||
|
|
695c1349e8 | ||
|
|
83de879894 | ||
|
|
7faed3ee1e | ||
|
|
c06bfb989e | ||
|
|
f7f7f469ad | ||
|
|
a589705e6d | ||
|
|
ee062c13d4 | ||
|
|
01fd4754f9 | ||
|
|
30645bc4e0 | ||
|
|
0dd07d10a0 | ||
|
|
7007c0a0bd | ||
|
|
24529bd0ad | ||
|
|
d4ec5eb497 | ||
|
|
1fd166d5c7 | ||
|
|
96599df89f | ||
|
|
fdee54f921 | ||
|
|
2ec13c64f3 | ||
|
|
c916eeb9d7 | ||
|
|
8ee85a4007 | ||
|
|
3dd8dd4288 | ||
|
|
2908c429a5 | ||
|
|
1aa716de55 | ||
|
|
f631bdc782 | ||
|
|
81cb055375 | ||
|
|
7e528d9c10 | ||
|
|
b27c608508 | ||
|
|
a4529617cc | ||
|
|
a6564fb43c | ||
|
|
3aba7404fc | ||
|
|
d8032e1c9e | ||
|
|
b4a42602e2 | ||
|
|
57171f57e4 | ||
|
|
1f54adad71 | ||
|
|
df512d0ba2 | ||
|
|
a54a11db88 | ||
|
|
ac4042ca04 | ||
|
|
a51d95743a | ||
|
|
1bc40693bb | ||
|
|
82df434d19 | ||
|
|
1e7dd8fc80 | ||
|
|
7fa63c8e19 | ||
|
|
60f1882bec | ||
|
|
3280c2c440 | ||
|
|
a91da7cf2c | ||
|
|
6c0429351a | ||
|
|
264deab637 | ||
|
|
69345ed26c | ||
|
|
36c0be1097 | ||
|
|
82d3b41699 | ||
|
|
e12bc6aa19 | ||
|
|
64d4d64aa7 | ||
|
|
1a87e5c3d4 | ||
|
|
1e16545517 | ||
|
|
757f1484e9 | ||
|
|
2500ce0920 | ||
|
|
2f725bf80d | ||
|
|
21c33f1e82 | ||
|
|
7979608cc5 | ||
|
|
bb583eaa72 | ||
|
|
d666cab77a | ||
|
|
4b9d40464c | ||
|
|
1733323132 | ||
|
|
1256ba0429 | ||
|
|
7487b0da58 | ||
|
|
e650f813c5 | ||
|
|
2267d27c9b | ||
|
|
598d0bdda3 | ||
|
|
0bb3c84b9e | ||
|
|
cf7f118784 | ||
|
|
1918f7f0aa | ||
|
|
ea0c9c65d9 | ||
|
|
8aec85c579 | ||
|
|
4fa03f4938 | ||
|
|
e0a957c4e9 | ||
|
|
5db72e5fee | ||
|
|
3dedc1f824 | ||
|
|
8ce2fff8ab | ||
|
|
3d921f4570 | ||
|
|
5a24e30820 | ||
|
|
bd86e3d951 | ||
|
|
b131d676c4 | ||
|
|
b78efdd155 | ||
|
|
036f08a729 | ||
|
|
f4ffcebb14 | ||
|
|
bd2ec7b2af | ||
|
|
57814cf855 | ||
|
|
66cb35b5fc | ||
|
|
9be8be49ef | ||
|
|
3512db1fe7 | ||
|
|
367d024a2d | ||
|
|
7ca9afad57 | ||
|
|
f79348817f | ||
|
|
a2e474c375 | ||
|
|
d9722a9825 | ||
|
|
dab18e5b40 | ||
|
|
95a8e64fbb | ||
|
|
3492558e06 | ||
|
|
66c8f8d8df | ||
|
|
dbd8efbf16 | ||
|
|
2fb4bd4975 | ||
|
|
7ae8049438 | ||
|
|
276301dc87 | ||
|
|
d4c7ad4beb | ||
|
|
3aac1b2715 | ||
|
|
1b39ba70cb | ||
|
|
dd282963c3 | ||
|
|
fd2d7fe14d | ||
|
|
d023a81a32 | ||
|
|
fb470eec79 | ||
|
|
7bd1c6e115 | ||
|
|
6039002ed5 | ||
|
|
73e8f955ca | ||
|
|
5e7657fc40 | ||
|
|
76b4d4c10c | ||
|
|
b3c975314d | ||
|
|
7a507505aa | ||
|
|
4e7e6e57fe | ||
|
|
0b78d3173d | ||
|
|
92d7e5c58a | ||
|
|
632d013fb8 | ||
|
|
207894dac6 | ||
|
|
b5e2c83fba | ||
|
|
d982ce13f5 | ||
|
|
2b833413cf | ||
|
|
6f170b1ad7 | ||
|
|
6dbe25fcc5 | ||
|
|
cc55bec521 | ||
|
|
2f567af80b | ||
|
|
0b3cfdce32 | ||
|
|
74828adcb8 | ||
|
|
ae5832b8a5 | ||
|
|
2b78a8cb51 | ||
|
|
84785b7a60 | ||
|
|
3120cd54fe | ||
|
|
b1cafc06eb | ||
|
|
fd66fb33a8 | ||
|
|
6598ce2fe4 | ||
|
|
42e46a7c22 | ||
|
|
56ab34a57f | ||
|
|
ac56fa36ba | ||
|
|
8752680233 | ||
|
|
5af9d0164b | ||
|
|
049a01d58f | ||
|
|
629af0efc3 | ||
|
|
a1262c2406 | ||
|
|
81a8efcca3 | ||
|
|
8ff168283c | ||
|
|
c2f16f740b | ||
|
|
c35e5b33d1 | ||
|
|
97dd879597 | ||
|
|
50204599b4 | ||
|
|
bec7cffe2a | ||
|
|
f1321d6140 | ||
|
|
4bf2fb85e3 | ||
|
|
0646f48ca6 | ||
|
|
4e4d410803 | ||
|
|
cf68414c40 | ||
|
|
a50d65393e | ||
|
|
67221b015d | ||
|
|
40aadbad85 | ||
|
|
77ebf306a3 | ||
|
|
94d3924432 | ||
|
|
1235ea5bb5 | ||
|
|
321ed12663 | ||
|
|
265af01f9c | ||
|
|
a9961df4e2 | ||
|
|
8d3f35f4f7 | ||
|
|
2b8ae406a3 | ||
|
|
d78f1a3ff9 | ||
|
|
538aa45e8b | ||
|
|
c500c9c199 | ||
|
|
b2363d2783 | ||
|
|
8aba600fa5 | ||
|
|
92bf7ebc52 | ||
|
|
2e1ddc9ae1 | ||
|
|
18596ecc34 | ||
|
|
420d289d35 | ||
|
|
eebd0f113b | ||
|
|
c4286984ab | ||
|
|
e0d6a0b974 | ||
|
|
71e46860ac | ||
|
|
ce942ffe16 | ||
|
|
e083ef0d6d | ||
|
|
c5b6971447 | ||
|
|
8dcb4be8a8 | ||
|
|
b91fb3f586 | ||
|
|
35657a7bbd | ||
|
|
79356baee1 | ||
|
|
cb6c0b6e45 | ||
|
|
543bc24bfd | ||
|
|
789ff72081 | ||
|
|
5dc4754181 | ||
|
|
eaa64b636a | ||
|
|
1c9cd40d34 | ||
|
|
9c54181ff8 | ||
|
|
c9fb0729f3 | ||
|
|
d499d20a9c | ||
|
|
d3dfeeccc3 | ||
|
|
d4211441b3 | ||
|
|
3307debacc | ||
|
|
2772a38dae | ||
|
|
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 | ||
|
|
c1517e259d | ||
|
|
66d30fb42a | ||
|
|
ea30132763 | ||
|
|
1c237435ec | ||
|
|
c37793d06f | ||
|
|
57c7a353b5 | ||
|
|
8154705f0b | ||
|
|
fd2c6ef590 | ||
|
|
6ece25e7f3 | ||
|
|
a2ef6180bb | ||
|
|
c3c4c9e9aa | ||
|
|
031c848984 | ||
|
|
1f70ff1b06 | ||
|
|
1af9a85847 | ||
|
|
e474d1e8b0 | ||
|
|
4b117a790a | ||
|
|
72a2a8c82e | ||
|
|
a7af16beb1 | ||
|
|
7d38bc7654 | ||
|
|
bcbcbf12ac | ||
|
|
8b5d2a8ca0 | ||
|
|
9b8e637618 | ||
|
|
15a45d9eb7 | ||
|
|
8a7bc38861 | ||
|
|
2d96560375 | ||
|
|
bb5e0e3fed | ||
|
|
4a8678bf39 | ||
|
|
ed28082c01 | ||
|
|
0d3dcfdc7a | ||
|
|
672203467d | ||
|
|
4ce619f9cb | ||
|
|
5344337b43 | ||
|
|
cf3238859c | ||
|
|
9a03a9e81b | ||
|
|
edfed24c27 | ||
|
|
7118dcc124 | ||
|
|
5bcb35f756 | ||
|
|
eaf3c42227 | ||
|
|
16a4feaeb6 | ||
|
|
b60458318c | ||
|
|
b10c88afd7 | ||
|
|
f0cae0fbac | ||
|
|
28bb8d4446 | ||
|
|
adea3c38be | ||
|
|
fb56ab9a06 | ||
|
|
72aea2613a | ||
|
|
6bd4e4bd7c | ||
|
|
1f6118f068 | ||
|
|
574e72a974 | ||
|
|
53646737e8 | ||
|
|
26b9cc75ca | ||
|
|
66e46aaded | ||
|
|
ddf5d49895 | ||
|
|
899d895f29 | ||
|
|
27588b8a48 | ||
|
|
c3f4adb777 | ||
|
|
3633503549 | ||
|
|
5494bcce88 | ||
|
|
b824a1f17f | ||
|
|
5a6b6dacc0 | ||
|
|
0c6e9d4fca | ||
|
|
0d0ca188bf | ||
|
|
9c91ae2744 | ||
|
|
882f027f6c | ||
|
|
b805d49cfd | ||
|
|
12f85cceb1 | ||
|
|
8f4a1db1f0 | ||
|
|
58bde32bfb | ||
|
|
26b3aa27ae | ||
|
|
26ebd23bfd | ||
|
|
12d347976c | ||
|
|
a779434bab | ||
|
|
c5ec3b48b4 | ||
|
|
712c292183 | ||
|
|
8900df27c9 | ||
|
|
0eb511c714 | ||
|
|
d48eec5e66 | ||
|
|
3a7fd7a620 | ||
|
|
85043b34a4 | ||
|
|
2df0e98749 | ||
|
|
3c3b2477af | ||
|
|
5a9b2122c2 | ||
|
|
37e72e078d | ||
|
|
a2dafc11f2 | ||
|
|
55869f551e | ||
|
|
967cde1fb5 | ||
|
|
26efd481e3 | ||
|
|
06fd7327de | ||
|
|
a08d57ca0f | ||
|
|
c60c51871d | ||
|
|
04c4150283 | ||
|
|
1feb038385 | ||
|
|
61a5b0dbe9 | ||
|
|
690cd683f0 | ||
|
|
c87c81f663 | ||
|
|
8190d5b1f4 | ||
|
|
75c11371e6 | ||
|
|
ffa0bf05cd | ||
|
|
8e1c57af25 | ||
|
|
c62916a43c | ||
|
|
f5145ffaae | ||
|
|
0a6aba1ac7 | ||
|
|
5d30246c35 | ||
|
|
e9386ecfe3 | ||
|
|
04f5d4acb7 | ||
|
|
841c08fcb6 | ||
|
|
2d7c354723 | ||
|
|
6fec79055e | ||
|
|
f61a8f82a7 | ||
|
|
136883fd94 | ||
|
|
9c3f5929c7 | ||
|
|
39bd1fe164 | ||
|
|
67ea467501 | ||
|
|
ed946195e2 | ||
|
|
84bf95fa85 | ||
|
|
cf9ba9b1f9 | ||
|
|
1a18ce9e21 | ||
|
|
044b717f86 | ||
|
|
8777718afc | ||
|
|
8e3910c76d | ||
|
|
448444eccf | ||
|
|
65cd380527 | ||
|
|
71a49ac1a6 | ||
|
|
1fba62276c | ||
|
|
29f265be30 | ||
|
|
4c9011f391 | ||
|
|
155475422e | ||
|
|
32353e0f02 | ||
|
|
69159b9aae | ||
|
|
b47d0ac240 | ||
|
|
d14af78403 | ||
|
|
9cb08036ef | ||
|
|
e0da6b1302 | ||
|
|
fcb1f938b9 | ||
|
|
9c094c1cc3 | ||
|
|
69c6f24d97 | ||
|
|
e8b020ff45 | ||
|
|
2ec9a7307e | ||
|
|
738ee5cf35 | ||
|
|
8144d39e29 | ||
|
|
788d5e9f9b | ||
|
|
d399d2fe1c | ||
|
|
615b09a774 | ||
|
|
7a5e8cc44b | ||
|
|
291b49488b | ||
|
|
aa58242551 | ||
|
|
b08ea2c416 | ||
|
|
98f02f78db | ||
|
|
d2f933e15f | ||
|
|
d672969840 | ||
|
|
8c4f0c1253 | ||
|
|
18c88e567c | ||
|
|
2c5505852e | ||
|
|
bc8f245064 | ||
|
|
30726144b8 | ||
|
|
893701c07b | ||
|
|
0ec9a4c89b | ||
|
|
96fb7e2296 | ||
|
|
750e390b5d | ||
|
|
7a8cfb5f66 | ||
|
|
d761ce929c | ||
|
|
29969582e9 | ||
|
|
749e334396 | ||
|
|
78a681f277 | ||
|
|
e22ec2c505 | ||
|
|
3f96fad7ce | ||
|
|
83bb9951b0 | ||
|
|
4a5f34801a | ||
|
|
2cd7839da3 | ||
|
|
35ddcb27f0 | ||
|
|
328aca48ab | ||
|
|
4eba641ec3 | ||
|
|
f2d4af04e3 | ||
|
|
d44ee2bbf6 | ||
|
|
6f07944442 | ||
|
|
7716b1e81e | ||
|
|
8914809775 | ||
|
|
dcb5531038 | ||
|
|
0c666f96b1 | ||
|
|
d9c3c20350 | ||
|
|
73349cd423 | ||
|
|
b3667a916b | ||
|
|
6791c7395b | ||
|
|
aba7e86cbc | ||
|
|
12f973f61b | ||
|
|
f98743dd9b | ||
|
|
2c8b258ae7 | ||
|
|
00520a7a38 | ||
|
|
611894fd05 | ||
|
|
aabae53e5d | ||
|
|
85cf7bb687 | ||
|
|
44b9358c60 | ||
|
|
ee6188d100 | ||
|
|
2bdae49425 | ||
|
|
9814a9f792 | ||
|
|
5aedfd3898 | ||
|
|
59ec2de8bd | ||
|
|
d154d3936d | ||
|
|
5125aac91c | ||
|
|
7ff34364a3 | ||
|
|
3d0d70dc17 | ||
|
|
62512b7a1a | ||
|
|
c5a1344e8a | ||
|
|
402b05a27b | ||
|
|
b67d9fc85d | ||
|
|
3e121f5d3c | ||
|
|
b6426f0417 | ||
|
|
59b341dfb8 | ||
|
|
e2834a7c4d | ||
|
|
e0b3068a5e | ||
|
|
2280031a80 | ||
|
|
8f2851e20a | ||
|
|
2eeb7d63a0 | ||
|
|
b20df55b88 | ||
|
|
de1b97bbce | ||
|
|
3b4a4108e5 | ||
|
|
dc1c0ddd4e | ||
|
|
182e21a9c3 | ||
|
|
187c19f3b2 | ||
|
|
d5eff0cd34 | ||
|
|
d4fe2052c7 | ||
|
|
2e92be0f23 | ||
|
|
94b0e6f690 | ||
|
|
202051bbbf | ||
|
|
a693975526 | ||
|
|
4cd4e890fe | ||
|
|
5dc8031ec9 | ||
|
|
03ad5dcff6 | ||
|
|
5f508e1839 | ||
|
|
c5642799df | ||
|
|
5a99fe8ba2 | ||
|
|
ee0f448d86 | ||
|
|
a222f64ee4 | ||
|
|
140daec0d3 | ||
|
|
b409c89d3b | ||
|
|
806893962c | ||
|
|
14d3c5e93e | ||
|
|
37e14b13a4 | ||
|
|
d7fa51be2c | ||
|
|
a3e28e71aa | ||
|
|
35cef8386c | ||
|
|
38072c9cdd | ||
|
|
13d741b89e | ||
|
|
cc90a1af15 | ||
|
|
21fc1245eb | ||
|
|
2511ba7627 | ||
|
|
23547f4504 | ||
|
|
e6f19d050f | ||
|
|
3ec8084450 | ||
|
|
2edb722c0e | ||
|
|
1f75498dca | ||
|
|
ab19c4d688 | ||
|
|
15265d9ef3 | ||
|
|
2839a7228f | ||
|
|
c2036975fa | ||
|
|
7aa0f87376 | ||
|
|
df372d1a7e | ||
|
|
6cd31502e7 | ||
|
|
bade88079f | ||
|
|
20ab05afc8 | ||
|
|
5b10f51af1 | ||
|
|
470d11f442 | ||
|
|
4952f0fbd2 | ||
|
|
0a3292566c | ||
|
|
f4e8ebc053 | ||
|
|
ad77bde8c8 | ||
|
|
5241b29cc6 | ||
|
|
8fcc40942f | ||
|
|
37d4d5d647 | ||
|
|
b67b9e83ff | ||
|
|
4c3dcec19e | ||
|
|
53375ff559 | ||
|
|
53e08988e7 | ||
|
|
d0bbda555f | ||
|
|
207e990798 | ||
|
|
b0a07af28d | ||
|
|
1a8bac7ab1 | ||
|
|
dc03c13642 | ||
|
|
739b20583d | ||
|
|
10ccbc780b | ||
|
|
f971a36ec0 | ||
|
|
3699464947 | ||
|
|
3a3d1262ab | ||
|
|
395a97c0e5 | ||
|
|
4a6aca4c07 | ||
|
|
08f0d5fd1f | ||
|
|
750be7f07e | ||
|
|
70538783d8 | ||
|
|
09336fa1e4 | ||
|
|
c124434429 | ||
|
|
0544a6f00d | ||
|
|
7b186af765 | ||
|
|
3f978bc45f | ||
|
|
488aeb119b | ||
|
|
160c72997f | ||
|
|
ccb9da9333 | ||
|
|
840cb5b182 | ||
|
|
04ee6b8be2 | ||
|
|
8c8a1685b2 | ||
|
|
28e6f8a0f6 | ||
|
|
d9e5e08af5 | ||
|
|
60980df26b | ||
|
|
d3462d2905 | ||
|
|
0aefcf29ef | ||
|
|
55c021796e | ||
|
|
4aad98256a | ||
|
|
30b13cbdbc | ||
|
|
6d140d6a86 | ||
|
|
9757983046 | ||
|
|
5bed926323 | ||
|
|
1d2f3f72e4 | ||
|
|
3a76e4733c | ||
|
|
a4fbb1b4c5 | ||
|
|
94296e7dd8 | ||
|
|
dc7ca6e405 | ||
|
|
09b128f27a | ||
|
|
acde2e5b6e | ||
|
|
420e35c33c | ||
|
|
c5ce51f242 | ||
|
|
2743c96694 | ||
|
|
36ccfac787 | ||
|
|
e27d5719f0 | ||
|
|
1a3816c1ff | ||
|
|
52a55f71e6 | ||
|
|
b5670d9a71 | ||
|
|
e7bd3abadc | ||
|
|
5878d7e5a6 | ||
|
|
3bce0ad4ae | ||
|
|
695e029147 | ||
|
|
08846e4cc2 | ||
|
|
f9219d2d96 | ||
|
|
7dfb2d50c7 | ||
|
|
349872bdb3 | ||
|
|
39f4613719 | ||
|
|
effc1f42eb | ||
|
|
23d275acec | ||
|
|
8036aa2942 | ||
|
|
f23c7a2dbf | ||
|
|
17e5af654b | ||
|
|
0909354a6c | ||
|
|
cda9dfa9d0 | ||
|
|
018fa816e2 | ||
|
|
efa6d03ba5 | ||
|
|
1ed4ebaf03 | ||
|
|
10c69a722f | ||
|
|
324500d0b3 | ||
|
|
4cd30c35ce | ||
|
|
e79dbf4d00 | ||
|
|
e29a18a076 | ||
|
|
f17df1e926 | ||
|
|
c21737d546 | ||
|
|
6dc4e441e4 | ||
|
|
7d93b0596b | ||
|
|
8b32cfaaff | ||
|
|
18b91cf250 | ||
|
|
4af9c07577 | ||
|
|
fb90ab480a | ||
|
|
d705d3c3b1 | ||
|
|
ee743a7b01 | ||
|
|
e422c2c479 | ||
|
|
aa79fe2861 | ||
|
|
530f55c234 | ||
|
|
6d343c0f1a | ||
|
|
1599793de2 | ||
|
|
42016f48ff | ||
|
|
f9e22dcaa9 | ||
|
|
703f94a25f | ||
|
|
0958c1d527 | ||
|
|
fef46823eb | ||
|
|
48523a2269 | ||
|
|
202c4ac4b3 | ||
|
|
1536201e9a | ||
|
|
3fac1c3432 | ||
|
|
415ab57749 | ||
|
|
c57fac283e | ||
|
|
2eff8d6b47 | ||
|
|
40be2a9153 | ||
|
|
4ba23390b5 | ||
|
|
dd1a85e733 | ||
|
|
c6c3caec39 | ||
|
|
8c0f3b2304 | ||
|
|
c859f866b8 | ||
|
|
b497063af4 | ||
|
|
1fe598a966 | ||
|
|
31e7aa24bc | ||
|
|
4c4e689af4 | ||
|
|
43326be637 | ||
|
|
7e1a71b694 | ||
|
|
b89c18e83d | ||
|
|
f4f5edb230 | ||
|
|
ce9e9f3e0d | ||
|
|
da4cf04594 | ||
|
|
0677b3bd7e | ||
|
|
eed233a793 | ||
|
|
2ad0802b65 | ||
|
|
0df8aa9a5d | ||
|
|
d3f71f9d0a | ||
|
|
8187b49599 | ||
|
|
2188643387 | ||
|
|
344031b575 | ||
|
|
a320093cb8 | ||
|
|
7fb7ba2fa5 | ||
|
|
3902599c52 | ||
|
|
4972407145 | ||
|
|
d714af43c9 | ||
|
|
29c2fc5472 | ||
|
|
1c9766b8fd | ||
|
|
68351230f3 | ||
|
|
0ad85262c1 | ||
|
|
1552d8103e | ||
|
|
c3a2331b59 | ||
|
|
5cf92c55c6 | ||
|
|
e56eb0c178 | ||
|
|
44bc13eb2c | ||
|
|
a77f89d302 | ||
|
|
b1bbbf0103 | ||
|
|
c2f31b9c9f | ||
|
|
198e2cfd90 | ||
|
|
936e95fd9e | ||
|
|
c56814e7da | ||
|
|
631ade5430 | ||
|
|
e61a0c2f78 | ||
|
|
89957e7058 | ||
|
|
26dde0f286 | ||
|
|
7d9f687768 | ||
|
|
0a0fea1c2f | ||
|
|
cb4970be59 | ||
|
|
460162737a | ||
|
|
393f95aeac | ||
|
|
03a4e3e8e9 | ||
|
|
243d549975 | ||
|
|
e309775ac1 | ||
|
|
f388fd9c90 | ||
|
|
b908f07355 | ||
|
|
1287594505 | ||
|
|
86b20e8ccd | ||
|
|
2181227a6e | ||
|
|
aab705f4a4 | ||
|
|
8af9a97518 | ||
|
|
9fac75b831 | ||
|
|
c83b5c6e73 | ||
|
|
a75326ff69 | ||
|
|
df6611e8de | ||
|
|
7e817f408c | ||
|
|
4ceb058a40 | ||
|
|
4710812c24 | ||
|
|
eb37c47ff5 | ||
|
|
e80c2c1a57 | ||
|
|
75f8607d75 | ||
|
|
828a286809 | ||
|
|
9b0e7eedb2 | ||
|
|
df4585af6b | ||
|
|
91d40dcc91 | ||
|
|
2b6363474e | ||
|
|
707c58a120 | ||
|
|
846ee0fb2d | ||
|
|
cdc9c0d62c | ||
|
|
b079cb99a4 | ||
|
|
0b0595384e |
2
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
github: [binwiederhier]
|
||||
liberapay: ntfy
|
||||
26
.github/ISSUE_TEMPLATE/1_bug_report.md
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: 🐛 Bug Report
|
||||
about: Report any errors and problems
|
||||
title: ''
|
||||
labels: '🪲 bug'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
:lady_beetle: **Describe the bug**
|
||||
<!-- A clear and concise description of the problem. -->
|
||||
|
||||
:computer: **Components impacted**
|
||||
<!-- ntfy server, Android app, iOS app, web app -->
|
||||
|
||||
:bulb: **Screenshots and/or logs**
|
||||
<!--
|
||||
If applicable, add screenshots or share logs help explain your problem.
|
||||
To get logs from the ...
|
||||
- ntfy server: Enable "log-level: trace" in your server.yml file
|
||||
- Android app: Go to "Settings" -> "Record logs", then eventually "Copy/upload logs"
|
||||
- web app: Press "F12" and find the "Console" window
|
||||
-->
|
||||
|
||||
:crystal_ball: **Additional context**
|
||||
<!-- Add any other context about the problem here. -->
|
||||
26
.github/ISSUE_TEMPLATE/2_enhancement_request.md
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: 💡 Feature/Enhancement Request
|
||||
about: Got a great idea? Let us know!
|
||||
title: ''
|
||||
labels: 'enhancement'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
|
||||
Before you submit, consider asking on Discord/Matrix instead. You'll usually get an answer
|
||||
sooner, and there are more people there to help!
|
||||
|
||||
- Discord: https://discord.gg/cT7ECsZj9w
|
||||
- Matrix: https://matrix.to/#/#ntfy:matrix.org / https://matrix.to/#/#ntfy-space:matrix.org
|
||||
|
||||
-->
|
||||
|
||||
:bulb: **Idea**
|
||||
<!-- Share your thoughts; try to be detailed if you can -->
|
||||
|
||||
:computer: **Target components**
|
||||
<!-- Where should this feature/enhancement be added? -->
|
||||
<!-- e.g. ntfy server, Android app, iOS app, web app -->
|
||||
|
||||
21
.github/ISSUE_TEMPLATE/3_tech_support.md
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: 🆘 I need help with ...
|
||||
about: Installing ntfy, configuring the app, etc.
|
||||
title: ''
|
||||
labels: 'tech-support'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
|
||||
<!--
|
||||
|
||||
STOP!
|
||||
|
||||
This is not the right place to ask for help. Consider asking on Discord/Matrix instead.
|
||||
You'll usually get an answer sooner, and there are more people there to help!
|
||||
|
||||
- Discord: https://discord.gg/cT7ECsZj9w
|
||||
- Matrix: https://matrix.to/#/#ntfy:matrix.org / https://matrix.to/#/#ntfy-space:matrix.org
|
||||
|
||||
-->
|
||||
21
.github/ISSUE_TEMPLATE/4_question.md
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: ❓ Question
|
||||
about: Ask a question about ntfy
|
||||
title: ''
|
||||
labels: 'question'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
|
||||
Before you submit, consider asking on Discord/Matrix instead. You'll usually get an answer
|
||||
sooner, and there are more people there to help!
|
||||
|
||||
- Discord: https://discord.gg/cT7ECsZj9w
|
||||
- Matrix: https://matrix.to/#/#ntfy:matrix.org / https://matrix.to/#/#ntfy-space:matrix.org
|
||||
|
||||
-->
|
||||
|
||||
:question: **Question**
|
||||
<!-- Go ahead and ask your question here :) -->
|
||||
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.19.x'
|
||||
-
|
||||
name: Install node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '18'
|
||||
-
|
||||
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.19.x'
|
||||
-
|
||||
name: Install node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '18'
|
||||
-
|
||||
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,19 +4,45 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install Go
|
||||
-
|
||||
name: Install Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: '1.17.x'
|
||||
- name: Checkout code
|
||||
go-version: '1.19.x'
|
||||
-
|
||||
name: Install node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '18'
|
||||
-
|
||||
name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Install dependencies
|
||||
run: sudo apt update && sudo apt install -y python3-pip curl
|
||||
- name: Build docs (required for tests)
|
||||
-
|
||||
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 docs (required for tests)
|
||||
run: make docs
|
||||
- name: Run tests, formatting, vetting and linting
|
||||
-
|
||||
name: Build web app (required for tests)
|
||||
run: make web
|
||||
-
|
||||
name: Run tests, formatting, vetting and linting
|
||||
run: make check
|
||||
- name: Run coverage
|
||||
-
|
||||
name: Run coverage
|
||||
run: make coverage
|
||||
- name: Upload coverage to codecov.io
|
||||
-
|
||||
name: Upload coverage to codecov.io
|
||||
run: make coverage-upload
|
||||
|
||||
7
.gitignore
vendored
@@ -1,7 +1,14 @@
|
||||
dist/
|
||||
build/
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
server/docs/
|
||||
server/site/
|
||||
tools/fbsend/fbsend
|
||||
playground/
|
||||
secrets/
|
||||
*.iml
|
||||
node_modules/
|
||||
.DS_Store
|
||||
__pycache__
|
||||
|
||||
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
|
||||
@@ -1,9 +1,10 @@
|
||||
before:
|
||||
hooks:
|
||||
- go mod download
|
||||
- go mod tidy
|
||||
builds:
|
||||
-
|
||||
id: ntfy
|
||||
id: ntfy_linux_amd64
|
||||
binary: ntfy
|
||||
env:
|
||||
- CGO_ENABLED=1 # required for go-sqlite3
|
||||
@@ -13,7 +14,19 @@ builds:
|
||||
goos: [linux]
|
||||
goarch: [amd64]
|
||||
-
|
||||
id: ntfy_armv7
|
||||
id: ntfy_linux_armv6
|
||||
binary: ntfy
|
||||
env:
|
||||
- CGO_ENABLED=1 # required for go-sqlite3
|
||||
- CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi
|
||||
tags: [sqlite_omit_load_extension,osusergo,netgo]
|
||||
ldflags:
|
||||
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||
goos: [linux]
|
||||
goarch: [arm]
|
||||
goarm: [6]
|
||||
-
|
||||
id: ntfy_linux_armv7
|
||||
binary: ntfy
|
||||
env:
|
||||
- CGO_ENABLED=1 # required for go-sqlite3
|
||||
@@ -25,7 +38,7 @@ builds:
|
||||
goarch: [arm]
|
||||
goarm: [7]
|
||||
-
|
||||
id: ntfy_arm64
|
||||
id: ntfy_linux_arm64
|
||||
binary: ntfy
|
||||
env:
|
||||
- CGO_ENABLED=1 # required for go-sqlite3
|
||||
@@ -35,6 +48,26 @@ builds:
|
||||
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||
goos: [linux]
|
||||
goarch: [arm64]
|
||||
-
|
||||
id: ntfy_windows_amd64
|
||||
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]
|
||||
-
|
||||
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:
|
||||
-
|
||||
package_name: ntfy
|
||||
@@ -49,20 +82,22 @@ nfpms:
|
||||
contents:
|
||||
- src: server/server.yml
|
||||
dst: /etc/ntfy/server.yml
|
||||
type: config
|
||||
type: "config|noreplace"
|
||||
- src: server/ntfy.service
|
||||
dst: /lib/systemd/system/ntfy.service
|
||||
- src: client/client.yml
|
||||
dst: /etc/ntfy/client.yml
|
||||
type: config
|
||||
type: "config|noreplace"
|
||||
- src: client/ntfy-client.service
|
||||
dst: /lib/systemd/system/ntfy-client.service
|
||||
- dst: /var/cache/ntfy
|
||||
type: dir
|
||||
- dst: /var/cache/ntfy/attachments
|
||||
type: dir
|
||||
- dst: /var/lib/ntfy
|
||||
type: dir
|
||||
- dst: /usr/share/ntfy/logo.png
|
||||
src: server/static/img/ntfy.png
|
||||
src: web/public/static/img/ntfy.png
|
||||
scripts:
|
||||
preinstall: "scripts/preinst.sh"
|
||||
postinstall: "scripts/postinst.sh"
|
||||
@@ -70,6 +105,12 @@ nfpms:
|
||||
postremove: "scripts/postrm.sh"
|
||||
archives:
|
||||
-
|
||||
id: ntfy_linux
|
||||
builds:
|
||||
- ntfy_linux_amd64
|
||||
- ntfy_linux_armv6
|
||||
- ntfy_linux_armv7
|
||||
- ntfy_linux_arm64
|
||||
wrap_in_directory: true
|
||||
files:
|
||||
- LICENSE
|
||||
@@ -79,8 +120,35 @@ archives:
|
||||
- client/client.yml
|
||||
- client/ntfy-client.service
|
||||
replacements:
|
||||
386: i386
|
||||
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:
|
||||
name_template: 'checksums.txt'
|
||||
snapshot:
|
||||
@@ -103,6 +171,7 @@ dockers:
|
||||
- &arm64v8_image "binwiederhier/ntfy:{{ .Tag }}-arm64v8"
|
||||
use: buildx
|
||||
dockerfile: Dockerfile
|
||||
goarch: arm64
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm64/v8"
|
||||
- image_templates:
|
||||
@@ -113,14 +182,24 @@ dockers:
|
||||
goarm: 7
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm/v7"
|
||||
- image_templates:
|
||||
- &armv6_image "binwiederhier/ntfy:{{ .Tag }}-armv6"
|
||||
use: buildx
|
||||
dockerfile: Dockerfile
|
||||
goarch: arm
|
||||
goarm: 6
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm/v6"
|
||||
docker_manifests:
|
||||
- name_template: "binwiederhier/ntfy:latest"
|
||||
image_templates:
|
||||
- *amd64_image
|
||||
- *arm64v8_image
|
||||
- *armv7_image
|
||||
- *armv6_image
|
||||
- name_template: "binwiederhier/ntfy:{{ .Tag }}"
|
||||
image_templates:
|
||||
- *amd64_image
|
||||
- *arm64v8_image
|
||||
- *armv7_image
|
||||
- *armv6_image
|
||||
|
||||
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
|
||||
|
||||
12
Dockerfile
@@ -1,5 +1,15 @@
|
||||
FROM alpine
|
||||
MAINTAINER Philipp C. Heckel <philipp.heckel@gmail.com>
|
||||
|
||||
LABEL org.opencontainers.image.authors="philipp.heckel@gmail.com"
|
||||
LABEL org.opencontainers.image.url="https://ntfy.sh/"
|
||||
LABEL org.opencontainers.image.documentation="https://docs.ntfy.sh/"
|
||||
LABEL org.opencontainers.image.source="https://github.com/binwiederhier/ntfy"
|
||||
LABEL org.opencontainers.image.vendor="Philipp C. Heckel"
|
||||
LABEL org.opencontainers.image.licenses="Apache-2.0, GPL-2.0"
|
||||
LABEL org.opencontainers.image.title="ntfy"
|
||||
LABEL org.opencontainers.image.description="Send push notifications to your phone or desktop using PUT/POST"
|
||||
|
||||
COPY ntfy /usr/bin
|
||||
|
||||
EXPOSE 80/tcp
|
||||
ENTRYPOINT ["ntfy"]
|
||||
|
||||
323
Makefile
@@ -1,62 +1,245 @@
|
||||
MAKEFLAGS := --jobs=1
|
||||
VERSION := $(shell git describe --tag)
|
||||
COMMIT := $(shell git rev-parse --short HEAD)
|
||||
|
||||
.PHONY:
|
||||
|
||||
help:
|
||||
@echo "Typical commands:"
|
||||
@echo " make check - Run all tests, vetting/formatting checks and linters"
|
||||
@echo " make fmt build-snapshot install - Build latest and install to local system"
|
||||
@echo "Typical commands (more see below):"
|
||||
@echo " make build - Build web app, documentation and server/client (sloowwww)"
|
||||
@echo " make cli-linux-amd64 - Build server/client binary (amd64, no web app or docs)"
|
||||
@echo " make install-linux-amd64 - Install ntfy binary to /usr/bin/ntfy (amd64)"
|
||||
@echo " make web - Build the web app"
|
||||
@echo " make docs - Build the documentation"
|
||||
@echo " make check - Run all tests, vetting/formatting checks and linters"
|
||||
@echo
|
||||
@echo "Build everything:"
|
||||
@echo " make build - Build web app, documentation and server/client"
|
||||
@echo " make clean - Clean build/dist folders"
|
||||
@echo
|
||||
@echo "Build server & client (using GoReleaser, not release version):"
|
||||
@echo " make cli - Build server & client (all architectures)"
|
||||
@echo " make cli-linux-amd64 - Build server & client (Linux, amd64 only)"
|
||||
@echo " make cli-linux-armv6 - Build server & client (Linux, armv6 only)"
|
||||
@echo " make cli-linux-armv7 - Build server & client (Linux, armv7 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 "Build web app:"
|
||||
@echo " make web - Build the web app"
|
||||
@echo " make web-deps - Install web app dependencies (npm install the universe)"
|
||||
@echo " make web-build - Actually build the web app"
|
||||
@echo
|
||||
@echo "Build documentation:"
|
||||
@echo " make docs - Build the documentation"
|
||||
@echo " make docs-deps - Install Python dependencies (pip3 install)"
|
||||
@echo " make docs-build - Actually build the documentation"
|
||||
@echo
|
||||
@echo "Test/check:"
|
||||
@echo " make test - Run tests"
|
||||
@echo " make race - Run tests with -race flag"
|
||||
@echo " make coverage - Run tests and show coverage"
|
||||
@echo " make coverage-html - Run tests and show coverage (as HTML)"
|
||||
@echo " make coverage-upload - Upload coverage results to codecov.io"
|
||||
@echo " make test - Run tests"
|
||||
@echo " make race - Run tests with -race flag"
|
||||
@echo " make coverage - Run tests and show coverage"
|
||||
@echo " make coverage-html - Run tests and show coverage (as HTML)"
|
||||
@echo " make coverage-upload - Upload coverage results to codecov.io"
|
||||
@echo
|
||||
@echo "Lint/format:"
|
||||
@echo " make fmt - Run 'go fmt'"
|
||||
@echo " make fmt-check - Run 'go fmt', but don't change anything"
|
||||
@echo " make vet - Run 'go vet'"
|
||||
@echo " make lint - Run 'golint'"
|
||||
@echo " make staticcheck - Run 'staticcheck'"
|
||||
@echo " make fmt - Run 'go fmt'"
|
||||
@echo " make fmt-check - Run 'go fmt', but don't change anything"
|
||||
@echo " make vet - Run 'go vet'"
|
||||
@echo " make lint - Run 'golint'"
|
||||
@echo " make staticcheck - Run 'staticcheck'"
|
||||
@echo
|
||||
@echo "Build:"
|
||||
@echo " make build - Build"
|
||||
@echo " make build-snapshot - Build snapshot"
|
||||
@echo " make build-simple - Build (using go build, without goreleaser)"
|
||||
@echo " make clean - Clean build folder"
|
||||
@echo
|
||||
@echo "Releasing (requires goreleaser):"
|
||||
@echo " make release - Create a release"
|
||||
@echo " make release-snapshot - Create a test release"
|
||||
@echo "Releasing:"
|
||||
@echo " make release - Create a release"
|
||||
@echo " make release-snapshot - Create a test release"
|
||||
@echo
|
||||
@echo "Install locally (requires sudo):"
|
||||
@echo " make install - Copy binary from dist/ to /usr/bin"
|
||||
@echo " make install-deb - Install .deb from dist/"
|
||||
@echo " make install-lint - Install golint"
|
||||
@echo " make install-linux-amd64 - Copy amd64 binary from dist/ to /usr/bin/ntfy"
|
||||
@echo " make install-linux-armv6 - Copy armv6 binary from dist/ to /usr/bin/ntfy"
|
||||
@echo " make install-linux-armv7 - Copy armv7 binary from dist/ to /usr/bin/ntfy"
|
||||
@echo " make install-linux-arm64 - Copy arm64 binary from dist/ to /usr/bin/ntfy"
|
||||
@echo " make install-linux-deb-amd64 - Install .deb from dist/ (amd64 only)"
|
||||
@echo " make install-linux-deb-armv6 - Install .deb from dist/ (armv6 only)"
|
||||
@echo " make install-linux-deb-armv7 - Install .deb from dist/ (armv7 only)"
|
||||
@echo " make install-linux-deb-arm64 - Install .deb from dist/ (arm64 only)"
|
||||
|
||||
|
||||
# Building everything
|
||||
|
||||
clean: .PHONY
|
||||
rm -rf dist build server/docs server/site
|
||||
|
||||
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 \
|
||||
jq
|
||||
which pip3 || sudo apt install -y python3-pip
|
||||
|
||||
# Documentation
|
||||
|
||||
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
|
||||
pip3 install -r requirements.txt
|
||||
|
||||
docs: docs-deps
|
||||
mkdocs build
|
||||
docs-deps-update: .PHONY
|
||||
pip3 install -r requirements.txt --upgrade
|
||||
|
||||
|
||||
# Web app
|
||||
|
||||
web: web-deps web-build
|
||||
|
||||
web-build:
|
||||
cd web \
|
||||
&& npm run build \
|
||||
&& mv build/index.html build/app.html \
|
||||
&& rm -rf ../server/site \
|
||||
&& mv build ../server/site \
|
||||
&& rm \
|
||||
../server/site/config.js \
|
||||
../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
|
||||
|
||||
cli: cli-deps
|
||||
goreleaser build --snapshot --clean
|
||||
|
||||
cli-linux-amd64: cli-deps-static-sites
|
||||
goreleaser build --snapshot --clean --id ntfy_linux_amd64
|
||||
|
||||
cli-linux-armv6: cli-deps-static-sites cli-deps-gcc-armv6-armv7
|
||||
goreleaser build --snapshot --clean --id ntfy_linux_armv6
|
||||
|
||||
cli-linux-armv7: cli-deps-static-sites cli-deps-gcc-armv6-armv7
|
||||
goreleaser build --snapshot --clean --id ntfy_linux_armv7
|
||||
|
||||
cli-linux-arm64: cli-deps-static-sites cli-deps-gcc-arm64
|
||||
goreleaser build --snapshot --clean --id ntfy_linux_arm64
|
||||
|
||||
cli-windows-amd64: cli-deps-static-sites
|
||||
goreleaser build --snapshot --clean --id ntfy_windows_amd64
|
||||
|
||||
cli-darwin-all: cli-deps-static-sites
|
||||
goreleaser build --snapshot --clean --id ntfy_darwin_all
|
||||
|
||||
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
|
||||
touch server/docs/index.html server/site/app.html
|
||||
|
||||
cli-deps-all:
|
||||
go install github.com/goreleaser/goreleaser@latest
|
||||
|
||||
cli-deps-gcc-armv6-armv7:
|
||||
which arm-linux-gnueabi-gcc || { echo "ERROR: ARMv6/ARMv7 cross compiler not installed. On Ubuntu, run: apt install gcc-arm-linux-gnueabi"; exit 1; }
|
||||
|
||||
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; }
|
||||
|
||||
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
|
||||
|
||||
check: test fmt-check vet lint staticcheck
|
||||
|
||||
test: .PHONY
|
||||
go test $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
|
||||
|
||||
testv: .PHONY
|
||||
go test -v $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
|
||||
|
||||
race: .PHONY
|
||||
go test -race $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
|
||||
go test -v -race $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
|
||||
|
||||
coverage:
|
||||
mkdir -p build/coverage
|
||||
go test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
|
||||
go test -v -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
|
||||
go tool cover -func build/coverage/coverage.txt
|
||||
|
||||
coverage-html:
|
||||
@@ -80,7 +263,7 @@ vet:
|
||||
go vet ./...
|
||||
|
||||
lint:
|
||||
which golint || go get -u golang.org/x/lint/golint
|
||||
which golint || go install golang.org/x/lint/golint@latest
|
||||
go list ./... | grep -v /vendor/ | xargs -L1 golint -set_exit_status
|
||||
|
||||
staticcheck: .PHONY
|
||||
@@ -92,55 +275,59 @@ staticcheck: .PHONY
|
||||
rm -rf build/staticcheck
|
||||
|
||||
|
||||
# Building targets
|
||||
|
||||
build-deps: docs
|
||||
which arm-linux-gnueabi-gcc || { echo "ERROR: ARMv6/v7 cross compiler not installed. On Ubuntu, run: apt install gcc-arm-linux-gnueabi"; 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; }
|
||||
|
||||
build: build-deps
|
||||
goreleaser build --rm-dist --debug
|
||||
|
||||
build-snapshot: build-deps
|
||||
goreleaser build --snapshot --rm-dist --debug
|
||||
|
||||
build-simple: clean
|
||||
mkdir -p dist/ntfy_linux_amd64 server/docs
|
||||
touch server/docs/dummy
|
||||
export CGO_ENABLED=1
|
||||
go build \
|
||||
-o dist/ntfy_linux_amd64/ntfy \
|
||||
-tags sqlite_omit_load_extension,osusergo,netgo \
|
||||
-ldflags \
|
||||
"-linkmode=external -extldflags=-static -s -w -X main.version=$(VERSION) -X main.commit=$(shell git rev-parse --short HEAD) -X main.date=$(shell date +%s)"
|
||||
|
||||
clean: .PHONY
|
||||
rm -rf dist build server/docs
|
||||
|
||||
|
||||
# Releasing targets
|
||||
|
||||
release-check-tags:
|
||||
release: clean cli-deps release-checks docs web check
|
||||
goreleaser release --clean
|
||||
|
||||
release-snapshot: clean cli-deps docs web check
|
||||
goreleaser release --snapshot --skip-publish --clean
|
||||
|
||||
release-checks:
|
||||
$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-))
|
||||
if ! grep -q $(LATEST_TAG) docs/install.md; then\
|
||||
echo "ERROR: Must update docs/install.md with latest tag first.";\
|
||||
exit 1;\
|
||||
fi
|
||||
|
||||
release: build-deps release-check-tags check
|
||||
goreleaser release --rm-dist --debug
|
||||
|
||||
release-snapshot: build-deps
|
||||
goreleaser release --snapshot --skip-publish --rm-dist --debug
|
||||
if ! grep -q $(LATEST_TAG) docs/releases.md; then\
|
||||
echo "ERROR: Must update docs/releases.md with latest tag first.";\
|
||||
exit 1;\
|
||||
fi
|
||||
if [ -n "$(shell git status -s)" ]; then\
|
||||
echo "ERROR: Git repository is in an unclean state.";\
|
||||
exit 1;\
|
||||
fi
|
||||
|
||||
|
||||
# Installing targets
|
||||
|
||||
install:
|
||||
sudo rm -f /usr/bin/ntfy
|
||||
sudo cp -a dist/ntfy_linux_amd64/ntfy /usr/bin/ntfy
|
||||
install-linux-amd64: remove-binary
|
||||
sudo cp -a dist/ntfy_linux_amd64_linux_amd64_v1/ntfy /usr/bin/ntfy
|
||||
|
||||
install-deb:
|
||||
install-linux-armv6: remove-binary
|
||||
sudo cp -a dist/ntfy_linux_armv6_linux_arm_6/ntfy /usr/bin/ntfy
|
||||
|
||||
install-linux-armv7: remove-binary
|
||||
sudo cp -a dist/ntfy_linux_armv7_linux_arm_7/ntfy /usr/bin/ntfy
|
||||
|
||||
install-linux-arm64: remove-binary
|
||||
sudo cp -a dist/ntfy_linux_arm64_linux_arm64/ntfy /usr/bin/ntfy
|
||||
|
||||
remove-binary:
|
||||
sudo rm -f /usr/bin/ntfy
|
||||
|
||||
install-linux-amd64-deb: purge-package
|
||||
sudo dpkg -i dist/ntfy_*_linux_amd64.deb
|
||||
|
||||
install-linux-armv6-deb: purge-package
|
||||
sudo dpkg -i dist/ntfy_*_linux_armv6.deb
|
||||
|
||||
install-linux-armv7-deb: purge-package
|
||||
sudo dpkg -i dist/ntfy_*_linux_armv7.deb
|
||||
|
||||
install-linux-arm64-deb: purge-package
|
||||
sudo dpkg -i dist/ntfy_*_linux_arm64.deb
|
||||
|
||||
purge-package:
|
||||
sudo systemctl stop ntfy || true
|
||||
sudo apt-get purge ntfy || true
|
||||
sudo dpkg -i dist/ntfy_*_linux_amd64.deb
|
||||
|
||||
151
README.md
@@ -1,4 +1,4 @@
|
||||

|
||||

|
||||
|
||||
# ntfy.sh | Send push notifications to your phone or desktop via PUT/POST
|
||||
[](https://github.com/binwiederhier/ntfy/releases/latest)
|
||||
@@ -8,21 +8,26 @@
|
||||
[](https://codecov.io/gh/binwiederhier/ntfy)
|
||||
[](https://discord.gg/cT7ECsZj9w)
|
||||
[](https://matrix.to/#/#ntfy:matrix.org)
|
||||
[](https://matrix.to/#/#ntfy-space:matrix.org)
|
||||
[](https://www.reddit.com/r/ntfy/)
|
||||
[](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.
|
||||
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.
|
||||
**ntfy** (pronounced "*notify*") is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern)
|
||||
notification service. With ntfy, you can **send notifications to your phone or desktop via scripts** from any computer,
|
||||
**without having to sign up or pay any fees**. If you'd like to run your own instance of the service, you can easily do
|
||||
so since ntfy is open source.
|
||||
|
||||
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)
|
||||
too.
|
||||
You can access the free version of ntfy at **[ntfy.sh](https://ntfy.sh)**. There is also an [open source Android app](https://github.com/binwiederhier/ntfy-android)
|
||||
available on [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/),
|
||||
as well as an [open source iOS app](https://github.com/binwiederhier/ntfy-ios) available on the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).
|
||||
|
||||
<p>
|
||||
<img src="server/static/img/screenshot-curl.png" height="180">
|
||||
<img src="server/static/img/screenshot-web-detail.png" height="180">
|
||||
<img src="server/static/img/screenshot-phone-main.jpg" height="180">
|
||||
<img src="server/static/img/screenshot-phone-detail.jpg" height="180">
|
||||
<img src="server/static/img/screenshot-phone-notification.jpg" height="180">
|
||||
<img src="web/public/static/img/screenshot-curl.png" height="180">
|
||||
<img src="web/public/static/img/screenshot-web-detail.png" height="180">
|
||||
<img src="web/public/static/img/screenshot-phone-main.jpg" height="180">
|
||||
<img src="web/public/static/img/screenshot-phone-detail.jpg" height="180">
|
||||
<img src="web/public/static/img/screenshot-phone-notification.jpg" height="180">
|
||||
</p>
|
||||
|
||||
## **[Documentation](https://ntfy.sh/docs/)**
|
||||
@@ -33,27 +38,131 @@ too.
|
||||
[Install / Self-hosting](https://ntfy.sh/docs/install/) |
|
||||
[Building](https://ntfy.sh/docs/develop/)
|
||||
|
||||
## Contributing
|
||||
I welcome any and all contributions. Just create a PR or an issue.
|
||||
## 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:
|
||||
|
||||
## Contact me
|
||||
You can directly contact me **[on Discord](https://discord.gg/cT7ECsZj9w)** or [on Matrix](https://matrix.to/#/#ntfy:matrix.org)
|
||||
(bridged from Discord), or via the [GitHub issues](https://github.com/binwiederhier/ntfy/issues), or find more contact information
|
||||
[on my website](https://heckel.io/about).
|
||||
* [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
|
||||
I welcome any and all contributions. Just create a PR or an issue. For larger features/ideas, please reach out
|
||||
on Discord/Matrix first to see if I'd accept them. To contribute code, check out the [build instructions](https://ntfy.sh/docs/develop/)
|
||||
for the server and the Android app. Or, if you'd like to help translate 🇩🇪 🇺🇸 🇧🇬, you can start immediately in
|
||||
[Hosted Weblate](https://hosted.weblate.org/projects/ntfy/).
|
||||
|
||||
<a href="https://hosted.weblate.org/engage/ntfy/">
|
||||
<img src="https://hosted.weblate.org/widgets/ntfy/-/multi-blue.svg" alt="Translation status" />
|
||||
</a>
|
||||
|
||||
## Sponsors
|
||||
I have just very recently started accepting donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier),
|
||||
and [Liberapay](https://liberapay.com/ntfy). I would be humbled if you helped me carry the server and developer
|
||||
account costs. Even small donations are very much appreciated. A big fat **Thank You** to the folks 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>
|
||||
<a href="https://github.com/Nickwasused"><img src="https://github.com/Nickwasused.png" width="40px" /></a>
|
||||
<a href="https://github.com/bahur142"><img src="https://github.com/bahur142.png" width="40px" /></a>
|
||||
<a href="https://github.com/vinhdizzo"><img src="https://github.com/vinhdizzo.png" width="40px" /></a>
|
||||
<a href="https://github.com/Ge0rg3"><img src="https://github.com/Ge0rg3.png" width="40px" /></a>
|
||||
<a href="https://github.com/biopsin"><img src="https://github.com/biopsin.png" width="40px" /></a>
|
||||
<a href="https://github.com/thebino"><img src="https://github.com/thebino.png" width="40px" /></a>
|
||||
<a href="https://github.com/sky4055"><img src="https://github.com/sky4055.png" width="40px" /></a>
|
||||
<a href="https://github.com/julianlam"><img src="https://github.com/julianlam.png" width="40px" /></a>
|
||||
<a href="https://github.com/andreapx"><img src="https://github.com/andreapx.png" width="40px" /></a>
|
||||
<a href="https://github.com/billycao"><img src="https://github.com/billycao.png" width="40px" /></a>
|
||||
<a href="https://github.com/zoic21"><img src="https://github.com/zoic21.png" width="40px" /></a>
|
||||
<a href="https://github.com/IanKulin"><img src="https://github.com/IanKulin.png" width="40px" /></a>
|
||||
<a href="https://github.com/Joachim256"><img src="https://github.com/Joachim256.png" width="40px" /></a>
|
||||
<a href="https://github.com/overtone1000"><img src="https://github.com/overtone1000.png" width="40px" /></a>
|
||||
<a href="https://github.com/oakd"><img src="https://github.com/oakd.png" width="40px" /></a>
|
||||
<a href="https://github.com/KucharczykL"><img src="https://github.com/KucharczykL.png" width="40px" /></a>
|
||||
<a href="https://github.com/hansbickhofe"><img src="https://github.com/hansbickhofe.png" width="40px" /></a>
|
||||
<a href="https://github.com/caseodilla"><img src="https://github.com/caseodilla.png" width="40px" /></a>
|
||||
<a href="https://github.com/0xAF"><img src="https://github.com/0xAF.png" width="40px" /></a>
|
||||
<a href="https://github.com/soonoo"><img src="https://github.com/soonoo.png" width="40px" /></a>
|
||||
<a href="https://github.com/nichu42"><img src="https://github.com/nichu42.png" width="40px" /></a>
|
||||
<a href="https://github.com/samliebow"><img src="https://github.com/samliebow.png" width="40px" /></a>
|
||||
<a href="https://github.com/johman10"><img src="https://github.com/johman10.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://m.do.co/c/442b929528db) (*referral link*) for supporting the project:
|
||||
|
||||
<a href="https://m.do.co/c/442b929528db"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="201px"></a>
|
||||
|
||||
## 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
|
||||
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).
|
||||
|
||||
Third party libraries and resources:
|
||||
* [github.com/urfave/cli/v2](https://github.com/urfave/cli/v2) (MIT) is used to drive the CLI
|
||||
* [Mixkit sound](https://mixkit.co/free-sound-effects/notification/) (Mixkit Free License) used as notification sound
|
||||
* [Lato Font](https://www.latofonts.com/) (OFL) is used as a font in the Web UI
|
||||
* [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
|
||||
* [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
|
||||
* [React](https://reactjs.org/) (MIT) is used for the web app
|
||||
* [Material UI components](https://mui.com/) (MIT) are used in the web app
|
||||
* [MUI dashboard template](https://github.com/mui/material-ui/tree/master/docs/data/material/getting-started/templates/dashboard) (MIT) was used as a basis for the web app
|
||||
* [Dexie.js](https://github.com/dexie/Dexie.js) (Apache 2.0) is used for web app persistence in IndexedDB
|
||||
* [GoReleaser](https://goreleaser.com/) (MIT) is used to create releases
|
||||
* [go-smtp](https://github.com/emersion/go-smtp) (MIT) is used to receive e-mails
|
||||
* [stretchr/testify](https://github.com/stretchr/testify) (MIT) is used for unit and integration tests
|
||||
* [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) (MIT) is used to provide the persistent message cache
|
||||
* [Firebase Admin SDK](https://github.com/firebase/firebase-admin-go) (Apache 2.0) is used to send FCM messages
|
||||
* [github/gemoji](https://github.com/github/gemoji) (MIT) is used for emoji support (specifically the [emoji.json](https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json) file)
|
||||
* [Lightbox with vanilla JS](https://yossiabramov.com/blog/vanilla-js-lightbox)
|
||||
* [Lightbox with vanilla JS](https://yossiabramov.com/blog/vanilla-js-lightbox) as a lightbox on the landing page
|
||||
* [HTTP middleware for gzip compression](https://gist.github.com/CJEnright/bc2d8b8dc0c1389a9feeddb110f822d7) (MIT) is used for serving static files
|
||||
* [Regex for auto-linking](https://github.com/bryanwoods/autolink-js) (MIT) is used to highlight links (the library is not used)
|
||||
* [Statically linking go-sqlite3](https://www.arp242.net/static-go.html)
|
||||
* [Linked tabs in mkdocs](https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs)
|
||||
|
||||
10
SECURITY.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
As of today, I only support the latest version of ntfy. Please make sure you stay up-to-date.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report severe security issues privately via ntfy@heckel.io, [Discord](https://discord.gg/cT7ECsZj9w),
|
||||
or [Matrix](https://matrix.to/#/#ntfy:matrix.org) (my username is `binwiederhier`).
|
||||
@@ -5,10 +5,11 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/log"
|
||||
"heckel.io/ntfy/util"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -17,9 +18,10 @@ import (
|
||||
|
||||
// Event type constants
|
||||
const (
|
||||
MessageEvent = "message"
|
||||
KeepaliveEvent = "keepalive"
|
||||
OpenEvent = "open"
|
||||
MessageEvent = "message"
|
||||
KeepaliveEvent = "keepalive"
|
||||
OpenEvent = "open"
|
||||
PollRequestEvent = "poll_request"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -36,14 +38,17 @@ type Client struct {
|
||||
|
||||
// Message is a struct that represents a ntfy message
|
||||
type Message struct { // TODO combine with server.message
|
||||
ID string
|
||||
Event string
|
||||
Time int64
|
||||
Topic string
|
||||
Message string
|
||||
Title string
|
||||
Priority int
|
||||
Tags []string
|
||||
ID string
|
||||
Event string
|
||||
Time int64
|
||||
Topic string
|
||||
Message string
|
||||
Title string
|
||||
Priority int
|
||||
Tags []string
|
||||
Click string
|
||||
Icon string
|
||||
Attachment *Attachment
|
||||
|
||||
// Additional fields
|
||||
TopicURL string
|
||||
@@ -51,6 +56,16 @@ type Message struct { // TODO combine with server.message
|
||||
Raw string
|
||||
}
|
||||
|
||||
// Attachment represents a message attachment
|
||||
type Attachment struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Expires int64 `json:"expires,omitempty"`
|
||||
URL string `json:"url"`
|
||||
Owner string `json:"-"` // IP address of uploader, used for rate limiting
|
||||
}
|
||||
|
||||
type subscription struct {
|
||||
ID string
|
||||
topicURL string
|
||||
@@ -88,18 +103,19 @@ func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishO
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
log.Debug("%s Publishing message with headers %s", util.ShortTopicURL(topicURL), req.Header)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected response %d from server", resp.StatusCode)
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errors.New(strings.TrimSpace(string(b)))
|
||||
}
|
||||
m, err := toMessage(string(b), topicURL, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -122,6 +138,7 @@ func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, err
|
||||
msgChan := make(chan *Message)
|
||||
errChan := make(chan error)
|
||||
topicURL := c.expandTopicURL(topic)
|
||||
log.Debug("%s Polling from topic", util.ShortTopicURL(topicURL))
|
||||
options = append(options, WithPoll())
|
||||
go func() {
|
||||
err := performSubscribeRequest(ctx, msgChan, topicURL, "", options...)
|
||||
@@ -147,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.
|
||||
//
|
||||
// Example:
|
||||
// c := client.New(client.NewConfig())
|
||||
// subscriptionID := c.Subscribe("mytopic")
|
||||
// for m := range c.Messages {
|
||||
// fmt.Printf("New message: %s", m.Message)
|
||||
// }
|
||||
//
|
||||
// c := client.New(client.NewConfig())
|
||||
// subscriptionID := c.Subscribe("mytopic")
|
||||
// for m := range c.Messages {
|
||||
// fmt.Printf("New message: %s", m.Message)
|
||||
// }
|
||||
func (c *Client) Subscribe(topic string, options ...SubscribeOption) string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
subscriptionID := util.RandomString(10)
|
||||
topicURL := c.expandTopicURL(topic)
|
||||
log.Debug("%s Subscribing to topic", util.ShortTopicURL(topicURL))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
c.subscriptions[subscriptionID] = &subscription{
|
||||
ID: subscriptionID,
|
||||
@@ -212,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
|
||||
// Android client, use since=, and do incremental backoff too
|
||||
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 {
|
||||
case <-ctx.Done():
|
||||
log.Printf("Connection to %s exited", topicURL)
|
||||
log.Info("%s Connection exited", util.ShortTopicURL(topicURL))
|
||||
return
|
||||
case <-time.After(10 * time.Second): // TODO Add incremental backoff
|
||||
}
|
||||
@@ -224,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 {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -238,12 +259,21 @@ func performSubscribeRequest(ctx context.Context, msgChan chan *Message, topicUR
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
for scanner.Scan() {
|
||||
m, err := toMessage(scanner.Text(), topicURL, subscriptionID)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New(strings.TrimSpace(string(b)))
|
||||
}
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
for scanner.Scan() {
|
||||
messageJSON := scanner.Text()
|
||||
m, err := toMessage(messageJSON, topicURL, subscriptionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Trace("%s Message received: %s", util.ShortTopicURL(topicURL), messageJSON)
|
||||
if m.Event == MessageEvent {
|
||||
msgChan <- m
|
||||
}
|
||||
|
||||
@@ -5,6 +5,18 @@
|
||||
#
|
||||
# default-host: https://ntfy.sh
|
||||
|
||||
# Default credentials will be used with "ntfy publish" and "ntfy subscribe" if no other credentials are provided.
|
||||
# You can set a default token to use or a default user:password combination, but not both. For an empty password,
|
||||
# use empty double-quotes ("")
|
||||
|
||||
# default-token:
|
||||
|
||||
# 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,
|
||||
# or if you cann "ntfy subscribe --from-config" directly.
|
||||
#
|
||||
@@ -16,6 +28,12 @@
|
||||
# command: 'echo "$message"'
|
||||
# if:
|
||||
# priority: high,urgent
|
||||
# - topic: secret
|
||||
# command: 'notify-send "$m"'
|
||||
# user: phill
|
||||
# password: mypass
|
||||
# - topic: token_topic
|
||||
# token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
||||
#
|
||||
# Variables:
|
||||
# Variable Aliases Description
|
||||
|
||||
@@ -4,11 +4,18 @@ import (
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/client"
|
||||
"heckel.io/ntfy/log"
|
||||
"heckel.io/ntfy/test"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
log.SetLevel(log.ErrorLevel)
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestClient_Publish_Subscribe(t *testing.T) {
|
||||
s, port := test.StartServer(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
@@ -12,19 +12,33 @@ const (
|
||||
|
||||
// Config is the config struct for a Client
|
||||
type Config struct {
|
||||
DefaultHost string `yaml:"default-host"`
|
||||
Subscribe []struct {
|
||||
Topic string `yaml:"topic"`
|
||||
Command string `yaml:"command"`
|
||||
If map[string]string `yaml:"if"`
|
||||
} `yaml:"subscribe"`
|
||||
DefaultHost string `yaml:"default-host"`
|
||||
DefaultUser string `yaml:"default-user"`
|
||||
DefaultPassword *string `yaml:"default-password"`
|
||||
DefaultToken string `yaml:"default-token"`
|
||||
DefaultCommand string `yaml:"default-command"`
|
||||
Subscribe []Subscribe `yaml:"subscribe"`
|
||||
}
|
||||
|
||||
// Subscribe is the struct for a Subscription within Config
|
||||
type Subscribe struct {
|
||||
Topic string `yaml:"topic"`
|
||||
User string `yaml:"user"`
|
||||
Password *string `yaml:"password"`
|
||||
Token string `yaml:"token"`
|
||||
Command string `yaml:"command"`
|
||||
If map[string]string `yaml:"if"`
|
||||
}
|
||||
|
||||
// NewConfig creates a new Config struct for a Client
|
||||
func NewConfig() *Config {
|
||||
return &Config{
|
||||
DefaultHost: DefaultBaseURL,
|
||||
Subscribe: nil,
|
||||
DefaultHost: DefaultBaseURL,
|
||||
DefaultUser: "",
|
||||
DefaultPassword: nil,
|
||||
DefaultToken: "",
|
||||
DefaultCommand: "",
|
||||
Subscribe: nil,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,25 +12,129 @@ func TestConfig_Load(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: mypass
|
||||
default-command: 'echo "Got the message: $message"'
|
||||
subscribe:
|
||||
- topic: no-command
|
||||
- topic: no-command-with-auth
|
||||
user: phil
|
||||
password: mypass
|
||||
- topic: echo-this
|
||||
command: 'echo "Message received: $message"'
|
||||
- topic: alerts
|
||||
command: notify-send -i /usr/share/ntfy/logo.png "Important" "$m"
|
||||
if:
|
||||
priority: high,urgent
|
||||
- topic: defaults
|
||||
`), 0600))
|
||||
|
||||
conf, err := client.LoadConfig(filename)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "http://localhost", conf.DefaultHost)
|
||||
require.Equal(t, 3, len(conf.Subscribe))
|
||||
require.Equal(t, "no-command", conf.Subscribe[0].Topic)
|
||||
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, "", conf.Subscribe[0].Command)
|
||||
require.Equal(t, "phil", conf.Subscribe[0].User)
|
||||
require.Equal(t, "mypass", *conf.Subscribe[0].Password)
|
||||
require.Equal(t, "echo-this", conf.Subscribe[1].Topic)
|
||||
require.Equal(t, `echo "Message received: $message"`, conf.Subscribe[1].Command)
|
||||
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, "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)
|
||||
}
|
||||
|
||||
func TestConfig_DefaultToken(t *testing.T) {
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(`
|
||||
default-host: http://localhost
|
||||
default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
||||
subscribe:
|
||||
- topic: mytopic
|
||||
`), 0600))
|
||||
|
||||
conf, err := client.LoadConfig(filename)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "http://localhost", conf.DefaultHost)
|
||||
require.Equal(t, "", conf.DefaultUser)
|
||||
require.Nil(t, conf.DefaultPassword)
|
||||
require.Equal(t, "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", conf.DefaultToken)
|
||||
require.Equal(t, 1, len(conf.Subscribe))
|
||||
require.Equal(t, "mytopic", conf.Subscribe[0].Topic)
|
||||
require.Equal(t, "", conf.Subscribe[0].User)
|
||||
require.Nil(t, conf.Subscribe[0].Password)
|
||||
require.Equal(t, "", conf.Subscribe[0].Token)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"heckel.io/ntfy/util"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -55,6 +56,17 @@ func WithClick(url string) PublishOption {
|
||||
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
|
||||
// simple format definition. See https://ntfy.sh/docs/publish/#action-buttons for details.
|
||||
func WithActions(value string) PublishOption {
|
||||
return WithHeader("X-Actions", value)
|
||||
}
|
||||
|
||||
// WithAttach sets a URL that will be used by the client to download an attachment
|
||||
func WithAttach(attach string) PublishOption {
|
||||
return WithHeader("X-Attach", attach)
|
||||
@@ -70,6 +82,16 @@ func WithEmail(email string) PublishOption {
|
||||
return WithHeader("X-Email", email)
|
||||
}
|
||||
|
||||
// WithBasicAuth adds the Authorization header for basic auth to the request
|
||||
func WithBasicAuth(user, pass string) PublishOption {
|
||||
return WithHeader("Authorization", util.BasicAuth(user, pass))
|
||||
}
|
||||
|
||||
// WithBearerAuth adds the Authorization header for Bearer auth to the request
|
||||
func WithBearerAuth(token string) PublishOption {
|
||||
return WithHeader("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||
}
|
||||
|
||||
// WithNoCache instructs the server not to cache the message server-side
|
||||
func WithNoCache() PublishOption {
|
||||
return WithHeader("X-Cache", "no")
|
||||
|
||||
228
cmd/access.go
Normal file
@@ -0,0 +1,228 @@
|
||||
//go:build !noserver
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/user"
|
||||
"heckel.io/ntfy/util"
|
||||
)
|
||||
|
||||
func init() {
|
||||
commands = append(commands, cmdAccess)
|
||||
}
|
||||
|
||||
const (
|
||||
userEveryone = "everyone"
|
||||
)
|
||||
|
||||
var flagsAccess = append(
|
||||
append([]cli.Flag{}, flagsUser...),
|
||||
&cli.BoolFlag{Name: "reset", Aliases: []string{"r"}, Usage: "reset access for user (and topic)"},
|
||||
)
|
||||
|
||||
var cmdAccess = &cli.Command{
|
||||
Name: "access",
|
||||
Usage: "Grant/revoke access to a topic, or show access",
|
||||
UsageText: "ntfy access [USERNAME [TOPIC [PERMISSION]]]",
|
||||
Flags: flagsAccess,
|
||||
Before: initConfigFileInputSourceFunc("config", flagsAccess, initLogFunc),
|
||||
Action: execUserAccess,
|
||||
Category: categoryServer,
|
||||
Description: `Manage the access control list for the ntfy server.
|
||||
|
||||
This is a server-only command. It directly manages the user.db as defined in the server config
|
||||
file server.yml. The command only works if 'auth-file' is properly defined. Please also refer
|
||||
to the related command 'ntfy user'.
|
||||
|
||||
The command allows you to show the access control list, as well as change it, depending on how
|
||||
it is called.
|
||||
|
||||
Usage:
|
||||
ntfy access # Shows access control list (alias: 'ntfy user list')
|
||||
ntfy access USERNAME # Shows access control entries for USERNAME
|
||||
ntfy access USERNAME TOPIC PERMISSION # Allow/deny access for USERNAME to TOPIC
|
||||
|
||||
Arguments:
|
||||
USERNAME an existing user, as created with 'ntfy user add', or "everyone"/"*"
|
||||
to define access rules for anonymous/unauthenticated clients
|
||||
TOPIC name of a topic with optional wildcards, e.g. "mytopic*"
|
||||
PERMISSION one of the following:
|
||||
- read-write (alias: rw)
|
||||
- read-only (aliases: read, ro)
|
||||
- write-only (aliases: write, wo)
|
||||
- deny (alias: none)
|
||||
|
||||
Examples:
|
||||
ntfy access # Shows access control list (alias: 'ntfy user list')
|
||||
ntfy access phil # Shows access for user phil
|
||||
ntfy access phil mytopic rw # Allow read-write access to mytopic for user phil
|
||||
ntfy access everyone mytopic rw # Allow anonymous read-write access to mytopic
|
||||
ntfy access everyone "up*" write # Allow anonymous write-only access to topics "up..."
|
||||
ntfy access --reset # Reset entire access control list
|
||||
ntfy access --reset phil # Reset all access for user phil
|
||||
ntfy access --reset phil mytopic # Reset access for user phil and topic mytopic
|
||||
`,
|
||||
}
|
||||
|
||||
func execUserAccess(c *cli.Context) error {
|
||||
if c.NArg() > 3 {
|
||||
return errors.New("too many arguments, please check 'ntfy access --help' for usage details")
|
||||
}
|
||||
manager, err := createUserManager(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
username := c.Args().Get(0)
|
||||
if username == userEveryone {
|
||||
username = user.Everyone
|
||||
}
|
||||
topic := c.Args().Get(1)
|
||||
perms := c.Args().Get(2)
|
||||
reset := c.Bool("reset")
|
||||
if reset {
|
||||
if perms != "" {
|
||||
return errors.New("too many arguments, please check 'ntfy access --help' for usage details")
|
||||
}
|
||||
return resetAccess(c, manager, username, topic)
|
||||
} else if perms == "" {
|
||||
if topic != "" {
|
||||
return errors.New("invalid syntax, please check 'ntfy access --help' for usage details")
|
||||
}
|
||||
return showAccess(c, manager, username)
|
||||
}
|
||||
return changeAccess(c, manager, username, topic, perms)
|
||||
}
|
||||
|
||||
func changeAccess(c *cli.Context, manager *user.Manager, username string, topic string, perms string) error {
|
||||
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)")
|
||||
}
|
||||
permission, err := user.ParsePermission(perms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u, err := manager.User(username)
|
||||
if err == user.ErrUserNotFound {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
} else if u.Role == user.RoleAdmin {
|
||||
return fmt.Errorf("user %s is an admin user, access control entries have no effect", username)
|
||||
}
|
||||
if err := manager.AllowAccess(username, topic, permission); err != nil {
|
||||
return err
|
||||
}
|
||||
if permission.IsReadWrite() {
|
||||
fmt.Fprintf(c.App.ErrWriter, "granted read-write access to topic %s\n\n", topic)
|
||||
} else if permission.IsRead() {
|
||||
fmt.Fprintf(c.App.ErrWriter, "granted read-only access to topic %s\n\n", topic)
|
||||
} else if permission.IsWrite() {
|
||||
fmt.Fprintf(c.App.ErrWriter, "granted write-only access to topic %s\n\n", topic)
|
||||
} else {
|
||||
fmt.Fprintf(c.App.ErrWriter, "revoked all access to topic %s\n\n", topic)
|
||||
}
|
||||
return showUserAccess(c, manager, username)
|
||||
}
|
||||
|
||||
func resetAccess(c *cli.Context, manager *user.Manager, username, topic string) error {
|
||||
if username == "" {
|
||||
return resetAllAccess(c, manager)
|
||||
} else if topic == "" {
|
||||
return resetUserAccess(c, manager, username)
|
||||
}
|
||||
return resetUserTopicAccess(c, manager, username, topic)
|
||||
}
|
||||
|
||||
func resetAllAccess(c *cli.Context, manager *user.Manager) error {
|
||||
if err := manager.ResetAccess("", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(c.App.ErrWriter, "reset access for all users")
|
||||
return nil
|
||||
}
|
||||
|
||||
func resetUserAccess(c *cli.Context, manager *user.Manager, username string) error {
|
||||
if err := manager.ResetAccess(username, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "reset access for user %s\n\n", username)
|
||||
return showUserAccess(c, manager, username)
|
||||
}
|
||||
|
||||
func resetUserTopicAccess(c *cli.Context, manager *user.Manager, username string, topic string) error {
|
||||
if err := manager.ResetAccess(username, topic); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "reset access for user %s and topic %s\n\n", username, topic)
|
||||
return showUserAccess(c, manager, username)
|
||||
}
|
||||
|
||||
func showAccess(c *cli.Context, manager *user.Manager, username string) error {
|
||||
if username == "" {
|
||||
return showAllAccess(c, manager)
|
||||
}
|
||||
return showUserAccess(c, manager, username)
|
||||
}
|
||||
|
||||
func showAllAccess(c *cli.Context, manager *user.Manager) error {
|
||||
users, err := manager.Users()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return showUsers(c, manager, users)
|
||||
}
|
||||
|
||||
func showUserAccess(c *cli.Context, manager *user.Manager, username string) error {
|
||||
users, err := manager.User(username)
|
||||
if err == user.ErrUserNotFound {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
return showUsers(c, manager, []*user.User{users})
|
||||
}
|
||||
|
||||
func showUsers(c *cli.Context, manager *user.Manager, users []*user.User) error {
|
||||
for _, u := range users {
|
||||
grants, err := manager.Grants(u.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tier := "none"
|
||||
if u.Tier != nil {
|
||||
tier = u.Tier.Name
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "user %s (role: %s, tier: %s)\n", u.Name, u.Role, tier)
|
||||
if u.Role == user.RoleAdmin {
|
||||
fmt.Fprintf(c.App.ErrWriter, "- read-write access to all topics (admin role)\n")
|
||||
} else if len(grants) > 0 {
|
||||
for _, grant := range grants {
|
||||
if grant.Allow.IsReadWrite() {
|
||||
fmt.Fprintf(c.App.ErrWriter, "- read-write access to topic %s\n", grant.TopicPattern)
|
||||
} else if grant.Allow.IsRead() {
|
||||
fmt.Fprintf(c.App.ErrWriter, "- read-only access to topic %s\n", grant.TopicPattern)
|
||||
} else if grant.Allow.IsWrite() {
|
||||
fmt.Fprintf(c.App.ErrWriter, "- write-only access to topic %s\n", grant.TopicPattern)
|
||||
} else {
|
||||
fmt.Fprintf(c.App.ErrWriter, "- no access to topic %s\n", grant.TopicPattern)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(c.App.ErrWriter, "- no topic-specific permissions\n")
|
||||
}
|
||||
if u.Name == user.Everyone {
|
||||
access := manager.DefaultAccess()
|
||||
if access.IsReadWrite() {
|
||||
fmt.Fprintln(c.App.ErrWriter, "- read-write access to all (other) topics (server config)")
|
||||
} else if access.IsRead() {
|
||||
fmt.Fprintln(c.App.ErrWriter, "- read-only access to all (other) topics (server config)")
|
||||
} else if access.IsWrite() {
|
||||
fmt.Fprintln(c.App.ErrWriter, "- write-only access to all (other) topics (server config)")
|
||||
} else {
|
||||
fmt.Fprintln(c.App.ErrWriter, "- no access to any (other) topics (server config)")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
89
cmd/access_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/server"
|
||||
"heckel.io/ntfy/test"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCLI_Access_Show(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, _, _, stderr := newTestApp()
|
||||
require.Nil(t, runAccessCommand(app, conf))
|
||||
require.Contains(t, stderr.String(), "user * (role: anonymous, tier: none)\n- no topic-specific permissions\n- no access to any (other) topics (server config)")
|
||||
}
|
||||
|
||||
func TestCLI_Access_Grant_And_Publish(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, _ := newTestApp()
|
||||
stdin.WriteString("philpass\nphilpass\nbenpass\nbenpass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "--role=admin", "phil"))
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "ben"))
|
||||
require.Nil(t, runAccessCommand(app, conf, "ben", "announcements", "rw"))
|
||||
require.Nil(t, runAccessCommand(app, conf, "ben", "sometopic", "read"))
|
||||
require.Nil(t, runAccessCommand(app, conf, "everyone", "announcements", "read"))
|
||||
|
||||
app, _, _, stderr := newTestApp()
|
||||
require.Nil(t, runAccessCommand(app, conf))
|
||||
expected := `user phil (role: admin, tier: none)
|
||||
- read-write access to all topics (admin role)
|
||||
user ben (role: user, tier: none)
|
||||
- read-write access to topic announcements
|
||||
- read-only access to topic sometopic
|
||||
user * (role: anonymous, tier: none)
|
||||
- read-only access to topic announcements
|
||||
- no access to any (other) topics (server config)
|
||||
`
|
||||
require.Equal(t, expected, stderr.String())
|
||||
|
||||
// See if access permissions match
|
||||
app, _, _, _ = newTestApp()
|
||||
require.Error(t, app.Run([]string{
|
||||
"ntfy",
|
||||
"publish",
|
||||
fmt.Sprintf("http://127.0.0.1:%d/announcements", port),
|
||||
}))
|
||||
require.Nil(t, app.Run([]string{
|
||||
"ntfy",
|
||||
"publish",
|
||||
"-u", "ben:benpass",
|
||||
fmt.Sprintf("http://127.0.0.1:%d/announcements", port),
|
||||
}))
|
||||
require.Nil(t, app.Run([]string{
|
||||
"ntfy",
|
||||
"publish",
|
||||
"-u", "phil:philpass",
|
||||
fmt.Sprintf("http://127.0.0.1:%d/announcements", port),
|
||||
}))
|
||||
require.Nil(t, app.Run([]string{
|
||||
"ntfy",
|
||||
"subscribe",
|
||||
"--poll",
|
||||
fmt.Sprintf("http://127.0.0.1:%d/announcements", port),
|
||||
}))
|
||||
require.Error(t, app.Run([]string{
|
||||
"ntfy",
|
||||
"subscribe",
|
||||
"--poll",
|
||||
fmt.Sprintf("http://127.0.0.1:%d/something-else", port),
|
||||
}))
|
||||
}
|
||||
|
||||
func runAccessCommand(app *cli.App, conf *server.Config, args ...string) error {
|
||||
userArgs := []string{
|
||||
"ntfy",
|
||||
"--log-level=ERROR",
|
||||
"access",
|
||||
"--config=" + conf.File, // Dummy config file to avoid lookups of real file
|
||||
"--auth-file=" + conf.AuthFile,
|
||||
"--auth-default-access=" + conf.AuthDefault.String(),
|
||||
}
|
||||
return app.Run(append(userArgs, args...))
|
||||
}
|
||||
87
cmd/app.go
@@ -5,13 +5,30 @@ import (
|
||||
"fmt"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
"heckel.io/ntfy/util"
|
||||
"heckel.io/ntfy/log"
|
||||
"os"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
const (
|
||||
categoryClient = "Client 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"}),
|
||||
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "log-level-overrides", Aliases: []string{"log_level_overrides"}, EnvVars: []string{"NTFY_LOG_LEVEL_OVERRIDES"}, Usage: "set log level overrides"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "log-format", Aliases: []string{"log_format"}, Value: log.TextFormat.String(), EnvVars: []string{"NTFY_LOG_FORMAT"}, Usage: "set log format"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "log-file", Aliases: []string{"log_file"}, EnvVars: []string{"NTFY_LOG_FILE"}, Usage: "set log file, default is STDOUT"}),
|
||||
}
|
||||
|
||||
var (
|
||||
defaultClientRootConfigFile = "/etc/ntfy/client.yml"
|
||||
defaultClientUserConfigFile = "~/.config/ntfy/client.yml"
|
||||
logLevelOverrideRegex = regexp.MustCompile(`(?i)^([^=\s]+)(?:\s*=\s*(\S+))?\s*->\s*(TRACE|DEBUG|INFO|WARN|ERROR)$`)
|
||||
)
|
||||
|
||||
// New creates a new CLI application
|
||||
@@ -25,37 +42,49 @@ func New() *cli.App {
|
||||
Reader: os.Stdin,
|
||||
Writer: os.Stdout,
|
||||
ErrWriter: os.Stderr,
|
||||
Action: execMainApp,
|
||||
Before: initConfigFileInputSource("config", flagsServe), // DEPRECATED, see deprecation notice
|
||||
Flags: flagsServe, // DEPRECATED, see deprecation notice
|
||||
Commands: []*cli.Command{
|
||||
cmdServe,
|
||||
cmdPublish,
|
||||
cmdSubscribe,
|
||||
},
|
||||
Commands: commands,
|
||||
Flags: flagsDefault,
|
||||
Before: initLogFunc,
|
||||
}
|
||||
}
|
||||
|
||||
func execMainApp(c *cli.Context) error {
|
||||
fmt.Fprintln(c.App.ErrWriter, "\x1b[1;33mDeprecation notice: Please run the server using 'ntfy serve'; see 'ntfy -h' for help.\x1b[0m")
|
||||
fmt.Fprintln(c.App.ErrWriter, "\x1b[1;33mThis way of running the server will be removed March 2022. See https://ntfy.sh/docs/deprecations/ for details.\x1b[0m")
|
||||
return execServe(c)
|
||||
}
|
||||
|
||||
// initConfigFileInputSource 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 initConfigFileInputSource(configFlag string, flags []cli.Flag) 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 := altsrc.NewYamlSourceFromFile(configFile)
|
||||
func initLogFunc(c *cli.Context) error {
|
||||
log.SetLevel(log.ToLevel(c.String("log-level")))
|
||||
log.SetFormat(log.ToFormat(c.String("log-format")))
|
||||
if c.Bool("trace") {
|
||||
log.SetLevel(log.TraceLevel)
|
||||
} else if c.Bool("debug") {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
}
|
||||
if c.Bool("no-log-dates") {
|
||||
log.DisableDates()
|
||||
}
|
||||
if err := applyLogLevelOverrides(c.StringSlice("log-level-overrides")); err != nil {
|
||||
return err
|
||||
}
|
||||
logFile := c.String("log-file")
|
||||
if logFile != "" {
|
||||
w, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return altsrc.ApplyInputSourceValues(context, inputSource, flags)
|
||||
log.SetOutput(w)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyLogLevelOverrides(rawOverrides []string) error {
|
||||
for _, override := range rawOverrides {
|
||||
m := logLevelOverrideRegex.FindStringSubmatch(override)
|
||||
if len(m) == 4 {
|
||||
field, value, level := m[1], m[2], m[3]
|
||||
log.SetLevelOverride(field, value, log.ToLevel(level))
|
||||
} else if len(m) == 3 {
|
||||
field, level := m[1], m[2]
|
||||
log.SetLevelOverride(field, "", log.ToLevel(level)) // Matches any value
|
||||
} else {
|
||||
return fmt.Errorf(`invalid log level override "%s", must be "field=value -> loglevel", e.g. "user_id=u_123 -> DEBUG"`, override)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/client"
|
||||
"io"
|
||||
"log"
|
||||
"heckel.io/ntfy/log"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -15,7 +14,7 @@ import (
|
||||
// This only contains helpers so far
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
log.SetOutput(io.Discard)
|
||||
log.SetLevel(log.ErrorLevel)
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
}
|
||||
224
cmd/publish.go
@@ -5,33 +5,55 @@ import (
|
||||
"fmt"
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/client"
|
||||
"heckel.io/ntfy/log"
|
||||
"heckel.io/ntfy/util"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
commands = append(commands, cmdPublish)
|
||||
}
|
||||
|
||||
var flagsPublish = append(
|
||||
append([]cli.Flag{}, 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.StringFlag{Name: "token", Aliases: []string{"k"}, EnvVars: []string{"NTFY_TOKEN"}, Usage: "access token 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: "quiet", Aliases: []string{"q"}, EnvVars: []string{"NTFY_QUIET"}, Usage: "do not print message"},
|
||||
)
|
||||
|
||||
var cmdPublish = &cli.Command{
|
||||
Name: "publish",
|
||||
Aliases: []string{"pub", "send", "trigger"},
|
||||
Usage: "Send message via a ntfy server",
|
||||
UsageText: "ntfy send [OPTIONS..] TOPIC [MESSAGE]",
|
||||
Action: execPublish,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
|
||||
&cli.StringFlag{Name: "title", Aliases: []string{"t"}, Usage: "message title"},
|
||||
&cli.StringFlag{Name: "priority", Aliases: []string{"p"}, Usage: "priority of the message (1=min, 2=low, 3=default, 4=high, 5=max)"},
|
||||
&cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, Usage: "comma separated list of tags and emojis"},
|
||||
&cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, Usage: "delay/schedule message"},
|
||||
&cli.StringFlag{Name: "click", Aliases: []string{"U"}, Usage: "URL to open when notification is clicked"},
|
||||
&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, Usage: "URL to send as an external attachment"},
|
||||
&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, Usage: "Filename for the attachment"},
|
||||
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, Usage: "File to upload as an attachment"},
|
||||
&cli.StringFlag{Name: "email", Aliases: []string{"e-mail", "mail", "e"}, Usage: "also send to e-mail address"},
|
||||
&cli.BoolFlag{Name: "no-cache", Aliases: []string{"C"}, Usage: "do not cache message server-side"},
|
||||
&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"F"}, Usage: "do not forward message to Firebase"},
|
||||
&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, Usage: "do print message"},
|
||||
},
|
||||
Name: "publish",
|
||||
Aliases: []string{"pub", "send", "trigger"},
|
||||
Usage: "Send message via a ntfy server",
|
||||
UsageText: `ntfy publish [OPTIONS..] TOPIC [MESSAGE...]
|
||||
ntfy publish [OPTIONS..] --wait-cmd COMMAND...
|
||||
NTFY_TOPIC=.. ntfy publish [OPTIONS..] [MESSAGE...]`,
|
||||
Action: execPublish,
|
||||
Category: categoryClient,
|
||||
Flags: flagsPublish,
|
||||
Before: initLogFunc,
|
||||
Description: `Publish a message to a ntfy server.
|
||||
|
||||
Examples:
|
||||
@@ -43,22 +65,24 @@ Examples:
|
||||
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 --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 --file=flower.jpg flowers 'Nice!' # Send image.jpg as attachment
|
||||
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_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
|
||||
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,
|
||||
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),
|
||||
or ~/.config/ntfy/client.yml for all other users.`,
|
||||
` + clientCommandDescriptionSuffix,
|
||||
}
|
||||
|
||||
func execPublish(c *cli.Context) error {
|
||||
if c.NArg() < 1 {
|
||||
return errors.New("must specify topic, type 'ntfy publish --help' for help")
|
||||
}
|
||||
conf, err := loadConfig(c)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -68,17 +92,28 @@ func execPublish(c *cli.Context) error {
|
||||
tags := c.String("tags")
|
||||
delay := c.String("delay")
|
||||
click := c.String("click")
|
||||
icon := c.String("icon")
|
||||
actions := c.String("actions")
|
||||
attach := c.String("attach")
|
||||
filename := c.String("filename")
|
||||
file := c.String("file")
|
||||
email := c.String("email")
|
||||
user := c.String("user")
|
||||
token := c.String("token")
|
||||
noCache := c.Bool("no-cache")
|
||||
noFirebase := c.Bool("no-firebase")
|
||||
quiet := c.Bool("quiet")
|
||||
topic := c.Args().Get(0)
|
||||
message := ""
|
||||
if c.NArg() > 1 {
|
||||
message = strings.Join(c.Args().Slice()[1:], " ")
|
||||
pid := c.Int("wait-pid")
|
||||
|
||||
// Checks
|
||||
if user != "" && token != "" {
|
||||
return errors.New("cannot set both --user and --token")
|
||||
}
|
||||
|
||||
// Do the things
|
||||
topic, message, command, err := parseTopicMessageCommand(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var options []client.PublishOption
|
||||
if title != "" {
|
||||
@@ -96,6 +131,12 @@ func execPublish(c *cli.Context) error {
|
||||
if click != "" {
|
||||
options = append(options, client.WithClick(click))
|
||||
}
|
||||
if icon != "" {
|
||||
options = append(options, client.WithIcon(icon))
|
||||
}
|
||||
if actions != "" {
|
||||
options = append(options, client.WithActions(strings.ReplaceAll(actions, "\n", " ")))
|
||||
}
|
||||
if attach != "" {
|
||||
options = append(options, client.WithAttach(attach))
|
||||
}
|
||||
@@ -111,6 +152,44 @@ func execPublish(c *cli.Context) error {
|
||||
if noFirebase {
|
||||
options = append(options, client.WithNoFirebase())
|
||||
}
|
||||
if token != "" {
|
||||
options = append(options, client.WithBearerAuth(token))
|
||||
} else if user != "" {
|
||||
var pass string
|
||||
parts := strings.SplitN(user, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
user = parts[0]
|
||||
pass = parts[1]
|
||||
} else {
|
||||
fmt.Fprint(c.App.ErrWriter, "Enter Password: ")
|
||||
p, err := util.ReadPassword(c.App.Reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pass = string(p)
|
||||
fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20))
|
||||
}
|
||||
options = append(options, client.WithBasicAuth(user, pass))
|
||||
} else if conf.DefaultToken != "" {
|
||||
options = append(options, client.WithBearerAuth(conf.DefaultToken))
|
||||
} 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
|
||||
if file == "" {
|
||||
body = strings.NewReader(message)
|
||||
@@ -143,3 +222,88 @@ func execPublish(c *cli.Context) error {
|
||||
}
|
||||
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,18 +5,33 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/test"
|
||||
"heckel.io/ntfy/util"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) {
|
||||
testMessage := util.RandomString(10)
|
||||
|
||||
app, _, _, _ := newTestApp()
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "ntfytest", "ntfy unit test " + testMessage}))
|
||||
|
||||
app2, _, stdout, _ := newTestApp()
|
||||
require.Nil(t, app2.Run([]string{"ntfy", "subscribe", "--poll", "ntfytest"}))
|
||||
require.Contains(t, stdout.String(), testMessage)
|
||||
_, err := util.Retry(func() (*int, error) {
|
||||
app2, _, stdout, _ := newTestApp()
|
||||
if err := app2.Run([]string{"ntfy", "subscribe", "--poll", "ntfytest"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !strings.Contains(stdout.String(), testMessage) {
|
||||
return nil, fmt.Errorf("test message %s not found in topic", testMessage)
|
||||
}
|
||||
return util.Int(1), nil
|
||||
}, time.Second, 2*time.Second, 5*time.Second) // Since #502, ntfy.sh writes messages to the cache asynchronously, after a timeout of ~1.5s
|
||||
require.Nil(t, err)
|
||||
}
|
||||
|
||||
func TestCLI_Publish_Subscribe_Poll(t *testing.T) {
|
||||
@@ -34,3 +49,252 @@ func TestCLI_Publish_Subscribe_Poll(t *testing.T) {
|
||||
m = toMessage(t, stdout.String())
|
||||
require.Equal(t, "some message", m.Message)
|
||||
}
|
||||
|
||||
func TestCLI_Publish_All_The_Things(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)
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
require.Nil(t, app.Run([]string{
|
||||
"ntfy", "publish",
|
||||
"--title", "this is a title",
|
||||
"--priority", "high",
|
||||
"--tags", "tag1,tag2",
|
||||
// No --delay, --email
|
||||
"--click", "https://ntfy.sh",
|
||||
"--icon", "https://ntfy.sh/static/img/ntfy.png",
|
||||
"--attach", "https://f-droid.org/F-Droid.apk",
|
||||
"--filename", "fdroid.apk",
|
||||
"--no-cache",
|
||||
"--no-firebase",
|
||||
topic,
|
||||
"some message",
|
||||
}))
|
||||
m := toMessage(t, stdout.String())
|
||||
require.Equal(t, "message", m.Event)
|
||||
require.Equal(t, "mytopic", m.Topic)
|
||||
require.Equal(t, "some message", m.Message)
|
||||
require.Equal(t, "this is a title", m.Title)
|
||||
require.Equal(t, 4, m.Priority)
|
||||
require.Equal(t, []string{"tag1", "tag2"}, m.Tags)
|
||||
require.Equal(t, "https://ntfy.sh", m.Click)
|
||||
require.Equal(t, "https://f-droid.org/F-Droid.apk", m.Attachment.URL)
|
||||
require.Equal(t, "fdroid.apk", m.Attachment.Name)
|
||||
require.Equal(t, int64(0), m.Attachment.Size)
|
||||
require.Equal(t, "", m.Attachment.Owner)
|
||||
require.Equal(t, int64(0), m.Attachment.Expires)
|
||||
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 ////
|
||||
t.Setenv("NTFY_TOPIC", topic)
|
||||
|
||||
// Test: Successful command with NTFY_TOPIC
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--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", "--wait-pid", strconv.Itoa(sleep.Process.Pid)}))
|
||||
m = toMessage(t, stdout.String())
|
||||
require.Regexp(t, `Process with PID \d+ exited after .+ms`, m.Message)
|
||||
}
|
||||
|
||||
func TestCLI_Publish_Default_UserPass(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic", r.URL.Path)
|
||||
require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
default-user: philipp
|
||||
default-password: mypass
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "mytopic", "triggered"}))
|
||||
m := toMessage(t, stdout.String())
|
||||
require.Equal(t, "triggered", m.Message)
|
||||
}
|
||||
|
||||
func TestCLI_Publish_Default_Token(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic", r.URL.Path)
|
||||
require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "mytopic", "triggered"}))
|
||||
m := toMessage(t, stdout.String())
|
||||
require.Equal(t, "triggered", m.Message)
|
||||
}
|
||||
|
||||
func TestCLI_Publish_Default_UserPass_CLI_Token(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic", r.URL.Path)
|
||||
require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
default-user: philipp
|
||||
default-password: mypass
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "mytopic", "triggered"}))
|
||||
m := toMessage(t, stdout.String())
|
||||
require.Equal(t, "triggered", m.Message)
|
||||
}
|
||||
|
||||
func TestCLI_Publish_Default_Token_CLI_UserPass(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic", r.URL.Path)
|
||||
require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--user", "philipp:mypass", "mytopic", "triggered"}))
|
||||
m := toMessage(t, stdout.String())
|
||||
require.Equal(t, "triggered", m.Message)
|
||||
}
|
||||
|
||||
func TestCLI_Publish_Default_Token_CLI_Token(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic", r.URL.Path)
|
||||
require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
default-token: tk_FAKETOKEN01234567890FAKETOKEN
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "mytopic", "triggered"}))
|
||||
m := toMessage(t, stdout.String())
|
||||
require.Equal(t, "triggered", m.Message)
|
||||
}
|
||||
|
||||
func TestCLI_Publish_Default_UserPass_CLI_UserPass(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic", r.URL.Path)
|
||||
require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
default-user: philipp
|
||||
default-password: fakepass
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--user", "philipp:mypass", "mytopic", "triggered"}))
|
||||
m := toMessage(t, stdout.String())
|
||||
require.Equal(t, "triggered", m.Message)
|
||||
}
|
||||
|
||||
func TestCLI_Publish_Token_And_UserPass(t *testing.T) {
|
||||
app, _, _, _ := newTestApp()
|
||||
err := app.Run([]string{"ntfy", "publish", "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "--user", "philipp:mypass", "mytopic", "triggered"})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "cannot set both --user and --token", err.Error())
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
305
cmd/serve.go
@@ -1,58 +1,104 @@
|
||||
//go:build !noserver
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/stripe/stripe-go/v74"
|
||||
"heckel.io/ntfy/user"
|
||||
"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/altsrc"
|
||||
"heckel.io/ntfy/server"
|
||||
"heckel.io/ntfy/util"
|
||||
"log"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
var flagsServe = []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: "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: "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: "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: "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.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)"}),
|
||||
func init() {
|
||||
commands = append(commands, cmdServe)
|
||||
}
|
||||
|
||||
const (
|
||||
defaultServerConfigFile = "/etc/ntfy/server.yml"
|
||||
)
|
||||
|
||||
var flagsServe = append(
|
||||
append([]cli.Flag{}, flagsDefault...),
|
||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, 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 as HTTP listen address"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"listen_https", "L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used as HTTPS listen address"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-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-startup-queries", Aliases: []string{"auth_startup_queries"}, EnvVars: []string{"NTFY_AUTH_STARTUP_QUERIES"}, Usage: "queries run when the auth database is initialized"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "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.NewStringSliceFlag(&cli.StringSliceFlag{Name: "disallowed-topics", Aliases: []string{"disallowed_topics"}, EnvVars: []string{"NTFY_DISALLOWED_TOPICS"}, Usage: "topics that are not allowed to be used"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", Aliases: []string{"web_root"}, EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "app", Usage: "sets web root to landing page (home), web app (app) or disabled (disable)"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-signup", Aliases: []string{"enable_signup"}, EnvVars: []string{"NTFY_ENABLE_SIGNUP"}, Value: false, Usage: "allows users to sign up via the web app, or API"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-login", Aliases: []string{"enable_login"}, EnvVars: []string{"NTFY_ENABLE_LOGIN"}, Value: false, Usage: "allows users to log in via the web app, or API"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-reservations", Aliases: []string{"enable_reservations"}, EnvVars: []string{"NTFY_ENABLE_RESERVATIONS"}, Value: false, Usage: "allows users to reserve topics (if their tier allows it)"}),
|
||||
altsrc.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-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
|
||||
altsrc.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: "visitor-subscriber-rate-limiting", Aliases: []string{"visitor_subscriber_rate_limiting"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING"}, Value: false, Usage: "enables subscriber-based rate limiting"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-secret-key", Aliases: []string{"stripe_secret_key"}, EnvVars: []string{"NTFY_STRIPE_SECRET_KEY"}, Value: "", Usage: "key used for the Stripe API communication, this enables payments"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-webhook-key", Aliases: []string{"stripe_webhook_key"}, EnvVars: []string{"NTFY_STRIPE_WEBHOOK_KEY"}, Value: "", Usage: "key required to validate the authenticity of incoming webhooks from Stripe"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "billing-contact", Aliases: []string{"billing_contact"}, EnvVars: []string{"NTFY_BILLING_CONTACT"}, Value: "", Usage: "e-mail or website to display in upgrade dialog (only if payments are enabled)"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-metrics", Aliases: []string{"enable_metrics"}, EnvVars: []string{"NTFY_ENABLE_METRICS"}, Value: false, Usage: "if set, Prometheus metrics are exposed via the /metrics endpoint"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "metrics-listen-http", Aliases: []string{"metrics_listen_http"}, EnvVars: []string{"NTFY_METRICS_LISTEN_HTTP"}, Usage: "ip:port used to expose the metrics endpoint (implicitly enables metrics)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "profile-listen-http", Aliases: []string{"profile_listen_http"}, EnvVars: []string{"NTFY_PROFILE_LISTEN_HTTP"}, Usage: "ip:port used to expose the profiling endpoints (implicitly enables profiling)"}),
|
||||
)
|
||||
|
||||
var cmdServe = &cli.Command{
|
||||
Name: "serve",
|
||||
Usage: "Run the ntfy server",
|
||||
UsageText: "ntfy serve [OPTIONS..]",
|
||||
Action: execServe,
|
||||
Category: categoryServer,
|
||||
Flags: flagsServe,
|
||||
Before: initConfigFileInputSource("config", flagsServe),
|
||||
Before: initConfigFileInputSourceFunc("config", flagsServe, initLogFunc),
|
||||
Description: `Run the ntfy server and listen for incoming requests
|
||||
|
||||
The command will load the configuration from /etc/ntfy/server.yml. Config options can
|
||||
@@ -69,20 +115,35 @@ func execServe(c *cli.Context) error {
|
||||
}
|
||||
|
||||
// Read all the options
|
||||
config := c.String("config")
|
||||
baseURL := c.String("base-url")
|
||||
listenHTTP := c.String("listen-http")
|
||||
listenHTTPS := c.String("listen-https")
|
||||
listenUnix := c.String("listen-unix")
|
||||
listenUnixMode := c.Int("listen-unix-mode")
|
||||
keyFile := c.String("key-file")
|
||||
certFile := c.String("cert-file")
|
||||
firebaseKeyFile := c.String("firebase-key-file")
|
||||
cacheFile := c.String("cache-file")
|
||||
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")
|
||||
authStartupQueries := c.String("auth-startup-queries")
|
||||
authDefaultAccess := c.String("auth-default-access")
|
||||
attachmentCacheDir := c.String("attachment-cache-dir")
|
||||
attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit")
|
||||
attachmentFileSizeLimitStr := c.String("attachment-file-size-limit")
|
||||
attachmentExpiryDuration := c.Duration("attachment-expiry-duration")
|
||||
keepaliveInterval := c.Duration("keepalive-interval")
|
||||
managerInterval := c.Duration("manager-interval")
|
||||
disallowedTopics := c.StringSlice("disallowed-topics")
|
||||
webRoot := c.String("web-root")
|
||||
enableSignup := c.Bool("enable-signup")
|
||||
enableLogin := c.Bool("enable-login")
|
||||
enableReservations := c.Bool("enable-reservations")
|
||||
upstreamBaseURL := c.String("upstream-base-url")
|
||||
smtpSenderAddr := c.String("smtp-sender-addr")
|
||||
smtpSenderUser := c.String("smtp-sender-user")
|
||||
smtpSenderPass := c.String("smtp-sender-pass")
|
||||
@@ -92,13 +153,22 @@ func execServe(c *cli.Context) error {
|
||||
smtpServerAddrPrefix := c.String("smtp-server-addr-prefix")
|
||||
totalTopicLimit := c.Int("global-topic-limit")
|
||||
visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
|
||||
visitorSubscriberRateLimiting := c.Bool("visitor-subscriber-rate-limiting")
|
||||
visitorAttachmentTotalSizeLimitStr := c.String("visitor-attachment-total-size-limit")
|
||||
visitorAttachmentDailyBandwidthLimitStr := c.String("visitor-attachment-daily-bandwidth-limit")
|
||||
visitorRequestLimitBurst := c.Int("visitor-request-limit-burst")
|
||||
visitorRequestLimitReplenish := c.Duration("visitor-request-limit-replenish")
|
||||
visitorRequestLimitExemptHosts := util.SplitNoEmpty(c.String("visitor-request-limit-exempt-hosts"), ",")
|
||||
visitorMessageDailyLimit := c.Int("visitor-message-daily-limit")
|
||||
visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
|
||||
visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish")
|
||||
behindProxy := c.Bool("behind-proxy")
|
||||
stripeSecretKey := c.String("stripe-secret-key")
|
||||
stripeWebhookKey := c.String("stripe-webhook-key")
|
||||
billingContact := c.String("billing-contact")
|
||||
metricsListenHTTP := c.String("metrics-listen-http")
|
||||
enableMetrics := c.Bool("enable-metrics") || metricsListenHTTP != ""
|
||||
profileListenHTTP := c.String("profile-listen-http")
|
||||
|
||||
// Check values
|
||||
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
|
||||
@@ -115,12 +185,46 @@ func execServe(c *cli.Context) error {
|
||||
return errors.New("if set, certificate file must exist")
|
||||
} else if listenHTTPS != "" && (keyFile == "" || certFile == "") {
|
||||
return errors.New("if listen-https is set, both key-file and cert-file must be set")
|
||||
} else if smtpSenderAddr != "" && (baseURL == "" || smtpSenderUser == "" || smtpSenderPass == "" || smtpSenderFrom == "") {
|
||||
return errors.New("if smtp-sender-addr is set, base-url, smtp-sender-user, smtp-sender-pass and smtp-sender-from must also be set")
|
||||
} else if smtpSenderAddr != "" && (baseURL == "" || smtpSenderFrom == "") {
|
||||
return errors.New("if smtp-sender-addr is set, base-url, and smtp-sender-from must also be set")
|
||||
} else if smtpServerListen != "" && smtpServerDomain == "" {
|
||||
return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set")
|
||||
} else if attachmentCacheDir != "" && baseURL == "" {
|
||||
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://") {
|
||||
return errors.New("if set, base-url must start with http:// or https://")
|
||||
} else if baseURL != "" && strings.HasSuffix(baseURL, "/") {
|
||||
return errors.New("if set, base-url must not end with a slash (/)")
|
||||
} else if !util.Contains([]string{"app", "home", "disable"}, webRoot) {
|
||||
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")
|
||||
} else if authFile == "" && (enableSignup || enableLogin || enableReservations || stripeSecretKey != "") {
|
||||
return errors.New("cannot set enable-signup, enable-login, enable-reserve-topics, or stripe-secret-key if auth-file is not set")
|
||||
} else if enableSignup && !enableLogin {
|
||||
return errors.New("cannot set enable-signup without also setting enable-login")
|
||||
} else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") {
|
||||
return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set")
|
||||
}
|
||||
|
||||
webRootIsApp := webRoot == "app"
|
||||
enableWeb := webRoot != "disable"
|
||||
|
||||
// Default auth permissions
|
||||
authDefault, err := user.ParsePermission(authDefaultAccess)
|
||||
if err != nil {
|
||||
return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
|
||||
}
|
||||
|
||||
// Special case: Unset default
|
||||
if listenHTTP == "-" {
|
||||
listenHTTP = ""
|
||||
}
|
||||
|
||||
// Convert sizes to bytes
|
||||
@@ -143,22 +247,54 @@ func execServe(c *cli.Context) error {
|
||||
return fmt.Errorf("config option visitor-attachment-daily-bandwidth-limit must be lower than %d", math.MaxInt)
|
||||
}
|
||||
|
||||
// Resolve hosts
|
||||
visitorRequestLimitExemptIPs := make([]netip.Prefix, 0)
|
||||
for _, host := range visitorRequestLimitExemptHosts {
|
||||
ips, err := parseIPHostPrefix(host)
|
||||
if err != nil {
|
||||
log.Warn("cannot resolve host %s: %s, ignoring visitor request exemption", host, err.Error())
|
||||
continue
|
||||
}
|
||||
visitorRequestLimitExemptIPs = append(visitorRequestLimitExemptIPs, ips...)
|
||||
}
|
||||
|
||||
// Stripe things
|
||||
if stripeSecretKey != "" {
|
||||
stripe.EnableTelemetry = false // Whoa!
|
||||
stripe.Key = stripeSecretKey
|
||||
}
|
||||
|
||||
// Add default forbidden topics
|
||||
disallowedTopics = append(disallowedTopics, server.DefaultDisallowedTopics...)
|
||||
|
||||
// Run server
|
||||
conf := server.NewConfig()
|
||||
conf.File = config
|
||||
conf.BaseURL = baseURL
|
||||
conf.ListenHTTP = listenHTTP
|
||||
conf.ListenHTTPS = listenHTTPS
|
||||
conf.ListenUnix = listenUnix
|
||||
conf.ListenUnixMode = fs.FileMode(listenUnixMode)
|
||||
conf.KeyFile = keyFile
|
||||
conf.CertFile = certFile
|
||||
conf.FirebaseKeyFile = firebaseKeyFile
|
||||
conf.CacheFile = cacheFile
|
||||
conf.CacheDuration = cacheDuration
|
||||
conf.CacheStartupQueries = cacheStartupQueries
|
||||
conf.CacheBatchSize = cacheBatchSize
|
||||
conf.CacheBatchTimeout = cacheBatchTimeout
|
||||
conf.AuthFile = authFile
|
||||
conf.AuthStartupQueries = authStartupQueries
|
||||
conf.AuthDefault = authDefault
|
||||
conf.AttachmentCacheDir = attachmentCacheDir
|
||||
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
|
||||
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
|
||||
conf.AttachmentExpiryDuration = attachmentExpiryDuration
|
||||
conf.KeepaliveInterval = keepaliveInterval
|
||||
conf.ManagerInterval = managerInterval
|
||||
conf.DisallowedTopics = disallowedTopics
|
||||
conf.WebRootIsApp = webRootIsApp
|
||||
conf.UpstreamBaseURL = upstreamBaseURL
|
||||
conf.SMTPSenderAddr = smtpSenderAddr
|
||||
conf.SMTPSenderUser = smtpSenderUser
|
||||
conf.SMTPSenderPass = smtpSenderPass
|
||||
@@ -169,20 +305,38 @@ func execServe(c *cli.Context) error {
|
||||
conf.TotalTopicLimit = totalTopicLimit
|
||||
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
|
||||
conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit
|
||||
conf.VisitorAttachmentDailyBandwidthLimit = int(visitorAttachmentDailyBandwidthLimit)
|
||||
conf.VisitorAttachmentDailyBandwidthLimit = visitorAttachmentDailyBandwidthLimit
|
||||
conf.VisitorRequestLimitBurst = visitorRequestLimitBurst
|
||||
conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish
|
||||
conf.VisitorRequestExemptIPAddrs = visitorRequestLimitExemptIPs
|
||||
conf.VisitorMessageDailyLimit = visitorMessageDailyLimit
|
||||
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
|
||||
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
|
||||
conf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting
|
||||
conf.BehindProxy = behindProxy
|
||||
conf.StripeSecretKey = stripeSecretKey
|
||||
conf.StripeWebhookKey = stripeWebhookKey
|
||||
conf.BillingContact = billingContact
|
||||
conf.EnableWeb = enableWeb
|
||||
conf.EnableSignup = enableSignup
|
||||
conf.EnableLogin = enableLogin
|
||||
conf.EnableReservations = enableReservations
|
||||
conf.EnableMetrics = enableMetrics
|
||||
conf.MetricsListenHTTP = metricsListenHTTP
|
||||
conf.ProfileListenHTTP = profileListenHTTP
|
||||
conf.Version = c.App.Version
|
||||
|
||||
// Set up hot-reloading of config
|
||||
go sigHandlerConfigReload(config)
|
||||
|
||||
// Run server
|
||||
s, err := server.New(conf)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
log.Fatal(err.Error())
|
||||
} else if err := s.Run(); err != nil {
|
||||
log.Fatal(err.Error())
|
||||
}
|
||||
if err := s.Run(); err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
log.Printf("Exiting.")
|
||||
log.Info("Exiting.")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -196,3 +350,66 @@ func parseSize(s string, defaultValue int64) (v int64, err error) {
|
||||
}
|
||||
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
|
||||
}
|
||||
if err := reloadLogLevel(inputSource); err != nil {
|
||||
log.Warn("Reloading log level failed: %s", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) error {
|
||||
newLevelStr, err := inputSource.String("log-level")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot load log level: %s", err.Error())
|
||||
}
|
||||
overrides, err := inputSource.StringSlice("log-level-overrides")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot load log level overrides (1): %s", err.Error())
|
||||
}
|
||||
log.ResetLevelOverrides()
|
||||
if err := applyLogLevelOverrides(overrides); err != nil {
|
||||
return fmt.Errorf("cannot load log level overrides (2): %s", err.Error())
|
||||
}
|
||||
log.SetLevel(log.ToLevel(newLevelStr))
|
||||
if len(overrides) > 0 {
|
||||
log.Info("Log level is %v, %d override(s) in place", strings.ToUpper(newLevelStr), len(overrides))
|
||||
} else {
|
||||
log.Info("Log level is %v", strings.ToUpper(newLevelStr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
95
cmd/serve_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"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() {
|
||||
rand.Seed(time.Now().UnixMilli())
|
||||
}
|
||||
|
||||
func TestCLI_Serve_Unix_Curl(t *testing.T) {
|
||||
sockFile := filepath.Join(t.TempDir(), "ntfy.sock")
|
||||
configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system
|
||||
go func() {
|
||||
app, _, _, _ := newTestApp()
|
||||
err := app.Run([]string{"ntfy", "serve", "--config=" + configFile, "--listen-http=-", "--listen-unix=" + sockFile})
|
||||
require.Nil(t, err)
|
||||
}()
|
||||
for i := 0; i < 40 && !util.FileExists(sockFile); i++ {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
require.True(t, util.FileExists(sockFile))
|
||||
|
||||
cmd := exec.Command("curl", "-s", "--unix-socket", sockFile, "-d", "this is a message", "localhost/mytopic")
|
||||
out, err := cmd.Output()
|
||||
require.Nil(t, err)
|
||||
m := toMessage(t, string(out))
|
||||
require.Equal(t, "this is a message", m.Message)
|
||||
}
|
||||
|
||||
func TestCLI_Serve_WebSocket(t *testing.T) {
|
||||
port := 10000 + rand.Intn(20000)
|
||||
go func() {
|
||||
configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system
|
||||
app, _, _, _ := newTestApp()
|
||||
err := app.Run([]string{"ntfy", "serve", "--config=" + configFile, fmt.Sprintf("--listen-http=:%d", port)})
|
||||
require.Nil(t, err)
|
||||
}()
|
||||
test.WaitForPortUp(t, port)
|
||||
|
||||
ws, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://127.0.0.1:%d/mytopic/ws", port), nil)
|
||||
require.Nil(t, err)
|
||||
|
||||
messageType, data, err := ws.ReadMessage()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, websocket.TextMessage, messageType)
|
||||
require.Equal(t, "open", toMessage(t, string(data)).Event)
|
||||
|
||||
c := client.New(client.NewConfig())
|
||||
_, err = c.Publish(fmt.Sprintf("http://127.0.0.1:%d/mytopic", port), "my message")
|
||||
require.Nil(t, err)
|
||||
|
||||
messageType, data, err = ws.ReadMessage()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, websocket.TextMessage, messageType)
|
||||
|
||||
m := toMessage(t, string(data))
|
||||
require.Equal(t, "my message", m.Message)
|
||||
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 {
|
||||
filename := filepath.Join(t.TempDir(), "empty")
|
||||
require.Nil(t, os.WriteFile(filename, []byte{}, 0600))
|
||||
return filename
|
||||
}
|
||||
186
cmd/subscribe.go
@@ -5,28 +5,46 @@ import (
|
||||
"fmt"
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/client"
|
||||
"heckel.io/ntfy/log"
|
||||
"heckel.io/ntfy/util"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
commands = append(commands, cmdSubscribe)
|
||||
}
|
||||
|
||||
const (
|
||||
clientRootConfigFileUnixAbsolute = "/etc/ntfy/client.yml"
|
||||
clientUserConfigFileUnixRelative = "ntfy/client.yml"
|
||||
clientUserConfigFileWindowsRelative = "ntfy\\client.yml"
|
||||
)
|
||||
|
||||
var flagsSubscribe = append(
|
||||
append([]cli.Flag{}, 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.StringFlag{Name: "token", Aliases: []string{"k"}, EnvVars: []string{"NTFY_TOKEN"}, Usage: "access token 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{
|
||||
Name: "subscribe",
|
||||
Aliases: []string{"sub"},
|
||||
Usage: "Subscribe to one or more topics on a ntfy server",
|
||||
UsageText: "ntfy subscribe [OPTIONS..] [TOPIC]",
|
||||
Action: execSubscribe,
|
||||
Flags: []cli.Flag{
|
||||
&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.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"},
|
||||
},
|
||||
Category: categoryClient,
|
||||
Flags: flagsSubscribe,
|
||||
Before: initLogFunc,
|
||||
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:
|
||||
|
||||
@@ -39,6 +57,7 @@ ntfy subscribe TOPIC
|
||||
ntfy subscribe mytopic # Prints JSON for incoming messages for ntfy.sh/mytopic
|
||||
ntfy sub home.lan/backups # Subscribe to topic on different server
|
||||
ntfy sub --poll home.lan/backups # Just query for latest messages and exit
|
||||
ntfy sub -u phil:mypass secret # Subscribe with username/password
|
||||
|
||||
ntfy subscribe TOPIC COMMAND
|
||||
This executes COMMAND for every incoming messages. The message fields are passed to the
|
||||
@@ -57,19 +76,17 @@ ntfy subscribe TOPIC COMMAND
|
||||
|
||||
Examples:
|
||||
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
|
||||
Service mode (used in ntfy-client.service). This reads the config file (/etc/ntfy/client.yml
|
||||
or ~/.config/ntfy/client.yml) and sets up subscriptions for every topic in the "subscribe:"
|
||||
block (see config file).
|
||||
Service mode (used in ntfy-client.service). This reads the config file and sets up
|
||||
subscriptions for every topic in the "subscribe:" block (see config file).
|
||||
|
||||
Examples:
|
||||
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),
|
||||
or ~/.config/ntfy/client.yml for all other users.`,
|
||||
` + clientCommandDescriptionSuffix,
|
||||
}
|
||||
|
||||
func execSubscribe(c *cli.Context) error {
|
||||
@@ -80,11 +97,19 @@ func execSubscribe(c *cli.Context) error {
|
||||
}
|
||||
cl := client.New(conf)
|
||||
since := c.String("since")
|
||||
user := c.String("user")
|
||||
token := c.String("token")
|
||||
poll := c.Bool("poll")
|
||||
scheduled := c.Bool("scheduled")
|
||||
fromConfig := c.Bool("from-config")
|
||||
topic := c.Args().Get(0)
|
||||
command := c.Args().Get(1)
|
||||
|
||||
// Checks
|
||||
if user != "" && token != "" {
|
||||
return errors.New("cannot set both --user and --token")
|
||||
}
|
||||
|
||||
if !fromConfig {
|
||||
conf.Subscribe = nil // wipe if --from-config not passed
|
||||
}
|
||||
@@ -92,8 +117,25 @@ func execSubscribe(c *cli.Context) error {
|
||||
if since != "" {
|
||||
options = append(options, client.WithSince(since))
|
||||
}
|
||||
if poll {
|
||||
options = append(options, client.WithPoll())
|
||||
if token != "" {
|
||||
options = append(options, client.WithBearerAuth(token))
|
||||
}
|
||||
if user != "" {
|
||||
var pass string
|
||||
parts := strings.SplitN(user, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
user = parts[0]
|
||||
pass = parts[1]
|
||||
} else {
|
||||
fmt.Fprint(c.App.ErrWriter, "Enter Password: ")
|
||||
p, err := util.ReadPassword(c.App.Reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pass = string(p)
|
||||
fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20))
|
||||
}
|
||||
options = append(options, client.WithBasicAuth(user, pass))
|
||||
}
|
||||
if scheduled {
|
||||
options = append(options, client.WithScheduled())
|
||||
@@ -111,6 +153,9 @@ func execSubscribe(c *cli.Context) error {
|
||||
|
||||
func doPoll(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error {
|
||||
for _, s := range conf.Subscribe { // may be nil
|
||||
if auth := maybeAddAuthHeader(s, conf); auth != nil {
|
||||
options = append(options, auth)
|
||||
}
|
||||
if err := doPollSingle(c, cl, s.Topic, s.Command, options...); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -135,25 +180,56 @@ 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 {
|
||||
commands := make(map[string]string) // Subscription ID -> command
|
||||
for _, s := range conf.Subscribe { // May be nil
|
||||
cmds := make(map[string]string) // Subscription ID -> command
|
||||
for _, s := range conf.Subscribe { // May be nil
|
||||
topicOptions := append(make([]client.SubscribeOption, 0), options...)
|
||||
for filter, value := range s.If {
|
||||
topicOptions = append(topicOptions, client.WithFilter(filter, value))
|
||||
}
|
||||
|
||||
if auth := maybeAddAuthHeader(s, conf); auth != nil {
|
||||
topicOptions = append(topicOptions, auth)
|
||||
}
|
||||
|
||||
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 != "" {
|
||||
subscriptionID := cl.Subscribe(topic, options...)
|
||||
commands[subscriptionID] = command
|
||||
cmds[subscriptionID] = command
|
||||
}
|
||||
for m := range cl.Messages {
|
||||
command, ok := commands[m.SubscriptionID]
|
||||
cmd, ok := cmds[m.SubscriptionID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
printMessageOrRunCommand(c, m, command)
|
||||
log.Debug("%s Dispatching received message: %s", logMessagePrefix(m), m.Raw)
|
||||
printMessageOrRunCommand(c, m, cmd)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func maybeAddAuthHeader(s client.Subscribe, conf *client.Config) client.SubscribeOption {
|
||||
// check for subscription token then subscription user:pass
|
||||
if s.Token != "" {
|
||||
return client.WithBearerAuth(s.Token)
|
||||
}
|
||||
if s.User != "" && s.Password != nil {
|
||||
return client.WithBasicAuth(s.User, *s.Password)
|
||||
}
|
||||
|
||||
// if no subscription token nor subscription user:pass, check for default token then default user:pass
|
||||
if conf.DefaultToken != "" {
|
||||
return client.WithBearerAuth(conf.DefaultToken)
|
||||
}
|
||||
if conf.DefaultUser != "" && conf.DefaultPassword != nil {
|
||||
return client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -162,27 +238,27 @@ func printMessageOrRunCommand(c *cli.Context, m *client.Message, command string)
|
||||
if command != "" {
|
||||
runCommand(c, command, m)
|
||||
} else {
|
||||
log.Debug("%s Printing raw message", logMessagePrefix(m))
|
||||
fmt.Fprintln(c.App.Writer, m.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func runCommand(c *cli.Context, command string, m *client.Message) {
|
||||
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 {
|
||||
scriptFile, err := createTmpScript(command)
|
||||
if err != nil {
|
||||
func runCommandInternal(c *cli.Context, script string, m *client.Message) error {
|
||||
scriptFile := fmt.Sprintf("%s/ntfy-subscribe-%s.%s", os.TempDir(), util.RandomString(10), scriptExt)
|
||||
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
|
||||
}
|
||||
defer os.Remove(scriptFile)
|
||||
verbose := c.Bool("verbose")
|
||||
if verbose {
|
||||
log.Printf("[%s] Executing: %s (for message: %s)", util.ShortTopicURL(m.TopicURL), command, m.Raw)
|
||||
}
|
||||
cmd := exec.Command("sh", "-c", scriptFile)
|
||||
log.Debug("%s Executing script %s", logMessagePrefix(m), scriptFile)
|
||||
cmd := exec.Command(scriptLauncher[0], append(scriptLauncher[1:], scriptFile)...)
|
||||
cmd.Stdin = c.App.Reader
|
||||
cmd.Stdout = c.App.Writer
|
||||
cmd.Stderr = c.App.ErrWriter
|
||||
@@ -190,17 +266,8 @@ func runCommandInternal(c *cli.Context, command string, m *client.Message) error
|
||||
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 {
|
||||
env := os.Environ()
|
||||
env := make([]string, 0)
|
||||
env = append(env, envVar(m.ID, "NTFY_ID", "id")...)
|
||||
env = append(env, envVar(m.Topic, "NTFY_TOPIC", "topic")...)
|
||||
env = append(env, envVar(fmt.Sprintf("%d", m.Time), "NTFY_TIME", "time")...)
|
||||
@@ -209,7 +276,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(strings.Join(m.Tags, ","), "NTFY_TAGS", "tags", "tag", "ta")...)
|
||||
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 {
|
||||
@@ -225,13 +296,30 @@ func loadConfig(c *cli.Context) (*client.Config, error) {
|
||||
if filename != "" {
|
||||
return client.LoadConfig(filename)
|
||||
}
|
||||
u, _ := user.Current()
|
||||
configFile := defaultClientRootConfigFile
|
||||
if u.Uid != "0" {
|
||||
configFile = util.ExpandHome(defaultClientUserConfigFile)
|
||||
}
|
||||
configFile := defaultClientConfigFile()
|
||||
if s, _ := os.Stat(configFile); s != nil {
|
||||
return client.LoadConfig(configFile)
|
||||
}
|
||||
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()
|
||||
}
|
||||
312
cmd/subscribe_test.go
Normal file
@@ -0,0 +1,312 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCLI_Subscribe_Default_UserPass_Subscription_Token(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
||||
require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
default-user: philipp
|
||||
default-password: mypass
|
||||
subscribe:
|
||||
- topic: mytopic
|
||||
token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
|
||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
|
||||
|
||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
||||
}
|
||||
|
||||
func TestCLI_Subscribe_Default_Token_Subscription_UserPass(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
||||
require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
||||
subscribe:
|
||||
- topic: mytopic
|
||||
user: philipp
|
||||
password: mypass
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
|
||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
|
||||
|
||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
||||
}
|
||||
|
||||
func TestCLI_Subscribe_Default_Token_Subscription_Token(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
||||
require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
default-token: tk_FAKETOKEN01234567890FAKETOKEN
|
||||
subscribe:
|
||||
- topic: mytopic
|
||||
token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
|
||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
|
||||
|
||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
||||
}
|
||||
|
||||
func TestCLI_Subscribe_Default_UserPass_Subscription_UserPass(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
||||
require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
default-user: fake
|
||||
default-password: password
|
||||
subscribe:
|
||||
- topic: mytopic
|
||||
user: philipp
|
||||
password: mypass
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
|
||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
|
||||
|
||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
||||
}
|
||||
|
||||
func TestCLI_Subscribe_Default_Token_Subscription_Empty(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
||||
require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
||||
subscribe:
|
||||
- topic: mytopic
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
|
||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
|
||||
|
||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
||||
}
|
||||
|
||||
func TestCLI_Subscribe_Default_UserPass_Subscription_Empty(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
||||
require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
default-user: philipp
|
||||
default-password: mypass
|
||||
subscribe:
|
||||
- topic: mytopic
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
|
||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
|
||||
|
||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
||||
}
|
||||
|
||||
func TestCLI_Subscribe_Default_Empty_Subscription_Token(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
||||
require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
subscribe:
|
||||
- topic: mytopic
|
||||
token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
|
||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
|
||||
|
||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
||||
}
|
||||
|
||||
func TestCLI_Subscribe_Default_Empty_Subscription_UserPass(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
||||
require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
subscribe:
|
||||
- topic: mytopic
|
||||
user: philipp
|
||||
password: mypass
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
|
||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
|
||||
|
||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
||||
}
|
||||
|
||||
func TestCLI_Subscribe_Default_Token_CLI_Token(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
||||
require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
default-token: tk_FAKETOKEN0123456789FAKETOKEN
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
|
||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "mytopic"}))
|
||||
|
||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
||||
}
|
||||
|
||||
func TestCLI_Subscribe_Default_Token_CLI_UserPass(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
||||
require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
|
||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "--user", "philipp:mypass", "mytopic"}))
|
||||
|
||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
||||
}
|
||||
|
||||
func TestCLI_Subscribe_Default_Token_Subscription_Token_CLI_UserPass(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
||||
require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
default-token: tk_FAKETOKEN01234567890FAKETOKEN
|
||||
subscribe:
|
||||
- topic: mytopic
|
||||
token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
|
||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "--user", "philipp:mypass"}))
|
||||
|
||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
||||
}
|
||||
|
||||
func TestCLI_Subscribe_Token_And_UserPass(t *testing.T) {
|
||||
app, _, _, _ := newTestApp()
|
||||
err := app.Run([]string{"ntfy", "subscribe", "--poll", "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "--user", "philipp:mypass", "mytopic", "triggered"})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "cannot set both --user and --token", err.Error())
|
||||
}
|
||||
18
cmd/subscribe_unix.go
Normal file
@@ -0,0 +1,18 @@
|
||||
//go: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()
|
||||
}
|
||||
366
cmd/tier.go
Normal file
@@ -0,0 +1,366 @@
|
||||
//go:build !noserver
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/user"
|
||||
"heckel.io/ntfy/util"
|
||||
)
|
||||
|
||||
func init() {
|
||||
commands = append(commands, cmdTier)
|
||||
}
|
||||
|
||||
const (
|
||||
defaultMessageLimit = 5000
|
||||
defaultMessageExpiryDuration = "12h"
|
||||
defaultEmailLimit = 20
|
||||
defaultReservationLimit = 3
|
||||
defaultAttachmentFileSizeLimit = "15M"
|
||||
defaultAttachmentTotalSizeLimit = "100M"
|
||||
defaultAttachmentExpiryDuration = "6h"
|
||||
defaultAttachmentBandwidthLimit = "1G"
|
||||
)
|
||||
|
||||
var (
|
||||
flagsTier = append([]cli.Flag{}, flagsUser...)
|
||||
)
|
||||
|
||||
var cmdTier = &cli.Command{
|
||||
Name: "tier",
|
||||
Usage: "Manage/show tiers",
|
||||
UsageText: "ntfy tier [list|add|change|remove] ...",
|
||||
Flags: flagsTier,
|
||||
Before: initConfigFileInputSourceFunc("config", flagsUser, initLogFunc),
|
||||
Category: categoryServer,
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "add",
|
||||
Aliases: []string{"a"},
|
||||
Usage: "Adds a new tier",
|
||||
UsageText: "ntfy tier add [OPTIONS] CODE",
|
||||
Action: execTierAdd,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "name", Usage: "tier name"},
|
||||
&cli.Int64Flag{Name: "message-limit", Value: defaultMessageLimit, Usage: "daily message limit"},
|
||||
&cli.StringFlag{Name: "message-expiry-duration", Value: defaultMessageExpiryDuration, Usage: "duration after which messages are deleted"},
|
||||
&cli.Int64Flag{Name: "email-limit", Value: defaultEmailLimit, Usage: "daily email limit"},
|
||||
&cli.Int64Flag{Name: "reservation-limit", Value: defaultReservationLimit, Usage: "topic reservation limit"},
|
||||
&cli.StringFlag{Name: "attachment-file-size-limit", Value: defaultAttachmentFileSizeLimit, Usage: "per-attachment file size limit"},
|
||||
&cli.StringFlag{Name: "attachment-total-size-limit", Value: defaultAttachmentTotalSizeLimit, Usage: "total size limit of attachments for the user"},
|
||||
&cli.StringFlag{Name: "attachment-expiry-duration", Value: defaultAttachmentExpiryDuration, Usage: "duration after which attachments are deleted"},
|
||||
&cli.StringFlag{Name: "attachment-bandwidth-limit", Value: defaultAttachmentBandwidthLimit, Usage: "daily bandwidth limit for attachment uploads/downloads"},
|
||||
&cli.StringFlag{Name: "stripe-monthly-price-id", Usage: "Monthly Stripe price ID for paid tiers (e.g. price_12345)"},
|
||||
&cli.StringFlag{Name: "stripe-yearly-price-id", Usage: "Yearly Stripe price ID for paid tiers (e.g. price_12345)"},
|
||||
&cli.BoolFlag{Name: "ignore-exists", Usage: "if the tier already exists, perform no action and exit"},
|
||||
},
|
||||
Description: `Add a new tier to the ntfy user database.
|
||||
|
||||
Tiers can be used to grant users higher limits, such as daily message limits, attachment size, or
|
||||
make it possible for users to reserve topics.
|
||||
|
||||
This is a server-only command. It directly reads from user.db as defined in the server config
|
||||
file server.yml. The command only works if 'auth-file' is properly defined.
|
||||
|
||||
Examples:
|
||||
ntfy tier add pro # Add tier with code "pro", using the defaults
|
||||
ntfy tier add \ # Add a tier with custom limits
|
||||
--name="Pro" \
|
||||
--message-limit=10000 \
|
||||
--message-expiry-duration=24h \
|
||||
--email-limit=50 \
|
||||
--reservation-limit=10 \
|
||||
--attachment-file-size-limit=100M \
|
||||
--attachment-total-size-limit=1G \
|
||||
--attachment-expiry-duration=12h \
|
||||
--attachment-bandwidth-limit=5G \
|
||||
pro
|
||||
`,
|
||||
},
|
||||
{
|
||||
Name: "change",
|
||||
Aliases: []string{"ch"},
|
||||
Usage: "Change a tier",
|
||||
UsageText: "ntfy tier change [OPTIONS] CODE",
|
||||
Action: execTierChange,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "name", Usage: "tier name"},
|
||||
&cli.Int64Flag{Name: "message-limit", Usage: "daily message limit"},
|
||||
&cli.StringFlag{Name: "message-expiry-duration", Usage: "duration after which messages are deleted"},
|
||||
&cli.Int64Flag{Name: "email-limit", Usage: "daily email limit"},
|
||||
&cli.Int64Flag{Name: "reservation-limit", Usage: "topic reservation limit"},
|
||||
&cli.StringFlag{Name: "attachment-file-size-limit", Usage: "per-attachment file size limit"},
|
||||
&cli.StringFlag{Name: "attachment-total-size-limit", Usage: "total size limit of attachments for the user"},
|
||||
&cli.StringFlag{Name: "attachment-expiry-duration", Usage: "duration after which attachments are deleted"},
|
||||
&cli.StringFlag{Name: "attachment-bandwidth-limit", Usage: "daily bandwidth limit for attachment uploads/downloads"},
|
||||
&cli.StringFlag{Name: "stripe-monthly-price-id", Usage: "Monthly Stripe price ID for paid tiers (e.g. price_12345)"},
|
||||
&cli.StringFlag{Name: "stripe-yearly-price-id", Usage: "Yearly Stripe price ID for paid tiers (e.g. price_12345)"},
|
||||
},
|
||||
Description: `Updates a tier to change the limits.
|
||||
|
||||
After updating a tier, you may have to restart the ntfy server to apply them
|
||||
to all visitors.
|
||||
|
||||
This is a server-only command. It directly reads from user.db as defined in the server config
|
||||
file server.yml. The command only works if 'auth-file' is properly defined.
|
||||
|
||||
Examples:
|
||||
ntfy tier change --name="Pro" pro # Update the name of an existing tier
|
||||
ntfy tier change \ # Update multiple limits and fields
|
||||
--message-expiry-duration=24h \
|
||||
--stripe-monthly-price-id=price_1234 \
|
||||
--stripe-monthly-price-id=price_5678 \
|
||||
pro
|
||||
`,
|
||||
},
|
||||
{
|
||||
Name: "remove",
|
||||
Aliases: []string{"del", "rm"},
|
||||
Usage: "Removes a tier",
|
||||
UsageText: "ntfy tier remove CODE",
|
||||
Action: execTierDel,
|
||||
Description: `Remove a tier from the ntfy user database.
|
||||
|
||||
You cannot remove a tier if there are users associated with a tier. Use "ntfy user change-tier"
|
||||
to remove or switch their tier first.
|
||||
|
||||
This is a server-only command. It directly reads from user.db as defined in the server config
|
||||
file server.yml. The command only works if 'auth-file' is properly defined.
|
||||
|
||||
Example:
|
||||
ntfy tier del pro
|
||||
`,
|
||||
},
|
||||
{
|
||||
Name: "list",
|
||||
Aliases: []string{"l"},
|
||||
Usage: "Shows a list of tiers",
|
||||
Action: execTierList,
|
||||
Description: `Shows a list of all configured tiers.
|
||||
|
||||
This is a server-only command. It directly reads from user.db as defined in the server config
|
||||
file server.yml. The command only works if 'auth-file' is properly defined.
|
||||
`,
|
||||
},
|
||||
},
|
||||
Description: `Manage tiers of the ntfy server.
|
||||
|
||||
The command allows you to add/remove/change tiers in the ntfy user database. Tiers are used
|
||||
to grant users higher limits, such as daily message limits, attachment size, or make it
|
||||
possible for users to reserve topics.
|
||||
|
||||
This is a server-only command. It directly manages the user.db as defined in the server config
|
||||
file server.yml. The command only works if 'auth-file' is properly defined.
|
||||
|
||||
Examples:
|
||||
ntfy tier add pro # Add tier with code "pro", using the defaults
|
||||
ntfy tier change --name="Pro" pro # Update the name of an existing tier
|
||||
ntfy tier del pro # Delete an existing tier
|
||||
`,
|
||||
}
|
||||
|
||||
func execTierAdd(c *cli.Context) error {
|
||||
code := c.Args().Get(0)
|
||||
if code == "" {
|
||||
return errors.New("tier code expected, type 'ntfy tier add --help' for help")
|
||||
} else if !user.AllowedTier(code) {
|
||||
return errors.New("tier code must consist only of numbers and letters")
|
||||
} else if c.String("stripe-monthly-price-id") != "" && c.String("stripe-yearly-price-id") == "" {
|
||||
return errors.New("if stripe-monthly-price-id is set, stripe-yearly-price-id must also be set")
|
||||
} else if c.String("stripe-monthly-price-id") == "" && c.String("stripe-yearly-price-id") != "" {
|
||||
return errors.New("if stripe-yearly-price-id is set, stripe-monthly-price-id must also be set")
|
||||
}
|
||||
manager, err := createUserManager(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tier, _ := manager.Tier(code); tier != nil {
|
||||
if c.Bool("ignore-exists") {
|
||||
fmt.Fprintf(c.App.ErrWriter, "tier %s already exists (exited successfully)\n", code)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("tier %s already exists", code)
|
||||
}
|
||||
name := c.String("name")
|
||||
if name == "" {
|
||||
name = code
|
||||
}
|
||||
messageExpiryDuration, err := util.ParseDuration(c.String("message-expiry-duration"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attachmentFileSizeLimit, err := util.ParseSize(c.String("attachment-file-size-limit"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attachmentTotalSizeLimit, err := util.ParseSize(c.String("attachment-total-size-limit"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attachmentBandwidthLimit, err := util.ParseSize(c.String("attachment-bandwidth-limit"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attachmentExpiryDuration, err := util.ParseDuration(c.String("attachment-expiry-duration"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tier := &user.Tier{
|
||||
ID: "", // Generated
|
||||
Code: code,
|
||||
Name: name,
|
||||
MessageLimit: c.Int64("message-limit"),
|
||||
MessageExpiryDuration: messageExpiryDuration,
|
||||
EmailLimit: c.Int64("email-limit"),
|
||||
ReservationLimit: c.Int64("reservation-limit"),
|
||||
AttachmentFileSizeLimit: attachmentFileSizeLimit,
|
||||
AttachmentTotalSizeLimit: attachmentTotalSizeLimit,
|
||||
AttachmentExpiryDuration: attachmentExpiryDuration,
|
||||
AttachmentBandwidthLimit: attachmentBandwidthLimit,
|
||||
StripeMonthlyPriceID: c.String("stripe-monthly-price-id"),
|
||||
StripeYearlyPriceID: c.String("stripe-yearly-price-id"),
|
||||
}
|
||||
if err := manager.AddTier(tier); err != nil {
|
||||
return err
|
||||
}
|
||||
tier, err = manager.Tier(code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "tier added\n\n")
|
||||
printTier(c, tier)
|
||||
return nil
|
||||
}
|
||||
|
||||
func execTierChange(c *cli.Context) error {
|
||||
code := c.Args().Get(0)
|
||||
if code == "" {
|
||||
return errors.New("tier code expected, type 'ntfy tier change --help' for help")
|
||||
} else if !user.AllowedTier(code) {
|
||||
return errors.New("tier code must consist only of numbers and letters")
|
||||
}
|
||||
manager, err := createUserManager(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tier, err := manager.Tier(code)
|
||||
if err == user.ErrTierNotFound {
|
||||
return fmt.Errorf("tier %s does not exist", code)
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.IsSet("name") {
|
||||
tier.Name = c.String("name")
|
||||
}
|
||||
if c.IsSet("message-limit") {
|
||||
tier.MessageLimit = c.Int64("message-limit")
|
||||
}
|
||||
if c.IsSet("message-expiry-duration") {
|
||||
tier.MessageExpiryDuration, err = util.ParseDuration(c.String("message-expiry-duration"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.IsSet("email-limit") {
|
||||
tier.EmailLimit = c.Int64("email-limit")
|
||||
}
|
||||
if c.IsSet("reservation-limit") {
|
||||
tier.ReservationLimit = c.Int64("reservation-limit")
|
||||
}
|
||||
if c.IsSet("attachment-file-size-limit") {
|
||||
tier.AttachmentFileSizeLimit, err = util.ParseSize(c.String("attachment-file-size-limit"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.IsSet("attachment-total-size-limit") {
|
||||
tier.AttachmentTotalSizeLimit, err = util.ParseSize(c.String("attachment-total-size-limit"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.IsSet("attachment-expiry-duration") {
|
||||
tier.AttachmentExpiryDuration, err = util.ParseDuration(c.String("attachment-expiry-duration"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.IsSet("attachment-bandwidth-limit") {
|
||||
tier.AttachmentBandwidthLimit, err = util.ParseSize(c.String("attachment-bandwidth-limit"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.IsSet("stripe-monthly-price-id") {
|
||||
tier.StripeMonthlyPriceID = c.String("stripe-monthly-price-id")
|
||||
}
|
||||
if c.IsSet("stripe-yearly-price-id") {
|
||||
tier.StripeYearlyPriceID = c.String("stripe-yearly-price-id")
|
||||
}
|
||||
if tier.StripeMonthlyPriceID != "" && tier.StripeYearlyPriceID == "" {
|
||||
return errors.New("if stripe-monthly-price-id is set, stripe-yearly-price-id must also be set")
|
||||
} else if tier.StripeMonthlyPriceID == "" && tier.StripeYearlyPriceID != "" {
|
||||
return errors.New("if stripe-yearly-price-id is set, stripe-monthly-price-id must also be set")
|
||||
}
|
||||
if err := manager.UpdateTier(tier); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "tier updated\n\n")
|
||||
printTier(c, tier)
|
||||
return nil
|
||||
}
|
||||
|
||||
func execTierDel(c *cli.Context) error {
|
||||
code := c.Args().Get(0)
|
||||
if code == "" {
|
||||
return errors.New("tier code expected, type 'ntfy tier del --help' for help")
|
||||
}
|
||||
manager, err := createUserManager(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := manager.Tier(code); err == user.ErrTierNotFound {
|
||||
return fmt.Errorf("tier %s does not exist", code)
|
||||
}
|
||||
if err := manager.RemoveTier(code); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "tier %s removed\n", code)
|
||||
return nil
|
||||
}
|
||||
|
||||
func execTierList(c *cli.Context) error {
|
||||
manager, err := createUserManager(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tiers, err := manager.Tiers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, tier := range tiers {
|
||||
printTier(c, tier)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printTier(c *cli.Context, tier *user.Tier) {
|
||||
prices := "(none)"
|
||||
if tier.StripeMonthlyPriceID != "" && tier.StripeYearlyPriceID != "" {
|
||||
prices = fmt.Sprintf("%s / %s", tier.StripeMonthlyPriceID, tier.StripeYearlyPriceID)
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "tier %s (id: %s)\n", tier.Code, tier.ID)
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Name: %s\n", tier.Name)
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Message limit: %d\n", tier.MessageLimit)
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Message expiry duration: %s (%d seconds)\n", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds()))
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Email limit: %d\n", tier.EmailLimit)
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit)
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSize(tier.AttachmentFileSizeLimit))
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSize(tier.AttachmentTotalSizeLimit))
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment expiry duration: %s (%d seconds)\n", tier.AttachmentExpiryDuration.String(), int64(tier.AttachmentExpiryDuration.Seconds()))
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment daily bandwidth limit: %s\n", util.FormatSize(tier.AttachmentBandwidthLimit))
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Stripe prices (monthly/yearly): %s\n", prices)
|
||||
}
|
||||
67
cmd/tier_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/server"
|
||||
"heckel.io/ntfy/test"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCLI_Tier_AddListChangeDelete(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, _, _, stderr := newTestApp()
|
||||
require.Nil(t, runTierCommand(app, conf, "add", "--name", "Pro", "--message-limit", "1234", "pro"))
|
||||
require.Contains(t, stderr.String(), "tier added\n\ntier pro (id: ti_")
|
||||
|
||||
err := runTierCommand(app, conf, "add", "pro")
|
||||
require.NotNil(t, err)
|
||||
require.Equal(t, "tier pro already exists", err.Error())
|
||||
|
||||
app, _, _, stderr = newTestApp()
|
||||
require.Nil(t, runTierCommand(app, conf, "list"))
|
||||
require.Contains(t, stderr.String(), "tier pro (id: ti_")
|
||||
require.Contains(t, stderr.String(), "- Name: Pro")
|
||||
require.Contains(t, stderr.String(), "- Message limit: 1234")
|
||||
|
||||
app, _, _, stderr = newTestApp()
|
||||
require.Nil(t, runTierCommand(app, conf, "change",
|
||||
"--message-limit=999",
|
||||
"--message-expiry-duration=2d",
|
||||
"--email-limit=91",
|
||||
"--reservation-limit=98",
|
||||
"--attachment-file-size-limit=100m",
|
||||
"--attachment-expiry-duration=1d",
|
||||
"--attachment-total-size-limit=10G",
|
||||
"--attachment-bandwidth-limit=100G",
|
||||
"--stripe-monthly-price-id=price_991",
|
||||
"--stripe-yearly-price-id=price_992",
|
||||
"pro",
|
||||
))
|
||||
require.Contains(t, stderr.String(), "- Message limit: 999")
|
||||
require.Contains(t, stderr.String(), "- Message expiry duration: 48h")
|
||||
require.Contains(t, stderr.String(), "- Email limit: 91")
|
||||
require.Contains(t, stderr.String(), "- Reservation limit: 98")
|
||||
require.Contains(t, stderr.String(), "- Attachment file size limit: 100.0 MB")
|
||||
require.Contains(t, stderr.String(), "- Attachment expiry duration: 24h")
|
||||
require.Contains(t, stderr.String(), "- Attachment total size limit: 10.0 GB")
|
||||
require.Contains(t, stderr.String(), "- Stripe prices (monthly/yearly): price_991 / price_992")
|
||||
|
||||
app, _, _, stderr = newTestApp()
|
||||
require.Nil(t, runTierCommand(app, conf, "remove", "pro"))
|
||||
require.Contains(t, stderr.String(), "tier pro removed")
|
||||
}
|
||||
|
||||
func runTierCommand(app *cli.App, conf *server.Config, args ...string) error {
|
||||
userArgs := []string{
|
||||
"ntfy",
|
||||
"--log-level=ERROR",
|
||||
"tier",
|
||||
"--config=" + conf.File, // Dummy config file to avoid lookups of real file
|
||||
"--auth-file=" + conf.AuthFile,
|
||||
"--auth-default-access=" + conf.AuthDefault.String(),
|
||||
}
|
||||
return app.Run(append(userArgs, args...))
|
||||
}
|
||||
210
cmd/token.go
Normal file
@@ -0,0 +1,210 @@
|
||||
//go:build !noserver
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/user"
|
||||
"heckel.io/ntfy/util"
|
||||
"net/netip"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
commands = append(commands, cmdToken)
|
||||
}
|
||||
|
||||
var flagsToken = append([]cli.Flag{}, flagsUser...)
|
||||
|
||||
var cmdToken = &cli.Command{
|
||||
Name: "token",
|
||||
Usage: "Create, list or delete user tokens",
|
||||
UsageText: "ntfy token [list|add|remove] ...",
|
||||
Flags: flagsToken,
|
||||
Before: initConfigFileInputSourceFunc("config", flagsToken, initLogFunc),
|
||||
Category: categoryServer,
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "add",
|
||||
Aliases: []string{"a"},
|
||||
Usage: "Create a new token",
|
||||
UsageText: "ntfy token add [--expires=<duration>] [--label=..] USERNAME",
|
||||
Action: execTokenAdd,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "expires", Aliases: []string{"e"}, Value: "", Usage: "token expires after"},
|
||||
&cli.StringFlag{Name: "label", Aliases: []string{"l"}, Value: "", Usage: "token label"},
|
||||
},
|
||||
Description: `Create a new user access token.
|
||||
|
||||
User access tokens can be used to publish, subscribe, or perform any other user-specific tasks.
|
||||
Tokens have full access, and can perform any task a user can do. They are meant to be used to
|
||||
avoid spreading the password to various places.
|
||||
|
||||
This is a server-only command. It directly reads from user.db as defined in the server config
|
||||
file server.yml. The command only works if 'auth-file' is properly defined.
|
||||
|
||||
Examples:
|
||||
ntfy token add phil # Create token for user phil which never expires
|
||||
ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days
|
||||
ntfy token add -e "tuesday, 8pm" phil # Create token for user phil which expires next Tuesday
|
||||
ntfy token add -l backups phil # Create token for user phil with label "backups"`,
|
||||
},
|
||||
{
|
||||
Name: "remove",
|
||||
Aliases: []string{"del", "rm"},
|
||||
Usage: "Removes a token",
|
||||
UsageText: "ntfy token remove USERNAME TOKEN",
|
||||
Action: execTokenDel,
|
||||
Description: `Remove a token from the ntfy user database.
|
||||
|
||||
Example:
|
||||
ntfy token del phil tk_th2srHVlxrANQHAso5t0HuQ1J1TjN`,
|
||||
},
|
||||
{
|
||||
Name: "list",
|
||||
Aliases: []string{"l"},
|
||||
Usage: "Shows a list of tokens",
|
||||
Action: execTokenList,
|
||||
Description: `Shows a list of all tokens.
|
||||
|
||||
This is a server-only command. It directly reads from user.db as defined in the server config
|
||||
file server.yml. The command only works if 'auth-file' is properly defined.`,
|
||||
},
|
||||
},
|
||||
Description: `Manage access tokens for individual users.
|
||||
|
||||
User access tokens can be used to publish, subscribe, or perform any other user-specific tasks.
|
||||
Tokens have full access, and can perform any task a user can do. They are meant to be used to
|
||||
avoid spreading the password to various places.
|
||||
|
||||
This is a server-only command. It directly manages the user.db as defined in the server config
|
||||
file server.yml. The command only works if 'auth-file' is properly defined.
|
||||
|
||||
Examples:
|
||||
ntfy token list # Shows list of tokens for all users
|
||||
ntfy token list phil # Shows list of tokens for user phil
|
||||
ntfy token add phil # Create token for user phil which never expires
|
||||
ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days
|
||||
ntfy token remove phil tk_th2srHVlxr... # Delete token`,
|
||||
}
|
||||
|
||||
func execTokenAdd(c *cli.Context) error {
|
||||
username := c.Args().Get(0)
|
||||
expiresStr := c.String("expires")
|
||||
label := c.String("label")
|
||||
if username == "" {
|
||||
return errors.New("username expected, type 'ntfy token add --help' for help")
|
||||
} else if username == userEveryone || username == user.Everyone {
|
||||
return errors.New("username not allowed")
|
||||
}
|
||||
expires := time.Unix(0, 0)
|
||||
if expiresStr != "" {
|
||||
var err error
|
||||
expires, err = util.ParseFutureTime(expiresStr, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
manager, err := createUserManager(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u, err := manager.User(username)
|
||||
if err == user.ErrUserNotFound {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
token, err := manager.CreateToken(u.ID, label, expires, netip.IPv4Unspecified())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if expires.Unix() == 0 {
|
||||
fmt.Fprintf(c.App.ErrWriter, "token %s created for user %s, never expires\n", token.Value, u.Name)
|
||||
} else {
|
||||
fmt.Fprintf(c.App.ErrWriter, "token %s created for user %s, expires %v\n", token.Value, u.Name, expires.Format(time.UnixDate))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func execTokenDel(c *cli.Context) error {
|
||||
username, token := c.Args().Get(0), c.Args().Get(1)
|
||||
if username == "" || token == "" {
|
||||
return errors.New("username and token expected, type 'ntfy token remove --help' for help")
|
||||
} else if username == userEveryone || username == user.Everyone {
|
||||
return errors.New("username not allowed")
|
||||
}
|
||||
manager, err := createUserManager(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u, err := manager.User(username)
|
||||
if err == user.ErrUserNotFound {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := manager.RemoveToken(u.ID, token); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "token %s for user %s removed\n", token, username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func execTokenList(c *cli.Context) error {
|
||||
username := c.Args().Get(0)
|
||||
if username == userEveryone || username == user.Everyone {
|
||||
return errors.New("username not allowed")
|
||||
}
|
||||
manager, err := createUserManager(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var users []*user.User
|
||||
if username != "" {
|
||||
u, err := manager.User(username)
|
||||
if err == user.ErrUserNotFound {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
users = append(users, u)
|
||||
} else {
|
||||
users, err = manager.Users()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
usersWithTokens := 0
|
||||
for _, u := range users {
|
||||
tokens, err := manager.Tokens(u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if len(tokens) == 0 && username != "" {
|
||||
fmt.Fprintf(c.App.ErrWriter, "user %s has no access tokens\n", username)
|
||||
return nil
|
||||
} else if len(tokens) == 0 {
|
||||
continue
|
||||
}
|
||||
usersWithTokens++
|
||||
fmt.Fprintf(c.App.ErrWriter, "user %s\n", u.Name)
|
||||
for _, t := range tokens {
|
||||
var label, expires string
|
||||
if t.Label != "" {
|
||||
label = fmt.Sprintf(" (%s)", t.Label)
|
||||
}
|
||||
if t.Expires.Unix() == 0 {
|
||||
expires = "never expires"
|
||||
} else {
|
||||
expires = fmt.Sprintf("expires %s", t.Expires.Format(time.RFC822))
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "- %s%s, %s, accessed from %s at %s\n", t.Value, label, expires, t.LastOrigin.String(), t.LastAccess.Format(time.RFC822))
|
||||
}
|
||||
}
|
||||
if usersWithTokens == 0 {
|
||||
fmt.Fprintf(c.App.ErrWriter, "no users with tokens\n")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
50
cmd/token_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/server"
|
||||
"heckel.io/ntfy/test"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCLI_Token_AddListRemove(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||
|
||||
app, _, _, stderr = newTestApp()
|
||||
require.Nil(t, runTokenCommand(app, conf, "add", "phil"))
|
||||
require.Regexp(t, `token tk_.+ created for user phil, never expires`, stderr.String())
|
||||
|
||||
app, _, _, stderr = newTestApp()
|
||||
require.Nil(t, runTokenCommand(app, conf, "list", "phil"))
|
||||
require.Regexp(t, `user phil\n- tk_.+, never expires, accessed from 0.0.0.0 at .+`, stderr.String())
|
||||
re := regexp.MustCompile(`tk_\w+`)
|
||||
token := re.FindString(stderr.String())
|
||||
|
||||
app, _, _, stderr = newTestApp()
|
||||
require.Nil(t, runTokenCommand(app, conf, "remove", "phil", token))
|
||||
require.Regexp(t, fmt.Sprintf("token %s for user phil removed", token), stderr.String())
|
||||
|
||||
app, _, _, stderr = newTestApp()
|
||||
require.Nil(t, runTokenCommand(app, conf, "list"))
|
||||
require.Equal(t, "no users with tokens\n", stderr.String())
|
||||
}
|
||||
|
||||
func runTokenCommand(app *cli.App, conf *server.Config, args ...string) error {
|
||||
userArgs := []string{
|
||||
"ntfy",
|
||||
"--log-level=ERROR",
|
||||
"token",
|
||||
"--config=" + conf.File, // Dummy config file to avoid lookups of real file
|
||||
"--auth-file=" + conf.AuthFile,
|
||||
}
|
||||
return app.Run(append(userArgs, args...))
|
||||
}
|
||||
357
cmd/user.go
Normal file
@@ -0,0 +1,357 @@
|
||||
//go:build !noserver
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/user"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
"heckel.io/ntfy/util"
|
||||
)
|
||||
|
||||
const (
|
||||
tierReset = "-"
|
||||
)
|
||||
|
||||
func init() {
|
||||
commands = append(commands, cmdUser)
|
||||
}
|
||||
|
||||
var flagsUser = append(
|
||||
append([]cli.Flag{}, flagsDefault...),
|
||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, Usage: "config file"},
|
||||
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{
|
||||
Name: "user",
|
||||
Usage: "Manage/show users",
|
||||
UsageText: "ntfy user [list|add|remove|change-pass|change-role] ...",
|
||||
Flags: flagsUser,
|
||||
Before: initConfigFileInputSourceFunc("config", flagsUser, initLogFunc),
|
||||
Category: categoryServer,
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "add",
|
||||
Aliases: []string{"a"},
|
||||
Usage: "Adds a new user",
|
||||
UsageText: "ntfy user add [--role=admin|user] USERNAME\nNTFY_PASSWORD=... ntfy user add [--role=admin|user] USERNAME",
|
||||
Action: execUserAdd,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "role", Aliases: []string{"r"}, Value: string(user.RoleUser), Usage: "user role"},
|
||||
&cli.BoolFlag{Name: "ignore-exists", Usage: "if the user already exists, perform no action and exit"},
|
||||
},
|
||||
Description: `Add a new user to the ntfy user database.
|
||||
|
||||
A user can be either a regular user, or an admin. A regular user has no read or write access (unless
|
||||
granted otherwise by the auth-default-access setting). An admin user has read and write access to all
|
||||
topics.
|
||||
|
||||
Examples:
|
||||
ntfy user add phil # Add regular 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.
|
||||
`,
|
||||
},
|
||||
{
|
||||
Name: "remove",
|
||||
Aliases: []string{"del", "rm"},
|
||||
Usage: "Removes a user",
|
||||
UsageText: "ntfy user remove USERNAME",
|
||||
Action: execUserDel,
|
||||
Description: `Remove a user from the ntfy user database.
|
||||
|
||||
Example:
|
||||
ntfy user del phil
|
||||
`,
|
||||
},
|
||||
{
|
||||
Name: "change-pass",
|
||||
Aliases: []string{"chp"},
|
||||
Usage: "Changes a user's password",
|
||||
UsageText: "ntfy user change-pass USERNAME\nNTFY_PASSWORD=... ntfy user change-pass USERNAME",
|
||||
Action: execUserChangePass,
|
||||
Description: `Change the password for the given user.
|
||||
|
||||
The new password will be read from STDIN, and it'll be confirmed by typing
|
||||
it twice.
|
||||
|
||||
Example:
|
||||
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.
|
||||
|
||||
`,
|
||||
},
|
||||
{
|
||||
Name: "change-role",
|
||||
Aliases: []string{"chr"},
|
||||
Usage: "Changes the role of a user",
|
||||
UsageText: "ntfy user change-role USERNAME ROLE",
|
||||
Action: execUserChangeRole,
|
||||
Description: `Change the role for the given user to admin or user.
|
||||
|
||||
This command can be used to change the role of a user either from a regular user
|
||||
to an admin user, or the other way around:
|
||||
|
||||
- admin: an admin has read/write access to all topics
|
||||
- user: a regular user only has access to what was explicitly granted via 'ntfy access'
|
||||
|
||||
When changing the role of a user to "admin", all access control entries for that
|
||||
user are removed, since they are no longer necessary.
|
||||
|
||||
Example:
|
||||
ntfy user change-role phil admin # Make user phil an admin
|
||||
ntfy user change-role phil user # Remove admin role from user phil
|
||||
`,
|
||||
},
|
||||
{
|
||||
Name: "change-tier",
|
||||
Aliases: []string{"cht"},
|
||||
Usage: "Changes the tier of a user",
|
||||
UsageText: "ntfy user change-tier USERNAME (TIER|-)",
|
||||
Action: execUserChangeTier,
|
||||
Description: `Change the tier for the given user.
|
||||
|
||||
This command can be used to change the tier of a user. Tiers define usage limits, such
|
||||
as messages per day, attachment file sizes, etc.
|
||||
|
||||
Example:
|
||||
ntfy user change-tier phil pro # Change tier to "pro" for user "phil"
|
||||
ntfy user change-tier phil - # Remove tier from user "phil" entirely
|
||||
`,
|
||||
},
|
||||
{
|
||||
Name: "list",
|
||||
Aliases: []string{"l"},
|
||||
Usage: "Shows a list of users",
|
||||
Action: execUserList,
|
||||
Description: `Shows a list of all configured users, including the everyone ('*') user.
|
||||
|
||||
This command is an alias to calling 'ntfy access' (display access control list).
|
||||
|
||||
This is a server-only command. It directly reads from user.db as defined in the server config
|
||||
file server.yml. The command only works if 'auth-file' is properly defined.
|
||||
`,
|
||||
},
|
||||
},
|
||||
Description: `Manage users of the ntfy server.
|
||||
|
||||
The command allows you to add/remove/change users in the ntfy user database, as well as change
|
||||
passwords or roles.
|
||||
|
||||
This is a server-only command. It directly manages the user.db as defined in the server config
|
||||
file server.yml. The command only works if 'auth-file' is properly defined. Please also refer
|
||||
to the related command 'ntfy access'.
|
||||
|
||||
Examples:
|
||||
ntfy user list # Shows list of users (alias: 'ntfy access')
|
||||
ntfy user add phil # Add regular user phil
|
||||
NTFY_PASSWORD=... ntfy user add phil # As above, using env variable to set password (for scripts)
|
||||
ntfy user add --role=admin phil # Add admin user phil
|
||||
ntfy user del phil # Delete user phil
|
||||
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 {
|
||||
username := c.Args().Get(0)
|
||||
role := user.Role(c.String("role"))
|
||||
password := os.Getenv("NTFY_PASSWORD")
|
||||
if username == "" {
|
||||
return errors.New("username expected, type 'ntfy user add --help' for help")
|
||||
} else if username == userEveryone || username == user.Everyone {
|
||||
return errors.New("username not allowed")
|
||||
} else if !user.AllowedRole(role) {
|
||||
return errors.New("role must be either 'user' or 'admin'")
|
||||
}
|
||||
manager, err := createUserManager(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user, _ := manager.User(username); user != nil {
|
||||
if c.Bool("ignore-exists") {
|
||||
fmt.Fprintf(c.App.ErrWriter, "user %s already exists (exited successfully)\n", username)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("user %s already exists", username)
|
||||
}
|
||||
if password == "" {
|
||||
p, err := readPasswordAndConfirm(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
password = p
|
||||
}
|
||||
if err := manager.AddUser(username, password, role); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "user %s added with role %s\n", username, role)
|
||||
return nil
|
||||
}
|
||||
|
||||
func execUserDel(c *cli.Context) error {
|
||||
username := c.Args().Get(0)
|
||||
if username == "" {
|
||||
return errors.New("username expected, type 'ntfy user del --help' for help")
|
||||
} else if username == userEveryone || username == user.Everyone {
|
||||
return errors.New("username not allowed")
|
||||
}
|
||||
manager, err := createUserManager(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := manager.User(username); err == user.ErrUserNotFound {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
}
|
||||
if err := manager.RemoveUser(username); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "user %s removed\n", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func execUserChangePass(c *cli.Context) error {
|
||||
username := c.Args().Get(0)
|
||||
password := os.Getenv("NTFY_PASSWORD")
|
||||
if username == "" {
|
||||
return errors.New("username expected, type 'ntfy user change-pass --help' for help")
|
||||
} else if username == userEveryone || username == user.Everyone {
|
||||
return errors.New("username not allowed")
|
||||
}
|
||||
manager, err := createUserManager(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := manager.User(username); err == user.ErrUserNotFound {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
}
|
||||
if password == "" {
|
||||
password, err = readPasswordAndConfirm(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := manager.ChangePassword(username, password); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "changed password for user %s\n", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func execUserChangeRole(c *cli.Context) error {
|
||||
username := c.Args().Get(0)
|
||||
role := user.Role(c.Args().Get(1))
|
||||
if username == "" || !user.AllowedRole(role) {
|
||||
return errors.New("username and new role expected, type 'ntfy user change-role --help' for help")
|
||||
} else if username == userEveryone || username == user.Everyone {
|
||||
return errors.New("username not allowed")
|
||||
}
|
||||
manager, err := createUserManager(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := manager.User(username); err == user.ErrUserNotFound {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
}
|
||||
if err := manager.ChangeRole(username, role); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "changed role for user %s to %s\n", username, role)
|
||||
return nil
|
||||
}
|
||||
|
||||
func execUserChangeTier(c *cli.Context) error {
|
||||
username := c.Args().Get(0)
|
||||
tier := c.Args().Get(1)
|
||||
if username == "" {
|
||||
return errors.New("username and new tier expected, type 'ntfy user change-tier --help' for help")
|
||||
} else if !user.AllowedTier(tier) && tier != tierReset {
|
||||
return errors.New("invalid tier, must be tier code, or - to reset")
|
||||
} else if username == userEveryone || username == user.Everyone {
|
||||
return errors.New("username not allowed")
|
||||
}
|
||||
manager, err := createUserManager(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := manager.User(username); err == user.ErrUserNotFound {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
}
|
||||
if tier == tierReset {
|
||||
if err := manager.ResetTier(username); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "removed tier from user %s\n", username)
|
||||
} else {
|
||||
if err := manager.ChangeTier(username, tier); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "changed tier for user %s to %s\n", username, tier)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func execUserList(c *cli.Context) error {
|
||||
manager, err := createUserManager(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
users, err := manager.Users()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return showUsers(c, manager, users)
|
||||
}
|
||||
|
||||
func createUserManager(c *cli.Context) (*user.Manager, error) {
|
||||
authFile := c.String("auth-file")
|
||||
authStartupQueries := c.String("auth-startup-queries")
|
||||
authDefaultAccess := c.String("auth-default-access")
|
||||
if authFile == "" {
|
||||
return nil, errors.New("option auth-file not set; auth is unconfigured for this server")
|
||||
} else if !util.FileExists(authFile) {
|
||||
return nil, errors.New("auth-file does not exist; please start the server at least once to create it")
|
||||
}
|
||||
authDefault, err := user.ParsePermission(authDefaultAccess)
|
||||
if err != nil {
|
||||
return nil, errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
|
||||
}
|
||||
return user.NewManager(authFile, authStartupQueries, authDefault, user.DefaultUserPasswordBcryptCost, user.DefaultUserStatsQueueWriterInterval)
|
||||
}
|
||||
|
||||
func readPasswordAndConfirm(c *cli.Context) (string, error) {
|
||||
fmt.Fprint(c.App.ErrWriter, "password: ")
|
||||
password, err := util.ReadPassword(c.App.Reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "\r%s\rconfirm: ", strings.Repeat(" ", 25))
|
||||
confirm, err := util.ReadPassword(c.App.Reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 25))
|
||||
if subtle.ConstantTimeCompare(confirm, password) != 1 {
|
||||
return "", errors.New("passwords do not match: try it again, but this time type slooowwwlly")
|
||||
}
|
||||
return string(password), nil
|
||||
}
|
||||
137
cmd/user_test.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/server"
|
||||
"heckel.io/ntfy/test"
|
||||
"heckel.io/ntfy/user"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCLI_User_Add(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||
}
|
||||
|
||||
func TestCLI_User_Add_Exists(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||
|
||||
app, stdin, _, _ = newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
err := runUserCommand(app, conf, "add", "phil")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "user phil already exists")
|
||||
}
|
||||
|
||||
func TestCLI_User_Add_Admin(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "--role=admin", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil added with role admin")
|
||||
}
|
||||
|
||||
func TestCLI_User_Add_Password_Mismatch(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nNOTMATCH")
|
||||
err := runUserCommand(app, conf, "add", "phil")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "passwords do not match: try it again, but this time type slooowwwlly")
|
||||
}
|
||||
|
||||
func TestCLI_User_ChangePass(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
// Add user
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||
|
||||
// Change pass
|
||||
app, stdin, _, stderr = newTestApp()
|
||||
stdin.WriteString("newpass\nnewpass")
|
||||
require.Nil(t, runUserCommand(app, conf, "change-pass", "phil"))
|
||||
require.Contains(t, stderr.String(), "changed password for user phil")
|
||||
}
|
||||
|
||||
func TestCLI_User_ChangeRole(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
// Add user
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||
|
||||
// Change role
|
||||
app, _, _, stderr = newTestApp()
|
||||
require.Nil(t, runUserCommand(app, conf, "change-role", "phil", "admin"))
|
||||
require.Contains(t, stderr.String(), "changed role for user phil to admin")
|
||||
}
|
||||
|
||||
func TestCLI_User_Delete(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
// Add user
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||
|
||||
// Delete user
|
||||
app, _, _, stderr = newTestApp()
|
||||
require.Nil(t, runUserCommand(app, conf, "del", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil removed")
|
||||
|
||||
// Delete user again (does not exist)
|
||||
app, _, _, _ = newTestApp()
|
||||
err := runUserCommand(app, conf, "del", "phil")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "user phil does not exist")
|
||||
}
|
||||
|
||||
func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, port int) {
|
||||
configFile := filepath.Join(t.TempDir(), "server-dummy.yml")
|
||||
require.Nil(t, os.WriteFile(configFile, []byte(""), 0600)) // Dummy config file to avoid lookup of real server.yml
|
||||
conf = server.NewConfig()
|
||||
conf.File = configFile
|
||||
conf.AuthFile = filepath.Join(t.TempDir(), "user.db")
|
||||
conf.AuthDefault = user.PermissionDenyAll
|
||||
s, port = test.StartServerWithConfig(t, conf)
|
||||
return
|
||||
}
|
||||
|
||||
func runUserCommand(app *cli.App, conf *server.Config, args ...string) error {
|
||||
userArgs := []string{
|
||||
"ntfy",
|
||||
"--log-level=ERROR",
|
||||
"user",
|
||||
"--config=" + conf.File, // Dummy config file to avoid lookups of real file
|
||||
"--auth-file=" + conf.AuthFile,
|
||||
"--auth-default-access=" + conf.AuthDefault.String(),
|
||||
}
|
||||
return app.Run(append(userArgs, args...))
|
||||
}
|
||||
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
|
||||
|
||||
50
docs/_overrides/main.html
Normal file
@@ -0,0 +1,50 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block announce %}
|
||||
<style>
|
||||
div[data-md-component="announce"] {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
div[data-md-component="announce"] a {
|
||||
color: white;
|
||||
}
|
||||
|
||||
div[data-md-component="announce"] a:hover, div[data-md-component="announce"] a:focus {
|
||||
transition: ease-in 150ms;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
div[data-md-component="announce"] .md-banner__button {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
div[data-md-component="announce"] .md-banner.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
div[data-md-component="announce"] .twemoji {
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
<button id="announce-bar-close" class="md-banner__button md-icon" aria-label="Don't show this again">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41Z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
If you like ntfy, please consider sponsoring it via <a target="_blank" href="https://github.com/sponsors/binwiederhier"><strong>GitHub Sponsors</strong></a>
|
||||
or <a target="_blank" href="https://en.liberapay.com/ntfy/"><strong>Liberapay</strong></a>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" role="img" viewBox="0 0 36 36" class="twemoji md-footer-custom-text">
|
||||
<path fill="#DD2E44" d="M35.885 11.833c0-5.45-4.418-9.868-9.867-9.868-3.308 0-6.227 1.633-8.018 4.129-1.791-2.496-4.71-4.129-8.017-4.129-5.45 0-9.868 4.417-9.868 9.868 0 .772.098 1.52.266 2.241C1.751 22.587 11.216 31.568 18 34.034c6.783-2.466 16.249-11.447 17.617-19.959.17-.721.268-1.469.268-2.242z"/>
|
||||
</svg>
|
||||
<script>
|
||||
announceBarKey = 'announce-bar-closed-sponsor';
|
||||
document.getElementById('announce-bar-close').addEventListener('click', (e) => {
|
||||
localStorage.setItem(announceBarKey, 'true');
|
||||
document.querySelector('div[data-md-component="announce"] .md-banner').style.display = 'none';
|
||||
});
|
||||
if (localStorage.getItem(announceBarKey) === 'true') {
|
||||
document.querySelector('div[data-md-component="announce"] .md-banner').style.display = 'none';
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
990
docs/config.md
@@ -1,11 +1,46 @@
|
||||
# Deprecation notices
|
||||
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
|
||||
_No active 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`)
|
||||
> since 2021-12-17
|
||||
> Deprecated 2021-12-17, behavior changed with v1.10.0
|
||||
|
||||
As more commands are added to the `ntfy` CLI tool, using just `ntfy` to run the server is not practical
|
||||
anymore. Please use `ntfy serve` instead. This also applies to Docker images, as they can also execute more than
|
||||
|
||||
395
docs/develop.md
@@ -1,54 +1,305 @@
|
||||
# Building
|
||||
# Development
|
||||
Hurray 🥳 🎉, you are interested in writing code for ntfy! **That's awesome.** 😎
|
||||
|
||||
I tried my very best to write up detailed instructions, but if at any point in time you run into issues, don't
|
||||
hesitate to **contact me on [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org)**.
|
||||
|
||||
## ntfy server
|
||||
The ntfy server source code is available [on GitHub](https://github.com/binwiederhier/ntfy).
|
||||
To quickly build on amd64, you can use `make build-simple`:
|
||||
The ntfy server source code is available [on GitHub](https://github.com/binwiederhier/ntfy). The codebase for the
|
||||
server consists of three components:
|
||||
|
||||
```
|
||||
git clone git@github.com:binwiederhier/ntfy.git
|
||||
cd ntfy
|
||||
make build-simple
|
||||
* **The main server/client** is written in [Go](https://go.dev/) (so you'll need Go). Its main entrypoint is at
|
||||
[main.go](https://github.com/binwiederhier/ntfy/blob/main/main.go), and the meat you're likely interested in is
|
||||
in [server.go](https://github.com/binwiederhier/ntfy/blob/main/server/server.go). Notably, the server uses a
|
||||
[SQLite](https://sqlite.org) library called [go-sqlite3](https://github.com/mattn/go-sqlite3), which requires
|
||||
[Cgo](https://go.dev/blog/cgo) and `CGO_ENABLED=1` to be set. Otherwise things will not work (see below).
|
||||
* **The documentation** is generated by [MkDocs](https://www.mkdocs.org/) and [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/),
|
||||
which is written in [Python](https://www.python.org/). You'll need Python and MkDocs (via `pip`) only if you want to
|
||||
build the docs.
|
||||
* **The web app** is written in [React](https://reactjs.org/), using [MUI](https://mui.com/). It uses [Create React App](https://create-react-app.dev/)
|
||||
to build the production build. If you want to modify the web app, you need [nodejs](https://nodejs.org/en/) (for `npm`)
|
||||
and install all the 100,000 dependencies (*sigh*).
|
||||
|
||||
All of these components are built and then **baked into one binary**.
|
||||
|
||||
### Navigating the code
|
||||
Code:
|
||||
|
||||
* [main.go](https://github.com/binwiederhier/ntfy/blob/main/main.go) - Main entrypoint into the CLI, for both server and client
|
||||
* [cmd/](https://github.com/binwiederhier/ntfy/tree/main/cmd) - CLI commands, such as `serve` or `publish`
|
||||
* [server/](https://github.com/binwiederhier/ntfy/tree/main/server) - The meat of the server logic
|
||||
* [docs/](https://github.com/binwiederhier/ntfy/tree/main/docs) - The [MkDocs](https://www.mkdocs.org/) documentation, also see `mkdocs.yml`
|
||||
* [web/](https://github.com/binwiederhier/ntfy/tree/main/web) - The [React](https://reactjs.org/) application, also see `web/package.json`
|
||||
|
||||
Build related:
|
||||
|
||||
* [Makefile](https://github.com/binwiederhier/ntfy/blob/main/Makefile) - Main entrypoint for all things related to building
|
||||
* [.goreleaser.yml](https://github.com/binwiederhier/ntfy/blob/main/.goreleaser.yml) - Describes all build outputs (for [GoReleaser](https://goreleaser.com/))
|
||||
* [go.mod](https://github.com/binwiederhier/ntfy/blob/main/go.mod) - Go modules dependency file
|
||||
* [mkdocs.yml](https://github.com/binwiederhier/ntfy/blob/main/mkdocs.yml) - Config file for the docs (for [MkDocs](https://www.mkdocs.org/))
|
||||
* [web/package.json](https://github.com/binwiederhier/ntfy/blob/main/web/package.json) - Build and dependency file for web app (for npm)
|
||||
|
||||
|
||||
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).
|
||||
|
||||
### 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
|
||||
|
||||
* [Go](https://go.dev/) (required for main server)
|
||||
* [gcc](https://gcc.gnu.org/) (required main server, for SQLite cgo-based bindings)
|
||||
* [Make](https://www.gnu.org/software/make/) (required for convenience)
|
||||
* [libsqlite3/libsqlite3-dev](https://www.sqlite.org/) (required for main server, for SQLite cgo-based bindings)
|
||||
* [GoReleaser](https://goreleaser.com/) (required for a proper main server build)
|
||||
* [Python](https://www.python.org/) (for `pip`, only to build the docs)
|
||||
* [nodejs](https://nodejs.org/en/) (for `npm`, only to build the web app)
|
||||
|
||||
### Install dependencies
|
||||
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)):
|
||||
``` shell
|
||||
wget https://go.dev/dl/go1.19.1.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
|
||||
go version # verifies that it worked
|
||||
```
|
||||
|
||||
That'll generate a statically linked binary in `dist/ntfy_linux_amd64/ntfy`.
|
||||
|
||||
For all other platforms (including Docker), and for production or other snapshot builds, you should use the amazingly
|
||||
awesome [GoReleaser](https://goreleaser.com/) make targets:
|
||||
|
||||
```
|
||||
Build:
|
||||
make build - Build
|
||||
make build-snapshot - Build snapshot
|
||||
make build-simple - Build (using go build, without goreleaser)
|
||||
make clean - Clean build folder
|
||||
|
||||
Releasing (requires goreleaser):
|
||||
make release - Create a release
|
||||
make release-snapshot - Create a test release
|
||||
Install [GoReleaser](https://goreleaser.com/) (see [official instructions](https://goreleaser.com/install/)):
|
||||
``` shell
|
||||
go install github.com/goreleaser/goreleaser@latest
|
||||
goreleaser -v # verifies that it worked
|
||||
```
|
||||
|
||||
There are currently no platform-specific make targets, so they will build for all platforms (which may take a while).
|
||||
Install [nodejs](https://nodejs.org/en/) (see [official instructions](https://nodejs.org/en/download/package-manager/)):
|
||||
``` shell
|
||||
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
npm -v # verifies that it worked
|
||||
```
|
||||
|
||||
Then install a few other things required:
|
||||
``` shell
|
||||
sudo apt install \
|
||||
build-essential \
|
||||
libsqlite3-dev \
|
||||
gcc-arm-linux-gnueabi \
|
||||
gcc-aarch64-linux-gnu \
|
||||
python3-pip \
|
||||
git
|
||||
```
|
||||
|
||||
### Check out code
|
||||
Now check out via git from the [GitHub repository](https://github.com/binwiederhier/ntfy):
|
||||
|
||||
=== "via HTTPS"
|
||||
``` shell
|
||||
git clone https://github.com/binwiederhier/ntfy.git
|
||||
cd ntfy
|
||||
```
|
||||
|
||||
=== "via SSH"
|
||||
``` shell
|
||||
git clone git@github.com:binwiederhier/ntfy.git
|
||||
cd ntfy
|
||||
```
|
||||
|
||||
### Build all the things
|
||||
Now you can finally build everything. There are tons of `make` targets, so maybe just review what's there first
|
||||
by typing `make`:
|
||||
|
||||
``` shell
|
||||
$ make
|
||||
Typical commands (more see below):
|
||||
make build - Build web app, documentation and server/client (sloowwww)
|
||||
make cli-linux-amd64 - Build server/client binary (amd64, no web app or docs)
|
||||
make install-linux-amd64 - Install ntfy binary to /usr/bin/ntfy (amd64)
|
||||
make web - Build the web app
|
||||
make docs - Build the documentation
|
||||
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 arm64),
|
||||
you can simply run `make build`:
|
||||
|
||||
``` shell
|
||||
$ make build
|
||||
...
|
||||
# This builds web app, docs, and the ntfy binary (for amd64, armv7 and arm64).
|
||||
# This will be SLOW (5+ minutes on my laptop on the first run). Maybe look at the other make targets?
|
||||
```
|
||||
|
||||
You'll see all the outputs in the `dist/` folder afterwards:
|
||||
|
||||
``` bash
|
||||
$ find dist
|
||||
dist
|
||||
dist/metadata.json
|
||||
dist/ntfy_arm64_linux_arm64
|
||||
dist/ntfy_arm64_linux_arm64/ntfy
|
||||
dist/ntfy_armv7_linux_arm_7
|
||||
dist/ntfy_armv7_linux_arm_7/ntfy
|
||||
dist/ntfy_amd64_linux_amd64
|
||||
dist/ntfy_amd64_linux_amd64/ntfy
|
||||
dist/config.yaml
|
||||
dist/artifacts.json
|
||||
```
|
||||
|
||||
If you also want to build the **Debian/RPM packages and the Docker images for all supported architectures**, you can
|
||||
use the `make release-snapshot` target:
|
||||
|
||||
``` shell
|
||||
$ make release-snapshot
|
||||
...
|
||||
# This will be REALLY SLOW (sometimes 5+ minutes on my laptop)
|
||||
```
|
||||
|
||||
During development, you may want to be more picky and build only certain things. Here are a few examples.
|
||||
|
||||
### Build the ntfy binary
|
||||
To build only the `ntfy` binary **without the web app or documentation**, use the `make cli-...` targets:
|
||||
|
||||
``` shell
|
||||
$ make
|
||||
Build server & client (using GoReleaser, not release version):
|
||||
make cli - Build server & client (all architectures)
|
||||
make cli-linux-amd64 - Build server & client (Linux, amd64 only)
|
||||
make cli-linux-armv6 - Build server & client (Linux, armv6 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 cli-linux-amd64` during testing. On a modern
|
||||
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:
|
||||
|
||||
``` shell
|
||||
$ make cli-linux-amd64 install-linux-amd64
|
||||
$ ntfy serve
|
||||
```
|
||||
|
||||
**During development of the main app, you can also just use `go run main.go`**, as long as you run
|
||||
`make cli-deps-static-sites`at least once and `CGO_ENABLED=1`:
|
||||
|
||||
``` shell
|
||||
$ export CGO_ENABLED=1
|
||||
$ make cli-deps-static-sites
|
||||
$ go run main.go serve
|
||||
2022/03/18 08:43:55 Listening on :2586[http]
|
||||
...
|
||||
```
|
||||
|
||||
If you don't run `cli-deps-static-sites`, you may see an error *`pattern ...: no matching files found`*:
|
||||
```
|
||||
$ go run main.go serve
|
||||
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
|
||||
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 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
|
||||
The sources for the web app live in `web/`. As long as you have `npm` installed (see above), building the web app
|
||||
is really simple. Just type `make web` and you're in business:
|
||||
|
||||
``` shell
|
||||
$ make web
|
||||
...
|
||||
```
|
||||
|
||||
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 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
|
||||
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
|
||||
will automatically refresh:
|
||||
|
||||
``` shell
|
||||
$ cd web
|
||||
$ npm start
|
||||
```
|
||||
|
||||
### Build the docs
|
||||
The sources for the docs live in `docs/`. Similarly to the web app, you can simply run `make docs` to build the
|
||||
documentation. As long as you have `mkdocs` installed (see above), this should work fine:
|
||||
|
||||
``` shell
|
||||
$ make docs
|
||||
...
|
||||
```
|
||||
|
||||
If you are changing the documentation, you should be running `mkdocs serve` directly. This will build the documentation,
|
||||
serve the files at `http://127.0.0.1:8000/`, and rebuild every time you save the source files:
|
||||
|
||||
```
|
||||
$ mkdocs serve
|
||||
INFO - Building documentation...
|
||||
INFO - Cleaning site directory
|
||||
INFO - Documentation built in 5.53 seconds
|
||||
INFO - [16:28:14] Serving on http://127.0.0.1:8000/
|
||||
```
|
||||
|
||||
Then you can navigate to http://127.0.0.1:8000/ and whenever you change a markdown file in your text editor it'll automatically update.
|
||||
|
||||
## Android app
|
||||
The ntfy Android app source code is available [on GitHub](https://github.com/binwiederhier/ntfy-android).
|
||||
The Android app has two flavors:
|
||||
|
||||
* **Google Play:** The `play` flavor includes Firebase (FCM) and requires a Firebase account
|
||||
* **Google Play:** The `play` flavor includes [Firebase (FCM)](https://firebase.google.com/) and requires a Firebase account
|
||||
* **F-Droid:** The `fdroid` flavor does not include Firebase or Google dependencies
|
||||
|
||||
### Navigating the code
|
||||
* [main/](https://github.com/binwiederhier/ntfy-android/tree/main/app/src/main) - Main Android app source code
|
||||
* [play/](https://github.com/binwiederhier/ntfy-android/tree/main/app/src/play) - Google Play / Firebase specific code
|
||||
* [fdroid/](https://github.com/binwiederhier/ntfy-android/tree/main/app/src/fdroid) - F-Droid Firebase stubs
|
||||
* [build.gradle](https://github.com/binwiederhier/ntfy-android/blob/main/app/build.gradle) - Main build file
|
||||
|
||||
### IDE/Environment
|
||||
You should download [Android Studio](https://developer.android.com/studio) (or [IntelliJ IDEA](https://www.jetbrains.com/idea/)
|
||||
with the relevant Android plugins). Everything else will just be a pain for you. Do yourself a favor. 😀
|
||||
|
||||
### Check out the code
|
||||
First check out the repository:
|
||||
|
||||
```
|
||||
git clone git@github.com:binwiederhier/ntfy-android.git
|
||||
cd ntfy-android
|
||||
```
|
||||
=== "via HTTPS"
|
||||
``` shell
|
||||
git clone https://github.com/binwiederhier/ntfy-android.git
|
||||
cd ntfy-android
|
||||
```
|
||||
|
||||
=== "via SSH"
|
||||
``` shell
|
||||
git clone git@github.com:binwiederhier/ntfy-android.git
|
||||
cd ntfy-android
|
||||
```
|
||||
|
||||
Then either follow the steps for building with or without Firebase.
|
||||
|
||||
### Building without Firebase (F-Droid flavor)
|
||||
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)
|
||||
### Build F-Droid flavor (no FCM)
|
||||
!!! info
|
||||
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.
|
||||
|
||||
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:
|
||||
```
|
||||
# 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)
|
||||
./gradlew assembleFdroidRelease
|
||||
|
||||
@@ -56,11 +307,16 @@ if you're self-hosting the server. Then run:
|
||||
./gradlew bundleFdroidRelease
|
||||
```
|
||||
|
||||
### Building with Firebase (FCM, Google Play flavor)
|
||||
### Build Play flavor (FCM)
|
||||
!!! info
|
||||
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.
|
||||
|
||||
To build your own version with Firebase, you must:
|
||||
|
||||
* Create a Firebase/FCM account
|
||||
* 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:
|
||||
```
|
||||
# To build an unsigned .apk (app/build/outputs/apk/play/*.apk)
|
||||
@@ -69,3 +325,78 @@ To build your own version with Firebase, you must:
|
||||
# To build a bundle .aab (app/play/release/*.aab)
|
||||
./gradlew bundlePlayRelease
|
||||
```
|
||||
|
||||
## iOS app
|
||||
Building the iOS app is very involved. Please report any inconsistencies or issues with it. The requirements are
|
||||
strictly based off of my development on this app. There may be other versions of macOS / XCode that work.
|
||||
|
||||
### Requirements
|
||||
1. macOS Monterey or later
|
||||
1. XCode 13.2+
|
||||
1. A physical iOS device (for push notifications, Firebase does not work in the XCode simulator)
|
||||
1. Firebase account
|
||||
1. Apple Developer license? (I forget if it's possible to do testing without purchasing the license)
|
||||
|
||||
### Apple setup
|
||||
|
||||
!!! info
|
||||
Along with this step, the [PLIST Deployment](#plist-deployment-and-configuration) step is also required
|
||||
for these changes to take effect in the iOS app.
|
||||
|
||||
1. [Create a new key in Apple Developer Member Center](https://developer.apple.com/account/resources/authkeys/add)
|
||||
1. Select "Apple Push Notifications service (APNs)"
|
||||
1. Download the newly created key (should have a file name similar to `AuthKey_ZZZZZZ.p8`, where `ZZZZZZ` is the **Key ID**)
|
||||
1. Record your **Team ID** - it can be seen in the top-right corner of the page, or on your Account > Membership page
|
||||
1. Next, navigate to "Project Settings" in the firebase console for your project, and select the iOS app you created. Then, click "Cloud Messaging" in the left sidebar, and scroll down to the "APNs Authentication Key" section. Click "Upload Key", and upload the key you downloaded from Apple Developer.
|
||||
|
||||
!!! warning
|
||||
If you don't do the above setups for APNS, **notifications will not post instantly or sometimes at all**. This is because of the missing APNS key, which is required for firebase to send notifications to the iOS app. See below for a snip from the firebase docs.
|
||||
|
||||
If you don't have an APNs authentication key, you can still send notifications to iOS devices, but they won't be delivered
|
||||
instantly. Instead, they'll be delivered when the device wakes up to check for new notifications or when your application
|
||||
sends a firebase request to check for them. The time to check for new notifications can vary from a few seconds to hours,
|
||||
days or even weeks. Enabling APNs authentication keys ensures that notifications are delivered instantly and is strongly
|
||||
recommended.
|
||||
|
||||
### Firebase setup
|
||||
|
||||
1. If you haven't already, create a Google / Firebase account
|
||||
1. Visit the [Firebase console](https://console.firebase.google.com)
|
||||
1. Create a new Firebase project:
|
||||
1. Enter a project name
|
||||
1. Disable Google Analytics (currently iOS app does not support analytics)
|
||||
1. On the "Project settings" page, add an iOS app
|
||||
1. Apple bundle ID - "com.copephobia.ntfy-ios" (this can be changed to match XCode's ntfy.sh target > "Bundle Identifier" value)
|
||||
1. Register the app
|
||||
1. Download the config file - GoogleInfo.plist (this will need to be included in the ntfy-ios repository / XCode)
|
||||
1. Generate a new service account private key for the ntfy server
|
||||
1. Go to "Project settings" > "Service accounts"
|
||||
1. Click "Generate new private key" to generate and download a private key to use for sending messages via the ntfy server
|
||||
|
||||
### ntfy server
|
||||
Note that the ntfy server is not officially supported on macOS. It should, however, be able to run on macOS using these
|
||||
steps:
|
||||
|
||||
1. If not already made, make the `/etc/ntfy/` directory and move the service account private key to that folder
|
||||
1. Copy the `server/server.yml` file from the ntfy repository to `/etc/ntfy/`
|
||||
1. Modify the `/etc/ntfy/server.yml` file `firebase-key-file` value to the path of the private key
|
||||
1. Install go: `brew install go`
|
||||
1. In the ntfy repository, run `make cli-darwin-server`.
|
||||
|
||||
### XCode setup
|
||||
|
||||
1. Follow step 4 of [https://firebase.google.com/docs/ios/setup](Add Firebase to your Apple project) to install the
|
||||
`firebase-ios-sdk` in XCode, if it's not already present - you can select any packages in addition to Firebase Core / Firebase Messaging
|
||||
1. Similarly, install the SQLite.swift package dependency in XCode
|
||||
1. When running the debug build, ensure XCode is pointed to the connected iOS device - registering for push notifications does not work in the iOS simulators
|
||||
|
||||
### PLIST config
|
||||
To have instant notifications/better notification delivery when using firebase, you will need to add the
|
||||
`GoogleService-Info.plist` file to your project. Here's how to do that:
|
||||
|
||||
1. In XCode, find the NTFY app target. **Not** the NSE app target.
|
||||
1. Find the Asset/ folder in the project navigator
|
||||
1. Drag the `GoogleService-Info.plist` file into the Asset/ folder that you get from the firebase console. It can be
|
||||
found in the "Project settings" > "General" > "Your apps" with a button labled "GoogleService-Info.plist"
|
||||
|
||||
After that, you should be all set!
|
||||
|
||||
533
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
|
||||
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
|
||||
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:
|
||||
@@ -16,11 +23,37 @@ rsync -a root@laptop /backups/laptop \
|
||||
|| curl -H tags:warning -H prio:high -d "Laptop backup failed" ntfy.sh/backups
|
||||
```
|
||||
|
||||
## Server-sent messages in your web app
|
||||
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.
|
||||
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.
|
||||
|
||||
## Notify on SSH login
|
||||
``` 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
|
||||
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.
|
||||
|
||||
``` bash
|
||||
#!/bin/bash
|
||||
|
||||
mingigs=10
|
||||
avail=$(df | awk '$6 == "/" && $4 < '$mingigs' * 1024*1024 { print $4/1024/1024 }')
|
||||
topicurl=https://ntfy.sh/mytopic
|
||||
|
||||
if [ -n "$avail" ]; then
|
||||
curl \
|
||||
-d "Only $avail GB available on the root disk. Better clean that up." \
|
||||
-H "Title: Low disk space alert on $(hostname)" \
|
||||
-H "Priority: high" \
|
||||
-H "Tags: warning,cd" \
|
||||
$topicurl
|
||||
fi
|
||||
```
|
||||
|
||||
## SSH login alerts
|
||||
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>
|
||||
to notify yourself on SSH login.
|
||||
@@ -68,10 +101,498 @@ 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.
|
||||
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
|
||||
uri:
|
||||
url: "https://ntfy.sh/{{ ntfy_channel }}"
|
||||
method: POST
|
||||
body: "{{ inventory_hostname }} reseeding complete"
|
||||
```
|
||||
|
||||
There's also a dedicated Ansible action plugin (one which runs on the Ansible controller) called
|
||||
[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:
|
||||
``` yaml
|
||||
services:
|
||||
watchtower:
|
||||
image: containrrr/watchtower
|
||||
environment:
|
||||
- WATCHTOWER_NOTIFICATIONS=shoutrrr
|
||||
- WATCHTOWER_NOTIFICATION_URL=generic+https://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates
|
||||
```
|
||||
|
||||
Or, if you only want to send notifications using shoutrrr:
|
||||
```
|
||||
shoutrrr send -u "generic+https://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage"
|
||||
```
|
||||
|
||||
## 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.
|
||||
Some simple bash scripts to achieve this are kindly provided in [nickexyz's repository](https://github.com/nickexyz/ntfy-shellscripts).
|
||||
|
||||
## Node-RED
|
||||
You can use the HTTP request node to send messages with [Node-RED](https://nodered.org), some examples:
|
||||
|
||||
<details>
|
||||
<summary>Example: Send a message (click to expand)</summary>
|
||||
|
||||
``` json
|
||||
[
|
||||
{
|
||||
"id": "c956e688cc74ad8e",
|
||||
"type": "http request",
|
||||
"z": "fabdd7a3.4045a",
|
||||
"name": "ntfy.sh",
|
||||
"method": "POST",
|
||||
"ret": "txt",
|
||||
"paytoqs": "ignore",
|
||||
"url": "https://ntfy.sh/mytopic",
|
||||
"tls": "",
|
||||
"persist": false,
|
||||
"proxy": "",
|
||||
"authType": "",
|
||||
"senderr": false,
|
||||
"credentials":
|
||||
{
|
||||
"user": "",
|
||||
"password": ""
|
||||
},
|
||||
"x": 590,
|
||||
"y": 3160,
|
||||
"wires":
|
||||
[
|
||||
[]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "32ee1eade51fae50",
|
||||
"type": "function",
|
||||
"z": "fabdd7a3.4045a",
|
||||
"name": "data",
|
||||
"func": "msg.payload = \"Something happened\";\nmsg.headers = {};\nmsg.headers['tags'] = 'house';\nmsg.headers['X-Title'] = 'Home Assistant';\n\nreturn msg;",
|
||||
"outputs": 1,
|
||||
"noerr": 0,
|
||||
"initialize": "",
|
||||
"finalize": "",
|
||||
"libs": [],
|
||||
"x": 470,
|
||||
"y": 3160,
|
||||
"wires":
|
||||
[
|
||||
[
|
||||
"c956e688cc74ad8e"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b287e59cd2311815",
|
||||
"type": "inject",
|
||||
"z": "fabdd7a3.4045a",
|
||||
"name": "Manual start",
|
||||
"props":
|
||||
[
|
||||
{
|
||||
"p": "payload"
|
||||
},
|
||||
{
|
||||
"p": "topic",
|
||||
"vt": "str"
|
||||
}
|
||||
],
|
||||
"repeat": "",
|
||||
"crontab": "",
|
||||
"once": false,
|
||||
"onceDelay": "20",
|
||||
"topic": "",
|
||||
"payload": "",
|
||||
"payloadType": "date",
|
||||
"x": 330,
|
||||
"y": 3160,
|
||||
"wires":
|
||||
[
|
||||
[
|
||||
"32ee1eade51fae50"
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||

|
||||
|
||||
<details>
|
||||
<summary>Example: Send a picture (click to expand)</summary>
|
||||
|
||||
``` json
|
||||
[
|
||||
{
|
||||
"id": "d135a13eadeb9d6d",
|
||||
"type": "http request",
|
||||
"z": "fabdd7a3.4045a",
|
||||
"name": "Download image",
|
||||
"method": "GET",
|
||||
"ret": "bin",
|
||||
"paytoqs": "ignore",
|
||||
"url": "https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png",
|
||||
"tls": "",
|
||||
"persist": false,
|
||||
"proxy": "",
|
||||
"authType": "",
|
||||
"senderr": false,
|
||||
"credentials":
|
||||
{
|
||||
"user": "",
|
||||
"password": ""
|
||||
},
|
||||
"x": 490,
|
||||
"y": 3320,
|
||||
"wires":
|
||||
[
|
||||
[
|
||||
"6e75bc41d2ec4a03"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "6e75bc41d2ec4a03",
|
||||
"type": "function",
|
||||
"z": "fabdd7a3.4045a",
|
||||
"name": "data",
|
||||
"func": "msg.payload = msg.payload;\nmsg.headers = {};\nmsg.headers['tags'] = 'house';\nmsg.headers['X-Title'] = 'Home Assistant - Picture';\n\nreturn msg;",
|
||||
"outputs": 1,
|
||||
"noerr": 0,
|
||||
"initialize": "",
|
||||
"finalize": "",
|
||||
"libs": [],
|
||||
"x": 650,
|
||||
"y": 3320,
|
||||
"wires":
|
||||
[
|
||||
[
|
||||
"eb160615b6ceda98"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "eb160615b6ceda98",
|
||||
"type": "http request",
|
||||
"z": "fabdd7a3.4045a",
|
||||
"name": "ntfy.sh",
|
||||
"method": "PUT",
|
||||
"ret": "bin",
|
||||
"paytoqs": "ignore",
|
||||
"url": "https://ntfy.sh/mytopic",
|
||||
"tls": "",
|
||||
"persist": false,
|
||||
"proxy": "",
|
||||
"authType": "",
|
||||
"senderr": false,
|
||||
"credentials":
|
||||
{
|
||||
"user": "",
|
||||
"password": ""
|
||||
},
|
||||
"x": 770,
|
||||
"y": 3320,
|
||||
"wires":
|
||||
[
|
||||
[]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "5b8dbf15c8a7a3a5",
|
||||
"type": "inject",
|
||||
"z": "fabdd7a3.4045a",
|
||||
"name": "Manual start",
|
||||
"props":
|
||||
[
|
||||
{
|
||||
"p": "payload"
|
||||
},
|
||||
{
|
||||
"p": "topic",
|
||||
"vt": "str"
|
||||
}
|
||||
],
|
||||
"repeat": "",
|
||||
"crontab": "",
|
||||
"once": false,
|
||||
"onceDelay": "20",
|
||||
"topic": "",
|
||||
"payload": "",
|
||||
"payloadType": "date",
|
||||
"x": 310,
|
||||
"y": 3320,
|
||||
"wires":
|
||||
[
|
||||
[
|
||||
"d135a13eadeb9d6d"
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||

|
||||
|
||||
## Gatus
|
||||
To use ntfy with [Gatus](https://github.com/TwiN/gatus), you can use the `ntfy` alerting provider like so:
|
||||
|
||||
```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:
|
||||
custom:
|
||||
url: "https://ntfy.sh"
|
||||
method: "POST"
|
||||
body: |
|
||||
{
|
||||
"topic": "mytopic",
|
||||
"message": "[ENDPOINT_NAME] - [ALERT_DESCRIPTION]",
|
||||
"title": "Gatus",
|
||||
"tags": ["[ALERT_TRIGGERED_OR_RESOLVED]"],
|
||||
"priority": 3
|
||||
}
|
||||
default-alert:
|
||||
enabled: true
|
||||
description: "health check failed"
|
||||
send-on-resolved: true
|
||||
failure-threshold: 3
|
||||
success-threshold: 3
|
||||
placeholders:
|
||||
ALERT_TRIGGERED_OR_RESOLVED:
|
||||
TRIGGERED: "warning"
|
||||
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://request.example.com` to your URL as the value of the JSON key click.
|
||||
And if you're not using the request `topic`, make sure to change it in the JSON payload to your topic.
|
||||
|
||||
``` 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`):
|
||||

|
||||
|
||||
## Traccar
|
||||
This will only work on selfhosted [traccar](https://www.traccar.org/) ([Github](https://github.com/traccar/traccar)) instances, as you need to be able to set `sms.http.*` keys, which is not possible through the UI attributes
|
||||
|
||||
The easiest way to integrate traccar with ntfy, is to configure ntfy as the SMS provider for your instance. You then can set your ntfy topic as your account's phone number in traccar. Sending the email notifications to ntfy will not work, as ntfy does not support HTML emails.
|
||||
|
||||
**Caution:** JSON publishing is only possible, when POST-ing to the root URL of the ntfy instance. (see [documentation](publish.md#publish-as-json))
|
||||
```xml
|
||||
<entry key='sms.http.url'>https://ntfy.sh</entry>
|
||||
<entry key='sms.http.template'>
|
||||
{
|
||||
"topic": "{phone}",
|
||||
"message": "{message}"
|
||||
}
|
||||
</entry>
|
||||
```
|
||||
If [access control](config.md#access-control) is enabled, and the target topic does not support anonymous writes, you'll also have to provide an authorization header, for example in form of a privileged token
|
||||
```xml
|
||||
<entry key='sms.http.authorization'>Bearer tk_JhbsnoMrgy2FcfHeofv97Pi5uXaZZ</entry>
|
||||
```
|
||||
or by simply providing traccar with a valid username/password combination.
|
||||
```xml
|
||||
<entry key='sms.http.user'>phil</entry>
|
||||
<entry key='sms.http.password'>mypass</entry>
|
||||
```
|
||||
|
||||
72
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.
|
||||
|
||||
## 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
|
||||
the service.
|
||||
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
|
||||
server without signup and free of charge, I may also offer paid plans in the future.
|
||||
|
||||
## 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?
|
||||
As per usual with pub-sub, all subscribers receive notifications if they are
|
||||
subscribed to a topic.
|
||||
As per usual with pub-sub, all subscribers receive notifications if they are subscribed to a topic.
|
||||
|
||||
## 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>.
|
||||
That said, the logs do not contain any topic names or other details about you.
|
||||
Messages are cached for the duration configured in `server.yml` (12h by default) to facilitate service restarts, message polling and to overcome
|
||||
client network disruptions.
|
||||
If you don't trust me or your messages are sensitive, run your own server. It's open source.
|
||||
That said, the logs do contain topic names and IP addresses, but I don't use them for anything other than
|
||||
troubleshooting and rate limiting. Messages are cached for the duration configured in `server.yml` (12h by default)
|
||||
to facilitate service restarts, message polling and to overcome client network disruptions.
|
||||
|
||||
## 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).
|
||||
|
||||
## Why is Firebase used?
|
||||
## Is Firebase used?
|
||||
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
|
||||
is to facilitate notifications on Android.
|
||||
@@ -33,16 +41,42 @@ If you do not care for Firebase, I suggest you install the [F-Droid version](htt
|
||||
of the app and [self-host your own ntfy server](install.md).
|
||||
|
||||
## 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,
|
||||
the Android 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 4% of
|
||||
battery in 17h of use (on my phone). I use it, and it makes no difference to me.
|
||||
If you use the ntfy.sh server, and you don't use the [instant delivery](subscribe/phone.md#instant-delivery) feature,
|
||||
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* (Android only), or install from F-droid ([which does not support FCM](https://f-droid.org/docs/Inclusion_Policy/)),
|
||||
the app has to maintain a constant connection to the server, which consumes 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.
|
||||
|
||||
## Paid plans? I thought it was open source?
|
||||
All of ntfy will remain open source, with a free software license (Apache 2.0 and GPLv2). If you'd like to self-host, you
|
||||
can (and should do that). The paid plans I am offering are for people that do not want to self-host, and/or need higher
|
||||
limits.
|
||||
|
||||
## 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
|
||||
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.
|
||||
|
||||
## Why is there no iOS app (yet)?
|
||||
I don't have an iPhone or a Mac, so I didn't make an iOS app yet. It'd be awesome if
|
||||
<a href="https://github.com/binwiederhier/ntfy/issues/4">someone else could help out</a>.
|
||||
## Can you implement feature X?
|
||||
Yes, maybe. Check out [existing GitHub issues](https://github.com/binwiederhier/ntfy/issues) to see if somebody else had
|
||||
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.
|
||||
|
||||
6
docs/hooks.py
Normal file
@@ -0,0 +1,6 @@
|
||||
import os
|
||||
import shutil
|
||||
|
||||
def copy_fonts(config, **kwargs):
|
||||
site_dir = config['site_dir']
|
||||
shutil.copytree('docs/static/fonts', os.path.join(site_dir, 'get'))
|
||||
@@ -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
|
||||
<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://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.
|
||||
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>
|
||||
|
||||
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:
|
||||
|
||||
|
||||
477
docs/install.md
@@ -13,36 +13,51 @@ The ntfy server comes as a statically linked binary and is shipped as tarball, d
|
||||
We support amd64, armv7 and arm64.
|
||||
|
||||
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))
|
||||
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))
|
||||
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` (for the non-root user) or `/etc/ntfy/client.yml` (for the root user), 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 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).
|
||||
|
||||
## Binaries and packages
|
||||
## Linux binaries
|
||||
Please check out the [releases page](https://github.com/binwiederhier/ntfy/releases) for binaries and
|
||||
deb/rpm packages.
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.1/ntfy_1.12.1_linux_x86_64.tar.gz
|
||||
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
|
||||
sudo ./ntfy serve
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.3.0/ntfy_2.3.0_linux_x86_64.tar.gz
|
||||
tar zxvf ntfy_2.3.0_linux_x86_64.tar.gz
|
||||
sudo cp -a ntfy_2.3.0_linux_x86_64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.3.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.3.0/ntfy_2.3.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.3.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.3.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.3.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.1/ntfy_1.12.1_linux_armv7.tar.gz
|
||||
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
|
||||
sudo ./ntfy serve
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.3.0/ntfy_2.3.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.3.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.3.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.3.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.1/ntfy_1.12.1_linux_arm64.tar.gz
|
||||
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
|
||||
sudo ./ntfy serve
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.3.0/ntfy_2.3.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.3.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.3.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.3.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
## Debian/Ubuntu repository
|
||||
@@ -50,9 +65,10 @@ Installation via Debian repository:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```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 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"
|
||||
sudo apt update
|
||||
sudo apt install ntfy
|
||||
@@ -62,10 +78,11 @@ Installation via Debian repository:
|
||||
|
||||
=== "armv7/armhf"
|
||||
```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 sh -c "echo 'deb [arch=armhf] https://archive.heckel.io/apt debian main' \
|
||||
> /etc/apt/sources.list.d/archive.heckel.io.list"
|
||||
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"
|
||||
sudo apt update
|
||||
sudo apt install ntfy
|
||||
sudo systemctl enable ntfy
|
||||
@@ -74,10 +91,11 @@ Installation via Debian repository:
|
||||
|
||||
=== "arm64"
|
||||
```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 sh -c "echo 'deb [arch=arm64] https://archive.heckel.io/apt debian main' \
|
||||
> /etc/apt/sources.list.d/archive.heckel.io.list"
|
||||
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"
|
||||
sudo apt update
|
||||
sudo apt install ntfy
|
||||
sudo systemctl enable ntfy
|
||||
@@ -88,7 +106,15 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.1/ntfy_1.12.1_linux_amd64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.3.0/ntfy_2.3.0_linux_amd64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.3.0/ntfy_2.3.0_linux_armv6.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -96,7 +122,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.1/ntfy_1.12.1_linux_armv7.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.3.0/ntfy_2.3.0_linux_armv7.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -104,7 +130,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.1/ntfy_1.12.1_linux_arm64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.3.0/ntfy_2.3.0_linux_arm64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -114,21 +140,28 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.12.1/ntfy_1.12.1_linux_amd64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.3.0/ntfy_2.3.0_linux_amd64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.3.0/ntfy_2.3.0_linux_armv6.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.12.1/ntfy_1.12.1_linux_armv7.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.3.0/ntfy_2.3.0_linux_armv7.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.12.1/ntfy_1.12.1_linux_arm64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.3.0/ntfy_2.3.0_linux_arm64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
@@ -146,15 +179,65 @@ cd ntfysh-bin
|
||||
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/v2.3.0/ntfy_2.3.0_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/v2.3.0/ntfy_2.3.0_macOS_all.tar.gz > ntfy_2.3.0_macOS_all.tar.gz
|
||||
tar zxvf ntfy_2.3.0_macOS_all.tar.gz
|
||||
sudo cp -a ntfy_2.3.0_macOS_all/ntfy /usr/local/bin/ntfy
|
||||
mkdir ~/Library/Application\ Support/ntfy
|
||||
cp ntfy_2.3.0_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/v2.3.0/ntfy_2.3.0_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
|
||||
The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv7 and arm64. It should be pretty
|
||||
straight forward to use.
|
||||
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.
|
||||
|
||||
The server exposes its web UI and the API on port 80, so you need to expose that in Docker. To use the persistent
|
||||
[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`.
|
||||
|
||||
!!! 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):
|
||||
```
|
||||
docker run -p 80:80 -it binwiederhier/ntfy serve
|
||||
@@ -167,20 +250,51 @@ docker run \
|
||||
-p 80:80 \
|
||||
-it \
|
||||
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
|
||||
docker run \
|
||||
-v /etc/ntfy:/etc/ntfy \
|
||||
-e TZ=UTC \
|
||||
-p 80:80 \
|
||||
-u UID:GID \
|
||||
-it \
|
||||
binwiederhier/ntfy \
|
||||
serve
|
||||
```
|
||||
|
||||
Using docker-compose with non-root user and healthchecks enabled:
|
||||
```yaml
|
||||
version: "2.1"
|
||||
|
||||
services:
|
||||
ntfy:
|
||||
image: binwiederhier/ntfy
|
||||
container_name: ntfy
|
||||
command:
|
||||
- serve
|
||||
environment:
|
||||
- TZ=UTC # optional: set desired timezone
|
||||
user: UID:GID # optional: replace with your own user/group or uid/gid
|
||||
volumes:
|
||||
- /var/cache/ntfy:/var/cache/ntfy
|
||||
- /etc/ntfy:/etc/ntfy
|
||||
ports:
|
||||
- 80:80
|
||||
healthcheck: # optional: remember to adapt the host:port to your environment
|
||||
test: ["CMD-SHELL", "wget -q --tries=1 http://localhost:80/v1/health -O - | grep -Eo '\"healthy\"\\s*:\\s*true' || exit 1"]
|
||||
interval: 60s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
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 and attachments directory 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.
|
||||
```
|
||||
FROM binwiederhier/ntfy
|
||||
@@ -189,12 +303,299 @@ 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.
|
||||
|
||||
## Go
|
||||
To install via Go, simply run:
|
||||
## 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
|
||||
- name: cache
|
||||
mountPath: "/var/cache/ntfy"
|
||||
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
|
||||
go install heckel.io/ntfy@latest
|
||||
kubectl apply -k /ntfy
|
||||
```
|
||||
|
||||
!!! info
|
||||
Please [let me know](https://github.com/binwiederhier/ntfy/issues) if there are any issues with this installation
|
||||
method. The SQLite bindings require CGO and it works for me, but I have the feeling it may not work for everyone.
|
||||
=== "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"
|
||||
```
|
||||
|
||||
181
docs/integrations.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# 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 |
|
||||
| [ntfy.mzte.de](https://ntfy.mzte.de/) | 🇩🇪 Germany |
|
||||
| [ntfy.hostux.net](https://ntfy.hostux.net/) | 🇫🇷 France |
|
||||
|
||||
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
|
||||
- [Shoutrrr](https://containrrr.dev/shoutrrr/v0.7/services/ntfy/) ⭐ - Notification library for gophers and their furry friends.
|
||||
- [Scrt.link](https://scrt.link/) - Share a secret
|
||||
- [Platypush](https://docs.platypush.tech/platypush/plugins/ntfy.html) - Automation platform aimed to run on any device that can run Python
|
||||
- [diun](https://crazymax.dev/diun/) - Docker Image Update Notifier
|
||||
- [Cloudron](https://www.cloudron.io/store/sh.ntfy.cloudronapp.html) - Platform that makes it easy to manage web apps on your server
|
||||
|
||||
## [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)
|
||||
- [ntfy-for-delphi](https://github.com/hazzelnuts/ntfy-for-delphi) - A friendly library to push instant notifications ntfy (Delphi)
|
||||
- [ntfy](https://github.com/ffflorian/ntfy) - Send notifications over ntfy (JS)
|
||||
|
||||
## 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)
|
||||
- [Grafana-ntfy-webhook-integration](https://github.com/academo/grafana-alerting-ntfy-webhook-integration) - Integrates Grafana alerts webhooks (Go)
|
||||
- [Grafana-to-ntfy](https://gitlab.com/Saibe1111/grafana-to-ntfy) - Grafana-to-ntfy alerts channel (Node Js)
|
||||
- [ntfy-long-zsh-command](https://github.com/robfox92/ntfy-long-zsh-command) - Notifies you once a long-running command completes (zsh)
|
||||
- [ntfy-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)
|
||||
- [ntfy Discord bot](https://github.com/jr1221/ntfy_discord_bot) - An advanced modal-based bot for interacting with the ntfy.sh API (Dart)
|
||||
- [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)
|
||||
- [aTable/ntfy alertmanager bridge](https://github.com/aTable/ntfy_alertmanager_bridge) - Basic alertmanager bridge to ntfy (JS)
|
||||
- [~xenrox/ntfy-alertmanager](https://hub.xenrox.net/~xenrox/ntfy-alertmanager) - A bridge between ntfy and Alertmanager (Go)
|
||||
- [pinpox/alertmanager-ntfy](https://github.com/pinpox/alertmanager-ntfy) - Relay prometheus alertmanager alerts to ntfy (Go)
|
||||
- [alexbakker/alertmanager-ntfy](https://github.com/alexbakker/alertmanager-ntfy) - Service that forwards Prometheus Alertmanager notifications 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 (Shell)
|
||||
- [ignition-ntfy-module](https://github.com/Kyvis-Labs/ignition-ntfy-module) - Adds support for sending notifications via a ntfy server to Ignition (Java)
|
||||
- [maubot-ntfy](https://gitlab.com/999eagle/maubot-ntfy) - Matrix bot to subscribe to ntfy topics and send messages to Matrix (Python)
|
||||
- [ntfy-wrapper](https://github.com/vict0rsch/ntfy-wrapper) - Wrapper around ntfy (Python)
|
||||
- [nodebb-plugin-ntfy](https://github.com/NodeBB/nodebb-plugin-ntfy) - Push notifications for NodeBB forums
|
||||
- [n8n-ntfy](https://github.com/raghavanand98/n8n-ntfy.sh) - n8n community node that lets you use ntfy in your workflows
|
||||
- [nlog-ntfy](https://github.com/MichelMichels/nlog-ntfy) - Send NLog messages over ntfy (C# / .NET / NLog)
|
||||
- [helm-charts](https://github.com/sarab97/helm-charts) - Helm charts of some of the selfhosted services, incl. ntfy
|
||||
|
||||
## Blog + forum posts
|
||||
|
||||
- [ntfy.sh](https://neo-sahara.com/wp/2023/03/25/ntfy-sh/) - neo-sahara.com - 3/2023
|
||||
- [Using Ntfy to send and receive push notifications - Samuel Rosa de Oliveria - Delphicon 2023](https://www.youtube.com/watch?v=feu0skpI9QI) - youtube.com - 3/2023
|
||||
- [ntfy: własny darmowy system powiadomień](https://sprawdzone.it/ntfy-wlasny-darmowy-system-powiadomien/) - sprawdzone.it - 3/2023
|
||||
- [Deploying ntfy on railway](https://www.youtube.com/watch?v=auJICXtxoNA) - youtube.com - 3/2023
|
||||
- [Start-Job,Variables, and ntfy.sh](https://klingele.dev/2023/03/01/start-jobvariables-and-ntfy-sh/) - klingele.dev - 3/2023
|
||||
- [enviar notificaciones automáticas usando ntfy.sh](https://osiux.com/2023-02-15-send-automatic-notifications-using-ntfy.html) - osiux.com - 2/2023
|
||||
- [Carnet IP动态解析以及通过ntfy推送IP信息](https://blog.wslll.cn/index.php/archives/201/) - blog.wslll.cn - 2/2023
|
||||
- [Open-Source-Brieftaube: ntfy verschickt Push-Meldungen auf Smartphone und PC](https://www.heise.de/news/Open-Source-Brieftaube-ntfy-verschickt-Push-Meldungen-auf-Smartphone-und-PC-7521583.html) ⭐ - heise.de - 2/2023
|
||||
- [Video: Simple Push Notifications ntfy](https://www.youtube.com/watch?v=u9EcWrsjE20) ⭐ - youtube.com - 2/2023
|
||||
- [Use ntfy.sh with Home Assistant](https://diecknet.de/en/2023/02/12/ntfy-sh-with-homeassistant/) - diecknet.de - 2/2023
|
||||
- [On installe Ntfy sur Synology Docker](https://www.maison-et-domotique.com/140356-serveur-notification-jeedom-ntfy-synology-docker/) - maison-et-domotique.co - 1/2023
|
||||
- [January 2023 Developer Update](https://community.nodebb.org/topic/16908/january-2023-developer-update) - nodebb.org - 1/2023
|
||||
- [Comment envoyer des notifications push sur votre téléphone facilement et gratuitement?](https://korben.info/notifications-push-telephone.html) - 1/2023
|
||||
- [UnifiedPush: a decentralized, open-source push notification protocol](https://f-droid.org/en/2022/12/18/unifiedpush.html) ⭐ - 12/2022
|
||||
- [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 - système de notification hyper simple et complet](https://www.youtube.com/watch?v=UieZYWVVgA4) - youtube.com - 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
|
||||
- [unRAID Notifications with ntfy.sh](https://lder.dev/posts/ntfy-Notifications-With-unRAID/) - lder.dev - 10/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 not 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
|
||||
[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,
|
||||
aside from a short on-disk cache to support service restarts.
|
||||
For debugging purposes, the ntfy server may temporarily log request paths, remote IP addresses or even topics
|
||||
or messages, though typically this is turned off.
|
||||
|
||||
2147
docs/publish.md
1153
docs/releases.md
Normal file
96
docs/static/css/extra.css
vendored
@@ -1,13 +1,20 @@
|
||||
:root {
|
||||
--md-primary-fg-color: #3a9784;
|
||||
--md-primary-fg-color--light: #3a9784;
|
||||
--md-primary-fg-color--dark: #3a9784;
|
||||
:root > * {
|
||||
--md-primary-fg-color: #338574;
|
||||
--md-primary-fg-color--light: #338574;
|
||||
--md-primary-fg-color--dark: #338574;
|
||||
--md-footer-bg-color: #353744;
|
||||
--md-text-font: "Roboto";
|
||||
--md-code-font: "Roboto Mono";
|
||||
}
|
||||
|
||||
.md-header__button.md-logo :is(img, svg) {
|
||||
width: unset !important;
|
||||
}
|
||||
|
||||
.md-header__topic:first-child {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.md-typeset h4 {
|
||||
font-weight: 500 !important;
|
||||
margin: 0 !important;
|
||||
@@ -26,12 +33,30 @@ figure img, figure video {
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="default"] figure img, body[data-md-color-scheme="default"] figure video {
|
||||
header {
|
||||
background: linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%);
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="default"] header {
|
||||
filter: drop-shadow(0 5px 10px #ccc);
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] header {
|
||||
filter: drop-shadow(0 5px 10px #333);
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="default"] figure img,
|
||||
body[data-md-color-scheme="default"] figure video,
|
||||
body[data-md-color-scheme="default"] .screenshots img,
|
||||
body[data-md-color-scheme="default"] .screenshots video {
|
||||
filter: drop-shadow(3px 3px 3px #ccc);
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] figure img, body[data-md-color-scheme="slate"] figure video {
|
||||
filter: drop-shadow(3px 3px 3px #1a1313);
|
||||
body[data-md-color-scheme="slate"] figure img,
|
||||
body[data-md-color-scheme="slate"] figure video,
|
||||
body[data-md-color-scheme="slate"] .screenshots img,
|
||||
body[data-md-color-scheme="slate"] .screenshots video {
|
||||
filter: drop-shadow(3px 3px 3px #353744);
|
||||
}
|
||||
|
||||
figure video {
|
||||
@@ -56,7 +81,8 @@ figure video {
|
||||
}
|
||||
|
||||
.screenshots img {
|
||||
height: 230px;
|
||||
max-height: 230px;
|
||||
max-width: 300px;
|
||||
margin: 3px;
|
||||
border-radius: 5px;
|
||||
filter: drop-shadow(2px 2px 2px #ddd);
|
||||
@@ -123,3 +149,57 @@ figure video {
|
||||
.lightbox .close-lightbox:hover::before {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
/* roboto-300 - latin */
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: url('../fonts/roboto-v30-latin-300.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* roboto-regular - latin */
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url('../fonts/roboto-v30-latin-regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* roboto-italic - latin */
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-family: 'Roboto';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
src: url('../fonts/roboto-v30-latin-italic.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* roboto-500 - latin */
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: url('../fonts/roboto-v30-latin-500.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* roboto-700 - latin */
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url('../fonts/roboto-v30-latin-700.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* roboto-mono - latin */
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-family: 'Roboto Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url('../fonts/roboto-mono-v22-latin-regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
BIN
docs/static/fonts/roboto-mono-v22-latin-regular.woff2
vendored
Normal file
BIN
docs/static/fonts/roboto-v30-latin-300.woff2
vendored
Normal file
BIN
docs/static/fonts/roboto-v30-latin-500.woff2
vendored
Normal file
BIN
docs/static/fonts/roboto-v30-latin-700.woff2
vendored
Normal file
BIN
docs/static/fonts/roboto-v30-latin-italic.woff2
vendored
Normal file
BIN
docs/static/fonts/roboto-v30-latin-regular.woff2
vendored
Normal file
BIN
docs/static/img/android-screenshot-icon.png
vendored
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
docs/static/img/android-screenshot-logs.jpg
vendored
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
docs/static/img/android-screenshot-notification-actions.png
vendored
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
docs/static/img/android-screenshot-notification-details.jpg
vendored
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
docs/static/img/android-screenshot-notification-multiline.jpg
vendored
Normal file
|
After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
BIN
docs/static/img/android-screenshot-share-1.jpg
vendored
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
docs/static/img/android-screenshot-share-2.jpg
vendored
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
docs/static/img/grafana-dashboard.png
vendored
Normal file
|
After Width: | Height: | Size: 334 KiB |
BIN
docs/static/img/nodered-message.png
vendored
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
docs/static/img/nodered-picture.png
vendored
Normal file
|
After Width: | Height: | Size: 9.9 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 |
BIN
docs/static/img/web-account.png
vendored
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
docs/static/img/web-detail.png
vendored
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 473 KiB |
BIN
docs/static/img/web-logs.png
vendored
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
docs/static/img/web-reserve-topic-dialog.png
vendored
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
docs/static/img/web-reserve-topic.png
vendored
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
docs/static/img/web-signup.png
vendored
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
docs/static/img/web-subscribe.png
vendored
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 76 KiB |
BIN
docs/static/img/web-token-create.png
vendored
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
docs/static/img/web-token-list.png
vendored
Normal file
|
After Width: | Height: | Size: 93 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
|
||||
|
||||
const savedTab = localStorage.getItem('savedTab')
|
||||
const tabs = document.querySelectorAll(".tabbed-set > input")
|
||||
for (const tab of tabs) {
|
||||
const savedCodeTab = localStorage.getItem('savedTab')
|
||||
const codeTabs = document.querySelectorAll(".tabbed-set > input")
|
||||
for (const tab of codeTabs) {
|
||||
tab.addEventListener("click", () => {
|
||||
const current = document.querySelector(`label[for=${tab.id}]`)
|
||||
const pos = current.getBoundingClientRect().top
|
||||
@@ -25,7 +25,7 @@ for (const tab of tabs) {
|
||||
// Select saved tab
|
||||
const current = document.querySelector(`label[for=${tab.id}]`)
|
||||
const labelContent = current.innerHTML
|
||||
if (savedTab === labelContent) {
|
||||
if (savedCodeTab === labelContent) {
|
||||
tab.checked = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ You can create and subscribe to a topic in the [web UI](web.md), via the [phone
|
||||
or in your own app or script by subscribing the API. This page describes how to subscribe via API. You may also want to
|
||||
check out the page that describes how to [publish messages](../publish.md).
|
||||
|
||||
The subscription API relies on a simple HTTP GET request with a streaming HTTP response, i.e **you open a GET request and
|
||||
You can consume the subscription API as either a **[simple HTTP stream (JSON, SSE or raw)](#http-stream)**, or
|
||||
**[via WebSockets](#websockets)**. Both are incredibly simple to use.
|
||||
|
||||
## HTTP stream
|
||||
The HTTP stream-based API relies on a simple GET request with a streaming HTTP response, i.e **you open a GET request and
|
||||
the connection stays open forever**, sending messages back as they come in. There are three different API endpoints, which
|
||||
only differ in the response format:
|
||||
|
||||
@@ -12,7 +16,7 @@ only differ in the response format:
|
||||
can be used with [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource)
|
||||
* [Raw stream](#subscribe-as-raw-stream): `<topic>/raw` returns messages as raw text, with one line per message
|
||||
|
||||
## Subscribe as JSON stream
|
||||
### Subscribe as JSON stream
|
||||
Here are a few examples of how to consume the JSON endpoint (`<topic>/json`). For almost all languages, **this is the
|
||||
recommended way to subscribe to a topic**. The notable exception is JavaScript, for which the
|
||||
[SSE/EventSource stream](#subscribe-as-sse-stream) is much easier to work with.
|
||||
@@ -80,10 +84,10 @@ recommended way to subscribe to a topic**. The notable exception is JavaScript,
|
||||
fclose($fp);
|
||||
```
|
||||
|
||||
## 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
|
||||
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)"
|
||||
```
|
||||
@@ -125,7 +129,7 @@ easy to use. Here's what it looks like. You may also want to check out the [live
|
||||
};
|
||||
```
|
||||
|
||||
## Subscribe as raw stream
|
||||
### Subscribe as raw stream
|
||||
The `/raw` endpoint will output one line per message, and **will only include the message body**. It's useful for extremely
|
||||
simple scripts, and doesn't include all the data. Additional fields such as [priority](../publish.md#message-priority),
|
||||
[tags](../publish.md#tags--emojis--) or [message title](../publish.md#message-title) are not included in this output
|
||||
@@ -184,6 +188,51 @@ format. Keepalive messages are sent as empty lines.
|
||||
fclose($fp);
|
||||
```
|
||||
|
||||
## WebSockets
|
||||
You may also subscribe to topics via [WebSockets](https://en.wikipedia.org/wiki/WebSocket), which is also widely
|
||||
supported in many languages. Most notably, WebSockets are natively supported in JavaScript. On the command line,
|
||||
I recommend [websocat](https://github.com/vi/websocat), a fantastic tool similar to `socat` or `curl`, but specifically
|
||||
for WebSockets.
|
||||
|
||||
The WebSockets endpoint is available at `<topic>/ws` and returns messages as JSON objects similar to the
|
||||
[JSON stream endpoint](#subscribe-as-json-stream).
|
||||
|
||||
=== "Command line (websocat)"
|
||||
```
|
||||
$ websocat wss://ntfy.sh/mytopic/ws
|
||||
{"id":"qRHUCCvjj8","time":1642307388,"event":"open","topic":"mytopic"}
|
||||
{"id":"eOWoUBJ14x","time":1642307754,"event":"message","topic":"mytopic","message":"hi there"}
|
||||
```
|
||||
|
||||
=== "HTTP"
|
||||
``` http
|
||||
GET /disk-alerts/ws HTTP/1.1
|
||||
Host: ntfy.sh
|
||||
Upgrade: websocket
|
||||
Connection: Upgrade
|
||||
|
||||
HTTP/1.1 101 Switching Protocols
|
||||
Upgrade: websocket
|
||||
Connection: Upgrade
|
||||
...
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
``` go
|
||||
import "github.com/gorilla/websocket"
|
||||
ws, _, _ := websocket.DefaultDialer.Dial("wss://ntfy.sh/mytopic/ws", nil)
|
||||
messageType, data, err := ws.ReadMessage()
|
||||
...
|
||||
```
|
||||
|
||||
=== "JavaScript"
|
||||
``` javascript
|
||||
const socket = new WebSocket('wss://ntfy.sh/mytopic/ws');
|
||||
socket.addEventListener('message', function (event) {
|
||||
console.log(event.data);
|
||||
});
|
||||
```
|
||||
|
||||
## Advanced features
|
||||
|
||||
### Poll for messages
|
||||
@@ -198,11 +247,13 @@ curl -s "ntfy.sh/mytopic/json?poll=1"
|
||||
### Fetch cached messages
|
||||
Messages may be cached for a couple of hours (see [message caching](../config.md#message-cache)) to account for network
|
||||
interruptions of subscribers. If the server has configured message caching, you can read back what you missed by using
|
||||
the `since=` query parameter. It takes either a duration (e.g. `10m` or `30s`), a Unix timestamp (e.g. `1635528757`)
|
||||
or `all` (all cached messages).
|
||||
the `since=` query parameter. It takes a duration (e.g. `10m` or `30s`), a Unix timestamp (e.g. `1635528757`),
|
||||
a message ID (e.g. `nFS3knfcQ1xe`), or `all` (all cached messages).
|
||||
|
||||
```
|
||||
curl -s "ntfy.sh/mytopic/json?since=10m"
|
||||
curl -s "ntfy.sh/mytopic/json?since=1645970742"
|
||||
curl -s "ntfy.sh/mytopic/json?since=nFS3knfcQ1xe"
|
||||
```
|
||||
|
||||
### Fetch scheduled messages
|
||||
@@ -216,7 +267,7 @@ curl -s "ntfy.sh/mytopic/json?poll=1&sched=1"
|
||||
```
|
||||
|
||||
### 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
|
||||
"zfs-error" and "error". Note that the `priority` filter is a logical OR and the `tags` filter is a logical AND.
|
||||
|
||||
@@ -229,12 +280,13 @@ $ curl "ntfy.sh/alerts/json?priority=high&tags=zfs-error"
|
||||
|
||||
Available filters (all case-insensitive):
|
||||
|
||||
| Filter variable | Alias | Example | Description |
|
||||
|---|---|---|---|
|
||||
| `message` | `X-Message`, `m` | `ntfy.sh/mytopic?message=lalala` | Only return messages that match this exact message string |
|
||||
| `title` | `X-Title`, `t` | `ntfy.sh/mytopic?title=some+title` | Only return messages that match this exact title string |
|
||||
| `priority` | `X-Priority`, `prio`, `p` | `ntfy.sh/mytopic?p=high,urgent` | Only return messages that match *any priority listed* (comma-separated) |
|
||||
| `tags` | `X-Tags`, `tag`, `ta` | `ntfy.sh/mytopic?tags=error,alert` | Only return messages that match *all listed tags* (comma-separated) |
|
||||
| Filter variable | Alias | Example | Description |
|
||||
|-----------------|---------------------------|-----------------------------------------------|-------------------------------------------------------------------------|
|
||||
| `id` | `X-ID` | `ntfy.sh/mytopic/json?poll=1&id=pbkiz8SD7ZxG` | Only return messages that match this exact message ID |
|
||||
| `message` | `X-Message`, `m` | `ntfy.sh/mytopic/json?message=lalala` | Only return messages that match this exact message string |
|
||||
| `title` | `X-Title`, `t` | `ntfy.sh/mytopic/json?title=some+title` | Only return messages that match this exact title string |
|
||||
| `priority` | `X-Priority`, `prio`, `p` | `ntfy.sh/mytopic/json?p=high,urgent` | Only return messages that match *any priority listed* (comma-separated) |
|
||||
| `tags` | `X-Tags`, `tag`, `ta` | `ntfy.sh/mytopic?/jsontags=error,alert` | Only return messages that match *all listed tags* (comma-separated) |
|
||||
|
||||
### Subscribe to multiple topics
|
||||
It's possible to subscribe to multiple topics in one HTTP call by providing a comma-separated list of topics
|
||||
@@ -247,37 +299,72 @@ $ curl -s ntfy.sh/mytopic1,mytopic2/json
|
||||
{"id":"Cm02DsxUHb","time":1637182643,"event":"message","topic":"mytopic2","message":"for topic 2"}
|
||||
```
|
||||
|
||||
### Authentication
|
||||
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.
|
||||
To publish/subscribe to protected topics, you can:
|
||||
|
||||
* Use [basic auth](../publish.md#basic-auth), e.g. `Authorization: Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk`
|
||||
* 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
|
||||
Both the [`/json` endpoint](#subscribe-as-json-stream) and the [`/sse` endpoint](#subscribe-as-sse-stream) return a JSON
|
||||
format of the message. It's very straight forward:
|
||||
|
||||
| Field | Required | Type | Example | Description |
|
||||
|---|---|---|---|---|
|
||||
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
|
||||
| `time` | ✔️ | *int* | `1635528741` | Message date time, as Unix time stamp |
|
||||
| `event` | ✔️ | `open`, `keepalive` or `message` | `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 |
|
||||
| `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>` |
|
||||
| `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 |
|
||||
**Message**:
|
||||
|
||||
| Field | Required | Type | Example | Description |
|
||||
|--------------|----------|---------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
|
||||
| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp |
|
||||
| `expires` | (✔)️ | *number* | `1673542291` | Unix time stamp indicating when the message will be deleted, not set if `Cache: no` is sent |
|
||||
| `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 |
|
||||
| `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>` |
|
||||
| `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 |
|
||||
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) |
|
||||
| `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):
|
||||
|
||||
| Field | Required | Type | Example | Description |
|
||||
|-----------|----------|-------------|--------------------------------|-----------------------------------------------------------------------------------------------------------|
|
||||
| `name` | ✔️ | *string* | `attachment.jpg` | Name of the attachment, can be overridden with `X-Filename`, see [attachments](../publish.md#attachments) |
|
||||
| `url` | ✔️ | *URL* | `https://example.com/file.jpg` | URL of the attachment |
|
||||
| `type` | -️ | *mime type* | `image/jpeg` | Mime type of the attachment, only defined if attachment was uploaded to ntfy server |
|
||||
| `size` | -️ | *number* | `33848` | Size of the attachment in bytes, only defined if attachment was uploaded to ntfy server |
|
||||
| `expires` | -️ | *number* | `1635528741` | Attachment expiry date as Unix time stamp, only defined if attachment was uploaded to ntfy server |
|
||||
|
||||
Here's an example for each message type:
|
||||
|
||||
=== "Notification message"
|
||||
``` json
|
||||
{
|
||||
"id": "wze9zgqK41",
|
||||
"time": 1638542110,
|
||||
"id": "sPs71M8A2T",
|
||||
"time": 1643935928,
|
||||
"expires": 1643936928,
|
||||
"event": "message",
|
||||
"topic": "phil_alerts",
|
||||
"topic": "mytopic",
|
||||
"priority": 5,
|
||||
"tags": [
|
||||
"warning",
|
||||
"skull"
|
||||
],
|
||||
"click": "https://homecam.mynet.lan/incident/1234",
|
||||
"attachment": {
|
||||
"name": "camera.jpg",
|
||||
"type": "image/png",
|
||||
"size": 33848,
|
||||
"expires": 1643946728,
|
||||
"url": "https://ntfy.sh/file/sPs71M8A2T.png"
|
||||
},
|
||||
"title": "Unauthorized access detected",
|
||||
"message": "Remote access to phils-laptop detected. Act right away."
|
||||
"message": "Movement detected in the yard. You better go check"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -287,6 +374,7 @@ Here's an example for each message type:
|
||||
{
|
||||
"id": "wze9zgqK41",
|
||||
"time": 1638542110,
|
||||
"expires": 1638543112,
|
||||
"event": "message",
|
||||
"topic": "phil_alerts",
|
||||
"message": "Remote access to phils-laptop detected. Act right away."
|
||||
@@ -313,15 +401,27 @@ Here's an example for each message type:
|
||||
}
|
||||
```
|
||||
|
||||
=== "Poll request message"
|
||||
``` json
|
||||
{
|
||||
"id": "371sevb0pD",
|
||||
"time": 1638542275,
|
||||
"event": "poll_request",
|
||||
"topic": "phil_alerts"
|
||||
}
|
||||
```
|
||||
|
||||
## List of all parameters
|
||||
The following is a list of all parameters that can be passed when subscribing to a message. Parameter names are **case-insensitive**,
|
||||
The following is a list of all parameters that can be passed **when subscribing to a message**. Parameter names are **case-insensitive**,
|
||||
and can be passed as **HTTP headers** or **query parameters in the URL**. They are listed in the table in their canonical form.
|
||||
|
||||
| Parameter | Aliases (case-insensitive) | Description |
|
||||
|---|---|---|
|
||||
| `poll` | `X-Poll`, `po` | Return cached messages and close connection |
|
||||
| `scheduled` | `X-Scheduled`, `sched` | Include scheduled/delayed messages in message list |
|
||||
| `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 |
|
||||
| `priority` | `X-Priority`, `prio`, `p` | Filter: Only return messages that match *any priority listed* (comma-separated) |
|
||||
| `tags` | `X-Tags`, `tag`, `ta` | Filter: Only return messages that match *all listed tags* (comma-separated) |
|
||||
| Parameter | Aliases (case-insensitive) | Description |
|
||||
|-------------|----------------------------|---------------------------------------------------------------------------------|
|
||||
| `poll` | `X-Poll`, `po` | Return cached messages and close connection |
|
||||
| `since` | `X-Since`, `si` | Return cached messages since timestamp, duration or message ID |
|
||||
| `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 |
|
||||
| `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) |
|
||||
| `tags` | `X-Tags`, `tag`, `ta` | Filter: Only return messages that match *all listed tags* (comma-separated) |
|
||||
|
||||
@@ -56,6 +56,71 @@ quick ones:
|
||||
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
|
||||
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
|
||||
@@ -103,16 +168,16 @@ The message fields are passed to the command as environment variables and can be
|
||||
these are environment variables, you typically don't have to worry about quoting too much, as long as you enclose them
|
||||
in double-quotes, you should be fine:
|
||||
|
||||
| Variable | Aliases | Description |
|
||||
|---|---|---
|
||||
| `$NTFY_ID` | `$id` | Unique message ID |
|
||||
| `$NTFY_TIME` | `$time` | Unix timestamp of the message delivery |
|
||||
| `$NTFY_TOPIC` | `$topic` | Topic name |
|
||||
| `$NTFY_MESSAGE` | `$message`, `$m` | Message body |
|
||||
| `$NTFY_TITLE` | `$title`, `$t` | Message title |
|
||||
| `$NTFY_PRIORITY` | `$priority`, `$prio`, `$p` | Message priority (1=min, 5=max) |
|
||||
| `$NTFY_TAGS` | `$tags`, `$tag`, `$ta` | Message tags (comma separated list) |
|
||||
| `$NTFY_RAW` | `$raw` | Raw JSON message |
|
||||
| Variable | Aliases | Description |
|
||||
|------------------|----------------------------|----------------------------------------|
|
||||
| `$NTFY_ID` | `$id` | Unique message ID |
|
||||
| `$NTFY_TIME` | `$time` | Unix timestamp of the message delivery |
|
||||
| `$NTFY_TOPIC` | `$topic` | Topic name |
|
||||
| `$NTFY_MESSAGE` | `$message`, `$m` | Message body |
|
||||
| `$NTFY_TITLE` | `$title`, `$t` | Message title |
|
||||
| `$NTFY_PRIORITY` | `$priority`, `$prio`, `$p` | Message priority (1=min, 5=max) |
|
||||
| `$NTFY_TAGS` | `$tags`, `$tag`, `$ta` | Message tags (comma separated list) |
|
||||
| `$NTFY_RAW` | `$raw` | Raw JSON message |
|
||||
|
||||
### Subscribe to multiple topics
|
||||
```
|
||||
@@ -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:
|
||||
|
||||
=== "~/.config/ntfy/client.yml"
|
||||
=== "~/.config/ntfy/client.yml (Linux)"
|
||||
```yaml
|
||||
subscribe:
|
||||
- topic: echo-this
|
||||
@@ -145,12 +210,42 @@ Here's an example config file that subscribes to three different topics, executi
|
||||
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:
|
||||
|
||||
* 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 `calc` open the gnome calculator 😀 (*because, why not*)
|
||||
* Messages to `print-temp` execute an inline script and print the CPU temperature
|
||||
* 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),
|
||||
[notifu](https://www.paralint.com/projects/notifu/) (Windows) or `osascript` (macOS)
|
||||
* 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:
|
||||
|
||||
@@ -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>
|
||||
</figure>
|
||||
|
||||
If most (or all) of your subscriptions use the same credentials, you can set defaults in `client.yml`. Use `default-user` and `default-password` or `default-token` (but not both).
|
||||
You can also specify a `default-command` that will run when a message is received. If a subscription does not include credentials 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`, `default-password`, and `default-token` 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
|
||||
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)
|
||||
@@ -196,3 +299,27 @@ EOF
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart ntfy-client
|
||||
```
|
||||
|
||||
|
||||
### Authentication
|
||||
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.
|
||||
To publish/subscribe to protected topics, you can use [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication)
|
||||
with a valid username/password. For your self-hosted server, **be sure to use HTTPS to avoid eavesdropping** and exposing
|
||||
your password.
|
||||
|
||||
You can either add your username and password to the configuration file:
|
||||
=== "~/.config/ntfy/client.yml"
|
||||
```yaml
|
||||
- topic: secret
|
||||
command: 'notify-send "$m"'
|
||||
user: phill
|
||||
password: mypass
|
||||
```
|
||||
|
||||
Or with the `ntfy subscibe` command:
|
||||
```
|
||||
ntfy subscribe \
|
||||
-u phil:mypass \
|
||||
ntfy.example.com/mysecrets
|
||||
```
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
# Subscribe from your phone
|
||||
You can use the [ntfy Android App](https://play.google.com/store/apps/details?id=io.heckel.ntfy) to receive
|
||||
notifications directly on your phone. Just like the server, this app is also [open source](https://github.com/binwiederhier/ntfy-android).
|
||||
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).
|
||||
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)
|
||||
to receive notifications directly on your phone. Just like the server, this app is also open source, and the code is available
|
||||
on GitHub ([Android](https://github.com/binwiederhier/ntfy-android), [iOS](https://github.com/binwiederhier/ntfy-ios)). Feel free to
|
||||
contribute, or [build your own](../develop.md).
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="../../static/img/badge-googleplay.png"></a>
|
||||
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="../../static/img/badge-fdroid.png"></a>
|
||||
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img src="../../static/img/badge-appstore.png"></a>
|
||||
|
||||
You can get the Android app from both [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) and
|
||||
from [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/). Both are largely identical, with the one exception that
|
||||
the F-Droid flavor does not use Firebase.
|
||||
the 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
|
||||
A picture is worth a thousand words. Here are a few screenshots showing what the app looks like. It's all pretty
|
||||
@@ -31,7 +33,9 @@ If those screenshots are still not enough, here's a video:
|
||||
</figure>
|
||||
|
||||
## Message priority
|
||||
When you [publish messages](../publish.md#message-priority) to a topic, you can define a priority. This priority defines
|
||||
_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
|
||||
how urgently Android will notify you about the notification, and whether they make a sound and/or vibrate.
|
||||
|
||||
By default, messages with default priority or higher (>= 3) will vibrate and make a sound. Messages with high or urgent
|
||||
@@ -42,15 +46,25 @@ priority (>= 4) will also show as pop-over, like so:
|
||||
<figcaption>High and urgent notifications show as pop-over</figcaption>
|
||||
</figure>
|
||||
|
||||
You can change these settings in Android by long-pressing on the app, and tapping "Notifications". You can then configure
|
||||
the settings (and custom sounds or vibration) for each of the priorities:
|
||||
You can change these settings in Android by long-pressing on the app, and tapping "Notifications", or from the "Settings"
|
||||
menu under "Channel settings". There is one notification channel for each priority:
|
||||
|
||||
<figure markdown>
|
||||
{ width=500 }
|
||||
{ width=500 }
|
||||
<figcaption>Per-priority channels</figcaption>
|
||||
</figure>
|
||||
|
||||
Per notification channel, you can configure a **channel-specific sound**, whether to **override the Do Not Disturb (DND)**
|
||||
setting, and other settings such as popover or notification dot:
|
||||
|
||||
<figure markdown>
|
||||
{ width=500 }
|
||||
<figcaption>Per-priority sound/vibration settings</figcaption>
|
||||
</figure>
|
||||
|
||||
## 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.
|
||||
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:
|
||||
@@ -80,9 +94,43 @@ notifications. Firebase is overall pretty bad at delivering messages in time, bu
|
||||
The ntfy Android app uses Firebase only for the main host `ntfy.sh`, and only in the Google Play flavor of the app.
|
||||
It won't use Firebase for any self-hosted servers, and not at all in the the F-Droid flavor.
|
||||
|
||||
## 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
|
||||
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.
|
||||
|
||||
The feature is pretty self-explanatory, and one picture says more than a thousand words. So here are two pictures:
|
||||
|
||||
<div id="share-to-topic-screenshots" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-share-1.jpg"><img src="../../static/img/android-screenshot-share-1.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-share-2.jpg"><img src="../../static/img/android-screenshot-share-2.jpg"/></a>
|
||||
</div>
|
||||
|
||||
## 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)
|
||||
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.
|
||||
|
||||
!!! info
|
||||
Android deep linking of http/https links is very brittle and limited, which is why something like `https://<host>/<topic>/subscribe` is
|
||||
**not possible**, and instead `ntfy://` links have to be used. More details in [issue #20](https://github.com/binwiederhier/ntfy/issues/20).
|
||||
|
||||
**Supported link formats:**
|
||||
|
||||
| Link format | Example | Description |
|
||||
|-------------------------------------------------------------------------------|-------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| <span style="white-space: nowrap">`ntfy://<host>/<topic>`</span> | `ntfy://ntfy.sh/mytopic` | Directly opens the Android app detail view for the given topic and server. Subscribes to the topic if not already subscribed. This is equivalent to the web view `https://ntfy.sh/mytopic` (HTTPS!) |
|
||||
| <span style="white-space: nowrap">`ntfy://<host>/<topic>?secure=false`</span> | `ntfy://example.com/mytopic?secure=false` | Same as above, except that this will use HTTP instead of HTTPS as topic URL. This is equivalent to the web view `http://example.com/mytopic` (HTTP!) |
|
||||
|
||||
## Integrations
|
||||
|
||||
### UnifiedPush
|
||||
_Supported on:_ :material-android:
|
||||
|
||||
[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
|
||||
in the control of the user. ntfy can act as a **UnifiedPush distributor**, forwarding messages to apps that support it.
|
||||
@@ -98,6 +146,8 @@ to handle messages. Here's an example with [FluffyChat](https://fluffychat.im/):
|
||||
</div>
|
||||
|
||||
### 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)
|
||||
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**.
|
||||
@@ -130,19 +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`:
|
||||
|
||||
| Extra name | Type | Example | Description |
|
||||
|---|---|---|---|
|
||||
| `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 |
|
||||
| `topic` ❤️ | *string* | `mytopic` | Topic name; **you'll likely want to filter for a specific topic** |
|
||||
| `muted` | *bool* | `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` |
|
||||
| `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 |
|
||||
| `message` ❤️ | *string* | `Some message` | Message body; **this is likely what you're interested in** |
|
||||
| `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 |
|
||||
| `priority` | *int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
||||
| Extra name | Type | Example | Description |
|
||||
|----------------------|------------------------------|------------------------------------------|------------------------------------------------------------------------------------|
|
||||
| `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 |
|
||||
| `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_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 |
|
||||
| `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_bytes` | *ByteArray* | `(binary data)` | Message body as binary data |
|
||||
| `encoding`️ | *String* | - | Message encoding (empty or "base64") |
|
||||
| `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 |
|
||||
| `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
|
||||
To send messages from other apps (such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
||||
@@ -164,17 +222,11 @@ Here's what that looks like:
|
||||
|
||||
The following intent extras are supported when for the intent with the `io.heckel.ntfy.SEND_MESSAGE` action:
|
||||
|
||||
| Extra name | Required | Type | Example | Description |
|
||||
|---|---|---|---|---|
|
||||
| `base_url` | - | *string* | `https://ntfy.sh` | Root URL of the ntfy server this message came from, defaults to `https://ntfy.sh` |
|
||||
| `topic` ❤️ | ✔ | *string* | `mytopic` | Topic name; **you must set this** |
|
||||
| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); may be empty if not set |
|
||||
| `message` ❤️ | ✔ | *string* | `Some message` | Message body; **you must set this** |
|
||||
| `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 |
|
||||
|
||||
## 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>
|
||||
| Extra name | Required | Type | Example | Description |
|
||||
|--------------|----------|-------------------------------|-------------------|------------------------------------------------------------------------------------|
|
||||
| `base_url` | - | *String* | `https://ntfy.sh` | Root URL of the ntfy server this message came from, defaults to `https://ntfy.sh` |
|
||||
| `topic` ❤️ | ✔ | *String* | `mytopic` | Topic name; **you must set this** |
|
||||
| `title` | - | *String* | `Some title` | Message [title](../publish.md#message-title); may be empty if not set |
|
||||
| `message` ❤️ | ✔ | *String* | `Some message` | Message body; **you must set this** |
|
||||
| `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 |
|
||||
|
||||