Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81a589483f | ||
|
|
4c2b69e059 | ||
|
|
ea6b1ca520 | ||
|
|
423063893d | ||
|
|
143b9fb55c | ||
|
|
3104ad20e2 | ||
|
|
00e3c1351a | ||
|
|
c23dc0b30c | ||
|
|
01af61d228 | ||
|
|
9328f1f3c8 | ||
|
|
61873b593f | ||
|
|
9dc30e4a97 | ||
|
|
cd333f130f | ||
|
|
f42326605e | ||
|
|
7ed7fea081 | ||
|
|
01c13e186a | ||
|
|
0ecba37334 | ||
|
|
ccfbc2309d | ||
|
|
fc808db251 | ||
|
|
5a6c4277ad | ||
|
|
dc11655153 | ||
|
|
bd96177fdf | ||
|
|
a3d43190c9 | ||
|
|
95ad323d1c | ||
|
|
e7efdaeb3b | ||
|
|
244a9bc06d | ||
|
|
5e13ca05d5 | ||
|
|
fdaf3316a0 | ||
|
|
310a5aa8df | ||
|
|
0ff1cd5bab | ||
|
|
4abdeb8d57 | ||
|
|
7fb1d25740 | ||
|
|
ef121a3f6c | ||
|
|
4a0f66e258 | ||
|
|
53bbd12cd7 | ||
|
|
e620fbe95b | ||
|
|
b703627d7f | ||
|
|
6f4f9e6407 | ||
|
|
f2d5c1ce6c | ||
|
|
7680cb4906 | ||
|
|
706fa3b491 | ||
|
|
2bc145f3ae | ||
|
|
311138ef7b | ||
|
|
f6b03b44dd | ||
|
|
c674985699 | ||
|
|
469d263a5c | ||
|
|
f8d2fcd7a6 | ||
|
|
ac63a2eea0 | ||
|
|
07e1260b89 | ||
|
|
af4d85ec03 | ||
|
|
24bc50b585 | ||
|
|
b55e78a918 | ||
|
|
6638699d48 | ||
|
|
3f56dae54a | ||
|
|
1e4e3b6e36 | ||
|
|
75c687de1c | ||
|
|
432da44dc4 | ||
|
|
703d7bb9de | ||
|
|
98a0daba86 | ||
|
|
ce01b357d8 | ||
|
|
c40a2ce0a7 | ||
|
|
88e598d8b8 | ||
|
|
6869d166ae | ||
|
|
08d81a3645 | ||
|
|
202d858826 | ||
|
|
73e9d46b49 | ||
|
|
9a021aba2d | ||
|
|
812dc4cded | ||
|
|
9f6c4743b3 | ||
|
|
165f012ae6 | ||
|
|
1d69ebaf58 | ||
|
|
a001ac5195 | ||
|
|
bde864756e | ||
|
|
b6d21415bb | ||
|
|
2e3d5babc8 | ||
|
|
b16efd2cb6 | ||
|
|
24f991c6d7 | ||
|
|
b15213e531 | ||
|
|
7b54850c16 | ||
|
|
be3dbdcb48 | ||
|
|
2eabfd2f01 | ||
|
|
d0054ea394 | ||
|
|
97282d6e3d | ||
|
|
ef7aa31881 | ||
|
|
aaa124973b | ||
|
|
69c2c18a9e | ||
|
|
479e406493 | ||
|
|
6429d36708 | ||
|
|
73f771dafa | ||
|
|
5e1f27d709 | ||
|
|
6ed71dd4d0 | ||
|
|
b4adf85805 | ||
|
|
7c0ab6e6b0 | ||
|
|
e39de727e0 | ||
|
|
8a67b5129e | ||
|
|
f5067d295c | ||
|
|
958ce520ec | ||
|
|
edbf600cd7 | ||
|
|
6ed57ec064 | ||
|
|
e28d8aca59 | ||
|
|
a6f0447482 | ||
|
|
f2b22c114f | ||
|
|
c873064cad | ||
|
|
4c0bd70408 | ||
|
|
9078662bdd | ||
|
|
5970f03973 | ||
|
|
6cbcfd95fa | ||
|
|
6b1339ff4a | ||
|
|
7881b973d7 | ||
|
|
3a5458d237 | ||
|
|
f6ab9e7c58 | ||
|
|
a8ac283353 | ||
|
|
86b077a292 | ||
|
|
d44a566528 | ||
|
|
72b7155130 | ||
|
|
aeacd0a3d6 | ||
|
|
3302ad2479 | ||
|
|
c9105dad09 | ||
|
|
5765be7892 | ||
|
|
7a99bfc717 | ||
|
|
156ad4ae92 | ||
|
|
a6dc691672 | ||
|
|
9114d1e996 | ||
|
|
55d871aeb2 | ||
|
|
a5318a4312 | ||
|
|
f77d299aa3 | ||
|
|
af66cc3ddc | ||
|
|
a47eef5006 | ||
|
|
0ac7cbd4fe | ||
|
|
72ac95148b | ||
|
|
817a0ece7e | ||
|
|
e617c5f453 | ||
|
|
a04a12eb5f | ||
|
|
46799db753 | ||
|
|
5e32f05302 | ||
|
|
ad8391dd31 | ||
|
|
e3889746c4 | ||
|
|
df6f1f3ee1 | ||
|
|
047a1258f1 | ||
|
|
2b1f2d6b9a | ||
|
|
87e9dc9da4 | ||
|
|
5d96888f0e | ||
|
|
65e409e9e4 | ||
|
|
8022401728 | ||
|
|
6d86574a38 | ||
|
|
6a4f68897a | ||
|
|
b0411e5c92 | ||
|
|
c5e5be0746 | ||
|
|
ab2966e70f | ||
|
|
3a960b1b89 | ||
|
|
ad45f70fd1 | ||
|
|
4b070546b5 | ||
|
|
e6a201bc11 | ||
|
|
8327047b71 | ||
|
|
c44575d7a1 | ||
|
|
f121f2ba8d | ||
|
|
0c313906ef | ||
|
|
ab1e170a20 | ||
|
|
fb75a65885 | ||
|
|
74240328dc | ||
|
|
3bfb9f334b | ||
|
|
7b37f2a3eb | ||
|
|
d5b13a925e | ||
|
|
50ac2c1925 | ||
|
|
2b30ce9ee0 | ||
|
|
9b3ab5a302 | ||
|
|
4313b02fc6 | ||
|
|
d8666b66ec | ||
|
|
5808c4d4c0 | ||
|
|
954fae44dc | ||
|
|
1979dbc7c3 | ||
|
|
d7dea6d250 | ||
|
|
eab3988304 | ||
|
|
7494d0acf6 | ||
|
|
914bf3b0c4 | ||
|
|
c45744558b | ||
|
|
74332fa302 | ||
|
|
3d02c99394 | ||
|
|
36d7d3bd24 | ||
|
|
8a7b73cc7e | ||
|
|
bb2ca0facf | ||
|
|
2ee8717e0c | ||
|
|
cac3b2986a | ||
|
|
19af0b65cc | ||
|
|
01eabb288a | ||
|
|
99bc803271 | ||
|
|
d8c87d04e7 | ||
|
|
b75d0e582c | ||
|
|
44d5bcf875 | ||
|
|
fc59339f86 | ||
|
|
5834f667b2 | ||
|
|
9fa8550ef6 | ||
|
|
eff808d0f8 | ||
|
|
ca58b885cb | ||
|
|
1215e99098 | ||
|
|
f558935c1e | ||
|
|
4516adea36 | ||
|
|
33ae31055c | ||
|
|
30dd4840a2 | ||
|
|
fd0f0657b9 | ||
|
|
44dac47d76 | ||
|
|
fd716e4807 | ||
|
|
a0775701c1 | ||
|
|
d824f1a11a | ||
|
|
2d7faac0ea | ||
|
|
d1696ac5b6 | ||
|
|
737163ba59 | ||
|
|
26bc28ae24 | ||
|
|
88fff8264b | ||
|
|
9cc6124f18 | ||
|
|
5ff460f1dc | ||
|
|
19a7a14b1d | ||
|
|
ddb878d985 | ||
|
|
33e303272a | ||
|
|
9ab1cf8918 | ||
|
|
b33f695dcd | ||
|
|
e3e7d03f2c | ||
|
|
b4cb1bb7fb | ||
|
|
d19617bb6b | ||
|
|
cb0f977120 | ||
|
|
3e81620dac | ||
|
|
30d0532811 | ||
|
|
93d45eba6f | ||
|
|
3eab9e0672 | ||
|
|
2bf5dd26eb | ||
|
|
869f005136 | ||
|
|
d2507dbeed | ||
|
|
ae27172f8d | ||
|
|
e18329a17e | ||
|
|
104182a8be | ||
|
|
9d3019004e | ||
|
|
7614405332 | ||
|
|
4196e6444c | ||
|
|
a2dc290f31 | ||
|
|
0e2c459d6b | ||
|
|
2f4afbdae5 | ||
|
|
204723f3c0 | ||
|
|
301be79f0a | ||
|
|
03d405ed80 | ||
|
|
4b87a27326 | ||
|
|
d987796243 | ||
|
|
c841caa3b3 | ||
|
|
6310e3a96f | ||
|
|
62a812b742 | ||
|
|
5c5766b031 | ||
|
|
2d2b1635fe | ||
|
|
c51a3c0cb1 | ||
|
|
6481713626 | ||
|
|
561a44b29b | ||
|
|
edc504b47a | ||
|
|
0635e1efdb | ||
|
|
8831d2f87f | ||
|
|
2215479294 | ||
|
|
aab944fc39 | ||
|
|
a829d1a4e1 | ||
|
|
965942844a | ||
|
|
4dd7d1cd62 | ||
|
|
f22f913590 | ||
|
|
f55886e3cc | ||
|
|
b908213f02 | ||
|
|
6596551bc1 | ||
|
|
5d6b864130 | ||
|
|
36ab5b3a8b | ||
|
|
9f217d9d40 | ||
|
|
360b40ec07 | ||
|
|
a3f0f6cfa0 | ||
|
|
0c819a003d | ||
|
|
ef0dde8aa4 | ||
|
|
85abd40658 | ||
|
|
fda636fe34 | ||
|
|
25520c4505 | ||
|
|
dbcc89ed8b | ||
|
|
6796f6147b | ||
|
|
134c4dd079 | ||
|
|
47044c632e | ||
|
|
b1008a78c4 | ||
|
|
46a5338a30 | ||
|
|
726b9d2b2c | ||
|
|
9302697a07 | ||
|
|
36ba1650ed | ||
|
|
a1d880aab9 | ||
|
|
acb4c1b3cc | ||
|
|
19bcf0f658 | ||
|
|
29113402ce | ||
|
|
160c916ce0 | ||
|
|
578abdfe08 | ||
|
|
b5ff765bb7 | ||
|
|
f24bf7b51a | ||
|
|
9ccad9da2e | ||
|
|
df2ce34dc0 | ||
|
|
d6397fc5e5 | ||
|
|
c15a242d1a | ||
|
|
c6e252b3d6 | ||
|
|
bdad542fc0 | ||
|
|
3758472345 | ||
|
|
3af7087af3 | ||
|
|
1af07233a9 | ||
|
|
c037e78bd6 | ||
|
|
8f2f69a512 | ||
|
|
a5cf3c0b74 | ||
|
|
6ba3b7c8be | ||
|
|
802c0a4c30 | ||
|
|
33a67cf05b | ||
|
|
8fb7f61dea | ||
|
|
4fbb8441ee | ||
|
|
9fc96bfb8f | ||
|
|
fe8c9b8f2c | ||
|
|
5ad2431dc3 | ||
|
|
2401e183d2 | ||
|
|
fa83d68754 | ||
|
|
1956c88392 | ||
|
|
eefd3d1a54 | ||
|
|
e10ece9715 | ||
|
|
ac09f9802b | ||
|
|
82e9dfe8f1 | ||
|
|
6e90b16d0d | ||
|
|
6cfadf9681 | ||
|
|
59229adf31 | ||
|
|
517bc45f1c | ||
|
|
e1dde9f385 | ||
|
|
a063e2bb35 | ||
|
|
79e70c9f62 | ||
|
|
55b27260cf | ||
|
|
11ff36a19e | ||
|
|
5c9e29ba1c | ||
|
|
5d93cb400a | ||
|
|
4b0a4eee3b | ||
|
|
795ef9da1c | ||
|
|
7e16203065 | ||
|
|
c11991a91d | ||
|
|
85cc652449 | ||
|
|
ec494aead3 | ||
|
|
1b948e9dfa | ||
|
|
ad7dc1bf5e | ||
|
|
4c3968eaba | ||
|
|
cc61d79313 | ||
|
|
9a2b93f7b2 | ||
|
|
05e0e4ed05 | ||
|
|
a47835f21f | ||
|
|
36b76eb318 | ||
|
|
eb624f4bc5 | ||
|
|
4417b951cc | ||
|
|
17ec63df77 | ||
|
|
7ce5e8adda | ||
|
|
6219784aae | ||
|
|
51da5e0f77 | ||
|
|
e57ef84f13 | ||
|
|
4c6225e311 | ||
|
|
07b3812549 | ||
|
|
63ec73a319 | ||
|
|
ffa22fc24b | ||
|
|
bdea8c314f | ||
|
|
3e634e0a5a | ||
|
|
6aebc5c677 | ||
|
|
61dd788dac | ||
|
|
266d0b9d37 | ||
|
|
835d1faac4 | ||
|
|
67fc7fe96a | ||
|
|
92fa88cf12 | ||
|
|
b5fee121d7 | ||
|
|
e1a344339f | ||
|
|
ae1ecfa1e9 | ||
|
|
874bdcf9f1 | ||
|
|
2235d44726 | ||
|
|
27bbb10a31 | ||
|
|
be4134fc3b | ||
|
|
e19ba059b5 | ||
|
|
11a14d8fe7 | ||
|
|
3759ff26b4 | ||
|
|
136b50f926 | ||
|
|
2770f65027 | ||
|
|
db6f813386 | ||
|
|
15d963cb53 | ||
|
|
a2206dba9f | ||
|
|
d159580ecf | ||
|
|
0de9dc11ad | ||
|
|
f790143b0b | ||
|
|
42b0254c9b | ||
|
|
d183af61fa | ||
|
|
f256a4101b | ||
|
|
3ff8bacc45 | ||
|
|
ca59cfc1e1 | ||
|
|
071543efda | ||
|
|
e22a77d4bb | ||
|
|
e0362dce36 | ||
|
|
e55d1cee6b | ||
|
|
676f1ff1cb | ||
|
|
dd760f16f7 | ||
|
|
d00277107a | ||
|
|
b95efe8dd3 | ||
|
|
075f2ffa15 | ||
|
|
2f6a044c34 | ||
|
|
e6192c94bd | ||
|
|
e87a3e62fe | ||
|
|
233ec0973d | ||
|
|
69cc80ec1e | ||
|
|
4d07897d2d | ||
|
|
a04128520d | ||
|
|
ef051afc09 | ||
|
|
f8397838e6 | ||
|
|
59ec76e8b2 | ||
|
|
9b3408d4c4 | ||
|
|
536c6f5807 | ||
|
|
56b63c475c | ||
|
|
fa33d63138 | ||
|
|
f76135c5ab | ||
|
|
f2d4575831 | ||
|
|
6a7c1c47aa | ||
|
|
ad501feab1 | ||
|
|
b81218953a | ||
|
|
6f47d77200 | ||
|
|
d5466f30e5 | ||
|
|
52cad203fa | ||
|
|
b3a8f18019 | ||
|
|
78d3138565 | ||
|
|
6a820b5030 | ||
|
|
1742302f83 | ||
|
|
f25f0e21f0 | ||
|
|
22225c5bd0 | ||
|
|
393f730d11 | ||
|
|
02ea09ab0f | ||
|
|
039ef19e6c | ||
|
|
f9974d8a2f | ||
|
|
0d162b984b | ||
|
|
1f270b68e0 | ||
|
|
d86e20173c | ||
|
|
a1b403d23c | ||
|
|
5ca0cf9912 | ||
|
|
ef314960d0 | ||
|
|
cffa57950a | ||
|
|
a47d692cbf | ||
|
|
6b11bc7468 | ||
|
|
d9efe50848 | ||
|
|
2ad78edca1 | ||
|
|
86015e100c | ||
|
|
e8199fa6b5 | ||
|
|
c29a7bc8cc | ||
|
|
458fbad770 | ||
|
|
9b1a32ec56 | ||
|
|
3d9ce69042 | ||
|
|
59ce581ba2 | ||
|
|
df82fdf44c | ||
|
|
3a37ea32f7 | ||
|
|
790ba243c7 | ||
|
|
4487299a80 | ||
|
|
6b38acb23a | ||
|
|
f5c255c53c | ||
|
|
fd0a49244e | ||
|
|
4699ed3ffd | ||
|
|
1afb99db67 | ||
|
|
66208e6f88 | ||
|
|
ce24594c32 | ||
|
|
888850d8bc | ||
|
|
be09acd411 | ||
|
|
bf19a5be2d | ||
|
|
b4ec6fa8df | ||
|
|
d517ce4a2a | ||
|
|
fd8f356d1f | ||
|
|
3296d158c5 | ||
|
|
45f045a5a4 | ||
|
|
f7b6e9bbe3 | ||
|
|
22868f4742 | ||
|
|
3801a28958 | ||
|
|
2bf8f6271b | ||
|
|
13be9747e4 | ||
|
|
26dd017401 | ||
|
|
d00cd64220 | ||
|
|
fab08e862d | ||
|
|
143935b917 | ||
|
|
a82ede8a14 | ||
|
|
8a34dfe3f8 | ||
|
|
270fec51a6 | ||
|
|
9eaadd74cf | ||
|
|
1f483dcbd3 | ||
|
|
85bdfc61ce | ||
|
|
ac65df1e83 | ||
|
|
78f0593abe | ||
|
|
aca58f040f | ||
|
|
ab33ac7ae5 | ||
|
|
f1865749d7 | ||
|
|
997e20fa3f | ||
|
|
3402510b47 | ||
|
|
19d1618bb8 | ||
|
|
612afb1435 | ||
|
|
2b36ad9eb9 | ||
|
|
bcd07115c2 | ||
|
|
109271a930 | ||
|
|
fcf95dc9b8 | ||
|
|
79c3ab9ecc | ||
|
|
d51465fb6a | ||
|
|
0b189f65ff | ||
|
|
0d4b1b00e6 | ||
|
|
28c3fd5cbe | ||
|
|
62bb335675 | ||
|
|
70fb2732af | ||
|
|
8e91e028a0 | ||
|
|
6d22f568f9 | ||
|
|
59e6c16633 | ||
|
|
2e0b934bc2 | ||
|
|
4f4a093f8d | ||
|
|
5610b7c56d | ||
|
|
d4038f566c | ||
|
|
bff2b47eb6 | ||
|
|
33b19814c7 | ||
|
|
fb26e7ef3a | ||
|
|
66449bd19b | ||
|
|
bedbb121e4 | ||
|
|
c4b8cfa756 | ||
|
|
c864a9baeb | ||
|
|
8afeb813d9 | ||
|
|
ea4739f79b | ||
|
|
31f0234098 | ||
|
|
0e6b467cf0 | ||
|
|
11c79a6369 | ||
|
|
10a6939d8e | ||
|
|
9736973286 | ||
|
|
941c43c10b | ||
|
|
af76aa011d | ||
|
|
039566bcaf | ||
|
|
544ce112b5 | ||
|
|
c19377109e | ||
|
|
ccbd02331c | ||
|
|
542aa403d2 | ||
|
|
ebb48e217d | ||
|
|
7710ace184 | ||
|
|
b937b44f2d | ||
|
|
e618cf1a39 | ||
|
|
e9cf2b5523 | ||
|
|
c49a8179cf | ||
|
|
a1cca7972d | ||
|
|
da1c7b1949 | ||
|
|
5c26e70fe7 | ||
|
|
c66fa92341 | ||
|
|
a7d5a9c5d8 | ||
|
|
391cd2c920 | ||
|
|
9eec72adcc | ||
|
|
28a436c0d2 | ||
|
|
b02366b42b | ||
|
|
90d0eca14d | ||
|
|
0d375d3a08 | ||
|
|
811c7ae25a | ||
|
|
850a9d4cc4 | ||
|
|
43280fbc0a | ||
|
|
35a54407a8 | ||
|
|
f726cc768e | ||
|
|
5d301e7dce | ||
|
|
8b12bdeb3a | ||
|
|
390cff0604 | ||
|
|
6375c2ce60 | ||
|
|
459c80ef9b | ||
|
|
b1eb90addc | ||
|
|
4b6979aa89 | ||
|
|
c76e39bb0e | ||
|
|
b82e1c3915 | ||
|
|
07e60ba041 | ||
|
|
4e22e7f4c8 | ||
|
|
cf3ae187ce | ||
|
|
d19b825192 | ||
|
|
2e499389fc | ||
|
|
7c69a76345 | ||
|
|
a28d8e7924 | ||
|
|
13a3062a7f | ||
|
|
eb6e1ac44a | ||
|
|
a4c836b531 | ||
|
|
e818b063f7 | ||
|
|
039d555689 | ||
|
|
209d5a4c62 | ||
|
|
bf265449ac | ||
|
|
4cbd80c68e | ||
|
|
305e3fc9af | ||
|
|
9e4a48b058 | ||
|
|
93cd7f99f8 | ||
|
|
28e85df36e | ||
|
|
939b3d1117 | ||
|
|
9cc9891f49 | ||
|
|
0d1f3444f2 | ||
|
|
2716ede6e1 | ||
|
|
2bc7b5217b | ||
|
|
046c0e8c79 | ||
|
|
652b2097ad | ||
|
|
ae5e1fe8d8 | ||
|
|
e3a402ed95 | ||
|
|
1abc1005d0 | ||
|
|
909c3fe17b | ||
|
|
07c3e280bf | ||
|
|
b567b4e904 | ||
|
|
60fa50f0d5 | ||
|
|
ceda5ec3d8 | ||
|
|
3d72845c81 | ||
|
|
82e15d84bd | ||
|
|
4e5f95ba0c | ||
|
|
869b972a50 | ||
|
|
bdd20197b3 | ||
|
|
a8dcecdb6d | ||
|
|
5331437664 | ||
|
|
e432bf2886 | ||
|
|
0edad84d86 | ||
|
|
ddf728acd1 | ||
|
|
b1d3671dbb | ||
|
|
3e6b46ec0c | ||
|
|
b16d381626 | ||
|
|
3bd1a1ea03 | ||
|
|
7adb37b94b | ||
|
|
bc08819525 | ||
|
|
a03a37feb1 | ||
|
|
4cd556f5aa | ||
|
|
90aeb811ff | ||
|
|
c6ab380ea4 | ||
|
|
7860f2142c | ||
|
|
18d5d31bd2 | ||
|
|
cfdc364e3f | ||
|
|
763215ecfa | ||
|
|
3f0a7b65ee | ||
|
|
65050ef4dc | ||
|
|
3647d3975c | ||
|
|
06ea1f98ac | ||
|
|
2827df26ee | ||
|
|
fe6ee1efa0 | ||
|
|
14df6462df | ||
|
|
b9f659c8ac | ||
|
|
623fd4f224 | ||
|
|
49991d5aa7 | ||
|
|
1b554d5b08 | ||
|
|
7b0eb3d467 | ||
|
|
6978fa69a8 | ||
|
|
a1da18b99f | ||
|
|
570b188a88 | ||
|
|
b34d23870b | ||
|
|
08eaafa77b | ||
|
|
325983deaf | ||
|
|
fe386e31dd | ||
|
|
bfb47c4046 | ||
|
|
ad334178de | ||
|
|
68e22ebe7d | ||
|
|
23aff6fb06 | ||
|
|
e01e9d6491 | ||
|
|
f382a13109 | ||
|
|
b2f4046574 | ||
|
|
fd836cacf6 | ||
|
|
7207839b2a | ||
|
|
9fbf5e460e | ||
|
|
08bf71b248 | ||
|
|
b3d246d1f8 | ||
|
|
946a2b6fbe | ||
|
|
b57bc5d86e | ||
|
|
62f3d991b4 | ||
|
|
1cf23a6f86 | ||
|
|
63e9b8425f | ||
|
|
4546eb02a1 | ||
|
|
c33f1494f5 | ||
|
|
27a4a71b23 | ||
|
|
11a8d2e3b4 | ||
|
|
f15a74521a | ||
|
|
df22835932 | ||
|
|
eb3549eedc | ||
|
|
cea5fececb | ||
|
|
f686b8c548 | ||
|
|
448c5bfb88 | ||
|
|
5c3fad28be | ||
|
|
f79c84e99e | ||
|
|
1a172eb73b | ||
|
|
b26546b709 | ||
|
|
2ae962d957 | ||
|
|
2343ce46bd | ||
|
|
a12e18cf12 | ||
|
|
64309c0101 | ||
|
|
b9844f48f1 | ||
|
|
77872f1b6a | ||
|
|
7a7c94cd40 | ||
|
|
01ef1d3004 | ||
|
|
d8232e539a | ||
|
|
860954bdc8 | ||
|
|
f4fe62bd91 | ||
|
|
4b474a89b7 | ||
|
|
5ba1c71140 | ||
|
|
de81865c27 | ||
|
|
ed9c1bcb78 | ||
|
|
190d12cd54 | ||
|
|
63bf82e915 | ||
|
|
014b7355c5 | ||
|
|
602f201bae | ||
|
|
2739d8a325 | ||
|
|
b8e01fde33 | ||
|
|
9ecf21c65a | ||
|
|
ac9cfbfaf4 | ||
|
|
c23d201186 | ||
|
|
86157fc7f6 | ||
|
|
279c164bf5 | ||
|
|
743b00e59c | ||
|
|
eddf654b96 | ||
|
|
886be722bc | ||
|
|
6886ca24b1 | ||
|
|
856f150958 | ||
|
|
5a1aa68ead | ||
|
|
cc9f9c0d24 | ||
|
|
8deb2df88d | ||
|
|
603273ab9d | ||
|
|
64b0bd63af | ||
|
|
220372d65a | ||
|
|
353fedb93f | ||
|
|
dfd12528f3 | ||
|
|
6d5cc6aeac | ||
|
|
9f3883eaf0 | ||
|
|
a0ebd64461 | ||
|
|
dafd130fe5 | ||
|
|
11e9e1e6a0 | ||
|
|
b23f6632b1 | ||
|
|
6bacf7dafc | ||
|
|
0e200b96e0 | ||
|
|
3ce56879ae | ||
|
|
48efdffa57 | ||
|
|
9135bb277b | ||
|
|
711899ad35 | ||
|
|
01435d5fea | ||
|
|
8ce2188b28 | ||
|
|
c1ee163cab | ||
|
|
fcf57a04e1 | ||
|
|
0ad06e808f | ||
|
|
2cc4bf7d28 | ||
|
|
5ae3774f19 | ||
|
|
94eb121f38 | ||
|
|
e81be48bf3 | ||
|
|
db4a4776d3 | ||
|
|
3d54260f79 | ||
|
|
a712d78e4c | ||
|
|
ea3008c707 | ||
|
|
2e39a1329c | ||
|
|
ff2b8167b4 | ||
|
|
96638b516c | ||
|
|
dd9b36cf0a | ||
|
|
44f20f6b4c | ||
|
|
a3c16d81f8 | ||
|
|
c0a5a1fb35 | ||
|
|
b5f24b12dc | ||
|
|
898a52ec63 | ||
|
|
7066ba92a6 | ||
|
|
8960459520 | ||
|
|
f581af6d27 | ||
|
|
88016a2549 | ||
|
|
a7603d1dfb | ||
|
|
65e2af9efb | ||
|
|
090cd720c1 | ||
|
|
7bc8edf7c3 | ||
|
|
13768d4289 | ||
|
|
37d71051de | ||
|
|
5ad3de2904 | ||
|
|
66ea25c18b | ||
|
|
1ab7ca876c | ||
|
|
fd8cd5ca91 | ||
|
|
7cffbfcd6d | ||
|
|
dffee9ea7d | ||
|
|
239959e2a4 | ||
|
|
75abf2e245 | ||
|
|
bfbe73aea3 | ||
|
|
b3721c1b71 | ||
|
|
544f7ef1cb | ||
|
|
2dd152df3f | ||
|
|
2856793eff | ||
|
|
16bec00b38 | ||
|
|
f51e99dc80 | ||
|
|
aca9a77498 | ||
|
|
1c2550d749 | ||
|
|
1c32ee7613 | ||
|
|
f356309f70 | ||
|
|
39936a95f8 | ||
|
|
e18d64933f | ||
|
|
20ccee5d7d | ||
|
|
3575abcde3 | ||
|
|
2cd444ece0 | ||
|
|
eca1ed4d8d | ||
|
|
4ce9508fd3 | ||
|
|
4f9f1292f1 | ||
|
|
d64376c5ac | ||
|
|
275487b3fd | ||
|
|
9301546e6d | ||
|
|
49b3c724cf | ||
|
|
7091775234 | ||
|
|
90667e7176 | ||
|
|
414bbb0171 | ||
|
|
6e6d35157d | ||
|
|
c0f57a448d | ||
|
|
b348bce06e | ||
|
|
06577c99f2 | ||
|
|
f48a9aef2c | ||
|
|
293dda3e79 | ||
|
|
f52b29931f | ||
|
|
3dea1b12cf | ||
|
|
d83be77727 | ||
|
|
6215113c91 | ||
|
|
954ac89b51 | ||
|
|
76fbe19fd1 | ||
|
|
b27c4cf95d | ||
|
|
d915552788 | ||
|
|
c66ad4d7e3 | ||
|
|
152a6b96d1 | ||
|
|
f642b09ebf | ||
|
|
ec414d29be | ||
|
|
0fbd3e33f3 | ||
|
|
17870cb471 | ||
|
|
4e03c96108 | ||
|
|
6cacdd47f2 | ||
|
|
a380860cab | ||
|
|
7f842eaeb1 | ||
|
|
3e019bfe88 | ||
|
|
430135606b | ||
|
|
62a79d9802 | ||
|
|
d9d02dbbc1 | ||
|
|
e0a9e3aa56 | ||
|
|
ce878a5d03 | ||
|
|
fb89b87efb | ||
|
|
43fe1b9ad8 | ||
|
|
b59f451c6a | ||
|
|
693d2d630f | ||
|
|
025c2963a0 | ||
|
|
76bf4a3de7 | ||
|
|
b7ae47d61c | ||
|
|
4fa265ed28 | ||
|
|
b531bc95ea | ||
|
|
da6f6f528c | ||
|
|
926d8b981b | ||
|
|
997923dd98 | ||
|
|
8131d0d883 | ||
|
|
a326dc034f | ||
|
|
736905c1e8 | ||
|
|
857d56c281 | ||
|
|
cef61b2c48 | ||
|
|
b483891bcb | ||
|
|
bb9bbdf736 | ||
|
|
9e1636a3f7 | ||
|
|
315326ffc0 | ||
|
|
65188b1f07 | ||
|
|
5fefcd1296 | ||
|
|
4c4a5725d8 | ||
|
|
ab2a686937 | ||
|
|
01bb0eeccd | ||
|
|
7cabc8bcec | ||
|
|
95f4e58ca0 | ||
|
|
692a1fa532 | ||
|
|
8293a24cf9 | ||
|
|
83b5470bc5 | ||
|
|
2aae3577cb | ||
|
|
54ded9db9a | ||
|
|
db2b3a0dd8 | ||
|
|
0b4bcf573e | ||
|
|
f9a88f841e | ||
|
|
c4291cc23e | ||
|
|
fd0595b547 | ||
|
|
b59e18bed8 | ||
|
|
1956ffbf02 | ||
|
|
e647c68cb9 | ||
|
|
53dce3013d | ||
|
|
7615aa86ad | ||
|
|
07ef3e5656 | ||
|
|
2804acf0f5 | ||
|
|
498b632796 | ||
|
|
61e496fc4c | ||
|
|
83e74b014e | ||
|
|
364696e059 | ||
|
|
546c94ba98 | ||
|
|
203d739d38 | ||
|
|
89fd37bc2c | ||
|
|
7856ab5dfb | ||
|
|
773be05bb1 | ||
|
|
c1a6e9a429 | ||
|
|
c9a0a40805 | ||
|
|
439049624c | ||
|
|
a6078037c0 | ||
|
|
9c5c17441f | ||
|
|
b56d250708 | ||
|
|
a714fe2618 | ||
|
|
e863872d0e | ||
|
|
77406f3496 | ||
|
|
157add4835 | ||
|
|
50f3563477 | ||
|
|
e08f3670d1 | ||
|
|
4f6f45a9c0 | ||
|
|
3de04b27ab | ||
|
|
ec1f97b726 | ||
|
|
569d89e8f8 | ||
|
|
f2f146e39b | ||
|
|
18d08298cc | ||
|
|
ebb386af58 | ||
|
|
b105ed6727 | ||
|
|
1916376f8d | ||
|
|
965110b2c3 | ||
|
|
c8ac104043 | ||
|
|
f6bd0a8d51 | ||
|
|
e39498702d | ||
|
|
9b97067b10 | ||
|
|
f72f0d800f | ||
|
|
5244e0be14 | ||
|
|
6eb25f68ac | ||
|
|
efe7c3fa70 | ||
|
|
ce4b2ae9a0 | ||
|
|
ba86e08ffe | ||
|
|
2d9e2356b1 | ||
|
|
fe5c844a21 | ||
|
|
97410db301 | ||
|
|
887751cd5d | ||
|
|
044326068c | ||
|
|
57a51ab2da | ||
|
|
998dbd9054 | ||
|
|
a5a55bd43a | ||
|
|
00409d834b | ||
|
|
d9ab7cc78d | ||
|
|
99a2ca8802 | ||
|
|
ea338ae4fa | ||
|
|
32fa8d43c1 | ||
|
|
0f166e0a1d | ||
|
|
46e423fc40 | ||
|
|
eac523dcf9 | ||
|
|
4225ce2f42 | ||
|
|
d35dfc14d1 | ||
|
|
cef228f880 | ||
|
|
bcfb50b35a | ||
|
|
c4c4916bc8 | ||
|
|
81463614c9 | ||
|
|
15a7f86344 | ||
|
|
3c1da90f47 | ||
|
|
27151d1cac | ||
|
|
a1c6dd2085 | ||
|
|
8f930acfb8 | ||
|
|
08d44703c3 | ||
|
|
82282419fe | ||
|
|
e290d1307f | ||
|
|
747c5c9fff | ||
|
|
9f987e66fa | ||
|
|
b91ff5f0b5 | ||
|
|
23ec7702fc | ||
|
|
0e79c4bd2a | ||
|
|
f8082d9481 | ||
|
|
d9ecee7200 | ||
|
|
149c13e9d8 | ||
|
|
07e9670a09 | ||
|
|
0e67228605 | ||
|
|
2578236d8d | ||
|
|
fe545423c5 | ||
|
|
f3c67f1d71 | ||
|
|
27b3a89247 | ||
|
|
1470afb715 | ||
|
|
b495a744c9 | ||
|
|
d2b5917e2b | ||
|
|
52ca98611c | ||
|
|
1b394e9bb8 | ||
|
|
0d36ab8af3 | ||
|
|
141ddb3a51 | ||
|
|
f99801a2e6 | ||
|
|
4457e9e26f | ||
|
|
4eb7dc563c | ||
|
|
269373d75d | ||
|
|
ef275ac0c1 | ||
|
|
f59df0f40a | ||
|
|
214f70e62f | ||
|
|
51af114b2e | ||
|
|
83bf9d4d6c | ||
|
|
f298d947bd | ||
|
|
d87d8a2db4 | ||
|
|
50c564d8a2 | ||
|
|
c807b5db21 | ||
|
|
4d1baae6d0 | ||
|
|
34bc551303 | ||
|
|
0847a6406e | ||
|
|
006f73af7d | ||
|
|
f4a74dac57 | ||
|
|
1f34c39eb0 | ||
|
|
8783c86cd6 | ||
|
|
892e82ceb8 | ||
|
|
8b4834929d | ||
|
|
f0d5392e9e | ||
|
|
dde07adbdc | ||
|
|
57df16dd62 | ||
|
|
ae62e0d955 | ||
|
|
4603802f62 | ||
|
|
610792b902 | ||
|
|
b1e935da45 | ||
|
|
93e14b73bb | ||
|
|
81a486adc1 | ||
|
|
8bf4727a1c | ||
|
|
2a468493f9 | ||
|
|
3ac3e2ec7c | ||
|
|
fea0f301d2 | ||
|
|
c0b5151bae | ||
|
|
650f492d7d | ||
|
|
1f2c76e63d | ||
|
|
efef587671 | ||
|
|
5ccc131e73 | ||
|
|
16900d2c10 | ||
|
|
03aeb707f2 | ||
|
|
950ba1e2e1 |
@@ -0,0 +1,26 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/web"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
groups:
|
||||
all:
|
||||
patterns:
|
||||
- "*"
|
||||
@@ -1,19 +1,22 @@
|
||||
name: build
|
||||
on: [ push, pull_request ]
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v4
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: '1.24.x'
|
||||
go-version-file: '.go-version'
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: './web/package-lock.json'
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -9,10 +9,10 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout ntfy code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
-
|
||||
name: Checkout docs pages code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: binwiederhier/ntfy-docs.github.io
|
||||
path: build/ntfy-docs.github.io
|
||||
|
||||
@@ -6,21 +6,38 @@ on:
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
env:
|
||||
POSTGRES_USER: ntfy
|
||||
POSTGRES_PASSWORD: ntfy
|
||||
POSTGRES_DB: ntfy_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U ntfy"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
env:
|
||||
NTFY_TEST_DATABASE_URL: "postgres://ntfy:ntfy@localhost:5432/ntfy_test?sslmode=disable"
|
||||
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v4
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: '1.24.x'
|
||||
go-version-file: '.go-version'
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: './web/package-lock.json'
|
||||
- name: Docker login
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2
|
||||
with:
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
|
||||
@@ -1,19 +1,39 @@
|
||||
name: test
|
||||
on: [ push, pull_request ]
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
env:
|
||||
POSTGRES_USER: ntfy
|
||||
POSTGRES_PASSWORD: ntfy
|
||||
POSTGRES_DB: ntfy_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U ntfy"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
env:
|
||||
NTFY_TEST_DATABASE_URL: "postgres://ntfy:ntfy@localhost:5432/ntfy_test?sslmode=disable"
|
||||
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v4
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: '1.24.x'
|
||||
go-version-file: '.go-version'
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: './web/package-lock.json'
|
||||
- name: Install dependencies
|
||||
@@ -23,8 +43,6 @@ jobs:
|
||||
- name: Build web app (required for tests)
|
||||
run: make web
|
||||
- name: Run tests, formatting, vetting and linting
|
||||
run: make check
|
||||
run: make checkv
|
||||
- name: Run coverage
|
||||
run: make coverage
|
||||
- name: Upload coverage to codecov.io
|
||||
run: make coverage-upload
|
||||
|
||||
@@ -7,6 +7,9 @@ build/
|
||||
server/docs/
|
||||
server/site/
|
||||
tools/fbsend/fbsend
|
||||
tools/pgimport/pgimport
|
||||
tools/loadtest/loadtest
|
||||
tools/s3cli/s3cli
|
||||
playground/
|
||||
secrets/
|
||||
*.iml
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
1.26.5
|
||||
@@ -48,13 +48,15 @@ builds:
|
||||
- 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
|
||||
- CGO_ENABLED=1 # required for go-sqlite3
|
||||
- CC=x86_64-w64-mingw32-gcc # apt install gcc-mingw-w64-x86-64
|
||||
tags: [ sqlite_omit_load_extension,osusergo,netgo ]
|
||||
ldflags:
|
||||
- "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||
- "-s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||
goos: [ windows ]
|
||||
goarch: [ amd64 ]
|
||||
- id: ntfy_darwin_all
|
||||
goarch: [amd64 ]
|
||||
-
|
||||
id: ntfy_darwin_all
|
||||
binary: ntfy
|
||||
env:
|
||||
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
|
||||
@@ -201,4 +203,4 @@ docker_manifests:
|
||||
- *amd64_image
|
||||
- *arm64v8_image
|
||||
- *armv7_image
|
||||
- *armv6_image
|
||||
- *armv6_image
|
||||
|
||||
@@ -60,7 +60,7 @@ representative at an online or offline event.
|
||||
|
||||
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
|
||||
or email (contact@mail.ntfy.sh). All complaints will be reviewed and investigated promptly
|
||||
and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
FROM golang:1.24-bullseye as builder
|
||||
FROM golang:1.25-bookworm AS builder
|
||||
|
||||
ARG VERSION=dev
|
||||
ARG COMMIT=unknown
|
||||
ARG NODE_MAJOR=18
|
||||
ARG NODE_MAJOR=24
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential ca-certificates curl gnupg \
|
||||
@@ -21,14 +21,14 @@ ADD Makefile .
|
||||
|
||||
# docs
|
||||
ADD ./requirements.txt .
|
||||
RUN make docs-deps
|
||||
RUN --mount=type=cache,target=/root/.cache/pip make docs-deps
|
||||
ADD ./mkdocs.yml .
|
||||
ADD ./docs ./docs
|
||||
RUN make docs-build
|
||||
|
||||
# web
|
||||
ADD ./web/package.json ./web/package-lock.json ./web/
|
||||
RUN make web-deps
|
||||
RUN --mount=type=cache,target=/root/.npm make web-deps
|
||||
ADD ./web ./web
|
||||
RUN make web-build
|
||||
|
||||
@@ -40,7 +40,18 @@ ADD ./log ./log
|
||||
ADD ./server ./server
|
||||
ADD ./user ./user
|
||||
ADD ./util ./util
|
||||
RUN make VERSION=$VERSION COMMIT=$COMMIT cli-linux-server
|
||||
ADD ./payments ./payments
|
||||
ADD ./db ./db
|
||||
ADD ./message ./message
|
||||
ADD ./model ./model
|
||||
ADD ./webpush ./webpush
|
||||
ADD ./attachment ./attachment
|
||||
ADD ./mail ./mail
|
||||
ADD ./s3 ./s3
|
||||
ADD ./action ./action
|
||||
ADD ./template/gotext ./template/gotext
|
||||
|
||||
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build make VERSION=$VERSION COMMIT=$COMMIT cli-linux-server
|
||||
|
||||
FROM alpine
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
MAKEFLAGS := --jobs=1
|
||||
NPM := npm
|
||||
PYTHON := python3
|
||||
PIP := pip3
|
||||
VERSION := $(shell git describe --tag)
|
||||
COMMIT := $(shell git rev-parse --short HEAD)
|
||||
|
||||
.PHONY:
|
||||
# FORCE is an always-out-of-date target with no recipe; listing it as a prerequisite
|
||||
# forces that target's recipe to run every time (the classic "FORCE target" idiom).
|
||||
FORCE:
|
||||
|
||||
help:
|
||||
@echo "Typical commands (more see below):"
|
||||
@@ -31,6 +34,7 @@ help:
|
||||
@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-windows-server - Build client & server (no GoReleaser, amd64 only, Windows)"
|
||||
@echo " make cli-client - Build client only (no GoReleaser, current arch, Linux/macOS/Windows)"
|
||||
@echo
|
||||
@echo "Build dev Docker:"
|
||||
@@ -41,6 +45,7 @@ help:
|
||||
@echo " make web-deps - Install web app dependencies (npm install the universe)"
|
||||
@echo " make web-build - Actually build the web app"
|
||||
@echo " make web-lint - Run eslint on the web app"
|
||||
@echo " make web-test - Run vitest unit tests for the web app"
|
||||
@echo " make web-fmt - Run prettier on the web app"
|
||||
@echo " make web-fmt-check - Run prettier on the web app, but don't change anything"
|
||||
@echo
|
||||
@@ -50,7 +55,9 @@ help:
|
||||
@echo " make docs-build - Actually build the documentation"
|
||||
@echo
|
||||
@echo "Test/check:"
|
||||
@echo " make test - Run tests"
|
||||
@echo " make test - Run all tests (Go + web)"
|
||||
@echo " make cli-test - Run Go tests only"
|
||||
@echo " make web-test - Run web app tests only"
|
||||
@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)"
|
||||
@@ -80,12 +87,12 @@ help:
|
||||
|
||||
# Building everything
|
||||
|
||||
clean: .PHONY
|
||||
clean: FORCE
|
||||
rm -rf dist build server/docs server/site
|
||||
|
||||
build: web docs cli
|
||||
|
||||
update: web-deps-update cli-deps-update docs-deps-update
|
||||
update: web-deps-update cli-deps-update docs-deps-update go-check
|
||||
docker pull alpine
|
||||
|
||||
docker-dev:
|
||||
@@ -106,6 +113,7 @@ build-deps-ubuntu:
|
||||
curl \
|
||||
gcc-aarch64-linux-gnu \
|
||||
gcc-arm-linux-gnueabi \
|
||||
gcc-mingw-w64-x86-64 \
|
||||
python3 \
|
||||
python3-venv \
|
||||
jq
|
||||
@@ -116,7 +124,7 @@ build-deps-ubuntu:
|
||||
|
||||
docs: docs-deps docs-build
|
||||
|
||||
docs-venv: .PHONY
|
||||
docs-venv: FORCE
|
||||
$(PYTHON) -m venv ./venv
|
||||
|
||||
docs-build: docs-venv
|
||||
@@ -125,7 +133,7 @@ docs-build: docs-venv
|
||||
docs-deps: docs-venv
|
||||
(. venv/bin/activate && $(PIP) install -r requirements.txt)
|
||||
|
||||
docs-deps-update: .PHONY
|
||||
docs-deps-update: FORCE
|
||||
(. venv/bin/activate && $(PIP) install -r requirements.txt --upgrade)
|
||||
|
||||
|
||||
@@ -135,7 +143,7 @@ web: web-deps web-build
|
||||
|
||||
web-build:
|
||||
cd web \
|
||||
&& npm run build \
|
||||
&& $(NPM) run build \
|
||||
&& mv build/index.html build/app.html \
|
||||
&& rm -rf ../server/site \
|
||||
&& mv build ../server/site \
|
||||
@@ -143,20 +151,25 @@ web-build:
|
||||
../server/site/config.js
|
||||
|
||||
web-deps:
|
||||
cd web && npm install
|
||||
cd web && $(NPM) ci
|
||||
# Use "npm ci" so that we don't change the package lock file
|
||||
# If this fails for .svg files, optimize them with svgo
|
||||
|
||||
web-deps-update:
|
||||
cd web && npm update
|
||||
cd web && $(NPM) update --before="$(shell date -d '7 days ago' +%Y-%m-%d)"
|
||||
cd web && $(NPM) install
|
||||
|
||||
web-fmt:
|
||||
cd web && npm run format
|
||||
cd web && $(NPM) run format
|
||||
|
||||
web-fmt-check:
|
||||
cd web && npm run format:check
|
||||
cd web && $(NPM) run format:check
|
||||
|
||||
web-lint:
|
||||
cd web && npm run lint
|
||||
cd web && $(NPM) run lint
|
||||
|
||||
web-test:
|
||||
cd web && $(NPM) run test
|
||||
|
||||
# Main server/client build
|
||||
|
||||
@@ -201,6 +214,16 @@ cli-darwin-server: cli-deps-static-sites
|
||||
-ldflags \
|
||||
"-linkmode=external -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)"
|
||||
|
||||
cli-windows-server: cli-deps-static-sites
|
||||
# This is a target to build the CLI (including the server) for Windows.
|
||||
# Use this for Windows development, if you really don't want to install GoReleaser ...
|
||||
mkdir -p dist/ntfy_windows_server server/docs
|
||||
CC=x86_64-w64-mingw32-gcc GOOS=windows GOARCH=amd64 CGO_ENABLED=1 go build \
|
||||
-o dist/ntfy_windows_server/ntfy.exe \
|
||||
-tags sqlite_omit_load_extension,osusergo,netgo \
|
||||
-ldflags \
|
||||
"-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 ...
|
||||
@@ -213,7 +236,7 @@ cli-client: cli-deps-static-sites
|
||||
|
||||
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-gcc: cli-deps-gcc-armv6-armv7 cli-deps-gcc-arm64 cli-deps-gcc-windows
|
||||
|
||||
cli-deps-static-sites:
|
||||
mkdir -p server/docs server/site
|
||||
@@ -228,11 +251,15 @@ cli-deps-gcc-armv6-armv7:
|
||||
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-gcc-windows:
|
||||
which x86_64-w64-mingw32-gcc || { echo "ERROR: Windows cross compiler not installed. On Ubuntu, run: apt install gcc-mingw-w64-x86-64"; exit 1; }
|
||||
|
||||
cli-deps-update:
|
||||
go get -u
|
||||
go mod tidy
|
||||
go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||
go install golang.org/x/lint/golint@latest
|
||||
go install github.com/goreleaser/goreleaser@latest
|
||||
go install github.com/goreleaser/goreleaser/v2@latest
|
||||
|
||||
cli-build-results:
|
||||
cat dist/config.yaml
|
||||
@@ -246,25 +273,31 @@ cli-build-results:
|
||||
|
||||
# Test/check targets
|
||||
|
||||
check: test web-fmt-check fmt-check vet web-lint lint staticcheck
|
||||
check: test web-fmt-check fmt-check vet web-lint lint staticcheck template-check go-check
|
||||
|
||||
test: .PHONY
|
||||
go test $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
|
||||
checkv: testv web-fmt-check fmt-check vet web-lint lint staticcheck template-check go-check
|
||||
|
||||
testv: .PHONY
|
||||
go test -v $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
|
||||
test: cli-test web-test
|
||||
|
||||
race: .PHONY
|
||||
go test -v -race $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
|
||||
testv: cli-testv web-test
|
||||
|
||||
cli-test: FORCE
|
||||
go test $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')
|
||||
|
||||
cli-testv: FORCE
|
||||
go test -v $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')
|
||||
|
||||
race: FORCE
|
||||
go test -v -race $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')
|
||||
|
||||
coverage:
|
||||
mkdir -p build/coverage
|
||||
go test -v -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 -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools|web)')
|
||||
go tool cover -func build/coverage/coverage.txt
|
||||
|
||||
coverage-html:
|
||||
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 -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')
|
||||
go tool cover -html build/coverage/coverage.txt
|
||||
|
||||
coverage-upload:
|
||||
@@ -284,17 +317,73 @@ vet:
|
||||
|
||||
lint:
|
||||
which golint || go install golang.org/x/lint/golint@latest
|
||||
go list ./... | grep -v /vendor/ | xargs -L1 golint -set_exit_status
|
||||
go list ./... | grep -v /vendor/ | grep -vE 'ntfy/v2/template/gotext' | xargs -L1 golint -set_exit_status
|
||||
|
||||
staticcheck: .PHONY
|
||||
staticcheck: FORCE
|
||||
rm -rf build/staticcheck
|
||||
which staticcheck || go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||
mkdir -p build/staticcheck
|
||||
ln -s "go" build/staticcheck/go
|
||||
PATH="$(PWD)/build/staticcheck:$(PATH)" staticcheck ./...
|
||||
PATH="$(PWD)/build/staticcheck:$(PATH)" staticcheck $$(go list ./... | grep -vE 'ntfy/v2/template/gotext')
|
||||
rm -rf build/staticcheck
|
||||
|
||||
|
||||
# Vendored template targets (see template/README.md)
|
||||
|
||||
TEMPLATE_GO_VERSION := go$(shell cat .go-version 2>/dev/null)
|
||||
|
||||
update-template:
|
||||
@if [ "$$(go env GOVERSION)" != "$(TEMPLATE_GO_VERSION)" ]; then \
|
||||
echo "ERROR: local Go $$(go env GOVERSION) != $(TEMPLATE_GO_VERSION) pinned in .go-version."; \
|
||||
echo "Bump .go-version and install that toolchain first: go install golang.org/dl/$(TEMPLATE_GO_VERSION)@latest && $(TEMPLATE_GO_VERSION) download"; \
|
||||
exit 1; \
|
||||
fi
|
||||
src="$$(go env GOROOT)/src"; \
|
||||
rm -f template/gotext/*.go template/gotext/fmtsort/*.go; \
|
||||
for f in $$(go list -f '{{range .GoFiles}}{{.}} {{end}}' text/template); do cp "$$src/text/template/$$f" template/gotext/; done; \
|
||||
for f in $$(go list -f '{{range .GoFiles}}{{.}} {{end}}' internal/fmtsort); do cp "$$src/internal/fmtsort/$$f" template/gotext/fmtsort/; done; \
|
||||
sed -i 's/^package template$$/package gotext/' template/gotext/*.go; \
|
||||
sed -i 's#"internal/fmtsort"#"heckel.io/ntfy/v2/template/gotext/fmtsort"#' template/gotext/*.go; \
|
||||
( cd template/gotext && for p in patches/*.patch; do echo "Applying $$p"; git apply "$$p" || exit 1; done )
|
||||
go env GOVERSION > template/gotext/GENERATED_FROM
|
||||
@echo "Regenerated template/gotext/ from $(TEMPLATE_GO_VERSION) (files enumerated via 'go list'); review with 'git diff'."
|
||||
|
||||
template-check: FORCE
|
||||
@if [ "$$(cat template/gotext/GENERATED_FROM)" != "$(TEMPLATE_GO_VERSION)" ]; then \
|
||||
echo "ERROR: template/gotext was generated from $$(cat template/gotext/GENERATED_FROM), but .go-version pins $(TEMPLATE_GO_VERSION). Run 'make update-template' on the pinned Go."; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if [ "$$(go env GOVERSION)" != "$(TEMPLATE_GO_VERSION)" ]; then \
|
||||
echo "SKIP: local Go $$(go env GOVERSION) != pinned $(TEMPLATE_GO_VERSION); skipping vendored template content check (version marker already verified)."; \
|
||||
exit 0; \
|
||||
fi
|
||||
@tmp=$$(mktemp -d); src="$$(go env GOROOT)/src"; \
|
||||
mkdir -p "$$tmp/gotext/fmtsort"; \
|
||||
for f in $$(go list -f '{{range .GoFiles}}{{.}} {{end}}' text/template); do cp "$$src/text/template/$$f" "$$tmp/gotext/"; done; \
|
||||
for f in $$(go list -f '{{range .GoFiles}}{{.}} {{end}}' internal/fmtsort); do cp "$$src/internal/fmtsort/$$f" "$$tmp/gotext/fmtsort/"; done; \
|
||||
sed -i 's/^package template$$/package gotext/' "$$tmp/gotext/"*.go; \
|
||||
sed -i 's#"internal/fmtsort"#"heckel.io/ntfy/v2/template/gotext/fmtsort"#' "$$tmp/gotext/"*.go; \
|
||||
cp template/gotext/patches/*.patch "$$tmp/"; \
|
||||
( cd "$$tmp/gotext" && for p in "$$tmp"/*.patch; do git apply "$$p" || exit 1; done ); \
|
||||
if diff -rq -x 'README.md' -x 'GENERATED_FROM' -x 'patches' "$$tmp/gotext" template/gotext >/dev/null 2>&1; then \
|
||||
rm -rf "$$tmp"; \
|
||||
else \
|
||||
echo "ERROR: template/gotext/ drifted from GOROOT+patches (or its file set changed). Run 'make update-template' on Go $(TEMPLATE_GO_VERSION):"; \
|
||||
diff -rq -x 'README.md' -x 'GENERATED_FROM' -x 'patches' "$$tmp/gotext" template/gotext; \
|
||||
rm -rf "$$tmp"; exit 1; \
|
||||
fi
|
||||
|
||||
# go-check is advisory only (never fails): it warns when the pinned Go (.go-version) is behind the
|
||||
# latest upstream release, so template/gotext doesn't silently fall behind on text/template fixes.
|
||||
go-check: FORCE
|
||||
@latest=$$(curl -s --max-time 10 'https://go.dev/VERSION?m=text' 2>/dev/null | head -1); \
|
||||
if [ -n "$$latest" ] && [ "$$latest" != "$(TEMPLATE_GO_VERSION)" ]; then \
|
||||
echo ""; \
|
||||
echo "note: latest Go is $$latest, but template/gotext is pinned to $(TEMPLATE_GO_VERSION) (.go-version)."; \
|
||||
echo " to bump: install $$latest, set .go-version to $${latest#go}, then run 'make update-template'."; \
|
||||
fi
|
||||
|
||||
|
||||
# Releasing targets
|
||||
|
||||
release: clean cli-deps release-checks docs web check
|
||||
@@ -305,6 +394,10 @@ release-snapshot: clean cli-deps docs web check
|
||||
|
||||
release-checks:
|
||||
$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-))
|
||||
if [ "$$(go env GOVERSION)" != "go$$(cat .go-version)" ]; then\
|
||||
echo "ERROR: releases must use the pinned Go toolchain (go$$(cat .go-version) from .go-version), but this is $$(go env GOVERSION). This also ensures 'make check' enforces (not skips) the template/gotext drift check.";\
|
||||
exit 1;\
|
||||
fi
|
||||
if ! grep -q $(LATEST_TAG) docs/install.md; then\
|
||||
echo "ERROR: Must update docs/install.md with latest tag first.";\
|
||||
exit 1;\
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
<div align="center" markdown="1">
|
||||
<sup>Special thanks to:</sup>
|
||||
<br>
|
||||
<br>
|
||||
<a href="https://go.warp.dev/ntfy">
|
||||
<img alt="Warp sponsorship" width="400" src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Github/Sponsor/Warp-Github-LG-02.png">
|
||||
</a>
|
||||
|
||||
### [Warp, built for coding with multiple AI agents.](https://go.warp.dev/ntfy)
|
||||
[Available for MacOS, Linux, & Windows](https://go.warp.dev/ntfy)<br>
|
||||
</div>
|
||||
<hr>
|
||||
|
||||

|
||||
|
||||
# ntfy.sh | Send push notifications to your phone or desktop via PUT/POST
|
||||
@@ -21,6 +34,12 @@ You can access the free version of ntfy at **[ntfy.sh](https://ntfy.sh)**. There
|
||||
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>
|
||||
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img height="50" src="docs/static/img/badge-googleplay.png"></a>
|
||||
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img width="170" src="docs/static/img/badge-fdroid.svg"></a>
|
||||
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img height="50" src="docs/static/img/badge-appstore.png"></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<img src=".github/images/screenshot-curl.png" height="180">
|
||||
<img src=".github/images/screenshot-web-detail.png" height="180">
|
||||
@@ -65,7 +84,7 @@ Thank you to our commercial sponsors, who help keep the service running and the
|
||||
|
||||
<a href="https://m.do.co/c/442b929528db"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="201px"></a>
|
||||
|
||||
<a href="https://www.magicbell.com/?utm_source=ntfy"><img src="assets/sponsors/magicbell.png" width="180px"></a>
|
||||
<a href="https://go.warp.dev/ntfy"><img src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Logos/Warp-Wordmark-Black.png" width="160px"></a>
|
||||
|
||||
And a big fat **Thank You** to the individuals who have sponsored ntfy in the past, or are still sponsoring ntfy:
|
||||
|
||||
@@ -247,9 +266,11 @@ Third-party libraries and resources:
|
||||
* [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)
|
||||
* Go's [text/template](https://pkg.go.dev/text/template) (BSD-3-Clause) is vendored under [template/gotext/](template/gotext/) with a small patch adding an execution deadline (see [template/gotext/README.md](template/gotext/README.md))
|
||||
* [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)
|
||||
* [webpush-go](https://github.com/SherClockHolmes/webpush-go) (MIT) is used to send web push notifications
|
||||
* [Sprig](https://github.com/Masterminds/sprig) (MIT) is used to add template parsing functions
|
||||
|
||||
@@ -6,5 +6,7 @@ As of today, I only support the latest version of ntfy. Please make sure you sta
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report severe security issues privately via ntfy@heckel.io, [Discord](https://discord.gg/cT7ECsZj9w),
|
||||
or [Matrix](https://matrix.to/#/#ntfy:matrix.org) (my username is `binwiederhier`).
|
||||
Please report security vulnerabilities privately via email to [security@mail.ntfy.sh](mailto:security@mail.ntfy.sh).
|
||||
|
||||
You can also reach me on [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org)
|
||||
(my username is `binwiederhier`).
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
package server
|
||||
// Package action parses the "action buttons" that can be attached to a notification, in both the
|
||||
// JSON and the human-readable "simple" format described at https://ntfy.sh/docs/publish/#action-buttons.
|
||||
package action
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -20,12 +24,14 @@ const (
|
||||
actionView = "view"
|
||||
actionBroadcast = "broadcast"
|
||||
actionHTTP = "http"
|
||||
actionCopy = "copy"
|
||||
)
|
||||
|
||||
var (
|
||||
actionsAll = []string{actionView, actionBroadcast, actionHTTP}
|
||||
actionsWithURL = []string{actionView, actionHTTP}
|
||||
actionsKeyRegex = regexp.MustCompile(`^([-.\w]+)\s*=\s*`)
|
||||
actionsAll = []string{actionView, actionBroadcast, actionHTTP, actionCopy}
|
||||
actionsWithURL = []string{actionView, actionHTTP} // Must be distinct from actionsWithValue, see populateAction()
|
||||
actionsWithValue = []string{actionCopy} // Must be distinct from actionsWithURL, see populateAction()
|
||||
actionsKeyRegex = regexp.MustCompile(`^([-.\w]+)\s*=\s*`)
|
||||
)
|
||||
|
||||
type actionParser struct {
|
||||
@@ -33,10 +39,10 @@ type actionParser struct {
|
||||
pos int
|
||||
}
|
||||
|
||||
// parseActions parses the actions string as described in https://ntfy.sh/docs/publish/#action-buttons.
|
||||
// Parse parses the actions string as described in https://ntfy.sh/docs/publish/#action-buttons.
|
||||
// It supports both a JSON representation (if the string begins with "[", see parseActionsFromJSON),
|
||||
// and the "simple" format, which is more human-readable, but harder to parse (see parseActionsFromSimple).
|
||||
func parseActions(s string) (actions []*action, err error) {
|
||||
func Parse(s string) (actions []*model.Action, err error) {
|
||||
// Parse JSON or simple format
|
||||
s = strings.TrimSpace(s)
|
||||
if strings.HasPrefix(s, "[") {
|
||||
@@ -61,11 +67,13 @@ func parseActions(s string) (actions []*action, err error) {
|
||||
}
|
||||
for _, action := range actions {
|
||||
if !util.Contains(actionsAll, action.Action) {
|
||||
return nil, fmt.Errorf("parameter 'action' cannot be '%s', valid values are 'view', 'broadcast' and 'http'", action.Action)
|
||||
return nil, fmt.Errorf("parameter 'action' cannot be '%s', valid values are 'view', 'broadcast', 'http' and 'copy'", action.Action)
|
||||
} else if action.Label == "" {
|
||||
return nil, fmt.Errorf("parameter 'label' is required")
|
||||
} else if util.Contains(actionsWithURL, action.Action) && action.URL == "" {
|
||||
return nil, fmt.Errorf("parameter 'url' is required for action '%s'", action.Action)
|
||||
} else if util.Contains(actionsWithValue, action.Action) && action.Value == "" {
|
||||
return nil, fmt.Errorf("parameter 'value' is required for action '%s'", action.Action)
|
||||
} else if action.Action == actionHTTP && util.Contains([]string{"GET", "HEAD"}, action.Method) && action.Body != "" {
|
||||
return nil, fmt.Errorf("parameter 'body' cannot be set if method is %s", action.Method)
|
||||
}
|
||||
@@ -75,8 +83,8 @@ func parseActions(s string) (actions []*action, err error) {
|
||||
}
|
||||
|
||||
// parseActionsFromJSON converts a JSON array into an array of actions
|
||||
func parseActionsFromJSON(s string) ([]*action, error) {
|
||||
actions := make([]*action, 0)
|
||||
func parseActionsFromJSON(s string) ([]*model.Action, error) {
|
||||
actions := make([]*model.Action, 0)
|
||||
if err := json.Unmarshal([]byte(s), &actions); err != nil {
|
||||
return nil, fmt.Errorf("JSON error: %w", err)
|
||||
}
|
||||
@@ -102,7 +110,7 @@ func parseActionsFromJSON(s string) ([]*action, error) {
|
||||
// https://github.com/adampresley/sample-ini-parser/blob/master/services/lexer/lexer/Lexer.go
|
||||
// https://github.com/benbjohnson/sql-parser/blob/master/scanner.go
|
||||
// https://blog.gopheracademy.com/advent-2014/parsers-lexers/
|
||||
func parseActionsFromSimple(s string) ([]*action, error) {
|
||||
func parseActionsFromSimple(s string) ([]*model.Action, error) {
|
||||
if !utf8.ValidString(s) {
|
||||
return nil, errors.New("invalid utf-8 string")
|
||||
}
|
||||
@@ -114,8 +122,8 @@ func parseActionsFromSimple(s string) ([]*action, error) {
|
||||
}
|
||||
|
||||
// Parse loops trough parseAction() until the end of the string is reached
|
||||
func (p *actionParser) Parse() ([]*action, error) {
|
||||
actions := make([]*action, 0)
|
||||
func (p *actionParser) Parse() ([]*model.Action, error) {
|
||||
actions := make([]*model.Action, 0)
|
||||
for !p.eof() {
|
||||
a, err := p.parseAction()
|
||||
if err != nil {
|
||||
@@ -129,8 +137,8 @@ func (p *actionParser) Parse() ([]*action, error) {
|
||||
// parseAction parses the individual sections of an action using parseSection into key/value pairs,
|
||||
// and then uses populateAction to interpret the keys/values. The function terminates
|
||||
// when EOF or ";" is reached.
|
||||
func (p *actionParser) parseAction() (*action, error) {
|
||||
a := newAction()
|
||||
func (p *actionParser) parseAction() (*model.Action, error) {
|
||||
a := model.NewAction()
|
||||
section := 0
|
||||
for {
|
||||
key, value, last, err := p.parseSection()
|
||||
@@ -150,7 +158,7 @@ func (p *actionParser) parseAction() (*action, error) {
|
||||
|
||||
// populateAction is the "business logic" of the parser. It applies the key/value
|
||||
// pair to the action instance.
|
||||
func populateAction(newAction *action, section int, key, value string) error {
|
||||
func populateAction(newAction *model.Action, section int, key, value string) error {
|
||||
// Auto-expand keys based on their index
|
||||
if key == "" && section == 0 {
|
||||
key = "action"
|
||||
@@ -158,6 +166,8 @@ func populateAction(newAction *action, section int, key, value string) error {
|
||||
key = "label"
|
||||
} else if key == "" && section == 2 && util.Contains(actionsWithURL, newAction.Action) {
|
||||
key = "url"
|
||||
} else if key == "" && section == 2 && util.Contains(actionsWithValue, newAction.Action) {
|
||||
key = "value"
|
||||
}
|
||||
|
||||
// Validate
|
||||
@@ -188,6 +198,8 @@ func populateAction(newAction *action, section int, key, value string) error {
|
||||
newAction.Method = value
|
||||
case "body":
|
||||
newAction.Body = value
|
||||
case "value":
|
||||
newAction.Value = value
|
||||
case "intent":
|
||||
newAction.Intent = value
|
||||
default:
|
||||
@@ -1,17 +1,18 @@
|
||||
package server
|
||||
package action
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseActions(t *testing.T) {
|
||||
actions, err := parseActions("[]")
|
||||
actions, err := Parse("[]")
|
||||
require.Nil(t, err)
|
||||
require.Empty(t, actions)
|
||||
|
||||
// Basic test
|
||||
actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open; view, Show portal, https://door.lan")
|
||||
actions, err = Parse("action=http, label=Open door, url=https://door.lan/open; view, Show portal, https://door.lan")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -22,7 +23,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, "https://door.lan", actions[1].URL)
|
||||
|
||||
// JSON
|
||||
actions, err = parseActions(`[{"action":"http","label":"Open door","url":"https://door.lan/open"}, {"action":"view","label":"Show portal","url":"https://door.lan"}]`)
|
||||
actions, err = Parse(`[{"action":"http","label":"Open door","url":"https://door.lan/open"}, {"action":"view","label":"Show portal","url":"https://door.lan"}]`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -33,7 +34,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, "https://door.lan", actions[1].URL)
|
||||
|
||||
// Other params
|
||||
actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open, body=this is a body, method=PUT")
|
||||
actions, err = Parse("action=http, label=Open door, url=https://door.lan/open, body=this is a body, method=PUT")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -43,7 +44,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, "this is a body", actions[0].Body)
|
||||
|
||||
// Extras with underscores
|
||||
actions, err = parseActions("action=broadcast, label=Do a thing, extras.command=some command, extras.some_param=a parameter")
|
||||
actions, err = Parse("action=broadcast, label=Do a thing, extras.command=some command, extras.some_param=a parameter")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "broadcast", actions[0].Action)
|
||||
@@ -53,7 +54,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, "a parameter", actions[0].Extras["some_param"])
|
||||
|
||||
// Broadcast action with intent
|
||||
actions, err = parseActions("action=broadcast, label=Do a thing, intent=io.heckel.ntfy.TEST_INTENT")
|
||||
actions, err = Parse("action=broadcast, label=Do a thing, intent=io.heckel.ntfy.TEST_INTENT")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "broadcast", actions[0].Action)
|
||||
@@ -61,7 +62,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, "io.heckel.ntfy.TEST_INTENT", actions[0].Intent)
|
||||
|
||||
// Headers with dashes
|
||||
actions, err = parseActions("action=http, label=Send request, url=http://example.com, method=GET, headers.Content-Type=application/json, headers.Authorization=Basic sdasffsf")
|
||||
actions, err = Parse("action=http, label=Send request, url=http://example.com, method=GET, headers.Content-Type=application/json, headers.Authorization=Basic sdasffsf")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -71,7 +72,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, "Basic sdasffsf", actions[0].Headers["Authorization"])
|
||||
|
||||
// Quotes
|
||||
actions, err = parseActions(`action=http, "Look ma, \"quotes\"; and semicolons", url=http://example.com`)
|
||||
actions, err = Parse(`action=http, "Look ma, \"quotes\"; and semicolons", url=http://example.com`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -79,7 +80,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, `http://example.com`, actions[0].URL)
|
||||
|
||||
// Single quotes
|
||||
actions, err = parseActions(`action=http, '"quotes" and \'single quotes\'', url=http://example.com`)
|
||||
actions, err = Parse(`action=http, '"quotes" and \'single quotes\'', url=http://example.com`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -87,7 +88,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, `http://example.com`, actions[0].URL)
|
||||
|
||||
// Single quotes (JSON)
|
||||
actions, err = parseActions(`action=http, Post it, url=http://example.com, body='{"temperature": 65}'`)
|
||||
actions, err = Parse(`action=http, Post it, url=http://example.com, body='{"temperature": 65}'`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -96,7 +97,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, `{"temperature": 65}`, actions[0].Body)
|
||||
|
||||
// Out of order
|
||||
actions, err = parseActions(`label="Out of order!" , action="http", url=http://example.com`)
|
||||
actions, err = Parse(`label="Out of order!" , action="http", url=http://example.com`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -104,7 +105,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, `http://example.com`, actions[0].URL)
|
||||
|
||||
// Spaces
|
||||
actions, err = parseActions(`action = http, label = 'this is a label', url = "http://google.com"`)
|
||||
actions, err = Parse(`action = http, label = 'this is a label', url = "http://google.com"`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -112,7 +113,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, `http://google.com`, actions[0].URL)
|
||||
|
||||
// Non-ASCII
|
||||
actions, err = parseActions(`action = http, 'Кохайтеся а не воюйте, 💙🫤', url = "http://google.com"`)
|
||||
actions, err = Parse(`action = http, 'Кохайтеся а не воюйте, 💙🫤', url = "http://google.com"`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -120,7 +121,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, `http://google.com`, actions[0].URL)
|
||||
|
||||
// Multiple actions, awkward spacing
|
||||
actions, err = parseActions(`http , 'Make love, not war 💙🫤' , https://ntfy.sh ; view, " yo ", https://x.org, clear=true`)
|
||||
actions, err = Parse(`http , 'Make love, not war 💙🫤' , https://ntfy.sh ; view, " yo ", https://x.org, clear=true`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -132,53 +133,91 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, `https://x.org`, actions[1].URL)
|
||||
require.Equal(t, true, actions[1].Clear)
|
||||
|
||||
// Copy action (simple format)
|
||||
actions, err = Parse("copy, Copy code, 1234")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "copy", actions[0].Action)
|
||||
require.Equal(t, "Copy code", actions[0].Label)
|
||||
require.Equal(t, "1234", actions[0].Value)
|
||||
|
||||
// Copy action (JSON)
|
||||
actions, err = Parse(`[{"action":"copy","label":"Copy OTP","value":"567890"}]`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "copy", actions[0].Action)
|
||||
require.Equal(t, "Copy OTP", actions[0].Label)
|
||||
require.Equal(t, "567890", actions[0].Value)
|
||||
|
||||
// Copy action with clear
|
||||
actions, err = Parse("copy, Copy code, 1234, clear=true")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "copy", actions[0].Action)
|
||||
require.Equal(t, "Copy code", actions[0].Label)
|
||||
require.Equal(t, "1234", actions[0].Value)
|
||||
require.Equal(t, true, actions[0].Clear)
|
||||
|
||||
// Copy action with explicit value key
|
||||
actions, err = Parse("action=copy, label=Copy token, clear=true, value=abc-123-def")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "copy", actions[0].Action)
|
||||
require.Equal(t, "Copy token", actions[0].Label)
|
||||
require.Equal(t, "abc-123-def", actions[0].Value)
|
||||
require.True(t, actions[0].Clear)
|
||||
|
||||
// Copy action without value (error)
|
||||
_, err = Parse("copy, Copy code")
|
||||
require.EqualError(t, err, "parameter 'value' is required for action 'copy'")
|
||||
|
||||
// Invalid syntax
|
||||
_, err = parseActions(`label="Out of order!" x, action="http", url=http://example.com`)
|
||||
_, err = Parse(`label="Out of order!" x, action="http", url=http://example.com`)
|
||||
require.EqualError(t, err, "unexpected character 'x' at position 22")
|
||||
|
||||
_, err = parseActions(`label="", action="http", url=http://example.com`)
|
||||
_, err = Parse(`label="", action="http", url=http://example.com`)
|
||||
require.EqualError(t, err, "parameter 'label' is required")
|
||||
|
||||
_, err = parseActions(`label=, action="http", url=http://example.com`)
|
||||
_, err = Parse(`label=, action="http", url=http://example.com`)
|
||||
require.EqualError(t, err, "parameter 'label' is required")
|
||||
|
||||
_, err = parseActions(`label="xx", action="http", url=http://example.com, what is this anyway`)
|
||||
_, err = Parse(`label="xx", action="http", url=http://example.com, what is this anyway`)
|
||||
require.EqualError(t, err, "term 'what is this anyway' unknown")
|
||||
|
||||
_, err = parseActions(`fdsfdsf`)
|
||||
require.EqualError(t, err, "parameter 'action' cannot be 'fdsfdsf', valid values are 'view', 'broadcast' and 'http'")
|
||||
_, err = Parse(`fdsfdsf`)
|
||||
require.EqualError(t, err, "parameter 'action' cannot be 'fdsfdsf', valid values are 'view', 'broadcast', 'http' and 'copy'")
|
||||
|
||||
_, err = parseActions(`aaa=a, "bbb, 'ccc, ddd, eee "`)
|
||||
_, err = Parse(`aaa=a, "bbb, 'ccc, ddd, eee "`)
|
||||
require.EqualError(t, err, "key 'aaa' unknown")
|
||||
|
||||
_, err = parseActions(`action=http, label="omg the end quote is missing`)
|
||||
_, err = Parse(`action=http, label="omg the end quote is missing`)
|
||||
require.EqualError(t, err, "unexpected end of input, quote started at position 20")
|
||||
|
||||
_, err = parseActions(`;;;;`)
|
||||
_, err = Parse(`;;;;`)
|
||||
require.EqualError(t, err, "only 3 actions allowed")
|
||||
|
||||
_, err = parseActions(`,,,,,,;;`)
|
||||
_, err = Parse(`,,,,,,;;`)
|
||||
require.EqualError(t, err, "term '' unknown")
|
||||
|
||||
_, err = parseActions(`''";,;"`)
|
||||
_, err = Parse(`''";,;"`)
|
||||
require.EqualError(t, err, "unexpected character '\"' at position 2")
|
||||
|
||||
_, err = parseActions(`action=http, label=a label, body=somebody`)
|
||||
_, err = Parse(`action=http, label=a label, body=somebody`)
|
||||
require.EqualError(t, err, "parameter 'url' is required for action 'http'")
|
||||
|
||||
_, err = parseActions(`action=http, label=a label, url=http://ntfy.sh, method=HEAD, body=somebody`)
|
||||
_, err = Parse(`action=http, label=a label, url=http://ntfy.sh, method=HEAD, body=somebody`)
|
||||
require.EqualError(t, err, "parameter 'body' cannot be set if method is HEAD")
|
||||
|
||||
_, err = parseActions(`[ invalid json ]`)
|
||||
_, err = Parse(`[ invalid json ]`)
|
||||
require.EqualError(t, err, "JSON error: invalid character 'i' looking for beginning of value")
|
||||
|
||||
_, err = parseActions(`[ { "some": "object" } ]`)
|
||||
require.EqualError(t, err, "parameter 'action' cannot be '', valid values are 'view', 'broadcast' and 'http'")
|
||||
_, err = Parse(`[ { "some": "object" } ]`)
|
||||
require.EqualError(t, err, "parameter 'action' cannot be '', valid values are 'view', 'broadcast', 'http' and 'copy'")
|
||||
|
||||
_, err = parseActions("\x00\x01\xFFx\xFE")
|
||||
_, err = Parse("\x00\x01\xFFx\xFE")
|
||||
require.EqualError(t, err, "invalid utf-8 string")
|
||||
|
||||
_, err = parseActions(`http, label, http://x.org, clear=x`)
|
||||
_, err = Parse(`http, label, http://x.org, clear=x`)
|
||||
require.EqualError(t, err, "parameter 'clear' cannot be 'x', only boolean values are allowed (true/yes/1/false/no/0)")
|
||||
|
||||
}
|
||||
|
Before Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,23 @@
|
||||
package attachment
|
||||
|
||||
import (
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
// backendObject represents an object stored in a backend.
|
||||
type object struct {
|
||||
ID string
|
||||
Size int64
|
||||
LastModified time.Time
|
||||
}
|
||||
|
||||
// backend is a minimal I/O interface for storing and retrieving attachment files.
|
||||
// It has no knowledge of size tracking, limiting, or ID validation.
|
||||
type backend interface {
|
||||
Put(id string, reader io.Reader, untrustedLength int64) error
|
||||
Get(id string) (io.ReadCloser, int64, error)
|
||||
List() ([]object, error)
|
||||
Delete(ids ...string) error
|
||||
DeleteIncomplete(cutoff time.Time) error
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package attachment
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fileBackend struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
var _ backend = (*fileBackend)(nil)
|
||||
|
||||
func newFileBackend(dir string) (*fileBackend, error) {
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &fileBackend{dir: dir}, nil
|
||||
}
|
||||
|
||||
func (b *fileBackend) Put(id string, reader io.Reader, untrustedLength int64) error {
|
||||
if untrustedLength > 0 {
|
||||
reader = io.LimitReader(reader, untrustedLength)
|
||||
}
|
||||
file := filepath.Join(b.dir, id)
|
||||
f, err := os.OpenFile(file, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
n, err := io.Copy(f, reader)
|
||||
if err != nil {
|
||||
os.Remove(file)
|
||||
return err
|
||||
} else if untrustedLength > 0 && n != untrustedLength {
|
||||
os.Remove(file)
|
||||
return fmt.Errorf("content length mismatch: claimed %d, got %d", untrustedLength, n)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
os.Remove(file)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *fileBackend) List() ([]object, error) {
|
||||
entries, err := os.ReadDir(b.dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
objects := make([]object, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
objects = append(objects, object{
|
||||
ID: e.Name(),
|
||||
Size: info.Size(),
|
||||
LastModified: info.ModTime(),
|
||||
})
|
||||
}
|
||||
return objects, nil
|
||||
}
|
||||
|
||||
func (b *fileBackend) Get(id string) (io.ReadCloser, int64, error) {
|
||||
file := filepath.Join(b.dir, id)
|
||||
stat, err := os.Stat(file)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return f, stat.Size(), nil
|
||||
}
|
||||
|
||||
func (b *fileBackend) Delete(ids ...string) error {
|
||||
for _, id := range ids {
|
||||
file := filepath.Join(b.dir, id)
|
||||
if err := os.Remove(file); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *fileBackend) DeleteIncomplete(_ time.Time) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package attachment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/s3"
|
||||
)
|
||||
|
||||
type s3Backend struct {
|
||||
client *s3.Client
|
||||
}
|
||||
|
||||
var _ backend = (*s3Backend)(nil)
|
||||
|
||||
func newS3Backend(client *s3.Client) *s3Backend {
|
||||
return &s3Backend{client: client}
|
||||
}
|
||||
|
||||
func (b *s3Backend) Put(id string, reader io.Reader, untrustedLength int64) error {
|
||||
return b.client.PutObject(context.Background(), id, reader, untrustedLength)
|
||||
}
|
||||
|
||||
func (b *s3Backend) Get(id string) (io.ReadCloser, int64, error) {
|
||||
return b.client.GetObject(context.Background(), id)
|
||||
}
|
||||
|
||||
func (b *s3Backend) List() ([]object, error) {
|
||||
objects, err := b.client.ListObjectsV2(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]object, 0, len(objects))
|
||||
for _, obj := range objects {
|
||||
result = append(result, object{
|
||||
ID: obj.Key,
|
||||
Size: obj.Size,
|
||||
LastModified: obj.LastModified,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (b *s3Backend) Delete(ids ...string) error {
|
||||
return b.client.DeleteObjects(context.Background(), ids)
|
||||
}
|
||||
|
||||
func (b *s3Backend) DeleteIncomplete(cutoff time.Time) error {
|
||||
return b.client.AbortIncompleteUploads(context.Background(), cutoff)
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package attachment
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/s3"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
const (
|
||||
tagStore = "attachment_store"
|
||||
syncInterval = 15 * time.Minute // How often to run the background sync loop
|
||||
)
|
||||
|
||||
var errInvalidFileID = errors.New("invalid file ID")
|
||||
|
||||
// Store manages attachment storage with shared logic for size tracking, limiting,
|
||||
// ID validation, and background sync to reconcile storage with the database.
|
||||
type Store struct {
|
||||
backend backend
|
||||
limit int64 // Defined limit of the store in bytes
|
||||
size int64 // Current size of the store in bytes
|
||||
sizes map[string]int64 // File ID -> size, for subtracting on Remove
|
||||
attachmentsWithSizes func() (map[string]int64, error) // Returns file ID -> size for active attachments
|
||||
orphanGracePeriod time.Duration // Don't delete orphaned objects younger than this
|
||||
closeChan chan struct{}
|
||||
doneChan chan struct{}
|
||||
mu sync.RWMutex // Protects size and sizes
|
||||
}
|
||||
|
||||
// NewFileStore creates a new file-system backed attachment cache
|
||||
func NewFileStore(dir string, totalSizeLimit int64, orphanGracePeriod time.Duration, attachmentsWithSizes func() (map[string]int64, error)) (*Store, error) {
|
||||
b, err := newFileBackend(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newStore(b, totalSizeLimit, orphanGracePeriod, attachmentsWithSizes)
|
||||
}
|
||||
|
||||
// NewS3Store creates a new S3-backed attachment cache. The s3URL must be in the format:
|
||||
//
|
||||
// s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT][&disable_http2=true]
|
||||
func NewS3Store(s3URL string, totalSizeLimit int64, orphanGracePeriod time.Duration, attachmentsWithSizes func() (map[string]int64, error)) (*Store, error) {
|
||||
config, err := s3.ParseURL(s3URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newStore(newS3Backend(s3.New(config)), totalSizeLimit, orphanGracePeriod, attachmentsWithSizes)
|
||||
}
|
||||
|
||||
func newStore(backend backend, totalSizeLimit int64, orphanGracePeriod time.Duration, attachmentsWithSizes func() (map[string]int64, error)) (*Store, error) {
|
||||
c := &Store{
|
||||
backend: backend,
|
||||
limit: totalSizeLimit,
|
||||
sizes: make(map[string]int64),
|
||||
attachmentsWithSizes: attachmentsWithSizes,
|
||||
orphanGracePeriod: orphanGracePeriod,
|
||||
closeChan: make(chan struct{}),
|
||||
doneChan: make(chan struct{}),
|
||||
}
|
||||
// Hydrate sizes from the database immediately so that Size()/Remaining()/Remove()
|
||||
// are accurate from the start, without waiting for the first sync() call.
|
||||
if attachmentsWithSizes != nil {
|
||||
attachments, err := attachmentsWithSizes()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("attachment store: failed to load existing attachments: %w", err)
|
||||
}
|
||||
for id, size := range attachments {
|
||||
c.sizes[id] = size
|
||||
c.size += size
|
||||
}
|
||||
go c.syncLoop()
|
||||
} else {
|
||||
close(c.doneChan)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Write stores an attachment file. The id is validated, and the write is subject to
|
||||
// the total size limit and any additional limiters. The untrustedLength is a hint
|
||||
// from the client's Content-Length header; backends may use it to optimize uploads (e.g.
|
||||
// streaming directly to S3 without buffering).
|
||||
func (c *Store) Write(id string, reader io.Reader, untrustedLength int64, limiters ...util.Limiter) (int64, error) {
|
||||
if !model.ValidMessageID(id) {
|
||||
return 0, errInvalidFileID
|
||||
}
|
||||
log.Tag(tagStore).Field("message_id", id).Debug("Writing attachment")
|
||||
limiters = append(limiters, util.NewFixedLimiter(c.Remaining()))
|
||||
countingReader := util.NewCountingReader(reader)
|
||||
limitReader := util.NewLimitReader(countingReader, limiters...)
|
||||
if err := c.backend.Put(id, limitReader, untrustedLength); err != nil {
|
||||
c.backend.Delete(id) //nolint:errcheck
|
||||
return 0, err
|
||||
}
|
||||
size := countingReader.Total()
|
||||
c.mu.Lock()
|
||||
c.size += size
|
||||
c.sizes[id] = size
|
||||
c.mu.Unlock()
|
||||
return size, nil
|
||||
}
|
||||
|
||||
// Read retrieves an attachment file by ID
|
||||
func (c *Store) Read(id string) (io.ReadCloser, int64, error) {
|
||||
if !model.ValidMessageID(id) {
|
||||
return nil, 0, errInvalidFileID
|
||||
}
|
||||
return c.backend.Get(id)
|
||||
}
|
||||
|
||||
// Remove deletes attachment files by ID and subtracts their known sizes from
|
||||
// the total. Sizes for objects not tracked (e.g. written before this process
|
||||
// started and before the first sync) are corrected by the next sync() call.
|
||||
func (c *Store) Remove(ids ...string) error {
|
||||
for _, id := range ids {
|
||||
if !model.ValidMessageID(id) {
|
||||
return errInvalidFileID
|
||||
}
|
||||
}
|
||||
// Remove from backend
|
||||
for _, id := range ids {
|
||||
log.Tag(tagStore).Field("message_id", id).Debug("Removing attachment")
|
||||
}
|
||||
if err := c.backend.Delete(ids...); err != nil {
|
||||
return err
|
||||
}
|
||||
// Update total cache size
|
||||
c.mu.Lock()
|
||||
for _, id := range ids {
|
||||
if size, ok := c.sizes[id]; ok {
|
||||
c.size -= size
|
||||
delete(c.sizes, id)
|
||||
}
|
||||
}
|
||||
if c.size < 0 {
|
||||
c.size = 0
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sync triggers an immediate reconciliation of storage with the database.
|
||||
func (c *Store) Sync() error {
|
||||
return c.sync()
|
||||
}
|
||||
|
||||
// sync reconciles the backend storage with the database. It lists all objects,
|
||||
// deletes orphans (not in the valid ID set and older than the grace period), and
|
||||
// recomputes the total size from the existing attachments in the database.
|
||||
func (c *Store) sync() error {
|
||||
if c.attachmentsWithSizes == nil {
|
||||
return nil
|
||||
}
|
||||
attachmentsWithSizes, err := c.attachmentsWithSizes()
|
||||
if err != nil {
|
||||
return fmt.Errorf("attachment sync: failed to get existing attachments: %w", err)
|
||||
}
|
||||
remoteObjects, err := c.backend.List()
|
||||
if err != nil {
|
||||
return fmt.Errorf("attachment sync: failed to list objects: %w", err)
|
||||
}
|
||||
// Calculate total cache size and collect orphaned attachments, excluding objects younger
|
||||
// than the grace period to account for races, and skipping objects with invalid IDs.
|
||||
cutoff := time.Now().Add(-c.orphanGracePeriod)
|
||||
var orphanIDs []string
|
||||
var count, totalSize int64
|
||||
sizes := make(map[string]int64, len(remoteObjects))
|
||||
for _, obj := range remoteObjects {
|
||||
if !model.ValidMessageID(obj.ID) {
|
||||
continue
|
||||
}
|
||||
if _, ok := attachmentsWithSizes[obj.ID]; !ok && obj.LastModified.Before(cutoff) {
|
||||
orphanIDs = append(orphanIDs, obj.ID)
|
||||
} else {
|
||||
count++
|
||||
totalSize += attachmentsWithSizes[obj.ID]
|
||||
sizes[obj.ID] = attachmentsWithSizes[obj.ID]
|
||||
}
|
||||
}
|
||||
log.Tag(tagStore).Debug("Attachment store updated: %d attachment(s), %s", count, util.FormatSizeHuman(totalSize))
|
||||
c.mu.Lock()
|
||||
c.size = totalSize
|
||||
c.sizes = sizes
|
||||
c.mu.Unlock()
|
||||
// Delete orphaned attachments
|
||||
if len(orphanIDs) > 0 {
|
||||
log.Tag(tagStore).Debug("Deleting %d orphaned attachment(s)", len(orphanIDs))
|
||||
if err := c.backend.Delete(orphanIDs...); err != nil {
|
||||
return fmt.Errorf("attachment sync: failed to delete orphaned objects: %w", err)
|
||||
}
|
||||
}
|
||||
// Clean up incomplete uploads (S3 only)
|
||||
if err := c.backend.DeleteIncomplete(cutoff); err != nil {
|
||||
log.Tag(tagStore).Err(err).Warn("Failed to abort incomplete uploads from attachment cache")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Size returns the current total size of all attachments
|
||||
func (c *Store) Size() int64 {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.size
|
||||
}
|
||||
|
||||
// Remaining returns the remaining capacity for attachments
|
||||
func (c *Store) Remaining() int64 {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
remaining := c.limit - c.size
|
||||
if remaining < 0 {
|
||||
return 0
|
||||
}
|
||||
return remaining
|
||||
}
|
||||
|
||||
// Close stops the background sync goroutine and waits for it to finish
|
||||
func (c *Store) Close() {
|
||||
close(c.closeChan)
|
||||
<-c.doneChan
|
||||
}
|
||||
|
||||
func (c *Store) syncLoop() {
|
||||
defer close(c.doneChan)
|
||||
if err := c.sync(); err != nil {
|
||||
log.Tag(tagStore).Err(err).Warn("Attachment sync failed")
|
||||
}
|
||||
ticker := time.NewTicker(syncInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if err := c.sync(); err != nil {
|
||||
log.Tag(tagStore).Err(err).Warn("Attachment sync failed")
|
||||
}
|
||||
case <-c.closeChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package attachment
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestFileStore(t *testing.T, totalSizeLimit int64) (dir string, cache *Store) {
|
||||
t.Helper()
|
||||
dir = t.TempDir()
|
||||
cache, err := NewFileStore(dir, totalSizeLimit, time.Hour, nil)
|
||||
require.Nil(t, err)
|
||||
t.Cleanup(func() { cache.Close() })
|
||||
return dir, cache
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package attachment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/s3"
|
||||
)
|
||||
|
||||
func TestS3Store_WriteWithPrefix(t *testing.T) {
|
||||
s3URL := os.Getenv("NTFY_TEST_S3_URL")
|
||||
if s3URL == "" {
|
||||
t.Skip("NTFY_TEST_S3_URL not set")
|
||||
}
|
||||
cfg, err := s3.ParseURL(s3URL)
|
||||
require.Nil(t, err)
|
||||
cfg.Prefix = "test-prefix"
|
||||
client := s3.New(cfg)
|
||||
deleteAllObjects(t, client)
|
||||
backend := newS3Backend(client)
|
||||
cache, err := newStore(backend, 10*1024, time.Hour, nil)
|
||||
require.Nil(t, err)
|
||||
t.Cleanup(func() {
|
||||
deleteAllObjects(t, client)
|
||||
cache.Close()
|
||||
})
|
||||
|
||||
size, err := cache.Write("abcdefghijkl", strings.NewReader("test"), 0)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(4), size)
|
||||
|
||||
reader, _, err := cache.Read("abcdefghijkl")
|
||||
require.Nil(t, err)
|
||||
data, err := io.ReadAll(reader)
|
||||
reader.Close()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "test", string(data))
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func newTestRealS3Store(t *testing.T, totalSizeLimit int64) (*Store, *modTimeOverrideBackend) {
|
||||
t.Helper()
|
||||
s3URL := os.Getenv("NTFY_TEST_S3_URL")
|
||||
if s3URL == "" {
|
||||
t.Skip("NTFY_TEST_S3_URL not set")
|
||||
}
|
||||
cfg, err := s3.ParseURL(s3URL)
|
||||
require.Nil(t, err)
|
||||
if cfg.Prefix != "" {
|
||||
cfg.Prefix = cfg.Prefix + "/testpkg-attachment"
|
||||
} else {
|
||||
cfg.Prefix = "testpkg-attachment"
|
||||
}
|
||||
client := s3.New(cfg)
|
||||
inner := newS3Backend(client)
|
||||
wrapper := &modTimeOverrideBackend{backend: inner, modTimes: make(map[string]time.Time)}
|
||||
deleteAllObjects(t, client)
|
||||
store, err := newStore(wrapper, totalSizeLimit, time.Hour, nil)
|
||||
require.Nil(t, err)
|
||||
t.Cleanup(func() {
|
||||
deleteAllObjects(t, client)
|
||||
store.Close()
|
||||
})
|
||||
return store, wrapper
|
||||
}
|
||||
|
||||
func deleteAllObjects(t *testing.T, client *s3.Client) {
|
||||
t.Helper()
|
||||
for i := 0; i < 20; i++ {
|
||||
objects, err := client.ListObjectsV2(context.Background())
|
||||
require.Nil(t, err)
|
||||
if len(objects) == 0 {
|
||||
return
|
||||
}
|
||||
keys := make([]string, len(objects))
|
||||
for j, obj := range objects {
|
||||
keys[j] = obj.Key
|
||||
}
|
||||
require.Nil(t, client.DeleteObjects(context.Background(), keys))
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("timed out waiting for bucket to be empty")
|
||||
}
|
||||
|
||||
// modTimeOverrideBackend wraps a backend and allows overriding LastModified times returned by List().
|
||||
// This is used in tests to simulate old objects on backends (like real S3) where
|
||||
// LastModified cannot be set directly.
|
||||
type modTimeOverrideBackend struct {
|
||||
backend
|
||||
mu sync.Mutex
|
||||
modTimes map[string]time.Time // object ID -> override time
|
||||
}
|
||||
|
||||
func (b *modTimeOverrideBackend) List() ([]object, error) {
|
||||
objects, err := b.backend.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
for i, obj := range objects {
|
||||
if t, ok := b.modTimes[obj.ID]; ok {
|
||||
objects[i].LastModified = t
|
||||
}
|
||||
}
|
||||
return objects, nil
|
||||
}
|
||||
|
||||
func (b *modTimeOverrideBackend) setModTime(id string, t time.Time) {
|
||||
b.mu.Lock()
|
||||
b.modTimes[id] = t
|
||||
b.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
package attachment
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
const testSizeLimit = 10 * 1024
|
||||
|
||||
func TestStore_WriteReadRemove(t *testing.T) {
|
||||
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
|
||||
// Write
|
||||
size, err := s.Write("abcdefghijkl", strings.NewReader("hello world"), 0)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(11), size)
|
||||
require.Equal(t, int64(11), s.Size())
|
||||
|
||||
// Read back
|
||||
reader, readSize, err := s.Read("abcdefghijkl")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(11), readSize)
|
||||
data, err := io.ReadAll(reader)
|
||||
reader.Close()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "hello world", string(data))
|
||||
|
||||
// Remove
|
||||
require.Nil(t, s.Remove("abcdefghijkl"))
|
||||
require.Equal(t, int64(0), s.Size())
|
||||
|
||||
// Read after remove should fail
|
||||
_, _, err = s.Read("abcdefghijkl")
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestStore_WriteRemoveMultiple(t *testing.T) {
|
||||
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
|
||||
for i := 0; i < 5; i++ {
|
||||
_, err := s.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(make([]byte, 100)), 0)
|
||||
require.Nil(t, err)
|
||||
}
|
||||
require.Equal(t, int64(500), s.Size())
|
||||
|
||||
require.Nil(t, s.Remove("abcdefghijk1", "abcdefghijk3"))
|
||||
require.Equal(t, int64(300), s.Size())
|
||||
|
||||
// Removed files should not be readable
|
||||
_, _, err := s.Read("abcdefghijk1")
|
||||
require.Error(t, err)
|
||||
_, _, err = s.Read("abcdefghijk3")
|
||||
require.Error(t, err)
|
||||
|
||||
// Remaining files should still be readable
|
||||
for _, id := range []string{"abcdefghijk0", "abcdefghijk2", "abcdefghijk4"} {
|
||||
reader, _, err := s.Read(id)
|
||||
require.Nil(t, err)
|
||||
reader.Close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStore_WriteTotalSizeLimit(t *testing.T) {
|
||||
forEachBackend(t, 100, func(t *testing.T, s *Store, _ func(string)) {
|
||||
// First write fits
|
||||
_, err := s.Write("abcdefghijk0", bytes.NewReader(make([]byte, 80)), 0)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(80), s.Size())
|
||||
require.Equal(t, int64(20), s.Remaining())
|
||||
|
||||
// Second write exceeds total limit
|
||||
_, err = s.Write("abcdefghijk1", bytes.NewReader(make([]byte, 50)), 0)
|
||||
require.ErrorIs(t, err, util.ErrLimitReached)
|
||||
})
|
||||
}
|
||||
|
||||
func TestStore_WriteAdditionalLimiter(t *testing.T) {
|
||||
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
|
||||
_, err := s.Write("abcdefghijkl", bytes.NewReader(make([]byte, 200)), 0, util.NewFixedLimiter(100))
|
||||
require.ErrorIs(t, err, util.ErrLimitReached)
|
||||
|
||||
// File should not be readable (was cleaned up)
|
||||
_, _, err = s.Read("abcdefghijkl")
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestStore_WriteWithLimiter(t *testing.T) {
|
||||
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
|
||||
size, err := s.Write("abcdefghijkl", strings.NewReader("normal file"), 0, util.NewFixedLimiter(999))
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(11), size)
|
||||
require.Equal(t, int64(11), s.Size())
|
||||
})
|
||||
}
|
||||
|
||||
func TestStore_WriteOverwriteSameID(t *testing.T) {
|
||||
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
|
||||
// Write 100 bytes
|
||||
_, err := s.Write("abcdefghijkl", bytes.NewReader(make([]byte, 100)), 0)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(100), s.Size())
|
||||
|
||||
// Overwrite with 50 bytes
|
||||
_, err = s.Write("abcdefghijkl", bytes.NewReader(make([]byte, 50)), 0)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(150), s.Size()) // Store tracks both writes
|
||||
|
||||
// Read back should return the latest content
|
||||
reader, readSize, err := s.Read("abcdefghijkl")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(50), readSize)
|
||||
reader.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func TestStore_WriteAfterFailure(t *testing.T) {
|
||||
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
|
||||
// Failed write: limiter rejects it
|
||||
_, err := s.Write("abcdefghijkl", bytes.NewReader(make([]byte, 200)), 0, util.NewFixedLimiter(100))
|
||||
require.ErrorIs(t, err, util.ErrLimitReached)
|
||||
require.Equal(t, int64(0), s.Size())
|
||||
|
||||
// Subsequent write with a different ID should succeed
|
||||
size, err := s.Write("abcdefghijk2", strings.NewReader("hello"), 0)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(5), size)
|
||||
require.Equal(t, int64(5), s.Size())
|
||||
|
||||
// The failed ID should not be readable
|
||||
_, _, err = s.Read("abcdefghijkl")
|
||||
require.Error(t, err)
|
||||
|
||||
// The successful ID should be readable
|
||||
reader, _, err := s.Read("abcdefghijk2")
|
||||
require.Nil(t, err)
|
||||
reader.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func TestStore_SyncRecomputesSize(t *testing.T) {
|
||||
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, makeOld func(string)) {
|
||||
// Write two files
|
||||
_, err := s.Write("abcdefghijk0", bytes.NewReader(make([]byte, 100)), 0)
|
||||
require.Nil(t, err)
|
||||
_, err = s.Write("abcdefghijk1", bytes.NewReader(make([]byte, 200)), 0)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(300), s.Size())
|
||||
|
||||
// Corrupt the in-memory size tracking
|
||||
s.mu.Lock()
|
||||
s.size = 999
|
||||
s.mu.Unlock()
|
||||
require.Equal(t, int64(999), s.Size())
|
||||
|
||||
// Set attachmentsWithSizes to include both files so nothing gets deleted
|
||||
s.attachmentsWithSizes = func() (map[string]int64, error) {
|
||||
return map[string]int64{"abcdefghijk0": 100, "abcdefghijk1": 200}, nil
|
||||
}
|
||||
|
||||
// Sync should recompute size from the backend
|
||||
require.Nil(t, s.sync())
|
||||
require.Equal(t, int64(300), s.Size())
|
||||
})
|
||||
}
|
||||
|
||||
func TestStore_ReadNotFound(t *testing.T) {
|
||||
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
|
||||
_, _, err := s.Read("abcdefghijkl")
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestStore_InvalidID(t *testing.T) {
|
||||
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
|
||||
_, err := s.Write("bad", strings.NewReader("x"), 0)
|
||||
require.Equal(t, errInvalidFileID, err)
|
||||
|
||||
_, _, err = s.Read("bad")
|
||||
require.Equal(t, errInvalidFileID, err)
|
||||
|
||||
err = s.Remove("bad")
|
||||
require.Equal(t, errInvalidFileID, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestStore_WriteLargeObjects(t *testing.T) {
|
||||
sizes := map[string]int64{
|
||||
"100B": 100,
|
||||
"6MB": 6 * 1024 * 1024,
|
||||
"12MB": 12 * 1024 * 1024,
|
||||
}
|
||||
for name, sz := range sizes {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
forEachBackend(t, sz+1024, func(t *testing.T, s *Store, _ func(string)) {
|
||||
data := make([]byte, sz)
|
||||
for i := range data {
|
||||
data[i] = byte(i % 251)
|
||||
}
|
||||
|
||||
size, err := s.Write("abcdefghijkl", bytes.NewReader(data), 0)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, sz, size)
|
||||
require.Equal(t, sz, s.Size())
|
||||
|
||||
reader, readSize, err := s.Read("abcdefghijkl")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, sz, readSize)
|
||||
got, err := io.ReadAll(reader)
|
||||
reader.Close()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, data, got)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_WriteUntrustedLengthExact(t *testing.T) {
|
||||
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
|
||||
size, err := s.Write("abcdefghijkl", strings.NewReader("hello world"), 11)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(11), size)
|
||||
|
||||
reader, _, err := s.Read("abcdefghijkl")
|
||||
require.Nil(t, err)
|
||||
data, err := io.ReadAll(reader)
|
||||
reader.Close()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "hello world", string(data))
|
||||
})
|
||||
}
|
||||
|
||||
func TestStore_WriteUntrustedLengthBodyLonger(t *testing.T) {
|
||||
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
|
||||
// Body has 11 bytes, but we claim 5 — only first 5 bytes should be stored
|
||||
size, err := s.Write("abcdefghijkl", strings.NewReader("hello world"), 5)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(5), size)
|
||||
|
||||
reader, _, err := s.Read("abcdefghijkl")
|
||||
require.Nil(t, err)
|
||||
data, err := io.ReadAll(reader)
|
||||
reader.Close()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "hello", string(data))
|
||||
})
|
||||
}
|
||||
|
||||
func TestStore_WriteUntrustedLengthBodyShorter(t *testing.T) {
|
||||
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
|
||||
// Body has 5 bytes, but we claim 100 — should fail
|
||||
_, err := s.Write("abcdefghijkl", strings.NewReader("hello"), 100)
|
||||
require.Error(t, err)
|
||||
|
||||
// File should not be readable (was cleaned up)
|
||||
_, _, err = s.Read("abcdefghijkl")
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestStore_Sync(t *testing.T) {
|
||||
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, makeOld func(string)) {
|
||||
// Write some files
|
||||
_, err := s.Write("abcdefghijk0", strings.NewReader("file0"), 0)
|
||||
require.Nil(t, err)
|
||||
_, err = s.Write("abcdefghijk1", strings.NewReader("file1"), 0)
|
||||
require.Nil(t, err)
|
||||
_, err = s.Write("abcdefghijk2", strings.NewReader("file2"), 0)
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, int64(15), s.Size())
|
||||
|
||||
// Set the ID provider to only know about file 0 and 2
|
||||
s.attachmentsWithSizes = func() (map[string]int64, error) {
|
||||
return map[string]int64{"abcdefghijk0": 5, "abcdefghijk2": 5}, nil
|
||||
}
|
||||
|
||||
// Make file 1 old enough to be cleaned up
|
||||
makeOld("abcdefghijk1")
|
||||
|
||||
// Run sync
|
||||
require.Nil(t, s.sync())
|
||||
|
||||
// File 1 should be deleted (orphan, old enough)
|
||||
_, _, err = s.Read("abcdefghijk1")
|
||||
require.Error(t, err)
|
||||
|
||||
// Files 0 and 2 should still be readable
|
||||
r, _, err := s.Read("abcdefghijk0")
|
||||
require.Nil(t, err)
|
||||
r.Close()
|
||||
r, _, err = s.Read("abcdefghijk2")
|
||||
require.Nil(t, err)
|
||||
r.Close()
|
||||
|
||||
// Size should be updated
|
||||
require.Equal(t, int64(10), s.Size())
|
||||
})
|
||||
}
|
||||
|
||||
func TestStore_Sync_SkipsRecentFiles(t *testing.T) {
|
||||
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
|
||||
// Write a file
|
||||
_, err := s.Write("abcdefghijk0", strings.NewReader("file0"), 0)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Set the ID provider to return empty (no valid IDs)
|
||||
s.attachmentsWithSizes = func() (map[string]int64, error) {
|
||||
return map[string]int64{}, nil
|
||||
}
|
||||
|
||||
// File was just created, so it should NOT be deleted (< 1 hour old)
|
||||
require.Nil(t, s.sync())
|
||||
|
||||
// File should still exist
|
||||
reader, _, err := s.Read("abcdefghijk0")
|
||||
require.Nil(t, err)
|
||||
reader.Close()
|
||||
})
|
||||
}
|
||||
|
||||
// forEachBackend runs f against both the file and S3 backends. It also provides a makeOld
|
||||
// callback that makes a specific object's timestamp old enough for orphan cleanup (> 1 hour).
|
||||
// For the file backend, this uses os.Chtimes; for the S3 backend, it overrides the object's
|
||||
// LastModified time via a modTimeOverrideBackend wrapper. Objects start with recent timestamps
|
||||
// by default. The S3 subtest is skipped if NTFY_TEST_S3_URL is not set.
|
||||
func forEachBackend(t *testing.T, totalSizeLimit int64, f func(t *testing.T, s *Store, makeOld func(string))) {
|
||||
t.Run("file", func(t *testing.T) {
|
||||
dir, s := newTestFileStore(t, totalSizeLimit)
|
||||
makeOld := func(id string) {
|
||||
oldTime := time.Unix(1, 0)
|
||||
os.Chtimes(filepath.Join(dir, id), oldTime, oldTime)
|
||||
}
|
||||
f(t, s, makeOld)
|
||||
})
|
||||
t.Run("s3", func(t *testing.T) {
|
||||
s, wrapper := newTestRealS3Store(t, totalSizeLimit)
|
||||
makeOld := func(id string) {
|
||||
wrapper.setModTime(id, time.Unix(1, 0))
|
||||
}
|
||||
f(t, s, makeOld)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// Package ban implements the abuse ban-feed: it tracks per-prefix weighted "strikes" from rejected
|
||||
// requests and appends breaching prefixes to a ban file that fail2ban tails. Keying by prefix (not
|
||||
// by visitor) makes the accounting match the unit fail2ban bans, even for shared account visitors.
|
||||
package ban
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
)
|
||||
|
||||
const (
|
||||
tag = "ban"
|
||||
pruneInterval = 10 * time.Minute
|
||||
writeInterval = 3 * time.Second
|
||||
)
|
||||
|
||||
// Config is the Service's config, kept separate from server.Config to avoid an import cycle.
|
||||
type Config struct {
|
||||
File string // Ban file that fail2ban tails (must be non-empty; the caller decides whether the feature is enabled)
|
||||
Window time.Duration // Rolling window over which weighted strikes are counted
|
||||
Threshold int // Weighted strikes per Window before a prefix is banned
|
||||
Weights Weights // Code matcher -> strike weight (0 = exempt)
|
||||
PrefixBitsIPv4 int // Mask width for the ban unit, e.g. 32 (matches rate-limiting granularity)
|
||||
PrefixBitsIPv6 int // Mask width for the ban unit, e.g. 64
|
||||
}
|
||||
|
||||
// tracker is the per-prefix strike state: a weighted breach detector plus timestamps for pruning and throttling.
|
||||
type tracker struct {
|
||||
limiter *rate.Limiter
|
||||
seen time.Time // Last strike, for pruning idle prefixes
|
||||
emitted time.Time // Last ban-line write for this prefix, throttles re-emits to once per Window
|
||||
}
|
||||
|
||||
// Service owns the ban-feed: per-prefix strike accounting, buffered file writes, and idle-prefix
|
||||
// pruning. The caller owns the enable/disable decision -- only construct a Service when the feature
|
||||
// is on (see server.New, which builds one only when a ban file is configured).
|
||||
type Service struct {
|
||||
conf *Config
|
||||
mu sync.Mutex // Guards trackers and pending
|
||||
trackers map[netip.Prefix]*tracker
|
||||
pending []string // Formatted ban lines buffered by Record, flushed to the ban file by runWriteLoop
|
||||
writeDone chan struct{} // Closed when runWriteLoop exits after its final flush
|
||||
closeChan chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// NewService builds a Service and starts its background loops. The caller must only call it when the
|
||||
// feature is enabled (conf non-nil, File non-empty); the Service does not model a disabled state.
|
||||
func NewService(conf *Config) *Service {
|
||||
s := &Service{
|
||||
conf: conf,
|
||||
trackers: make(map[netip.Prefix]*tracker),
|
||||
closeChan: make(chan struct{}),
|
||||
writeDone: make(chan struct{}),
|
||||
}
|
||||
go s.runPruneLoop()
|
||||
go s.runWriteLoop()
|
||||
return s
|
||||
}
|
||||
|
||||
// Record counts one rejection against the IP's prefix bucket and, on breach, buffers a ban line
|
||||
// (throttled to once per Window per prefix). No-ops for a non-4xx/5xx status or a zero-weight code.
|
||||
func (s *Service) Record(ip netip.Addr, httpCode, errorCode int) {
|
||||
if httpCode < 400 {
|
||||
return
|
||||
}
|
||||
weight := s.conf.Weights.WeightFor(errorCode)
|
||||
if weight == 0 {
|
||||
return // Weight 0: exempt, no strike
|
||||
}
|
||||
prefix := s.prefix(ip)
|
||||
now := time.Now()
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
t := s.trackers[prefix]
|
||||
if t == nil {
|
||||
t = &tracker{limiter: rate.NewLimiter(rate.Limit(float64(s.conf.Threshold)/s.conf.Window.Seconds()), s.conf.Threshold)}
|
||||
s.trackers[prefix] = t
|
||||
}
|
||||
t.seen = now
|
||||
if t.limiter.AllowN(now, weight) {
|
||||
return // Within the strike budget, no breach
|
||||
}
|
||||
if !t.emitted.IsZero() && now.Sub(t.emitted) < s.conf.Window {
|
||||
return // Already emitted this prefix within the window (one ban line per prefix per window)
|
||||
}
|
||||
t.emitted = now
|
||||
s.pending = append(s.pending, formatBanLine(now, ip, prefix, httpCode, errorCode))
|
||||
}
|
||||
|
||||
// prefix masks ip to the ban unit (PrefixBitsIPv4/IPv6) -- what fail2ban bans, e.g. a whole /64.
|
||||
func (s *Service) prefix(ip netip.Addr) netip.Prefix {
|
||||
if ip.Is4() {
|
||||
return netip.PrefixFrom(ip, s.conf.PrefixBitsIPv4).Masked()
|
||||
}
|
||||
return netip.PrefixFrom(ip, s.conf.PrefixBitsIPv6).Masked()
|
||||
}
|
||||
|
||||
// formatBanLine builds the "<RFC3339-UTC> <ip> <prefix> <http> <ntfy>" line the fail2ban filter
|
||||
// parses. The timestamp is captured at breach time, not flush time.
|
||||
func formatBanLine(t time.Time, ip netip.Addr, prefix netip.Prefix, httpCode, errorCode int) string {
|
||||
return fmt.Sprintf("%s %s %s %d %d\n", t.UTC().Format(time.RFC3339), ip.String(), prefix.String(), httpCode, errorCode)
|
||||
}
|
||||
|
||||
// runPruneLoop prunes idle prefixes until Close.
|
||||
func (s *Service) runPruneLoop() {
|
||||
ticker := time.NewTicker(pruneInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.prune()
|
||||
case <-s.closeChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runWriteLoop flushes buffered ban lines every writeInterval, plus a final flush on Close.
|
||||
func (s *Service) runWriteLoop() {
|
||||
defer close(s.writeDone)
|
||||
ticker := time.NewTicker(writeInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.flush()
|
||||
case <-s.closeChan:
|
||||
s.flush()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flush appends the buffered lines to the file in one open/write. Best-effort: a batch is dropped on
|
||||
// error. Concurrent calls are safe -- pending is drained under mu, so only one flush writes a batch.
|
||||
func (s *Service) flush() {
|
||||
s.mu.Lock()
|
||||
lines := s.pending
|
||||
s.pending = nil
|
||||
s.mu.Unlock()
|
||||
if len(lines) == 0 {
|
||||
return
|
||||
}
|
||||
f, err := os.OpenFile(s.conf.File, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
log.Tag(tag).Err(err).Warn("Cannot open ban file %s, dropped %d ban(s)", s.conf.File, len(lines))
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
for i, line := range lines {
|
||||
if _, err := f.WriteString(line); err != nil {
|
||||
log.Tag(tag).Err(err).Warn("Cannot write to ban file %s, dropped %d ban(s)", s.conf.File, len(lines)-i)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// prune drops prefixes idle for a full Window -- their bucket has refilled, so forgetting them is a
|
||||
// no-op that bounds memory under a flood of distinct IPs.
|
||||
func (s *Service) prune() {
|
||||
now := time.Now()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for prefix, t := range s.trackers {
|
||||
if now.Sub(t.seen) >= s.conf.Window {
|
||||
delete(s.trackers, prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close stops the loops and blocks until the final flush completes. Idempotent.
|
||||
func (s *Service) Close() {
|
||||
s.closeOnce.Do(func() {
|
||||
close(s.closeChan)
|
||||
<-s.writeDone
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package ban
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var testIP = netip.MustParseAddr("1.2.3.4")
|
||||
|
||||
// newTestService creates a Service wired for testing, with the given ban file, weighted-bucket
|
||||
// threshold, and weights, plus a 1-minute window (so the emit throttle only fires once per test).
|
||||
func newTestService(t *testing.T, banFile string, threshold int, weights map[string]int) *Service {
|
||||
t.Helper()
|
||||
s := NewService(&Config{
|
||||
File: banFile,
|
||||
Window: time.Minute,
|
||||
Threshold: threshold,
|
||||
Weights: weights,
|
||||
PrefixBitsIPv4: 32,
|
||||
PrefixBitsIPv6: 64,
|
||||
})
|
||||
t.Cleanup(s.Close)
|
||||
return s
|
||||
}
|
||||
|
||||
// flushAndRead forces a synchronous flush of the buffered bans (writes are otherwise async, on the
|
||||
// runWriteLoop ticker) and returns the ban file's lines.
|
||||
func flushAndRead(t *testing.T, s *Service, path string) []string {
|
||||
t.Helper()
|
||||
s.flush()
|
||||
data, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
return strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||
}
|
||||
|
||||
func TestService_Record_Weight2BansAtHalfThreshold(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
// Threshold 10, code weight 2 -> the budget covers exactly 5 hits, so the 6th breaches.
|
||||
s := newTestService(t, banFile, 10, map[string]int{"*": 2})
|
||||
for i := 0; i < 5; i++ {
|
||||
s.Record(testIP, 400, 40001)
|
||||
}
|
||||
s.flush()
|
||||
require.NoFileExists(t, banFile) // 5 hits * weight 2 = 10 == budget, exactly at the limit, not over
|
||||
s.Record(testIP, 400, 40001) // 6th hit cannot be covered -> breach
|
||||
lines := flushAndRead(t, s, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 400 40001")) // <ip> <prefix> <http> <ntfy-code>
|
||||
}
|
||||
|
||||
func TestService_Record_Weight10BansFast(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
// Threshold 10, code weight 10 -> a single hit drains the whole budget, so the 2nd breaches.
|
||||
s := newTestService(t, banFile, 10, map[string]int{"42909": 10, "*": 1})
|
||||
s.Record(testIP, 429, 42909)
|
||||
s.flush()
|
||||
require.NoFileExists(t, banFile)
|
||||
s.Record(testIP, 429, 42909) // 2nd hit cannot be covered -> breach
|
||||
lines := flushAndRead(t, s, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 429 42909"))
|
||||
}
|
||||
|
||||
func TestService_Record_Weight0NeverBans(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
// The legit-quota code is exempt (weight 0), so no number of hits ever bans.
|
||||
s := newTestService(t, banFile, 10, map[string]int{"42908": 0, "*": 1})
|
||||
for i := 0; i < 100; i++ {
|
||||
s.Record(testIP, 429, 42908)
|
||||
}
|
||||
s.flush()
|
||||
require.NoFileExists(t, banFile)
|
||||
}
|
||||
|
||||
func TestService_Record_SingleBucketNoRelaxation(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
// One shared bucket per prefix: different codes draw down the SAME budget, so mixing them creates
|
||||
// no extra headroom (unlike per-code buckets, which would relax the effective limit for a mixed
|
||||
// offender).
|
||||
s := newTestService(t, banFile, 10, map[string]int{"403*": 2, "*": 1})
|
||||
s.Record(testIP, 403, 40301) // weight 2 -> 8 left
|
||||
s.Record(testIP, 403, 40301) // weight 2 -> 6 left
|
||||
s.Record(testIP, 403, 40301) // weight 2 -> 4 left
|
||||
s.flush()
|
||||
require.NoFileExists(t, banFile)
|
||||
for i := 0; i < 4; i++ {
|
||||
s.Record(testIP, 400, 40001) // weight 1 each -> drains the remaining 4 -> 0 left
|
||||
}
|
||||
s.flush()
|
||||
require.NoFileExists(t, banFile) // 6 + 4 = 10 == budget exactly, still not over
|
||||
s.Record(testIP, 400, 40001) // one more cannot be covered -> breach
|
||||
lines := flushAndRead(t, s, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
}
|
||||
|
||||
func TestService_Record_ExactLineFormat(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
|
||||
before := time.Now().UTC().Truncate(time.Second)
|
||||
for i := 0; i < 3; i++ {
|
||||
s.Record(testIP, 429, 42901)
|
||||
}
|
||||
after := time.Now().UTC()
|
||||
lines := flushAndRead(t, s, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
parts := strings.Split(lines[0], " ")
|
||||
require.Len(t, parts, 5) // "<timestamp> <ip> <prefix> <http-code> <ntfy-code>"
|
||||
ts, err := time.Parse(time.RFC3339, parts[0])
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, time.UTC, ts.Location())
|
||||
require.False(t, ts.Before(before))
|
||||
require.False(t, ts.After(after.Add(time.Second)))
|
||||
require.Equal(t, "1.2.3.4", parts[1]) // full IP
|
||||
require.Equal(t, "1.2.3.4/32", parts[2]) // masked to the default IPv4 prefix (/32)
|
||||
require.Equal(t, "429", parts[3]) // HTTP status
|
||||
require.Equal(t, "42901", parts[4]) // ntfy code
|
||||
}
|
||||
|
||||
func TestService_Record_IPv6MaskedToPrefix(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
|
||||
ip := netip.MustParseAddr("2001:db8::abcd")
|
||||
for i := 0; i < 3; i++ {
|
||||
s.Record(ip, 429, 42901)
|
||||
}
|
||||
parts := strings.Split(flushAndRead(t, s, banFile)[0], " ")
|
||||
require.Len(t, parts, 5)
|
||||
require.Equal(t, "2001:db8::abcd", parts[1]) // full IPv6 address
|
||||
require.Equal(t, "2001:db8::/64", parts[2]) // masked to the default IPv6 prefix (/64)
|
||||
}
|
||||
|
||||
func TestService_Record_PerPrefixIsolation(t *testing.T) {
|
||||
// Each source prefix gets its own bucket: one IP hammering to a breach must not push a different,
|
||||
// quiet IP over the edge. This is the whole point of keying by prefix instead of by visitor.
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
s := newTestService(t, banFile, 2, map[string]int{"*": 1})
|
||||
noisy := netip.MustParseAddr("1.1.1.1")
|
||||
quiet := netip.MustParseAddr("2.2.2.2")
|
||||
for i := 0; i < 5; i++ {
|
||||
s.Record(noisy, 429, 42901) // breaches its own bucket
|
||||
}
|
||||
s.Record(quiet, 429, 42901) // single hit, well under threshold
|
||||
lines := flushAndRead(t, s, banFile)
|
||||
require.Len(t, lines, 1) // only the noisy prefix is written
|
||||
require.True(t, strings.HasSuffix(lines[0], " 1.1.1.1 1.1.1.1/32 429 42901"))
|
||||
}
|
||||
|
||||
func TestService_Record_OncePerWindowThrottle(t *testing.T) {
|
||||
// Once a prefix has been written, further breaches within the window must not re-append it, so a
|
||||
// persistent offender produces exactly one line per window (mirrors the old per-visitor banEmit).
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
|
||||
for i := 0; i < 50; i++ {
|
||||
s.Record(testIP, 429, 42901)
|
||||
}
|
||||
lines := flushAndRead(t, s, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
}
|
||||
|
||||
func TestService_Record_BansPassedIP(t *testing.T) {
|
||||
// The Service bans the exact IP passed to Record -- the caller passes the offending request's IP,
|
||||
// which for an account-keyed visitor is not the visitor's stored IP.
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
|
||||
offender := netip.MustParseAddr("5.6.7.8")
|
||||
for i := 0; i < 3; i++ {
|
||||
s.Record(offender, 429, 42901)
|
||||
}
|
||||
parts := strings.Split(flushAndRead(t, s, banFile)[0], " ")
|
||||
require.Equal(t, "5.6.7.8", parts[1]) // the IP passed to Record
|
||||
require.Equal(t, "5.6.7.8/32", parts[2]) // its prefix
|
||||
}
|
||||
|
||||
func TestService_Record_Ignores2xx3xx(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
s := newTestService(t, banFile, 3, map[string]int{"*": 1})
|
||||
// Success and redirects must never count toward a ban, even over the threshold -- otherwise a
|
||||
// legit high-volume publisher (lots of 200s) would get banned.
|
||||
for i := 0; i < 20; i++ {
|
||||
s.Record(testIP, 200, 20000)
|
||||
s.Record(testIP, 302, 30000)
|
||||
}
|
||||
s.flush()
|
||||
require.NoFileExists(t, banFile)
|
||||
// A 4xx over the same budget still gets written.
|
||||
for i := 0; i < 5; i++ {
|
||||
s.Record(testIP, 400, 40001)
|
||||
}
|
||||
lines := flushAndRead(t, s, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 400 40001"))
|
||||
}
|
||||
|
||||
func TestService_Record_BuffersUntilFlush(t *testing.T) {
|
||||
// Writes are async: a breach buffers the ban line rather than writing it synchronously on the
|
||||
// request path. The line only reaches the file when runWriteLoop (or an explicit flush) runs.
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
|
||||
for i := 0; i < 3; i++ {
|
||||
s.Record(testIP, 429, 42901)
|
||||
}
|
||||
require.NoFileExists(t, banFile) // not written synchronously
|
||||
s.mu.Lock()
|
||||
require.Len(t, s.pending, 1) // one line buffered (throttled to once per window)
|
||||
s.mu.Unlock()
|
||||
lines := flushAndRead(t, s, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 429 42901"))
|
||||
}
|
||||
|
||||
func TestService_Close_FlushesPending(t *testing.T) {
|
||||
// Close must flush buffered bans so nothing is lost on shutdown, and must block until it has.
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
s := NewService(&Config{File: banFile, Window: time.Minute, Threshold: 1, Weights: Weights{"*": 1}, PrefixBitsIPv4: 32, PrefixBitsIPv6: 64})
|
||||
for i := 0; i < 3; i++ {
|
||||
s.Record(testIP, 429, 42901)
|
||||
}
|
||||
require.NoFileExists(t, banFile) // still buffered
|
||||
s.Close() // blocks until the final flush completes
|
||||
data, err := os.ReadFile(banFile)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, strings.Split(strings.TrimRight(string(data), "\n"), "\n"), 1)
|
||||
}
|
||||
|
||||
func TestService_Prune_DropsIdlePrefixes(t *testing.T) {
|
||||
// A prefix idle for a full window has a refilled bucket, so prune drops it to bound memory. An
|
||||
// active prefix (seen within the window) is kept.
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
s := newTestService(t, banFile, 10, map[string]int{"*": 1})
|
||||
idle := netip.MustParseAddr("9.9.9.9")
|
||||
s.Record(idle, 400, 40001)
|
||||
s.Record(testIP, 400, 40001)
|
||||
require.Len(t, s.trackers, 2)
|
||||
|
||||
// Backdate the idle prefix past the window, then prune.
|
||||
idlePrefix := s.prefix(idle)
|
||||
s.mu.Lock()
|
||||
s.trackers[idlePrefix].seen = time.Now().Add(-2 * time.Minute)
|
||||
s.mu.Unlock()
|
||||
s.prune()
|
||||
|
||||
require.Len(t, s.trackers, 1)
|
||||
_, ok := s.trackers[idlePrefix]
|
||||
require.False(t, ok) // idle prefix dropped
|
||||
_, ok = s.trackers[s.prefix(testIP)]
|
||||
require.True(t, ok) // active prefix kept
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package ban
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Weights maps a matcher key to a strike weight for the abuse ban-feed (see ParseWeights, WeightFor).
|
||||
type Weights map[string]int
|
||||
|
||||
// ParseWeights normalizes a list like ["42909:10","403:2","*:1"] into a Weights map. A key is an
|
||||
// exact ntfy code, a family ("429*"), a bare HTTP status ("403" -> "403*"), or "*"; weights are ints
|
||||
// >= 0 (0 = exempt). Malformed entries are rejected so misconfiguration fails at startup.
|
||||
func ParseWeights(entries []string) (Weights, error) {
|
||||
out := make(Weights, len(entries))
|
||||
for _, entry := range entries {
|
||||
key, weightStr, ok := strings.Cut(entry, ":")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid ban-weight %q, want KEY:WEIGHT", entry)
|
||||
}
|
||||
weight, err := strconv.Atoi(strings.TrimSpace(weightStr))
|
||||
if err != nil || weight < 0 {
|
||||
return nil, fmt.Errorf("invalid ban-weight value in %q, want a non-negative integer", entry)
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if !validWeightKey(key) {
|
||||
return nil, fmt.Errorf("invalid ban-weight key in %q, want %q, an ntfy code, an HTTP status, or a PREFIX*", entry, "*")
|
||||
}
|
||||
// A bare 3-digit HTTP status is shorthand for the whole family (e.g. "403" -> "403*").
|
||||
if len(key) == 3 && isAllDigits(key) {
|
||||
key += "*"
|
||||
}
|
||||
out[key] = weight
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// WeightFor returns the strike weight for an ntfy error code, longest-match-wins (exact > family > "*").
|
||||
// If nothing matches (no "*" catch-all) it returns the implied default 1, so a forgotten "*" still
|
||||
// bans; use "*:0" to exempt everything not explicitly weighted.
|
||||
func (w Weights) WeightFor(errorCode int) int {
|
||||
code := strconv.Itoa(errorCode)
|
||||
weight, bestLen, matched := 0, -1, false
|
||||
for key, wt := range w {
|
||||
matchLen := -1
|
||||
switch {
|
||||
case key == "*":
|
||||
matchLen = 0
|
||||
case strings.HasSuffix(key, "*"):
|
||||
if prefix := strings.TrimSuffix(key, "*"); strings.HasPrefix(code, prefix) {
|
||||
matchLen = len(prefix)
|
||||
}
|
||||
case key == code:
|
||||
matchLen = len(code)
|
||||
}
|
||||
if matchLen > bestLen {
|
||||
weight, bestLen, matched = wt, matchLen, true
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return 1
|
||||
}
|
||||
return weight
|
||||
}
|
||||
|
||||
// validWeightKey reports whether key is a legal matcher: "*", an all-digits code, or DIGITS*.
|
||||
func validWeightKey(key string) bool {
|
||||
if key == "*" {
|
||||
return true
|
||||
}
|
||||
digits := strings.TrimSuffix(key, "*")
|
||||
return digits != "" && isAllDigits(digits)
|
||||
}
|
||||
|
||||
func isAllDigits(s string) bool {
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return s != ""
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package ban
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseWeights(t *testing.T) {
|
||||
// Exact codes, a bare 3-digit HTTP status (normalized to a family), an exempt code, and "*".
|
||||
weights, err := ParseWeights([]string{"42909:10", "403:2", "42908:0", "*:1"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Weights{"42909": 10, "403*": 2, "42908": 0, "*": 1}, weights)
|
||||
|
||||
// A bare 3-digit HTTP status normalizes to its family.
|
||||
weights, err = ParseWeights([]string{"429:5"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Weights{"429*": 5}, weights)
|
||||
|
||||
// An explicit family key stays as-is.
|
||||
weights, err = ParseWeights([]string{"429*:5"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Weights{"429*": 5}, weights)
|
||||
|
||||
// Weight 0 is valid and means exempt.
|
||||
weights, err = ParseWeights([]string{"42908:0"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Weights{"42908": 0}, weights)
|
||||
|
||||
_, err = ParseWeights([]string{"401"}) // Missing weight
|
||||
require.Error(t, err)
|
||||
_, err = ParseWeights([]string{"401:-1"}) // Negative weight
|
||||
require.Error(t, err)
|
||||
_, err = ParseWeights([]string{"401:abc"}) // Non-integer weight
|
||||
require.Error(t, err)
|
||||
_, err = ParseWeights([]string{"abc:10"}) // Non-numeric key
|
||||
require.Error(t, err)
|
||||
_, err = ParseWeights([]string{"4*3:10"}) // Star not at the end
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestWeights_WeightFor(t *testing.T) {
|
||||
weights, err := ParseWeights([]string{"42908:0", "42903:0", "42905:0", "42910:0", "42909:10", "429*:1", "403*:2", "4*:1", "5*:1"})
|
||||
require.NoError(t, err)
|
||||
// Longest-match-wins: exact 5-digit beats "429*" beats "4*" beats "*".
|
||||
require.Equal(t, 0, weights.WeightFor(42908))
|
||||
require.Equal(t, 10, weights.WeightFor(42909))
|
||||
require.Equal(t, 1, weights.WeightFor(42901))
|
||||
require.Equal(t, 2, weights.WeightFor(40311))
|
||||
require.Equal(t, 1, weights.WeightFor(40011))
|
||||
require.Equal(t, 1, weights.WeightFor(50312))
|
||||
// No rule matches (this config has 4*/5* but no "*"), so the implied default weight 1 applies.
|
||||
require.Equal(t, 1, weights.WeightFor(30012))
|
||||
}
|
||||
|
||||
func TestWeights_WeightFor_NoStarRuleImpliesWeight1(t *testing.T) {
|
||||
// With no "*" rule, a code that matches nothing defaults to weight 1 (can be banned), so the
|
||||
// feature can't be silently turned into a no-op by forgetting "*". Explicit codes still win.
|
||||
weights, err := ParseWeights([]string{"42908:0", "42909:10"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, weights.WeightFor(42908)) // explicitly exempt
|
||||
require.Equal(t, 10, weights.WeightFor(42909)) // explicit
|
||||
require.Equal(t, 1, weights.WeightFor(42901)) // unmatched -> implied 1
|
||||
require.Equal(t, 1, weights.WeightFor(40001)) // unmatched -> implied 1
|
||||
}
|
||||
|
||||
func TestWeights_WeightFor_ExplicitStarZeroExemptsAll(t *testing.T) {
|
||||
// An explicit "*:0" is the opt-out: exempt everything not otherwise weighted.
|
||||
weights, err := ParseWeights([]string{"42909:10", "*:0"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 10, weights.WeightFor(42909)) // explicit
|
||||
require.Equal(t, 0, weights.WeightFor(42901)) // *:0 -> exempt everything else
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
# default-command:
|
||||
|
||||
# Subscriptions to topics and their actions. This option is primarily used by the systemd service,
|
||||
# or if you cann "ntfy subscribe --from-config" directly.
|
||||
# or if you can "ntfy subscribe --from-config" directly.
|
||||
#
|
||||
# Example:
|
||||
# subscribe:
|
||||
|
||||
@@ -11,6 +11,9 @@ const (
|
||||
DefaultBaseURL = "https://ntfy.sh"
|
||||
)
|
||||
|
||||
// DefaultConfigFile is the default path to the client config file (set in config_*.go)
|
||||
var DefaultConfigFile string
|
||||
|
||||
// Config is the config struct for a Client
|
||||
type Config struct {
|
||||
DefaultHost string `yaml:"default-host"`
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
//go:build darwin
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func init() {
|
||||
u, err := user.Current()
|
||||
if err == nil && u.Uid == "0" {
|
||||
DefaultConfigFile = "/etc/ntfy/client.yml"
|
||||
} else if configDir, err := os.UserConfigDir(); err == nil {
|
||||
DefaultConfigFile = filepath.Join(configDir, "ntfy", "client.yml")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//go:build linux || dragonfly || freebsd || netbsd || openbsd
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func init() {
|
||||
u, err := user.Current()
|
||||
if err == nil && u.Uid == "0" {
|
||||
DefaultConfigFile = "/etc/ntfy/client.yml"
|
||||
} else if configDir, err := os.UserConfigDir(); err == nil {
|
||||
DefaultConfigFile = filepath.Join(configDir, "ntfy", "client.yml")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build windows
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func init() {
|
||||
if configDir, err := os.UserConfigDir(); err == nil {
|
||||
DefaultConfigFile = filepath.Join(configDir, "ntfy", "client.yml")
|
||||
}
|
||||
}
|
||||
@@ -77,11 +77,22 @@ func WithMarkdown() PublishOption {
|
||||
return WithHeader("X-Markdown", "yes")
|
||||
}
|
||||
|
||||
// WithTemplate instructs the server to use a specific template for the message. If templateName is is "yes" or "1",
|
||||
// the server will interpret the message and title as a template.
|
||||
func WithTemplate(templateName string) PublishOption {
|
||||
return WithHeader("X-Template", templateName)
|
||||
}
|
||||
|
||||
// WithFilename sets a filename for the attachment, and/or forces the HTTP body to interpreted as an attachment
|
||||
func WithFilename(filename string) PublishOption {
|
||||
return WithHeader("X-Filename", filename)
|
||||
}
|
||||
|
||||
// WithSequenceID sets a sequence ID for the message, allowing updates to existing notifications
|
||||
func WithSequenceID(sequenceID string) PublishOption {
|
||||
return WithHeader("X-Sequence-ID", sequenceID)
|
||||
}
|
||||
|
||||
// WithEmail instructs the server to also send the message to the given e-mail address
|
||||
func WithEmail(email string) PublishOption {
|
||||
return WithHeader("X-Email", email)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// Package cluster implements cross-node message delivery for a multi-node ntfy cluster. Nodes
|
||||
// register themselves in a PostgreSQL node registry (control plane) and fan published messages
|
||||
// out to each other directly over HTTP (data plane); PostgreSQL is never on the message path.
|
||||
// The single-node default is the nop cluster, which does nothing.
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
)
|
||||
|
||||
// The internal peer API: every kind of node-to-node communication is a path under
|
||||
// /v1/internal/, served only on the dedicated cluster listener. Future concerns (rate limit
|
||||
// counters, stats) become new paths or new sections of the state envelope.
|
||||
const (
|
||||
// MessagePath receives batches of published messages (NDJSON, one apiMessage per line).
|
||||
MessagePath = "/v1/internal/message"
|
||||
// StatePath receives peer state (JSON apiState): full subscription snapshots and
|
||||
// incremental updates.
|
||||
StatePath = "/v1/internal/state"
|
||||
)
|
||||
|
||||
// NodeID identifies a cluster node; it keys the registry, the per-peer queues, and the peer
|
||||
// state table.
|
||||
//
|
||||
// Naming convention: a "node" is any cluster member in the absolute sense (identity, registry,
|
||||
// config); a "peer" is another node as seen from this one (Peers, peerQueue, peerState). A peer
|
||||
// IS a node, which is why peer values carry a NodeID.
|
||||
type NodeID string
|
||||
|
||||
const (
|
||||
// secretHeader carries the shared secret authenticating node-to-node fan-out requests.
|
||||
secretHeader = "X-Cluster-Secret"
|
||||
|
||||
// originHeader carries the sending node's ID on fan-out requests, so a node can skip
|
||||
// requests that carry its own broadcasts (loop prevention).
|
||||
originHeader = "X-Cluster-Origin"
|
||||
)
|
||||
|
||||
// Content types of the peer API: message bodies are NDJSON (one JSON message per line, matching
|
||||
// the framing of ntfy's own /topic/json subscribe stream), state bodies are plain JSON. Future
|
||||
// node-to-node request types get their own paths on the cluster listener; an old node answering
|
||||
// 404 on an unknown path keeps mixed-version clusters working during rolling deploys.
|
||||
const (
|
||||
contentTypeNDJSON = "application/x-ndjson"
|
||||
contentTypeJSON = "application/json"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultHeartbeatInterval = 3 * time.Second // How often a node refreshes its registry heartbeat
|
||||
defaultNodeTTL = 30 * time.Second // A node counts as live if its heartbeat is newer than this; generous to avoid false-dead flapping (see plans)
|
||||
defaultStateInterval = 15 * time.Second // How often the full subscription state is pushed to peers
|
||||
|
||||
// DefaultBatchLinger is how long a fan-out message may wait in a peer's queue for more
|
||||
// messages to arrive, so they are delivered as one batch. It trades up to this much
|
||||
// cross-node latency for a bounded request rate per peer.
|
||||
DefaultBatchLinger = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// Cluster fans published messages out to peer cluster nodes and receives their fan-out requests.
|
||||
// Local delivery to a node's own subscribers still happens inline in the server; the cluster
|
||||
// only covers the cross-node hop.
|
||||
type Cluster interface {
|
||||
http.Handler
|
||||
// ForwardMessage sends a locally published message on to the peer nodes that may have subscribers
|
||||
// for its topic (all of them, when subscription knowledge is missing or stale). It is
|
||||
// fire-and-forget and must not block the caller's request path.
|
||||
ForwardMessage(m *model.Message) error
|
||||
// BroadcastState pushes a subscription-state delta to ALL peers (unlike ForwardMessage,
|
||||
// which routes), closing the routing-knowledge window to ~one round trip. Nop single-node.
|
||||
BroadcastState(state *State)
|
||||
// IsLeader reports whether this node holds the cluster leader lock. Singleton background
|
||||
// jobs (e.g. the Firebase keepaliver) are gated on the leader.
|
||||
IsLeader() bool
|
||||
// Healthy reports whether this node is fit to serve: its registry heartbeat is fresh
|
||||
// enough (within NodeTTL) that peers still forward messages to it. Health checkers must
|
||||
// fail open (never pull ALL nodes): during a full database outage every node reports
|
||||
// unhealthy while the mesh keeps delivering on stale peer caches.
|
||||
Healthy() bool
|
||||
// Close stops the cluster and releases its resources.
|
||||
Close() error
|
||||
}
|
||||
|
||||
// New creates the cluster for the given config: the nop cluster when clustering is disabled (the
|
||||
// single-node default), or the peer-mesh cluster otherwise.
|
||||
func New(conf *Config, pool *db.DB, deliver DeliverFunc, topics TopicsFunc) (Cluster, error) {
|
||||
if !conf.Enabled {
|
||||
return &nopCluster{}, nil
|
||||
}
|
||||
if pool == nil {
|
||||
return nil, errors.New("cluster mode requires a PostgreSQL database (set database-url)")
|
||||
}
|
||||
if conf.AdvertiseURL == "" {
|
||||
return nil, errors.New("cluster mode requires an advertise URL (set cluster-advertise-url)")
|
||||
}
|
||||
if conf.NodeID == "" {
|
||||
return nil, errors.New("cluster mode requires a stable node ID (set cluster-node-id)")
|
||||
}
|
||||
if conf.HeartbeatInterval == 0 {
|
||||
conf.HeartbeatInterval = defaultHeartbeatInterval
|
||||
}
|
||||
if conf.NodeTTL == 0 {
|
||||
conf.NodeTTL = defaultNodeTTL
|
||||
}
|
||||
if conf.StateInterval == 0 {
|
||||
conf.StateInterval = defaultStateInterval
|
||||
}
|
||||
return newMeshCluster(conf, pool, deliver, topics)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
dbtest "heckel.io/ntfy/v2/db/test"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
)
|
||||
|
||||
// TestMesh_Soak floods the mesh with concurrent publishers and asserts exact delivery: every
|
||||
// message reaches the peer exactly once, nothing is dropped, and batching keeps the request
|
||||
// count far below the message count. Skipped unless NTFY_TEST_SOAK is set (it takes a few
|
||||
// seconds and is meant for pre-deploy verification, not the regular suite).
|
||||
func TestMesh_Soak(t *testing.T) {
|
||||
if os.Getenv("NTFY_TEST_SOAK") == "" {
|
||||
t.Skip("NTFY_TEST_SOAK not set")
|
||||
}
|
||||
// ~1000 msg/s aggregate (10x the ntfy.sh peak of ~88 msg/s): each publisher paces itself to
|
||||
// 100 msg/s. Unthrottled publishing intentionally overruns the bounded per-peer queue (load
|
||||
// shedding by design), so a zero-drop assertion only holds below the drain ceiling.
|
||||
const (
|
||||
publishers = 10
|
||||
messagesPerPublisher = 300
|
||||
publishInterval = 10 * time.Millisecond
|
||||
total = publishers * messagesPerPublisher
|
||||
)
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
var mu sync.Mutex
|
||||
received := make(map[string]int, total) // message body -> count, to catch duplicates
|
||||
requests := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
messages, err := unmarshalMessageBody(body, 1<<20)
|
||||
require.Nil(t, err)
|
||||
mu.Lock()
|
||||
requests++
|
||||
for _, m := range messages {
|
||||
received[m.Message]++
|
||||
}
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
conf := newTestMeshConfig("node-a", "http://127.0.0.1:1")
|
||||
conf.BatchLinger = 50 * time.Millisecond
|
||||
conf.NodeTTL = time.Minute // The fake peer never heartbeats; liveness is not under test here
|
||||
registerFakePeer(t, pool, "node-peer", srv.URL)
|
||||
mesh, err := newMeshCluster(conf, pool, nil, nil)
|
||||
require.Nil(t, err)
|
||||
defer mesh.Close()
|
||||
start := time.Now()
|
||||
var wg sync.WaitGroup
|
||||
for p := 0; p < publishers; p++ {
|
||||
wg.Add(1)
|
||||
go func(p int) {
|
||||
defer wg.Done()
|
||||
ticker := time.NewTicker(publishInterval)
|
||||
defer ticker.Stop()
|
||||
for i := 0; i < messagesPerPublisher; i++ {
|
||||
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("mytopic", fmt.Sprintf("p%d-m%d", p, i))))
|
||||
<-ticker.C
|
||||
}
|
||||
}(p)
|
||||
}
|
||||
wg.Wait()
|
||||
waitFor(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return len(received) == total
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for body, count := range received {
|
||||
require.Equalf(t, 1, count, "message %s delivered %d times", body, count)
|
||||
}
|
||||
require.Less(t, requests, total/10, "expected strong batching under load")
|
||||
t.Logf("soak: %d messages, %d requests (%.1f msgs/request), %.0f msgs/s",
|
||||
total, requests, float64(total)/float64(requests), float64(total)/elapsed.Seconds())
|
||||
}
|
||||
|
||||
// BenchmarkForwardMessage measures the publish-path cost of ForwardMessage: marshal + peer lookup (cached)
|
||||
// + enqueue. The peer never drains, so enqueued fragments are dropped once the queue fills;
|
||||
// the benchmark measures the hot path, not HTTP delivery.
|
||||
func BenchmarkForwardMessage(b *testing.B) {
|
||||
if os.Getenv("NTFY_TEST_DATABASE_URL") == "" {
|
||||
b.Skip("NTFY_TEST_DATABASE_URL not set")
|
||||
}
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(b)
|
||||
pool := openTestPool(b, schemaDSN)
|
||||
conf := newTestMeshConfig("node-a", "http://127.0.0.1:1")
|
||||
conf.BatchLinger = time.Minute // Never flush; we measure enqueue only
|
||||
mesh, err := newMeshCluster(conf, pool, nil, nil)
|
||||
require.Nil(b, err)
|
||||
defer mesh.Close()
|
||||
registerFakePeer(b, pool, "node-peer", "http://127.0.0.1:1")
|
||||
m := model.NewDefaultMessage("mytopic", "benchmark message body of typical size for a push")
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if err := mesh.ForwardMessage(m); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkDecodeFanout measures the receive-path cost of decoding a 100-message NDJSON body.
|
||||
func BenchmarkDecodeFanout(b *testing.B) {
|
||||
frags := make([][]byte, 100)
|
||||
for i := range frags {
|
||||
frag, err := marshalMessage(model.NewDefaultMessage("mytopic", fmt.Sprintf("benchmark message %d", i)))
|
||||
require.Nil(b, err)
|
||||
frags[i] = frag
|
||||
}
|
||||
body := assembleMessageBody(frags)
|
||||
b.SetBytes(int64(len(body)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
messages, err := unmarshalMessageBody(body, 1<<20)
|
||||
if err != nil || len(messages) != 100 {
|
||||
b.Fatal("decode failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/cluster/registry"
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/db/pg"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/metrics"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
const (
|
||||
meshHTTPTimeout = 5 * time.Second
|
||||
peerQueueSize = 1024 // Bounded per-peer fan-out queue (drop on overflow)
|
||||
batchMaxMessages = 100 // Flush a batch early when it reaches this many messages
|
||||
batchMaxBytes = 256 * 1024 // Flush a batch early when it reaches this size
|
||||
stateMaxBytes = 4 * 1024 * 1024 // Upper bound for inbound state bodies (filter over ~1M topics)
|
||||
stateFilterFPRate = 0.01 // Bloom false-positive rate; a false positive is one wasted send
|
||||
tag = "cluster"
|
||||
)
|
||||
|
||||
// meshCluster fans messages out directly to peer nodes over HTTP (the data plane), using
|
||||
// PostgreSQL only as a control plane: the node_registry table for membership/discovery, and a
|
||||
// Postgres advisory lock for singleton-job leader election. Fan-out never touches the database on
|
||||
// the message path (only the cached peer list does). See plans/260715-scale-out-mesh.md.
|
||||
//
|
||||
// Each peer has its own bounded send queue and delivery worker, so a slow or wedged peer only
|
||||
// backs up (and eventually drops) its own queue and never delays delivery to healthy peers.
|
||||
type meshCluster struct {
|
||||
conf *Config
|
||||
deliver DeliverFunc
|
||||
topics TopicsFunc
|
||||
registry *registry.Registry
|
||||
leader *pg.Leader
|
||||
httpClient *http.Client
|
||||
mux *http.ServeMux // The internal peer API; Cluster is an http.Handler
|
||||
queues map[NodeID]*peerQueue // per-peer send queues; reconciled against the registry
|
||||
closed bool // Guards against ForwardMessage spawning new workers after Close
|
||||
states map[NodeID]*peerState // what each peer last told us (subscription knowledge)
|
||||
lastStatePush time.Time // Only touched by the heartbeat goroutine
|
||||
knownPeers map[NodeID]string // Peers seen in the last reconcile, for join/leave logging
|
||||
lastRegistered time.Time // Last successful registry heartbeat, for Healthy
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex // Protects queues, closed, knownPeers and lastRegistered
|
||||
statesMu sync.Mutex // Protects states
|
||||
}
|
||||
|
||||
// newMeshCluster creates the mesh cluster: it sets up the registry schema, registers this node
|
||||
// (synchronously, so it is discoverable before New returns), and starts the heartbeat loop.
|
||||
// Peer delivery workers are started lazily as peers appear in the registry.
|
||||
func newMeshCluster(conf *Config, pool *db.DB, deliver DeliverFunc, topics TopicsFunc) (*meshCluster, error) {
|
||||
if topics == nil {
|
||||
topics = func() []string { return nil } // No known topics; peers will broadcast to us
|
||||
}
|
||||
reg, err := registry.New(pool, string(conf.NodeID), conf.AdvertiseURL, conf.NodeTTL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Register synchronously so the node is discoverable before the constructor returns; the
|
||||
// heartbeat loop refreshes the registration from here on
|
||||
if err := reg.Register(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
c := &meshCluster{
|
||||
conf: conf,
|
||||
deliver: deliver,
|
||||
topics: topics,
|
||||
registry: reg,
|
||||
// Renews its lease on its own fixed cadence; see pg.Leader for the semantics
|
||||
leader: pg.NewLeader(pool.Primary(), pg.LeaderLockKey, conf.LeaderRenewInterval),
|
||||
httpClient: &http.Client{Timeout: meshHTTPTimeout},
|
||||
queues: make(map[NodeID]*peerQueue),
|
||||
lastRegistered: time.Now(), // The synchronous Register above just succeeded
|
||||
states: make(map[NodeID]*peerState),
|
||||
knownPeers: make(map[NodeID]string),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
c.mux = http.NewServeMux()
|
||||
c.mux.HandleFunc("POST "+MessagePath, c.authenticated(c.handleMessage))
|
||||
c.mux.HandleFunc("POST "+StatePath, c.authenticated(c.handleState))
|
||||
c.wg.Add(1)
|
||||
go c.heartbeatLoop()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// ServeHTTP serves the internal peer API. Auth lives in the authenticated middleware, so every
|
||||
// endpoint gets the same shared-secret and origin handling.
|
||||
func (c *meshCluster) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
c.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// authenticated wraps a peer API handler with the checks every endpoint needs: the shared
|
||||
// secret (constant-time compare, rejected before any body is read), a present origin, and the
|
||||
// origin self-skip (a request carrying this node's own traffic is acknowledged but ignored).
|
||||
func (c *meshCluster) authenticated(h func(origin NodeID, w http.ResponseWriter, r *http.Request)) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if c.conf.Secret == "" || subtle.ConstantTimeCompare([]byte(r.Header.Get(secretHeader)), []byte(c.conf.Secret)) != 1 {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
origin := NodeID(r.Header.Get(originHeader))
|
||||
if origin == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if origin == c.conf.NodeID {
|
||||
w.WriteHeader(http.StatusOK) // Our own traffic; nothing to do
|
||||
return
|
||||
}
|
||||
h(origin, w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// heartbeatLoop runs one heartbeat immediately (the ticker first fires a full interval after
|
||||
// startup, and a fresh node should be leader-capable and state-visible right away), then one per
|
||||
// interval until shutdown.
|
||||
func (c *meshCluster) heartbeatLoop() {
|
||||
defer c.wg.Done()
|
||||
ticker := time.NewTicker(c.conf.HeartbeatInterval)
|
||||
defer ticker.Stop()
|
||||
if err := c.heartbeat(); err != nil {
|
||||
log.Tag(tag).Err(err).Warn("Cluster heartbeat failed")
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := c.heartbeat(); err != nil {
|
||||
log.Tag(tag).Err(err).Warn("Cluster heartbeat failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// heartbeat is one control-plane tick: refresh this node's registry row, retry/confirm the
|
||||
// leader lock, prune long-dead registry rows (as leader), reconcile the per-peer queues, and
|
||||
// periodically push our subscription state to peers.
|
||||
//
|
||||
// A node that cannot even register itself aborts the tick: the remaining database work would
|
||||
// fail against the same database, and everything downstream degrades safely without it -- ForwardMessage
|
||||
// serves the stale peer cache on its own, and peers fall back to broadcasting to us once our
|
||||
// last pushed state expires.
|
||||
func (c *meshCluster) heartbeat() error {
|
||||
if err := c.registry.Register(); err != nil {
|
||||
return err
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.lastRegistered = time.Now()
|
||||
c.mu.Unlock()
|
||||
// Effective leadership: pg.Leader's lease semantics guarantee a no-leader gap on
|
||||
// failover, never two leaders
|
||||
if c.leader.IsLeader() {
|
||||
metrics.ClusterLeader.Set(1)
|
||||
if err := c.registry.Prune(); err != nil {
|
||||
log.Tag(tag).Err(err).Warn("Failed to prune stale nodes") // Housekeeping only; not fatal for the tick
|
||||
}
|
||||
} else {
|
||||
metrics.ClusterLeader.Set(0)
|
||||
}
|
||||
peers, err := c.registry.Peers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.reconcilePeers(peers)
|
||||
if time.Since(c.lastStatePush) >= c.conf.StateInterval {
|
||||
c.pushState(peers)
|
||||
c.lastStatePush = time.Now()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconcilePeers aligns this node's per-peer attachments with the live peer set: it retires the
|
||||
// queues (and workers) of peers that have left the registry or re-registered under a new
|
||||
// advertise URL (the retired queue's remainder was headed for a dead address anyway), and prunes
|
||||
// the stale state of departed peers. New and replacement queues are created lazily by ForwardMessage, not
|
||||
// here, so a freshly joined peer is reachable immediately.
|
||||
func (c *meshCluster) reconcilePeers(peers []*registry.Peer) {
|
||||
metrics.ClusterPeers.Set(float64(len(peers)))
|
||||
alive := make(map[NodeID]string, len(peers)) // node ID -> advertise URL
|
||||
for _, p := range peers {
|
||||
alive[NodeID(p.NodeID)] = p.AdvertiseURL
|
||||
}
|
||||
c.mu.Lock()
|
||||
// Log joins and leaves (as seen through the up-to-NodeTTL-stale registry view)
|
||||
for nodeID, url := range alive {
|
||||
if _, ok := c.knownPeers[nodeID]; !ok {
|
||||
log.Tag(tag).Info("Peer %s (%s) joined the cluster", nodeID, url)
|
||||
}
|
||||
}
|
||||
for nodeID := range c.knownPeers {
|
||||
if _, ok := alive[nodeID]; !ok {
|
||||
log.Tag(tag).Info("Peer %s left the cluster", nodeID)
|
||||
}
|
||||
}
|
||||
c.knownPeers = alive
|
||||
for nodeID, q := range c.queues {
|
||||
if url, ok := alive[nodeID]; !ok || q.advertiseURL != url {
|
||||
q.queue.Close() // Flushes the remainder; the worker exits when the queue is drained
|
||||
delete(c.queues, nodeID)
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
// Prune the state of departed peers, but only once stale: state is push-driven and can
|
||||
// arrive before a new peer is visible in the (up to NodeTTL stale) registry view, so fresh
|
||||
// state must survive even when its peer is not in the live set. Without this, the states of
|
||||
// long-gone nodes would accumulate forever.
|
||||
c.statesMu.Lock()
|
||||
for nodeID, state := range c.states {
|
||||
if _, ok := alive[nodeID]; !ok && time.Since(state.updatedAt) > 3*c.conf.StateInterval {
|
||||
delete(c.states, nodeID)
|
||||
}
|
||||
}
|
||||
c.statesMu.Unlock()
|
||||
}
|
||||
|
||||
// queueFor returns the send queue for the given peer, creating it (and its delivery worker) if it
|
||||
// does not exist yet. The caller must hold c.mu.
|
||||
func (c *meshCluster) queueFor(p *registry.Peer) *peerQueue {
|
||||
nodeID := NodeID(p.NodeID)
|
||||
q, ok := c.queues[nodeID]
|
||||
if ok {
|
||||
return q
|
||||
}
|
||||
q = &peerQueue{
|
||||
advertiseURL: p.AdvertiseURL,
|
||||
queue: util.NewLingerQueue(peerQueueSize, batchMaxMessages, batchMaxBytes,
|
||||
func(frag []byte) int { return len(frag) }, c.conf.BatchLinger),
|
||||
}
|
||||
c.queues[nodeID] = q
|
||||
c.wg.Add(1)
|
||||
go c.peerWorker(nodeID, q)
|
||||
return q
|
||||
}
|
||||
|
||||
// ForwardMessage enqueues the message for delivery to every live peer node that may have subscribers for
|
||||
// its topic (all of them, absent fresh knowledge). Delivery is fire-and-forget via each peer's
|
||||
// bounded batching queue; if a peer's queue is full the message is dropped for that peer
|
||||
// (subscribers reconnect and re-poll history from the database).
|
||||
func (c *meshCluster) ForwardMessage(msg *model.Message) error {
|
||||
peers, err := c.registry.Peers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(peers) == 0 {
|
||||
return nil // Cluster of one; skip the marshal
|
||||
}
|
||||
frag, err := marshalMessage(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
metrics.ClusterMessagesForwarded.Inc()
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.closed {
|
||||
return nil // Shutting down; the message is dropped like any other in-flight fan-out
|
||||
}
|
||||
for _, p := range peers {
|
||||
// Route around peers whose fresh state provably excludes this topic; anything less
|
||||
// certain (no state, stale state) falls back to broadcasting
|
||||
if !c.mayNeed(NodeID(p.NodeID), msg.Topic) {
|
||||
metrics.ClusterRouteSkipped.Inc()
|
||||
if ev := log.Tag(tag); ev.IsTrace() {
|
||||
ev.Trace("Skipping peer %s for message %s: no subscribers for topic %s", p.NodeID, msg.ID, msg.Topic)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !c.queueFor(p).queue.TryEnqueue(frag) {
|
||||
metrics.ClusterQueueDropped.Inc()
|
||||
log.Tag(tag).Warn("Fan-out queue for peer %s full, dropping message %s", p.NodeID, msg.ID)
|
||||
} else if ev := log.Tag(tag); ev.IsTrace() {
|
||||
ev.Trace("Enqueued message %s (topic %s) for peer %s", msg.ID, msg.Topic, p.NodeID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mayNeed reports whether the peer may have a subscriber for the topic. Conservative by
|
||||
// construction: it returns false only when a fresh state snapshot provably excludes the topic.
|
||||
// A false positive costs one wasted send; a false negative would lose a message and cannot
|
||||
// happen for topics a peer has reported (Bloom filters have no false negatives).
|
||||
func (c *meshCluster) mayNeed(peer NodeID, topic string) bool {
|
||||
c.statesMu.Lock()
|
||||
defer c.statesMu.Unlock()
|
||||
state, ok := c.states[peer]
|
||||
if !ok || time.Since(state.updatedAt) > 3*c.conf.StateInterval {
|
||||
return true // No knowledge, or too old to trust for skipping
|
||||
}
|
||||
return state.topics.Contains(topic)
|
||||
}
|
||||
|
||||
// peerWorker delivers batches of queued fan-out messages to a single peer. Batches form in the
|
||||
// peer's LingerQueue (up to BatchLinger delay, flushed early on size/count caps); the worker
|
||||
// exits when the queue is closed (peer left the registry, or mesh shutdown) and drained.
|
||||
func (c *meshCluster) peerWorker(nodeID NodeID, q *peerQueue) {
|
||||
defer c.wg.Done()
|
||||
for frags := range q.queue.Dequeue() {
|
||||
body := assembleMessageBody(frags)
|
||||
log.Tag(tag).Debug("Sending batch of %d message(s) (%d bytes) to peer %s", len(frags), len(body), nodeID)
|
||||
c.postToPeer(nodeID, messageURL(q.advertiseURL), contentTypeNDJSON, body)
|
||||
metrics.ClusterBatchesSent.Inc()
|
||||
}
|
||||
}
|
||||
|
||||
// postToPeer POSTs a peer API payload, authenticated with the shared cluster secret. Failures
|
||||
// are logged and counted, never retried: peer traffic is best-effort by design (messages are
|
||||
// recovered via since= replay, state via the next periodic push).
|
||||
func (c *meshCluster) postToPeer(nodeID NodeID, url, contentType string, payload []byte) {
|
||||
req, err := http.NewRequestWithContext(c.ctx, http.MethodPost, url, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
metrics.ClusterSendErrors.Inc()
|
||||
log.Tag(tag).Err(err).Warn("Failed to build request for peer %s", nodeID)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
req.Header.Set(secretHeader, c.conf.Secret)
|
||||
req.Header.Set(originHeader, string(c.conf.NodeID))
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
if c.ctx.Err() == nil {
|
||||
metrics.ClusterSendErrors.Inc()
|
||||
log.Tag(tag).Err(err).Warn("Failed to send to peer %s (%s)", nodeID, url)
|
||||
}
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
metrics.ClusterSendErrors.Inc()
|
||||
log.Tag(tag).Warn("Peer %s (%s) rejected request with HTTP %d", nodeID, url, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// handleMessage receives a batch of peer messages (NDJSON) and streams them to local
|
||||
// subscribers line by line, delivering each message as it is decoded.
|
||||
func (c *meshCluster) handleMessage(origin NodeID, w http.ResponseWriter, r *http.Request) {
|
||||
// A batch can exceed its byte cap by one message, plus framing overhead
|
||||
maxBodyBytes := int64(batchMaxBytes) + c.conf.MaxMessageBytes + 1024
|
||||
received := 0
|
||||
deliver := func(m *model.Message) {
|
||||
received++
|
||||
if ev := log.Tag(tag); ev.IsTrace() {
|
||||
ev.Trace("Delivering message %s (topic %s) from peer %s", m.ID, m.Topic, origin)
|
||||
}
|
||||
c.deliver(m)
|
||||
}
|
||||
if err := decodeMessageBody(io.LimitReader(r.Body, maxBodyBytes), int(c.conf.MaxMessageBytes), deliver); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
log.Tag(tag).Debug("Received batch of %d message(s) from peer %s", received, origin)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// handleState receives a peer's state envelope and applies each section it carries.
|
||||
func (c *meshCluster) handleState(origin NodeID, w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, stateMaxBytes))
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var state apiState
|
||||
if err := json.Unmarshal(body, &state); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if state.Topics != nil {
|
||||
if err := c.applyTopicState(origin, state.Topics); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// applyTopicState updates what we know about a peer's subscriptions: a full snapshot replaces
|
||||
// all prior knowledge, an incremental add merges into it. Increments without a baseline are
|
||||
// ignored on purpose -- without a snapshot the peer is broadcast to anyway.
|
||||
func (c *meshCluster) applyTopicState(origin NodeID, topics *apiStateTopics) error {
|
||||
c.statesMu.Lock()
|
||||
defer c.statesMu.Unlock()
|
||||
if len(topics.Filter) > 0 {
|
||||
filter, err := util.UnmarshalBloomFilter(topics.Filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.states[origin] = &peerState{topics: filter, updatedAt: time.Now()}
|
||||
log.Tag(tag).Debug("Received subscription state from peer %s (%d filter bytes)", origin, len(topics.Filter))
|
||||
return nil
|
||||
}
|
||||
if state, ok := c.states[origin]; ok {
|
||||
for _, topic := range topics.Added {
|
||||
state.topics.Add(topic)
|
||||
}
|
||||
state.updatedAt = time.Now()
|
||||
log.Tag(tag).Debug("Received %d announced topic(s) from peer %s", len(topics.Added), origin)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pushState sends a full state snapshot to every live peer: a Bloom filter over the topics that
|
||||
// currently have local subscribers. Sent directly (not via the linger queues -- state must not
|
||||
// wait behind message batches); a lost push self-heals at the next interval. Topics without
|
||||
// subscribers disappear simply by not being in the next snapshot.
|
||||
func (c *meshCluster) pushState(peers []*registry.Peer) {
|
||||
if len(peers) == 0 {
|
||||
return
|
||||
}
|
||||
topics := c.topics()
|
||||
filter := util.NewBloomFilter(len(topics), stateFilterFPRate)
|
||||
for _, topic := range topics {
|
||||
filter.Add(topic)
|
||||
}
|
||||
data, err := filter.MarshalBinary()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
body, err := json.Marshal(&apiState{Topics: &apiStateTopics{Filter: data}})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
log.Tag(tag).Debug("Pushing subscription state (%d topics, %d bytes) to %d peer(s)", len(topics), len(body), len(peers))
|
||||
for _, p := range peers {
|
||||
go c.postToPeer(NodeID(p.NodeID), stateURL(p.AdvertiseURL), contentTypeJSON, body)
|
||||
}
|
||||
metrics.ClusterStatePushes.Inc()
|
||||
}
|
||||
|
||||
// BroadcastState immediately tells all live peers that these topics gained their first local
|
||||
// subscriber, shrinking the window in which a publisher could wrongly skip this node from a
|
||||
// full state interval down to about one round trip.
|
||||
func (c *meshCluster) BroadcastState(state *State) {
|
||||
if len(state.AddedTopics) == 0 {
|
||||
return
|
||||
}
|
||||
peers, err := c.registry.Peers()
|
||||
if err != nil || len(peers) == 0 {
|
||||
return
|
||||
}
|
||||
body, err := json.Marshal(&apiState{Topics: &apiStateTopics{Added: state.AddedTopics}})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
log.Tag(tag).Debug("Broadcasting state (%d new topics) to %d peer(s)", len(state.AddedTopics), len(peers))
|
||||
for _, p := range peers {
|
||||
go c.postToPeer(NodeID(p.NodeID), stateURL(p.AdvertiseURL), contentTypeJSON, body)
|
||||
}
|
||||
}
|
||||
|
||||
// IsLeader reports whether this node currently holds singleton-job leadership.
|
||||
func (c *meshCluster) IsLeader() bool {
|
||||
return c.leader.IsLeader()
|
||||
}
|
||||
|
||||
// Healthy reports whether this node's registry heartbeat is fresh enough that peers still
|
||||
// forward messages to it (see the Cluster interface for the checker's fail-open duty).
|
||||
func (c *meshCluster) Healthy() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return time.Since(c.lastRegistered) < c.conf.NodeTTL
|
||||
}
|
||||
|
||||
// Close stops the mesh: it deregisters this node, releases leadership, stops all peer workers,
|
||||
// and waits for them to exit.
|
||||
func (c *meshCluster) Close() error {
|
||||
c.cancel() // Stops the heartbeat loop and aborts in-flight peer deliveries
|
||||
// Close the peer queues so their workers flush and exit; final sends are best-effort since
|
||||
// the context is already canceled (parity with fire-and-forget delivery)
|
||||
c.mu.Lock()
|
||||
c.closed = true
|
||||
for nodeID, q := range c.queues {
|
||||
q.queue.Close()
|
||||
delete(c.queues, nodeID)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
// Wait for the loops BEFORE deregistering: an in-flight heartbeat's Register would otherwise
|
||||
// re-insert our row right after Deregister deleted it
|
||||
c.wg.Wait()
|
||||
if err := c.registry.Deregister(); err != nil {
|
||||
log.Tag(tag).Err(err).Warn("Failed to deregister node")
|
||||
}
|
||||
c.leader.Close()
|
||||
metrics.ClusterLeader.Set(0)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/cluster/registry"
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/db/pg"
|
||||
dbtest "heckel.io/ntfy/v2/db/test"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
const (
|
||||
testSecret = "s3cret"
|
||||
)
|
||||
|
||||
// openTestPool opens a dedicated connection pool to the given test schema, so that each simulated
|
||||
// node has its own pool like real nodes would.
|
||||
func openTestPool(t testing.TB, dsn string) *db.DB {
|
||||
host, err := pg.Open(dsn)
|
||||
require.Nil(t, err)
|
||||
d := db.New(host, nil)
|
||||
t.Cleanup(func() { d.Close() })
|
||||
return d
|
||||
}
|
||||
|
||||
func newTestMeshConfig(nodeID, advertiseURL string) *Config {
|
||||
return &Config{
|
||||
Enabled: true,
|
||||
NodeID: NodeID(nodeID),
|
||||
AdvertiseURL: advertiseURL,
|
||||
Secret: testSecret,
|
||||
HeartbeatInterval: 100 * time.Millisecond,
|
||||
LeaderRenewInterval: 20 * time.Millisecond, // Lease duration 60ms, hold-off 120ms; keeps leadership tests fast
|
||||
NodeTTL: time.Second, // Also the peer cache bound; short so fake peers registered mid-test are seen quickly
|
||||
MaxMessageBytes: 1 << 20,
|
||||
StateInterval: time.Minute, // Individual tests lower this to exercise state pushes
|
||||
}
|
||||
}
|
||||
|
||||
// registerFakePeer registers a fake peer via the registry (creating the table if the mesh has
|
||||
// not been constructed yet): tests register fakes before the mesh boots, since its first
|
||||
// heartbeat caches the peer list. The fake never refreshes its heartbeat.
|
||||
func registerFakePeer(t testing.TB, pool *db.DB, nodeID NodeID, url string) {
|
||||
t.Helper()
|
||||
reg, err := registry.New(pool, string(nodeID), url, time.Minute)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, reg.Register())
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, f func() bool) {
|
||||
t.Helper()
|
||||
for i := 0; i < 100; i++ {
|
||||
if f() {
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("timed out waiting for condition")
|
||||
}
|
||||
|
||||
func TestMesh_CrossNodeDelivery(t *testing.T) {
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
poolA, poolB := openTestPool(t, schemaDSN), openTestPool(t, schemaDSN)
|
||||
var mu sync.Mutex
|
||||
var received []*model.Message
|
||||
var meshB *meshCluster
|
||||
srvB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
meshB.ServeHTTP(w, r)
|
||||
}))
|
||||
defer srvB.Close()
|
||||
meshB, err := newMeshCluster(newTestMeshConfig("node-b", srvB.URL), poolB, func(m *model.Message) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
received = append(received, m)
|
||||
}, nil)
|
||||
require.Nil(t, err)
|
||||
defer meshB.Close()
|
||||
meshA, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), poolA, func(m *model.Message) {
|
||||
t.Error("node A must not receive its own relayed message")
|
||||
}, nil)
|
||||
require.Nil(t, err)
|
||||
defer meshA.Close()
|
||||
msg := model.NewDefaultMessage("mytopic", "hello cross-node")
|
||||
require.Nil(t, meshA.ForwardMessage(msg))
|
||||
waitFor(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return len(received) == 1
|
||||
})
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
require.Equal(t, "mytopic", received[0].Topic)
|
||||
require.Equal(t, "hello cross-node", received[0].Message)
|
||||
}
|
||||
|
||||
func TestMesh_PeerAPI_Auth(t *testing.T) {
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
var delivered int
|
||||
mesh, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), pool, func(m *model.Message) {
|
||||
delivered++
|
||||
}, nil)
|
||||
require.Nil(t, err)
|
||||
defer mesh.Close()
|
||||
frag, err := marshalMessage(model.NewDefaultMessage("mytopic", "hi"))
|
||||
require.Nil(t, err)
|
||||
payload := assembleMessageBody([][]byte{frag})
|
||||
|
||||
// Wrong secret -> 401, not delivered
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", MessagePath, strings.NewReader(string(payload)))
|
||||
req.Header.Set(secretHeader, "wrong")
|
||||
req.Header.Set(originHeader, "node-b")
|
||||
mesh.ServeHTTP(rr, req)
|
||||
require.Equal(t, 401, rr.Code)
|
||||
|
||||
// Missing secret -> 401, not delivered
|
||||
rr = httptest.NewRecorder()
|
||||
mesh.ServeHTTP(rr, httptest.NewRequest("POST", MessagePath, strings.NewReader(string(payload))))
|
||||
require.Equal(t, 401, rr.Code)
|
||||
require.Equal(t, 0, delivered)
|
||||
|
||||
// Missing origin -> 400, not delivered
|
||||
rr = httptest.NewRecorder()
|
||||
req = httptest.NewRequest("POST", MessagePath, strings.NewReader(string(payload)))
|
||||
req.Header.Set(secretHeader, testSecret)
|
||||
mesh.ServeHTTP(rr, req)
|
||||
require.Equal(t, 400, rr.Code)
|
||||
require.Equal(t, 0, delivered)
|
||||
|
||||
// Correct secret and origin -> 200, delivered
|
||||
rr = httptest.NewRecorder()
|
||||
req = httptest.NewRequest("POST", MessagePath, strings.NewReader(string(payload)))
|
||||
req.Header.Set(secretHeader, testSecret)
|
||||
req.Header.Set(originHeader, "node-b")
|
||||
mesh.ServeHTTP(rr, req)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Equal(t, 1, delivered)
|
||||
}
|
||||
|
||||
func TestMesh_PeerAPI_SelfOrigin(t *testing.T) {
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
var delivered int
|
||||
mesh, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), pool, func(m *model.Message) {
|
||||
delivered++
|
||||
}, nil)
|
||||
require.Nil(t, err)
|
||||
defer mesh.Close()
|
||||
|
||||
// A request that carries this node's own broadcasts must not be re-delivered (loop prevention)
|
||||
frag, err := marshalMessage(model.NewDefaultMessage("mytopic", "loop"))
|
||||
require.Nil(t, err)
|
||||
payload := assembleMessageBody([][]byte{frag})
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", MessagePath, strings.NewReader(string(payload)))
|
||||
req.Header.Set(secretHeader, testSecret)
|
||||
req.Header.Set(originHeader, "node-a") // Same as the receiving node's ID
|
||||
mesh.ServeHTTP(rr, req)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Equal(t, 0, delivered)
|
||||
}
|
||||
|
||||
func TestMesh_SlowPeerIsolation(t *testing.T) {
|
||||
// A wedged peer must not delay delivery to healthy peers: each peer has its own queue and
|
||||
// delivery worker. With a shared send queue (the design this replaces), the slow peer's
|
||||
// requests would occupy all delivery workers and starve the fast peer.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
var mu sync.Mutex
|
||||
fastReceived := 0 // Messages, not requests: with batching, one request can carry many
|
||||
srvFast := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
messages, err := unmarshalMessageBody(body, 1<<20)
|
||||
require.Nil(t, err)
|
||||
mu.Lock()
|
||||
fastReceived += len(messages)
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srvFast.Close()
|
||||
release := make(chan struct{})
|
||||
srvSlow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-release // Wedged until the end of the test
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srvSlow.Close()
|
||||
defer close(release)
|
||||
// Register the fake peers before the mesh boots; its first heartbeat caches the peer list
|
||||
for i, url := range []string{srvFast.URL, srvSlow.URL} {
|
||||
registerFakePeer(t, pool, NodeID(fmt.Sprintf("node-fake-%d", i)), url)
|
||||
}
|
||||
mesh, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), pool, nil, nil)
|
||||
require.Nil(t, err)
|
||||
defer mesh.Close()
|
||||
const n = 20
|
||||
for i := 0; i < n; i++ {
|
||||
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("mytopic", fmt.Sprintf("message %d", i))))
|
||||
}
|
||||
waitFor(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return fastReceived == n
|
||||
})
|
||||
}
|
||||
|
||||
func TestMesh_BatchCoalescing(t *testing.T) {
|
||||
// Messages published within the linger window arrive as batches: fewer HTTP requests than
|
||||
// messages, with nothing lost. Fails against a one-request-per-message sender.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
var mu sync.Mutex
|
||||
requests, messages := 0, 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
decoded, err := unmarshalMessageBody(body, 1<<20)
|
||||
require.Nil(t, err)
|
||||
mu.Lock()
|
||||
requests++
|
||||
messages += len(decoded)
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
registerFakePeer(t, pool, "node-fake", srv.URL)
|
||||
conf := newTestMeshConfig("node-a", "http://127.0.0.1:1")
|
||||
conf.BatchLinger = 150 * time.Millisecond
|
||||
mesh, err := newMeshCluster(conf, pool, nil, nil)
|
||||
require.Nil(t, err)
|
||||
defer mesh.Close()
|
||||
const n = 20
|
||||
for i := 0; i < n; i++ {
|
||||
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("mytopic", fmt.Sprintf("message %d", i))))
|
||||
}
|
||||
waitFor(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return messages == n
|
||||
})
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
require.Less(t, requests, 5, "expected %d messages coalesced into few requests, got %d", n, requests)
|
||||
}
|
||||
|
||||
func TestMesh_DeadPeerRemovedAndRejoin(t *testing.T) {
|
||||
// A peer that dies ungracefully (no Deregister) stops refreshing its heartbeat: after the
|
||||
// TTL it no longer counts as live (no more sends), its queue/worker are reconciled away, the
|
||||
// leader prunes its registry row, and a re-registered peer starts receiving again.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
var mu sync.Mutex
|
||||
received := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
messages, err := unmarshalMessageBody(body, 1<<20)
|
||||
require.Nil(t, err)
|
||||
mu.Lock()
|
||||
received += len(messages)
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
conf := newTestMeshConfig("node-a", "http://127.0.0.1:1")
|
||||
conf.NodeTTL = 300 * time.Millisecond // Fast expiry so the test observes TTL-based removal
|
||||
mesh, err := newMeshCluster(conf, pool, nil, nil)
|
||||
require.Nil(t, err)
|
||||
defer mesh.Close()
|
||||
// The fake peer registers once and then "dies": its heartbeat is never refreshed
|
||||
registerFakePeer(t, pool, "node-dead", srv.URL)
|
||||
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("mytopic", "while alive")))
|
||||
waitFor(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return received == 1
|
||||
})
|
||||
// After the TTL, the peer is no longer live: its queue is reconciled away and its registry
|
||||
// row is pruned by the leader (this mesh is the only real node, so it holds the lock)
|
||||
waitFor(t, func() bool {
|
||||
mesh.mu.Lock()
|
||||
defer mesh.mu.Unlock()
|
||||
return len(mesh.queues) == 0
|
||||
})
|
||||
waitFor(t, func() bool {
|
||||
var count int
|
||||
require.Nil(t, pool.QueryRow(`SELECT COUNT(*) FROM node_registry WHERE node_id = 'node-dead'`).Scan(&count))
|
||||
return count == 0
|
||||
})
|
||||
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("mytopic", "while dead")))
|
||||
time.Sleep(250 * time.Millisecond) // Give a wrong implementation time to deliver anyway
|
||||
mu.Lock()
|
||||
require.Equal(t, 1, received) // Only the first message arrived
|
||||
mu.Unlock()
|
||||
// The peer comes back (same node ID, fresh heartbeat) and receives messages again; the
|
||||
// relay retries because the peer list is cached for up to the node TTL
|
||||
registerFakePeer(t, pool, "node-dead", srv.URL)
|
||||
waitFor(t, func() bool {
|
||||
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("mytopic", "after rejoin")))
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return received > 1
|
||||
})
|
||||
}
|
||||
|
||||
func TestMesh_ForwardAfterClose(t *testing.T) {
|
||||
// A ForwardMessage racing shutdown (e.g. an in-flight publish during server Stop) must not spawn
|
||||
// a new peer queue and worker after Close: the worker would never exit (its queue is never
|
||||
// closed) and nothing waits for it.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
mesh, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), pool, nil, nil)
|
||||
require.Nil(t, err)
|
||||
registerFakePeer(t, pool, "node-peer", "http://127.0.0.1:1")
|
||||
require.Nil(t, mesh.Close())
|
||||
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("mytopic", "too late"))) // Dropped silently
|
||||
mesh.mu.Lock()
|
||||
defer mesh.mu.Unlock()
|
||||
require.Empty(t, mesh.queues)
|
||||
}
|
||||
|
||||
func TestMesh_LeaderFailover(t *testing.T) {
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
poolA, poolB := openTestPool(t, schemaDSN), openTestPool(t, schemaDSN)
|
||||
meshA, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), poolA, nil, nil)
|
||||
require.Nil(t, err)
|
||||
defer meshA.Close()
|
||||
meshB, err := newMeshCluster(newTestMeshConfig("node-b", "http://127.0.0.1:1"), poolB, nil, nil)
|
||||
require.Nil(t, err)
|
||||
defer meshB.Close()
|
||||
// Exactly one node becomes leader
|
||||
waitFor(t, func() bool {
|
||||
return meshA.IsLeader() != meshB.IsLeader() // Exactly one
|
||||
})
|
||||
// The leader steps down; the follower takes over
|
||||
leader, follower := meshA, meshB
|
||||
if meshB.IsLeader() {
|
||||
leader, follower = meshB, meshA
|
||||
}
|
||||
require.Nil(t, leader.Close())
|
||||
waitFor(t, follower.IsLeader)
|
||||
}
|
||||
|
||||
func TestMesh_CloseDeregisters(t *testing.T) {
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
mesh, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), pool, nil, nil)
|
||||
require.Nil(t, err)
|
||||
var count int
|
||||
require.Nil(t, pool.QueryRow(`SELECT COUNT(*) FROM node_registry WHERE node_id = 'node-a'`).Scan(&count))
|
||||
require.Equal(t, 1, count)
|
||||
require.Nil(t, mesh.Close())
|
||||
require.Nil(t, pool.QueryRow(`SELECT COUNT(*) FROM node_registry WHERE node_id = 'node-a'`).Scan(&count))
|
||||
require.Equal(t, 0, count)
|
||||
}
|
||||
|
||||
// postState delivers a state envelope to a mesh's peer API, as a peer would.
|
||||
func postState(c *meshCluster, origin NodeID, state *apiState) *httptest.ResponseRecorder {
|
||||
body, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", StatePath, bytes.NewReader(body))
|
||||
req.Header.Set(secretHeader, testSecret)
|
||||
req.Header.Set(originHeader, string(origin))
|
||||
c.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
// topicFilter builds a marshaled Bloom filter over the given topics.
|
||||
func topicFilter(t *testing.T, topics ...string) []byte {
|
||||
t.Helper()
|
||||
filter := util.NewBloomFilter(len(topics), 0.01)
|
||||
for _, topic := range topics {
|
||||
filter.Add(topic)
|
||||
}
|
||||
data, err := filter.MarshalBinary()
|
||||
require.Nil(t, err)
|
||||
return data
|
||||
}
|
||||
|
||||
func TestMesh_RouteSkipsUnsubscribedPeer(t *testing.T) {
|
||||
// A peer whose fresh state provably excludes a topic is not contacted for it; a topic in its
|
||||
// state is delivered as usual.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
var mu sync.Mutex
|
||||
received := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
messages, err := unmarshalMessageBody(body, 1<<20)
|
||||
require.Nil(t, err)
|
||||
mu.Lock()
|
||||
received += len(messages)
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
registerFakePeer(t, pool, "node-b", srv.URL)
|
||||
mesh, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), pool, nil, nil)
|
||||
require.Nil(t, err)
|
||||
defer mesh.Close()
|
||||
// node-b reports subscribers only for "subscribed-topic"
|
||||
rr := postState(mesh, "node-b", &apiState{Topics: &apiStateTopics{Filter: topicFilter(t, "subscribed-topic")}})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
// A topic outside the peer's state is skipped
|
||||
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("other-topic", "skipped")))
|
||||
time.Sleep(300 * time.Millisecond) // Give a wrong implementation time to deliver anyway
|
||||
mu.Lock()
|
||||
require.Equal(t, 0, received)
|
||||
mu.Unlock()
|
||||
// A topic inside the peer's state is delivered
|
||||
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("subscribed-topic", "delivered")))
|
||||
waitFor(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return received == 1
|
||||
})
|
||||
}
|
||||
|
||||
func TestMesh_RouteBroadcastsOnStaleState(t *testing.T) {
|
||||
// State too old to trust cannot justify skipping: the peer is broadcast to as if unknown.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
var mu sync.Mutex
|
||||
received := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == MessagePath { // The mesh also pushes state here; count only messages
|
||||
mu.Lock()
|
||||
received++
|
||||
mu.Unlock()
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
registerFakePeer(t, pool, "node-b", srv.URL)
|
||||
mesh, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), pool, nil, nil)
|
||||
require.Nil(t, err)
|
||||
defer mesh.Close()
|
||||
rr := postState(mesh, "node-b", &apiState{Topics: &apiStateTopics{Filter: topicFilter(t, "subscribed-topic")}})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
// Age the state beyond the trust window
|
||||
mesh.statesMu.Lock()
|
||||
mesh.states["node-b"].updatedAt = time.Now().Add(-time.Hour)
|
||||
mesh.statesMu.Unlock()
|
||||
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("other-topic", "broadcast anyway")))
|
||||
waitFor(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return received == 1
|
||||
})
|
||||
}
|
||||
|
||||
func TestMesh_StatePushReplacesAndRemoves(t *testing.T) {
|
||||
// Node A periodically pushes a full snapshot of its live topics to node B; each snapshot
|
||||
// REPLACES B's knowledge, so topics that lost their subscribers disappear without any
|
||||
// explicit removal protocol.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
poolA, poolB := openTestPool(t, schemaDSN), openTestPool(t, schemaDSN)
|
||||
var topicsMu sync.Mutex
|
||||
topicsA := []string{"topic-1"}
|
||||
source := func() []string {
|
||||
topicsMu.Lock()
|
||||
defer topicsMu.Unlock()
|
||||
return append([]string{}, topicsA...)
|
||||
}
|
||||
var meshB *meshCluster
|
||||
srvB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
meshB.ServeHTTP(w, r)
|
||||
}))
|
||||
defer srvB.Close()
|
||||
meshB, err := newMeshCluster(newTestMeshConfig("node-b", srvB.URL), poolB, nil, nil)
|
||||
require.Nil(t, err)
|
||||
defer meshB.Close()
|
||||
confA := newTestMeshConfig("node-a", "http://127.0.0.1:1")
|
||||
confA.StateInterval = 200 * time.Millisecond
|
||||
meshA, err := newMeshCluster(confA, poolA, nil, source)
|
||||
require.Nil(t, err)
|
||||
defer meshA.Close()
|
||||
// B learns A's topics via the periodic push
|
||||
knows := func(topic string) func() bool {
|
||||
return func() bool {
|
||||
meshB.statesMu.Lock()
|
||||
defer meshB.statesMu.Unlock()
|
||||
state, ok := meshB.states["node-a"]
|
||||
return ok && state.topics.Contains(topic)
|
||||
}
|
||||
}
|
||||
waitFor(t, knows("topic-1"))
|
||||
// A's subscribers change; the next snapshot replaces the old knowledge entirely
|
||||
topicsMu.Lock()
|
||||
topicsA = []string{"topic-2"}
|
||||
topicsMu.Unlock()
|
||||
waitFor(t, knows("topic-2"))
|
||||
waitFor(t, func() bool { return !knows("topic-1")() })
|
||||
}
|
||||
|
||||
func TestMesh_AnnounceClosesWindow(t *testing.T) {
|
||||
// A topic gaining its first subscriber is announced immediately, so peers learn about it
|
||||
// without waiting for the next full state push.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
poolA, poolB := openTestPool(t, schemaDSN), openTestPool(t, schemaDSN)
|
||||
var meshB *meshCluster
|
||||
srvB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
meshB.ServeHTTP(w, r)
|
||||
}))
|
||||
defer srvB.Close()
|
||||
meshB, err := newMeshCluster(newTestMeshConfig("node-b", srvB.URL), poolB, nil, nil)
|
||||
require.Nil(t, err)
|
||||
defer meshB.Close()
|
||||
confA := newTestMeshConfig("node-a", "http://127.0.0.1:1")
|
||||
confA.StateInterval = 200 * time.Millisecond // One full push establishes the baseline
|
||||
meshA, err := newMeshCluster(confA, poolA, nil, func() []string { return []string{"existing"} })
|
||||
require.Nil(t, err)
|
||||
defer meshA.Close()
|
||||
waitFor(t, func() bool {
|
||||
meshB.statesMu.Lock()
|
||||
defer meshB.statesMu.Unlock()
|
||||
_, ok := meshB.states["node-a"]
|
||||
return ok
|
||||
})
|
||||
// Announcements merge into the baseline right away
|
||||
meshA.BroadcastState(&State{AddedTopics: []string{"fresh-topic"}})
|
||||
waitFor(t, func() bool {
|
||||
meshB.statesMu.Lock()
|
||||
defer meshB.statesMu.Unlock()
|
||||
state, ok := meshB.states["node-a"]
|
||||
return ok && state.topics.Contains("fresh-topic")
|
||||
})
|
||||
}
|
||||
|
||||
func TestMesh_StateOfDepartedPeerPruned(t *testing.T) {
|
||||
// peerState is push-driven and can arrive before the peer is visible in the registry, so it
|
||||
// must survive reconcile while fresh -- but a departed peer's state must not leak forever:
|
||||
// once it is both absent from the registry and stale past the trust window, it is pruned.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
mesh, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), pool, nil, nil)
|
||||
require.Nil(t, err)
|
||||
defer mesh.Close()
|
||||
rr := postState(mesh, "node-gone", &apiState{Topics: &apiStateTopics{Filter: topicFilter(t, "some-topic")}})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
// Fresh state of an unknown peer survives reconcile (the new-node visibility window)
|
||||
mesh.reconcilePeers(nil)
|
||||
mesh.statesMu.Lock()
|
||||
_, ok := mesh.states["node-gone"]
|
||||
mesh.statesMu.Unlock()
|
||||
require.True(t, ok)
|
||||
// Stale state of an absent peer is pruned
|
||||
mesh.statesMu.Lock()
|
||||
mesh.states["node-gone"].updatedAt = time.Now().Add(-time.Hour)
|
||||
mesh.statesMu.Unlock()
|
||||
mesh.reconcilePeers(nil)
|
||||
mesh.statesMu.Lock()
|
||||
_, ok = mesh.states["node-gone"]
|
||||
mesh.statesMu.Unlock()
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
func TestMesh_HealthyReflectsRegistration(t *testing.T) {
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
mesh, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), pool, nil, nil)
|
||||
require.Nil(t, err)
|
||||
defer mesh.Close()
|
||||
require.True(t, mesh.Healthy()) // Registered synchronously at construction
|
||||
// Stale heartbeat: peers stop forwarding to this node, so it must report unhealthy
|
||||
mesh.mu.Lock()
|
||||
mesh.lastRegistered = time.Now().Add(-2 * mesh.conf.NodeTTL)
|
||||
mesh.mu.Unlock()
|
||||
require.False(t, mesh.Healthy())
|
||||
// A successful heartbeat restores health
|
||||
require.Nil(t, mesh.heartbeat())
|
||||
require.True(t, mesh.Healthy())
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"heckel.io/ntfy/v2/model"
|
||||
)
|
||||
|
||||
// nopCluster is the single-node default: it drops all relayed messages, rejects peer API requests, and
|
||||
// reports this node as leader (a single node is trivially the leader, so leader-gated jobs need
|
||||
// no special-casing in single-node mode).
|
||||
type nopCluster struct{}
|
||||
|
||||
func (c *nopCluster) ForwardMessage(_ *model.Message) error { return nil }
|
||||
|
||||
func (c *nopCluster) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
|
||||
func (c *nopCluster) BroadcastState(_ *State) {}
|
||||
|
||||
func (c *nopCluster) IsLeader() bool { return true }
|
||||
|
||||
func (c *nopCluster) Healthy() bool { return true }
|
||||
|
||||
func (c *nopCluster) Close() error { return nil }
|
||||
@@ -0,0 +1,84 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
)
|
||||
|
||||
func TestDeliver_RoundTrip(t *testing.T) {
|
||||
// The fan-out body is NDJSON: one apiDeliverMessage per line, joined from pre-marshaled
|
||||
// fragments; the origin travels in a header, not the body
|
||||
m1 := model.NewDefaultMessage("mytopic", "my message")
|
||||
m1.Sender = netip.MustParseAddr("1.2.3.4")
|
||||
m1.User = "u_abc"
|
||||
m2 := model.NewDefaultMessage("othertopic", "other message")
|
||||
frag1, err := marshalMessage(m1)
|
||||
require.Nil(t, err)
|
||||
frag2, err := marshalMessage(m2)
|
||||
require.Nil(t, err)
|
||||
messages, err := unmarshalMessageBody(assembleMessageBody([][]byte{frag1, frag2}), 1<<20)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, messages, 2)
|
||||
require.Equal(t, "mytopic", messages[0].Topic)
|
||||
require.Equal(t, "my message", messages[0].Message)
|
||||
// Sender and User are json:"-" on model.Message; the lines must carry and reattach them
|
||||
require.Equal(t, netip.MustParseAddr("1.2.3.4"), messages[0].Sender)
|
||||
require.Equal(t, "u_abc", messages[0].User)
|
||||
require.Equal(t, "othertopic", messages[1].Topic)
|
||||
require.False(t, messages[1].Sender.IsValid())
|
||||
}
|
||||
|
||||
func TestDeliver_SingleMessage(t *testing.T) {
|
||||
// A single message is just a one-line body; there is no separate single-message format
|
||||
frag, err := marshalMessage(model.NewDefaultMessage("mytopic", "hi"))
|
||||
require.Nil(t, err)
|
||||
messages, err := unmarshalMessageBody(assembleMessageBody([][]byte{frag}), 1<<20)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, messages, 1)
|
||||
}
|
||||
|
||||
func TestDeliver_MalformedLinesSkipped(t *testing.T) {
|
||||
// Fan-out is fire-and-forget: a malformed or message-less line is skipped (and logged), the
|
||||
// remaining lines are still delivered
|
||||
frag, err := marshalMessage(model.NewDefaultMessage("mytopic", "good"))
|
||||
require.Nil(t, err)
|
||||
body := []byte("this is not json\n{\"sender\":\"1.2.3.4\"}\n" + string(frag) + "\n\n")
|
||||
messages, err := unmarshalMessageBody(body, 1<<20)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, messages, 1)
|
||||
require.Equal(t, "good", messages[0].Message)
|
||||
}
|
||||
|
||||
// unmarshalMessageBody is a test helper collecting the messages of an NDJSON message body.
|
||||
func unmarshalMessageBody(body []byte, maxLineBytes int) ([]*model.Message, error) {
|
||||
var messages []*model.Message
|
||||
err := decodeMessageBody(bytes.NewReader(body), maxLineBytes, func(m *model.Message) {
|
||||
messages = append(messages, m)
|
||||
})
|
||||
return messages, err
|
||||
}
|
||||
|
||||
func TestNop(t *testing.T) {
|
||||
b, err := New(&Config{}, nil, nil, nil) // not enabled -> nop cluster, no database required
|
||||
require.Nil(t, err)
|
||||
require.IsType(t, &nopCluster{}, b)
|
||||
require.Nil(t, b.ForwardMessage(model.NewDefaultMessage("mytopic", "hi")))
|
||||
// A single node is trivially the leader, so leader-gated jobs run without special-casing
|
||||
require.True(t, b.IsLeader())
|
||||
require.True(t, b.Healthy())
|
||||
rr := httptest.NewRecorder()
|
||||
b.ServeHTTP(rr, httptest.NewRequest("POST", MessagePath, nil))
|
||||
require.Equal(t, 404, rr.Code)
|
||||
require.Nil(t, b.Close())
|
||||
}
|
||||
|
||||
func TestNew_EnabledRequiresDatabase(t *testing.T) {
|
||||
_, err := New(&Config{Enabled: true, Secret: "secret"}, nil, nil, nil)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "database")
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// Package registry implements cluster membership: each node upserts its own row into the
|
||||
// node_registry table with a fresh heartbeat, and discovers its peers by reading the other
|
||||
// fresh rows. Node IDs are plain strings here; the cluster package layers its NodeID type on
|
||||
// top.
|
||||
package registry
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/db/schema"
|
||||
)
|
||||
|
||||
// Registry queries
|
||||
const (
|
||||
upsertNodeQuery = `
|
||||
INSERT INTO node_registry (node_id, advertise_url, last_heartbeat)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (node_id) DO UPDATE SET advertise_url = EXCLUDED.advertise_url, last_heartbeat = EXCLUDED.last_heartbeat
|
||||
`
|
||||
selectPeersQuery = `SELECT node_id, advertise_url FROM node_registry WHERE last_heartbeat >= $1 AND node_id != $2`
|
||||
pruneStaleNodesQuery = `DELETE FROM node_registry WHERE last_heartbeat < $1`
|
||||
deleteNodeQuery = `DELETE FROM node_registry WHERE node_id = $1`
|
||||
)
|
||||
|
||||
// Schema version and queries
|
||||
|
||||
const (
|
||||
schemaVersion = 1
|
||||
schemaStoreKey = "node_registry"
|
||||
)
|
||||
|
||||
var (
|
||||
createTable = schema.AsMigrateFunc(`
|
||||
CREATE TABLE IF NOT EXISTS node_registry (
|
||||
node_id TEXT PRIMARY KEY,
|
||||
advertise_url TEXT NOT NULL,
|
||||
last_heartbeat BIGINT NOT NULL
|
||||
)
|
||||
`)
|
||||
)
|
||||
|
||||
// Peer is a live remote node as read from the registry.
|
||||
type Peer struct {
|
||||
NodeID string
|
||||
AdvertiseURL string
|
||||
}
|
||||
|
||||
// Registry is the node membership table (control plane): each node upserts its own row with a
|
||||
// fresh heartbeat every few seconds, and peers are the other rows with a heartbeat newer than
|
||||
// the TTL. Stale rows are pruned by the leader. The TTL bounds membership staleness in BOTH
|
||||
// directions: how long a silent node still counts as live, and how long the cached peer list is
|
||||
// served before a re-read -- so a new node may take up to a TTL to become visible.
|
||||
type Registry struct {
|
||||
pool *db.DB
|
||||
nodeID string
|
||||
advertiseURL string
|
||||
ttl time.Duration
|
||||
peers []*Peer // cached peer list
|
||||
peersFetched time.Time
|
||||
mu sync.Mutex // Protects peers and peersFetched
|
||||
}
|
||||
|
||||
// New creates or migrates the registry schema and returns this node's membership handle. It
|
||||
// does NOT register the node: joining the cluster is an explicit Register call, owned by the
|
||||
// caller, so read-only uses of the registry stay side-effect free.
|
||||
func New(pool *db.DB, nodeID, advertiseURL string, ttl time.Duration) (*Registry, error) {
|
||||
if err := schema.Migrate(pool.Primary(), schema.Postgres, schemaStoreKey, schemaVersion, createTable, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Registry{
|
||||
pool: pool,
|
||||
nodeID: nodeID,
|
||||
advertiseURL: advertiseURL,
|
||||
ttl: ttl,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Register upserts this node into the registry with a fresh heartbeat. It is a pure write: it
|
||||
// does not touch the peer cache, because our own row is excluded from Peers() anyway.
|
||||
func (r *Registry) Register() error {
|
||||
_, err := r.pool.Exec(upsertNodeQuery, r.nodeID, r.advertiseURL, time.Now().Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
// Peers returns the current set of live peer nodes (all registry rows with a fresh heartbeat,
|
||||
// excluding this node), cached for the TTL.
|
||||
func (r *Registry) Peers() ([]*Peer, error) {
|
||||
r.mu.Lock()
|
||||
if r.peers != nil && time.Since(r.peersFetched) < r.ttl {
|
||||
peers := r.peers
|
||||
r.mu.Unlock()
|
||||
return peers, nil
|
||||
}
|
||||
r.mu.Unlock()
|
||||
peers, err := r.queryPeers()
|
||||
if err != nil {
|
||||
// Serve the last-known peer list during database hiccups: fan-out keeps flowing to
|
||||
// known peers instead of erroring (and logging) once per published message for the
|
||||
// duration of the outage. Dead peers in the stale list only cost failed sends.
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.peers != nil {
|
||||
return r.peers, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.peers = peers
|
||||
r.peersFetched = time.Now()
|
||||
r.mu.Unlock()
|
||||
return peers, nil
|
||||
}
|
||||
|
||||
// Prune deletes registry rows whose heartbeat is long expired. Only the leader calls this; the
|
||||
// grace period of 3x the TTL avoids deleting rows of nodes that are merely slow to heartbeat.
|
||||
func (r *Registry) Prune() error {
|
||||
_, err := r.pool.Exec(pruneStaleNodesQuery, time.Now().Add(-3*r.ttl).Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
// Deregister deletes this node's registry row; called on shutdown.
|
||||
func (r *Registry) Deregister() error {
|
||||
_, err := r.pool.Exec(deleteNodeQuery, r.nodeID)
|
||||
return err
|
||||
}
|
||||
|
||||
// queryPeers reads the current live peer set from the registry table.
|
||||
func (r *Registry) queryPeers() ([]*Peer, error) {
|
||||
cutoff := time.Now().Add(-r.ttl).Unix()
|
||||
rows, err := r.pool.Query(selectPeersQuery, cutoff, r.nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
peers := make([]*Peer, 0)
|
||||
for rows.Next() {
|
||||
p := &Peer{}
|
||||
if err := rows.Scan(&p.NodeID, &p.AdvertiseURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
peers = append(peers, p)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return peers, nil
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/db/pg"
|
||||
dbtest "heckel.io/ntfy/v2/db/test"
|
||||
)
|
||||
|
||||
func openTestPool(t *testing.T, dsn string) *db.DB {
|
||||
t.Helper()
|
||||
host, err := pg.Open(dsn)
|
||||
require.Nil(t, err)
|
||||
d := db.New(host, nil)
|
||||
t.Cleanup(func() { d.Close() })
|
||||
return d
|
||||
}
|
||||
|
||||
func TestRegistry_NewDoesNotRegister(t *testing.T) {
|
||||
// New only sets up the schema and the identity handle; joining the cluster is an explicit
|
||||
// Register call, owned by the caller (the mesh registers synchronously at construction).
|
||||
// This keeps read-only uses (ops tooling, future admin endpoints) side-effect free.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
r1, err := New(pool, "node-1", "http://10.0.0.1:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, countRows(t, pool, "node-1"))
|
||||
require.Nil(t, r1.Register())
|
||||
require.Equal(t, 1, countRows(t, pool, "node-1"))
|
||||
}
|
||||
|
||||
func TestRegistry_RegisterAndPeers(t *testing.T) {
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
r1, err := New(pool, "node-1", "http://10.0.0.1:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, r1.Register())
|
||||
r2, err := New(pool, "node-2", "http://10.0.0.2:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, r2.Register())
|
||||
// Each node sees the other, never itself
|
||||
peers, err := r1.Peers()
|
||||
require.Nil(t, err)
|
||||
require.Len(t, peers, 1)
|
||||
require.Equal(t, "node-2", peers[0].NodeID)
|
||||
require.Equal(t, "http://10.0.0.2:2587", peers[0].AdvertiseURL)
|
||||
peers, err = r2.Peers()
|
||||
require.Nil(t, err)
|
||||
require.Len(t, peers, 1)
|
||||
require.Equal(t, "node-1", peers[0].NodeID)
|
||||
}
|
||||
|
||||
func TestRegistry_ReRegisterUpdatesAdvertiseURL(t *testing.T) {
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
r1, err := New(pool, "node-1", "http://10.0.0.1:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
// The same node comes back under a new address; the upsert replaces the row
|
||||
old, err := New(pool, "node-2", "http://old:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, old.Register())
|
||||
renewed, err := New(pool, "node-2", "http://new:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, renewed.Register())
|
||||
expireCache(r1)
|
||||
peers, err := r1.Peers()
|
||||
require.Nil(t, err)
|
||||
require.Len(t, peers, 1)
|
||||
require.Equal(t, "http://new:2587", peers[0].AdvertiseURL)
|
||||
}
|
||||
|
||||
func TestRegistry_PeersCachedForTTL(t *testing.T) {
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
r1, err := New(pool, "node-1", "http://10.0.0.1:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
peers, err := r1.Peers()
|
||||
require.Nil(t, err)
|
||||
require.Empty(t, peers)
|
||||
// A node joining after the cache was populated is invisible until the cache expires
|
||||
r2, err := New(pool, "node-2", "http://10.0.0.2:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, r2.Register())
|
||||
peers, err = r1.Peers()
|
||||
require.Nil(t, err)
|
||||
require.Empty(t, peers)
|
||||
expireCache(r1)
|
||||
peers, err = r1.Peers()
|
||||
require.Nil(t, err)
|
||||
require.Len(t, peers, 1)
|
||||
}
|
||||
|
||||
func TestRegistry_TTLExcludesSilentNodes(t *testing.T) {
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
r1, err := New(pool, "node-1", "http://10.0.0.1:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
// A node whose heartbeat is older than the TTL does not count as live
|
||||
_, err = pool.Exec(upsertNodeQuery, "node-silent", "http://10.0.0.9:2587", time.Now().Add(-2*time.Minute).Unix())
|
||||
require.Nil(t, err)
|
||||
peers, err := r1.Peers()
|
||||
require.Nil(t, err)
|
||||
require.Empty(t, peers)
|
||||
}
|
||||
|
||||
func TestRegistry_PruneDeletesLongDeadOnly(t *testing.T) {
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
r1, err := New(pool, "node-1", "http://10.0.0.1:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
// One node beyond the 3x TTL grace period, one merely stale
|
||||
_, err = pool.Exec(upsertNodeQuery, "node-long-dead", "http://10.0.0.8:2587", time.Now().Add(-4*time.Minute).Unix())
|
||||
require.Nil(t, err)
|
||||
_, err = pool.Exec(upsertNodeQuery, "node-slow", "http://10.0.0.9:2587", time.Now().Add(-2*time.Minute).Unix())
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, r1.Prune())
|
||||
require.Equal(t, 0, countRows(t, pool, "node-long-dead"))
|
||||
require.Equal(t, 1, countRows(t, pool, "node-slow")) // Slow, not dead: kept
|
||||
}
|
||||
|
||||
func TestRegistry_Deregister(t *testing.T) {
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
r1, err := New(pool, "node-1", "http://10.0.0.1:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, r1.Register())
|
||||
require.Equal(t, 1, countRows(t, pool, "node-1"))
|
||||
require.Nil(t, r1.Deregister())
|
||||
require.Equal(t, 0, countRows(t, pool, "node-1"))
|
||||
}
|
||||
|
||||
func TestRegistry_PeersStaleCacheOnError(t *testing.T) {
|
||||
// During a database hiccup, Peers serves the last-known peer list instead of erroring:
|
||||
// fan-out keeps flowing to known peers, and the publish path does not log a warning per
|
||||
// message for the duration of the outage.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
r1, err := New(pool, "node-1", "http://10.0.0.1:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
r2, err := New(pool, "node-2", "http://10.0.0.2:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, r2.Register())
|
||||
peers, err := r1.Peers()
|
||||
require.Nil(t, err)
|
||||
require.Len(t, peers, 1)
|
||||
// Expire the cache and break the database; the stale list must still be served
|
||||
expireCache(r1)
|
||||
require.Nil(t, pool.Close())
|
||||
peers, err = r1.Peers()
|
||||
require.Nil(t, err)
|
||||
require.Len(t, peers, 1)
|
||||
require.Equal(t, "node-2", peers[0].NodeID)
|
||||
}
|
||||
|
||||
func TestRegistry_ConcurrentCreate(t *testing.T) {
|
||||
// Multiple nodes cold-booting on a fresh database must not race on table creation: CREATE
|
||||
// TABLE IF NOT EXISTS is not atomic in PostgreSQL, so creation is serialized via an advisory
|
||||
// lock. Without it, this test fails sporadically with a duplicate-key error on pg_class.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
const n = 8
|
||||
errs := make(chan error, n)
|
||||
for i := 0; i < n; i++ {
|
||||
go func(i int) {
|
||||
pool, err := pg.Open(schemaDSN)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
defer pool.DB.Close()
|
||||
_, err = New(db.New(pool, nil), fmt.Sprintf("node-%d", i), "http://127.0.0.1:1", time.Second)
|
||||
errs <- err
|
||||
}(i)
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
require.Nil(t, <-errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_SchemaVersionWritten(t *testing.T) {
|
||||
// The registry participates in the shared schema_version framework like every other store,
|
||||
// so future table changes can be applied as migrations.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
_, err := New(pool, "node-1", "http://10.0.0.1:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
var version int
|
||||
require.Nil(t, pool.QueryRow(`SELECT version FROM schema_version WHERE store = $1`, schemaStoreKey).Scan(&version))
|
||||
require.Equal(t, schemaVersion, version)
|
||||
// Setup is idempotent: a second node boots against the migrated schema
|
||||
_, err = New(pool, "node-2", "http://10.0.0.2:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
}
|
||||
|
||||
func TestRegistry_SchemaVersionFromTheFuture(t *testing.T) {
|
||||
// A node running older code must refuse to touch a schema migrated by newer code
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
pool := openTestPool(t, schemaDSN)
|
||||
_, err := New(pool, "node-1", "http://10.0.0.1:2587", time.Minute)
|
||||
require.Nil(t, err)
|
||||
_, err = pool.Exec(`UPDATE schema_version SET version = 99 WHERE store = $1`, schemaStoreKey)
|
||||
require.Nil(t, err)
|
||||
_, err = New(pool, "node-2", "http://10.0.0.2:2587", time.Minute)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// expireCache forces the next Peers() call to re-read the registry table.
|
||||
func expireCache(r *Registry) {
|
||||
r.mu.Lock()
|
||||
r.peersFetched = time.Time{}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func countRows(t *testing.T, pool *db.DB, nodeID string) int {
|
||||
t.Helper()
|
||||
var count int
|
||||
require.Nil(t, pool.QueryRow(`SELECT COUNT(*) FROM node_registry WHERE node_id = $1`, nodeID).Scan(&count))
|
||||
return count
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
// Config configures the cluster. It is assembled by the server from its own config, which keeps
|
||||
// this package free of server types.
|
||||
type Config struct {
|
||||
Enabled bool // Master switch; when false, New returns the nop cluster
|
||||
NodeID NodeID // Stable per-node identifier; required
|
||||
AdvertiseURL string // Base URL peers use to reach this node's fan-out endpoint
|
||||
Secret string // Shared secret authenticating node-to-node fan-out requests
|
||||
HeartbeatInterval time.Duration // How often the node registry heartbeat is refreshed
|
||||
NodeTTL time.Duration // Registry rows older than this do not count as live peers
|
||||
BatchLinger time.Duration // How long messages wait in a peer queue to form a batch; 0 = send immediately
|
||||
StateInterval time.Duration // How often the full subscription state is pushed to peers
|
||||
MaxMessageBytes int64 // Upper bound for a single message on the wire (batch limits derive from this)
|
||||
LeaderRenewInterval time.Duration // Overrides the leader lease renewal cadence; tests only, 0 = default
|
||||
}
|
||||
|
||||
// DeliverFunc hands a message received from a peer node to this node's local subscribers. The
|
||||
// server supplies it, which inverts the dependency: this package never imports the server.
|
||||
type DeliverFunc func(m *model.Message)
|
||||
|
||||
// State is a subscription-state delta for Cluster.BroadcastState.
|
||||
type State struct {
|
||||
AddedTopics []string // Topics that just gained their first local subscriber on this node
|
||||
}
|
||||
|
||||
// TopicsFunc returns the topics that currently have at least one live subscriber, computed
|
||||
// fresh on every call: membership is never tracked as a list, so topics "leave" simply by not
|
||||
// appearing in the next snapshot. The server supplies it (same inversion as DeliverFunc).
|
||||
type TopicsFunc func() []string
|
||||
|
||||
// apiMessage is one line of a message request body (NDJSON: one message per line; a single
|
||||
// message is just a one-line body). It carries the two fields that model.Message does not
|
||||
// serialize to JSON (Sender and User), which are needed to reconstruct the visitor on the
|
||||
// receiving node. The origin node travels in a request header, not in the body.
|
||||
type apiMessage struct {
|
||||
Sender string `json:"sender,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
Message *model.Message `json:"message"`
|
||||
}
|
||||
|
||||
// apiState is the peer state-exchange envelope. Each concern is an optional section; future
|
||||
// concerns (rate limit counters, stats) become siblings of Topics.
|
||||
type apiState struct {
|
||||
Topics *apiStateTopics `json:"topics,omitempty"`
|
||||
}
|
||||
|
||||
// apiStateTopics carries a peer's subscription knowledge: either a full snapshot (Filter, a
|
||||
// marshaled Bloom filter over the topics with live subscribers) replacing all prior knowledge,
|
||||
// or an incremental update (Added) merged into it.
|
||||
type apiStateTopics struct {
|
||||
Filter []byte `json:"filter,omitempty"`
|
||||
Added []string `json:"added,omitempty"`
|
||||
}
|
||||
|
||||
// peerState is what a peer last told us about itself; ForwardMessage routes around peers whose
|
||||
// fresh state provably excludes a topic.
|
||||
type peerState struct {
|
||||
topics *util.BloomFilter
|
||||
updatedAt time.Time
|
||||
}
|
||||
|
||||
// peerQueue is the bounded, batching send queue for a single peer, pinned to the advertise URL
|
||||
// the peer was created with: a peer re-registering under a different advertise URL is treated
|
||||
// as a replacement (reconcile retires the old queue; ForwardMessage creates a fresh one on demand).
|
||||
type peerQueue struct {
|
||||
advertiseURL string
|
||||
queue *util.LingerQueue[[]byte] // pre-marshaled apiMessage fragments
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
)
|
||||
|
||||
// messageURL derives the peer's message endpoint URL from its advertise URL.
|
||||
func messageURL(advertiseURL string) string {
|
||||
return strings.TrimRight(advertiseURL, "/") + MessagePath
|
||||
}
|
||||
|
||||
// stateURL derives the peer's state endpoint URL from its advertise URL.
|
||||
func stateURL(advertiseURL string) string {
|
||||
return strings.TrimRight(advertiseURL, "/") + StatePath
|
||||
}
|
||||
|
||||
// marshalMessage serializes one message and its non-JSON fields (Sender, User) as an
|
||||
// apiMessage line. Lines are marshaled once per publish and shared across all per-peer
|
||||
// queues; assembleMessageBody joins them without re-marshaling.
|
||||
func marshalMessage(m *model.Message) ([]byte, error) {
|
||||
apiMsg := &apiMessage{User: m.User, Message: m}
|
||||
if m.Sender.IsValid() {
|
||||
apiMsg.Sender = m.Sender.String()
|
||||
}
|
||||
return json.Marshal(apiMsg)
|
||||
}
|
||||
|
||||
// assembleMessageBody builds an NDJSON fan-out request body from pre-marshaled apiMessage
|
||||
// lines, avoiding a second JSON marshal of the messages.
|
||||
func assembleMessageBody(frags [][]byte) []byte {
|
||||
return append(bytes.Join(frags, []byte("\n")), '\n')
|
||||
}
|
||||
|
||||
// decodeMessageBody reads NDJSON apiMessage lines from r, reattaches the non-JSON fields
|
||||
// (Sender, User) onto each message, and hands them to deliver. Malformed or message-less lines
|
||||
// are skipped and logged, not fatal: fan-out is fire-and-forget, so the valid remainder of a
|
||||
// request is still delivered. It returns an error only for stream-level failures (e.g. a line
|
||||
// exceeding maxLineBytes).
|
||||
func decodeMessageBody(r io.Reader, maxLineBytes int, deliver DeliverFunc) error {
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 64*1024), maxLineBytes)
|
||||
for scanner.Scan() {
|
||||
line := bytes.TrimSpace(scanner.Bytes())
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var apiMsg apiMessage
|
||||
if err := json.Unmarshal(line, &apiMsg); err != nil || apiMsg.Message == nil {
|
||||
log.Tag(tag).Warn("Skipping malformed fan-out line")
|
||||
continue
|
||||
}
|
||||
apiMsg.Message.User = apiMsg.User
|
||||
if apiMsg.Sender != "" {
|
||||
if addr, err := netip.ParseAddr(apiMsg.Sender); err == nil {
|
||||
apiMsg.Message.Sender = addr
|
||||
}
|
||||
}
|
||||
deliver(apiMsg.Message)
|
||||
}
|
||||
return scanner.Err()
|
||||
}
|
||||
@@ -105,8 +105,10 @@ func changeAccess(c *cli.Context, manager *user.Manager, username string, topic
|
||||
return err
|
||||
}
|
||||
u, err := manager.User(username)
|
||||
if err == user.ErrUserNotFound {
|
||||
if errors.Is(err, user.ErrUserNotFound) {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
} else if err != nil {
|
||||
return err
|
||||
} else if u.Role == user.RoleAdmin {
|
||||
return fmt.Errorf("user %s is an admin user, access control entries have no effect", username)
|
||||
}
|
||||
@@ -114,13 +116,13 @@ func changeAccess(c *cli.Context, manager *user.Manager, username string, topic
|
||||
return err
|
||||
}
|
||||
if permission.IsReadWrite() {
|
||||
fmt.Fprintf(c.App.ErrWriter, "granted read-write access to topic %s\n\n", topic)
|
||||
fmt.Fprintf(c.App.Writer, "granted read-write access to topic %s\n\n", topic)
|
||||
} else if permission.IsRead() {
|
||||
fmt.Fprintf(c.App.ErrWriter, "granted read-only access to topic %s\n\n", topic)
|
||||
fmt.Fprintf(c.App.Writer, "granted read-only access to topic %s\n\n", topic)
|
||||
} else if permission.IsWrite() {
|
||||
fmt.Fprintf(c.App.ErrWriter, "granted write-only access to topic %s\n\n", topic)
|
||||
fmt.Fprintf(c.App.Writer, "granted write-only access to topic %s\n\n", topic)
|
||||
} else {
|
||||
fmt.Fprintf(c.App.ErrWriter, "revoked all access to topic %s\n\n", topic)
|
||||
fmt.Fprintf(c.App.Writer, "revoked all access to topic %s\n\n", topic)
|
||||
}
|
||||
return showUserAccess(c, manager, username)
|
||||
}
|
||||
@@ -138,7 +140,7 @@ func resetAllAccess(c *cli.Context, manager *user.Manager) error {
|
||||
if err := manager.ResetAccess("", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(c.App.ErrWriter, "reset access for all users")
|
||||
fmt.Fprintln(c.App.Writer, "reset access for all users")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -146,7 +148,7 @@ func resetUserAccess(c *cli.Context, manager *user.Manager, username string) err
|
||||
if err := manager.ResetAccess(username, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "reset access for user %s\n\n", username)
|
||||
fmt.Fprintf(c.App.Writer, "reset access for user %s\n\n", username)
|
||||
return showUserAccess(c, manager, username)
|
||||
}
|
||||
|
||||
@@ -154,7 +156,7 @@ func resetUserTopicAccess(c *cli.Context, manager *user.Manager, username string
|
||||
if err := manager.ResetAccess(username, topic); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "reset access for user %s and topic %s\n\n", username, topic)
|
||||
fmt.Fprintf(c.App.Writer, "reset access for user %s and topic %s\n\n", username, topic)
|
||||
return showUserAccess(c, manager, username)
|
||||
}
|
||||
|
||||
@@ -175,7 +177,7 @@ func showAllAccess(c *cli.Context, manager *user.Manager) error {
|
||||
|
||||
func showUserAccess(c *cli.Context, manager *user.Manager, username string) error {
|
||||
users, err := manager.User(username)
|
||||
if err == user.ErrUserNotFound {
|
||||
if errors.Is(err, user.ErrUserNotFound) {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
} else if err != nil {
|
||||
return err
|
||||
@@ -193,34 +195,42 @@ func showUsers(c *cli.Context, manager *user.Manager, users []*user.User) error
|
||||
if u.Tier != nil {
|
||||
tier = u.Tier.Name
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "user %s (role: %s, tier: %s)\n", u.Name, u.Role, tier)
|
||||
provisioned := ""
|
||||
if u.Provisioned {
|
||||
provisioned = ", server config"
|
||||
}
|
||||
fmt.Fprintf(c.App.Writer, "user %s (role: %s, tier: %s%s)\n", u.Name, u.Role, tier, provisioned)
|
||||
if u.Role == user.RoleAdmin {
|
||||
fmt.Fprintf(c.App.ErrWriter, "- read-write access to all topics (admin role)\n")
|
||||
fmt.Fprintf(c.App.Writer, "- read-write access to all topics (admin role)\n")
|
||||
} else if len(grants) > 0 {
|
||||
for _, grant := range grants {
|
||||
if grant.Allow.IsReadWrite() {
|
||||
fmt.Fprintf(c.App.ErrWriter, "- read-write access to topic %s\n", grant.TopicPattern)
|
||||
} else if grant.Allow.IsRead() {
|
||||
fmt.Fprintf(c.App.ErrWriter, "- read-only access to topic %s\n", grant.TopicPattern)
|
||||
} else if grant.Allow.IsWrite() {
|
||||
fmt.Fprintf(c.App.ErrWriter, "- write-only access to topic %s\n", grant.TopicPattern)
|
||||
grantProvisioned := ""
|
||||
if grant.Provisioned {
|
||||
grantProvisioned = " (server config)"
|
||||
}
|
||||
if grant.Permission.IsReadWrite() {
|
||||
fmt.Fprintf(c.App.Writer, "- read-write access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
|
||||
} else if grant.Permission.IsRead() {
|
||||
fmt.Fprintf(c.App.Writer, "- read-only access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
|
||||
} else if grant.Permission.IsWrite() {
|
||||
fmt.Fprintf(c.App.Writer, "- write-only access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
|
||||
} else {
|
||||
fmt.Fprintf(c.App.ErrWriter, "- no access to topic %s\n", grant.TopicPattern)
|
||||
fmt.Fprintf(c.App.Writer, "- no access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(c.App.ErrWriter, "- no topic-specific permissions\n")
|
||||
fmt.Fprintf(c.App.Writer, "- no topic-specific permissions\n")
|
||||
}
|
||||
if u.Name == user.Everyone {
|
||||
access := manager.DefaultAccess()
|
||||
if access.IsReadWrite() {
|
||||
fmt.Fprintln(c.App.ErrWriter, "- read-write access to all (other) topics (server config)")
|
||||
fmt.Fprintln(c.App.Writer, "- read-write access to all (other) topics (server config)")
|
||||
} else if access.IsRead() {
|
||||
fmt.Fprintln(c.App.ErrWriter, "- read-only access to all (other) topics (server config)")
|
||||
fmt.Fprintln(c.App.Writer, "- read-only access to all (other) topics (server config)")
|
||||
} else if access.IsWrite() {
|
||||
fmt.Fprintln(c.App.ErrWriter, "- write-only access to all (other) topics (server config)")
|
||||
fmt.Fprintln(c.App.Writer, "- write-only access to all (other) topics (server config)")
|
||||
} else {
|
||||
fmt.Fprintln(c.App.ErrWriter, "- no access to any (other) topics (server config)")
|
||||
fmt.Fprintln(c.App.Writer, "- no access to any (other) topics (server config)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ func TestCLI_Access_Show(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, _, _, stderr := newTestApp()
|
||||
app, _, stdout, _ := newTestApp()
|
||||
require.Nil(t, runAccessCommand(app, conf))
|
||||
require.Contains(t, stderr.String(), "user * (role: anonymous, tier: none)\n- no topic-specific permissions\n- no access to any (other) topics (server config)")
|
||||
require.Contains(t, stdout.String(), "user * (role: anonymous, tier: none)\n- no topic-specific permissions\n- no access to any (other) topics (server config)")
|
||||
}
|
||||
|
||||
func TestCLI_Access_Grant_And_Publish(t *testing.T) {
|
||||
@@ -30,7 +30,7 @@ func TestCLI_Access_Grant_And_Publish(t *testing.T) {
|
||||
require.Nil(t, runAccessCommand(app, conf, "ben", "sometopic", "read"))
|
||||
require.Nil(t, runAccessCommand(app, conf, "everyone", "announcements", "read"))
|
||||
|
||||
app, _, _, stderr := newTestApp()
|
||||
app, _, stdout, _ := newTestApp()
|
||||
require.Nil(t, runAccessCommand(app, conf))
|
||||
expected := `user phil (role: admin, tier: none)
|
||||
- read-write access to all topics (admin role)
|
||||
@@ -41,7 +41,7 @@ user * (role: anonymous, tier: none)
|
||||
- read-only access to topic announcements
|
||||
- no access to any (other) topics (server config)
|
||||
`
|
||||
require.Equal(t, expected, stderr.String())
|
||||
require.Equal(t, expected, stdout.String())
|
||||
|
||||
// See if access permissions match
|
||||
app, _, _, _ = newTestApp()
|
||||
|
||||
@@ -3,11 +3,12 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"os"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -15,6 +16,12 @@ const (
|
||||
categoryServer = "Server commands"
|
||||
)
|
||||
|
||||
// Build metadata keys for app.Metadata
|
||||
const (
|
||||
MetadataKeyCommit = "commit"
|
||||
MetadataKeyDate = "date"
|
||||
)
|
||||
|
||||
var commands = make([]*cli.Command, 0)
|
||||
|
||||
var flagsDefault = []cli.Flag{
|
||||
@@ -37,7 +44,7 @@ func New() *cli.App {
|
||||
Name: "ntfy",
|
||||
Usage: "Simple pub-sub notification service",
|
||||
UsageText: "ntfy [OPTION..]",
|
||||
HideVersion: true,
|
||||
HideVersion: false,
|
||||
UseShortOptionHandling: true,
|
||||
Reader: os.Stdin,
|
||||
Writer: os.Stdout,
|
||||
|
||||
@@ -32,7 +32,9 @@ var flagsPublish = append(
|
||||
&cli.StringFlag{Name: "actions", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ACTIONS"}, Usage: "actions JSON array or simple definition"},
|
||||
&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"},
|
||||
&cli.BoolFlag{Name: "markdown", Aliases: []string{"md"}, EnvVars: []string{"NTFY_MARKDOWN"}, Usage: "Message is formatted as Markdown"},
|
||||
&cli.StringFlag{Name: "template", Aliases: []string{"tpl"}, EnvVars: []string{"NTFY_TEMPLATE"}, Usage: "use templates to transform JSON message body"},
|
||||
&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"},
|
||||
&cli.StringFlag{Name: "sequence-id", Aliases: []string{"sequence_id", "sid", "S"}, EnvVars: []string{"NTFY_SEQUENCE_ID"}, Usage: "sequence ID for updating notifications"},
|
||||
&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"},
|
||||
@@ -69,6 +71,7 @@ Examples:
|
||||
ntfy pub --icon="http://some.tld/icon.png" 'Icon!' # Send notification with custom icon
|
||||
ntfy pub --attach="http://some.tld/file.zip" files # Send ZIP archive from URL as attachment
|
||||
ntfy pub --file=flower.jpg flowers 'Nice!' # Send image.jpg as attachment
|
||||
ntfy pub -S my-id mytopic 'Update me' # Send with sequence ID for updates
|
||||
echo 'message' | ntfy publish mytopic # Send message from stdin
|
||||
ntfy pub -u phil:mypass secret Psst # Publish with username/password
|
||||
ntfy pub --wait-pid 1234 mytopic # Wait for process 1234 to exit before publishing
|
||||
@@ -98,7 +101,9 @@ func execPublish(c *cli.Context) error {
|
||||
actions := c.String("actions")
|
||||
attach := c.String("attach")
|
||||
markdown := c.Bool("markdown")
|
||||
template := c.String("template")
|
||||
filename := c.String("filename")
|
||||
sequenceID := c.String("sequence-id")
|
||||
file := c.String("file")
|
||||
email := c.String("email")
|
||||
user := c.String("user")
|
||||
@@ -146,9 +151,15 @@ func execPublish(c *cli.Context) error {
|
||||
if markdown {
|
||||
options = append(options, client.WithMarkdown())
|
||||
}
|
||||
if template != "" {
|
||||
options = append(options, client.WithTemplate(template))
|
||||
}
|
||||
if filename != "" {
|
||||
options = append(options, client.WithFilename(filename))
|
||||
}
|
||||
if sequenceID != "" {
|
||||
options = append(options, client.WithSequenceID(sequenceID))
|
||||
}
|
||||
if email != "" {
|
||||
options = append(options, client.WithEmail(email))
|
||||
}
|
||||
|
||||
@@ -2,9 +2,6 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/test"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -14,9 +11,14 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/test"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) {
|
||||
t.Skip("temporarily disabled") // FIXME
|
||||
testMessage := util.RandomString(10)
|
||||
app, _, _, _ := newTestApp()
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "ntfytest", "ntfy unit test " + testMessage}))
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//go:build darwin || linux || dragonfly || freebsd || netbsd || openbsd
|
||||
// +build darwin linux dragonfly freebsd netbsd openbsd
|
||||
|
||||
package cmd
|
||||
|
||||
|
||||
@@ -10,16 +10,18 @@ import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/stripe/stripe-go/v74"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
"heckel.io/ntfy/v2/ban"
|
||||
"heckel.io/ntfy/v2/cluster"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/payments"
|
||||
"heckel.io/ntfy/v2/server"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
@@ -29,13 +31,9 @@ func init() {
|
||||
commands = append(commands, cmdServe)
|
||||
}
|
||||
|
||||
const (
|
||||
defaultServerConfigFile = "/etc/ntfy/server.yml"
|
||||
)
|
||||
|
||||
var flagsServe = append(
|
||||
append([]cli.Flag{}, flagsDefault...),
|
||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, Usage: "config file"},
|
||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: server.DefaultConfigFile, Usage: "config file"},
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used as HTTP listen address"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"listen_https", "L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used as HTTPS listen address"}),
|
||||
@@ -44,6 +42,13 @@ var flagsServe = append(
|
||||
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: "database-url", Aliases: []string{"database_url"}, EnvVars: []string{"NTFY_DATABASE_URL"}, Usage: "PostgreSQL connection string for database-backed stores (e.g. postgres://user:pass@host:5432/ntfy)"}),
|
||||
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "database-replica-urls", Aliases: []string{"database_replica_urls"}, EnvVars: []string{"NTFY_DATABASE_REPLICA_URLS"}, Usage: "PostgreSQL read replica connection strings for offloading read queries"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cluster-node-id", Aliases: []string{"cluster_node_id"}, EnvVars: []string{"NTFY_CLUSTER_NODE_ID"}, Usage: "stable per-node identifier for the cluster node registry (required in cluster mode)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cluster-listen", Aliases: []string{"cluster_listen"}, EnvVars: []string{"NTFY_CLUSTER_LISTEN"}, Usage: "ip:port for the dedicated cluster fan-out listener; bind it to the private network (e.g. 10.0.0.5:2587)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cluster-advertise-url", Aliases: []string{"cluster_advertise_url"}, EnvVars: []string{"NTFY_CLUSTER_ADVERTISE_URL"}, Usage: "base URL peer nodes use to reach this node's fan-out listener (defaults to http://<cluster-listen>)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cluster-secret", Aliases: []string{"cluster_secret"}, EnvVars: []string{"NTFY_CLUSTER_SECRET"}, Usage: "shared secret authenticating node-to-node fan-out requests"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cluster-batch-linger", Aliases: []string{"cluster_batch_linger"}, EnvVars: []string{"NTFY_CLUSTER_BATCH_LINGER"}, Value: util.FormatDuration(cluster.DefaultBatchLinger), Usage: "how long fan-out messages wait to form a batch per peer node (0 = send immediately)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"cache_file", "C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-duration", Aliases: []string{"cache_duration", "b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: util.FormatDuration(server.DefaultCacheDuration), Usage: "buffer messages for this time to allow `since` requests"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "cache-batch-size", Aliases: []string{"cache_batch_size"}, EnvVars: []string{"NTFY_BATCH_SIZE"}, Usage: "max size of messages to batch together when writing to message cache (if zero, writes are synchronous)"}),
|
||||
@@ -52,10 +57,15 @@ var flagsServe = append(
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-startup-queries", Aliases: []string{"auth_startup_queries"}, EnvVars: []string{"NTFY_AUTH_STARTUP_QUERIES"}, Usage: "queries run when the auth database is initialized"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
|
||||
altsrc.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.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-users", Aliases: []string{"auth_users"}, EnvVars: []string{"NTFY_AUTH_USERS"}, Usage: "pre-provisioned declarative users"}),
|
||||
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-access", Aliases: []string{"auth_access"}, EnvVars: []string{"NTFY_AUTH_ACCESS"}, Usage: "pre-provisioned declarative access control entries"}),
|
||||
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-tokens", Aliases: []string{"auth_tokens"}, EnvVars: []string{"NTFY_AUTH_TOKENS"}, Usage: "pre-provisioned declarative access tokens"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "auth-access-cache", Aliases: []string{"auth_access_cache"}, EnvVars: []string{"NTFY_AUTH_ACCESS_CACHE"}, Value: user.DefaultAccessCacheEnabled, Usage: "enables the in-memory ACL cache (high-volume servers only)"}),
|
||||
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, or S3 URL (s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT])"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentTotalSizeLimit), Usage: "limit of the on-disk attachment cache"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentFileSizeLimit), Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: util.FormatDuration(server.DefaultAttachmentExpiryDuration), Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "template-dir", Aliases: []string{"template_dir"}, EnvVars: []string{"NTFY_TEMPLATE_DIR"}, Value: server.DefaultTemplateDir, Usage: "directory to load named message templates from"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: util.FormatDuration(server.DefaultKeepaliveInterval), Usage: "interval of keepalive messages"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: util.FormatDuration(server.DefaultManagerInterval), Usage: "interval of for message pruning and stats printing"}),
|
||||
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "disallowed-topics", Aliases: []string{"disallowed_topics"}, EnvVars: []string{"NTFY_DISALLOWED_TOPICS"}, Usage: "topics that are not allowed to be used"}),
|
||||
@@ -63,12 +73,14 @@ var flagsServe = append(
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-signup", Aliases: []string{"enable_signup"}, EnvVars: []string{"NTFY_ENABLE_SIGNUP"}, Value: false, Usage: "allows users to sign up via the web app, or API"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-login", Aliases: []string{"enable_login"}, EnvVars: []string{"NTFY_ENABLE_LOGIN"}, Value: false, Usage: "allows users to log in via the web app, or API"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-reservations", Aliases: []string{"enable_reservations"}, EnvVars: []string{"NTFY_ENABLE_RESERVATIONS"}, Value: false, Usage: "allows users to reserve topics (if their tier allows it)"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "require-login", Aliases: []string{"require_login"}, EnvVars: []string{"NTFY_REQUIRE_LOGIN"}, Value: false, Usage: "all actions via the web app requires a login"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-base-url", Aliases: []string{"upstream_base_url"}, EnvVars: []string{"NTFY_UPSTREAM_BASE_URL"}, Value: "", Usage: "forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-access-token", Aliases: []string{"upstream_access_token"}, EnvVars: []string{"NTFY_UPSTREAM_ACCESS_TOKEN"}, Value: "", Usage: "access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", Aliases: []string{"smtp_sender_addr"}, EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}),
|
||||
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.NewBoolFlag(&cli.BoolFlag{Name: "smtp-sender-verify", Aliases: []string{"smtp_sender_verify"}, EnvVars: []string{"NTFY_SMTP_SENDER_VERIFY"}, Value: false, Usage: "require verified email addresses for sending email notifications"}),
|
||||
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-')"}),
|
||||
@@ -76,6 +88,7 @@ var flagsServe = append(
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-auth-token", Aliases: []string{"twilio_auth_token"}, EnvVars: []string{"NTFY_TWILIO_AUTH_TOKEN"}, Usage: "Twilio auth token"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-phone-number", Aliases: []string{"twilio_phone_number"}, EnvVars: []string{"NTFY_TWILIO_PHONE_NUMBER"}, Usage: "Twilio number to use for outgoing calls"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-verify-service", Aliases: []string{"twilio_verify_service"}, EnvVars: []string{"NTFY_TWILIO_VERIFY_SERVICE"}, Usage: "Twilio Verify service ID, used for phone number verification"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-call-format", Aliases: []string{"twilio_call_format"}, EnvVars: []string{"NTFY_TWILIO_CALL_FORMAT"}, Usage: "Twilio/TwiML format string for phone calls"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "message-size-limit", Aliases: []string{"message_size_limit"}, EnvVars: []string{"NTFY_MESSAGE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultMessageSizeLimit), Usage: "size limit for the message (see docs for limitations)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "message-delay-limit", Aliases: []string{"message_delay_limit"}, EnvVars: []string{"NTFY_MESSAGE_DELAY_LIMIT"}, Value: util.FormatDuration(server.DefaultMessageDelayMax), Usage: "max duration a message can be scheduled into the future"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
|
||||
@@ -89,8 +102,14 @@ var flagsServe = append(
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorEmailLimitReplenish), Usage: "interval at which burst limit is replenished (one per x)"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-topic-creation-limit-burst", Aliases: []string{"visitor_topic_creation_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_TOPIC_CREATION_LIMIT_BURST"}, Value: server.DefaultVisitorTopicCreationLimitBurst, Usage: "burst of new topic creations per visitor (0 = disabled)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-topic-creation-limit-replenish", Aliases: []string{"visitor_topic_creation_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_TOPIC_CREATION_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorTopicCreationLimitReplenish), Usage: "interval at which topic-creation tokens are refilled (one per x)"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv4", Aliases: []string{"visitor_prefix_bits_ipv4"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV4"}, Value: server.DefaultVisitorPrefixBitsIPv4, Usage: "number of bits of the IPv4 address to use for rate limiting (default: 32, full address)"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv6", Aliases: []string{"visitor_prefix_bits_ipv6"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV6"}, Value: server.DefaultVisitorPrefixBitsIPv6, Usage: "number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "ban-file", Aliases: []string{"ban_file"}, EnvVars: []string{"NTFY_BAN_FILE"}, Value: "", Usage: "if set, append IPs of abusive visitors to this file for fail2ban to tail (empty disables)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "ban-window", Aliases: []string{"ban_window"}, EnvVars: []string{"NTFY_BAN_WINDOW"}, Value: util.FormatDuration(server.DefaultBanWindow), Usage: "rolling window over which weighted strikes are counted for the ban file"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "ban-threshold", Aliases: []string{"ban_threshold"}, EnvVars: []string{"NTFY_BAN_THRESHOLD"}, Value: server.DefaultBanThreshold, Usage: "weighted strikes per window before an offender is banned"}),
|
||||
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "ban-weights", Aliases: []string{"ban_weights"}, EnvVars: []string{"NTFY_BAN_WEIGHTS"}, Value: cli.NewStringSlice(server.DefaultBanWeights...), Usage: "per-code strike weights as KEY:WEIGHT, where KEY is an ntfy code, an HTTP status, a PREFIX*, or '*' (weight 0 exempts)"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-forwarded-header", Aliases: []string{"proxy_forwarded_header"}, EnvVars: []string{"NTFY_PROXY_FORWARDED_HEADER"}, Value: "X-Forwarded-For", Usage: "use specified header to determine visitor IP address (for rate limiting)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-trusted-hosts", Aliases: []string{"proxy_trusted_hosts"}, EnvVars: []string{"NTFY_PROXY_TRUSTED_HOSTS"}, Value: "", Usage: "comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header"}),
|
||||
@@ -142,6 +161,13 @@ func execServe(c *cli.Context) error {
|
||||
keyFile := c.String("key-file")
|
||||
certFile := c.String("cert-file")
|
||||
firebaseKeyFile := c.String("firebase-key-file")
|
||||
databaseURL := c.String("database-url")
|
||||
databaseReplicaURLs := c.StringSlice("database-replica-urls")
|
||||
clusterNodeID := c.String("cluster-node-id")
|
||||
clusterListen := c.String("cluster-listen")
|
||||
clusterAdvertiseURL := c.String("cluster-advertise-url")
|
||||
clusterSecret := c.String("cluster-secret")
|
||||
clusterBatchLingerStr := c.String("cluster-batch-linger")
|
||||
webPushPrivateKey := c.String("web-push-private-key")
|
||||
webPushPublicKey := c.String("web-push-public-key")
|
||||
webPushFile := c.String("web-push-file")
|
||||
@@ -157,16 +183,22 @@ func execServe(c *cli.Context) error {
|
||||
authFile := c.String("auth-file")
|
||||
authStartupQueries := c.String("auth-startup-queries")
|
||||
authDefaultAccess := c.String("auth-default-access")
|
||||
authUsersRaw := c.StringSlice("auth-users")
|
||||
authAccessRaw := c.StringSlice("auth-access")
|
||||
authTokensRaw := c.StringSlice("auth-tokens")
|
||||
authAccessCacheEnabled := c.Bool("auth-access-cache")
|
||||
attachmentCacheDir := c.String("attachment-cache-dir")
|
||||
attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit")
|
||||
attachmentFileSizeLimitStr := c.String("attachment-file-size-limit")
|
||||
attachmentExpiryDurationStr := c.String("attachment-expiry-duration")
|
||||
templateDir := c.String("template-dir")
|
||||
keepaliveIntervalStr := c.String("keepalive-interval")
|
||||
managerIntervalStr := c.String("manager-interval")
|
||||
disallowedTopics := c.StringSlice("disallowed-topics")
|
||||
webRoot := c.String("web-root")
|
||||
enableSignup := c.Bool("enable-signup")
|
||||
enableLogin := c.Bool("enable-login")
|
||||
requireLogin := c.Bool("require-login")
|
||||
enableReservations := c.Bool("enable-reservations")
|
||||
upstreamBaseURL := c.String("upstream-base-url")
|
||||
upstreamAccessToken := c.String("upstream-access-token")
|
||||
@@ -174,6 +206,7 @@ func execServe(c *cli.Context) error {
|
||||
smtpSenderUser := c.String("smtp-sender-user")
|
||||
smtpSenderPass := c.String("smtp-sender-pass")
|
||||
smtpSenderFrom := c.String("smtp-sender-from")
|
||||
smtpSenderVerify := c.Bool("smtp-sender-verify")
|
||||
smtpServerListen := c.String("smtp-server-listen")
|
||||
smtpServerDomain := c.String("smtp-server-domain")
|
||||
smtpServerAddrPrefix := c.String("smtp-server-addr-prefix")
|
||||
@@ -181,6 +214,7 @@ func execServe(c *cli.Context) error {
|
||||
twilioAuthToken := c.String("twilio-auth-token")
|
||||
twilioPhoneNumber := c.String("twilio-phone-number")
|
||||
twilioVerifyService := c.String("twilio-verify-service")
|
||||
twilioCallFormat := c.String("twilio-call-format")
|
||||
messageSizeLimitStr := c.String("message-size-limit")
|
||||
messageDelayLimitStr := c.String("message-delay-limit")
|
||||
totalTopicLimit := c.Int("global-topic-limit")
|
||||
@@ -194,8 +228,14 @@ func execServe(c *cli.Context) error {
|
||||
visitorMessageDailyLimit := c.Int("visitor-message-daily-limit")
|
||||
visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
|
||||
visitorEmailLimitReplenishStr := c.String("visitor-email-limit-replenish")
|
||||
visitorTopicCreationLimitBurst := c.Int("visitor-topic-creation-limit-burst")
|
||||
visitorTopicCreationLimitReplenishStr := c.String("visitor-topic-creation-limit-replenish")
|
||||
visitorPrefixBitsIPv4 := c.Int("visitor-prefix-bits-ipv4")
|
||||
visitorPrefixBitsIPv6 := c.Int("visitor-prefix-bits-ipv6")
|
||||
banFile := c.String("ban-file")
|
||||
banWindowStr := c.String("ban-window")
|
||||
banThreshold := c.Int("ban-threshold")
|
||||
banWeightsRaw := c.StringSlice("ban-weights")
|
||||
behindProxy := c.Bool("behind-proxy")
|
||||
proxyForwardedHeader := c.String("proxy-forwarded-header")
|
||||
proxyTrustedHosts := util.SplitNoEmpty(c.String("proxy-trusted-hosts"), ",")
|
||||
@@ -223,6 +263,10 @@ func execServe(c *cli.Context) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid keepalive interval: %s", keepaliveIntervalStr)
|
||||
}
|
||||
clusterBatchLinger, err := util.ParseDuration(clusterBatchLingerStr)
|
||||
if err != nil || clusterBatchLinger < 0 {
|
||||
return fmt.Errorf("invalid cluster batch linger: %s", clusterBatchLingerStr)
|
||||
}
|
||||
managerInterval, err := util.ParseDuration(managerIntervalStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid manager interval: %s", managerIntervalStr)
|
||||
@@ -239,6 +283,10 @@ func execServe(c *cli.Context) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid visitor email limit replenish: %s", visitorEmailLimitReplenishStr)
|
||||
}
|
||||
visitorTopicCreationLimitReplenish, err := util.ParseDuration(visitorTopicCreationLimitReplenishStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid visitor topic creation limit replenish: %s", visitorTopicCreationLimitReplenishStr)
|
||||
}
|
||||
webPushExpiryDuration, err := util.ParseDuration(webPushExpiryDurationStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid web push expiry duration: %s", webPushExpiryDurationStr)
|
||||
@@ -247,6 +295,16 @@ func execServe(c *cli.Context) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid web push expiry warning duration: %s", webPushExpiryWarningDurationStr)
|
||||
}
|
||||
banWindow, err := util.ParseDuration(banWindowStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid ban window: %s", banWindowStr)
|
||||
}
|
||||
|
||||
// Parse abuse ban-feed weights ("KEY:WEIGHT" list, "*" fallback)
|
||||
banWeights, err := ban.ParseWeights(banWeightsRaw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Convert sizes to bytes
|
||||
messageSizeLimit, err := util.ParseSize(messageSizeLimitStr)
|
||||
@@ -273,10 +331,28 @@ func execServe(c *cli.Context) error {
|
||||
}
|
||||
|
||||
// Check values
|
||||
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
|
||||
if databaseURL != "" && !strings.HasPrefix(databaseURL, "postgres://") && !strings.HasPrefix(databaseURL, "postgresql://") {
|
||||
return errors.New("if database-url is set, it must start with postgres:// or postgresql://")
|
||||
} else if databaseURL != "" && (authFile != "" || cacheFile != "" || webPushFile != "") {
|
||||
return errors.New("if database-url is set, auth-file, cache-file, and web-push-file must not be set")
|
||||
} else if len(databaseReplicaURLs) > 0 && databaseURL == "" {
|
||||
return errors.New("database-replica-urls can only be used if database-url is also set")
|
||||
} else if clusterListen != "" && databaseURL == "" {
|
||||
return errors.New("cluster-listen requires database-url to be set")
|
||||
} else if clusterListen != "" && clusterSecret == "" {
|
||||
return errors.New("cluster-listen requires cluster-secret to be set")
|
||||
} else if clusterListen != "" && clusterNodeID == "" {
|
||||
return errors.New("cluster-listen requires cluster-node-id to be set")
|
||||
} else if clusterListen == "" && clusterSecret != "" {
|
||||
return errors.New("cluster-secret can only be used if cluster-listen is set")
|
||||
} else if clusterListen != "" && clusterAdvertiseURL == "" && wildcardAddr(clusterListen) {
|
||||
return errors.New("cluster-advertise-url must be set if cluster-listen binds a wildcard address")
|
||||
} else if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
|
||||
return errors.New("if set, FCM key file must exist")
|
||||
} else if webPushPublicKey != "" && (webPushPrivateKey == "" || webPushFile == "" || webPushEmailAddress == "" || baseURL == "") {
|
||||
return errors.New("if web push is enabled, web-push-private-key, web-push-public-key, web-push-file, web-push-email-address, and base-url should be set. run 'ntfy webpush keys' to generate keys")
|
||||
} else if firebaseKeyFile != "" && !server.FirebaseAvailable {
|
||||
return errors.New("cannot set firebase-key-file, support for Firebase is not available (nofirebase)")
|
||||
} else if webPushPublicKey != "" && (webPushPrivateKey == "" || (webPushFile == "" && databaseURL == "") || webPushEmailAddress == "" || baseURL == "") {
|
||||
return errors.New("if web push is enabled, web-push-private-key, web-push-public-key, web-push-file (or database-url), web-push-email-address, and base-url should be set. run 'ntfy webpush keys' to generate keys")
|
||||
} else if keepaliveInterval < 5*time.Second {
|
||||
return errors.New("keepalive interval cannot be lower than five seconds")
|
||||
} else if managerInterval < 5*time.Second {
|
||||
@@ -291,6 +367,8 @@ func execServe(c *cli.Context) error {
|
||||
return errors.New("if listen-https is set, both key-file and cert-file must 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 smtpSenderVerify && smtpSenderAddr == "" {
|
||||
return errors.New("if smtp-sender-verify is set, smtp-sender-addr 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 == "" {
|
||||
@@ -312,19 +390,25 @@ func execServe(c *cli.Context) error {
|
||||
return errors.New("if upstream-base-url is set, base-url must also be set")
|
||||
} else if upstreamBaseURL != "" && baseURL != "" && baseURL == upstreamBaseURL {
|
||||
return errors.New("base-url and upstream-base-url cannot be identical, you'll likely want to set upstream-base-url to https://ntfy.sh, see https://ntfy.sh/docs/config/#ios-instant-notifications")
|
||||
} else if authFile == "" && (enableSignup || enableLogin || enableReservations || stripeSecretKey != "") {
|
||||
return errors.New("cannot set enable-signup, enable-login, enable-reserve-topics, or stripe-secret-key if auth-file is not set")
|
||||
} else if authFile == "" && databaseURL == "" && (enableSignup || enableLogin || requireLogin || enableReservations || stripeSecretKey != "") {
|
||||
return errors.New("cannot set enable-signup, enable-login, require-login, enable-reserve-topics, or stripe-secret-key if auth-file or database-url is not set")
|
||||
} else if enableSignup && !enableLogin {
|
||||
return errors.New("cannot set enable-signup without also setting enable-login")
|
||||
} else if requireLogin && !enableLogin {
|
||||
return errors.New("cannot set require-login without also setting enable-login")
|
||||
} else if !payments.Available && (stripeSecretKey != "" || stripeWebhookKey != "") {
|
||||
return errors.New("cannot set stripe-secret-key or stripe-webhook-key, support for payments is not available in this build (nopayments)")
|
||||
} else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") {
|
||||
return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set")
|
||||
} else if twilioAccount != "" && (twilioAuthToken == "" || twilioPhoneNumber == "" || twilioVerifyService == "" || baseURL == "" || authFile == "") {
|
||||
return errors.New("if twilio-account is set, twilio-auth-token, twilio-phone-number, twilio-verify-service, base-url, and auth-file must also be set")
|
||||
} else if twilioAccount != "" && (twilioAuthToken == "" || twilioPhoneNumber == "" || twilioVerifyService == "" || baseURL == "" || (authFile == "" && databaseURL == "")) {
|
||||
return errors.New("if twilio-account is set, twilio-auth-token, twilio-phone-number, twilio-verify-service, base-url, and auth-file (or database-url) must also be set")
|
||||
} else if messageSizeLimit > server.DefaultMessageSizeLimit {
|
||||
log.Warn("message-size-limit is greater than 4K, this is not recommended and largely untested, and may lead to issues with some clients")
|
||||
if messageSizeLimit > 5*1024*1024 {
|
||||
return errors.New("message-size-limit cannot be higher than 5M")
|
||||
}
|
||||
} else if !server.WebPushAvailable && (webPushPrivateKey != "" || webPushPublicKey != "" || webPushFile != "") {
|
||||
return errors.New("cannot enable WebPush, support is not available in this build (nowebpush)")
|
||||
} else if webPushExpiryWarningDuration > 0 && webPushExpiryWarningDuration > webPushExpiryDuration {
|
||||
return errors.New("web push expiry warning duration cannot be higher than web push expiry duration")
|
||||
} else if behindProxy && proxyForwardedHeader == "" {
|
||||
@@ -333,6 +417,16 @@ func execServe(c *cli.Context) error {
|
||||
return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32")
|
||||
} else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 {
|
||||
return errors.New("visitor-prefix-bits-ipv6 must be between 1 and 128")
|
||||
} else if banFile != "" && banWindow <= 0 {
|
||||
return errors.New("if ban-file is set, ban-window must be greater than zero")
|
||||
} else if banFile != "" && banThreshold <= 0 {
|
||||
return errors.New("if ban-file is set, ban-threshold must be greater than zero")
|
||||
} else if banFile != "" && len(banWeights) == 0 {
|
||||
return errors.New("if ban-file is set, ban-weights must not be empty")
|
||||
} else if banFile != "" && !util.FileExists(filepath.Dir(banFile)) {
|
||||
return fmt.Errorf("if ban-file is set, its directory (%s) must exist", filepath.Dir(banFile))
|
||||
} else if runtime.GOOS == "windows" && listenUnix != "" {
|
||||
return errors.New("listen-unix is not supported on Windows")
|
||||
}
|
||||
|
||||
// Backwards compatibility
|
||||
@@ -346,11 +440,23 @@ func execServe(c *cli.Context) error {
|
||||
webRoot = "/" + webRoot
|
||||
}
|
||||
|
||||
// Default auth permissions
|
||||
// Convert default auth permission, read provisioned users
|
||||
authDefault, err := user.ParsePermission(authDefaultAccess)
|
||||
if err != nil {
|
||||
return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
|
||||
}
|
||||
authUsers, err := parseUsers(authUsersRaw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
authAccess, err := parseAccess(authUsers, authAccessRaw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
authTokens, err := parseTokens(authUsers, authTokensRaw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Special case: Unset default
|
||||
if listenHTTP == "-" {
|
||||
@@ -380,8 +486,16 @@ func execServe(c *cli.Context) error {
|
||||
|
||||
// Stripe things
|
||||
if stripeSecretKey != "" {
|
||||
stripe.EnableTelemetry = false // Whoa!
|
||||
stripe.Key = stripeSecretKey
|
||||
payments.Setup(stripeSecretKey)
|
||||
}
|
||||
|
||||
// Parse Twilio template
|
||||
var twilioCallFormatTemplate *template.Template
|
||||
if twilioCallFormat != "" {
|
||||
twilioCallFormatTemplate, err = template.New("").Parse(twilioCallFormat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse twilio-call-format template: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add default forbidden topics
|
||||
@@ -406,10 +520,15 @@ func execServe(c *cli.Context) error {
|
||||
conf.AuthFile = authFile
|
||||
conf.AuthStartupQueries = authStartupQueries
|
||||
conf.AuthDefault = authDefault
|
||||
conf.AuthUsers = authUsers
|
||||
conf.AuthAccess = authAccess
|
||||
conf.AuthTokens = authTokens
|
||||
conf.AuthAccessCacheEnabled = authAccessCacheEnabled
|
||||
conf.AttachmentCacheDir = attachmentCacheDir
|
||||
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
|
||||
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
|
||||
conf.AttachmentExpiryDuration = attachmentExpiryDuration
|
||||
conf.TemplateDir = templateDir
|
||||
conf.KeepaliveInterval = keepaliveInterval
|
||||
conf.ManagerInterval = managerInterval
|
||||
conf.DisallowedTopics = disallowedTopics
|
||||
@@ -420,6 +539,7 @@ func execServe(c *cli.Context) error {
|
||||
conf.SMTPSenderUser = smtpSenderUser
|
||||
conf.SMTPSenderPass = smtpSenderPass
|
||||
conf.SMTPSenderFrom = smtpSenderFrom
|
||||
conf.SMTPSenderVerify = smtpSenderVerify
|
||||
conf.SMTPServerListen = smtpServerListen
|
||||
conf.SMTPServerDomain = smtpServerDomain
|
||||
conf.SMTPServerAddrPrefix = smtpServerAddrPrefix
|
||||
@@ -427,6 +547,7 @@ func execServe(c *cli.Context) error {
|
||||
conf.TwilioAuthToken = twilioAuthToken
|
||||
conf.TwilioPhoneNumber = twilioPhoneNumber
|
||||
conf.TwilioVerifyService = twilioVerifyService
|
||||
conf.TwilioCallFormat = twilioCallFormatTemplate
|
||||
conf.MessageSizeLimit = int(messageSizeLimit)
|
||||
conf.MessageDelayMax = messageDelayLimit
|
||||
conf.TotalTopicLimit = totalTopicLimit
|
||||
@@ -440,8 +561,14 @@ func execServe(c *cli.Context) error {
|
||||
conf.VisitorMessageDailyLimit = visitorMessageDailyLimit
|
||||
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
|
||||
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
|
||||
conf.VisitorTopicCreationLimitBurst = visitorTopicCreationLimitBurst
|
||||
conf.VisitorTopicCreationLimitReplenish = visitorTopicCreationLimitReplenish
|
||||
conf.VisitorPrefixBitsIPv4 = visitorPrefixBitsIPv4
|
||||
conf.VisitorPrefixBitsIPv6 = visitorPrefixBitsIPv6
|
||||
conf.BanFile = banFile
|
||||
conf.BanWindow = banWindow
|
||||
conf.BanThreshold = banThreshold
|
||||
conf.BanWeights = banWeights
|
||||
conf.BehindProxy = behindProxy
|
||||
conf.ProxyForwardedHeader = proxyForwardedHeader
|
||||
conf.ProxyTrustedPrefixes = trustedProxyPrefixes
|
||||
@@ -450,10 +577,18 @@ func execServe(c *cli.Context) error {
|
||||
conf.BillingContact = billingContact
|
||||
conf.EnableSignup = enableSignup
|
||||
conf.EnableLogin = enableLogin
|
||||
conf.RequireLogin = requireLogin
|
||||
conf.EnableReservations = enableReservations
|
||||
conf.EnableMetrics = enableMetrics
|
||||
conf.MetricsListenHTTP = metricsListenHTTP
|
||||
conf.ProfileListenHTTP = profileListenHTTP
|
||||
conf.DatabaseURL = databaseURL
|
||||
conf.DatabaseReplicaURLs = databaseReplicaURLs
|
||||
conf.ClusterNodeID = clusterNodeID
|
||||
conf.ClusterListen = clusterListen
|
||||
conf.ClusterAdvertiseURL = clusterAdvertiseURL
|
||||
conf.ClusterSecret = clusterSecret
|
||||
conf.ClusterBatchLinger = clusterBatchLinger
|
||||
conf.WebPushPrivateKey = webPushPrivateKey
|
||||
conf.WebPushPublicKey = webPushPublicKey
|
||||
conf.WebPushFile = webPushFile
|
||||
@@ -461,7 +596,17 @@ func execServe(c *cli.Context) error {
|
||||
conf.WebPushStartupQueries = webPushStartupQueries
|
||||
conf.WebPushExpiryDuration = webPushExpiryDuration
|
||||
conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration
|
||||
conf.Version = c.App.Version
|
||||
conf.BuildVersion = c.App.Version
|
||||
conf.BuildDate = maybeFromMetadata(c.App.Metadata, MetadataKeyDate)
|
||||
conf.BuildCommit = maybeFromMetadata(c.App.Metadata, MetadataKeyCommit)
|
||||
|
||||
// Check if we should run as a Windows service
|
||||
if ranAsService, err := maybeRunAsService(conf); err != nil {
|
||||
log.Fatal("%s", err.Error())
|
||||
} else if ranAsService {
|
||||
log.Info("Exiting.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set up hot-reloading of config
|
||||
go sigHandlerConfigReload(config)
|
||||
@@ -477,22 +622,6 @@ func execServe(c *cli.Context) error {
|
||||
return 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 or 2001:db8::/32
|
||||
prefix, err := netip.ParsePrefix(host)
|
||||
@@ -518,24 +647,133 @@ func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) {
|
||||
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())
|
||||
func parseUsers(usersRaw []string) ([]*user.User, error) {
|
||||
users := make([]*user.User, 0)
|
||||
for _, userLine := range usersRaw {
|
||||
parts := strings.Split(userLine, ":")
|
||||
if len(parts) != 3 {
|
||||
return nil, fmt.Errorf("invalid auth-users: %s, expected format: 'name:hash:role'", userLine)
|
||||
}
|
||||
username := strings.TrimSpace(parts[0])
|
||||
passwordHash := strings.TrimSpace(parts[1])
|
||||
role := user.Role(strings.TrimSpace(parts[2]))
|
||||
if !user.AllowedUsername(username) {
|
||||
return nil, fmt.Errorf("invalid auth-users: %s, username invalid", userLine)
|
||||
} else if err := user.ValidPasswordHash(passwordHash, user.DefaultUserPasswordBcryptCost); err != nil {
|
||||
return nil, fmt.Errorf("invalid auth-users: %s, password hash invalid, %s", userLine, err.Error())
|
||||
} else if !user.AllowedRole(role) {
|
||||
return nil, fmt.Errorf("invalid auth-users: %s, role %s is not allowed, allowed roles are 'admin' or 'user'", userLine, role)
|
||||
}
|
||||
users = append(users, &user.User{
|
||||
Name: username,
|
||||
Hash: passwordHash,
|
||||
Role: role,
|
||||
Provisioned: true,
|
||||
})
|
||||
}
|
||||
overrides, err := inputSource.StringSlice("log-level-overrides")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot load log level overrides (1): %s", err.Error())
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func parseAccess(users []*user.User, accessRaw []string) (map[string][]*user.Grant, error) {
|
||||
access := make(map[string][]*user.Grant)
|
||||
for _, accessLine := range accessRaw {
|
||||
parts := strings.Split(accessLine, ":")
|
||||
if len(parts) != 3 {
|
||||
return nil, fmt.Errorf("invalid auth-access: %s, expected format: 'user:topic:permission'", accessLine)
|
||||
}
|
||||
username := strings.TrimSpace(parts[0])
|
||||
if username == userEveryone {
|
||||
username = user.Everyone
|
||||
}
|
||||
u, exists := util.Find(users, func(u *user.User) bool {
|
||||
return u.Name == username
|
||||
})
|
||||
if username != user.Everyone {
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("invalid auth-access: %s, user %s is not provisioned", accessLine, username)
|
||||
} else if !user.AllowedUsername(username) {
|
||||
return nil, fmt.Errorf("invalid auth-access: %s, username %s invalid", accessLine, username)
|
||||
} else if u.Role != user.RoleUser {
|
||||
return nil, fmt.Errorf("invalid auth-access: %s, user %s is not a regular user, only regular users can have ACL entries", accessLine, username)
|
||||
}
|
||||
}
|
||||
topic := strings.TrimSpace(parts[1])
|
||||
if !user.AllowedTopicPattern(topic) {
|
||||
return nil, fmt.Errorf("invalid auth-access: %s, topic pattern %s invalid", accessLine, topic)
|
||||
}
|
||||
permission, err := user.ParsePermission(strings.TrimSpace(parts[2]))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid auth-access: %s, permission %s invalid, %s", accessLine, parts[2], err.Error())
|
||||
}
|
||||
if _, exists := access[username]; !exists {
|
||||
access[username] = make([]*user.Grant, 0)
|
||||
}
|
||||
access[username] = append(access[username], &user.Grant{
|
||||
TopicPattern: topic,
|
||||
Permission: permission,
|
||||
Provisioned: true,
|
||||
})
|
||||
}
|
||||
return access, nil
|
||||
}
|
||||
|
||||
func parseTokens(users []*user.User, tokensRaw []string) (map[string][]*user.Token, error) {
|
||||
tokens := make(map[string][]*user.Token)
|
||||
for _, tokenLine := range tokensRaw {
|
||||
parts := strings.Split(tokenLine, ":")
|
||||
if len(parts) < 2 || len(parts) > 3 {
|
||||
return nil, fmt.Errorf("invalid auth-tokens: %s, expected format: 'user:token[:label]'", tokenLine)
|
||||
}
|
||||
username := strings.TrimSpace(parts[0])
|
||||
_, exists := util.Find(users, func(u *user.User) bool {
|
||||
return u.Name == username
|
||||
})
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("invalid auth-tokens: %s, user %s is not provisioned", tokenLine, username)
|
||||
} else if !user.AllowedUsername(username) {
|
||||
return nil, fmt.Errorf("invalid auth-tokens: %s, username %s invalid", tokenLine, username)
|
||||
}
|
||||
token := strings.TrimSpace(parts[1])
|
||||
if !user.ValidToken(token) {
|
||||
return nil, fmt.Errorf("invalid auth-tokens: %s, token %s invalid, use 'ntfy token generate' to generate a random token", tokenLine, token)
|
||||
}
|
||||
var label string
|
||||
if len(parts) > 2 {
|
||||
label = parts[2]
|
||||
}
|
||||
if _, exists := tokens[username]; !exists {
|
||||
tokens[username] = make([]*user.Token, 0)
|
||||
}
|
||||
tokens[username] = append(tokens[username], &user.Token{
|
||||
Value: token,
|
||||
Label: label,
|
||||
Provisioned: true,
|
||||
})
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
func maybeFromMetadata(m map[string]any, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
v, exists := m[key]
|
||||
if !exists {
|
||||
return ""
|
||||
}
|
||||
log.ResetLevelOverrides()
|
||||
if err := applyLogLevelOverrides(overrides); err != nil {
|
||||
return fmt.Errorf("cannot load log level overrides (2): %s", err.Error())
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
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 s
|
||||
}
|
||||
|
||||
// wildcardAddr reports whether the given listen address binds all interfaces (e.g. ":2587",
|
||||
// "0.0.0.0:2587", "[::]:2587"), in which case peers cannot derive a reachable URL from it.
|
||||
func wildcardAddr(addr string) bool {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return true // Unparseable -> cannot derive a URL either
|
||||
}
|
||||
return nil
|
||||
return host == "" || host == "0.0.0.0" || host == "::"
|
||||
}
|
||||
|
||||
@@ -14,9 +14,461 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/client"
|
||||
"heckel.io/ntfy/v2/test"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
func TestParseUsers_Success(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []string
|
||||
expected []*user.User
|
||||
}{
|
||||
{
|
||||
name: "single user",
|
||||
input: []string{"alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"},
|
||||
expected: []*user.User{
|
||||
{
|
||||
Name: "alice",
|
||||
Hash: "$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S",
|
||||
Role: user.RoleUser,
|
||||
Provisioned: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple users with different roles",
|
||||
input: []string{
|
||||
"alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user",
|
||||
"bob:$2a$10$jIcuBWcbxd6oW1aPvoJ5iOShzu3/UJ2kSxKbTZtDypG06nBflQagq:admin",
|
||||
},
|
||||
expected: []*user.User{
|
||||
{
|
||||
Name: "alice",
|
||||
Hash: "$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S",
|
||||
Role: user.RoleUser,
|
||||
Provisioned: true,
|
||||
},
|
||||
{
|
||||
Name: "bob",
|
||||
Hash: "$2a$10$jIcuBWcbxd6oW1aPvoJ5iOShzu3/UJ2kSxKbTZtDypG06nBflQagq",
|
||||
Role: user.RoleAdmin,
|
||||
Provisioned: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
input: []string{},
|
||||
expected: []*user.User{},
|
||||
},
|
||||
{
|
||||
name: "user with special characters in name",
|
||||
input: []string{"alice.test+123@example.com:$2a$10$RYUYAsl5zOnAIp6fH7BPX.Eug0rUfEUk92r8WiVusb0VK.vGojWBe:user"},
|
||||
expected: []*user.User{
|
||||
{
|
||||
Name: "alice.test+123@example.com",
|
||||
Hash: "$2a$10$RYUYAsl5zOnAIp6fH7BPX.Eug0rUfEUk92r8WiVusb0VK.vGojWBe",
|
||||
Role: user.RoleUser,
|
||||
Provisioned: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := parseUsers(tt.input)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result, len(tt.expected))
|
||||
|
||||
for i, expectedUser := range tt.expected {
|
||||
assert.Equal(t, expectedUser.Name, result[i].Name)
|
||||
assert.Equal(t, expectedUser.Hash, result[i].Hash)
|
||||
assert.Equal(t, expectedUser.Role, result[i].Role)
|
||||
assert.Equal(t, expectedUser.Provisioned, result[i].Provisioned)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUsers_Errors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []string
|
||||
error string
|
||||
}{
|
||||
{
|
||||
name: "invalid format - too few parts",
|
||||
input: []string{"alice:hash"},
|
||||
error: "invalid auth-users: alice:hash, expected format: 'name:hash:role'",
|
||||
},
|
||||
{
|
||||
name: "invalid format - too many parts",
|
||||
input: []string{"alice:hash:role:extra"},
|
||||
error: "invalid auth-users: alice:hash:role:extra, expected format: 'name:hash:role'",
|
||||
},
|
||||
{
|
||||
name: "invalid username",
|
||||
input: []string{"alice@#$%:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"},
|
||||
error: "invalid auth-users: alice@#$%:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user, username invalid",
|
||||
},
|
||||
{
|
||||
name: "invalid password hash - wrong prefix",
|
||||
input: []string{"alice:plaintext:user"},
|
||||
error: "invalid auth-users: alice:plaintext:user, password hash invalid, password hash must be a bcrypt hash, use 'ntfy user hash' to generate",
|
||||
},
|
||||
{
|
||||
name: "invalid role",
|
||||
input: []string{"alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:invalid"},
|
||||
error: "invalid auth-users: alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:invalid, role invalid is not allowed, allowed roles are 'admin' or 'user'",
|
||||
},
|
||||
{
|
||||
name: "empty username",
|
||||
input: []string{":$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"},
|
||||
error: "invalid auth-users: :$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user, username invalid",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := parseUsers(tt.input)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), tt.error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAccess_Success(t *testing.T) {
|
||||
users := []*user.User{
|
||||
{Name: "alice", Role: user.RoleUser},
|
||||
{Name: "bob", Role: user.RoleUser},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
users []*user.User
|
||||
input []string
|
||||
expected map[string][]*user.Grant
|
||||
}{
|
||||
{
|
||||
name: "single access entry",
|
||||
users: users,
|
||||
input: []string{"alice:mytopic:read-write"},
|
||||
expected: map[string][]*user.Grant{
|
||||
"alice": {
|
||||
{
|
||||
TopicPattern: "mytopic",
|
||||
Permission: user.PermissionReadWrite,
|
||||
Provisioned: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple access entries for same user",
|
||||
users: users,
|
||||
input: []string{
|
||||
"alice:topic1:read-only",
|
||||
"alice:topic2:write-only",
|
||||
},
|
||||
expected: map[string][]*user.Grant{
|
||||
"alice": {
|
||||
{
|
||||
TopicPattern: "topic1",
|
||||
Permission: user.PermissionRead,
|
||||
Provisioned: true,
|
||||
},
|
||||
{
|
||||
TopicPattern: "topic2",
|
||||
Permission: user.PermissionWrite,
|
||||
Provisioned: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "access for everyone",
|
||||
users: users,
|
||||
input: []string{"everyone:publictopic:read-only"},
|
||||
expected: map[string][]*user.Grant{
|
||||
user.Everyone: {
|
||||
{
|
||||
TopicPattern: "publictopic",
|
||||
Permission: user.PermissionRead,
|
||||
Provisioned: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wildcard topic pattern",
|
||||
users: users,
|
||||
input: []string{"alice:topic*:read-write"},
|
||||
expected: map[string][]*user.Grant{
|
||||
"alice": {
|
||||
{
|
||||
TopicPattern: "topic*",
|
||||
Permission: user.PermissionReadWrite,
|
||||
Provisioned: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
users: users,
|
||||
input: []string{},
|
||||
expected: map[string][]*user.Grant{},
|
||||
},
|
||||
{
|
||||
name: "deny-all permission",
|
||||
users: users,
|
||||
input: []string{"alice:secretopic:deny-all"},
|
||||
expected: map[string][]*user.Grant{
|
||||
"alice": {
|
||||
{
|
||||
TopicPattern: "secretopic",
|
||||
Permission: user.PermissionDenyAll,
|
||||
Provisioned: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := parseAccess(tt.users, tt.input)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAccess_Errors(t *testing.T) {
|
||||
users := []*user.User{
|
||||
{Name: "alice", Role: user.RoleUser},
|
||||
{Name: "admin", Role: user.RoleAdmin},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
users []*user.User
|
||||
input []string
|
||||
error string
|
||||
}{
|
||||
{
|
||||
name: "invalid format - too few parts",
|
||||
users: users,
|
||||
input: []string{"alice:topic"},
|
||||
error: "invalid auth-access: alice:topic, expected format: 'user:topic:permission'",
|
||||
},
|
||||
{
|
||||
name: "invalid format - too many parts",
|
||||
users: users,
|
||||
input: []string{"alice:topic:read:extra"},
|
||||
error: "invalid auth-access: alice:topic:read:extra, expected format: 'user:topic:permission'",
|
||||
},
|
||||
{
|
||||
name: "user not provisioned",
|
||||
users: users,
|
||||
input: []string{"charlie:topic:read"},
|
||||
error: "invalid auth-access: charlie:topic:read, user charlie is not provisioned",
|
||||
},
|
||||
{
|
||||
name: "admin user cannot have ACL entries",
|
||||
users: users,
|
||||
input: []string{"admin:topic:read"},
|
||||
error: "invalid auth-access: admin:topic:read, user admin is not a regular user, only regular users can have ACL entries",
|
||||
},
|
||||
{
|
||||
name: "invalid topic pattern",
|
||||
users: users,
|
||||
input: []string{"alice:topic-with-invalid-chars!:read"},
|
||||
error: "invalid auth-access: alice:topic-with-invalid-chars!:read, topic pattern topic-with-invalid-chars! invalid",
|
||||
},
|
||||
{
|
||||
name: "invalid permission",
|
||||
users: users,
|
||||
input: []string{"alice:topic:invalid-permission"},
|
||||
error: "invalid auth-access: alice:topic:invalid-permission, permission invalid-permission invalid",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := parseAccess(tt.users, tt.input)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), tt.error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTokens_Success(t *testing.T) {
|
||||
users := []*user.User{
|
||||
{Name: "alice"},
|
||||
{Name: "bob"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
users []*user.User
|
||||
input []string
|
||||
expected map[string][]*user.Token
|
||||
}{
|
||||
{
|
||||
name: "single token without label",
|
||||
users: users,
|
||||
input: []string{"alice:tk_abcdefghijklmnopqrstuvwxyz123"},
|
||||
expected: map[string][]*user.Token{
|
||||
"alice": {
|
||||
{
|
||||
Value: "tk_abcdefghijklmnopqrstuvwxyz123",
|
||||
Label: "",
|
||||
Provisioned: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "single token with label",
|
||||
users: users,
|
||||
input: []string{"alice:tk_abcdefghijklmnopqrstuvwxyz123:My Phone"},
|
||||
expected: map[string][]*user.Token{
|
||||
"alice": {
|
||||
{
|
||||
Value: "tk_abcdefghijklmnopqrstuvwxyz123",
|
||||
Label: "My Phone",
|
||||
Provisioned: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple tokens for same user",
|
||||
users: users,
|
||||
input: []string{
|
||||
"alice:tk_abcdefghijklmnopqrstuvwxyz123:Phone",
|
||||
"alice:tk_zyxwvutsrqponmlkjihgfedcba987:Laptop",
|
||||
},
|
||||
expected: map[string][]*user.Token{
|
||||
"alice": {
|
||||
{
|
||||
Value: "tk_abcdefghijklmnopqrstuvwxyz123",
|
||||
Label: "Phone",
|
||||
Provisioned: true,
|
||||
},
|
||||
{
|
||||
Value: "tk_zyxwvutsrqponmlkjihgfedcba987",
|
||||
Label: "Laptop",
|
||||
Provisioned: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tokens for multiple users",
|
||||
users: users,
|
||||
input: []string{
|
||||
"alice:tk_abcdefghijklmnopqrstuvwxyz123:Phone",
|
||||
"bob:tk_zyxwvutsrqponmlkjihgfedcba987:Tablet",
|
||||
},
|
||||
expected: map[string][]*user.Token{
|
||||
"alice": {
|
||||
{
|
||||
Value: "tk_abcdefghijklmnopqrstuvwxyz123",
|
||||
Label: "Phone",
|
||||
Provisioned: true,
|
||||
},
|
||||
},
|
||||
"bob": {
|
||||
{
|
||||
Value: "tk_zyxwvutsrqponmlkjihgfedcba987",
|
||||
Label: "Tablet",
|
||||
Provisioned: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
users: users,
|
||||
input: []string{},
|
||||
expected: map[string][]*user.Token{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := parseTokens(tt.users, tt.input)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTokens_Errors(t *testing.T) {
|
||||
users := []*user.User{
|
||||
{Name: "alice"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
users []*user.User
|
||||
input []string
|
||||
error string
|
||||
}{
|
||||
{
|
||||
name: "invalid format - too few parts",
|
||||
users: users,
|
||||
input: []string{"alice"},
|
||||
error: "invalid auth-tokens: alice, expected format: 'user:token[:label]'",
|
||||
},
|
||||
{
|
||||
name: "invalid format - too many parts",
|
||||
users: users,
|
||||
input: []string{"alice:token:label:extra:parts"},
|
||||
error: "invalid auth-tokens: alice:token:label:extra:parts, expected format: 'user:token[:label]'",
|
||||
},
|
||||
{
|
||||
name: "user not provisioned",
|
||||
users: users,
|
||||
input: []string{"charlie:tk_abcdefghijklmnopqrstuvwxyz123"},
|
||||
error: "invalid auth-tokens: charlie:tk_abcdefghijklmnopqrstuvwxyz123, user charlie is not provisioned",
|
||||
},
|
||||
{
|
||||
name: "invalid token format",
|
||||
users: users,
|
||||
input: []string{"alice:invalid-token"},
|
||||
error: "invalid auth-tokens: alice:invalid-token, token invalid-token invalid, use 'ntfy token generate' to generate a random token",
|
||||
},
|
||||
{
|
||||
name: "token too short",
|
||||
users: users,
|
||||
input: []string{"alice:tk_short"},
|
||||
error: "invalid auth-tokens: alice:tk_short, token tk_short invalid, use 'ntfy token generate' to generate a random token",
|
||||
},
|
||||
{
|
||||
name: "token without prefix",
|
||||
users: users,
|
||||
input: []string{"alice:abcdefghijklmnopqrstuvwxyz12345"},
|
||||
error: "invalid auth-tokens: alice:abcdefghijklmnopqrstuvwxyz12345, token abcdefghijklmnopqrstuvwxyz12345 invalid, use 'ntfy token generate' to generate a random token",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := parseTokens(tt.users, tt.input)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), tt.error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLI_Serve_Unix_Curl(t *testing.T) {
|
||||
sockFile := filepath.Join(t.TempDir(), "ntfy.sock")
|
||||
configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system
|
||||
@@ -84,6 +536,41 @@ func TestIP_Host_Parsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLI_Serve_ClusterValidation(t *testing.T) {
|
||||
configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system
|
||||
// Setting cluster-listen implicitly enables clustering, which requires database-url; all
|
||||
// validation must fail before any database connection is attempted
|
||||
app, _, _, _ := newTestApp()
|
||||
err := app.Run([]string{"ntfy", "serve", "--config=" + configFile, "--cluster-listen=127.0.0.1:2587"})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "database-url")
|
||||
// cluster-listen requires cluster-secret
|
||||
app, _, _, _ = newTestApp()
|
||||
err = app.Run([]string{"ntfy", "serve", "--config=" + configFile, "--cluster-listen=127.0.0.1:2587", "--database-url=postgres://user:pass@localhost:1/na"})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "cluster-secret")
|
||||
// cluster-listen requires an explicit stable node ID
|
||||
app, _, _, _ = newTestApp()
|
||||
err = app.Run([]string{"ntfy", "serve", "--config=" + configFile, "--cluster-listen=127.0.0.1:2587", "--database-url=postgres://user:pass@localhost:1/na", "--cluster-secret=s3cret"})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "cluster-node-id")
|
||||
// cluster-secret without cluster-listen is a config error (clustering would silently be off)
|
||||
app, _, _, _ = newTestApp()
|
||||
err = app.Run([]string{"ntfy", "serve", "--config=" + configFile, "--cluster-secret=s3cret"})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "cluster-listen")
|
||||
// A wildcard cluster-listen bind cannot derive an advertise URL
|
||||
app, _, _, _ = newTestApp()
|
||||
err = app.Run([]string{"ntfy", "serve", "--config=" + configFile, "--cluster-listen=:2587", "--database-url=postgres://user:pass@localhost:1/na", "--cluster-secret=s3cret", "--cluster-node-id=node-a"})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "cluster-advertise-url")
|
||||
// cluster-batch-linger must not be negative
|
||||
app, _, _, _ = newTestApp()
|
||||
err = app.Run([]string{"ntfy", "serve", "--config=" + configFile, "--cluster-batch-linger=-1s"})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "cluster batch linger")
|
||||
}
|
||||
|
||||
func newEmptyFile(t *testing.T) string {
|
||||
filename := filepath.Join(t.TempDir(), "empty")
|
||||
require.Nil(t, os.WriteFile(filename, []byte{}, 0600))
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//go:build (darwin || linux || dragonfly || freebsd || netbsd || openbsd) && !noserver
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/server"
|
||||
)
|
||||
|
||||
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 reloadLogLevel(inputSource altsrc.InputSourceContext) error {
|
||||
newLevelStr, err := inputSource.String("log-level")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
overrides, err := inputSource.StringSlice("log-level-overrides")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.ResetLevelOverrides()
|
||||
if err := applyLogLevelOverrides(overrides); err != nil {
|
||||
return err
|
||||
}
|
||||
log.SetLevel(log.ToLevel(newLevelStr))
|
||||
if len(overrides) > 0 {
|
||||
log.Info("Log level is %v, %d override(s) in place", newLevelStr, len(overrides))
|
||||
} else {
|
||||
log.Info("Log level is %v", newLevelStr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func maybeRunAsService(conf *server.Config) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//go:build windows && !noserver
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/sys/windows/svc"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/server"
|
||||
)
|
||||
|
||||
const serviceName = "ntfy"
|
||||
|
||||
// sigHandlerConfigReload is a no-op on Windows since SIGHUP is not available.
|
||||
// Windows users can restart the service to reload configuration.
|
||||
func sigHandlerConfigReload(config string) {
|
||||
log.Debug("Config hot-reload via SIGHUP is not supported on Windows")
|
||||
}
|
||||
|
||||
// runAsWindowsService runs the ntfy server as a Windows service
|
||||
func runAsWindowsService(conf *server.Config) error {
|
||||
return svc.Run(serviceName, &windowsService{conf: conf})
|
||||
}
|
||||
|
||||
// windowsService implements the svc.Handler interface
|
||||
type windowsService struct {
|
||||
conf *server.Config
|
||||
server *server.Server
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Execute is the main entry point for the Windows service
|
||||
func (s *windowsService) Execute(args []string, requests <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) {
|
||||
const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown
|
||||
status <- svc.Status{State: svc.StartPending}
|
||||
|
||||
// Create and start the server
|
||||
var err error
|
||||
s.mu.Lock()
|
||||
s.server, err = server.New(s.conf)
|
||||
s.mu.Unlock()
|
||||
if err != nil {
|
||||
log.Error("Failed to create server: %s", err.Error())
|
||||
return true, 1
|
||||
}
|
||||
|
||||
// Start server in a goroutine
|
||||
serverErrChan := make(chan error, 1)
|
||||
go func() {
|
||||
serverErrChan <- s.server.Run()
|
||||
}()
|
||||
|
||||
status <- svc.Status{State: svc.Running, Accepts: cmdsAccepted}
|
||||
log.Info("Windows service started")
|
||||
|
||||
for {
|
||||
select {
|
||||
case err := <-serverErrChan:
|
||||
if err != nil {
|
||||
log.Error("Server error: %s", err.Error())
|
||||
return true, 1
|
||||
}
|
||||
return false, 0
|
||||
case req := <-requests:
|
||||
switch req.Cmd {
|
||||
case svc.Interrogate:
|
||||
status <- req.CurrentStatus
|
||||
case svc.Stop, svc.Shutdown:
|
||||
log.Info("Windows service stopping...")
|
||||
status <- svc.Status{State: svc.StopPending}
|
||||
s.mu.Lock()
|
||||
if s.server != nil {
|
||||
s.server.Stop()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return false, 0
|
||||
default:
|
||||
log.Warn("Unexpected service control request: %d", req.Cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// maybeRunAsService checks if the process is running as a Windows service,
|
||||
// and if so, runs the server as a service. Returns true if it ran as a service.
|
||||
func maybeRunAsService(conf *server.Config) (bool, error) {
|
||||
isService, err := svc.IsWindowsService()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to detect Windows service mode: %w", err)
|
||||
} else if !isService {
|
||||
return false, nil
|
||||
}
|
||||
log.Info("Running as Windows service")
|
||||
if err := runAsWindowsService(conf); err != nil {
|
||||
return true, fmt.Errorf("failed to run as Windows service: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -3,28 +3,21 @@ package cmd
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/v2/client"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"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"},
|
||||
@@ -310,45 +303,16 @@ func loadConfig(c *cli.Context) (*client.Config, error) {
|
||||
if filename != "" {
|
||||
return client.LoadConfig(filename)
|
||||
}
|
||||
configFile, err := defaultClientConfigFile()
|
||||
if err != nil {
|
||||
log.Warn("Could not determine default client config file: %s", err.Error())
|
||||
} else {
|
||||
if s, _ := os.Stat(configFile); s != nil {
|
||||
return client.LoadConfig(configFile)
|
||||
if client.DefaultConfigFile != "" {
|
||||
if s, _ := os.Stat(client.DefaultConfigFile); s != nil {
|
||||
return client.LoadConfig(client.DefaultConfigFile)
|
||||
}
|
||||
log.Debug("Config file %s not found", configFile)
|
||||
log.Debug("Config file %s not found", client.DefaultConfigFile)
|
||||
}
|
||||
log.Debug("Loading default config")
|
||||
return client.NewConfig(), nil
|
||||
}
|
||||
|
||||
//lint:ignore U1000 Conditionally used in different builds
|
||||
func defaultClientConfigFileUnix() (string, error) {
|
||||
u, err := user.Current()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not determine current user: %w", err)
|
||||
}
|
||||
configFile := clientRootConfigFileUnixAbsolute
|
||||
if u.Uid != "0" {
|
||||
homeDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not determine user config dir: %w", err)
|
||||
}
|
||||
return filepath.Join(homeDir, clientUserConfigFileUnixRelative), nil
|
||||
}
|
||||
return configFile, nil
|
||||
}
|
||||
|
||||
//lint:ignore U1000 Conditionally used in different builds
|
||||
func defaultClientConfigFileWindows() (string, error) {
|
||||
homeDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not determine user config dir: %w", err)
|
||||
}
|
||||
return filepath.Join(homeDir, clientUserConfigFileWindowsRelative), nil
|
||||
}
|
||||
|
||||
func logMessagePrefix(m *client.Message) string {
|
||||
return fmt.Sprintf("%s/%s", util.ShortTopicURL(m.TopicURL), m.ID)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build darwin
|
||||
|
||||
package cmd
|
||||
|
||||
const (
|
||||
@@ -10,7 +12,3 @@ or "~/Library/Application Support/ntfy/client.yml" for all other users.`
|
||||
var (
|
||||
scriptLauncher = []string{"sh", "-c"}
|
||||
)
|
||||
|
||||
func defaultClientConfigFile() (string, error) {
|
||||
return defaultClientConfigFileUnix()
|
||||
}
|
||||
|
||||
@@ -12,7 +12,3 @@ or ~/.config/ntfy/client.yml for all other users.`
|
||||
var (
|
||||
scriptLauncher = []string{"sh", "-c"}
|
||||
)
|
||||
|
||||
func defaultClientConfigFile() (string, error) {
|
||||
return defaultClientConfigFileUnix()
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows
|
||||
|
||||
package cmd
|
||||
|
||||
const (
|
||||
@@ -9,7 +11,3 @@ const (
|
||||
var (
|
||||
scriptLauncher = []string{"cmd.exe", "/Q", "/C"}
|
||||
)
|
||||
|
||||
func defaultClientConfigFile() (string, error) {
|
||||
return defaultClientConfigFileWindows()
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ func execTierAdd(c *cli.Context) error {
|
||||
}
|
||||
if tier, _ := manager.Tier(code); tier != nil {
|
||||
if c.Bool("ignore-exists") {
|
||||
fmt.Fprintf(c.App.ErrWriter, "tier %s already exists (exited successfully)\n", code)
|
||||
fmt.Fprintf(c.App.Writer, "tier %s already exists (exited successfully)\n", code)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("tier %s already exists", code)
|
||||
@@ -234,7 +234,7 @@ func execTierAdd(c *cli.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "tier added\n\n")
|
||||
fmt.Fprintf(c.App.Writer, "tier added\n\n")
|
||||
printTier(c, tier)
|
||||
return nil
|
||||
}
|
||||
@@ -315,7 +315,7 @@ func execTierChange(c *cli.Context) error {
|
||||
if err := manager.UpdateTier(tier); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "tier updated\n\n")
|
||||
fmt.Fprintf(c.App.Writer, "tier updated\n\n")
|
||||
printTier(c, tier)
|
||||
return nil
|
||||
}
|
||||
@@ -335,7 +335,7 @@ func execTierDel(c *cli.Context) error {
|
||||
if err := manager.RemoveTier(code); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "tier %s removed\n", code)
|
||||
fmt.Fprintf(c.App.Writer, "tier %s removed\n", code)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -359,16 +359,16 @@ func printTier(c *cli.Context, tier *user.Tier) {
|
||||
if tier.StripeMonthlyPriceID != "" && tier.StripeYearlyPriceID != "" {
|
||||
prices = fmt.Sprintf("%s / %s", tier.StripeMonthlyPriceID, tier.StripeYearlyPriceID)
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "tier %s (id: %s)\n", tier.Code, tier.ID)
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Name: %s\n", tier.Name)
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Message limit: %d\n", tier.MessageLimit)
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Message expiry duration: %s (%d seconds)\n", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds()))
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Email limit: %d\n", tier.EmailLimit)
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Phone call limit: %d\n", tier.CallLimit)
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit)
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSizeHuman(tier.AttachmentFileSizeLimit))
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSizeHuman(tier.AttachmentTotalSizeLimit))
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment expiry duration: %s (%d seconds)\n", tier.AttachmentExpiryDuration.String(), int64(tier.AttachmentExpiryDuration.Seconds()))
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment daily bandwidth limit: %s\n", util.FormatSizeHuman(tier.AttachmentBandwidthLimit))
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Stripe prices (monthly/yearly): %s\n", prices)
|
||||
fmt.Fprintf(c.App.Writer, "tier %s (id: %s)\n", tier.Code, tier.ID)
|
||||
fmt.Fprintf(c.App.Writer, "- Name: %s\n", tier.Name)
|
||||
fmt.Fprintf(c.App.Writer, "- Message limit: %d\n", tier.MessageLimit)
|
||||
fmt.Fprintf(c.App.Writer, "- Message expiry duration: %s (%d seconds)\n", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds()))
|
||||
fmt.Fprintf(c.App.Writer, "- Email limit: %d\n", tier.EmailLimit)
|
||||
fmt.Fprintf(c.App.Writer, "- Phone call limit: %d\n", tier.CallLimit)
|
||||
fmt.Fprintf(c.App.Writer, "- Reservation limit: %d\n", tier.ReservationLimit)
|
||||
fmt.Fprintf(c.App.Writer, "- Attachment file size limit: %s\n", util.FormatSizeHuman(tier.AttachmentFileSizeLimit))
|
||||
fmt.Fprintf(c.App.Writer, "- Attachment total size limit: %s\n", util.FormatSizeHuman(tier.AttachmentTotalSizeLimit))
|
||||
fmt.Fprintf(c.App.Writer, "- Attachment expiry duration: %s (%d seconds)\n", tier.AttachmentExpiryDuration.String(), int64(tier.AttachmentExpiryDuration.Seconds()))
|
||||
fmt.Fprintf(c.App.Writer, "- Attachment daily bandwidth limit: %s\n", util.FormatSizeHuman(tier.AttachmentBandwidthLimit))
|
||||
fmt.Fprintf(c.App.Writer, "- Stripe prices (monthly/yearly): %s\n", prices)
|
||||
}
|
||||
|
||||
@@ -12,21 +12,21 @@ func TestCLI_Tier_AddListChangeDelete(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, _, _, stderr := newTestApp()
|
||||
app, _, stdout, _ := newTestApp()
|
||||
require.Nil(t, runTierCommand(app, conf, "add", "--name", "Pro", "--message-limit", "1234", "pro"))
|
||||
require.Contains(t, stderr.String(), "tier added\n\ntier pro (id: ti_")
|
||||
require.Contains(t, stdout.String(), "tier added\n\ntier pro (id: ti_")
|
||||
|
||||
err := runTierCommand(app, conf, "add", "pro")
|
||||
require.NotNil(t, err)
|
||||
require.Equal(t, "tier pro already exists", err.Error())
|
||||
|
||||
app, _, _, stderr = newTestApp()
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, runTierCommand(app, conf, "list"))
|
||||
require.Contains(t, stderr.String(), "tier pro (id: ti_")
|
||||
require.Contains(t, stderr.String(), "- Name: Pro")
|
||||
require.Contains(t, stderr.String(), "- Message limit: 1234")
|
||||
require.Contains(t, stdout.String(), "tier pro (id: ti_")
|
||||
require.Contains(t, stdout.String(), "- Name: Pro")
|
||||
require.Contains(t, stdout.String(), "- Message limit: 1234")
|
||||
|
||||
app, _, _, stderr = newTestApp()
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, runTierCommand(app, conf, "change",
|
||||
"--message-limit=999",
|
||||
"--message-expiry-duration=2d",
|
||||
@@ -40,18 +40,18 @@ func TestCLI_Tier_AddListChangeDelete(t *testing.T) {
|
||||
"--stripe-yearly-price-id=price_992",
|
||||
"pro",
|
||||
))
|
||||
require.Contains(t, stderr.String(), "- Message limit: 999")
|
||||
require.Contains(t, stderr.String(), "- Message expiry duration: 48h")
|
||||
require.Contains(t, stderr.String(), "- Email limit: 91")
|
||||
require.Contains(t, stderr.String(), "- Reservation limit: 98")
|
||||
require.Contains(t, stderr.String(), "- Attachment file size limit: 100.0 MB")
|
||||
require.Contains(t, stderr.String(), "- Attachment expiry duration: 24h")
|
||||
require.Contains(t, stderr.String(), "- Attachment total size limit: 10.0 GB")
|
||||
require.Contains(t, stderr.String(), "- Stripe prices (monthly/yearly): price_991 / price_992")
|
||||
require.Contains(t, stdout.String(), "- Message limit: 999")
|
||||
require.Contains(t, stdout.String(), "- Message expiry duration: 48h")
|
||||
require.Contains(t, stdout.String(), "- Email limit: 91")
|
||||
require.Contains(t, stdout.String(), "- Reservation limit: 98")
|
||||
require.Contains(t, stdout.String(), "- Attachment file size limit: 100.0 MB")
|
||||
require.Contains(t, stdout.String(), "- Attachment expiry duration: 24h")
|
||||
require.Contains(t, stdout.String(), "- Attachment total size limit: 10.0 GB")
|
||||
require.Contains(t, stdout.String(), "- Stripe prices (monthly/yearly): price_991 / price_992")
|
||||
|
||||
app, _, _, stderr = newTestApp()
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, runTierCommand(app, conf, "remove", "pro"))
|
||||
require.Contains(t, stderr.String(), "tier pro removed")
|
||||
require.Contains(t, stdout.String(), "tier pro removed")
|
||||
}
|
||||
|
||||
func runTierCommand(app *cli.App, conf *server.Config, args ...string) error {
|
||||
|
||||
@@ -72,6 +72,15 @@ Example:
|
||||
This is a server-only command. It directly reads from user.db as defined in the server config
|
||||
file server.yml. The command only works if 'auth-file' is properly defined.`,
|
||||
},
|
||||
{
|
||||
Name: "generate",
|
||||
Usage: "Generates a random token",
|
||||
Action: execTokenGenerate,
|
||||
Description: `Randomly generate a token to be used in provisioned tokens.
|
||||
|
||||
This command only generates the token value, but does not persist it anywhere.
|
||||
The output can be used in the 'auth-tokens' config option.`,
|
||||
},
|
||||
},
|
||||
Description: `Manage access tokens for individual users.
|
||||
|
||||
@@ -112,19 +121,19 @@ func execTokenAdd(c *cli.Context) error {
|
||||
return err
|
||||
}
|
||||
u, err := manager.User(username)
|
||||
if err == user.ErrUserNotFound {
|
||||
if errors.Is(err, user.ErrUserNotFound) {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
token, err := manager.CreateToken(u.ID, label, expires, netip.IPv4Unspecified())
|
||||
token, err := manager.CreateToken(u.ID, label, expires, netip.IPv4Unspecified(), false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if expires.Unix() == 0 {
|
||||
fmt.Fprintf(c.App.ErrWriter, "token %s created for user %s, never expires\n", token.Value, u.Name)
|
||||
fmt.Fprintf(c.App.Writer, "token %s created for user %s, never expires\n", token.Value, u.Name)
|
||||
} else {
|
||||
fmt.Fprintf(c.App.ErrWriter, "token %s created for user %s, expires %v\n", token.Value, u.Name, expires.Format(time.UnixDate))
|
||||
fmt.Fprintf(c.App.Writer, "token %s created for user %s, expires %v\n", token.Value, u.Name, expires.Format(time.UnixDate))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -141,7 +150,7 @@ func execTokenDel(c *cli.Context) error {
|
||||
return err
|
||||
}
|
||||
u, err := manager.User(username)
|
||||
if err == user.ErrUserNotFound {
|
||||
if errors.Is(err, user.ErrUserNotFound) {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
} else if err != nil {
|
||||
return err
|
||||
@@ -149,7 +158,7 @@ func execTokenDel(c *cli.Context) error {
|
||||
if err := manager.RemoveToken(u.ID, token); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "token %s for user %s removed\n", token, username)
|
||||
fmt.Fprintf(c.App.Writer, "token %s for user %s removed\n", token, username)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -165,7 +174,7 @@ func execTokenList(c *cli.Context) error {
|
||||
var users []*user.User
|
||||
if username != "" {
|
||||
u, err := manager.User(username)
|
||||
if err == user.ErrUserNotFound {
|
||||
if errors.Is(err, user.ErrUserNotFound) {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
} else if err != nil {
|
||||
return err
|
||||
@@ -183,15 +192,15 @@ func execTokenList(c *cli.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
} else if len(tokens) == 0 && username != "" {
|
||||
fmt.Fprintf(c.App.ErrWriter, "user %s has no access tokens\n", username)
|
||||
fmt.Fprintf(c.App.Writer, "user %s has no access tokens\n", username)
|
||||
return nil
|
||||
} else if len(tokens) == 0 {
|
||||
continue
|
||||
}
|
||||
usersWithTokens++
|
||||
fmt.Fprintf(c.App.ErrWriter, "user %s\n", u.Name)
|
||||
fmt.Fprintf(c.App.Writer, "user %s\n", u.Name)
|
||||
for _, t := range tokens {
|
||||
var label, expires string
|
||||
var label, expires, provisioned string
|
||||
if t.Label != "" {
|
||||
label = fmt.Sprintf(" (%s)", t.Label)
|
||||
}
|
||||
@@ -200,11 +209,19 @@ func execTokenList(c *cli.Context) error {
|
||||
} else {
|
||||
expires = fmt.Sprintf("expires %s", t.Expires.Format(time.RFC822))
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "- %s%s, %s, accessed from %s at %s\n", t.Value, label, expires, t.LastOrigin.String(), t.LastAccess.Format(time.RFC822))
|
||||
if t.Provisioned {
|
||||
provisioned = " (server config)"
|
||||
}
|
||||
fmt.Fprintf(c.App.Writer, "- %s%s, %s, accessed from %s at %s%s\n", t.Value, label, expires, t.LastOrigin.String(), t.LastAccess.Format(time.RFC822), provisioned)
|
||||
}
|
||||
}
|
||||
if usersWithTokens == 0 {
|
||||
fmt.Fprintf(c.App.ErrWriter, "no users with tokens\n")
|
||||
fmt.Fprintf(c.App.Writer, "no users with tokens\n")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func execTokenGenerate(c *cli.Context) error {
|
||||
fmt.Fprintln(c.App.Writer, user.GenerateToken())
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -14,28 +14,28 @@ func TestCLI_Token_AddListRemove(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
app, stdin, stdout, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||
require.Contains(t, stdout.String(), "user phil added with role user")
|
||||
|
||||
app, _, _, stderr = newTestApp()
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, runTokenCommand(app, conf, "add", "phil"))
|
||||
require.Regexp(t, `token tk_.+ created for user phil, never expires`, stderr.String())
|
||||
require.Regexp(t, `token tk_.+ created for user phil, never expires`, stdout.String())
|
||||
|
||||
app, _, _, stderr = newTestApp()
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, runTokenCommand(app, conf, "list", "phil"))
|
||||
require.Regexp(t, `user phil\n- tk_.+, never expires, accessed from 0.0.0.0 at .+`, stderr.String())
|
||||
require.Regexp(t, `user phil\n- tk_.+, never expires, accessed from 0.0.0.0 at .+`, stdout.String())
|
||||
re := regexp.MustCompile(`tk_\w+`)
|
||||
token := re.FindString(stderr.String())
|
||||
token := re.FindString(stdout.String())
|
||||
|
||||
app, _, _, stderr = newTestApp()
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, runTokenCommand(app, conf, "remove", "phil", token))
|
||||
require.Regexp(t, fmt.Sprintf("token %s for user phil removed", token), stderr.String())
|
||||
require.Regexp(t, fmt.Sprintf("token %s for user phil removed", token), stdout.String())
|
||||
|
||||
app, _, _, stderr = newTestApp()
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, runTokenCommand(app, conf, "list"))
|
||||
require.Equal(t, "no users with tokens\n", stderr.String())
|
||||
require.Equal(t, "no users with tokens\n", stdout.String())
|
||||
}
|
||||
|
||||
func runTokenCommand(app *cli.App, conf *server.Config, args ...string) error {
|
||||
|
||||
@@ -6,12 +6,17 @@ import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/db/pg"
|
||||
"heckel.io/ntfy/v2/mail"
|
||||
"heckel.io/ntfy/v2/server"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
@@ -25,15 +30,21 @@ func init() {
|
||||
|
||||
var flagsUser = append(
|
||||
append([]cli.Flag{}, flagsDefault...),
|
||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, Usage: "config file"},
|
||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: server.DefaultConfigFile, DefaultText: server.DefaultConfigFile, Usage: "config file"},
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "database-url", Aliases: []string{"database_url"}, EnvVars: []string{"NTFY_DATABASE_URL"}, Usage: "PostgreSQL connection string for database-backed stores"}),
|
||||
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: "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)"}),
|
||||
)
|
||||
|
||||
var cmdUser = &cli.Command{
|
||||
Name: "user",
|
||||
Usage: "Manage/show users",
|
||||
UsageText: "ntfy user [list|add|remove|change-pass|change-role] ...",
|
||||
UsageText: "ntfy user [list|add|remove|change-pass|reset-pass|change-role] ...",
|
||||
Flags: flagsUser,
|
||||
Before: initConfigFileInputSourceFunc("config", flagsUser, initLogFunc),
|
||||
Category: categoryServer,
|
||||
@@ -94,7 +105,30 @@ Example:
|
||||
|
||||
You may set the NTFY_PASSWORD environment variable to pass the new password or NTFY_PASSWORD_HASH to pass
|
||||
directly the bcrypt hash. This is useful if you are updating users via scripts.
|
||||
`,
|
||||
},
|
||||
{
|
||||
Name: "reset-pass",
|
||||
Aliases: []string{"rp"},
|
||||
Usage: "Generates a password reset link for a user",
|
||||
UsageText: "ntfy user reset-pass [--send-email] USERNAME",
|
||||
Action: execUserResetPass,
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{Name: "send-email", Aliases: []string{"e"}, Usage: "also email the reset link to the user's primary email"},
|
||||
},
|
||||
Description: `Generate a password reset link for the given user and print it to stdout.
|
||||
|
||||
The user completes the reset by opening the link in a browser and choosing a new password;
|
||||
the admin never learns or chooses the new password. The link is single-use and expires after
|
||||
one hour. This is an admin override of the self-service reset flow -- unlike self-service, it
|
||||
does not require the user to have a verified primary email (the token is bound to the user).
|
||||
|
||||
With --send-email, the link is additionally emailed to the user's primary email address (this
|
||||
requires SMTP to be configured and the user to have a verified primary email).
|
||||
|
||||
Example:
|
||||
ntfy user reset-pass phil # Print a reset link for user phil
|
||||
ntfy user reset-pass --send-email phil # Print and email the reset link
|
||||
`,
|
||||
},
|
||||
{
|
||||
@@ -133,6 +167,22 @@ as messages per day, attachment file sizes, etc.
|
||||
Example:
|
||||
ntfy user change-tier phil pro # Change tier to "pro" for user "phil"
|
||||
ntfy user change-tier phil - # Remove tier from user "phil" entirely
|
||||
`,
|
||||
},
|
||||
{
|
||||
Name: "hash",
|
||||
Usage: "Create password hash for a predefined user",
|
||||
UsageText: "ntfy user hash",
|
||||
Action: execUserHash,
|
||||
Description: `Asks for a password and creates a bcrypt password hash.
|
||||
|
||||
This command is useful to create a password hash for a user, which can then be used
|
||||
for predefined users in the server config file, in auth-users.
|
||||
|
||||
Example:
|
||||
$ ntfy user hash
|
||||
(asks for password and confirmation)
|
||||
$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C
|
||||
`,
|
||||
},
|
||||
{
|
||||
@@ -195,7 +245,7 @@ func execUserAdd(c *cli.Context) error {
|
||||
}
|
||||
if user, _ := manager.User(username); user != nil {
|
||||
if c.Bool("ignore-exists") {
|
||||
fmt.Fprintf(c.App.ErrWriter, "user %s already exists (exited successfully)\n", username)
|
||||
fmt.Fprintf(c.App.Writer, "user %s already exists (exited successfully)\n", username)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("user %s already exists", username)
|
||||
@@ -210,7 +260,7 @@ func execUserAdd(c *cli.Context) error {
|
||||
if err := manager.AddUser(username, password, role, hashed); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "user %s added with role %s\n", username, role)
|
||||
fmt.Fprintf(c.App.Writer, "user %s added with role %s\n", username, role)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -225,20 +275,19 @@ func execUserDel(c *cli.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := manager.User(username); err == user.ErrUserNotFound {
|
||||
if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
}
|
||||
if err := manager.RemoveUser(username); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "user %s removed\n", username)
|
||||
fmt.Fprintf(c.App.Writer, "user %s removed\n", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func execUserChangePass(c *cli.Context) error {
|
||||
username := c.Args().Get(0)
|
||||
password, hashed := os.LookupEnv("NTFY_PASSWORD_HASH")
|
||||
|
||||
if !hashed {
|
||||
password = os.Getenv("NTFY_PASSWORD")
|
||||
}
|
||||
@@ -251,7 +300,7 @@ func execUserChangePass(c *cli.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := manager.User(username); err == user.ErrUserNotFound {
|
||||
if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
}
|
||||
if password == "" {
|
||||
@@ -263,7 +312,62 @@ func execUserChangePass(c *cli.Context) error {
|
||||
if err := manager.ChangePassword(username, password, hashed); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "changed password for user %s\n", username)
|
||||
fmt.Fprintf(c.App.Writer, "changed password for user %s\n", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func execUserResetPass(c *cli.Context) error {
|
||||
username := c.Args().Get(0)
|
||||
sendEmail := c.Bool("send-email")
|
||||
baseURL := strings.TrimSuffix(c.String("base-url"), "/")
|
||||
if username == "" {
|
||||
return errors.New("username expected, type 'ntfy user reset-pass --help' for help")
|
||||
} else if username == userEveryone || username == user.Everyone {
|
||||
return errors.New("username not allowed")
|
||||
} else if baseURL == "" {
|
||||
return errors.New("base-url must be configured to generate a reset link")
|
||||
}
|
||||
manager, err := createUserManager(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u, err := manager.User(username)
|
||||
if errors.Is(err, user.ErrUserNotFound) {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
} else if err != nil {
|
||||
return err
|
||||
} else if u.Provisioned {
|
||||
return fmt.Errorf("user %s is provisioned in the config file; its password cannot be reset", username)
|
||||
}
|
||||
// Resolve the primary email up front if we need to send -- fail before creating a token
|
||||
var primaryEmail string
|
||||
if sendEmail {
|
||||
primaryEmail, err = manager.PrimaryEmail(u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if primaryEmail == "" {
|
||||
return fmt.Errorf("user %s has no primary email; cannot send reset link (omit --send-email to just print it)", username)
|
||||
}
|
||||
}
|
||||
// The reset token is bound to the user, not an email -- so this works even with no SMTP
|
||||
token, err := manager.AddMagicLink(user.MagicLinkKindPasswordReset, u.ID, "", time.Hour)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
link := baseURL + "/account/password/reset/" + token
|
||||
fmt.Fprintln(c.App.Writer, link)
|
||||
if sendEmail {
|
||||
sender := mail.NewSender(&mail.Config{
|
||||
SMTPAddr: c.String("smtp-sender-addr"),
|
||||
SMTPUser: c.String("smtp-sender-user"),
|
||||
SMTPPass: c.String("smtp-sender-pass"),
|
||||
From: c.String("smtp-sender-from"),
|
||||
})
|
||||
if err := sender.SendPasswordReset(primaryEmail, link); err != nil {
|
||||
return fmt.Errorf("failed to send reset email to %s: %w", primaryEmail, err)
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "reset link emailed to %s\n", primaryEmail)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -279,13 +383,26 @@ func execUserChangeRole(c *cli.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := manager.User(username); err == user.ErrUserNotFound {
|
||||
if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
}
|
||||
if err := manager.ChangeRole(username, role); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "changed role for user %s to %s\n", username, role)
|
||||
fmt.Fprintf(c.App.Writer, "changed role for user %s to %s\n", username, role)
|
||||
return nil
|
||||
}
|
||||
|
||||
func execUserHash(c *cli.Context) error {
|
||||
password, err := readPasswordAndConfirm(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hash, err := user.HashPassword(password, user.DefaultUserPasswordBcryptCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
fmt.Fprintln(c.App.Writer, hash)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -303,19 +420,19 @@ func execUserChangeTier(c *cli.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := manager.User(username); err == user.ErrUserNotFound {
|
||||
if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
}
|
||||
if tier == tierReset {
|
||||
if err := manager.ResetTier(username); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "removed tier from user %s\n", username)
|
||||
fmt.Fprintf(c.App.Writer, "removed tier from user %s\n", username)
|
||||
} else {
|
||||
if err := manager.ChangeTier(username, tier); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "changed tier for user %s to %s\n", username, tier)
|
||||
fmt.Fprintf(c.App.Writer, "changed tier for user %s to %s\n", username, tier)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -336,16 +453,31 @@ 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")
|
||||
}
|
||||
databaseURL := c.String("database-url")
|
||||
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)
|
||||
authConfig := &user.Config{
|
||||
DefaultAccess: authDefault,
|
||||
ProvisionEnabled: false, // Hack: Do not re-provision users on manager initialization
|
||||
BcryptCost: user.DefaultUserPasswordBcryptCost,
|
||||
QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval,
|
||||
AccessCacheEnabled: false, // Do not cache for CLI commands
|
||||
}
|
||||
if databaseURL != "" {
|
||||
host, dbErr := pg.Open(databaseURL)
|
||||
if dbErr != nil {
|
||||
return nil, dbErr
|
||||
}
|
||||
return user.NewPostgresManager(db.New(host, nil), authConfig)
|
||||
} else if authFile != "" {
|
||||
if !util.FileExists(authFile) {
|
||||
return nil, errors.New("auth-file does not exist; please start the server at least once to create it")
|
||||
}
|
||||
return user.NewSQLiteManager(authFile, authStartupQueries, authConfig)
|
||||
}
|
||||
return nil, errors.New("option database-url or auth-file not set; auth is unconfigured for this server")
|
||||
}
|
||||
|
||||
func readPasswordAndConfirm(c *cli.Context) (string, error) {
|
||||
|
||||
@@ -1,34 +1,35 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/v2/server"
|
||||
"heckel.io/ntfy/v2/test"
|
||||
"heckel.io/ntfy/v2/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()
|
||||
app, stdin, stdout, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||
require.Contains(t, stdout.String(), "user phil added with role user")
|
||||
}
|
||||
|
||||
func TestCLI_User_Add_Exists(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
app, stdin, stdout, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||
require.Contains(t, stdout.String(), "user phil added with role user")
|
||||
|
||||
app, stdin, _, _ = newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
@@ -41,10 +42,10 @@ func TestCLI_User_Add_Admin(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
app, stdin, stdout, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "--role=admin", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil added with role admin")
|
||||
require.Contains(t, stdout.String(), "user phil added with role admin")
|
||||
}
|
||||
|
||||
func TestCLI_User_Add_Password_Mismatch(t *testing.T) {
|
||||
@@ -60,19 +61,27 @@ func TestCLI_User_Add_Password_Mismatch(t *testing.T) {
|
||||
|
||||
func TestCLI_User_ChangePass(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
conf.AuthUsers = []*user.User{
|
||||
{Name: "philuser", Hash: "$2a$10$U4WSIYY6evyGmZaraavM2e2JeVG6EMGUKN1uUwufUeeRd4Jpg6cGC", Role: user.RoleUser}, // philuser:philpass
|
||||
}
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
// Add user
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
app, stdin, stdout, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||
require.Contains(t, stdout.String(), "user phil added with role user")
|
||||
|
||||
// Change pass
|
||||
app, stdin, _, stderr = newTestApp()
|
||||
app, stdin, stdout, _ = newTestApp()
|
||||
stdin.WriteString("newpass\nnewpass")
|
||||
require.Nil(t, runUserCommand(app, conf, "change-pass", "phil"))
|
||||
require.Contains(t, stderr.String(), "changed password for user phil")
|
||||
require.Contains(t, stdout.String(), "changed password for user phil")
|
||||
|
||||
// Cannot change provisioned user's pass
|
||||
app, stdin, _, _ = newTestApp()
|
||||
stdin.WriteString("newpass\nnewpass")
|
||||
require.Error(t, runUserCommand(app, conf, "change-pass", "philuser"))
|
||||
}
|
||||
|
||||
func TestCLI_User_ChangeRole(t *testing.T) {
|
||||
@@ -80,15 +89,15 @@ func TestCLI_User_ChangeRole(t *testing.T) {
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
// Add user
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
app, stdin, stdout, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||
require.Contains(t, stdout.String(), "user phil added with role user")
|
||||
|
||||
// Change role
|
||||
app, _, _, stderr = newTestApp()
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, runUserCommand(app, conf, "change-role", "phil", "admin"))
|
||||
require.Contains(t, stderr.String(), "changed role for user phil to admin")
|
||||
require.Contains(t, stdout.String(), "changed role for user phil to admin")
|
||||
}
|
||||
|
||||
func TestCLI_User_Delete(t *testing.T) {
|
||||
@@ -96,15 +105,15 @@ func TestCLI_User_Delete(t *testing.T) {
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
// Add user
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
app, stdin, stdout, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||
require.Contains(t, stdout.String(), "user phil added with role user")
|
||||
|
||||
// Delete user
|
||||
app, _, _, stderr = newTestApp()
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, runUserCommand(app, conf, "del", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil removed")
|
||||
require.Contains(t, stdout.String(), "user phil removed")
|
||||
|
||||
// Delete user again (does not exist)
|
||||
app, _, _, _ = newTestApp()
|
||||
@@ -113,6 +122,69 @@ func TestCLI_User_Delete(t *testing.T) {
|
||||
require.Contains(t, err.Error(), "user phil does not exist")
|
||||
}
|
||||
|
||||
func TestCLI_User_ResetPass(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
|
||||
// Prints a working-looking reset link when base-url is set
|
||||
app, _, stdout, _ := newTestApp()
|
||||
require.Nil(t, runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "reset-pass", "phil"))
|
||||
require.Contains(t, stdout.String(), "https://ntfy.example.com/account/password/reset/")
|
||||
}
|
||||
|
||||
func TestCLI_User_ResetPass_NoBaseURL(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
|
||||
app, _, _, _ = newTestApp()
|
||||
err := runUserCommand(app, conf, "reset-pass", "phil")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "base-url")
|
||||
}
|
||||
|
||||
func TestCLI_User_ResetPass_SendEmailNoPrimary(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
|
||||
// --send-email requires a primary email; phil has none
|
||||
app, _, _, _ = newTestApp()
|
||||
err := runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "reset-pass", "--send-email", "phil")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "no primary email")
|
||||
}
|
||||
|
||||
func TestCLI_User_ResetPass_ProvisionedRejected(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
// Seed a provisioned user into the auth database via config provisioning
|
||||
m, err := user.NewSQLiteManager(conf.AuthFile, "", &user.Config{
|
||||
ProvisionEnabled: true,
|
||||
Users: []*user.User{
|
||||
{Name: "provuser", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: user.RoleUser},
|
||||
},
|
||||
})
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, m.Close())
|
||||
|
||||
app, _, _, _ := newTestApp()
|
||||
err = runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "reset-pass", "provuser")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "provisioned")
|
||||
}
|
||||
|
||||
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
|
||||
@@ -120,6 +192,7 @@ func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config,
|
||||
conf.File = configFile
|
||||
conf.AuthFile = filepath.Join(t.TempDir(), "user.db")
|
||||
conf.AuthDefault = user.PermissionDenyAll
|
||||
conf.AuthAccessCacheEnabled = false
|
||||
s, port = test.StartServerWithConfig(t, conf)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !noserver
|
||||
//go:build !noserver && !nowebpush
|
||||
|
||||
package cmd
|
||||
|
||||
@@ -53,9 +53,9 @@ web-push-private-key: %s
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintf(c.App.ErrWriter, "Web Push keys written to %s.\n", outputFile)
|
||||
_, err = fmt.Fprintf(c.App.Writer, "Web Push keys written to %s.\n", outputFile)
|
||||
} else {
|
||||
_, err = fmt.Fprintf(c.App.ErrWriter, `Web Push keys generated. Add the following lines to your config file:
|
||||
_, err = fmt.Fprintf(c.App.Writer, `Web Push keys generated. Add the following lines to your config file:
|
||||
|
||||
web-push-public-key: %s
|
||||
web-push-private-key: %s
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -9,16 +10,18 @@ import (
|
||||
)
|
||||
|
||||
func TestCLI_WebPush_GenerateKeys(t *testing.T) {
|
||||
app, _, _, stderr := newTestApp()
|
||||
app, _, stdout, _ := newTestApp()
|
||||
require.Nil(t, runWebPushCommand(app, server.NewConfig(), "keys"))
|
||||
require.Contains(t, stderr.String(), "Web Push keys generated.")
|
||||
require.Contains(t, stdout.String(), "Web Push keys generated.")
|
||||
}
|
||||
|
||||
func TestCLI_WebPush_WriteKeysToFile(t *testing.T) {
|
||||
app, _, _, stderr := newTestApp()
|
||||
tempDir := t.TempDir()
|
||||
t.Chdir(tempDir)
|
||||
app, _, stdout, _ := newTestApp()
|
||||
require.Nil(t, runWebPushCommand(app, server.NewConfig(), "keys", "--output-file=key-file.yaml"))
|
||||
require.Contains(t, stderr.String(), "Web Push keys written to key-file.yaml")
|
||||
require.FileExists(t, "key-file.yaml")
|
||||
require.Contains(t, stdout.String(), "Web Push keys written to key-file.yaml")
|
||||
require.FileExists(t, filepath.Join(tempDir, "key-file.yaml"))
|
||||
}
|
||||
|
||||
func runWebPushCommand(app *cli.App, conf *server.Config, args ...string) error {
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
)
|
||||
|
||||
const (
|
||||
tag = "db"
|
||||
replicaHealthCheckInitialDelay = 5 * time.Second
|
||||
replicaHealthCheckInterval = 30 * time.Second
|
||||
replicaHealthCheckTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// DB wraps a primary *sql.DB and optional read replicas. All standard query/exec methods
|
||||
// delegate to the primary. The ReadOnly() method returns a *sql.DB from a healthy replica
|
||||
// (round-robin), falling back to the primary if no replicas are configured or all are unhealthy.
|
||||
type DB struct {
|
||||
primary *Host
|
||||
replicas []*Host
|
||||
counter atomic.Uint64
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// New creates a new DB that wraps the given primary and optional replica connections.
|
||||
// If replicas is nil or empty, ReadOnly() simply returns the primary.
|
||||
// Replicas start unhealthy and are checked immediately by a background goroutine.
|
||||
func New(primary *Host, replicas []*Host) *DB {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
d := &DB{
|
||||
primary: primary,
|
||||
replicas: replicas,
|
||||
cancel: cancel,
|
||||
}
|
||||
if len(d.replicas) > 0 {
|
||||
go d.healthCheckLoop(ctx)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// Query delegates to the primary database.
|
||||
func (d *DB) Query(query string, args ...any) (*sql.Rows, error) {
|
||||
return d.primary.DB.Query(query, args...)
|
||||
}
|
||||
|
||||
// QueryRow delegates to the primary database.
|
||||
func (d *DB) QueryRow(query string, args ...any) *sql.Row {
|
||||
return d.primary.DB.QueryRow(query, args...)
|
||||
}
|
||||
|
||||
// Exec delegates to the primary database.
|
||||
func (d *DB) Exec(query string, args ...any) (sql.Result, error) {
|
||||
return d.primary.DB.Exec(query, args...)
|
||||
}
|
||||
|
||||
// Begin delegates to the primary database.
|
||||
func (d *DB) Begin() (*sql.Tx, error) {
|
||||
return d.primary.DB.Begin()
|
||||
}
|
||||
|
||||
// Ping delegates to the primary database.
|
||||
func (d *DB) Ping() error {
|
||||
return d.primary.DB.Ping()
|
||||
}
|
||||
|
||||
// Primary returns the underlying primary *sql.DB. This is only intended for
|
||||
// one-time schema setup during store initialization, not for regular queries.
|
||||
func (d *DB) Primary() *sql.DB {
|
||||
return d.primary.DB
|
||||
}
|
||||
|
||||
// ReadOnly returns a *sql.DB suitable for read-only queries. It round-robins across healthy
|
||||
// replicas. If all replicas are unhealthy or none are configured, the primary is returned.
|
||||
func (d *DB) ReadOnly() *sql.DB {
|
||||
if len(d.replicas) == 0 {
|
||||
return d.primary.DB
|
||||
}
|
||||
n := len(d.replicas)
|
||||
start := int(d.counter.Add(1) - 1)
|
||||
for i := 0; i < n; i++ {
|
||||
r := d.replicas[(start+i)%n]
|
||||
if r.healthy.Load() {
|
||||
return r.DB
|
||||
}
|
||||
}
|
||||
return d.primary.DB
|
||||
}
|
||||
|
||||
// MarkReplicasHealthyForTest immediately marks all configured replicas as healthy, bypassing the
|
||||
// async health-check loop's initial delay. It exists so tests can deterministically route
|
||||
// ReadOnly() to a replica without waiting; it is not used in production code.
|
||||
func (d *DB) MarkReplicasHealthyForTest() {
|
||||
for _, r := range d.replicas {
|
||||
r.healthy.Store(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes the primary database and all replicas, and stops the health-check goroutine.
|
||||
func (d *DB) Close() error {
|
||||
d.cancel()
|
||||
for _, r := range d.replicas {
|
||||
r.DB.Close()
|
||||
}
|
||||
return d.primary.DB.Close()
|
||||
}
|
||||
|
||||
// healthCheckLoop checks replicas immediately, then periodically on a ticker.
|
||||
func (d *DB) healthCheckLoop(ctx context.Context) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(replicaHealthCheckInitialDelay):
|
||||
d.checkReplicas(ctx)
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(replicaHealthCheckInterval):
|
||||
d.checkReplicas(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkReplicas pings each replica with a timeout and updates its health status.
|
||||
func (d *DB) checkReplicas(ctx context.Context) {
|
||||
for _, r := range d.replicas {
|
||||
wasHealthy := r.healthy.Load()
|
||||
pingCtx, cancel := context.WithTimeout(ctx, replicaHealthCheckTimeout)
|
||||
err := r.DB.PingContext(pingCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
r.healthy.Store(false)
|
||||
log.Tag(tag).Error("Database replica %s is unhealthy: %s", r.Addr, err)
|
||||
} else {
|
||||
r.healthy.Store(true)
|
||||
if !wasHealthy {
|
||||
log.Tag(tag).Info("Database replica %s is healthy", r.Addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package pg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
)
|
||||
|
||||
const (
|
||||
tagLeader = "leader"
|
||||
|
||||
tryAdvisoryLockQuery = `SELECT pg_try_advisory_lock($1)`
|
||||
advisoryUnlockQuery = `SELECT pg_advisory_unlock($1)`
|
||||
|
||||
defaultRenewInterval = 5 * time.Second
|
||||
leaderMissedRenewals = 3
|
||||
leaderHoldoffFactor = 2
|
||||
)
|
||||
|
||||
// Leader implements singleton-job leader election via a Postgres advisory lock held on a
|
||||
// pinned connection. The lock auto-releases when the holding connection dies, so a crashed
|
||||
// leader is replaced without manual fencing; distinct keys elect independently. The Leader
|
||||
// renews its lease on its own loop; callers only ask IsLeader and eventually Close.
|
||||
//
|
||||
// Holding the lock is not the same as believing to be the leader: IsLeader also requires a
|
||||
// recent renewal (lease duration) and a completed hold-off after winning the lock. The
|
||||
// hold-off outlasts the lease duration by construction, so on failover the old belief always
|
||||
// expires before the new one begins: a short no-leader gap, never two leaders. Defaults:
|
||||
// renew every 5s, lease duration 15s, hold-off 30s -> up to ~35s without a leader.
|
||||
type Leader struct {
|
||||
db *sql.DB
|
||||
key int64
|
||||
renewInterval time.Duration
|
||||
conn *sql.Conn // holds the advisory lock while this process is leader
|
||||
acquiredAt time.Time // When the lock was won (this tenure), for the hold-off
|
||||
renewedAt time.Time // Last successful renewal, for the lease duration; zero = lock not held
|
||||
cancel context.CancelFunc // Stops the renew loop and aborts its in-flight query on Close
|
||||
closeOnce sync.Once
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex // Protects conn, acquiredAt and renewedAt
|
||||
}
|
||||
|
||||
// NewLeader creates a Leader competing for the lock identified by key and starts its renew
|
||||
// loop. renewInterval is for tests; pass 0 for the default.
|
||||
func NewLeader(db *sql.DB, key int64, renewInterval time.Duration) *Leader {
|
||||
if renewInterval <= 0 {
|
||||
renewInterval = defaultRenewInterval
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
l := &Leader{
|
||||
db: db,
|
||||
key: key,
|
||||
renewInterval: renewInterval,
|
||||
cancel: cancel,
|
||||
}
|
||||
l.wg.Add(1)
|
||||
go l.runAcquireOrRenewLoop(ctx)
|
||||
return l
|
||||
}
|
||||
|
||||
// IsLeader reports whether this process should act as the leader: lock held, lease renewed
|
||||
// recently, hold-off elapsed (see the Leader doc comment).
|
||||
func (l *Leader) IsLeader() bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
leaseDuration := leaderMissedRenewals * l.renewInterval
|
||||
holdoff := leaderHoldoffFactor * leaseDuration
|
||||
return time.Since(l.renewedAt) < leaseDuration && time.Since(l.acquiredAt) >= holdoff
|
||||
}
|
||||
|
||||
// Close stops competing for leadership and releases the lock. Idempotent.
|
||||
func (l *Leader) Close() {
|
||||
l.closeOnce.Do(func() {
|
||||
l.cancel() // Also aborts an in-flight renewal query
|
||||
l.wg.Wait()
|
||||
if l.IsLeader() {
|
||||
log.Tag(tagLeader).Info("Lost leadership: closed (lock key %d)", l.key)
|
||||
}
|
||||
l.release()
|
||||
})
|
||||
}
|
||||
|
||||
// runAcquireOrRenewLoop acquires or renews the lock every renewInterval until ctx is canceled
|
||||
func (l *Leader) runAcquireOrRenewLoop(ctx context.Context) {
|
||||
defer l.wg.Done()
|
||||
ticker := time.NewTicker(l.renewInterval)
|
||||
defer ticker.Stop()
|
||||
wasLeader := false
|
||||
for {
|
||||
attemptCtx, cancel := context.WithTimeout(ctx, l.renewInterval)
|
||||
l.tryAcquireOrRenew(attemptCtx)
|
||||
cancel()
|
||||
if isLeader := l.IsLeader(); isLeader != wasLeader {
|
||||
wasLeader = isLeader
|
||||
if isLeader {
|
||||
log.Tag(tagLeader).Info("Became leader (lock key %d)", l.key)
|
||||
} else {
|
||||
log.Tag(tagLeader).Info("Lost leadership (lock key %d)", l.key)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ticker.C:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tryAcquireOrRenew renews the lock on a healthy leader (a cheap ping) or retries acquiring
|
||||
// it on a follower, on a pinned connection.
|
||||
func (l *Leader) tryAcquireOrRenew(ctx context.Context) {
|
||||
l.mu.Lock()
|
||||
conn := l.conn
|
||||
l.mu.Unlock()
|
||||
if conn != nil {
|
||||
if conn.PingContext(ctx) == nil {
|
||||
// Still holding the lock, connection healthy: renew the lease
|
||||
l.mu.Lock()
|
||||
l.renewedAt = time.Now()
|
||||
l.mu.Unlock()
|
||||
log.Tag(tagLeader).Trace("Renewed leader lease (lock key %d)", l.key)
|
||||
return
|
||||
}
|
||||
log.Tag(tagLeader).Debug("Leader lock connection died, lock lost (lock key %d)", l.key)
|
||||
l.release() // Connection died; the lock is already gone, re-acquire below
|
||||
}
|
||||
newConn, err := l.db.Conn(ctx)
|
||||
if err != nil {
|
||||
log.Tag(tagLeader).Debug("Cannot get connection to compete for leader lock (lock key %d): %s", l.key, err.Error())
|
||||
return
|
||||
}
|
||||
var acquired bool
|
||||
if err := newConn.QueryRowContext(ctx, tryAdvisoryLockQuery, l.key).Scan(&acquired); err != nil || !acquired {
|
||||
newConn.Close()
|
||||
log.Tag(tagLeader).Trace("Leader lock held elsewhere (lock key %d)", l.key)
|
||||
return
|
||||
}
|
||||
log.Tag(tagLeader).Debug("Acquired leader lock (lock key %d); leadership after the hold-off", l.key)
|
||||
l.mu.Lock()
|
||||
l.conn = newConn
|
||||
l.acquiredAt = time.Now()
|
||||
l.renewedAt = l.acquiredAt
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// release unlocks the advisory lock and returns the pinned connection to the pool
|
||||
func (l *Leader) release() {
|
||||
l.mu.Lock()
|
||||
conn := l.conn
|
||||
l.conn = nil
|
||||
l.renewedAt = time.Time{} // Zero revokes belief; without it, IsLeader would linger a lease duration
|
||||
l.mu.Unlock()
|
||||
if conn != nil {
|
||||
// Unlock explicitly: sql.Conn.Close() returns the connection to the pool, so the
|
||||
// session-scoped lock would otherwise stay held
|
||||
conn.ExecContext(context.Background(), advisoryUnlockQuery, l.key)
|
||||
conn.Close()
|
||||
log.Tag(tagLeader).Debug("Released leader lock (lock key %d)", l.key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package pg
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The lease logic is pure time arithmetic, so it is unit-tested here without a database; the
|
||||
// external leader tests cover the loop end to end.
|
||||
|
||||
func TestLeader_Lease_HoldoffMeansNoLeaderRatherThanTwo(t *testing.T) {
|
||||
// Freshly acquired lock: belief must wait out the hold-off
|
||||
l := &Leader{renewInterval: 20 * time.Second} // Lease duration 1m, hold-off 2m
|
||||
l.acquiredAt = time.Now()
|
||||
l.renewedAt = l.acquiredAt
|
||||
require.False(t, l.IsLeader())
|
||||
// Once the hold-off has passed (and verification is fresh), belief begins
|
||||
l.acquiredAt = time.Now().Add(-3 * time.Minute)
|
||||
l.renewedAt = time.Now()
|
||||
require.True(t, l.IsLeader())
|
||||
}
|
||||
|
||||
func TestLeader_Lease_ExpiredLeaseRevokesLeadership(t *testing.T) {
|
||||
// A leader that cannot renew its lease (wedged process, long GC pause) must stop
|
||||
// believing once the lease expires, even though the lock may still be held
|
||||
l := &Leader{renewInterval: 20 * time.Second} // Lease duration 1m, hold-off 2m
|
||||
l.acquiredAt = time.Now().Add(-time.Hour)
|
||||
l.renewedAt = time.Now().Add(-2 * time.Minute) // Lease expired
|
||||
require.False(t, l.IsLeader())
|
||||
l.renewedAt = time.Now() // Fresh renewal restores belief
|
||||
require.True(t, l.IsLeader())
|
||||
}
|
||||
|
||||
func TestLeader_Lease_ReleasedIsNeverLeader(t *testing.T) {
|
||||
// release() zeroes renewedAt, which fails the lease check no matter how old the tenure
|
||||
l := &Leader{renewInterval: 20 * time.Second} // Lease duration 1m, hold-off 2m
|
||||
l.acquiredAt = time.Now().Add(-time.Hour)
|
||||
require.False(t, l.IsLeader())
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package pg_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/db/pg"
|
||||
dbtest "heckel.io/ntfy/v2/db/test"
|
||||
)
|
||||
|
||||
const testRenewInterval = 20 * time.Millisecond // Lease duration 60ms, hold-off 120ms
|
||||
|
||||
func TestLeader_AcquireAndFailover(t *testing.T) {
|
||||
testDB := dbtest.CreateTestPostgres(t) // skips if NTFY_TEST_DATABASE_URL is unset
|
||||
const key = int64(42)
|
||||
l1 := pg.NewLeader(testDB.Primary(), key, testRenewInterval)
|
||||
defer l1.Close()
|
||||
// Belief follows the hold-off, it is never instant
|
||||
require.False(t, l1.IsLeader())
|
||||
waitForLeader(t, l1)
|
||||
// A competitor never becomes leader while the leader lives
|
||||
l2 := pg.NewLeader(testDB.Primary(), key, testRenewInterval)
|
||||
defer l2.Close()
|
||||
time.Sleep(300 * time.Millisecond) // Several verification rounds
|
||||
require.False(t, l2.IsLeader())
|
||||
require.True(t, l1.IsLeader())
|
||||
// Close -> the follower takes over
|
||||
l1.Close()
|
||||
require.False(t, l1.IsLeader())
|
||||
waitForLeader(t, l2)
|
||||
require.False(t, l1.IsLeader())
|
||||
}
|
||||
|
||||
func TestLeader_ConnectionLossFailover(t *testing.T) {
|
||||
// A crashed leader must not wedge the cluster: Postgres releases the session-scoped lock
|
||||
// when the pinned connection dies (simulated by terminating the backend), and someone
|
||||
// re-acquires. Either node may win; the invariant is one leader eventually, never two.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
hostA, err := pg.Open(schemaDSN)
|
||||
require.Nil(t, err)
|
||||
defer hostA.DB.Close()
|
||||
hostB, err := pg.Open(schemaDSN)
|
||||
require.Nil(t, err)
|
||||
defer hostB.DB.Close()
|
||||
const key = int64(43)
|
||||
l1 := pg.NewLeader(hostA.DB, key, testRenewInterval)
|
||||
defer l1.Close()
|
||||
waitForLeader(t, l1)
|
||||
l2 := pg.NewLeader(hostB.DB, key, testRenewInterval)
|
||||
defer l2.Close()
|
||||
// Kill the backend holding the lock (advisory lock keys map to classid/objid)
|
||||
_, err = hostB.DB.Exec(`SELECT pg_terminate_backend(pid) FROM pg_locks WHERE locktype = 'advisory' AND objid = $1 AND granted`, key)
|
||||
require.Nil(t, err)
|
||||
// Eventually exactly one leader again, and never two along the way
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
leader1, leader2 := l1.IsLeader(), l2.IsLeader()
|
||||
require.False(t, leader1 && leader2, "two leaders at once")
|
||||
if leader1 != leader2 {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("no leader re-emerged after connection loss")
|
||||
}
|
||||
|
||||
func TestLeader_DistinctKeysAreIndependent(t *testing.T) {
|
||||
testDB := dbtest.CreateTestPostgres(t)
|
||||
l1 := pg.NewLeader(testDB.Primary(), 1, testRenewInterval)
|
||||
defer l1.Close()
|
||||
l2 := pg.NewLeader(testDB.Primary(), 2, testRenewInterval)
|
||||
defer l2.Close()
|
||||
// Different keys do not compete: both become effective leaders
|
||||
waitForLeader(t, l1)
|
||||
waitForLeader(t, l2)
|
||||
}
|
||||
|
||||
// waitForLeader waits until the node believes it is the leader, or fails the test
|
||||
func waitForLeader(t *testing.T, l *pg.Leader) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if l.IsLeader() {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("node never became effective leader")
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package pg
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver
|
||||
|
||||
"heckel.io/ntfy/v2/db"
|
||||
)
|
||||
|
||||
// Advisory lock keys. PostgreSQL advisory locks share one database-wide key space, so every
|
||||
// ntfy key is defined here, following the "ntfy"+2586+letter scheme
|
||||
const (
|
||||
SchemaLockKey = int64(0x6e7466792586a) // Schema setup serialization (transaction-scoped, see db/schema)
|
||||
LeaderLockKey = int64(0x6e7466792586b) // Cluster singleton-job leader (session-scoped, held for process lifetime)
|
||||
)
|
||||
|
||||
// Open opens a PostgreSQL connection pool for a primary database. It pings the database
|
||||
// to verify connectivity before returning.
|
||||
func Open(dsn string) (*db.Host, error) {
|
||||
d, err := open(dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
if err := d.DB.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("database ping failed on %v: %w", d.Addr, err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// OpenReplica opens a PostgreSQL connection pool for a read replica. Unlike Open, it does
|
||||
// not ping the database, since replicas are health-checked in the background by db.DB.
|
||||
func OpenReplica(dsn string) (*db.Host, error) {
|
||||
return open(dsn)
|
||||
}
|
||||
|
||||
// open opens a PostgreSQL database connection pool from a DSN string. It supports custom
|
||||
// query parameters for pool configuration: pool_max_conns (default 10), pool_max_idle_conns,
|
||||
// pool_conn_max_lifetime, and pool_conn_max_idle_time. These parameters are stripped from
|
||||
// the DSN before passing it to the driver.
|
||||
func open(dsn string) (*db.Host, error) {
|
||||
u, err := url.Parse(dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid database URL: %w", err)
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "postgres", "postgresql":
|
||||
// OK
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid database URL scheme %q, must be \"postgres\" or \"postgresql\" (URL: %s)", u.Scheme, censorPassword(u))
|
||||
}
|
||||
q := u.Query()
|
||||
maxOpenConns, err := extractIntParam(q, "pool_max_conns", 10)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
maxIdleConns, err := extractIntParam(q, "pool_max_idle_conns", 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
connMaxLifetime, err := extractDurationParam(q, "pool_conn_max_lifetime", 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
connMaxIdleTime, err := extractDurationParam(q, "pool_conn_max_idle_time", 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
d, err := sql.Open("pgx", u.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.SetMaxOpenConns(maxOpenConns)
|
||||
if maxIdleConns > 0 {
|
||||
d.SetMaxIdleConns(maxIdleConns)
|
||||
}
|
||||
if connMaxLifetime > 0 {
|
||||
d.SetConnMaxLifetime(connMaxLifetime)
|
||||
}
|
||||
if connMaxIdleTime > 0 {
|
||||
d.SetConnMaxIdleTime(connMaxIdleTime)
|
||||
}
|
||||
return &db.Host{
|
||||
Addr: u.Host,
|
||||
DB: d,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func extractIntParam(q url.Values, key string, defaultValue int) (int, error) {
|
||||
s := q.Get(key)
|
||||
if s == "" {
|
||||
return defaultValue, nil
|
||||
}
|
||||
q.Del(key)
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid %s value %q: %w", key, s, err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// censorPassword returns a string representation of the URL with the password replaced by "*****".
|
||||
func censorPassword(u *url.URL) string {
|
||||
if password, hasPassword := u.User.Password(); hasPassword {
|
||||
return strings.Replace(u.String(), ":"+password+"@", ":*****@", 1)
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func extractDurationParam(q url.Values, key string, defaultValue time.Duration) (time.Duration, error) {
|
||||
s := q.Get(key)
|
||||
if s == "" {
|
||||
return defaultValue, nil
|
||||
}
|
||||
q.Del(key)
|
||||
d, err := time.ParseDuration(s)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid %s value %q: %w", key, s, err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package pg
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOpen_InvalidScheme(t *testing.T) {
|
||||
_, err := Open("postgresql+psycopg2://user:pass@localhost/db")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), `invalid database URL scheme "postgresql+psycopg2"`)
|
||||
require.Contains(t, err.Error(), "*****")
|
||||
require.NotContains(t, err.Error(), "pass")
|
||||
}
|
||||
|
||||
func TestOpen_InvalidURL(t *testing.T) {
|
||||
_, err := Open("not a valid url\x00")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "invalid database URL")
|
||||
}
|
||||
|
||||
func TestCensorPassword(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "with password",
|
||||
url: "postgres://user:secret@localhost/db",
|
||||
expected: "postgres://user:*****@localhost/db",
|
||||
},
|
||||
{
|
||||
name: "without password",
|
||||
url: "postgres://localhost/db",
|
||||
expected: "postgres://localhost/db",
|
||||
},
|
||||
{
|
||||
name: "user only",
|
||||
url: "postgres://user@localhost/db",
|
||||
expected: "postgres://user@localhost/db",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
u, err := url.Parse(tt.url)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.expected, censorPassword(u))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Package schema tracks and migrates database schemas, and Migrate creates or upgrades a
|
||||
// store's schema inside a single transaction. On PostgreSQL, all stores share one database, so
|
||||
// versions live in a shared schema_version table keyed by store name. On SQLite, every store is
|
||||
// its own database file, so the version lives in the schemaVersion table keyed by id = 1.
|
||||
package schema
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"heckel.io/ntfy/v2/db/pg"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
)
|
||||
|
||||
const (
|
||||
tag = "schema"
|
||||
)
|
||||
|
||||
const (
|
||||
sqliteCreateVersionTableQuery = `CREATE TABLE IF NOT EXISTS schemaVersion (id INT PRIMARY KEY, version INT NOT NULL)`
|
||||
sqliteSelectVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
|
||||
sqliteUpsertVersionQuery = `INSERT INTO schemaVersion (id, version) VALUES (1, ?) ON CONFLICT (id) DO UPDATE SET version = excluded.version`
|
||||
|
||||
postgresCreateVersionTableQuery = `CREATE TABLE IF NOT EXISTS schema_version (store TEXT PRIMARY KEY, version INT NOT NULL)`
|
||||
postgresSelectVersionQuery = `SELECT version FROM schema_version WHERE store = $1`
|
||||
postgresUpsertVersionQuery = `INSERT INTO schema_version (store, version) VALUES ($1, $2) ON CONFLICT (store) DO UPDATE SET version = EXCLUDED.version`
|
||||
postgresAdvisoryLockQuery = `SELECT pg_advisory_xact_lock($1)` // Transaction-scoped lock to avoid migration races
|
||||
)
|
||||
|
||||
// Migrate creates or upgrades the named store's schema to targetVersion in one transaction, or
|
||||
// creates a new database using the "create" function.
|
||||
func Migrate(db *sql.DB, dialect Dialect, store string, targetVersion int, create MigrateFunc, migrations map[int]MigrateFunc) error {
|
||||
if dialect != Postgres && dialect != SQLite {
|
||||
return fmt.Errorf("unsupported schema dialect %d", dialect)
|
||||
}
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot begin %s schema transaction: %w", store, err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if dialect == Postgres {
|
||||
// Serialize setup across nodes: CREATE TABLE IF NOT EXISTS is not atomic, and
|
||||
// concurrently cold-booting nodes would otherwise race on DDL and crash
|
||||
if _, err := tx.Exec(postgresAdvisoryLockQuery, pg.SchemaLockKey); err != nil {
|
||||
return fmt.Errorf("cannot acquire %s schema advisory lock: %w", store, err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(createVersionTableQuery(dialect)); err != nil {
|
||||
return fmt.Errorf("cannot create schema version table: %w", err)
|
||||
}
|
||||
version, err := readVersion(tx, dialect, store)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// Fresh database: create the store's tables at the target version
|
||||
if err := create(tx); err != nil {
|
||||
return fmt.Errorf("cannot create %s schema: %w", store, err)
|
||||
}
|
||||
if err := writeVersion(tx, dialect, store, targetVersion); err != nil {
|
||||
return fmt.Errorf("cannot write %s schema version: %w", store, err)
|
||||
}
|
||||
return tx.Commit()
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("cannot read %s schema version: %w", store, err)
|
||||
}
|
||||
if version == targetVersion {
|
||||
return tx.Commit()
|
||||
}
|
||||
if version > targetVersion {
|
||||
return fmt.Errorf("unexpected %s schema version %d, this version of ntfy supports up to %d", store, version, targetVersion)
|
||||
}
|
||||
for v := version; v < targetVersion; v++ {
|
||||
migrate, ok := migrations[v]
|
||||
if !ok {
|
||||
return fmt.Errorf("cannot find %s migration step from version %d to %d", store, v, v+1)
|
||||
}
|
||||
log.Tag(tag).Info("Migrating %s database schema: from %d to %d", store, v, v+1)
|
||||
if err := migrate(tx); err != nil {
|
||||
return fmt.Errorf("%s migration step from version %d to %d failed: %w", store, v, v+1, err)
|
||||
}
|
||||
}
|
||||
if err := writeVersion(tx, dialect, store, targetVersion); err != nil {
|
||||
return fmt.Errorf("cannot write %s schema version: %w", store, err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func createVersionTableQuery(dialect Dialect) string {
|
||||
if dialect == Postgres {
|
||||
return postgresCreateVersionTableQuery
|
||||
}
|
||||
return sqliteCreateVersionTableQuery
|
||||
}
|
||||
|
||||
func readVersion(tx *sql.Tx, dialect Dialect, store string) (version int, err error) {
|
||||
if dialect == Postgres {
|
||||
err = tx.QueryRow(postgresSelectVersionQuery, store).Scan(&version)
|
||||
} else {
|
||||
err = tx.QueryRow(sqliteSelectVersionQuery).Scan(&version)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func writeVersion(tx *sql.Tx, dialect Dialect, store string, version int) error {
|
||||
var err error
|
||||
if dialect == Postgres {
|
||||
_, err = tx.Exec(postgresUpsertVersionQuery, store, version)
|
||||
} else {
|
||||
_, err = tx.Exec(sqliteUpsertVersionQuery, version)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package schema_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/db/pg"
|
||||
"heckel.io/ntfy/v2/db/schema"
|
||||
dbtest "heckel.io/ntfy/v2/db/test"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
const (
|
||||
testCreateQuery = `CREATE TABLE IF NOT EXISTS things (id TEXT PRIMARY KEY, name TEXT NOT NULL)`
|
||||
)
|
||||
|
||||
func testCreate(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(testCreateQuery)
|
||||
return err
|
||||
}
|
||||
|
||||
func openTestPostgres(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
host, err := pg.Open(dbtest.CreateTestPostgresSchema(t))
|
||||
require.Nil(t, err)
|
||||
t.Cleanup(func() { host.DB.Close() })
|
||||
return host.DB
|
||||
}
|
||||
|
||||
func openTestSQLite(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
d, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "test.db"))
|
||||
require.Nil(t, err)
|
||||
t.Cleanup(func() { d.Close() })
|
||||
return d
|
||||
}
|
||||
|
||||
func forEachDialect(t *testing.T, f func(t *testing.T, d *sql.DB, dialect schema.Dialect)) {
|
||||
t.Run("postgres", func(t *testing.T) {
|
||||
f(t, openTestPostgres(t), schema.Postgres)
|
||||
})
|
||||
t.Run("sqlite", func(t *testing.T) {
|
||||
f(t, openTestSQLite(t), schema.SQLite)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMigrate_FreshCreate(t *testing.T) {
|
||||
forEachDialect(t, func(t *testing.T, d *sql.DB, dialect schema.Dialect) {
|
||||
// A fresh database jumps straight to the target version; migration steps are not consulted
|
||||
require.Nil(t, schema.Migrate(d, dialect, "things", 3, testCreate, nil))
|
||||
_, err := d.Exec(`INSERT INTO things (id, name) VALUES ('a', 'thing a')`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 3, storeVersion(t, d, dialect, "things"))
|
||||
// Idempotent: a second node boots against the migrated schema
|
||||
require.Nil(t, schema.Migrate(d, dialect, "things", 3, testCreate, nil))
|
||||
})
|
||||
}
|
||||
|
||||
func TestMigrate_AppliesMigrationSteps(t *testing.T) {
|
||||
forEachDialect(t, func(t *testing.T, d *sql.DB, dialect schema.Dialect) {
|
||||
require.Nil(t, schema.Migrate(d, dialect, "things", 1, testCreate, nil))
|
||||
// A newer version of the code migrates 1 -> 3 step by step, in order
|
||||
migrations := map[int]schema.MigrateFunc{
|
||||
1: func(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`ALTER TABLE things ADD COLUMN color TEXT NOT NULL DEFAULT ''`)
|
||||
return err
|
||||
},
|
||||
2: func(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`ALTER TABLE things ADD COLUMN size INT NOT NULL DEFAULT 0`)
|
||||
return err
|
||||
},
|
||||
}
|
||||
require.Nil(t, schema.Migrate(d, dialect, "things", 3, testCreate, migrations))
|
||||
_, err := d.Exec(`INSERT INTO things (id, name, color, size) VALUES ('b', 'thing b', 'red', 2)`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 3, storeVersion(t, d, dialect, "things"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestMigrate_ClosureCarriesConfig(t *testing.T) {
|
||||
// Migrations needing config take it via closure at map-construction time; there is no
|
||||
// params plumbing in the framework itself
|
||||
migrationsFor := func(defaultName string) map[int]schema.MigrateFunc {
|
||||
return map[int]schema.MigrateFunc{
|
||||
1: schema.AsMigrateFunc(fmt.Sprintf(`ALTER TABLE things ADD COLUMN nick TEXT NOT NULL DEFAULT '%s'`, defaultName)),
|
||||
}
|
||||
}
|
||||
forEachDialect(t, func(t *testing.T, d *sql.DB, dialect schema.Dialect) {
|
||||
require.Nil(t, schema.Migrate(d, dialect, "things", 1, testCreate, nil))
|
||||
_, err := d.Exec(`INSERT INTO things (id, name) VALUES ('a', 'thing a')`)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, schema.Migrate(d, dialect, "things", 2, testCreate, migrationsFor("configured-default")))
|
||||
var nick string
|
||||
require.Nil(t, d.QueryRow(`SELECT nick FROM things WHERE id = 'a'`).Scan(&nick))
|
||||
require.Equal(t, "configured-default", nick)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMigrate_InvalidDialect(t *testing.T) {
|
||||
d := openTestSQLite(t)
|
||||
err := schema.Migrate(d, schema.Dialect(99), "things", 1, testCreate, nil)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestMigrate_RefusesFutureVersion(t *testing.T) {
|
||||
forEachDialect(t, func(t *testing.T, d *sql.DB, dialect schema.Dialect) {
|
||||
require.Nil(t, schema.Migrate(d, dialect, "things", 2, testCreate, map[int]schema.MigrateFunc{}))
|
||||
err := schema.Migrate(d, dialect, "things", 1, testCreate, nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMigrate_MissingStepFails(t *testing.T) {
|
||||
forEachDialect(t, func(t *testing.T, d *sql.DB, dialect schema.Dialect) {
|
||||
require.Nil(t, schema.Migrate(d, dialect, "things", 1, testCreate, nil))
|
||||
err := schema.Migrate(d, dialect, "things", 3, testCreate, nil) // No step 1 -> 2 registered
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMigrate_StoresAreIndependent(t *testing.T) {
|
||||
// Postgres only: stores share one database, tracked as rows in schema_version. On SQLite
|
||||
// every store has its own database file, so independence is by file.
|
||||
d := openTestPostgres(t)
|
||||
require.Nil(t, schema.Migrate(d, schema.Postgres, "things", 1, testCreate, nil))
|
||||
require.Nil(t, schema.Migrate(d, schema.Postgres, "gadgets", 4, func(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`CREATE TABLE IF NOT EXISTS gadgets (id TEXT PRIMARY KEY)`)
|
||||
return err
|
||||
}, nil))
|
||||
require.Equal(t, 1, storeVersion(t, d, schema.Postgres, "things"))
|
||||
require.Equal(t, 4, storeVersion(t, d, schema.Postgres, "gadgets"))
|
||||
}
|
||||
|
||||
func TestMigrate_SQLiteReadsExistingSchemaVersionTable(t *testing.T) {
|
||||
// Existing ntfy SQLite databases (message, user, webpush) track their version in a
|
||||
// schemaVersion (id, version) table keyed by id = 1; the framework uses that table as-is
|
||||
// on SQLite, so existing databases migrate without any adoption step
|
||||
d := openTestSQLite(t)
|
||||
_, err := d.Exec(testCreateQuery)
|
||||
require.Nil(t, err)
|
||||
_, err = d.Exec(`CREATE TABLE schemaVersion (id INT PRIMARY KEY, version INT NOT NULL)`)
|
||||
require.Nil(t, err)
|
||||
_, err = d.Exec(`INSERT INTO schemaVersion VALUES (1, 1)`)
|
||||
require.Nil(t, err)
|
||||
migrations := map[int]schema.MigrateFunc{
|
||||
1: func(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`ALTER TABLE things ADD COLUMN color TEXT NOT NULL DEFAULT ''`)
|
||||
return err
|
||||
},
|
||||
}
|
||||
require.Nil(t, schema.Migrate(d, schema.SQLite, "things", 2, testCreate, migrations))
|
||||
_, err = d.Exec(`INSERT INTO things (id, name, color) VALUES ('a', 'thing a', 'red')`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, storeVersion(t, d, schema.SQLite, "things"))
|
||||
}
|
||||
|
||||
func TestMigrate_ConcurrentFreshCreate(t *testing.T) {
|
||||
// Postgres only: concurrent cold-boots must not race on DDL (CREATE TABLE IF NOT EXISTS is
|
||||
// not atomic); Migrate serializes via an advisory lock. SQLite has a single writer.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
const n = 8
|
||||
errs := make(chan error, n)
|
||||
for i := 0; i < n; i++ {
|
||||
go func() {
|
||||
host, err := pg.Open(schemaDSN)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
defer host.DB.Close()
|
||||
errs <- schema.Migrate(host.DB, schema.Postgres, "things", 1, testCreate, nil)
|
||||
}()
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
require.Nil(t, <-errs)
|
||||
}
|
||||
}
|
||||
|
||||
func storeVersion(t *testing.T, d *sql.DB, dialect schema.Dialect, store string) int {
|
||||
t.Helper()
|
||||
var version int
|
||||
if dialect == schema.Postgres {
|
||||
require.Nil(t, d.QueryRow(`SELECT version FROM schema_version WHERE store = $1`, store).Scan(&version), fmt.Sprintf("store %s", store))
|
||||
} else {
|
||||
require.Nil(t, d.QueryRow(`SELECT version FROM schemaVersion WHERE id = 1`).Scan(&version), fmt.Sprintf("store %s", store))
|
||||
}
|
||||
return version
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package schema
|
||||
|
||||
import "database/sql"
|
||||
|
||||
// Dialect selects the SQL flavor Migrate speaks to the version table.
|
||||
type Dialect int
|
||||
|
||||
// Supported dialects; SQLite is the zero value
|
||||
const (
|
||||
SQLite Dialect = iota
|
||||
Postgres
|
||||
)
|
||||
|
||||
// MigrateFunc applies one schema change inside the setup transaction: the initial creation of
|
||||
// a store's tables, or one step upgrading a store from version N to N+1. Migrations needing
|
||||
// config capture it via closure, e.g. func migrations(cacheDuration time.Duration) map[int]MigrateFunc.
|
||||
type MigrateFunc func(tx *sql.Tx) error
|
||||
|
||||
// AsMigrateFunc converts a simple query to a migration function
|
||||
func AsMigrateFunc(query string) MigrateFunc {
|
||||
return func(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(query)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// NopMigrateFunc is a migration step that does nothing, for versions where a dialect has no
|
||||
// work to do (e.g. when only the other dialect's schema changed).
|
||||
func NopMigrateFunc(_ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package dbtest
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Querier is the subset of *sql.DB / *db.DB needed to introspect a schema.
|
||||
type Querier interface {
|
||||
Query(query string, args ...any) (*sql.Rows, error)
|
||||
}
|
||||
|
||||
// SQLiteSchema returns a normalized, comparable description of the database schema: tables
|
||||
// with their columns, named indexes, and foreign keys. Column order, declared type spelling
|
||||
// (INT vs INTEGER) and default values are not part of the description, so the schema produced
|
||||
// by a migration chain can be compared to a freshly created one.
|
||||
func SQLiteSchema(t testing.TB, d Querier) string {
|
||||
t.Helper()
|
||||
lines := make([]string, 0)
|
||||
for _, table := range sqliteTables(t, d) {
|
||||
lines = append(lines, "table "+table)
|
||||
lines = append(lines, sqliteColumns(t, d, table)...)
|
||||
lines = append(lines, sqliteForeignKeys(t, d, table)...)
|
||||
lines = append(lines, sqliteIndexes(t, d, table)...)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// PostgresSchema is SQLiteSchema's PostgreSQL counterpart, describing the current schema's
|
||||
// tables, columns, constraints and indexes in a normalized, comparable way.
|
||||
func PostgresSchema(t testing.TB, d Querier) string {
|
||||
t.Helper()
|
||||
lines := make([]string, 0)
|
||||
for _, table := range postgresTables(t, d) {
|
||||
lines = append(lines, "table "+table)
|
||||
lines = append(lines, postgresColumns(t, d, table)...)
|
||||
}
|
||||
lines = append(lines, postgresConstraints(t, d)...)
|
||||
lines = append(lines, postgresIndexes(t, d)...)
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func sqliteTables(t testing.TB, d Querier) []string {
|
||||
t.Helper()
|
||||
return queryStrings(t, d, `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name`)
|
||||
}
|
||||
|
||||
func sqliteColumns(t testing.TB, d Querier, table string) []string {
|
||||
t.Helper()
|
||||
rows, err := d.Query(fmt.Sprintf(`PRAGMA table_info(%q)`, table))
|
||||
require.Nil(t, err)
|
||||
defer rows.Close()
|
||||
lines := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var cid, notNull, pk int
|
||||
var name, typ string
|
||||
var dflt sql.NullString
|
||||
require.Nil(t, rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk))
|
||||
typ = strings.ToUpper(typ)
|
||||
if typ == "INT" { // INT and INTEGER are the same affinity; migrations spell them inconsistently
|
||||
typ = "INTEGER"
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(" col %s %s notnull=%d pk=%d", name, typ, notNull, pk))
|
||||
}
|
||||
require.Nil(t, rows.Err())
|
||||
sort.Strings(lines)
|
||||
return lines
|
||||
}
|
||||
|
||||
func sqliteForeignKeys(t testing.TB, d Querier, table string) []string {
|
||||
t.Helper()
|
||||
rows, err := d.Query(fmt.Sprintf(`PRAGMA foreign_key_list(%q)`, table))
|
||||
require.Nil(t, err)
|
||||
defer rows.Close()
|
||||
lines := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var id, seq int
|
||||
var refTable, from, onUpdate, onDelete, match string
|
||||
var to sql.NullString // NULL when referencing the parent's primary key implicitly
|
||||
require.Nil(t, rows.Scan(&id, &seq, &refTable, &from, &to, &onUpdate, &onDelete, &match))
|
||||
lines = append(lines, fmt.Sprintf(" fk %s -> %s(%s) on_delete=%s", from, refTable, to.String, onDelete))
|
||||
}
|
||||
require.Nil(t, rows.Err())
|
||||
sort.Strings(lines)
|
||||
return lines
|
||||
}
|
||||
|
||||
func sqliteIndexes(t testing.TB, d Querier, table string) []string {
|
||||
t.Helper()
|
||||
rows, err := d.Query(fmt.Sprintf(`PRAGMA index_list(%q)`, table))
|
||||
require.Nil(t, err)
|
||||
type index struct {
|
||||
name string
|
||||
unique, partial int
|
||||
}
|
||||
indexes := make([]index, 0)
|
||||
for rows.Next() {
|
||||
var seq, unique, partial int
|
||||
var name, origin string
|
||||
require.Nil(t, rows.Scan(&seq, &name, &unique, &origin, &partial))
|
||||
// Skip auto-indexes backing PRIMARY KEY/UNIQUE table constraints; those are described
|
||||
// by the column and constraint listings already
|
||||
if strings.HasPrefix(name, "sqlite_autoindex_") {
|
||||
continue
|
||||
}
|
||||
indexes = append(indexes, index{name, unique, partial})
|
||||
}
|
||||
require.Nil(t, rows.Err())
|
||||
require.Nil(t, rows.Close())
|
||||
lines := make([]string, 0, len(indexes))
|
||||
for _, idx := range indexes {
|
||||
cols := queryStrings(t, d, fmt.Sprintf(`SELECT name FROM pragma_index_info(%q) ORDER BY seqno`, idx.name))
|
||||
lines = append(lines, fmt.Sprintf(" index %s unique=%d partial=%d cols=(%s)", idx.name, idx.unique, idx.partial, strings.Join(cols, ",")))
|
||||
}
|
||||
sort.Strings(lines)
|
||||
return lines
|
||||
}
|
||||
|
||||
func postgresTables(t testing.TB, d Querier) []string {
|
||||
t.Helper()
|
||||
return queryStrings(t, d, `SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() AND table_type = 'BASE TABLE' ORDER BY table_name`)
|
||||
}
|
||||
|
||||
func postgresColumns(t testing.TB, d Querier, table string) []string {
|
||||
t.Helper()
|
||||
rows, err := d.Query(`SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 ORDER BY column_name`, table)
|
||||
require.Nil(t, err)
|
||||
defer rows.Close()
|
||||
lines := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var name, typ, nullable string
|
||||
require.Nil(t, rows.Scan(&name, &typ, &nullable))
|
||||
lines = append(lines, fmt.Sprintf(" col %s %s nullable=%s", name, typ, nullable))
|
||||
}
|
||||
require.Nil(t, rows.Err())
|
||||
return lines
|
||||
}
|
||||
|
||||
func postgresConstraints(t testing.TB, d Querier) []string {
|
||||
t.Helper()
|
||||
return queryStrings(t, d, `
|
||||
SELECT 'constraint ' || conrelid::regclass::text || ': ' || pg_get_constraintdef(oid)
|
||||
FROM pg_constraint
|
||||
WHERE connamespace = current_schema()::regnamespace
|
||||
ORDER BY 1
|
||||
`)
|
||||
}
|
||||
|
||||
func postgresIndexes(t testing.TB, d Querier) []string {
|
||||
t.Helper()
|
||||
rows, err := d.Query(`SELECT indexname, indexdef, schemaname FROM pg_indexes WHERE schemaname = current_schema() ORDER BY indexname`)
|
||||
require.Nil(t, err)
|
||||
defer rows.Close()
|
||||
lines := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var name, def, schema string
|
||||
require.Nil(t, rows.Scan(&name, &def, &schema))
|
||||
// The index definition qualifies the table with the (test-specific) schema name; strip
|
||||
// it so snapshots from different test schemas compare equal
|
||||
def = strings.ReplaceAll(def, schema+".", "")
|
||||
lines = append(lines, "index "+def)
|
||||
}
|
||||
require.Nil(t, rows.Err())
|
||||
return lines
|
||||
}
|
||||
|
||||
func queryStrings(t testing.TB, d Querier, query string) []string {
|
||||
t.Helper()
|
||||
rows, err := d.Query(query)
|
||||
require.Nil(t, err)
|
||||
defer rows.Close()
|
||||
values := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var value string
|
||||
require.Nil(t, rows.Scan(&value))
|
||||
values = append(values, value)
|
||||
}
|
||||
require.Nil(t, rows.Err())
|
||||
return values
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package dbtest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/db/pg"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
const testPoolMaxConns = "2"
|
||||
|
||||
// CreateTestPostgresSchema creates a temporary PostgreSQL schema and returns the DSN pointing to it.
|
||||
// It registers a cleanup function to drop the schema when the test finishes.
|
||||
// If NTFY_TEST_DATABASE_URL is not set, the test is skipped.
|
||||
func CreateTestPostgresSchema(t testing.TB) string {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("NTFY_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("NTFY_TEST_DATABASE_URL not set")
|
||||
}
|
||||
schema := fmt.Sprintf("test_%s", util.RandomString(10))
|
||||
u, err := url.Parse(dsn)
|
||||
require.Nil(t, err)
|
||||
q := u.Query()
|
||||
q.Set("pool_max_conns", testPoolMaxConns)
|
||||
u.RawQuery = q.Encode()
|
||||
dsn = u.String()
|
||||
setupHost, err := pg.Open(dsn)
|
||||
require.Nil(t, err)
|
||||
_, err = setupHost.DB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema))
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, setupHost.DB.Close())
|
||||
q.Set("search_path", schema)
|
||||
u.RawQuery = q.Encode()
|
||||
schemaDSN := u.String()
|
||||
t.Cleanup(func() {
|
||||
cleanHost, err := pg.Open(dsn)
|
||||
if err == nil {
|
||||
cleanHost.DB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema))
|
||||
cleanHost.DB.Close()
|
||||
}
|
||||
})
|
||||
return schemaDSN
|
||||
}
|
||||
|
||||
// CreateTestPostgres creates a temporary PostgreSQL schema and returns an open *db.DB connection to it.
|
||||
// It registers cleanup functions to close the DB and drop the schema when the test finishes.
|
||||
// If NTFY_TEST_DATABASE_URL is not set, the test is skipped.
|
||||
func CreateTestPostgres(t testing.TB) *db.DB {
|
||||
t.Helper()
|
||||
schemaDSN := CreateTestPostgresSchema(t)
|
||||
testHost, err := pg.Open(schemaDSN)
|
||||
require.Nil(t, err)
|
||||
d := db.New(testHost, nil)
|
||||
t.Cleanup(func() {
|
||||
d.Close()
|
||||
})
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Beginner is an interface for types that can begin a database transaction.
|
||||
// Both *sql.DB and *DB implement this.
|
||||
type Beginner interface {
|
||||
Begin() (*sql.Tx, error)
|
||||
}
|
||||
|
||||
// Querier is an interface for types that can execute SQL queries.
|
||||
// *sql.DB, *sql.Tx, and *DB all implement this.
|
||||
type Querier interface {
|
||||
Query(query string, args ...any) (*sql.Rows, error)
|
||||
}
|
||||
|
||||
// Host pairs a *sql.DB with the host:port it was opened against.
|
||||
type Host struct {
|
||||
Addr string // "host:port"
|
||||
DB *sql.DB
|
||||
healthy atomic.Bool
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package db
|
||||
|
||||
import "database/sql"
|
||||
|
||||
// ExecTx executes a function within a database transaction. If the function returns an error,
|
||||
// the transaction is rolled back. Otherwise, the transaction is committed.
|
||||
func ExecTx(db Beginner, f func(tx *sql.Tx) error) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := f(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// QueryTx executes a function within a database transaction and returns the result. If the function
|
||||
// returns an error, the transaction is rolled back. Otherwise, the transaction is committed.
|
||||
func QueryTx[T any](db Beginner, f func(tx *sql.Tx) (T, error)) (T, error) {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
var zero T
|
||||
return zero, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
t, err := f(tx)
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return t, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
# Contact
|
||||
|
||||
This service is run by [Philipp C. Heckel](https://heckel.io). There are several ways to get in touch with me and the
|
||||
ntfy community. Please choose the appropriate channel based on your needs.
|
||||
|
||||
## Support
|
||||
|
||||
### Community support
|
||||
|
||||
For general questions, feature discussions, and community help, please use one of these public channels:
|
||||
|
||||
| Channel | Link | Description |
|
||||
|-------------------|--------------------------------------------------------------------------------------|------------------------------------------------------------|
|
||||
| **Discord** | [discord.gg/cT7ECsZj9w](https://discord.gg/cT7ECsZj9w) | Real-time chat with the community (I'm `binwiederhier`) |
|
||||
| **Matrix** | [#ntfy:matrix.org](https://matrix.to/#/#ntfy:matrix.org) | Bridged from Discord, same community (I'm `binwiederhier`) |
|
||||
| **Matrix Space** | [#ntfy-space:matrix.org](https://matrix.to/#/#ntfy-space:matrix.org) | Matrix space with all ntfy rooms |
|
||||
| **GitHub Issues** | [github.com/binwiederhier/ntfy/issues](https://github.com/binwiederhier/ntfy/issues) | Bug reports and feature requests |
|
||||
|
||||
!!! info "Why public channels?"
|
||||
Answering questions in public channels benefits the entire community. Other users can learn from the
|
||||
discussion, and answers can be referenced later. This is much more scalable than 1-on-1 support.
|
||||
|
||||
### Paid support
|
||||
|
||||
If you are subscribed to a [ntfy Pro](https://ntfy.sh/#pricing) plan, you are entitled to priority support
|
||||
via the following channels:
|
||||
|
||||
| Channel | Contact | Description |
|
||||
|-----------------------|-----------------------------------------------------|------------------------------------------|
|
||||
| **General Support** | [support@mail.ntfy.sh](mailto:support@mail.ntfy.sh) | Direct email support for Pro subscribers |
|
||||
| **Billing Inquiries** | [billing@mail.ntfy.sh](mailto:billing@mail.ntfy.sh) | Inquire about billing issues |
|
||||
| **Discord/Matrix** | Mention your Pro status | Priority responses in community channels |
|
||||
|
||||
Please include your ntfy.sh username when contacting support so we can verify your subscription status.
|
||||
|
||||
## Security issues
|
||||
|
||||
If you discover a security vulnerability, please report it responsibly via [security@mail.ntfy.sh](mailto:security@mail.ntfy.sh). See also: [SECURITY.md](https://github.com/binwiederhier/ntfy/blob/main/SECURITY.md).
|
||||
|
||||
## Abuse reports
|
||||
|
||||
To report spam, phishing, or other abuse of ntfy.sh, please email [abuse@mail.ntfy.sh](mailto:abuse@mail.ntfy.sh).
|
||||
Please include the topic name and any relevant message details so we can investigate.
|
||||
|
||||
## Other inquiries
|
||||
|
||||
For questions about our [privacy policy](privacy.md), data handling, or to exercise your data rights
|
||||
(access, deletion, etc.), please email [privacy@mail.ntfy.sh](mailto:privacy@mail.ntfy.sh).
|
||||
|
||||
For business inquiries, partnerships, press, or other general questions that don't fit the categories above, please
|
||||
use [contact@mail.ntfy.sh](mailto:contact@mail.ntfy.sh).
|
||||
@@ -0,0 +1,43 @@
|
||||
# Contributing
|
||||
|
||||
Thank you for your interest in contributing to ntfy! There are many ways to help, whether you're a developer,
|
||||
translator, or just an enthusiastic user.
|
||||
|
||||
## Code contributions
|
||||
|
||||
If you'd like to contribute code to ntfy:
|
||||
|
||||
1. Check out the [development guide](develop.md) to set up your environment
|
||||
2. Look at [open issues](https://github.com/binwiederhier/ntfy/issues) for ideas, or propose your own
|
||||
3. For larger features or architectural changes, please reach out on [Discord/Matrix](contact.md) first to discuss
|
||||
before investing significant time
|
||||
4. Submit a pull request on GitHub
|
||||
|
||||
All contributions are welcome, from small bug fixes to major features.
|
||||
|
||||
## Translations
|
||||
|
||||
Help make ntfy accessible to users around the world! We use Hosted Weblate for translations:
|
||||
|
||||
- **Weblate**: [hosted.weblate.org/projects/ntfy](https://hosted.weblate.org/projects/ntfy/)
|
||||
|
||||
You can start translating immediately without any coding knowledge.
|
||||
|
||||
## Documentation
|
||||
|
||||
Found a typo? Want to improve the docs? Documentation contributions are very welcome:
|
||||
|
||||
- Edit any page directly on GitHub using the edit button
|
||||
- Submit a pull request with your improvements
|
||||
|
||||
## Bug reports and feature requests
|
||||
|
||||
- **GitHub Issues**: [github.com/binwiederhier/ntfy/issues](https://github.com/binwiederhier/ntfy/issues)
|
||||
|
||||
Please search existing issues before creating a new one to avoid duplicates.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
Please be respectful and constructive in all interactions. See the
|
||||
[Code of Conduct](https://github.com/binwiederhier/ntfy/blob/main/CODE_OF_CONDUCT.md) for details.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Hurray 🥳 🎉, you are interested in writing code for ntfy! **That's awesome.** 😎
|
||||
|
||||
I tried my very best to write up detailed instructions, but if at any point in time you run into issues, don't
|
||||
hesitate to **contact me on [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org)**.
|
||||
hesitate to reach out via one of the channels listed on the [contact page](contact.md).
|
||||
|
||||
## ntfy server
|
||||
The ntfy server source code is available [on GitHub](https://github.com/binwiederhier/ntfy). The codebase for the
|
||||
@@ -65,8 +65,8 @@ These steps **assume Ubuntu**. Steps may vary on different Linux distributions.
|
||||
|
||||
First, install [Go](https://go.dev/) (see [official instructions](https://go.dev/doc/install)):
|
||||
``` 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
|
||||
wget https://go.dev/dl/go1.25.8.linux-amd64.tar.gz
|
||||
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.25.8.linux-amd64.tar.gz
|
||||
export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin
|
||||
go version # verifies that it worked
|
||||
```
|
||||
@@ -77,9 +77,11 @@ go install github.com/goreleaser/goreleaser@latest
|
||||
goreleaser -v # verifies that it worked
|
||||
```
|
||||
|
||||
Install [nodejs](https://nodejs.org/en/) (see [official instructions](https://nodejs.org/en/download/package-manager/)):
|
||||
Install [nodejs](https://nodejs.org/en/) (see [official instructions](https://nodejs.org/en/download/package-manager/)).
|
||||
Use a current LTS release (Node 24 is what CI builds with; anything older than Node 20 will not work
|
||||
with the current Vite-based web build):
|
||||
``` shell
|
||||
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
npm -v # verifies that it worked
|
||||
```
|
||||
@@ -255,6 +257,7 @@ Reference: <https://stackoverflow.com/questions/34160509/options-for-testing-ser
|
||||
go run main.go \
|
||||
--log-level debug \
|
||||
serve \
|
||||
--base-url http://localhost \
|
||||
--web-push-public-key KEY \
|
||||
--web-push-private-key KEY \
|
||||
--web-push-email-address <email> \
|
||||
@@ -340,10 +343,6 @@ Then either follow the steps for building with or without Firebase.
|
||||
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
|
||||
|
||||
@@ -351,6 +350,8 @@ sed -i -e '/google-services/d' app/build.gradle
|
||||
./gradlew bundleFdroidRelease
|
||||
```
|
||||
|
||||
The F-Droid flavor automatically excludes Google Services dependencies.
|
||||
|
||||
### 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
|
||||
@@ -441,6 +442,6 @@ To have instant notifications/better notification delivery when using firebase,
|
||||
1. In XCode, find the NTFY app target. **Not** the NSE app target.
|
||||
1. Find the Asset/ folder in the project navigator
|
||||
1. Drag the `GoogleService-Info.plist` file into the Asset/ folder that you get from the firebase console. It can be
|
||||
found in the "Project settings" > "General" > "Your apps" with a button labled "GoogleService-Info.plist"
|
||||
found in the "Project settings" > "General" > "Your apps" with a button labeled "GoogleService-Info.plist"
|
||||
|
||||
After that, you should be all set!
|
||||
|
||||
@@ -641,6 +641,32 @@ or by simply providing traccar with a valid username/password combination.
|
||||
<entry key='sms.http.password'>mypass</entry>
|
||||
```
|
||||
|
||||
## Flowtriq DDoS detection
|
||||
[Flowtriq](https://flowtriq.com) is a real-time DDoS detection and mitigation platform. Its Linux agent, ftagent,
|
||||
supports webhook alerts that can POST directly to an ntfy topic, so you get push notifications on your phone
|
||||
whenever an attack is detected.
|
||||
|
||||
Configure the webhook URL in your ftagent configuration to point to your ntfy topic:
|
||||
|
||||
```yaml
|
||||
# /etc/ftagent/ftagent.yml
|
||||
alerts:
|
||||
webhooks:
|
||||
- url: https://ntfy.sh/flowtriq-attacks
|
||||
method: POST
|
||||
```
|
||||
|
||||
You can also use curl to test the integration manually with a sample attack alert:
|
||||
|
||||
```bash
|
||||
curl \
|
||||
-H "Title: DDoS Attack Detected" \
|
||||
-H "Priority: urgent" \
|
||||
-H "Tags: rotating_light" \
|
||||
-d "Attack detected on 203.0.113.5: 14.2 Gbps UDP flood from 3,482 sources" \
|
||||
ntfy.sh/flowtriq-attacks
|
||||
```
|
||||
|
||||
## Terminal Notifications for Long-Running Commands
|
||||
|
||||
This example provides a simple way to send notifications using [ntfy.sh](https://ntfy.sh) when a terminal command completes. It includes success or failure indicators based on the command's exit status.
|
||||
@@ -661,6 +687,8 @@ Add the following function and alias to your `.bashrc` or `.bash_profile`:
|
||||
local token=$(< ~/.ntfy_token) # Securely read the token
|
||||
local status_icon="$([ $exit_status -eq 0 ] && echo magic_wand || echo warning)"
|
||||
local last_command=$(history | tail -n1 | sed -e 's/^[[:space:]]*[0-9]\{1,\}[[:space:]]*//' -e 's/[;&|][[:space:]]*alert$//')
|
||||
# for zsh users, use the same sed pattern but get the history differently.
|
||||
# local last_command=$(history "$HISTCMD" | sed -e 's/^[[:space:]]*[0-9]\{1,\}[[:space:]]*//' -e 's/[;&|][[:space:]]*alert$//')
|
||||
|
||||
curl -s -X POST "https://n.example.dev/alerts" \
|
||||
-H "Authorization: Bearer $token" \
|
||||
@@ -692,4 +720,4 @@ To test failure notifications:
|
||||
false; alert # Always fails (exit 1)
|
||||
ls --invalid; alert # Invalid option
|
||||
cat nonexistent_file; alert # File not found
|
||||
```
|
||||
```
|
||||
|
||||
@@ -71,7 +71,8 @@ The web app is a static website without a backend (other than the ntfy API). All
|
||||
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.
|
||||
However, if you still want, you can require login with the `require-login: true` option,
|
||||
or disable it 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.
|
||||
@@ -94,11 +95,11 @@ I would be humbled if you helped me carry the server and developer account costs
|
||||
appreciated.
|
||||
|
||||
## Can I email you? Can I DM you on Discord/Matrix?
|
||||
While I love chatting on [Discord](https://discord.gg/cT7ECsZj9w), [Matrix](https://matrix.to/#/#ntfy-space:matrix.org),
|
||||
[Lemmy](https://discuss.ntfy.sh/c/ntfy), or [GitHub](https://github.com/binwiederhier/ntfy/issues), I generally
|
||||
**do not respond to emails about ntfy or direct messages** about ntfy, unless you are paying for a
|
||||
[ntfy Pro](https://ntfy.sh/#pricing) plan, or you are inquiring about business opportunities.
|
||||
For community support, please use the public channels listed on the [contact page](contact.md). I generally
|
||||
**do not respond to direct messages** about ntfy, unless you are paying for a [ntfy Pro](https://ntfy.sh/#pricing)
|
||||
plan (see [paid support](contact.md#paid-support)), or you are inquiring about business
|
||||
opportunities (see [other inquiries](contact.md#other-inquiries)).
|
||||
|
||||
I am sorry, but answering individual questions about ntfy on a 1-on-1 basis is not scalable. Answering your questions
|
||||
in the above-mentioned forums benefits others, since I can link to the discussion at a later point in time, or other users
|
||||
in public forums benefits others, since I can link to the discussion at a later point in time, or other users
|
||||
may be able to help out. I hope you understand.
|
||||
|
||||
@@ -4,7 +4,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 width="170" src="static/img/badge-googleplay.png"></a>
|
||||
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img width="170" src="static/img/badge-fdroid.png"></a>
|
||||
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img width="170" src="static/img/badge-fdroid.svg"></a>
|
||||
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img width="150" src="static/img/badge-appstore.png"></a>
|
||||
|
||||
To [receive notifications on your phone](subscribe/phone.md), install the app, either via Google Play, App Store or F-Droid.
|
||||
|
||||
@@ -28,52 +28,146 @@ resources to get started. _I am not affiliated with Kris or Alex, I just liked t
|
||||
Please check out the [releases page](https://github.com/binwiederhier/ntfy/releases) for binaries and
|
||||
deb/rpm packages.
|
||||
|
||||
### Download and run
|
||||
The steps below allow you to download ntfy server and run it in a pinch. But it won't be enough to install it permanently
|
||||
as a service starting at boot time.
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_amd64.tar.gz
|
||||
tar zxvf ntfy_2.13.0_linux_amd64.tar.gz
|
||||
sudo cp -a ntfy_2.13.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_amd64.tar.gz
|
||||
tar zxvf ntfy_2.27.0_linux_amd64.tar.gz
|
||||
sudo cp -a ntfy_2.27.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.27.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.13.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.13.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.27.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.27.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.27.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.13.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.13.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.27.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.27.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.27.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.13.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.13.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.27.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.27.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.27.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
### Install as a service
|
||||
If you want to install ntfy server permanently as a service, and your OS/distribution of choice doesn't offer a package,
|
||||
there are a few more steps to follow.
|
||||
|
||||
Create the ntfy user and group:
|
||||
```bash
|
||||
useradd --system --home-dir /var/lib/ntfy --shell /bin/false --comment "User for the simple HTTP-based pub-sub notification service" ntfy
|
||||
```
|
||||
|
||||
Depending on your init system, the following steps will diverge.
|
||||
|
||||
#### On systemd systems
|
||||
Install the ntfy server unit file (which contains parameters to start the service at boot time):
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo mv ntfy_2.27.0_linux_amd64/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo mv ntfy_2.27.0_linux_armv6/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo mv ntfy_2.27.0_linux_armv7/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo mv ntfy_2.27.0_linux_arm64/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
Then notify systemd we have added a new service and start the service:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
#### On OpenRC systems
|
||||
Install the ntfy server service script:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo mv ntfy_2.27.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo mv ntfy_2.27.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo mv ntfy_2.27.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo mv ntfy_2.27.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
Start the ntfy server service:
|
||||
|
||||
```bash
|
||||
sudo rc-service ntfy start
|
||||
```
|
||||
|
||||
Add the ntfy server service to the default runlevel (so that it starts at boot time):
|
||||
|
||||
```bash
|
||||
sudo rc-update add ntfy default
|
||||
```
|
||||
|
||||
## Debian/Ubuntu repository
|
||||
Installation via Debian repository:
|
||||
|
||||
!!! info
|
||||
As of September 2025, **the official ntfy.sh Debian/Ubuntu repository has moved to [archive.ntfy.sh](https://archive.ntfy.sh/apt)**.
|
||||
The old repository [archive.heckel.io](https://archive.heckel.io/apt) is still available for now, but will likely
|
||||
go away soon. I suspect I will phase it out some time in early 2026.
|
||||
|
||||
Installation via Debian/Ubuntu repository (fingerprint `55BA 774A 6F5E E674 31E4 B6B7 CFDB 962D 4F1E C4AF`):
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://archive.heckel.io/apt/pubkey.txt | sudo gpg --dearmor -o /etc/apt/keyrings/archive.heckel.io.gpg
|
||||
sudo curl -L -o /etc/apt/keyrings/ntfy.gpg https://archive.ntfy.sh/apt/keyring.gpg
|
||||
sudo apt install apt-transport-https
|
||||
sudo sh -c "echo 'deb [arch=amd64 signed-by=/etc/apt/keyrings/archive.heckel.io.gpg] https://archive.heckel.io/apt debian main' \
|
||||
> /etc/apt/sources.list.d/archive.heckel.io.list"
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/ntfy.gpg] https://archive.ntfy.sh/apt stable main" \
|
||||
| sudo tee /etc/apt/sources.list.d/ntfy.list
|
||||
sudo apt update
|
||||
sudo apt install ntfy
|
||||
sudo systemctl enable ntfy
|
||||
@@ -83,10 +177,10 @@ Installation via Debian repository:
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://archive.heckel.io/apt/pubkey.txt | sudo gpg --dearmor -o /etc/apt/keyrings/archive.heckel.io.gpg
|
||||
sudo curl -L -o /etc/apt/keyrings/ntfy.gpg https://archive.ntfy.sh/apt/keyring.gpg
|
||||
sudo apt install apt-transport-https
|
||||
sudo sh -c "echo 'deb [arch=armhf signed-by=/etc/apt/keyrings/archive.heckel.io.gpg] https://archive.heckel.io/apt debian main' \
|
||||
> /etc/apt/sources.list.d/archive.heckel.io.list"
|
||||
echo "deb [arch=armhf signed-by=/etc/apt/keyrings/ntfy.gpg] https://archive.ntfy.sh/apt stable main" \
|
||||
| sudo tee /etc/apt/sources.list.d/ntfy.list
|
||||
sudo apt update
|
||||
sudo apt install ntfy
|
||||
sudo systemctl enable ntfy
|
||||
@@ -96,10 +190,10 @@ Installation via Debian repository:
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://archive.heckel.io/apt/pubkey.txt | sudo gpg --dearmor -o /etc/apt/keyrings/archive.heckel.io.gpg
|
||||
sudo curl -L -o /etc/apt/keyrings/ntfy.gpg https://archive.ntfy.sh/apt/keyring.gpg
|
||||
sudo apt install apt-transport-https
|
||||
sudo sh -c "echo 'deb [arch=arm64 signed-by=/etc/apt/keyrings/archive.heckel.io.gpg] https://archive.heckel.io/apt debian main' \
|
||||
> /etc/apt/sources.list.d/archive.heckel.io.list"
|
||||
echo "deb [arch=arm64 signed-by=/etc/apt/keyrings/ntfy.gpg] https://archive.ntfy.sh/apt stable main" \
|
||||
| sudo tee /etc/apt/sources.list.d/ntfy.list
|
||||
sudo apt update
|
||||
sudo apt install ntfy
|
||||
sudo systemctl enable ntfy
|
||||
@@ -110,7 +204,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_amd64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_amd64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -118,7 +212,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv6.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_armv6.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -126,7 +220,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv7.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_armv7.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -134,7 +228,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_arm64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_arm64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -144,33 +238,35 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_amd64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.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.13.0/ntfy_2.13.0_linux_armv6.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_armv6.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv7.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_armv7.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_arm64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_linux_arm64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
## Arch Linux
|
||||
<span class="community-badge" title="This package is maintained by the community, not the ntfy developers"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg> Community maintained</span>
|
||||
|
||||
ntfy can be installed using an [AUR package](https://aur.archlinux.org/packages/ntfysh-bin/).
|
||||
You can use an [AUR helper](https://wiki.archlinux.org/title/AUR_helpers) like `paru`, `yay` or others to download,
|
||||
build and install ntfy and keep it up to date.
|
||||
@@ -185,7 +281,9 @@ cd ntfysh-bin
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
## NixOS / Nix
|
||||
## NixOS / Nix
|
||||
<span class="community-badge" title="This package is maintained by the community, not the ntfy developers"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg> Community maintained</span>
|
||||
|
||||
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
|
||||
@@ -193,20 +291,28 @@ 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).
|
||||
|
||||
## FreeBSD
|
||||
<span class="community-badge" title="This package is maintained by the community, not the ntfy developers"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg> Community maintained</span>
|
||||
|
||||
ntfy is ported to FreeBSD and available via the ports collection as [sysutils/go-ntfy](https://www.freshports.org/sysutils/go-ntfy/). You can install it via `pkg`:
|
||||
```
|
||||
pkg install go-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.13.0/ntfy_2.13.0_darwin_all.tar.gz),
|
||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_darwin_all.tar.gz),
|
||||
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
|
||||
|
||||
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
|
||||
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
|
||||
|
||||
```bash
|
||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_darwin_all.tar.gz > ntfy_2.13.0_darwin_all.tar.gz
|
||||
tar zxvf ntfy_2.13.0_darwin_all.tar.gz
|
||||
sudo cp -a ntfy_2.13.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_darwin_all.tar.gz > ntfy_2.27.0_darwin_all.tar.gz
|
||||
tar zxvf ntfy_2.27.0_darwin_all.tar.gz
|
||||
sudo cp -a ntfy_2.27.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||
mkdir ~/Library/Application\ Support/ntfy
|
||||
cp ntfy_2.13.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
cp ntfy_2.27.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
ntfy --help
|
||||
```
|
||||
|
||||
@@ -215,27 +321,38 @@ ntfy --help
|
||||
development as well. Check out the [build instructions](develop.md) for details.
|
||||
|
||||
## Homebrew
|
||||
<span class="community-badge" title="This package is maintained by the community, not the ntfy developers"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg> Community maintained</span>
|
||||
|
||||
To install the [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) via Homebrew (Linux and macOS),
|
||||
simply run:
|
||||
```
|
||||
brew install ntfy
|
||||
```
|
||||
|
||||
|
||||
## Windows
|
||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
|
||||
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_windows_amd64.zip),
|
||||
The ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service.
|
||||
To install, you can either
|
||||
|
||||
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.27.0/ntfy_2.27.0_windows_amd64.zip),
|
||||
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
|
||||
* Or install ntfy from the [Scoop](https://scoop.sh) main repository via `scoop install ntfy`
|
||||
|
||||
The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
|
||||
Once installed, you can run the ntfy CLI commands like so:
|
||||
|
||||
Also available in [Scoop's](https://scoop.sh) Main repository:
|
||||
```
|
||||
ntfy.exe -h
|
||||
```
|
||||
|
||||
`scoop install ntfy`
|
||||
The default configuration file location on Windows is `%ProgramData%\ntfy\server.yml` (e.g., `C:\ProgramData\ntfy\server.yml`)
|
||||
for the server, and `%AppData%\ntfy\client.yml` for the client. You may need to create the directory and config file manually.
|
||||
|
||||
!!! 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.
|
||||
To install the ntfy server as a Windows service, you can use the built-in `sc` command. For example, run this in an
|
||||
elevated command prompt (adjust the path to `ntfy.exe` accordingly):
|
||||
|
||||
```
|
||||
sc create ntfy binPath="C:\path\to\ntfy.exe serve" start=auto
|
||||
sc start ntfy
|
||||
```
|
||||
|
||||
## Docker
|
||||
The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv6, armv7 and arm64. It should
|
||||
@@ -301,6 +418,7 @@ services:
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
restart: unless-stopped
|
||||
init: true # needed, if healthcheck is used. Prevents zombie processes
|
||||
```
|
||||
|
||||
If using a non-root user when running the docker version, be sure to chown the server.yml, user.db, and cache.db files and attachments directory to the same uid/gid.
|
||||
@@ -319,7 +437,6 @@ The setup for Kubernetes is very similar to that for Docker, and requires a fair
|
||||
are a few options to mix and match, including a deployment without a cache file, a stateful set with a persistent cache, and a standalone
|
||||
unmanned pod.
|
||||
|
||||
|
||||
=== "deployment"
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
@@ -538,18 +655,18 @@ kubectl apply -k /ntfy
|
||||
cpu: 150m
|
||||
memory: 150Mi
|
||||
volumeMounts:
|
||||
- mountPath: /etc/ntfy
|
||||
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
|
||||
- mountPath: /etc/ntfy/server.yml
|
||||
subPath: server.yml
|
||||
name: config-volume # generated via 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"
|
||||
@@ -609,3 +726,31 @@ kubectl apply -k /ntfy
|
||||
cache-file: "/var/cache/ntfy/cache.db"
|
||||
attachment-cache-dir: "/var/cache/ntfy/attachments"
|
||||
```
|
||||
|
||||
## Helm
|
||||
<span class="community-badge" title="This package is maintained by the community, not the ntfy developers"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg> Community maintained</span>
|
||||
|
||||
If you prefer [Helm](https://helm.sh/), ntfy can be deployed using the third-party
|
||||
[HelmForge chart](https://helmforge.dev/docs/charts/ntfy), which packages the official
|
||||
`binwiederhier/ntfy` image with persistent storage, Service, Ingress, optional Prometheus metrics, and more.
|
||||
This chart is **not** maintained by the ntfy developers.
|
||||
|
||||
!!! warning
|
||||
The HelmForge project is young and maintained by a small community. Review the chart before deploying it,
|
||||
and use it at your own risk.
|
||||
|
||||
```bash
|
||||
helm repo add helmforge https://repo.helmforge.dev
|
||||
helm repo update
|
||||
helm install ntfy helmforge/ntfy
|
||||
```
|
||||
|
||||
Alternatively, install it directly from the OCI registry:
|
||||
|
||||
```bash
|
||||
helm install ntfy oci://ghcr.io/helmforgedev/helm/ntfy
|
||||
```
|
||||
|
||||
Because ntfy's default SQLite storage is single-writer, run the chart as a single instance rather than
|
||||
treating it as a horizontally scalable deployment. See the [chart documentation](https://helmforge.dev/docs/charts/ntfy)
|
||||
for the full list of configurable values.
|
||||
|
||||
@@ -42,6 +42,8 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
- [Monibot](https://monibot.io/) - Monibot monitors your websites, servers and applications and notifies you if something goes wrong.
|
||||
- [Miniflux](https://miniflux.app/docs/ntfy.html) - Minimalist and opinionated feed reader
|
||||
- [Beszel](https://beszel.dev/guide/notifications/ntfy) - Server monitoring platform
|
||||
- [Simple Observability](https://simpleobservability.com/docs/alerts/ntfy) - Server monitoring and observability platform
|
||||
- [Sifio](https://sifio.net) - Aggregate updates from RSS, social media, and other sources, then deliver them to ntfy, Slack, Notion and more
|
||||
|
||||
## Integration via HTTP/SMTP/etc.
|
||||
|
||||
@@ -53,6 +55,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
- [Proxmox-Ntfy](https://github.com/qtsone/proxmox-ntfy) - Python script that monitors Proxmox tasks and sends notifications using the Ntfy service.
|
||||
- [Scrutiny](https://github.com/AnalogJ/scrutiny) - WebUI for smartd S.M.A.R.T monitoring. Scrutiny includes shoutrrr/ntfy integration ([see integration README](https://github.com/AnalogJ/scrutiny?tab=readme-ov-file#notifications))
|
||||
- [UptimeObserver](https://uptimeobserver.com) - Uptime Monitoring tool for Websites, APIs, SSL Certificates, DNS, Domain Names and Ports. [Integration Guide](https://support.uptimeobserver.com/integrations/ntfy/)
|
||||
- [Flowtriq](https://flowtriq.com) - Real-time DDoS detection and mitigation platform (integration via [webhook alerts](https://flowtriq.com))
|
||||
|
||||
## [UnifiedPush](https://unifiedpush.org/users/apps/) integrations
|
||||
|
||||
@@ -81,21 +84,24 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
- [symfony/ntfy-notifier](https://symfony.com/components/NtfyNotifier) ⭐ - Symfony Notifier integration for ntfy (PHP)
|
||||
- [ntfy-java](https://github.com/MaheshBabu11/ntfy-java/) - A Java package to interact with a ntfy server (Java)
|
||||
- [aiontfy](https://github.com/tr4nt0r/aiontfy) - Asynchronous client library for publishing and subscribing to ntfy (Python)
|
||||
- [ex_ntfy](https://github.com/houllette/ex_ntfy) - Elixir SDK covering publishing, polling, and streaming subscriptions for ntfy servers (Elixir)
|
||||
|
||||
## CLIs + GUIs
|
||||
|
||||
- [ntfy.sh.sh](https://github.com/mininmobile/ntfy.sh.sh) - Run scripts on ntfy.sh events
|
||||
- [ntfy-desktop](https://codeberg.org/zvava/ntfy-desktop) - Cross-platform desktop application for ntfy
|
||||
- [ntfy-desktop](https://github.com/Aetherinox/ntfy-desktop) - Desktop client for Windows, Linux, and MacOS with push notifications
|
||||
- [ntfy svelte front-end](https://github.com/novatorem/Ntfy) - Front-end built with svelte
|
||||
- [ntfy Desktop (Windows)](https://github.com/simoneferrari/ntfy-desktop) - Native Windows desktop client with multi-server support, toast notifications and message history, built with WPF and .NET (C#)
|
||||
- [wio-ntfy-ticker](https://github.com/nachotp/wio-ntfy-ticker) - Ticker display for a ntfy.sh topic
|
||||
- [ntfysh-windows](https://github.com/lucas-bortoli/ntfysh-windows) - A ntfy client for Windows Desktop
|
||||
- [ntfysh-windows](https://github.com/mshafer1/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
|
||||
- [wlzntfy](https://github.com/Walzen-Group/ntfy-toaster) - A minimalistic, receive-only toast notification client for Windows 11
|
||||
- [Ntfy_CSV_Reminders](https://github.com/thiswillbeyourgithub/Ntfy_CSV_Reminders) - A Python tool that sends random-timing phone notifications for recurring tasks by using daily probability checks based on CSV-defined frequencies.
|
||||
- [Daily Fact Ntfy](https://github.com/thiswillbeyourgithub/Daily_Fact_Ntfy) - Generate [llm](https://github.com/simonw/llm) generated fact every day about any topic you're interested in.
|
||||
- [ntfyexec](https://github.com/alecthomas/ntfyexec) - Send a notification through ntfy.sh if a command fails
|
||||
- [Ntfy Desktop](https://github.com/emmaexe/ntfyDesktop) - Fully featured desktop client for Linux, built with Qt and C++.
|
||||
- [Ntfy App](https://github.com/rubix-studios-pty-ltd/ntfy-app) - Tauri/Rust desktop client for Windows, Linux and MacOS with push notifications.
|
||||
|
||||
## Projects + scripts
|
||||
|
||||
@@ -105,6 +111,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
- [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)
|
||||
- [alertmanager-ntfy-relay](https://github.com/therobbielee/alertmanager-ntfy-relay) - ntfy.sh relay for Alertmanager (Go)
|
||||
- [oci-notifications-ntfy-relay](https://github.com/Ryan02I5/oci-notifications-ntfy-relay) - Minimal OCI Notifications / Oracle Functions relay to ntfy topics (Python)
|
||||
- [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)
|
||||
@@ -174,10 +181,21 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
- [ntfy-me-mcp](https://github.com/gitmotion/ntfy-me-mcp) - An ntfy MCP server for sending/fetching ntfy notifications to your self-hosted ntfy server from AI Agents (supports secure token auth & more - use with npx or docker!) (Node/Typescript)
|
||||
- [InvaderInformant](https://github.com/patricksthannon/InvaderInformant) - Script for Mac OS systems that monitors new or dropped connections to your network using ntfy (Shell)
|
||||
- [NtfyPwsh](https://github.com/ptmorris1/NtfyPwsh) - PowerShell module to help send messages to ntfy (PowerShell)
|
||||
- [ntfyrr](https://github.com/leukosaima/ntfyrr) - Currently an Overseerr webhook notification to ntfy helper service.
|
||||
- [ntfyrr](https://github.com/leukosaima/ntfyrr) - Overseerr and Maintainerr webhook notification to ntfy helper service (C#)
|
||||
- [ntfy for Sandstorm](https://apps.sandstorm.io/app/c6rk81r4qk6dm3k04x1kxmyccqewhh4npuxeyg1xrpfypn2ddy0h) - ntfy app for the Sandstorm platform
|
||||
- [ntfy-heartbeat-monitor](https://codeberg.org/RockWolf/ntfy-heartbeat-monitor) - Application for implementing heartbeat monitoring/alerting by utilizing ntfy
|
||||
- [ntfy-bridge](https://github.com/AlexGaudon/ntfy-bridge) - An application to bridge Discord messages (or webhooks) to ntfy.
|
||||
- [ntailfy](https://github.com/leukosaima/ntailfy) - ntfy notifications when Tailscale devices connect/disconnect (Go)
|
||||
- [BRun](https://github.com/cbrake/brun) - Native Linux automation platform connecting triggers to actions without containers (Go)
|
||||
- [Uptime Monitor](https://uptime-monitor.org) - Self-hosted, enterprise-grade uptime monitoring and alerting system (TS)
|
||||
- [send_to_ntfy_extension](https://github.com/TheDuffman85/send_to_ntfy_extension/) ⭐ - A browser extension to send the notifications to ntfy (JS)
|
||||
- [SIA-Server](https://github.com/ZebMcKayhan/SIA-Server) - A light weight, self-hosted notification Server for Honywell Galaxy Flex alarm systems (Python)
|
||||
- [zabbix-ntfy](https://github.com/torgrimt/zabbix-ntfy) - Zabbix server Mediatype to add support for ntfy.sh services
|
||||
- [Rubix Notify](https://wordpress.org/plugins/rubix-notify) - WordPress Integration with ntfy (PHP + React).
|
||||
|
||||
## Blog + forum posts
|
||||
|
||||
- [Push alerts for WHM using ntfy](https://rubixstudios.com.au/insights/push-alerts-for-whm-using-ntfy) - rubixstudios.com.au - 5/2026
|
||||
- [Device notifications via HTTP with ntfy](https://alistairshepherd.uk/writing/ntfy/) - alistairshepherd.uk - 6/2025
|
||||
- [Notifications about (almost) anything with ntfy.sh](https://hamatti.org/posts/notifications-about-almost-anything-with-ntfy-sh/) - hamatti.org - 6/2025
|
||||
- [I set up a self-hosted notification service for everything, and I'll never look back](https://www.xda-developers.com/set-up-self-hosted-notification-service/) ⭐ - xda-developers.com - 5/2025
|
||||
@@ -295,7 +313,7 @@ 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.tedomum.fr](https://ntfy.tedomum.fr/) | 🇫🇷 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 |
|
||||
|
||||
@@ -1,12 +1,198 @@
|
||||
# Privacy policy
|
||||
|
||||
I love free software, and I'm doing this because it's fun. I have no bad intentions, and **I will
|
||||
never monetize or sell your information, and this service and software will always stay free and open.**
|
||||
**Last updated:** June 15, 2026
|
||||
|
||||
Neither the server nor the app record any personal information, or share any of the messages and topics with
|
||||
any outside service. All data is exclusively used to make the service function properly. The only external service
|
||||
I use is Firebase Cloud Messaging (FCM) service, which is required to provide instant Android notifications (see
|
||||
[FAQ](faq.md) for details). To avoid FCM altogether, download the F-Droid version.
|
||||
This privacy policy describes how ntfy ("we", "us", or "our") collects, uses, and handles your information
|
||||
when you use the ntfy.sh service, web app, and mobile applications (Android and iOS).
|
||||
|
||||
For debugging purposes, the ntfy server may temporarily log request paths, remote IP addresses or even topics
|
||||
or messages, though typically this is turned off.
|
||||
## Our commitment to privacy
|
||||
|
||||
We love free software, and we're doing this because it's fun. We have no bad intentions, and **we will
|
||||
never monetize or sell your information**. The ntfy service and software will always stay free and open source.
|
||||
If you don't trust us or your messages are sensitive, you can [self-host your own ntfy server](install.md).
|
||||
|
||||
## Information we collect
|
||||
|
||||
### Account information (optional)
|
||||
|
||||
If you create an account on ntfy.sh, we collect:
|
||||
|
||||
- **Username** - A unique identifier you choose
|
||||
- **Password** - Stored as a secure bcrypt hash (we never store your plaintext password)
|
||||
- **Email address** - If you add an email address to your account for account recovery and password resets, for use
|
||||
with the email notification feature, or if you subscribe to a paid plan (for billing purposes via Stripe). Email
|
||||
addresses you add to your account are verified by sending a confirmation link.
|
||||
- **Phone number** - Only if you enable the phone call notification feature (verified via SMS/call)
|
||||
|
||||
You can use ntfy without creating an account. Anonymous usage is fully supported.
|
||||
|
||||
### Messages and notifications
|
||||
|
||||
- **Message content** - Messages you publish are temporarily cached on our servers (default: 12 hours) to support
|
||||
message polling and to overcome client network disruptions. Messages are deleted after the cache duration expires.
|
||||
- **Attachments** - File attachments are temporarily stored (default: 3 hours) and then automatically deleted.
|
||||
- **Topic names** - The topic names you publish to or subscribe to are processed by our servers.
|
||||
|
||||
### Technical information
|
||||
|
||||
- **IP addresses** - Used for rate limiting to prevent abuse. May be temporarily logged for debugging purposes,
|
||||
though this is typically turned off.
|
||||
- **Access tokens** - If you create access tokens, we store the token value, an optional label, last access time,
|
||||
and the IP address of the last access.
|
||||
- **Web push subscriptions** - If you enable browser notifications, we store your browser's push subscription
|
||||
endpoint to deliver notifications.
|
||||
|
||||
### Billing information (paid plans only)
|
||||
|
||||
If you subscribe to a paid plan, payment processing is handled by Stripe. We store:
|
||||
|
||||
- Stripe customer ID
|
||||
- Subscription status and billing period
|
||||
|
||||
We do not store your credit card numbers or payment details directly. These are handled entirely by Stripe.
|
||||
|
||||
## Third-party services
|
||||
|
||||
To provide the ntfy.sh service, we use the following third-party services:
|
||||
|
||||
### Firebase Cloud Messaging (FCM)
|
||||
|
||||
We use Google's Firebase Cloud Messaging to deliver push notifications to Android and iOS devices. When you
|
||||
receive a notification through the mobile apps (Google Play or App Store versions):
|
||||
|
||||
- Message metadata and content may be transmitted through Google's FCM infrastructure
|
||||
- Google's [privacy policy](https://policies.google.com/privacy) applies to their handling of this data
|
||||
|
||||
**To avoid FCM entirely:** Download the [F-Droid version](https://f-droid.org/en/packages/io.heckel.ntfy/) of
|
||||
the Android app and use a self-hosted server, or use the instant delivery feature with your own server.
|
||||
|
||||
### Twilio (phone calls)
|
||||
|
||||
If you use the phone call notification feature (`X-Call` header), we use Twilio to:
|
||||
|
||||
- Make voice calls to your verified phone number
|
||||
- Send SMS or voice calls for phone number verification
|
||||
|
||||
Your phone number is shared with Twilio to deliver these services. Twilio's
|
||||
[privacy policy](https://www.twilio.com/legal/privacy) applies.
|
||||
|
||||
### Amazon SES (email delivery)
|
||||
|
||||
If you use the email notification feature (`X-Email` header), or when ntfy sends account-related emails (email
|
||||
address verification and password reset links), we use Amazon Simple Email Service (SES) to deliver emails. The
|
||||
recipient email address and message content are transmitted through Amazon's infrastructure. Amazon's
|
||||
[privacy policy](https://aws.amazon.com/privacy/) applies.
|
||||
|
||||
### Stripe (payments)
|
||||
|
||||
If you subscribe to a paid plan, payments are processed by Stripe. Your payment information is handled directly
|
||||
by Stripe and is subject to Stripe's [privacy policy](https://stripe.com/privacy).
|
||||
|
||||
Note: We have explicitly disabled Stripe's telemetry features in our integration.
|
||||
|
||||
### Web push providers
|
||||
|
||||
If you enable browser notifications in the ntfy web app, push messages are delivered through your browser
|
||||
vendor's push service:
|
||||
|
||||
- Google (Chrome)
|
||||
- Mozilla (Firefox)
|
||||
- Apple (Safari)
|
||||
- Microsoft (Edge)
|
||||
|
||||
Your browser's push subscription endpoint is shared with these providers to deliver notifications.
|
||||
|
||||
## Mobile applications
|
||||
|
||||
### Android app
|
||||
|
||||
The Android app is available from two sources:
|
||||
|
||||
- **Google Play Store** - Uses Firebase Cloud Messaging for push notifications. Firebase Analytics is
|
||||
**explicitly disabled** in our app.
|
||||
- **F-Droid** - Does not include any Google services or Firebase. Uses a foreground service to maintain
|
||||
a direct connection to the server.
|
||||
|
||||
The Android app stores the following data locally on your device:
|
||||
|
||||
- Subscribed topics and their settings
|
||||
- Cached notifications
|
||||
- User credentials (if you add a server with authentication)
|
||||
- Application logs (for debugging, stored locally only)
|
||||
|
||||
### iOS app
|
||||
|
||||
The iOS app uses Firebase Cloud Messaging (via Apple Push Notification service) to deliver notifications.
|
||||
The app stores the following data locally on your device:
|
||||
|
||||
- Subscribed topics
|
||||
- Cached notifications
|
||||
- User credentials (if configured)
|
||||
|
||||
## Web application
|
||||
|
||||
The ntfy web app is a static website that stores all data locally in your browser:
|
||||
|
||||
- **IndexedDB** - Stores your subscriptions and cached notifications
|
||||
- **Local Storage** - Stores your preferences and session information
|
||||
|
||||
No cookies are used for tracking. The web app does not have a backend beyond the ntfy API.
|
||||
|
||||
## Data retention
|
||||
|
||||
| Data type | Retention period |
|
||||
|------------------------|---------------------------------------------------|
|
||||
| Messages | 12 hours (configurable by server operators) |
|
||||
| Attachments | 3 hours (configurable by server operators) |
|
||||
| User accounts | Until you delete your account |
|
||||
| Access tokens | Until you revoke them or delete your account |
|
||||
| Email addresses | Until you remove them or delete your account |
|
||||
| Phone numbers | Until you remove them or delete your account |
|
||||
| Web push subscriptions | 60 days of inactivity, then automatically removed |
|
||||
| Server logs | Varies; debugging logs are typically temporary |
|
||||
|
||||
## Self-hosting
|
||||
|
||||
If you prefer complete control over your data, you can [self-host your own ntfy server](install.md).
|
||||
When self-hosting:
|
||||
|
||||
- You control all data storage and retention
|
||||
- You can choose whether to use Firebase, Twilio, email delivery, or any other integrations
|
||||
- No data is shared with ntfy.sh or any third party (unless you configure those integrations)
|
||||
|
||||
The server and all apps are fully open source:
|
||||
|
||||
- Server: [github.com/binwiederhier/ntfy](https://github.com/binwiederhier/ntfy)
|
||||
- Android app: [github.com/binwiederhier/ntfy-android](https://github.com/binwiederhier/ntfy-android)
|
||||
- iOS app: [github.com/binwiederhier/ntfy-ios](https://github.com/binwiederhier/ntfy-ios)
|
||||
|
||||
## Data security
|
||||
|
||||
- All connections to ntfy.sh are encrypted using TLS/HTTPS
|
||||
- Passwords are hashed using bcrypt before storage
|
||||
- Access tokens are generated using cryptographically secure random values
|
||||
- The server does not log message content by default
|
||||
|
||||
## Your rights
|
||||
|
||||
You have the right to:
|
||||
|
||||
- **Access** - View your account information and data
|
||||
- **Delete** - Delete your account and associated data via the web app
|
||||
- **Export** - Your messages are available via the API while cached
|
||||
|
||||
To delete your account, use the account settings in the web app or contact us.
|
||||
|
||||
## Changes to this policy
|
||||
|
||||
We may update this privacy policy from time to time. Changes will be posted on this page with an updated
|
||||
"Last updated" date. You may also review all changes in the [Git history](https://github.com/binwiederhier/ntfy/commits/main/docs/privacy.md).
|
||||
|
||||
For significant changes, we may provide additional notice on Discord/Matrix or through the
|
||||
[announcements](https://ntfy.sh/announcements) ntfy topic.
|
||||
|
||||
## Contact
|
||||
|
||||
For privacy-related inquiries, please email [privacy@mail.ntfy.sh](mailto:privacy@mail.ntfy.sh).
|
||||
|
||||
For all other contact options, see the [contact page](contact.md).
|
||||
|
||||
@@ -2,7 +2,633 @@
|
||||
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
|
||||
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
|
||||
|
||||
### ntfy server v2.13.0
|
||||
## Current stable releases
|
||||
|
||||
| Component | Version | Release date |
|
||||
|------------------|---------|---------------|
|
||||
| ntfy server | v2.27.0 | Aug 4, 2026 |
|
||||
| ntfy Android app | v1.25.2 | July 23, 2026 |
|
||||
| ntfy iOS app | v1.7.0 | May 30, 2026 |
|
||||
|
||||
Please check out the release notes for [upcoming releases](#not-released-yet) below.
|
||||
|
||||
### ntfy server v2.27.0
|
||||
Released August 4, 2026
|
||||
|
||||
This release lets you sign in with your verified email address instead of your username, which should help if you ever
|
||||
signed up with an email and then forgot which username you picked. It also hardens the message templating engine against
|
||||
a few ways a small template could eat a lot of memory, and it drops the "experimental" label from
|
||||
[PostgreSQL support](config.md#postgresql), which has been running ntfy.sh for a while now.
|
||||
|
||||
I also did a bunch of refactoring in, mostly in preparation for being able to cluster ntfy nodes and scale the service
|
||||
horizontally. It'll be a while until then, ... baby steps.
|
||||
|
||||
**Security:**
|
||||
|
||||
* Limit message templates (`Template: yes`) to 32 KB, limit `printf` widths and precisions to below 1000, and limit `indent`/`nindent` to 100 spaces, preventing excessive memory use from a single small template
|
||||
* Exclude secrets from the config hash served to the web app, preventing a rather theoretical information leak
|
||||
|
||||
**Features:**
|
||||
|
||||
* Allow logging in with your verified primary email address (in addition to your username), so a password reset no longer leaves you unable to sign in when you only remember the email you signed up with
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Fix Twilio phone calls and phone number verifications failing silently when Twilio rejected the request, and move the Twilio integration into its own `twilio` package
|
||||
* Move the Prometheus metrics into a dedicated `metrics` package
|
||||
* Message cache databases from ntfy older than v1.10.0 (November 2021) can no longer be migrated; upgrade via an older ntfy version first, or delete the cache database
|
||||
* Fix `user_phone` table in the SQLite user database referencing a dropped table after the v2.14 schema migration; repaired automatically by a new migration
|
||||
|
||||
## ntfy Android v1.25.2
|
||||
Released July 23, 2026
|
||||
|
||||
This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out
|
||||
or even gave ntfy bad reviews. I heard you! You can re-enable the alert in the advanced settings.
|
||||
|
||||
The release also tries to be smarter about not retrying the connection at all if the app is in flight mode, or has no network. If there
|
||||
is no network, ntfy now keeps the foreground service alive and shows a "Waiting for network" notification, then resumes automatically
|
||||
once connectivity returns.
|
||||
|
||||
Another change related to the networking is that we now force-reconnect when the connection is changed, e.g. during transitions
|
||||
from Wi-Fi to cellular network, or vice versa. That should allow for faster transitions during hand-overs.
|
||||
|
||||
We also increase the client-side WebSocket ping interval from 1 minute to 3 minutes, which should slightly improve battery life,
|
||||
especially when paired with increaseing the server-side `keepalive-interval` in your self-hosted server.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Add configurable "Alert when connection is lost" setting, turned off by default ([#1665](https://github.com/binwiederhier/ntfy/issues/1665), [#1662](https://github.com/binwiederhier/ntfy/issues/1662), [#1652](https://github.com/binwiederhier/ntfy/issues/1652), [#1655](https://github.com/binwiederhier/ntfy/issues/1655), thanks to [@tintamarre](https://github.com/tintamarre), [@sjozs](https://github.com/sjozs), [@TheRealOne78](https://github.com/TheRealOne78), and [@DAE51D](https://github.com/DAE51D) for reporting)
|
||||
* Handle "no network" gracefully: when the device is offline or in airplane mode, ntfy now stops retrying, suppresses the connection-lost alert, and keeps the foreground service alive with a "Waiting for network" notification, resuming instant delivery automatically when connectivity returns ([ntfy-android#165](https://github.com/binwiederhier/ntfy-android/pull/165), thanks to [@tintamarre](https://github.com/tintamarre) for the contribution, and [#1709](https://github.com/binwiederhier/ntfy/issues/1709), thanks to [@isaitgirl](https://github.com/isaitgirl) for reporting)
|
||||
* Improve battery life by increasing WebSocket client ping interval from 1 min to 3 min, and reconnect instantly on Wi-Fi/cellular/VPN transitions ([ntfy-android#113](https://github.com/binwiederhier/ntfy-android/pull/113), thanks to [@ftilde](https://github.com/ftilde) for the investigation)
|
||||
* Disable UnifiedPush components when UnifiedPush is disabled in settings ([ntfy-android#168](https://github.com/binwiederhier/ntfy-android/pull/168), thanks to [@p1gp1g](https://github.com/p1gp1g) for the contribution)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Fix the "connection lost" alert briefly disappearing and re-firing when roaming between networks (e.g. Wi-Fi to cellular), by no longer cancelling it during the transient no-network gap of a handover
|
||||
* Fix the "connection lost" alert repeatedly waking the screen while a server stayed unreachable, by no longer re-posting the alert once it is already showing
|
||||
* Fix the "connection lost" alert firing late, erratically, or not at all when a connection kept dropping (e.g. a flaky server) rather than being refused outright, by tracking how long the connection has been down independently of whether the drop warrants a UI error
|
||||
* Undo automatic phone number linking for numbers in message body ([ntfy-android#170](https://github.com/binwiederhier/ntfy-android/pull/170), thanks to [@acortelyou](https://github.com/acortelyou) for the contribution)
|
||||
* Fix subscription icons disappearing after a few days due to Android clearing cache ([#1322](https://github.com/binwiederhier/ntfy/issues/1322), thanks to [@mcanning](https://github.com/mcanning) for reporting)
|
||||
* Fix UnifiedPush `failed_reason` being sent as an enum instead of a string, which caused an exception in receiving apps that read it as a string extra ([ntfy-android#182](https://github.com/binwiederhier/ntfy-android/pull/182), thanks to [@p1gp1g](https://github.com/p1gp1g) for the contribution)
|
||||
|
||||
### ntfy server v2.26.3
|
||||
Released July 20, 2026
|
||||
|
||||
This is a hotfix release, useful pretty much only for ntfy.sh. It was adds the ability to track abusive IPs more
|
||||
efficiently, reducing the load on the IP banning services and preventing them from falling behind and leaving abusers
|
||||
unbanned for too long. It works by tracking HTTP errors, and writing out a ban file that fail2ban can read and ban
|
||||
offenders instantly. See [ban-feed](config.md#ban-feed) for details.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Add an abuse ban-feed: when enabled via `ban-file`, ntfy tracks a weighted strike budget per visitor and appends abusive IPs to a file that fail2ban can tail and ban on sight (`ban-file`, `ban-window`, `ban-threshold`, `ban-weights`; see [ban-feed docs](config.md#ban-feed))
|
||||
|
||||
### ntfy server v2.26.0
|
||||
Released July 9, 2026
|
||||
|
||||
This release hardens **message templates**, which are now executed with a hard-capped execution timeout. This closes
|
||||
a denial-of-service hole.
|
||||
|
||||
On the web app side, it adds configurable **date and time formats**, a smoother loading and page-transition experience,
|
||||
and a fix that strips unsafe URL protocols from rendered Markdown.
|
||||
|
||||
**Security:**
|
||||
|
||||
* Prevent a CPU denial of service via message templates (`Template: yes`) ([#1826](https://github.com/binwiederhier/ntfy/pull/1826), thanks to [@alanturing881](https://github.com/alanturing881), [@5ud0er](https://github.com/5ud0er) and [@jvoisin](https://github.com/jvoisin) for reporting)
|
||||
|
||||
**Features:**
|
||||
|
||||
* Web app: Add "Date format" and "Time format" settings (Settings -> Appearance), with ISO 8601, day/month/year (slash or dot) and month/day/year date options and a 12-/24-hour clock option, and base the default format on your browser/system locale rather than the selected display language. When logged in, both settings sync across devices via your account ([#1647](https://github.com/binwiederhier/ntfy/issues/1647), thanks to [@wsw70](https://github.com/wsw70) for reporting)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Web app: Smooth transitions and loading animation, remove flickering
|
||||
* Web app: `GET /account` now reads from the primary database instead of a read replica, so the account view no longer shows stale data right after a change when replicas lag behind
|
||||
* Docs: Document the third-party HelmForge Helm chart as a Kubernetes installation option ([#1727](https://github.com/binwiederhier/ntfy/issues/1727), thanks to [@mberlofa](https://github.com/mberlofa))
|
||||
* Web app: Strip unsafe URL protocols (`javascript:`, `data:`, ...) from links and images in Markdown-rendered messages, so they no longer trigger an uncaught "React has blocked a javascript: URL" error (thanks to [@jvoisin](https://github.com/jvoisin) for reporting)
|
||||
|
||||
## ntfy server v2.25.0
|
||||
Released June 24, 2026
|
||||
|
||||
This release adds **password reset** via email, and reworks email verification to use durable,
|
||||
link-based magic links (replacing the old in-memory 6-digit codes). Email stays optional at
|
||||
signup; a user can reset their password only once they have a verified "primary" (recovery)
|
||||
email.
|
||||
|
||||
All of this work is probably not useful for self-hosters, but it hopefully will be useful for me,
|
||||
since I do have to reset accounts on a regular basis.
|
||||
|
||||
**Security issues:**
|
||||
|
||||
* Generate access tokens, IDs, and magic-link tokens with a cryptographically secure RNG (`crypto/rand`) instead of a clock-seeded PRNG
|
||||
|
||||
**Features:**
|
||||
|
||||
* Add password reset via emailed magic link, with a "Forgot password" link on the login page and a `ntfy user reset-pass` CLI command for admins
|
||||
* Rework email verification to use durable, single-use, expiring magic links instead of in-memory 6-digit codes, and add a "primary" email (used for account recovery and as the `X-Email: yes` target) with verified/unverified state in the account UI
|
||||
* You can now clear/read messages and delete messages with a GET request ([#1771](https://github.com/binwiederhier/ntfy/issues/1771), thanks to [@lemmi](https://github.com/lemmi) for reporting and to [@wunter8](https://github.com/wunter8) for implementing)
|
||||
* Add a reload button to the web app's action bar when running as an installed PWA, which clears the service worker caches and hard-refreshes the app ([#1281](https://github.com/binwiederhier/ntfy/issues/1281), thanks to [@leanza](https://github.com/leanza) for reporting)
|
||||
* Add a "Back to app" link to the web app's login, signup, and password-reset pages (alongside the existing links), which previously had no way back to the app
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* `X-Email: yes` (also `true`/`1`) now sends to your primary verified email regardless of the `smtp-sender-verify` setting (previously it was rejected unless verification was enabled); it requires being logged in with a verified address
|
||||
* Grant users full access to their own sync topic (`st_...`) so cross-device subscription sync works under `auth-default-access: deny-all` ([#733](https://github.com/binwiederhier/ntfy/issues/733), [#1795](https://github.com/binwiederhier/ntfy/pull/1795), thanks to [@lmorchard](https://github.com/lmorchard) for the contribution)
|
||||
* Support HTTP (non-TLS) S3-compatible endpoints by preserving the endpoint scheme, e.g. for a local MinIO instance ([#1794](https://github.com/binwiederhier/ntfy/pull/1794), [#1734](https://github.com/binwiederhier/ntfy/issues/1734), thanks to [@sskender](https://github.com/sskender) for the contribution, and [@Kernald](https://github.com/Kernald) for reporting)
|
||||
* Stop silently stripping spaces from passwords while typing in the web app's login, signup, and password-reset forms ([#1246](https://github.com/binwiederhier/ntfy/issues/1246), thanks to [@aldem](https://github.com/aldem) for reporting)
|
||||
* Update web app dependencies, including major-version upgrades to Vite (6 -> 8, now Rolldown-based), Material UI (5 -> 9), and Dexie (3 -> 4) ([#1800](https://github.com/binwiederhier/ntfy/pull/1800), [#1764](https://github.com/binwiederhier/ntfy/pull/1764), [#1767](https://github.com/binwiederhier/ntfy/pull/1767), [#1762](https://github.com/binwiederhier/ntfy/pull/1762), [#1766](https://github.com/binwiederhier/ntfy/pull/1766), [#1765](https://github.com/binwiederhier/ntfy/pull/1765), thanks Dependabot)
|
||||
* Play notification sounds in the web app even when the Notification API is unavailable, e.g. over plain HTTP or in browsers without notification support ([#1772](https://github.com/binwiederhier/ntfy/pull/1772), thanks to [@mitya12342](https://github.com/mitya12342) for the contribution)
|
||||
* Stop escaping `<`, `>`, and `&` as `\u003c`/`\u003e`/`\u0026` in JSON responses ([#1511](https://github.com/binwiederhier/ntfy/issues/1511), [#1512](https://github.com/binwiederhier/ntfy/pull/1512), thanks to [@wunter8](https://github.com/wunter8) for the contribution)
|
||||
* Fix the web app navbar not reflecting a topic reservation (lock icon, and "Reserve topic" -> "Change reservation"/"Remove reservation" menu) until a page reload, by persisting reservation and display-name changes onto already-subscribed topics during account sync
|
||||
* Reduce the web app's initial bundle size by ~300 KB (~50 KB gzipped) by lazy-loading the emoji picker dataset and the Markdown renderer, and by importing Material UI icons individually
|
||||
|
||||
## ntfy server v2.24.0
|
||||
Released June 4, 2026
|
||||
|
||||
The main feature for this release is an in-memory ACL cache (`auth-access-cache`) that can help bring down the read load
|
||||
on the production database. The topic authorization queries are consistently the highest ranking queries on the database,
|
||||
so this will help quite a bit. The current database load is quite low, but I'm expecting it to increase as more users join
|
||||
and use ntfy.
|
||||
|
||||
**Security issues:**
|
||||
|
||||
* Fix case-insensitive ACL topic matching on SQLite: an access control rule for `secret` no longer also matches a request for `SECRET`. SQLite's `LIKE` is case-insensitive for ASCII by default. PostgreSQL was unaffected. It's honestly incredible that this issue remained undetected for so long, especially while ntfy.sh was running on SQLite (it now runs on PostgreSQL).
|
||||
|
||||
**Features:**
|
||||
|
||||
* Add opt-in in-memory ACL cache (`auth-access-cache`) that serves topic authorization without a database round-trip; off by default, intended for high-volume servers
|
||||
* Add `ntfy --version` flag to the CLI ([#1722](https://github.com/binwiederhier/ntfy/issues/1722), [#1748](https://github.com/binwiederhier/ntfy/pull/1748), thanks to [@sskender](https://github.com/sskender) for the contribution, and [@Saucy9607](https://github.com/Saucy9607) for reporting)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Extend account token automatically from the PWA service worker, so installed PWAs don't get logged out ([#1669](https://github.com/binwiederhier/ntfy/pull/1669), [#1203](https://github.com/binwiederhier/ntfy/issues/1203), [#1533](https://github.com/binwiederhier/ntfy/issues/1533), thanks to [@nihalgonsalves](https://github.com/nihalgonsalves) for the contribution)
|
||||
* Fix `rel` attribute on auto-linked notification URLs so `noreferrer`/`noopener` are actually applied ([#1720](https://github.com/binwiederhier/ntfy/pull/1720), thanks to [@dmitrylyzo](https://github.com/dmitrylyzo) for the contribution)
|
||||
* Add systemd sandboxing/hardening to the `ntfy.service` unit ([#1467](https://github.com/binwiederhier/ntfy/pull/1467), thanks to [@Velocifyer](https://github.com/Velocifyer) for the contribution)
|
||||
* Fix `cmd` package build on macOS (darwin) so the server compiles from source ([#1631](https://github.com/binwiederhier/ntfy/issues/1631), [#1696](https://github.com/binwiederhier/ntfy/pull/1696), thanks to [@ShipItAndPray](https://github.com/ShipItAndPray) for the contribution, and [@XYenon](https://github.com/XYenon) for reporting)
|
||||
|
||||
## ntfy iOS app v1.7.0
|
||||
Released May 30, 2026
|
||||
|
||||
This release brings **image and attachment support** to the iOS app, finally closing one of the longest-standing iOS
|
||||
feature gaps. Images sent via the `Attach` header (or as a PUT body) are now previewed inline in the notification banner
|
||||
and inside the app, and other attachments can be downloaded, previewed via Quick Look, and shared from the notification
|
||||
row. There's also a new "Download attachments" setting to control auto-download by size.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Show image previews in notifications and inline in the notification list, with tap-to-zoom Quick Look preview and share sheet ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), [#276](https://github.com/binwiederhier/ntfy/issues/276), [#1226](https://github.com/binwiederhier/ntfy/issues/1226), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
* Download non-image attachments on demand with progress indication, persist them locally, and reuse files already fetched by the notification service extension ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
* Add "Download attachments" setting with size thresholds (Never, Under 100 KB / 500 KB / 1 MB / 5 MB / 10 MB / 50 MB, Always) to control automatic attachment downloads ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Improve background download reliability so attachments continue downloading when the app is suspended ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
* Reorganize notification and subscription views into their own folders and split out `NotificationRowView` for readability ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
|
||||
## ntfy server v2.23.0
|
||||
Released May 17, 2026
|
||||
|
||||
**Features:**
|
||||
|
||||
* Add per-visitor rate limit on new topic creations (`visitor-topic-creation-limit-burst` / `visitor-topic-creation-limit-replenish`, defaults 100 burst / 1m replenish) to mitigate topic-enumeration / squatting attacks that inflate the in-memory topic map
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Remove `stacktrace-js`, `stacktrace-gps`, `humanize-duration`, and `js-base64` from the web app to reduce dependency and security footprint
|
||||
* Restrict the publish dialog's local file preview to safe image types (png/jpg/gif/webp) to prevent same-origin script execution from blob URLs when previewing a crafted SVG ([GHSA-j8hr-p342-xrmh](https://github.com/binwiederhier/ntfy/security/advisories/GHSA-j8hr-p342-xrmh), thanks to [@Venukamatchi](https://github.com/Venukamatchi) for reporting)
|
||||
|
||||
## ntfy iOS app v1.6.0
|
||||
Released May 12, 2026
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Fix crash in iOS v1.5.1 ([#1736](https://github.com/binwiederhier/ntfy/issues/1736), thanks to [@russ-who](https://github.com/russ-who) for reporting and [@am7590](https://github.com/am7590) for fixing)
|
||||
|
||||
**Features:**
|
||||
|
||||
* Tap a notification to open its click URL, or copy the message text if no click URL is set; inline URLs in message text are now tappable as well ([ntfy-ios#37](https://github.com/binwiederhier/ntfy-ios/pull/37), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
|
||||
## ntfy iOS app v1.5.1
|
||||
Released April 27, 2026
|
||||
|
||||
This release continues the iOS stability push from v1.4.1, with improved background polling reliability, better error
|
||||
handling and logging, and a few small UI fixes. The minimum supported iOS version is now iOS 15.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Bump minimum iOS version to iOS 15 ([ntfy-ios#36](https://github.com/binwiederhier/ntfy-ios/pull/36), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Improve background poll reliability by waiting for polls to finish before calling the fetch completion handler, and saving notifications on the Core Data context queue ([ntfy-ios#34](https://github.com/binwiederhier/ntfy-ios/pull/34), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
* Make `poll_request` parsing more tolerant and surface concrete poll errors instead of failing silently ([ntfy-ios#34](https://github.com/binwiederhier/ntfy-ios/pull/34), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
* Poll subscriptions when the subscribed topics list appears, for more reactive updates ([ntfy-ios#34](https://github.com/binwiederhier/ntfy-ios/pull/34), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
* Fix bug where tapping "Add user" a second time would briefly open and then dismiss the add user view ([ntfy-ios#35](https://github.com/binwiederhier/ntfy-ios/pull/35), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
* Split `SettingsView` into separate files to improve readability ([ntfy-ios#35](https://github.com/binwiederhier/ntfy-ios/pull/35), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
* Add Firebase subscribe/unsubscribe logging to aid debugging ([ntfy-ios#34](https://github.com/binwiederhier/ntfy-ios/pull/34), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
|
||||
## ntfy server v2.22.0
|
||||
Released April 21, 2026
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Tighten web push endpoint allow-list regex to prevent SSRF via unanchored pattern matching ([GHSA-w9hq-5jg7-q4j7](https://github.com/binwiederhier/ntfy/security/advisories/GHSA-w9hq-5jg7-q4j7), thanks to [@MightyNawaf](https://github.com/MightyNawaf) for reporting)
|
||||
* Fix web app not allowing access tokens to be changed to never expire ([#1693](https://github.com/binwiederhier/ntfy/issues/1693)/[#1694](https://github.com/binwiederhier/ntfy/pull/1694), thanks to [@lastsamurai26](https://github.com/lastsamurai26) for reporting and to [@ShipItAndPray](https://github.com/ShipItAndPray) for fixing)
|
||||
* Fix web app crashing on account page for tokens without a last access time ([#1651](https://github.com/binwiederhier/ntfy/issues/1651), [#1684](https://github.com/binwiederhier/ntfy/issues/1684), thanks to [@Pulsar7](https://github.com/Pulsar7) and [@rzhli](https://github.com/rzhli) for reporting)
|
||||
|
||||
## ntfy iOS app v1.4.1
|
||||
Released April 14, 2026
|
||||
|
||||
This is the first iOS release in 3 years, focusing on stability fixes as per the [iOS improvement plan](https://github.com/binwiederhier/ntfy/issues/1680).
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Fix crash when deleting notifications ([ntfy-ios#33](https://github.com/binwiederhier/ntfy-ios/pull/33), [#1642](https://github.com/binwiederhier/ntfy/issues/1642), [#377](https://github.com/binwiederhier/ntfy/issues/377), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
* Fix topic normalization for base URLs and refresh list after sending test notification ([ntfy-ios#32](https://github.com/binwiederhier/ntfy-ios/pull/32), [#337](https://github.com/binwiederhier/ntfy/issues/337), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
|
||||
## ntfy server v2.21.0
|
||||
Released March 30, 2026
|
||||
|
||||
This release adds the ability to verify email addresses using the `smtp-sender-verify` flag. This is a change that is
|
||||
required because ntfy.sh was used to send unsolicited emails and the AWS SES account was suspended. Going forward,
|
||||
ntfy.sh won't be able to send emails unless the email address was verified ahead of time.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Add verified email recipients feature with `smtp-sender-verify` config flag, allowing server admins to require email
|
||||
address verification before sending email notifications ([#1681](https://github.com/binwiederhier/ntfy/pull/1681))
|
||||
|
||||
## ntfy server v2.20.1
|
||||
Released March 27, 2026
|
||||
|
||||
This is a small bugfix release that only affects high volume S3 backends that struggle with HTTP/2.
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* [Attachments](config.md#attachments): Add `disable_http2=true` S3 URL option to work around HTTP/2 stream errors with DigitalOcean Spaces and other S3-compatible providers ([#1678](https://github.com/binwiederhier/ntfy/issues/1678)/[#1679](https://github.com/binwiederhier/ntfy/pull/1679))
|
||||
|
||||
## ntfy server v2.20.0
|
||||
Released March 26, 2026
|
||||
|
||||
This release is another step towards making it possible to help scale ntfy up and out 🔥! With this release, you can store
|
||||
attachments in an S3-compatible object store as an alterative to the directory. See [attachment store](config.md#attachments)
|
||||
for details.
|
||||
|
||||
!!! warning
|
||||
With this release, ntfy will take full control over the attachment directory or S3 bucket. Files/objects in the configured `attachment-cache-dir`
|
||||
that match the message ID format (12 chars, matching `^[A-Za-z0-9]{12}$`), and have no entries in the message database will be deleted.
|
||||
**Do not use a directory or S3 bucket as `attachment-cache-dir` that is also used for something else.**
|
||||
|
||||
This is a small behavioral change that was necessary because the old logic often left attachments behind and would not clean them
|
||||
up. Unless you have re-used the attachment directory for anything else (which is hopefully never done), this should not affect
|
||||
you at all.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Add S3-compatible object storage as an alternative [attachment store](config.md#attachments) via `attachment-cache-dir` config option ([#1656](https://github.com/binwiederhier/ntfy/pull/1656)/[#1672](https://github.com/binwiederhier/ntfy/pull/1672))
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Reject invalid e-mail addresses (e.g. multiple comma-separated recipients) with HTTP 400
|
||||
* Add OpenRC init service file ([#1650](https://github.com/binwiederhier/ntfy/pull/1650), thanks to [@ageru](https://github.com/ageru) for the contribution)
|
||||
|
||||
## ntfy server v2.19.2
|
||||
Released March 16, 2026
|
||||
|
||||
This is another small bugfix release for PostgreSQL, avoiding races between primary and read replica, as well as to
|
||||
further reduce primary load.
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Fix race condition in web push subscription causing FK constraint violation when concurrent requests hit the same endpoint
|
||||
* Route authorization query to read-only database replica to reduce primary database load
|
||||
|
||||
## ntfy server v2.19.1
|
||||
Released March 15, 2026
|
||||
|
||||
This is a bugfix release to avoid PostgreSQL insert failures due to invalid UTF-8 messages. It also fixes `database-url`
|
||||
validation incorrectly rejecting `postgresql://` connection strings.
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Fix invalid UTF-8 in HTTP headers (e.g. Latin-1 encoded text) causing PostgreSQL insert failures and dropping entire message batches
|
||||
* Fix `database-url` validation rejecting `postgresql://` connection strings ([#1657](https://github.com/binwiederhier/ntfy/issues/1657)/[#1658](https://github.com/binwiederhier/ntfy/pull/1658))
|
||||
|
||||
## ntfy server v2.19.0
|
||||
Released March 15, 2026
|
||||
|
||||
This is a fast-follow release that enables Postgres read replica support.
|
||||
|
||||
To offload read-heavy queries from the primary database, you can optionally configure one or more read replicas
|
||||
using the `database-replica-urls` option. When configured, non-critical read-only queries (e.g. fetching messages,
|
||||
checking access permissions, etc) are distributed across the replicas using round-robin, while all writes and
|
||||
correctness-critical reads continue to go to the primary. If a replica becomes unhealthy, ntfy automatically falls back
|
||||
to the primary until the replica recovers.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Support [PostgreSQL read replicas](config.md#postgresql) for offloading non-critical read queries via `database-replica-urls` config option ([#1648](https://github.com/binwiederhier/ntfy/pull/1648))
|
||||
* Add interactive [config generator](config.md#config-generator) to the documentation to help create server configuration files ([#1654](https://github.com/binwiederhier/ntfy/pull/1654))
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Web: Throttle notification sound in web app to play at most once every 2 seconds (similar to [#1550](https://github.com/binwiederhier/ntfy/issues/1550), thanks to [@jlaffaye](https://github.com/jlaffaye) for reporting)
|
||||
* Web: Add hover tooltips to icon buttons in web app account and preferences pages ([#1565](https://github.com/binwiederhier/ntfy/issues/1565), thanks to [@jermanuts](https://github.com/jermanuts) for reporting)
|
||||
|
||||
## ntfy server v2.18.0
|
||||
Released March 7, 2026
|
||||
|
||||
This is the biggest release I've ever done on the server. It's 14,997 added lines of code, and 10,202 lines removed, all from
|
||||
one [pull request](https://github.com/binwiederhier/ntfy/pull/1619) that adds [PostgreSQL support](config.md#postgresql).
|
||||
|
||||
The code was written by Cursor and Claude, but reviewed and heavily tested over 2-3 weeks by me. I created comparison documents,
|
||||
went through all queries multiple times and reviewed the logic over and over again. I also did load tests and manual regression tests,
|
||||
which took lots of evenings.
|
||||
|
||||
I'll not instantly switch ntfy.sh over. Instead, I'm kindly asking the community to test the Postgres support and report back to me
|
||||
if things are working (or not working). There is a [one-off migration tool](https://github.com/binwiederhier/ntfy/tree/main/tools/pgimport) (entirely written by AI) that you can use to migrate.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Add experimental [PostgreSQL support](config.md#postgresql) as an alternative database backend (message cache, user manager, web push subscriptions) via `database-url` config option ([#1114](https://github.com/binwiederhier/ntfy/issues/1114)/[#1619](https://github.com/binwiederhier/ntfy/pull/1619), thanks to [@brettinternet](https://github.com/brettinternet) for reporting)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Preserve `<br>` line breaks in HTML-only emails received via SMTP ([#690](https://github.com/binwiederhier/ntfy/issues/690), [#1620](https://github.com/binwiederhier/ntfy/pull/1620), thanks to [@uzkikh](https://github.com/uzkikh) for the fix and to [@teastrainer](https://github.com/teastrainer) for reporting)
|
||||
|
||||
## ntfy Android v1.24.0
|
||||
Released March 5, 2026
|
||||
|
||||
This is a tiny release that will revert the "reconnecting ..." behavior of the foreground notification. Lots of people
|
||||
have complained about it, so I'm replacing it with a notification that shows up when the server connection has failed
|
||||
for >15 minutes, hoping that people will be less annoyed by that.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Show notification when connection to server has been lost for 15+ minutes, with dismiss, snooze and never-show-again actions
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Fix crash in settings when fragment is detached during backup/restore or log operations
|
||||
|
||||
## ntfy Android v1.23.0
|
||||
Released February 22, 2026
|
||||
|
||||
This release adds support for search within a topic, and adds [copy action](publish.md#copy-to-clipboard) support
|
||||
to the Android app.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Search within a topic ([#141](https://github.com/binwiederhier/ntfy/issues/141), [ntfy-android#153](https://github.com/binwiederhier/ntfy-android/pull/153), thanks to [@Copephobia](https://github.com/Copephobia) and [@StoyanYonkov](https://github.com/StoyanYonkov) for reporting and sponsoring)
|
||||
* Add "reconnecting to N topics ..." to foreground notification ([#1101](https://github.com/binwiederhier/ntfy/issues/1101), thanks to [@milosivanovic](https://github.com/milosivanovic) for reporting)
|
||||
* Improved default server dialog with full-screen UI and stricter URL validation ([#1582](https://github.com/binwiederhier/ntfy/issues/1582))
|
||||
* Show last notification time for UnifiedPush subscriptions ([#1230](https://github.com/binwiederhier/ntfy/issues/1230), [#1454](https://github.com/binwiederhier/ntfy/issues/1454), thanks to [@Tealk](https://github.com/Tealk) and [@user4andre](https://github.com/user4andre) for reporting)
|
||||
* Support "copy" action button to copy a value to the clipboard ([#1364](https://github.com/binwiederhier/ntfy/issues/1364), thanks to [@SudoWatson](https://github.com/SudoWatson) for reporting)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Fix `clear=true` on action buttons not marking notification as read ([#1029](https://github.com/binwiederhier/ntfy/issues/1029), thanks to [@ElFishi](https://github.com/ElFishi) for reporting)
|
||||
* Fix crash when default server URL is missing scheme by auto-prepending `https://` ([#1582](https://github.com/binwiederhier/ntfy/issues/1582), thanks to [@hard-zero1](https://github.com/hard-zero1))
|
||||
* Fix notification timestamp to use original send time instead of receive time ([#1112](https://github.com/binwiederhier/ntfy/issues/1112), thanks to [@voruti](https://github.com/voruti) for reporting)
|
||||
* Fix notifications being missed after service restart by using persisted lastNotificationId ([#1591](https://github.com/binwiederhier/ntfy/issues/1591), thanks to @Epifeny for reporting)
|
||||
|
||||
## ntfy server v2.17.0
|
||||
Released February 8, 2026
|
||||
|
||||
This release adds support for templating in the priority field, a new "copy" action button to copy values to the clipboard,
|
||||
a red notification dot on the favicon for unread messages, and an admin-only version endpoint. It also includes several
|
||||
crash fixes, web app improvements, and documentation updates.
|
||||
|
||||
❤️ If you like ntfy, **please consider sponsoring me** via [GitHub Sponsors](https://github.com/sponsors/binwiederhier), [Liberapay](https://en.liberapay.com/ntfy/), Bitcoin (`1626wjrw3uWk9adyjCfYwafw4sQWujyjn8`),
|
||||
or by buying a [paid plan via the web app](https://ntfy.sh/app). ntfy will always remain open source.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Server: Support templating in the priority field ([#1426](https://github.com/binwiederhier/ntfy/issues/1426), thanks to [@seantomburke](https://github.com/seantomburke) for reporting)
|
||||
* Server: Add admin-only `GET /v1/version` endpoint returning server version, build commit, and date ([#1599](https://github.com/binwiederhier/ntfy/issues/1599), thanks to [@crivchri](https://github.com/crivchri) for reporting)
|
||||
* Server/Web: [Support "copy" action](publish.md#copy-to-clipboard) button to copy a value to the clipboard ([#1364](https://github.com/binwiederhier/ntfy/issues/1364), thanks to [@SudoWatson](https://github.com/SudoWatson) for reporting)
|
||||
* Web: Show red notification dot on favicon when there are unread messages ([#1017](https://github.com/binwiederhier/ntfy/issues/1017), thanks to [@ad-si](https://github.com/ad-si) for reporting)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Server: Fix crash when commit string is shorter than 7 characters in non-GitHub-Action builds ([#1493](https://github.com/binwiederhier/ntfy/issues/1493), thanks to [@cyrinux](https://github.com/cyrinux) for reporting)
|
||||
* Server: Fix server crash (nil pointer panic) when subscriber disconnects during publish ([#1598](https://github.com/binwiederhier/ntfy/pull/1598))
|
||||
* Server: Fix log spam from `http: response.WriteHeader on hijacked connection` for WebSocket errors ([#1362](https://github.com/binwiederhier/ntfy/issues/1362), thanks to [@bonfiresh](https://github.com/bonfiresh) for reporting)
|
||||
* Server: Use `slices.Contains` from stdlib to simplify code ([#1406](https://github.com/binwiederhier/ntfy/pull/1406), thanks to [@tanhuaan](https://github.com/tanhuaan))
|
||||
* Web: Fix `clear=true` on action buttons not clearing the notification ([#1029](https://github.com/binwiederhier/ntfy/issues/1029), thanks to [@ElFishi](https://github.com/ElFishi) for reporting)
|
||||
* Web: Fix Markdown message line height to match plain text (1.5 instead of 1.2) ([#1139](https://github.com/binwiederhier/ntfy/issues/1139), thanks to [@etfz](https://github.com/etfz) for reporting)
|
||||
* Web: Fix long lines (e.g. JSON) being truncated by adding horizontal scroll ([#1363](https://github.com/binwiederhier/ntfy/issues/1363), thanks to [@v3DJG6GL](https://github.com/v3DJG6GL) for reporting)
|
||||
* Web: Fix Windows notification icon being cut off ([#884](https://github.com/binwiederhier/ntfy/issues/884), thanks to [@ZhangTianrong](https://github.com/ZhangTianrong) for reporting)
|
||||
* Web: Use full URL in curl example on empty topic pages ([#1435](https://github.com/binwiederhier/ntfy/issues/1435), [#1535](https://github.com/binwiederhier/ntfy/pull/1535), thanks to [@elmatadoor](https://github.com/elmatadoor) for reporting and [@jjasghar](https://github.com/jjasghar) for the PR)
|
||||
* Web: Add validation feedback for service URL when adding user ([#1566](https://github.com/binwiederhier/ntfy/issues/1566), thanks to [@jermanuts](https://github.com/jermanuts))
|
||||
* Docs: Remove obsolete `version` field from docker-compose examples ([#1333](https://github.com/binwiederhier/ntfy/issues/1333), thanks to [@seals187](https://github.com/seals187) for reporting and [@cyb3rko](https://github.com/cyb3rko) for fixing)
|
||||
* Docs: Fix Kustomize config in installation docs ([#1367](https://github.com/binwiederhier/ntfy/issues/1367), thanks to [@toby-griffiths](https://github.com/toby-griffiths))
|
||||
* Docs: Use SVG F-Droid badge and add app store badges to README ([#1170](https://github.com/binwiederhier/ntfy/issues/1170), thanks to [@PanderMusubi](https://github.com/PanderMusubi) for reporting)
|
||||
|
||||
## ntfy Android app v1.22.2
|
||||
Released January 20, 2026
|
||||
|
||||
This release adds support for [updating and deleting notifications](publish.md#updating-deleting-notifications) (requires server v2.16.0),
|
||||
as well as [certificate management for self-signed certs and mTLS client certificates](subscribe/phone.md#manage-certificates),
|
||||
and a new connection error dialog to help [troubleshoot connection issues](subscribe/phone.md#troubleshooting).
|
||||
|
||||
<div id="v1221-screenshots-1" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-notification-update-1.png"><img src="../../static/img/android-screenshot-notification-update-1.png"/></a>
|
||||
<a href="../../static/img/android-screenshot-notification-update-2.png"><img src="../../static/img/android-screenshot-notification-update-2.png"/></a>
|
||||
</div>
|
||||
|
||||
<div id="v1221-screenshots-2" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-certs-warning-dialog.jpg"><img src="../../static/img/android-screenshot-certs-warning-dialog.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-certs-manage.jpg"><img src="../../static/img/android-screenshot-certs-manage.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-connection-error-dialog.jpg"><img src="../../static/img/android-screenshot-connection-error-dialog.jpg"/></a>
|
||||
</div>
|
||||
|
||||
**Features:**
|
||||
|
||||
* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications)
|
||||
([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536),
|
||||
[ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8)
|
||||
for the initial implementation)
|
||||
* Support for self-signed certs and client certs for mTLS ([#215](https://github.com/binwiederhier/ntfy/issues/215),
|
||||
[#530](https://github.com/binwiederhier/ntfy/issues/530), [ntfy-android#149](https://github.com/binwiederhier/ntfy-android/pull/149),
|
||||
thanks to [@cyb3rko](https://github.com/cyb3rko) for reviewing)
|
||||
* Connection error dialog to help diagnose connection issues
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Use server-specific user for attachment downloads ([#1529](https://github.com/binwiederhier/ntfy/issues/1529),
|
||||
thanks to [@ManInDark](https://github.com/ManInDark) for reporting and testing)
|
||||
* Fix crash in sharing dialog (thanks to [@rogeliodh](https://github.com/rogeliodh))
|
||||
* Fix crash when exiting multi-delete in detail view
|
||||
* Fix potential crashes with icon downloader and backuper
|
||||
|
||||
## ntfy server v2.16.0
|
||||
Released January 19, 2026
|
||||
|
||||
This release adds support for updating and deleting notifications, heartbeat-style / dead man's switch notifications,
|
||||
custom Twilio call formats, and makes `ntfy serve` work on Windows. It also adds a "New version available" banner to the web app.
|
||||
|
||||
This one is very exciting, as it brings a lot of highly requested features to ntfy.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications) ([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536),
|
||||
[ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) for the initial implementation)
|
||||
* Support for heartbeat-style / [dead man's switch](https://en.wikipedia.org/wiki/Dead_man%27s_switch) notifications aka
|
||||
[updating and deleting scheduled notifications](publish.md#scheduled-delivery) ([#1556](https://github.com/binwiederhier/ntfy/pull/1556),
|
||||
[#1142](https://github.com/binwiederhier/ntfy/pull/1142), [#954](https://github.com/binwiederhier/ntfy/issues/954),
|
||||
thanks to [@GamerGirlandCo](https://github.com/GamerGirlandCo) for the initial implementation)
|
||||
* Configure [custom Twilio call format](config.md#phone-calls) for phone calls ([#1289](https://github.com/binwiederhier/ntfy/pull/1289), thanks to [@mmichaa](https://github.com/mmichaa) for the initial implementation)
|
||||
* `ntfy serve` now works on Windows, including support for running it as a Windows service ([#1104](https://github.com/binwiederhier/ntfy/issues/1104),
|
||||
[#1552](https://github.com/binwiederhier/ntfy/pull/1552), originally [#1328](https://github.com/binwiederhier/ntfy/pull/1328),
|
||||
thanks to [@wtf911](https://github.com/wtf911))
|
||||
* Web app: "New version available" banner ([#1554](https://github.com/binwiederhier/ntfy/pull/1554))
|
||||
|
||||
## ntfy Android app v1.21.1
|
||||
Released January 6, 2026
|
||||
|
||||
This is the first feature release in a long time. After all the SDK updates, fixes to comply with the Google Play policies
|
||||
and the framework updates, this release ships a lot of highly requested features: Sending messages through the app (WhatsApp-style),
|
||||
support for passing headers to your proxy, an in-app language switcher, and more.
|
||||
|
||||
<div id="v1211-screenshots" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-publish-message-bar.jpg"><img src="../../static/img/android-screenshot-publish-message-bar.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-publish-dialog.jpg"><img src="../../static/img/android-screenshot-publish-dialog.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-custom-headers.jpg"><img src="../../static/img/android-screenshot-custom-headers.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-language-selection.jpg"><img src="../../static/img/android-screenshot-language-selection.jpg"/></a>
|
||||
</div>
|
||||
|
||||
If you are waiting for a feature, please 👍 the corresponding [GitHub issue](https://github.com/binwiederhier/ntfy/issues?q=is%3Aissue%20state%3Aopen%20sort%3Areactions-%2B1-desc).
|
||||
If you like ntfy, please consider purchasing [ntfy Pro](https://ntfy.sh/app) to support us.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Allow publishing messages through the message bar and publish dialog ([#98](https://github.com/binwiederhier/ntfy/issues/98), [ntfy-android#144](https://github.com/binwiederhier/ntfy-android/pull/144))
|
||||
* Define custom HTTP headers to support authenticated proxies, tunnels and SSO ([ntfy-android#116](https://github.com/binwiederhier/ntfy-android/issues/116), [#1018](https://github.com/binwiederhier/ntfy/issues/1018), [ntfy-android#132](https://github.com/binwiederhier/ntfy-android/pull/132), [ntfy-android#146](https://github.com/binwiederhier/ntfy-android/pull/146), thanks to [@CrazyWolf13](https://github.com/CrazyWolf13))
|
||||
* Implement UnifiedPush "raise to foreground" requirement ([ntfy-android#98](https://github.com/binwiederhier/ntfy-android/pull/98), [ntfy-android#148](https://github.com/binwiederhier/ntfy-android/pull/148), thanks to [@p1gp1g](https://github.com/p1gp1g))
|
||||
* Language selector to allow overriding the system language ([#1508](https://github.com/binwiederhier/ntfy/issues/1508), [ntfy-android#145](https://github.com/binwiederhier/ntfy-android/pull/145), thanks to [@hudsonm62](https://github.com/hudsonm62) for reporting)
|
||||
* Highlight phone numbers and email addresses in notifications ([#957](https://github.com/binwiederhier/ntfy/issues/957), [ntfy-android#71](https://github.com/binwiederhier/ntfy-android/pull/71), thanks to [@brennenputh](https://github.com/brennenputh), and [@XylenSky](https://github.com/XylenSky) for reporting)
|
||||
* Support for port and display name in [ntfy://](subscribe/phone.md#ntfy-links) links ([ntfy-android#130](https://github.com/binwiederhier/ntfy-android/pull/130), thanks to [@godovski](https://github.com/godovski))
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Add support for (technically incorrect) 'image/jpg' MIME type ([ntfy-android#142](https://github.com/binwiederhier/ntfy-android/pull/142), thanks to [@Murilobeluco](https://github.com/Murilobeluco))
|
||||
* Unify "copy to clipboard" notifications, use Android 13 style ([ntfy-android#61](https://github.com/binwiederhier/ntfy-android/pull/61), thanks to [@thgoebel](https://github.com/thgoebel))
|
||||
* Fix crash in user add dialog (onAddUser)
|
||||
* Fix ForegroundServiceDidNotStartInTimeException (attempt 2, see [#1520](https://github.com/binwiederhier/ntfy/issues/1520))
|
||||
* Hide "Exact alarms" setting if battery optimization exemption has been granted ([#1456](https://github.com/binwiederhier/ntfy/issues/1456), thanks for reporting [@HappyLer](https://github.com/HappyLer))
|
||||
|
||||
## ntfy Android app v1.20.0
|
||||
Released December 28, 2025
|
||||
|
||||
This is the last pure maintenance release for now. It'll bring all dependencies and library version to the latest version,
|
||||
and fixes some crashes. I had to drop support for about 4,000 devices (only ~200 installations), because the libraries
|
||||
themselves do not support SDK 21 anymore, which was the previous minimum SDK version (Android 5, 2014). Now the minimum
|
||||
SDK version is 26 (Android 8, 2017).
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Updated dependencies, minimum SDK version to 26, clean up legacy code, upgrade Gradle ([ntfy-android#140](https://github.com/binwiederhier/ntfy-android/pull/140),
|
||||
thanks to [@cyb3rko](https://github.com/cyb3rko) for the implementation)
|
||||
* Updated target SDK version to 36 (Android 8, 2017)
|
||||
* Fixed ForegroundServiceDidNotStartInTimeException ([#1520](https://github.com/binwiederhier/ntfy/issues/1520))
|
||||
* Fixed crashes with redrawing the list when temporarily muted topics expire
|
||||
|
||||
## ntfy Android app v1.19.4
|
||||
Released December 21, 2025
|
||||
|
||||
This release upgrades the Android app to use [Material 3](https://m3.material.io/) design components and adds the
|
||||
ability to use [dynamic colors](https://developer.android.com/develop/ui/views/theming/dynamic-colors).
|
||||
**This was a lot of work** and I want to thank [@Bnyro](https://github.com/Bnyro) and [@cyb3rko](https://github.com/cyb3rko) for implementing this. You guys rock!
|
||||
|
||||
**Features:**
|
||||
|
||||
* Moved the user interface to Material 3 and added dynamic color support ([#580](https://github.com/binwiederhier/ntfy/issues/580),
|
||||
[ntfy-android#56](https://github.com/binwiederhier/ntfy-android/pull/56), [ntfy-android#126](https://github.com/binwiederhier/ntfy-android/pull/126),
|
||||
[ntfy-android#135](https://github.com/binwiederhier/ntfy-android/pull/135), thanks to [@Bnyro](https://github.com/Bnyro)
|
||||
and [@cyb3rko](https://github.com/cyb3rko) for the implementation, and to [@RokeJulianLockhart](https://github.com/RokeJulianLockhart) for reporting)
|
||||
|
||||
## ntfy Android app v1.18.0
|
||||
Released December 4, 2025
|
||||
|
||||
**Features:**
|
||||
|
||||
* Added GIF support for preview images ([ntfy-android#76](https://github.com/binwiederhier/ntfy-android/pull/76)/[#532](https://github.com/binwiederhier/ntfy/issues/532), thanks to [@MichaelArkh](https://github.com/MichaelArkh) and [@dimatx](https://github.com/dimatx) for reporting)
|
||||
* Added WebP support for preview images ([ntfy-android#81](https://github.com/binwiederhier/ntfy-android/pull/81)/[ntfy-android#80](https://github.com/binwiederhier/ntfy-android/issues/80), thanks to [@jokakilla](https://github.com/jokakilla))
|
||||
* Added UnifiedPush distributor selection support ([#137](https://github.com/binwiederhier/ntfy-android/pull/137), thanks to [@p1gp1g](https://github.com/p1gp1g))
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Remove REQUEST_INSTALL_PACKAGES permission ([#684](https://github.com/binwiederhier/ntfy/issues/684))
|
||||
* Request to ignore battery optimizations before receiving subscription ([ntfy-android#97](https://github.com/binwiederhier/ntfy-android/pull/97), thanks to [@p1gp1g](https://github.com/p1gp1g))
|
||||
|
||||
## ntfy server v2.15.0
|
||||
Released Nov 16, 2025
|
||||
|
||||
This release adds a `require-login` flag to topics, which forces users to log in before they can
|
||||
use the web app. This is useful for self-hosters and will obviously not be enabled on ntfy.sh.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Add `require-login` flag to redirect to login page if not logged in ([#1434](https://github.com/binwiederhier/ntfy/pull/1434)/[#238](https://github.com/binwiederhier/ntfy/issues/238)/[#1329](https://github.com/binwiederhier/ntfy/pull/1329), thanks to [@theatischbein](https://github.com/theatischbein) for implementing most of this)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* The official ntfy.sh Debian/Ubuntu repository has moved to [archive.ntfy.sh](https://archive.ntfy.sh) ([#1357](https://github.com/binwiederhier/ntfy/issues/1357)/[#1401](https://github.com/binwiederhier/ntfy/issues/1401), thanks to [@skibbipl](https://github.com/skibbipl) and [@lduesing](https://github.com/lduesing) for reporting)
|
||||
* Add mutex around message cache writes to avoid `database locked` errors ([#1397](https://github.com/binwiederhier/ntfy/pull/1397), [#1391](https://github.com/binwiederhier/ntfy/issues/1391), thanks to [@timofej673](https://github.com/timofej673))
|
||||
* Add build tags `nopayments`, `nofirebase` and `nowebpush` to allow excluding external dependencies, useful for
|
||||
packaging in Debian ([#1420](https://github.com/binwiederhier/ntfy/pull/1420), discussion in [#1258](https://github.com/binwiederhier/ntfy/issues/1258), thanks to [@thekhalifa](https://github.com/thekhalifa) for packaging ntfy for Debian/Ubuntu)
|
||||
* Make copying tokens, phone numbers, etc. possible on HTTP ([#1432](https://github.com/binwiederhier/ntfy/pull/1432)/[#1408](https://github.com/binwiederhier/ntfy/issues/1408)/[#1295](https://github.com/binwiederhier/ntfy/issues/1295), thanks to [@EdwinKM](https://github.com/EdwinKM), [@xxl6097](https://github.com/xxl6097) for reporting)
|
||||
|
||||
## ntfy Android app v1.17.13
|
||||
Released October 21, 2025
|
||||
|
||||
This release makes changes to comply with the Google Play policies. See [#1463](https://github.com/binwiederhier/ntfy/issues/1463)
|
||||
or [ef57cd1](https://github.com/binwiederhier/ntfy-android/commit/ef57cd1374118b3e4d7a7ab496afe337e714fff7) for details.
|
||||
|
||||
The policies do not allow directly or indirectly linking to paid plans or donation links that do not go through Google Play.
|
||||
|
||||
**Changes:**
|
||||
|
||||
* Remove the "Donate" button from menu (all variants)
|
||||
* Change default display name from "ntfy.sh/mytopic" to "mytopic" (all variants)
|
||||
* Remove links to ntfy docs and issue tracker (Play variant only)
|
||||
* Remove how-to links to ntfy.sh in a few places (Play variant only)
|
||||
* Remove "Copy topic address" from subscription menu (Play variant only)
|
||||
|
||||
## ntfy Android app v1.17.8
|
||||
Released September 23, 2025
|
||||
|
||||
This is largely a maintenance update to ensure the SDK is up-to-date.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Markdown is now rendered if "Markdown: yes" was passed ([#310](https://github.com/binwiederhier/ntfy/issues/310), thanks to [@NiNiyas](https://github.com/NiNiyas) for reporting)
|
||||
* You can now disable UnifiedPush so ntfy does not act as a UnifiedPush distributor ([#646](https://github.com/binwiederhier/ntfy/issues/646), thanks to [@ollien](https://github.com/ollien) for reporting and to [@wunter8](https://github.com/wunter8) for implementing)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* UnifiedPush subscriptions now include the `Rate-Topics` header to facilitate subscriber-based billing ([#652](https://github.com/binwiederhier/ntfy/issues/652), thanks to [@wunter8](https://github.com/wunter8))
|
||||
* Subscriptions without icons no longer appear to use another subscription's icon ([#634](https://github.com/binwiederhier/ntfy/issues/634), thanks to [@topcaser](https://github.com/topcaser) for reporting and to [@wunter8](https://github.com/wunter8) for fixing)
|
||||
* Bumped all dependencies to the latest versions (no ticket)
|
||||
|
||||
## ntfy server v2.14.0
|
||||
Released August 5, 2025
|
||||
|
||||
This release adds support for [declarative users](config.md#users-via-the-config), [declarative ACL entries](config.md#acl-entries-via-the-config) and [declarative tokens](config.md#tokens-via-the-config). This allows you to define users, ACL entries and tokens in the config file, which is useful for static deployments or deployments that use a configuration management system.
|
||||
|
||||
It also adds support for [pre-defined templates](publish.md#pre-defined-templates) and [custom templates](publish.md#custom-templates) for enhanced JSON webhook support, as well as advanced [template functions](publish.md#template-functions) based on the [Sprig](https://github.com/Masterminds/sprig) functions.
|
||||
|
||||
❤️ If you like ntfy, **please consider sponsoring me** via [GitHub Sponsors](https://github.com/sponsors/binwiederhier), [Liberapay](https://en.liberapay.com/ntfy/), Bitcoin (`1626wjrw3uWk9adyjCfYwafw4sQWujyjn8`), or by buying a [paid plan via the web app](https://ntfy.sh/app). ntfy
|
||||
will always remain open source.
|
||||
|
||||
**Features:**
|
||||
|
||||
* [Declarative users](config.md#users-via-the-config), [declarative ACL entries](config.md#acl-entries-via-the-config) and [declarative tokens](config.md#tokens-via-the-config) ([#464](https://github.com/binwiederhier/ntfy/issues/464), [#1384](https://github.com/binwiederhier/ntfy/pull/1384), [#1413](https://github.com/binwiederhier/ntfy/pull/1413), thanks to [pinpox](https://github.com/pinpox) for reporting, to [@wunter8](https://github.com/wunter8) for reviewing and implementing parts of it)
|
||||
* [Pre-defined templates](publish.md#pre-defined-templates) and [custom templates](publish.md#custom-templates) for enhanced JSON webhook support ([#1390](https://github.com/binwiederhier/ntfy/pull/1390))
|
||||
* Support of advanced [template functions](publish.md#template-functions) based on the [Sprig](https://github.com/Masterminds/sprig) library ([#1121](https://github.com/binwiederhier/ntfy/issues/1121), thanks to [@davidatkinsondoyle](https://github.com/davidatkinsondoyle) for reporting, to [@wunter8](https://github.com/wunter8) for implementing, and to the Sprig team for their work)
|
||||
|
||||
## ntfy server v2.13.0
|
||||
Released July 10, 2025
|
||||
|
||||
This is a relatively small release, mainly to support IPv6 and to add more sophisticated
|
||||
@@ -21,7 +647,7 @@ ntfy will always remain open source.
|
||||
* Update new languages from Weblate. Thanks to all the contributors!
|
||||
* Added Estonian (Esti), Galician (Galego), Romanian (Română), Slovak (Slovenčina) as new languages to the web app
|
||||
|
||||
### ntfy server v2.12.0
|
||||
## ntfy server v2.12.0
|
||||
Released May 29, 2025
|
||||
|
||||
This is mainly a maintenance release that updates dependencies, though since it's been over a year, there are a few
|
||||
@@ -81,7 +707,7 @@ user support in Discord/Matrix/GitHub! You rock, man!
|
||||
* Update new languages from Weblate. Thanks to all the contributors!
|
||||
* Added Tamil (தமிழ்) as a new language to the web app
|
||||
|
||||
### ntfy server v2.11.0
|
||||
## ntfy server v2.11.0
|
||||
Released May 13, 2024
|
||||
|
||||
This is a tiny release that fixes a database index issue that caused performance issues on ntfy.sh. It also fixes a bug
|
||||
@@ -96,7 +722,7 @@ and [Liberapay](https://en.liberapay.com/ntfy/), or buying a [paid plan via the
|
||||
* Do not set rate visitor for non-eligible topics (no ticket)
|
||||
* Do not cache `config.js` ([#1098](https://github.com/binwiederhier/ntfy/pull/1098), thanks to [@wunter8](https://github.com/wunter8))
|
||||
|
||||
### ntfy server v2.10.0
|
||||
## ntfy server v2.10.0
|
||||
Released Mar 27, 2024
|
||||
|
||||
This release adds support for **message templating** in the ntfy server, which allows you to include a message and/or
|
||||
@@ -107,7 +733,7 @@ This is great for services that let you specify a webhook URL but do not let you
|
||||
|
||||
* [Message templating](publish.md#message-templating): You can now include a message and/or title template that will be filled with values from a JSON body ([#724](https://github.com/binwiederhier/ntfy/issues/724), thanks to [@wunter8](https://github.com/wunter8) for implementing)
|
||||
|
||||
### ntfy server v2.9.0
|
||||
## ntfy server v2.9.0
|
||||
Released Mar 7, 2024
|
||||
|
||||
A small release after a long pause (lots of day job work). This release adds for **larger messages** and **longer
|
||||
@@ -1452,18 +2078,15 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
## Not released yet
|
||||
|
||||
### ntfy Android app v1.16.1 (UNRELEASED)
|
||||
### ntfy iOS app v1.8.0 (UNRELEASED)
|
||||
|
||||
**Features:**
|
||||
|
||||
* You can now disable UnifiedPush so ntfy does not act as a UnifiedPush distributor ([#646](https://github.com/binwiederhier/ntfy/issues/646), thanks to [@ollien](https://github.com/ollien) for reporting and to [@wunter8](https://github.com/wunter8) for implementing)
|
||||
* Deliver priority 5 (max/urgent) notifications as critical alerts that bypass silent mode and Do Not Disturb ([ntfy-ios#44](https://github.com/binwiederhier/ntfy-ios/pull/44), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
* Apply the attachment auto-download size setting to all attachment types, not just images ([ntfy-ios#43](https://github.com/binwiederhier/ntfy-ios/pull/43), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* UnifiedPush subscriptions now include the `Rate-Topics` header to facilitate subscriber-based billing ([#652](https://github.com/binwiederhier/ntfy/issues/652), thanks to [@wunter8](https://github.com/wunter8))
|
||||
* Subscriptions without icons no longer appear to use another subscription's icon ([#634](https://github.com/binwiederhier/ntfy/issues/634), thanks to [@topcaser](https://github.com/topcaser) for reporting and to [@wunter8](https://github.com/wunter8) for fixing)
|
||||
* Bumped all dependencies to the latest versions (no ticket)
|
||||
|
||||
**Additional languages:**
|
||||
|
||||
* Swedish (thanks to [@hellbown](https://hosted.weblate.org/user/hellbown/))
|
||||
* Restore the native swipe-to-go-back gesture in the topic detail view ([ntfy-ios#45](https://github.com/binwiederhier/ntfy-ios/pull/45), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
* Fix saved attachments being left "in use" so they couldn't be deleted in the Files app ([ntfy-ios#43](https://github.com/binwiederhier/ntfy-ios/pull/43), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
* Improve poll request subscription matching for protected topics so notifications resolve to the real message content, with better logging ([ntfy-ios#43](https://github.com/binwiederhier/ntfy-ios/pull/43), thanks to [@am7590](https://github.com/am7590) for the contribution)
|
||||
|
||||
@@ -0,0 +1,853 @@
|
||||
/* Config Generator */
|
||||
|
||||
/* Hidden utility */
|
||||
.cg-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Open button */
|
||||
.cg-open-btn {
|
||||
display: inline-block;
|
||||
padding: 8px 20px;
|
||||
background: var(--md-primary-fg-color);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.cg-open-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Modal overlay */
|
||||
.cg-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.cg-modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.cg-modal-dialog {
|
||||
position: absolute;
|
||||
inset: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
|
||||
overflow: hidden;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.cg-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cg-modal-header-left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cg-modal-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cg-badge-beta {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
margin-left: 8px;
|
||||
background: var(--md-primary-fg-color);
|
||||
color: #fff;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
border-radius: 10px;
|
||||
letter-spacing: 0.5px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.cg-modal-desc {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.cg-modal-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cg-modal-reset {
|
||||
background: none;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: #777;
|
||||
cursor: pointer;
|
||||
padding: 4px 12px;
|
||||
font-family: inherit;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.cg-modal-reset:hover {
|
||||
color: #333;
|
||||
border-color: #999;
|
||||
}
|
||||
|
||||
.cg-modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.4rem;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cg-modal-close:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Modal body: left + right */
|
||||
.cg-modal-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Left panel */
|
||||
#cg-left {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid #ddd;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cg-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid #ddd;
|
||||
flex-shrink: 0;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.cg-nav-tab {
|
||||
padding: 9px 14px;
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: #777;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
user-select: none;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cg-nav-tab:hover {
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.cg-nav-tab.active {
|
||||
color: var(--md-primary-fg-color);
|
||||
border-bottom-color: var(--md-primary-fg-color);
|
||||
}
|
||||
|
||||
.cg-panels {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.cg-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cg-panel.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cg-panel-desc {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cg-panel-desc a {
|
||||
color: var(--md-primary-fg-color);
|
||||
}
|
||||
|
||||
.cg-help {
|
||||
color: var(--md-primary-fg-color);
|
||||
text-decoration: none;
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.cg-help:hover {
|
||||
color: var(--md-primary-fg-color--dark, #2a6e5f);
|
||||
}
|
||||
|
||||
/* Right panel */
|
||||
#cg-right {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cg-output-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #ddd;
|
||||
flex-shrink: 0;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.cg-output-tab {
|
||||
padding: 9px 14px;
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
color: #777;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cg-output-tab:hover {
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.cg-output-tab.active {
|
||||
color: var(--md-primary-fg-color);
|
||||
border-bottom-color: var(--md-primary-fg-color);
|
||||
}
|
||||
|
||||
.cg-btn-copy {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
color: #777;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
padding: 9px 10px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.cg-btn-copy:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.cg-output-wrap {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cg-output-wrap pre {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.5;
|
||||
flex: 1;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.cg-empty-msg {
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.cg-warning {
|
||||
padding: 6px 10px;
|
||||
margin-top: 8px;
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
border: 1px solid #ffc107;
|
||||
border-radius: 4px;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
/* Form fields */
|
||||
.cg-field {
|
||||
margin-bottom: 0;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.cg-field:nth-child(odd) {
|
||||
background: #f8f8f8;
|
||||
}
|
||||
|
||||
.cg-field:nth-child(even) {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.cg-field > label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.78rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.cg-field input[type="text"],
|
||||
.cg-field input[type="password"],
|
||||
.cg-field select {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.cg-field input[type="text"]:focus,
|
||||
.cg-field input[type="password"]:focus,
|
||||
.cg-field select:focus {
|
||||
border-color: var(--md-primary-fg-color);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(51, 133, 116, 0.15);
|
||||
}
|
||||
|
||||
.cg-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.cg-checkbox input[type="checkbox"] {
|
||||
accent-color: var(--md-primary-fg-color);
|
||||
}
|
||||
|
||||
.cg-checkbox label {
|
||||
font-weight: 500;
|
||||
font-size: 0.78rem;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cg-radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.cg-radio-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-weight: 400;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cg-radio-group input[type="radio"] {
|
||||
accent-color: var(--md-primary-fg-color);
|
||||
}
|
||||
|
||||
/* Inline field: label + control side by side */
|
||||
.cg-inline-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cg-inline-field > label {
|
||||
margin-bottom: 0;
|
||||
width: 60%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cg-panel:not(#cg-panel-general) .cg-inline-field > label {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.cg-inline-field > input[type="text"],
|
||||
.cg-inline-field > select {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
line-height: 1.4;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.cg-inline-field > input[type="text"]:focus,
|
||||
.cg-inline-field > select:focus {
|
||||
border-color: var(--md-primary-fg-color);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(51, 133, 116, 0.15);
|
||||
}
|
||||
|
||||
#cg-email-in-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.cg-pg-label {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Button group toggle */
|
||||
.cg-btn-group {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cg-btn-group label {
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cg-btn-group input[type="radio"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cg-btn-group span {
|
||||
display: block;
|
||||
padding: 4px 14px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
border: 1px solid #ccc;
|
||||
color: #555;
|
||||
background: #fff;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.cg-btn-group label:first-child span {
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
||||
.cg-btn-group label:last-child span {
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.cg-btn-group label + label span {
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
.cg-btn-group input[type="radio"]:checked + span {
|
||||
background: var(--md-primary-fg-color);
|
||||
color: #fff;
|
||||
border-color: var(--md-primary-fg-color);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.cg-feature-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.cg-feature-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cg-feature-grid label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cg-feature-grid input[type="checkbox"] {
|
||||
accent-color: var(--md-primary-fg-color);
|
||||
}
|
||||
|
||||
.cg-btn-configure {
|
||||
background: none;
|
||||
border: 1px solid var(--md-primary-fg-color);
|
||||
border-radius: 10px;
|
||||
color: var(--md-primary-fg-color);
|
||||
font-size: 0.68rem;
|
||||
font-family: inherit;
|
||||
padding: 1px 10px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.cg-btn-configure:hover {
|
||||
background: var(--md-primary-fg-color);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Repeatable rows */
|
||||
.cg-repeatable-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cg-repeatable-row input,
|
||||
.cg-repeatable-row select {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
padding: 5px 6px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cg-repeatable-row input:focus,
|
||||
.cg-repeatable-row select:focus {
|
||||
border-color: var(--md-primary-fg-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cg-repeatable-row input:disabled,
|
||||
.cg-repeatable-row select:disabled {
|
||||
background: #eee;
|
||||
color: #999;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cg-btn-remove {
|
||||
background: none;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
padding: 5px 8px;
|
||||
color: #999;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cg-btn-remove:hover {
|
||||
background: #fee;
|
||||
border-color: #c66;
|
||||
color: #c33;
|
||||
}
|
||||
|
||||
.cg-btn-add {
|
||||
background: none;
|
||||
border: 1px dashed #bbb;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
padding: 5px 10px;
|
||||
font-size: 0.75rem;
|
||||
color: #777;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.cg-btn-add:hover {
|
||||
border-color: var(--md-primary-fg-color);
|
||||
color: var(--md-primary-fg-color);
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
body[data-md-color-scheme="slate"] .cg-modal-dialog {
|
||||
background: #1e1e2e;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-modal-header {
|
||||
border-bottom-color: #444;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-modal-title {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-modal-desc {
|
||||
color: #777;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-modal-reset {
|
||||
border-color: #555;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-modal-reset:hover {
|
||||
border-color: #888;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-modal-close {
|
||||
color: #777;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-modal-close:hover {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] #cg-left {
|
||||
border-right-color: #444;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-nav {
|
||||
border-bottom-color: #444;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-nav-tab {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-nav-tab:hover {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-output-tabs {
|
||||
border-bottom-color: #444;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-output-tab {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-output-tab:hover {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-btn-copy {
|
||||
color: #777;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-btn-copy:hover {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-output-wrap pre {
|
||||
background: #161620;
|
||||
color: #ddd;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-field:nth-child(odd) {
|
||||
background: #232334;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-field:nth-child(even) {
|
||||
background: #1e1e2e;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-panel-desc {
|
||||
color: #777;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-field > label {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-field input[type="text"],
|
||||
body[data-md-color-scheme="slate"] .cg-field input[type="password"],
|
||||
body[data-md-color-scheme="slate"] .cg-field select {
|
||||
background: #2a2a3a;
|
||||
border-color: #555;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-btn-group span {
|
||||
background: #2a2a3a;
|
||||
border-color: #555;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-btn-group input[type="radio"]:checked + span {
|
||||
background: var(--md-primary-fg-color);
|
||||
color: #fff;
|
||||
border-color: var(--md-primary-fg-color);
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-checkbox label {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-radio-group label {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-feature-grid label {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-repeatable-row input,
|
||||
body[data-md-color-scheme="slate"] .cg-repeatable-row select {
|
||||
background: #2a2a3a;
|
||||
border-color: #555;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-repeatable-row input:disabled,
|
||||
body[data-md-color-scheme="slate"] .cg-repeatable-row select:disabled {
|
||||
background: #1a1a28;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-btn-remove {
|
||||
border-color: #555;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-btn-add {
|
||||
border-color: #555;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-warning {
|
||||
background: #3a2e00;
|
||||
color: #ffc107;
|
||||
border-color: #665200;
|
||||
}
|
||||
|
||||
/* Mobile toggle bar (hidden on desktop) */
|
||||
.cg-mobile-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 900px) {
|
||||
.cg-modal-dialog {
|
||||
inset: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.cg-modal-header {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.cg-modal-title {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.cg-modal-desc {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cg-modal-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cg-mobile-toggle {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.cg-mobile-toggle-btn {
|
||||
flex: 1;
|
||||
padding: 8px 0;
|
||||
border: none;
|
||||
background: #f5f5f5;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
color: #777;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.cg-mobile-toggle-btn.active {
|
||||
background: #fff;
|
||||
color: var(--md-primary-fg-color);
|
||||
box-shadow: inset 0 -2px 0 var(--md-primary-fg-color);
|
||||
}
|
||||
|
||||
#cg-left {
|
||||
border-right: none;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
#cg-right {
|
||||
flex: 1;
|
||||
display: none;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
#cg-right.cg-mobile-active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#cg-left.cg-mobile-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cg-nav {
|
||||
overflow-x: auto;
|
||||
flex-wrap: nowrap;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.cg-inline-field {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.cg-inline-field > label {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cg-panel:not(#cg-panel-general) .cg-inline-field > label {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode mobile toggle */
|
||||
body[data-md-color-scheme="slate"] .cg-mobile-toggle {
|
||||
border-bottom-color: #444;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-mobile-toggle-btn {
|
||||
background: #2a2a3a;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-mobile-toggle-btn.active {
|
||||
background: #1e1e2e;
|
||||
color: var(--md-primary-fg-color);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
:root > * {
|
||||
--md-primary-fg-color: #338574;
|
||||
--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-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) {
|
||||
@@ -34,7 +34,7 @@ figure img, figure video {
|
||||
}
|
||||
|
||||
header {
|
||||
background: linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%);
|
||||
background: linear-gradient(150deg, rgba(51, 133, 116, 1) 0%, rgba(86, 189, 168, 1) 100%);
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="default"] header {
|
||||
@@ -92,8 +92,8 @@ figure video {
|
||||
}
|
||||
|
||||
.screenshots img {
|
||||
max-height: 230px;
|
||||
max-width: 300px;
|
||||
max-height: 350px;
|
||||
max-width: 350px;
|
||||
margin: 3px;
|
||||
border-radius: 5px;
|
||||
filter: drop-shadow(2px 2px 2px #ddd);
|
||||
@@ -107,7 +107,7 @@ figure video {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
position: fixed;
|
||||
left:0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
@@ -119,7 +119,7 @@ figure video {
|
||||
}
|
||||
|
||||
.lightbox.show {
|
||||
background-color: rgba(0,0,0, 0.75);
|
||||
background-color: rgba(0, 0, 0, 0.75);
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
z-index: 1000;
|
||||
@@ -214,3 +214,30 @@ figure video {
|
||||
font-weight: 400;
|
||||
src: url('../fonts/roboto-mono-v22-latin-regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* Community maintained badge */
|
||||
.community-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35em;
|
||||
background-color: rgba(51, 133, 116, 0.1);
|
||||
border: 1px solid rgba(51, 133, 116, 0.3);
|
||||
border-radius: 0.7em;
|
||||
padding: 0.1em 0.7em;
|
||||
font-size: 0.75rem;
|
||||
color: #338574;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.community-badge svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .community-badge {
|
||||
background-color: rgba(86, 189, 168, 0.15);
|
||||
border-color: rgba(86, 189, 168, 0.4);
|
||||
color: #56bda8;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
/* Topic name generator (Publishing page) */
|
||||
/* Styled to mirror the config generator (header + left form / right output panels). */
|
||||
|
||||
.tg-generator {
|
||||
margin: 16px 0 24px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
font-size: 0.78rem;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* Header (matches .cg-modal-header) */
|
||||
.tg-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.tg-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tg-reset {
|
||||
background: none;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 0.72rem;
|
||||
color: #777;
|
||||
cursor: pointer;
|
||||
padding: 4px 12px;
|
||||
font-family: inherit;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.tg-reset:hover {
|
||||
color: #333;
|
||||
border-color: #999;
|
||||
}
|
||||
|
||||
/* Body: left (form) + right (output), matches .cg-modal-body */
|
||||
.tg-body {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tg-left {
|
||||
flex: 1;
|
||||
border-right: 1px solid #ddd;
|
||||
padding: 16px 18px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tg-right {
|
||||
flex: 1;
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* One output per block: label on its own line, then value field + copy button */
|
||||
.tg-output-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tg-output-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Form field (matches .cg-field) */
|
||||
.tg-field > label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.78rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.tg-field input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.tg-field input[type="text"]:focus {
|
||||
border-color: var(--md-primary-fg-color);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(51, 133, 116, 0.15);
|
||||
}
|
||||
|
||||
.tg-note {
|
||||
margin-top: 10px;
|
||||
font-size: 0.72rem;
|
||||
color: #999;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tg-note code {
|
||||
font-size: 0.72rem;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
.tg-output-label {
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
font-size: 0.78rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* Copy button (matches .cg-btn-copy) */
|
||||
.tg-btn-copy {
|
||||
background: none;
|
||||
color: #777;
|
||||
border: none;
|
||||
padding: 2px 4px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.tg-btn-copy:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Output block (matches .cg-output-wrap pre). Scoped under .tg-generator so the margin
|
||||
reset beats the theme's .md-typeset pre rule, which otherwise adds a stray top margin. */
|
||||
.tg-generator .tg-output {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 6px 9px;
|
||||
background: #f5f5f5;
|
||||
color: var(--md-default-fg-color);
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
font-family: var(--md-code-font-family, monospace);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
body[data-md-color-scheme="slate"] .tg-generator {
|
||||
background: #1e1e2e;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-header {
|
||||
border-bottom-color: #444;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-title {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-reset {
|
||||
border-color: #555;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-reset:hover {
|
||||
border-color: #888;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-left {
|
||||
border-right-color: #444;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-field > label,
|
||||
body[data-md-color-scheme="slate"] .tg-output-label {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-btn-copy {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-btn-copy:hover {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-field input[type="text"] {
|
||||
background: #2a2a3a;
|
||||
border-color: #555;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-note {
|
||||
color: #777;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-output {
|
||||
background: #161620;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
/* Responsive: stack panels like the config generator does on mobile */
|
||||
@media (max-width: 700px) {
|
||||
.tg-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tg-left {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-left {
|
||||
border-bottom-color: #444;
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 72 KiB |