Compare commits
476 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5981b851d | ||
|
|
c357979f11 | ||
|
|
6ee3349cca | ||
|
|
91e6eaab19 | ||
|
|
3973f1e5ed | ||
|
|
15ac5ed23b | ||
|
|
344da326cd | ||
|
|
cacfb704a4 | ||
|
|
040bb53383 | ||
|
|
5cac63bfbe | ||
|
|
8d908fe438 | ||
|
|
7db99d18c7 | ||
|
|
2bb5d6f934 | ||
|
|
bb13011046 | ||
|
|
8cc12e12da | ||
|
|
6e2b300d9e | ||
|
|
1197d72523 | ||
|
|
c1517e259d | ||
|
|
66d30fb42a | ||
|
|
ea30132763 | ||
|
|
1c237435ec | ||
|
|
c37793d06f | ||
|
|
57c7a353b5 | ||
|
|
8154705f0b | ||
|
|
fd2c6ef590 | ||
|
|
6ece25e7f3 | ||
|
|
a2ef6180bb | ||
|
|
c3c4c9e9aa | ||
|
|
031c848984 | ||
|
|
1f70ff1b06 | ||
|
|
1af9a85847 | ||
|
|
e474d1e8b0 | ||
|
|
4b117a790a | ||
|
|
72a2a8c82e | ||
|
|
a7af16beb1 | ||
|
|
7d38bc7654 | ||
|
|
bcbcbf12ac | ||
|
|
8b5d2a8ca0 | ||
|
|
9b8e637618 | ||
|
|
15a45d9eb7 | ||
|
|
8a7bc38861 | ||
|
|
2d96560375 | ||
|
|
bb5e0e3fed | ||
|
|
4a8678bf39 | ||
|
|
ed28082c01 | ||
|
|
0d3dcfdc7a | ||
|
|
672203467d | ||
|
|
4ce619f9cb | ||
|
|
5344337b43 | ||
|
|
cf3238859c | ||
|
|
9a03a9e81b | ||
|
|
edfed24c27 | ||
|
|
7118dcc124 | ||
|
|
5bcb35f756 | ||
|
|
eaf3c42227 | ||
|
|
16a4feaeb6 | ||
|
|
b60458318c | ||
|
|
b10c88afd7 | ||
|
|
f0cae0fbac | ||
|
|
28bb8d4446 | ||
|
|
adea3c38be | ||
|
|
fb56ab9a06 | ||
|
|
72aea2613a | ||
|
|
6bd4e4bd7c | ||
|
|
1f6118f068 | ||
|
|
574e72a974 | ||
|
|
53646737e8 | ||
|
|
26b9cc75ca | ||
|
|
66e46aaded | ||
|
|
ddf5d49895 | ||
|
|
899d895f29 | ||
|
|
27588b8a48 | ||
|
|
c3f4adb777 | ||
|
|
3633503549 | ||
|
|
5494bcce88 | ||
|
|
b824a1f17f | ||
|
|
5a6b6dacc0 | ||
|
|
0c6e9d4fca | ||
|
|
0d0ca188bf | ||
|
|
9c91ae2744 | ||
|
|
882f027f6c | ||
|
|
b805d49cfd | ||
|
|
12f85cceb1 | ||
|
|
8f4a1db1f0 | ||
|
|
58bde32bfb | ||
|
|
26b3aa27ae | ||
|
|
26ebd23bfd | ||
|
|
12d347976c | ||
|
|
a779434bab | ||
|
|
c5ec3b48b4 | ||
|
|
712c292183 | ||
|
|
8900df27c9 | ||
|
|
0eb511c714 | ||
|
|
d48eec5e66 | ||
|
|
3a7fd7a620 | ||
|
|
85043b34a4 | ||
|
|
2df0e98749 | ||
|
|
3c3b2477af | ||
|
|
5a9b2122c2 | ||
|
|
37e72e078d | ||
|
|
a2dafc11f2 | ||
|
|
55869f551e | ||
|
|
967cde1fb5 | ||
|
|
26efd481e3 | ||
|
|
06fd7327de | ||
|
|
a08d57ca0f | ||
|
|
c60c51871d | ||
|
|
04c4150283 | ||
|
|
1feb038385 | ||
|
|
61a5b0dbe9 | ||
|
|
690cd683f0 | ||
|
|
c87c81f663 | ||
|
|
8190d5b1f4 | ||
|
|
75c11371e6 | ||
|
|
ffa0bf05cd | ||
|
|
8e1c57af25 | ||
|
|
c62916a43c | ||
|
|
f5145ffaae | ||
|
|
0a6aba1ac7 | ||
|
|
5d30246c35 | ||
|
|
e9386ecfe3 | ||
|
|
04f5d4acb7 | ||
|
|
841c08fcb6 | ||
|
|
2d7c354723 | ||
|
|
6fec79055e | ||
|
|
f61a8f82a7 | ||
|
|
136883fd94 | ||
|
|
9c3f5929c7 | ||
|
|
39bd1fe164 | ||
|
|
67ea467501 | ||
|
|
ed946195e2 | ||
|
|
84bf95fa85 | ||
|
|
cf9ba9b1f9 | ||
|
|
1a18ce9e21 | ||
|
|
044b717f86 | ||
|
|
8777718afc | ||
|
|
8e3910c76d | ||
|
|
448444eccf | ||
|
|
65cd380527 | ||
|
|
71a49ac1a6 | ||
|
|
1fba62276c | ||
|
|
29f265be30 | ||
|
|
4c9011f391 | ||
|
|
155475422e | ||
|
|
32353e0f02 | ||
|
|
69159b9aae | ||
|
|
b47d0ac240 | ||
|
|
d14af78403 | ||
|
|
9cb08036ef | ||
|
|
e0da6b1302 | ||
|
|
fcb1f938b9 | ||
|
|
9c094c1cc3 | ||
|
|
69c6f24d97 | ||
|
|
e8b020ff45 | ||
|
|
2ec9a7307e | ||
|
|
738ee5cf35 | ||
|
|
8144d39e29 | ||
|
|
788d5e9f9b | ||
|
|
d399d2fe1c | ||
|
|
615b09a774 | ||
|
|
7a5e8cc44b | ||
|
|
291b49488b | ||
|
|
aa58242551 | ||
|
|
b08ea2c416 | ||
|
|
98f02f78db | ||
|
|
d2f933e15f | ||
|
|
d672969840 | ||
|
|
8c4f0c1253 | ||
|
|
18c88e567c | ||
|
|
2c5505852e | ||
|
|
bc8f245064 | ||
|
|
30726144b8 | ||
|
|
893701c07b | ||
|
|
0ec9a4c89b | ||
|
|
96fb7e2296 | ||
|
|
750e390b5d | ||
|
|
7a8cfb5f66 | ||
|
|
d761ce929c | ||
|
|
29969582e9 | ||
|
|
749e334396 | ||
|
|
78a681f277 | ||
|
|
e22ec2c505 | ||
|
|
3f96fad7ce | ||
|
|
83bb9951b0 | ||
|
|
4a5f34801a | ||
|
|
2cd7839da3 | ||
|
|
35ddcb27f0 | ||
|
|
328aca48ab | ||
|
|
4eba641ec3 | ||
|
|
f2d4af04e3 | ||
|
|
d44ee2bbf6 | ||
|
|
6f07944442 | ||
|
|
7716b1e81e | ||
|
|
8914809775 | ||
|
|
dcb5531038 | ||
|
|
0c666f96b1 | ||
|
|
d9c3c20350 | ||
|
|
73349cd423 | ||
|
|
b3667a916b | ||
|
|
6791c7395b | ||
|
|
aba7e86cbc | ||
|
|
12f973f61b | ||
|
|
f98743dd9b | ||
|
|
2c8b258ae7 | ||
|
|
00520a7a38 | ||
|
|
611894fd05 | ||
|
|
aabae53e5d | ||
|
|
85cf7bb687 | ||
|
|
44b9358c60 | ||
|
|
ee6188d100 | ||
|
|
2bdae49425 | ||
|
|
9814a9f792 | ||
|
|
5aedfd3898 | ||
|
|
59ec2de8bd | ||
|
|
d154d3936d | ||
|
|
5125aac91c | ||
|
|
7ff34364a3 | ||
|
|
3d0d70dc17 | ||
|
|
62512b7a1a | ||
|
|
c5a1344e8a | ||
|
|
402b05a27b | ||
|
|
b67d9fc85d | ||
|
|
3e121f5d3c | ||
|
|
b6426f0417 | ||
|
|
59b341dfb8 | ||
|
|
e2834a7c4d | ||
|
|
e0b3068a5e | ||
|
|
2280031a80 | ||
|
|
8f2851e20a | ||
|
|
2eeb7d63a0 | ||
|
|
b20df55b88 | ||
|
|
de1b97bbce | ||
|
|
3b4a4108e5 | ||
|
|
dc1c0ddd4e | ||
|
|
182e21a9c3 | ||
|
|
187c19f3b2 | ||
|
|
d5eff0cd34 | ||
|
|
d4fe2052c7 | ||
|
|
2e92be0f23 | ||
|
|
94b0e6f690 | ||
|
|
202051bbbf | ||
|
|
a693975526 | ||
|
|
4cd4e890fe | ||
|
|
5dc8031ec9 | ||
|
|
03ad5dcff6 | ||
|
|
5f508e1839 | ||
|
|
c5642799df | ||
|
|
5a99fe8ba2 | ||
|
|
ee0f448d86 | ||
|
|
a222f64ee4 | ||
|
|
140daec0d3 | ||
|
|
b409c89d3b | ||
|
|
806893962c | ||
|
|
14d3c5e93e | ||
|
|
37e14b13a4 | ||
|
|
d7fa51be2c | ||
|
|
a3e28e71aa | ||
|
|
35cef8386c | ||
|
|
38072c9cdd | ||
|
|
13d741b89e | ||
|
|
cc90a1af15 | ||
|
|
21fc1245eb | ||
|
|
2511ba7627 | ||
|
|
23547f4504 | ||
|
|
e6f19d050f | ||
|
|
3ec8084450 | ||
|
|
2edb722c0e | ||
|
|
1f75498dca | ||
|
|
ab19c4d688 | ||
|
|
15265d9ef3 | ||
|
|
2839a7228f | ||
|
|
c2036975fa | ||
|
|
7aa0f87376 | ||
|
|
df372d1a7e | ||
|
|
6cd31502e7 | ||
|
|
bade88079f | ||
|
|
20ab05afc8 | ||
|
|
5b10f51af1 | ||
|
|
470d11f442 | ||
|
|
4952f0fbd2 | ||
|
|
0a3292566c | ||
|
|
f4e8ebc053 | ||
|
|
ad77bde8c8 | ||
|
|
5241b29cc6 | ||
|
|
8fcc40942f | ||
|
|
37d4d5d647 | ||
|
|
b67b9e83ff | ||
|
|
4c3dcec19e | ||
|
|
53375ff559 | ||
|
|
53e08988e7 | ||
|
|
d0bbda555f | ||
|
|
207e990798 | ||
|
|
b0a07af28d | ||
|
|
1a8bac7ab1 | ||
|
|
dc03c13642 | ||
|
|
739b20583d | ||
|
|
10ccbc780b | ||
|
|
f971a36ec0 | ||
|
|
3699464947 | ||
|
|
3a3d1262ab | ||
|
|
395a97c0e5 | ||
|
|
4a6aca4c07 | ||
|
|
08f0d5fd1f | ||
|
|
750be7f07e | ||
|
|
70538783d8 | ||
|
|
09336fa1e4 | ||
|
|
c124434429 | ||
|
|
0544a6f00d | ||
|
|
7b186af765 | ||
|
|
3f978bc45f | ||
|
|
488aeb119b | ||
|
|
160c72997f | ||
|
|
ccb9da9333 | ||
|
|
840cb5b182 | ||
|
|
04ee6b8be2 | ||
|
|
8c8a1685b2 | ||
|
|
28e6f8a0f6 | ||
|
|
d9e5e08af5 | ||
|
|
60980df26b | ||
|
|
d3462d2905 | ||
|
|
0aefcf29ef | ||
|
|
55c021796e | ||
|
|
4aad98256a | ||
|
|
30b13cbdbc | ||
|
|
6d140d6a86 | ||
|
|
9757983046 | ||
|
|
5bed926323 | ||
|
|
1d2f3f72e4 | ||
|
|
3a76e4733c | ||
|
|
a4fbb1b4c5 | ||
|
|
94296e7dd8 | ||
|
|
dc7ca6e405 | ||
|
|
09b128f27a | ||
|
|
acde2e5b6e | ||
|
|
420e35c33c | ||
|
|
c5ce51f242 | ||
|
|
2743c96694 | ||
|
|
36ccfac787 | ||
|
|
e27d5719f0 | ||
|
|
1a3816c1ff | ||
|
|
52a55f71e6 | ||
|
|
b5670d9a71 | ||
|
|
e7bd3abadc | ||
|
|
5878d7e5a6 | ||
|
|
3bce0ad4ae | ||
|
|
695e029147 | ||
|
|
08846e4cc2 | ||
|
|
f9219d2d96 | ||
|
|
7dfb2d50c7 | ||
|
|
349872bdb3 | ||
|
|
39f4613719 | ||
|
|
effc1f42eb | ||
|
|
23d275acec | ||
|
|
8036aa2942 | ||
|
|
f23c7a2dbf | ||
|
|
17e5af654b | ||
|
|
0909354a6c | ||
|
|
cda9dfa9d0 | ||
|
|
018fa816e2 | ||
|
|
efa6d03ba5 | ||
|
|
1ed4ebaf03 | ||
|
|
10c69a722f | ||
|
|
324500d0b3 | ||
|
|
4cd30c35ce | ||
|
|
e79dbf4d00 | ||
|
|
e29a18a076 | ||
|
|
f17df1e926 | ||
|
|
c21737d546 | ||
|
|
6dc4e441e4 | ||
|
|
7d93b0596b | ||
|
|
8b32cfaaff | ||
|
|
18b91cf250 | ||
|
|
4af9c07577 | ||
|
|
fb90ab480a | ||
|
|
d705d3c3b1 | ||
|
|
ee743a7b01 | ||
|
|
e422c2c479 | ||
|
|
aa79fe2861 | ||
|
|
530f55c234 | ||
|
|
6d343c0f1a | ||
|
|
1599793de2 | ||
|
|
42016f48ff | ||
|
|
f9e22dcaa9 | ||
|
|
703f94a25f | ||
|
|
0958c1d527 | ||
|
|
fef46823eb | ||
|
|
48523a2269 | ||
|
|
202c4ac4b3 | ||
|
|
1536201e9a | ||
|
|
3fac1c3432 | ||
|
|
415ab57749 | ||
|
|
c57fac283e | ||
|
|
2eff8d6b47 | ||
|
|
40be2a9153 | ||
|
|
4ba23390b5 | ||
|
|
dd1a85e733 | ||
|
|
c6c3caec39 | ||
|
|
8c0f3b2304 | ||
|
|
c859f866b8 | ||
|
|
b497063af4 | ||
|
|
1fe598a966 | ||
|
|
31e7aa24bc | ||
|
|
4c4e689af4 | ||
|
|
43326be637 | ||
|
|
7e1a71b694 | ||
|
|
b89c18e83d | ||
|
|
f4f5edb230 | ||
|
|
ce9e9f3e0d | ||
|
|
da4cf04594 | ||
|
|
0677b3bd7e | ||
|
|
eed233a793 | ||
|
|
2ad0802b65 | ||
|
|
0df8aa9a5d | ||
|
|
d3f71f9d0a | ||
|
|
8187b49599 | ||
|
|
2188643387 | ||
|
|
344031b575 | ||
|
|
a320093cb8 | ||
|
|
7fb7ba2fa5 | ||
|
|
3902599c52 | ||
|
|
4972407145 | ||
|
|
d714af43c9 | ||
|
|
29c2fc5472 | ||
|
|
1c9766b8fd | ||
|
|
68351230f3 | ||
|
|
0ad85262c1 | ||
|
|
1552d8103e | ||
|
|
c3a2331b59 | ||
|
|
5cf92c55c6 | ||
|
|
e56eb0c178 | ||
|
|
44bc13eb2c | ||
|
|
a77f89d302 | ||
|
|
b1bbbf0103 | ||
|
|
c2f31b9c9f | ||
|
|
198e2cfd90 | ||
|
|
936e95fd9e | ||
|
|
c56814e7da | ||
|
|
631ade5430 | ||
|
|
e61a0c2f78 | ||
|
|
89957e7058 | ||
|
|
26dde0f286 | ||
|
|
7d9f687768 | ||
|
|
0a0fea1c2f | ||
|
|
cb4970be59 | ||
|
|
460162737a | ||
|
|
393f95aeac | ||
|
|
03a4e3e8e9 | ||
|
|
243d549975 | ||
|
|
e309775ac1 | ||
|
|
f388fd9c90 | ||
|
|
b908f07355 | ||
|
|
1287594505 | ||
|
|
86b20e8ccd | ||
|
|
2181227a6e | ||
|
|
aab705f4a4 | ||
|
|
8af9a97518 | ||
|
|
9fac75b831 | ||
|
|
c83b5c6e73 | ||
|
|
a75326ff69 | ||
|
|
df6611e8de | ||
|
|
7e817f408c | ||
|
|
4ceb058a40 | ||
|
|
4710812c24 | ||
|
|
eb37c47ff5 | ||
|
|
e80c2c1a57 | ||
|
|
75f8607d75 | ||
|
|
828a286809 | ||
|
|
9b0e7eedb2 | ||
|
|
df4585af6b | ||
|
|
91d40dcc91 | ||
|
|
2b6363474e | ||
|
|
707c58a120 | ||
|
|
846ee0fb2d | ||
|
|
cdc9c0d62c | ||
|
|
b079cb99a4 | ||
|
|
0b0595384e |
6
.github/workflows/test.yaml
vendored
@@ -8,12 +8,18 @@ jobs:
|
|||||||
uses: actions/setup-go@v2
|
uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: '1.17.x'
|
go-version: '1.17.x'
|
||||||
|
- name: Install node
|
||||||
|
uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: '16'
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: sudo apt update && sudo apt install -y python3-pip curl
|
run: sudo apt update && sudo apt install -y python3-pip curl
|
||||||
- name: Build docs (required for tests)
|
- name: Build docs (required for tests)
|
||||||
run: make docs
|
run: make docs
|
||||||
|
- name: Build web app (required for tests)
|
||||||
|
run: make web
|
||||||
- name: Run tests, formatting, vetting and linting
|
- name: Run tests, formatting, vetting and linting
|
||||||
run: make check
|
run: make check
|
||||||
- name: Run coverage
|
- name: Run coverage
|
||||||
|
|||||||
2
.gitignore
vendored
@@ -2,6 +2,8 @@ dist/
|
|||||||
build/
|
build/
|
||||||
.idea/
|
.idea/
|
||||||
server/docs/
|
server/docs/
|
||||||
|
server/site/
|
||||||
tools/fbsend/fbsend
|
tools/fbsend/fbsend
|
||||||
playground/
|
playground/
|
||||||
*.iml
|
*.iml
|
||||||
|
node_modules/
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- go mod download
|
- go mod download
|
||||||
|
- go mod tidy
|
||||||
builds:
|
builds:
|
||||||
-
|
-
|
||||||
id: ntfy
|
id: ntfy_amd64
|
||||||
binary: ntfy
|
binary: ntfy
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=1 # required for go-sqlite3
|
- CGO_ENABLED=1 # required for go-sqlite3
|
||||||
@@ -12,6 +13,23 @@ builds:
|
|||||||
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||||
goos: [linux]
|
goos: [linux]
|
||||||
goarch: [amd64]
|
goarch: [amd64]
|
||||||
|
hooks:
|
||||||
|
post:
|
||||||
|
- upx "{{ .Path }}" # apt install upx
|
||||||
|
-
|
||||||
|
id: ntfy_armv6
|
||||||
|
binary: ntfy
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=1 # required for go-sqlite3
|
||||||
|
- CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi
|
||||||
|
tags: [sqlite_omit_load_extension,osusergo,netgo]
|
||||||
|
ldflags:
|
||||||
|
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||||
|
goos: [linux]
|
||||||
|
goarch: [arm]
|
||||||
|
goarm: [6]
|
||||||
|
# No "upx", since it causes random core dumps, see
|
||||||
|
# https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
|
||||||
-
|
-
|
||||||
id: ntfy_armv7
|
id: ntfy_armv7
|
||||||
binary: ntfy
|
binary: ntfy
|
||||||
@@ -24,6 +42,8 @@ builds:
|
|||||||
goos: [linux]
|
goos: [linux]
|
||||||
goarch: [arm]
|
goarch: [arm]
|
||||||
goarm: [7]
|
goarm: [7]
|
||||||
|
# No "upx", since it causes random core dumps, see
|
||||||
|
# https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
|
||||||
-
|
-
|
||||||
id: ntfy_arm64
|
id: ntfy_arm64
|
||||||
binary: ntfy
|
binary: ntfy
|
||||||
@@ -35,6 +55,8 @@ builds:
|
|||||||
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||||
goos: [linux]
|
goos: [linux]
|
||||||
goarch: [arm64]
|
goarch: [arm64]
|
||||||
|
# No "upx", since it causes random core dumps, see
|
||||||
|
# https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
|
||||||
nfpms:
|
nfpms:
|
||||||
-
|
-
|
||||||
package_name: ntfy
|
package_name: ntfy
|
||||||
@@ -49,20 +71,22 @@ nfpms:
|
|||||||
contents:
|
contents:
|
||||||
- src: server/server.yml
|
- src: server/server.yml
|
||||||
dst: /etc/ntfy/server.yml
|
dst: /etc/ntfy/server.yml
|
||||||
type: config
|
type: "config|noreplace"
|
||||||
- src: server/ntfy.service
|
- src: server/ntfy.service
|
||||||
dst: /lib/systemd/system/ntfy.service
|
dst: /lib/systemd/system/ntfy.service
|
||||||
- src: client/client.yml
|
- src: client/client.yml
|
||||||
dst: /etc/ntfy/client.yml
|
dst: /etc/ntfy/client.yml
|
||||||
type: config
|
type: "config|noreplace"
|
||||||
- src: client/ntfy-client.service
|
- src: client/ntfy-client.service
|
||||||
dst: /lib/systemd/system/ntfy-client.service
|
dst: /lib/systemd/system/ntfy-client.service
|
||||||
- dst: /var/cache/ntfy
|
- dst: /var/cache/ntfy
|
||||||
type: dir
|
type: dir
|
||||||
- dst: /var/cache/ntfy/attachments
|
- dst: /var/cache/ntfy/attachments
|
||||||
type: dir
|
type: dir
|
||||||
|
- dst: /var/lib/ntfy
|
||||||
|
type: dir
|
||||||
- dst: /usr/share/ntfy/logo.png
|
- dst: /usr/share/ntfy/logo.png
|
||||||
src: server/static/img/ntfy.png
|
src: web/public/static/img/ntfy.png
|
||||||
scripts:
|
scripts:
|
||||||
preinstall: "scripts/preinst.sh"
|
preinstall: "scripts/preinst.sh"
|
||||||
postinstall: "scripts/postinst.sh"
|
postinstall: "scripts/postinst.sh"
|
||||||
@@ -103,6 +127,7 @@ dockers:
|
|||||||
- &arm64v8_image "binwiederhier/ntfy:{{ .Tag }}-arm64v8"
|
- &arm64v8_image "binwiederhier/ntfy:{{ .Tag }}-arm64v8"
|
||||||
use: buildx
|
use: buildx
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
goarch: arm64
|
||||||
build_flag_templates:
|
build_flag_templates:
|
||||||
- "--platform=linux/arm64/v8"
|
- "--platform=linux/arm64/v8"
|
||||||
- image_templates:
|
- image_templates:
|
||||||
@@ -113,14 +138,24 @@ dockers:
|
|||||||
goarm: 7
|
goarm: 7
|
||||||
build_flag_templates:
|
build_flag_templates:
|
||||||
- "--platform=linux/arm/v7"
|
- "--platform=linux/arm/v7"
|
||||||
|
- image_templates:
|
||||||
|
- &armv6_image "binwiederhier/ntfy:{{ .Tag }}-armv6"
|
||||||
|
use: buildx
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
goarch: arm
|
||||||
|
goarm: 6
|
||||||
|
build_flag_templates:
|
||||||
|
- "--platform=linux/arm/v6"
|
||||||
docker_manifests:
|
docker_manifests:
|
||||||
- name_template: "binwiederhier/ntfy:latest"
|
- name_template: "binwiederhier/ntfy:latest"
|
||||||
image_templates:
|
image_templates:
|
||||||
- *amd64_image
|
- *amd64_image
|
||||||
- *arm64v8_image
|
- *arm64v8_image
|
||||||
- *armv7_image
|
- *armv7_image
|
||||||
|
- *armv6_image
|
||||||
- name_template: "binwiederhier/ntfy:{{ .Tag }}"
|
- name_template: "binwiederhier/ntfy:{{ .Tag }}"
|
||||||
image_templates:
|
image_templates:
|
||||||
- *amd64_image
|
- *amd64_image
|
||||||
- *arm64v8_image
|
- *arm64v8_image
|
||||||
- *armv7_image
|
- *armv7_image
|
||||||
|
- *armv6_image
|
||||||
|
|||||||
@@ -2,4 +2,6 @@ FROM alpine
|
|||||||
MAINTAINER Philipp C. Heckel <philipp.heckel@gmail.com>
|
MAINTAINER Philipp C. Heckel <philipp.heckel@gmail.com>
|
||||||
|
|
||||||
COPY ntfy /usr/bin
|
COPY ntfy /usr/bin
|
||||||
|
|
||||||
|
EXPOSE 80/tcp
|
||||||
ENTRYPOINT ["ntfy"]
|
ENTRYPOINT ["ntfy"]
|
||||||
|
|||||||
218
Makefile
@@ -3,47 +3,137 @@ VERSION := $(shell git describe --tag)
|
|||||||
.PHONY:
|
.PHONY:
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "Typical commands:"
|
@echo "Typical commands (more see below):"
|
||||||
@echo " make check - Run all tests, vetting/formatting checks and linters"
|
@echo " make build - Build web app, documentation and server/client (sloowwww)"
|
||||||
@echo " make fmt build-snapshot install - Build latest and install to local system"
|
@echo " make server-amd64 - Build server/client binary (amd64, no web app or docs)"
|
||||||
|
@echo " make install-amd64 - Install ntfy binary to /usr/bin/ntfy (amd64)"
|
||||||
|
@echo " make web - Build the web app"
|
||||||
|
@echo " make docs - Build the documentation"
|
||||||
|
@echo " make check - Run all tests, vetting/formatting checks and linters"
|
||||||
|
@echo
|
||||||
|
@echo "Build everything:"
|
||||||
|
@echo " make build - Build web app, documentation and server/client"
|
||||||
|
@echo " make clean - Clean build/dist folders"
|
||||||
|
@echo
|
||||||
|
@echo "Build server & client (not release version):"
|
||||||
|
@echo " make server - Build server & client (all architectures)"
|
||||||
|
@echo " make server-amd64 - Build server & client (amd64 only)"
|
||||||
|
@echo " make server-armv6 - Build server & client (armv6 only)"
|
||||||
|
@echo " make server-armv7 - Build server & client (armv7 only)"
|
||||||
|
@echo " make server-arm64 - Build server & client (arm64 only)"
|
||||||
|
@echo
|
||||||
|
@echo "Build web app:"
|
||||||
|
@echo " make web - Build the web app"
|
||||||
|
@echo " make web-deps - Install web app dependencies (npm install the universe)"
|
||||||
|
@echo " make web-build - Actually build the web app"
|
||||||
|
@echo
|
||||||
|
@echo "Build documentation:"
|
||||||
|
@echo " make docs - Build the documentation"
|
||||||
|
@echo " make docs-deps - Install Python dependencies (pip3 install)"
|
||||||
|
@echo " make docs-build - Actually build the documentation"
|
||||||
@echo
|
@echo
|
||||||
@echo "Test/check:"
|
@echo "Test/check:"
|
||||||
@echo " make test - Run tests"
|
@echo " make test - Run tests"
|
||||||
@echo " make race - Run tests with -race flag"
|
@echo " make race - Run tests with -race flag"
|
||||||
@echo " make coverage - Run tests and show coverage"
|
@echo " make coverage - Run tests and show coverage"
|
||||||
@echo " make coverage-html - Run tests and show coverage (as HTML)"
|
@echo " make coverage-html - Run tests and show coverage (as HTML)"
|
||||||
@echo " make coverage-upload - Upload coverage results to codecov.io"
|
@echo " make coverage-upload - Upload coverage results to codecov.io"
|
||||||
@echo
|
@echo
|
||||||
@echo "Lint/format:"
|
@echo "Lint/format:"
|
||||||
@echo " make fmt - Run 'go fmt'"
|
@echo " make fmt - Run 'go fmt'"
|
||||||
@echo " make fmt-check - Run 'go fmt', but don't change anything"
|
@echo " make fmt-check - Run 'go fmt', but don't change anything"
|
||||||
@echo " make vet - Run 'go vet'"
|
@echo " make vet - Run 'go vet'"
|
||||||
@echo " make lint - Run 'golint'"
|
@echo " make lint - Run 'golint'"
|
||||||
@echo " make staticcheck - Run 'staticcheck'"
|
@echo " make staticcheck - Run 'staticcheck'"
|
||||||
@echo
|
@echo
|
||||||
@echo "Build:"
|
@echo "Releasing:"
|
||||||
@echo " make build - Build"
|
@echo " make release - Create a release"
|
||||||
@echo " make build-snapshot - Build snapshot"
|
@echo " make release-snapshot - Create a test release"
|
||||||
@echo " make build-simple - Build (using go build, without goreleaser)"
|
|
||||||
@echo " make clean - Clean build folder"
|
|
||||||
@echo
|
|
||||||
@echo "Releasing (requires goreleaser):"
|
|
||||||
@echo " make release - Create a release"
|
|
||||||
@echo " make release-snapshot - Create a test release"
|
|
||||||
@echo
|
@echo
|
||||||
@echo "Install locally (requires sudo):"
|
@echo "Install locally (requires sudo):"
|
||||||
@echo " make install - Copy binary from dist/ to /usr/bin"
|
@echo " make install-amd64 - Copy amd64 binary from dist/ to /usr/bin/ntfy"
|
||||||
@echo " make install-deb - Install .deb from dist/"
|
@echo " make install-armv6 - Copy armv6 binary from dist/ to /usr/bin/ntfy"
|
||||||
@echo " make install-lint - Install golint"
|
@echo " make install-armv7 - Copy armv7 binary from dist/ to /usr/bin/ntfy"
|
||||||
|
@echo " make install-arm64 - Copy arm64 binary from dist/ to /usr/bin/ntfy"
|
||||||
|
@echo " make install-deb-amd64 - Install .deb from dist/ (amd64 only)"
|
||||||
|
@echo " make install-deb-armv6 - Install .deb from dist/ (armv6 only)"
|
||||||
|
@echo " make install-deb-armv7 - Install .deb from dist/ (armv7 only)"
|
||||||
|
@echo " make install-deb-arm64 - Install .deb from dist/ (arm64 only)"
|
||||||
|
|
||||||
|
|
||||||
|
# Building everything
|
||||||
|
|
||||||
|
clean: .PHONY
|
||||||
|
rm -rf dist build server/docs server/site
|
||||||
|
|
||||||
|
build: web docs server
|
||||||
|
|
||||||
|
|
||||||
# Documentation
|
# Documentation
|
||||||
|
|
||||||
|
docs: docs-deps docs-build
|
||||||
|
|
||||||
docs-deps: .PHONY
|
docs-deps: .PHONY
|
||||||
pip3 install -r requirements.txt
|
pip3 install -r requirements.txt
|
||||||
|
|
||||||
docs: docs-deps
|
docs-build: .PHONY
|
||||||
mkdocs build
|
mkdocs build
|
||||||
|
|
||||||
|
|
||||||
|
# Web app
|
||||||
|
|
||||||
|
web: web-deps web-build
|
||||||
|
|
||||||
|
web-deps:
|
||||||
|
cd web && npm install
|
||||||
|
# If this fails for .svg files, optimizes them with svgo
|
||||||
|
|
||||||
|
web-build:
|
||||||
|
cd web \
|
||||||
|
&& npm run build \
|
||||||
|
&& mv build/index.html build/app.html \
|
||||||
|
&& rm -rf ../server/site \
|
||||||
|
&& mv build ../server/site \
|
||||||
|
&& rm \
|
||||||
|
../server/site/config.js \
|
||||||
|
../server/site/asset-manifest.json
|
||||||
|
|
||||||
|
|
||||||
|
# Main server/client build
|
||||||
|
|
||||||
|
server: server-deps
|
||||||
|
goreleaser build --snapshot --rm-dist --debug
|
||||||
|
|
||||||
|
server-amd64: server-deps-static-sites
|
||||||
|
goreleaser build --snapshot --rm-dist --debug --id ntfy_amd64
|
||||||
|
|
||||||
|
server-armv6: server-deps-static-sites server-deps-gcc-armv6-armv7
|
||||||
|
goreleaser build --snapshot --rm-dist --debug --id ntfy_armv6
|
||||||
|
|
||||||
|
server-armv7: server-deps-static-sites server-deps-gcc-armv6-armv7
|
||||||
|
goreleaser build --snapshot --rm-dist --debug --id ntfy_armv7
|
||||||
|
|
||||||
|
server-arm64: server-deps-static-sites server-deps-gcc-arm64
|
||||||
|
goreleaser build --snapshot --rm-dist --debug --id ntfy_arm64
|
||||||
|
|
||||||
|
server-deps: server-deps-static-sites server-deps-all server-deps-gcc
|
||||||
|
|
||||||
|
server-deps-gcc: server-deps-gcc-armv6-armv7 server-deps-gcc-arm64
|
||||||
|
|
||||||
|
server-deps-static-sites:
|
||||||
|
mkdir -p server/docs server/site
|
||||||
|
touch server/docs/index.html server/site/app.html
|
||||||
|
|
||||||
|
server-deps-all:
|
||||||
|
which upx || { echo "ERROR: upx not installed. On Ubuntu, run: apt install upx"; exit 1; }
|
||||||
|
|
||||||
|
server-deps-gcc-armv6-armv7:
|
||||||
|
which arm-linux-gnueabi-gcc || { echo "ERROR: ARMv6/ARMv7 cross compiler not installed. On Ubuntu, run: apt install gcc-arm-linux-gnueabi"; exit 1; }
|
||||||
|
|
||||||
|
server-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; }
|
||||||
|
|
||||||
|
|
||||||
# Test/check targets
|
# Test/check targets
|
||||||
|
|
||||||
check: test fmt-check vet lint staticcheck
|
check: test fmt-check vet lint staticcheck
|
||||||
@@ -80,7 +170,7 @@ vet:
|
|||||||
go vet ./...
|
go vet ./...
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
which golint || go get -u golang.org/x/lint/golint
|
which golint || go install golang.org/x/lint/golint@latest
|
||||||
go list ./... | grep -v /vendor/ | xargs -L1 golint -set_exit_status
|
go list ./... | grep -v /vendor/ | xargs -L1 golint -set_exit_status
|
||||||
|
|
||||||
staticcheck: .PHONY
|
staticcheck: .PHONY
|
||||||
@@ -92,55 +182,55 @@ staticcheck: .PHONY
|
|||||||
rm -rf build/staticcheck
|
rm -rf build/staticcheck
|
||||||
|
|
||||||
|
|
||||||
# Building targets
|
|
||||||
|
|
||||||
build-deps: docs
|
|
||||||
which arm-linux-gnueabi-gcc || { echo "ERROR: ARMv6/v7 cross compiler not installed. On Ubuntu, run: apt install gcc-arm-linux-gnueabi"; exit 1; }
|
|
||||||
which aarch64-linux-gnu-gcc || { echo "ERROR: ARM64 cross compiler not installed. On Ubuntu, run: apt install gcc-aarch64-linux-gnu"; exit 1; }
|
|
||||||
|
|
||||||
build: build-deps
|
|
||||||
goreleaser build --rm-dist --debug
|
|
||||||
|
|
||||||
build-snapshot: build-deps
|
|
||||||
goreleaser build --snapshot --rm-dist --debug
|
|
||||||
|
|
||||||
build-simple: clean
|
|
||||||
mkdir -p dist/ntfy_linux_amd64 server/docs
|
|
||||||
touch server/docs/dummy
|
|
||||||
export CGO_ENABLED=1
|
|
||||||
go build \
|
|
||||||
-o dist/ntfy_linux_amd64/ntfy \
|
|
||||||
-tags sqlite_omit_load_extension,osusergo,netgo \
|
|
||||||
-ldflags \
|
|
||||||
"-linkmode=external -extldflags=-static -s -w -X main.version=$(VERSION) -X main.commit=$(shell git rev-parse --short HEAD) -X main.date=$(shell date +%s)"
|
|
||||||
|
|
||||||
clean: .PHONY
|
|
||||||
rm -rf dist build server/docs
|
|
||||||
|
|
||||||
|
|
||||||
# Releasing targets
|
# Releasing targets
|
||||||
|
|
||||||
|
release: clean server-deps release-check-tags docs web check
|
||||||
|
goreleaser release --rm-dist --debug
|
||||||
|
|
||||||
|
release-snapshot: clean server-deps docs web check
|
||||||
|
goreleaser release --snapshot --skip-publish --rm-dist --debug
|
||||||
|
|
||||||
release-check-tags:
|
release-check-tags:
|
||||||
$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-))
|
$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-))
|
||||||
if ! grep -q $(LATEST_TAG) docs/install.md; then\
|
if ! grep -q $(LATEST_TAG) docs/install.md; then\
|
||||||
echo "ERROR: Must update docs/install.md with latest tag first.";\
|
echo "ERROR: Must update docs/install.md with latest tag first.";\
|
||||||
exit 1;\
|
exit 1;\
|
||||||
fi
|
fi
|
||||||
|
if ! grep -q $(LATEST_TAG) docs/releases.md; then\
|
||||||
release: build-deps release-check-tags check
|
echo "ERROR: Must update docs/releases.md with latest tag first.";\
|
||||||
goreleaser release --rm-dist --debug
|
exit 1;\
|
||||||
|
fi
|
||||||
release-snapshot: build-deps
|
|
||||||
goreleaser release --snapshot --skip-publish --rm-dist --debug
|
|
||||||
|
|
||||||
|
|
||||||
# Installing targets
|
# Installing targets
|
||||||
|
|
||||||
install:
|
install-amd64: remove-binary
|
||||||
sudo rm -f /usr/bin/ntfy
|
sudo cp -a dist/ntfy_amd64_linux_amd64_v1/ntfy /usr/bin/ntfy
|
||||||
sudo cp -a dist/ntfy_linux_amd64/ntfy /usr/bin/ntfy
|
|
||||||
|
|
||||||
install-deb:
|
install-armv6: remove-binary
|
||||||
|
sudo cp -a dist/ntfy_armv6_linux_arm_6/ntfy /usr/bin/ntfy
|
||||||
|
|
||||||
|
install-armv7: remove-binary
|
||||||
|
sudo cp -a dist/ntfy_armv7_linux_arm_7/ntfy /usr/bin/ntfy
|
||||||
|
|
||||||
|
install-arm64: remove-binary
|
||||||
|
sudo cp -a dist/ntfy_arm64_linux_arm64/ntfy /usr/bin/ntfy
|
||||||
|
|
||||||
|
remove-binary:
|
||||||
|
sudo rm -f /usr/bin/ntfy
|
||||||
|
|
||||||
|
install-amd64-deb: purge-package
|
||||||
|
sudo dpkg -i dist/ntfy_*_linux_amd64.deb
|
||||||
|
|
||||||
|
install-armv6-deb: purge-package
|
||||||
|
sudo dpkg -i dist/ntfy_*_linux_armv6.deb
|
||||||
|
|
||||||
|
install-armv7-deb: purge-package
|
||||||
|
sudo dpkg -i dist/ntfy_*_linux_armv7.deb
|
||||||
|
|
||||||
|
install-arm64-deb: purge-package
|
||||||
|
sudo dpkg -i dist/ntfy_*_linux_arm64.deb
|
||||||
|
|
||||||
|
purge-package:
|
||||||
sudo systemctl stop ntfy || true
|
sudo systemctl stop ntfy || true
|
||||||
sudo apt-get purge ntfy || true
|
sudo apt-get purge ntfy || true
|
||||||
sudo dpkg -i dist/ntfy_*_linux_amd64.deb
|
|
||||||
|
|||||||
35
README.md
@@ -1,4 +1,4 @@
|
|||||||

|

|
||||||
|
|
||||||
# ntfy.sh | Send push notifications to your phone or desktop via PUT/POST
|
# ntfy.sh | Send push notifications to your phone or desktop via PUT/POST
|
||||||
[](https://github.com/binwiederhier/ntfy/releases/latest)
|
[](https://github.com/binwiederhier/ntfy/releases/latest)
|
||||||
@@ -18,11 +18,11 @@ I run a free version of it at **[ntfy.sh](https://ntfy.sh)**, and there's an [op
|
|||||||
too.
|
too.
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src="server/static/img/screenshot-curl.png" height="180">
|
<img src="web/public/static/img/screenshot-curl.png" height="180">
|
||||||
<img src="server/static/img/screenshot-web-detail.png" height="180">
|
<img src="web/public/static/img/screenshot-web-detail.png" height="180">
|
||||||
<img src="server/static/img/screenshot-phone-main.jpg" height="180">
|
<img src="web/public/static/img/screenshot-phone-main.jpg" height="180">
|
||||||
<img src="server/static/img/screenshot-phone-detail.jpg" height="180">
|
<img src="web/public/static/img/screenshot-phone-detail.jpg" height="180">
|
||||||
<img src="server/static/img/screenshot-phone-notification.jpg" height="180">
|
<img src="web/public/static/img/screenshot-phone-notification.jpg" height="180">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## **[Documentation](https://ntfy.sh/docs/)**
|
## **[Documentation](https://ntfy.sh/docs/)**
|
||||||
@@ -34,7 +34,14 @@ too.
|
|||||||
[Building](https://ntfy.sh/docs/develop/)
|
[Building](https://ntfy.sh/docs/develop/)
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
I welcome any and all contributions. Just create a PR or an issue.
|
I welcome any and all contributions. Just create a PR or an issue. To contribute code, check out
|
||||||
|
the [build instructions](https://ntfy.sh/docs/develop/) for the server and the Android app.
|
||||||
|
Or, if you'd like to help translate 🇩🇪 🇺🇸 🇧🇬, you can start immediately in
|
||||||
|
[Hosted Weblate](https://hosted.weblate.org/projects/ntfy/).
|
||||||
|
|
||||||
|
<a href="https://hosted.weblate.org/engage/ntfy/">
|
||||||
|
<img src="https://hosted.weblate.org/widgets/ntfy/-/multi-blue.svg" alt="Translation status" />
|
||||||
|
</a>
|
||||||
|
|
||||||
## Contact me
|
## Contact me
|
||||||
You can directly contact me **[on Discord](https://discord.gg/cT7ECsZj9w)** or [on Matrix](https://matrix.to/#/#ntfy:matrix.org)
|
You can directly contact me **[on Discord](https://discord.gg/cT7ECsZj9w)** or [on Matrix](https://matrix.to/#/#ntfy:matrix.org)
|
||||||
@@ -47,13 +54,21 @@ The project is dual licensed under the [Apache License 2.0](LICENSE) and the [GP
|
|||||||
|
|
||||||
Third party libraries and resources:
|
Third party libraries and resources:
|
||||||
* [github.com/urfave/cli/v2](https://github.com/urfave/cli/v2) (MIT) is used to drive the CLI
|
* [github.com/urfave/cli/v2](https://github.com/urfave/cli/v2) (MIT) is used to drive the CLI
|
||||||
* [Mixkit sound](https://mixkit.co/free-sound-effects/notification/) (Mixkit Free License) used as notification sound
|
* [Mixkit sounds](https://mixkit.co/free-sound-effects/notification/) (Mixkit Free License) are used as notification sounds
|
||||||
* [Lato Font](https://www.latofonts.com/) (OFL) is used as a font in the Web UI
|
* [Sounds from notificationsounds.com](https://notificationsounds.com) (Creative Commons Attribution) are used as notification sounds
|
||||||
|
* [Roboto Font](https://fonts.google.com/specimen/Roboto) (Apache 2.0) is used as a font in everything web
|
||||||
|
* [React](https://reactjs.org/) (MIT) is used for the web app
|
||||||
|
* [Material UI components](https://mui.com/) (MIT) are used in the web app
|
||||||
|
* [MUI dashboard template](https://github.com/mui/material-ui/tree/master/docs/data/material/getting-started/templates/dashboard) (MIT) was used as a basis for the web app
|
||||||
|
* [Dexie.js](https://github.com/dexie/Dexie.js) (Apache 2.0) is used for web app persistence in IndexedDB
|
||||||
* [GoReleaser](https://goreleaser.com/) (MIT) is used to create releases
|
* [GoReleaser](https://goreleaser.com/) (MIT) is used to create releases
|
||||||
* [go-smtp](https://github.com/emersion/go-smtp) (MIT) is used to receive e-mails
|
* [go-smtp](https://github.com/emersion/go-smtp) (MIT) is used to receive e-mails
|
||||||
* [stretchr/testify](https://github.com/stretchr/testify) (MIT) is used for unit and integration tests
|
* [stretchr/testify](https://github.com/stretchr/testify) (MIT) is used for unit and integration tests
|
||||||
* [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) (MIT) is used to provide the persistent message cache
|
* [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
|
* [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)
|
* [github/gemoji](https://github.com/github/gemoji) (MIT) is used for emoji support (specifically the [emoji.json](https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json) file)
|
||||||
* [Lightbox with vanilla JS](https://yossiabramov.com/blog/vanilla-js-lightbox)
|
* [Lightbox with vanilla JS](https://yossiabramov.com/blog/vanilla-js-lightbox) as a lightbox on the landing page
|
||||||
|
* [HTTP middleware for gzip compression](https://gist.github.com/CJEnright/bc2d8b8dc0c1389a9feeddb110f822d7) (MIT) is used for serving static files
|
||||||
|
* [Regex for auto-linking](https://github.com/bryanwoods/autolink-js) (MIT) is used to highlight links (the library is not used)
|
||||||
* [Statically linking go-sqlite3](https://www.arp242.net/static-go.html)
|
* [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)
|
||||||
|
|||||||
122
auth/auth.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
// Package auth deals with authentication and authorization against topics
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"regexp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Auther is a generic interface to implement password-based authentication and authorization
|
||||||
|
type Auther interface {
|
||||||
|
// Authenticate checks username and password and returns a user if correct. The method
|
||||||
|
// returns in constant-ish time, regardless of whether the user exists or the password is
|
||||||
|
// correct or incorrect.
|
||||||
|
Authenticate(username, password string) (*User, error)
|
||||||
|
|
||||||
|
// Authorize returns nil if the given user has access to the given topic using the desired
|
||||||
|
// permission. The user param may be nil to signal an anonymous user.
|
||||||
|
Authorize(user *User, topic string, perm Permission) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager is an interface representing user and access management
|
||||||
|
type Manager interface {
|
||||||
|
// AddUser adds a user with the given username, password and role. The password should be hashed
|
||||||
|
// before it is stored in a persistence layer.
|
||||||
|
AddUser(username, password string, role Role) error
|
||||||
|
|
||||||
|
// RemoveUser deletes the user with the given username. The function returns nil on success, even
|
||||||
|
// if the user did not exist in the first place.
|
||||||
|
RemoveUser(username string) error
|
||||||
|
|
||||||
|
// Users returns a list of users. It always also returns the Everyone user ("*").
|
||||||
|
Users() ([]*User, error)
|
||||||
|
|
||||||
|
// User returns the user with the given username if it exists, or ErrNotFound otherwise.
|
||||||
|
// You may also pass Everyone to retrieve the anonymous user and its Grant list.
|
||||||
|
User(username string) (*User, error)
|
||||||
|
|
||||||
|
// ChangePassword changes a user's password
|
||||||
|
ChangePassword(username, password string) error
|
||||||
|
|
||||||
|
// ChangeRole changes a user's role. When a role is changed from RoleUser to RoleAdmin,
|
||||||
|
// all existing access control entries (Grant) are removed, since they are no longer needed.
|
||||||
|
ChangeRole(username string, role Role) error
|
||||||
|
|
||||||
|
// AllowAccess adds or updates an entry in th access control list for a specific user. It controls
|
||||||
|
// read/write access to a topic. The parameter topicPattern may include wildcards (*).
|
||||||
|
AllowAccess(username string, topicPattern string, read bool, write bool) error
|
||||||
|
|
||||||
|
// ResetAccess removes an access control list entry for a specific username/topic, or (if topic is
|
||||||
|
// empty) for an entire user. The parameter topicPattern may include wildcards (*).
|
||||||
|
ResetAccess(username string, topicPattern string) error
|
||||||
|
|
||||||
|
// DefaultAccess returns the default read/write access if no access control entry matches
|
||||||
|
DefaultAccess() (read bool, write bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// User is a struct that represents a user
|
||||||
|
type User struct {
|
||||||
|
Name string
|
||||||
|
Hash string // password hash (bcrypt)
|
||||||
|
Role Role
|
||||||
|
Grants []Grant
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant is a struct that represents an access control entry to a topic
|
||||||
|
type Grant struct {
|
||||||
|
TopicPattern string // May include wildcard (*)
|
||||||
|
AllowRead bool
|
||||||
|
AllowWrite bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission represents a read or write permission to a topic
|
||||||
|
type Permission int
|
||||||
|
|
||||||
|
// Permissions to a topic
|
||||||
|
const (
|
||||||
|
PermissionRead = Permission(1)
|
||||||
|
PermissionWrite = Permission(2)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Role represents a user's role, either admin or regular user
|
||||||
|
type Role string
|
||||||
|
|
||||||
|
// User roles
|
||||||
|
const (
|
||||||
|
RoleAdmin = Role("admin")
|
||||||
|
RoleUser = Role("user")
|
||||||
|
RoleAnonymous = Role("anonymous")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Everyone is a special username representing anonymous users
|
||||||
|
const (
|
||||||
|
Everyone = "*"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
allowedUsernameRegex = regexp.MustCompile(`^[-_.@a-zA-Z0-9]+$`) // Does not include Everyone (*)
|
||||||
|
allowedTopicPatternRegex = regexp.MustCompile(`^[-_*A-Za-z0-9]{1,64}$`) // Adds '*' for wildcards!
|
||||||
|
)
|
||||||
|
|
||||||
|
// AllowedRole returns true if the given role can be used for new users
|
||||||
|
func AllowedRole(role Role) bool {
|
||||||
|
return role == RoleUser || role == RoleAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowedUsername returns true if the given username is valid
|
||||||
|
func AllowedUsername(username string) bool {
|
||||||
|
return allowedUsernameRegex.MatchString(username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowedTopicPattern returns true if the given topic pattern is valid; this includes the wildcard character (*)
|
||||||
|
func AllowedTopicPattern(username string) bool {
|
||||||
|
return allowedTopicPatternRegex.MatchString(username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error constants used by the package
|
||||||
|
var (
|
||||||
|
ErrUnauthenticated = errors.New("unauthenticated")
|
||||||
|
ErrUnauthorized = errors.New("unauthorized")
|
||||||
|
ErrInvalidArgument = errors.New("invalid argument")
|
||||||
|
ErrNotFound = errors.New("not found")
|
||||||
|
)
|
||||||
399
auth/auth_sqlite.go
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
bcryptCost = 10
|
||||||
|
intentionalSlowDownHash = "$2a$10$YFCQvqQDwIIwnJM1xkAYOeih0dg17UVGanaTStnrSzC8NCWxcLDwy" // Cost should match bcryptCost
|
||||||
|
)
|
||||||
|
|
||||||
|
// Auther-related queries
|
||||||
|
const (
|
||||||
|
createAuthTablesQueries = `
|
||||||
|
BEGIN;
|
||||||
|
CREATE TABLE IF NOT EXISTS user (
|
||||||
|
user TEXT NOT NULL PRIMARY KEY,
|
||||||
|
pass TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS access (
|
||||||
|
user TEXT NOT NULL,
|
||||||
|
topic TEXT NOT NULL,
|
||||||
|
read INT NOT NULL,
|
||||||
|
write INT NOT NULL,
|
||||||
|
PRIMARY KEY (topic, user)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
version INT NOT NULL
|
||||||
|
);
|
||||||
|
COMMIT;
|
||||||
|
`
|
||||||
|
selectUserQuery = `SELECT pass, role FROM user WHERE user = ?`
|
||||||
|
selectTopicPermsQuery = `
|
||||||
|
SELECT read, write
|
||||||
|
FROM access
|
||||||
|
WHERE user IN ('*', ?) AND ? LIKE topic
|
||||||
|
ORDER BY user DESC
|
||||||
|
`
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manager-related queries
|
||||||
|
const (
|
||||||
|
insertUserQuery = `INSERT INTO user (user, pass, role) VALUES (?, ?, ?)`
|
||||||
|
selectUsernamesQuery = `SELECT user FROM user ORDER BY role, user`
|
||||||
|
updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?`
|
||||||
|
updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?`
|
||||||
|
deleteUserQuery = `DELETE FROM user WHERE user = ?`
|
||||||
|
|
||||||
|
upsertUserAccessQuery = `
|
||||||
|
INSERT INTO access (user, topic, read, write)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT (user, topic) DO UPDATE SET read=excluded.read, write=excluded.write
|
||||||
|
`
|
||||||
|
selectUserAccessQuery = `SELECT topic, read, write FROM access WHERE user = ?`
|
||||||
|
deleteAllAccessQuery = `DELETE FROM access`
|
||||||
|
deleteUserAccessQuery = `DELETE FROM access WHERE user = ?`
|
||||||
|
deleteTopicAccessQuery = `DELETE FROM access WHERE user = ? AND topic = ?`
|
||||||
|
)
|
||||||
|
|
||||||
|
// Schema management queries
|
||||||
|
const (
|
||||||
|
currentSchemaVersion = 1
|
||||||
|
insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
|
||||||
|
selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
|
||||||
|
)
|
||||||
|
|
||||||
|
// SQLiteAuth is an implementation of Auther and Manager. It stores users and access control list
|
||||||
|
// in a SQLite database.
|
||||||
|
type SQLiteAuth struct {
|
||||||
|
db *sql.DB
|
||||||
|
defaultRead bool
|
||||||
|
defaultWrite bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Auther = (*SQLiteAuth)(nil)
|
||||||
|
var _ Manager = (*SQLiteAuth)(nil)
|
||||||
|
|
||||||
|
// NewSQLiteAuth creates a new SQLiteAuth instance
|
||||||
|
func NewSQLiteAuth(filename string, defaultRead, defaultWrite bool) (*SQLiteAuth, error) {
|
||||||
|
db, err := sql.Open("sqlite3", filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := setupAuthDB(db); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &SQLiteAuth{
|
||||||
|
db: db,
|
||||||
|
defaultRead: defaultRead,
|
||||||
|
defaultWrite: defaultWrite,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate checks username and password and returns a user if correct. The method
|
||||||
|
// returns in constant-ish time, regardless of whether the user exists or the password is
|
||||||
|
// correct or incorrect.
|
||||||
|
func (a *SQLiteAuth) Authenticate(username, password string) (*User, error) {
|
||||||
|
if username == Everyone {
|
||||||
|
return nil, ErrUnauthenticated
|
||||||
|
}
|
||||||
|
user, err := a.User(username)
|
||||||
|
if err != nil {
|
||||||
|
bcrypt.CompareHashAndPassword([]byte(intentionalSlowDownHash),
|
||||||
|
[]byte("intentional slow-down to avoid timing attacks"))
|
||||||
|
return nil, ErrUnauthenticated
|
||||||
|
}
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(user.Hash), []byte(password)); err != nil {
|
||||||
|
return nil, ErrUnauthenticated
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorize returns nil if the given user has access to the given topic using the desired
|
||||||
|
// permission. The user param may be nil to signal an anonymous user.
|
||||||
|
func (a *SQLiteAuth) Authorize(user *User, topic string, perm Permission) error {
|
||||||
|
if user != nil && user.Role == RoleAdmin {
|
||||||
|
return nil // Admin can do everything
|
||||||
|
}
|
||||||
|
username := Everyone
|
||||||
|
if user != nil {
|
||||||
|
username = user.Name
|
||||||
|
}
|
||||||
|
// Select the read/write permissions for this user/topic combo. The query may return two
|
||||||
|
// rows (one for everyone, and one for the user), but prioritizes the user. The value for
|
||||||
|
// user.Name may be empty (= everyone).
|
||||||
|
rows, err := a.db.Query(selectTopicPermsQuery, username, topic)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
if !rows.Next() {
|
||||||
|
return a.resolvePerms(a.defaultRead, a.defaultWrite, perm)
|
||||||
|
}
|
||||||
|
var read, write bool
|
||||||
|
if err := rows.Scan(&read, &write); err != nil {
|
||||||
|
return err
|
||||||
|
} else if err := rows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return a.resolvePerms(read, write, perm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *SQLiteAuth) resolvePerms(read, write bool, perm Permission) error {
|
||||||
|
if perm == PermissionRead && read {
|
||||||
|
return nil
|
||||||
|
} else if perm == PermissionWrite && write {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddUser adds a user with the given username, password and role. The password should be hashed
|
||||||
|
// before it is stored in a persistence layer.
|
||||||
|
func (a *SQLiteAuth) AddUser(username, password string, role Role) error {
|
||||||
|
if !AllowedUsername(username) || !AllowedRole(role) {
|
||||||
|
return ErrInvalidArgument
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = a.db.Exec(insertUserQuery, username, hash, role); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveUser deletes the user with the given username. The function returns nil on success, even
|
||||||
|
// if the user did not exist in the first place.
|
||||||
|
func (a *SQLiteAuth) RemoveUser(username string) error {
|
||||||
|
if !AllowedUsername(username) {
|
||||||
|
return ErrInvalidArgument
|
||||||
|
}
|
||||||
|
if _, err := a.db.Exec(deleteUserQuery, username); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := a.db.Exec(deleteUserAccessQuery, username); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Users returns a list of users. It always also returns the Everyone user ("*").
|
||||||
|
func (a *SQLiteAuth) Users() ([]*User, error) {
|
||||||
|
rows, err := a.db.Query(selectUsernamesQuery)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
usernames := make([]string, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var username string
|
||||||
|
if err := rows.Scan(&username); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
usernames = append(usernames, username)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
users := make([]*User, 0)
|
||||||
|
for _, username := range usernames {
|
||||||
|
user, err := a.User(username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
users = append(users, user)
|
||||||
|
}
|
||||||
|
everyone, err := a.everyoneUser()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
users = append(users, everyone)
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// User returns the user with the given username if it exists, or ErrNotFound otherwise.
|
||||||
|
// You may also pass Everyone to retrieve the anonymous user and its Grant list.
|
||||||
|
func (a *SQLiteAuth) User(username string) (*User, error) {
|
||||||
|
if username == Everyone {
|
||||||
|
return a.everyoneUser()
|
||||||
|
}
|
||||||
|
rows, err := a.db.Query(selectUserQuery, username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var hash, role string
|
||||||
|
if !rows.Next() {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err := rows.Scan(&hash, &role); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
grants, err := a.readGrants(username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &User{
|
||||||
|
Name: username,
|
||||||
|
Hash: hash,
|
||||||
|
Role: Role(role),
|
||||||
|
Grants: grants,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *SQLiteAuth) everyoneUser() (*User, error) {
|
||||||
|
grants, err := a.readGrants(Everyone)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &User{
|
||||||
|
Name: Everyone,
|
||||||
|
Hash: "",
|
||||||
|
Role: RoleAnonymous,
|
||||||
|
Grants: grants,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *SQLiteAuth) readGrants(username string) ([]Grant, error) {
|
||||||
|
rows, err := a.db.Query(selectUserAccessQuery, username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
grants := make([]Grant, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var topic string
|
||||||
|
var read, write bool
|
||||||
|
if err := rows.Scan(&topic, &read, &write); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
grants = append(grants, Grant{
|
||||||
|
TopicPattern: fromSQLWildcard(topic),
|
||||||
|
AllowRead: read,
|
||||||
|
AllowWrite: write,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return grants, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword changes a user's password
|
||||||
|
func (a *SQLiteAuth) ChangePassword(username, password string) error {
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := a.db.Exec(updateUserPassQuery, hash, username); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangeRole changes a user's role. When a role is changed from RoleUser to RoleAdmin,
|
||||||
|
// all existing access control entries (Grant) are removed, since they are no longer needed.
|
||||||
|
func (a *SQLiteAuth) ChangeRole(username string, role Role) error {
|
||||||
|
if !AllowedUsername(username) || !AllowedRole(role) {
|
||||||
|
return ErrInvalidArgument
|
||||||
|
}
|
||||||
|
if _, err := a.db.Exec(updateUserRoleQuery, string(role), username); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if role == RoleAdmin {
|
||||||
|
if _, err := a.db.Exec(deleteUserAccessQuery, username); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowAccess adds or updates an entry in th access control list for a specific user. It controls
|
||||||
|
// read/write access to a topic. The parameter topicPattern may include wildcards (*).
|
||||||
|
func (a *SQLiteAuth) AllowAccess(username string, topicPattern string, read bool, write bool) error {
|
||||||
|
if (!AllowedUsername(username) && username != Everyone) || !AllowedTopicPattern(topicPattern) {
|
||||||
|
return ErrInvalidArgument
|
||||||
|
}
|
||||||
|
if _, err := a.db.Exec(upsertUserAccessQuery, username, toSQLWildcard(topicPattern), read, write); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetAccess removes an access control list entry for a specific username/topic, or (if topic is
|
||||||
|
// empty) for an entire user. The parameter topicPattern may include wildcards (*).
|
||||||
|
func (a *SQLiteAuth) ResetAccess(username string, topicPattern string) error {
|
||||||
|
if !AllowedUsername(username) && username != Everyone && username != "" {
|
||||||
|
return ErrInvalidArgument
|
||||||
|
} else if !AllowedTopicPattern(topicPattern) && topicPattern != "" {
|
||||||
|
return ErrInvalidArgument
|
||||||
|
}
|
||||||
|
if username == "" && topicPattern == "" {
|
||||||
|
_, err := a.db.Exec(deleteAllAccessQuery, username)
|
||||||
|
return err
|
||||||
|
} else if topicPattern == "" {
|
||||||
|
_, err := a.db.Exec(deleteUserAccessQuery, username)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := a.db.Exec(deleteTopicAccessQuery, username, toSQLWildcard(topicPattern))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultAccess returns the default read/write access if no access control entry matches
|
||||||
|
func (a *SQLiteAuth) DefaultAccess() (read bool, write bool) {
|
||||||
|
return a.defaultRead, a.defaultWrite
|
||||||
|
}
|
||||||
|
|
||||||
|
func toSQLWildcard(s string) string {
|
||||||
|
return strings.ReplaceAll(s, "*", "%")
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromSQLWildcard(s string) string {
|
||||||
|
return strings.ReplaceAll(s, "%", "*")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupAuthDB(db *sql.DB) error {
|
||||||
|
// If 'schemaVersion' table does not exist, this must be a new database
|
||||||
|
rowsSV, err := db.Query(selectSchemaVersionQuery)
|
||||||
|
if err != nil {
|
||||||
|
return setupNewAuthDB(db)
|
||||||
|
}
|
||||||
|
defer rowsSV.Close()
|
||||||
|
|
||||||
|
// If 'schemaVersion' table exists, read version and potentially upgrade
|
||||||
|
schemaVersion := 0
|
||||||
|
if !rowsSV.Next() {
|
||||||
|
return errors.New("cannot determine schema version: database file may be corrupt")
|
||||||
|
}
|
||||||
|
if err := rowsSV.Scan(&schemaVersion); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rowsSV.Close()
|
||||||
|
|
||||||
|
// Do migrations
|
||||||
|
if schemaVersion == currentSchemaVersion {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("unexpected schema version found: %d", schemaVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupNewAuthDB(db *sql.DB) error {
|
||||||
|
if _, err := db.Exec(createAuthTablesQueries); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(insertSchemaVersion, currentSchemaVersion); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
243
auth/auth_sqlite_test.go
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
package auth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"heckel.io/ntfy/auth"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const minBcryptTimingMillis = int64(50) // Ideally should be >100ms, but this should also run on a Raspberry Pi without massive resources
|
||||||
|
|
||||||
|
func TestSQLiteAuth_FullScenario_Default_DenyAll(t *testing.T) {
|
||||||
|
a := newTestAuth(t, false, false)
|
||||||
|
require.Nil(t, a.AddUser("phil", "phil", auth.RoleAdmin))
|
||||||
|
require.Nil(t, a.AddUser("ben", "ben", auth.RoleUser))
|
||||||
|
require.Nil(t, a.AllowAccess("ben", "mytopic", true, true))
|
||||||
|
require.Nil(t, a.AllowAccess("ben", "readme", true, false))
|
||||||
|
require.Nil(t, a.AllowAccess("ben", "writeme", false, true))
|
||||||
|
require.Nil(t, a.AllowAccess("ben", "everyonewrite", false, false)) // How unfair!
|
||||||
|
require.Nil(t, a.AllowAccess(auth.Everyone, "announcements", true, false))
|
||||||
|
require.Nil(t, a.AllowAccess(auth.Everyone, "everyonewrite", true, true))
|
||||||
|
require.Nil(t, a.AllowAccess(auth.Everyone, "up*", false, true)) // Everyone can write to /up*
|
||||||
|
|
||||||
|
phil, err := a.Authenticate("phil", "phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "phil", phil.Name)
|
||||||
|
require.True(t, strings.HasPrefix(phil.Hash, "$2a$10$"))
|
||||||
|
require.Equal(t, auth.RoleAdmin, phil.Role)
|
||||||
|
require.Equal(t, []auth.Grant{}, phil.Grants)
|
||||||
|
|
||||||
|
ben, err := a.Authenticate("ben", "ben")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "ben", ben.Name)
|
||||||
|
require.True(t, strings.HasPrefix(ben.Hash, "$2a$10$"))
|
||||||
|
require.Equal(t, auth.RoleUser, ben.Role)
|
||||||
|
require.Equal(t, []auth.Grant{
|
||||||
|
{"mytopic", true, true},
|
||||||
|
{"readme", true, false},
|
||||||
|
{"writeme", false, true},
|
||||||
|
{"everyonewrite", false, false},
|
||||||
|
}, ben.Grants)
|
||||||
|
|
||||||
|
notben, err := a.Authenticate("ben", "this is wrong")
|
||||||
|
require.Nil(t, notben)
|
||||||
|
require.Equal(t, auth.ErrUnauthenticated, err)
|
||||||
|
|
||||||
|
// Admin can do everything
|
||||||
|
require.Nil(t, a.Authorize(phil, "sometopic", auth.PermissionWrite))
|
||||||
|
require.Nil(t, a.Authorize(phil, "mytopic", auth.PermissionRead))
|
||||||
|
require.Nil(t, a.Authorize(phil, "readme", auth.PermissionWrite))
|
||||||
|
require.Nil(t, a.Authorize(phil, "writeme", auth.PermissionWrite))
|
||||||
|
require.Nil(t, a.Authorize(phil, "announcements", auth.PermissionWrite))
|
||||||
|
require.Nil(t, a.Authorize(phil, "everyonewrite", auth.PermissionWrite))
|
||||||
|
|
||||||
|
// User cannot do everything
|
||||||
|
require.Nil(t, a.Authorize(ben, "mytopic", auth.PermissionWrite))
|
||||||
|
require.Nil(t, a.Authorize(ben, "mytopic", auth.PermissionRead))
|
||||||
|
require.Nil(t, a.Authorize(ben, "readme", auth.PermissionRead))
|
||||||
|
require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "readme", auth.PermissionWrite))
|
||||||
|
require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "writeme", auth.PermissionRead))
|
||||||
|
require.Nil(t, a.Authorize(ben, "writeme", auth.PermissionWrite))
|
||||||
|
require.Nil(t, a.Authorize(ben, "writeme", auth.PermissionWrite))
|
||||||
|
require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "everyonewrite", auth.PermissionRead))
|
||||||
|
require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "everyonewrite", auth.PermissionWrite))
|
||||||
|
require.Nil(t, a.Authorize(ben, "announcements", auth.PermissionRead))
|
||||||
|
require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "announcements", auth.PermissionWrite))
|
||||||
|
|
||||||
|
// Everyone else can do barely anything
|
||||||
|
require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "sometopicnotinthelist", auth.PermissionRead))
|
||||||
|
require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "sometopicnotinthelist", auth.PermissionWrite))
|
||||||
|
require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "mytopic", auth.PermissionRead))
|
||||||
|
require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "mytopic", auth.PermissionWrite))
|
||||||
|
require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "readme", auth.PermissionRead))
|
||||||
|
require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "readme", auth.PermissionWrite))
|
||||||
|
require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "writeme", auth.PermissionRead))
|
||||||
|
require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "writeme", auth.PermissionWrite))
|
||||||
|
require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "announcements", auth.PermissionWrite))
|
||||||
|
require.Nil(t, a.Authorize(nil, "announcements", auth.PermissionRead))
|
||||||
|
require.Nil(t, a.Authorize(nil, "everyonewrite", auth.PermissionRead))
|
||||||
|
require.Nil(t, a.Authorize(nil, "everyonewrite", auth.PermissionWrite))
|
||||||
|
require.Nil(t, a.Authorize(nil, "up1234", auth.PermissionWrite)) // Wildcard permission
|
||||||
|
require.Nil(t, a.Authorize(nil, "up5678", auth.PermissionWrite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSQLiteAuth_AddUser_Invalid(t *testing.T) {
|
||||||
|
a := newTestAuth(t, false, false)
|
||||||
|
require.Equal(t, auth.ErrInvalidArgument, a.AddUser(" invalid ", "pass", auth.RoleAdmin))
|
||||||
|
require.Equal(t, auth.ErrInvalidArgument, a.AddUser("validuser", "pass", "invalid-role"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSQLiteAuth_AddUser_Timing(t *testing.T) {
|
||||||
|
a := newTestAuth(t, false, false)
|
||||||
|
start := time.Now().UnixMilli()
|
||||||
|
require.Nil(t, a.AddUser("user", "pass", auth.RoleAdmin))
|
||||||
|
require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSQLiteAuth_Authenticate_Timing(t *testing.T) {
|
||||||
|
a := newTestAuth(t, false, false)
|
||||||
|
require.Nil(t, a.AddUser("user", "pass", auth.RoleAdmin))
|
||||||
|
|
||||||
|
// Timing a correct attempt
|
||||||
|
start := time.Now().UnixMilli()
|
||||||
|
_, err := a.Authenticate("user", "pass")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis)
|
||||||
|
|
||||||
|
// Timing an incorrect attempt
|
||||||
|
start = time.Now().UnixMilli()
|
||||||
|
_, err = a.Authenticate("user", "INCORRECT")
|
||||||
|
require.Equal(t, auth.ErrUnauthenticated, err)
|
||||||
|
require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis)
|
||||||
|
|
||||||
|
// Timing a non-existing user attempt
|
||||||
|
start = time.Now().UnixMilli()
|
||||||
|
_, err = a.Authenticate("DOES-NOT-EXIST", "hithere")
|
||||||
|
require.Equal(t, auth.ErrUnauthenticated, err)
|
||||||
|
require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSQLiteAuth_UserManagement(t *testing.T) {
|
||||||
|
a := newTestAuth(t, false, false)
|
||||||
|
require.Nil(t, a.AddUser("phil", "phil", auth.RoleAdmin))
|
||||||
|
require.Nil(t, a.AddUser("ben", "ben", auth.RoleUser))
|
||||||
|
require.Nil(t, a.AllowAccess("ben", "mytopic", true, true))
|
||||||
|
require.Nil(t, a.AllowAccess("ben", "readme", true, false))
|
||||||
|
require.Nil(t, a.AllowAccess("ben", "writeme", false, true))
|
||||||
|
require.Nil(t, a.AllowAccess("ben", "everyonewrite", false, false)) // How unfair!
|
||||||
|
require.Nil(t, a.AllowAccess(auth.Everyone, "announcements", true, false))
|
||||||
|
require.Nil(t, a.AllowAccess(auth.Everyone, "everyonewrite", true, true))
|
||||||
|
|
||||||
|
// Query user details
|
||||||
|
phil, err := a.User("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "phil", phil.Name)
|
||||||
|
require.True(t, strings.HasPrefix(phil.Hash, "$2a$10$"))
|
||||||
|
require.Equal(t, auth.RoleAdmin, phil.Role)
|
||||||
|
require.Equal(t, []auth.Grant{}, phil.Grants)
|
||||||
|
|
||||||
|
ben, err := a.User("ben")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "ben", ben.Name)
|
||||||
|
require.True(t, strings.HasPrefix(ben.Hash, "$2a$10$"))
|
||||||
|
require.Equal(t, auth.RoleUser, ben.Role)
|
||||||
|
require.Equal(t, []auth.Grant{
|
||||||
|
{"mytopic", true, true},
|
||||||
|
{"readme", true, false},
|
||||||
|
{"writeme", false, true},
|
||||||
|
{"everyonewrite", false, false},
|
||||||
|
}, ben.Grants)
|
||||||
|
|
||||||
|
everyone, err := a.User(auth.Everyone)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "*", everyone.Name)
|
||||||
|
require.Equal(t, "", everyone.Hash)
|
||||||
|
require.Equal(t, auth.RoleAnonymous, everyone.Role)
|
||||||
|
require.Equal(t, []auth.Grant{
|
||||||
|
{"announcements", true, false},
|
||||||
|
{"everyonewrite", true, true},
|
||||||
|
}, everyone.Grants)
|
||||||
|
|
||||||
|
// Ben: Before revoking
|
||||||
|
require.Nil(t, a.AllowAccess("ben", "mytopic", true, true))
|
||||||
|
require.Nil(t, a.AllowAccess("ben", "readme", true, false))
|
||||||
|
require.Nil(t, a.AllowAccess("ben", "writeme", false, true))
|
||||||
|
require.Nil(t, a.Authorize(ben, "mytopic", auth.PermissionRead))
|
||||||
|
require.Nil(t, a.Authorize(ben, "mytopic", auth.PermissionWrite))
|
||||||
|
require.Nil(t, a.Authorize(ben, "readme", auth.PermissionRead))
|
||||||
|
require.Nil(t, a.Authorize(ben, "writeme", auth.PermissionWrite))
|
||||||
|
|
||||||
|
// Revoke access for "ben" to "mytopic", then check again
|
||||||
|
require.Nil(t, a.ResetAccess("ben", "mytopic"))
|
||||||
|
require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "mytopic", auth.PermissionWrite)) // Revoked
|
||||||
|
require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "mytopic", auth.PermissionRead)) // Revoked
|
||||||
|
require.Nil(t, a.Authorize(ben, "readme", auth.PermissionRead)) // Unchanged
|
||||||
|
require.Nil(t, a.Authorize(ben, "writeme", auth.PermissionWrite)) // Unchanged
|
||||||
|
|
||||||
|
// Revoke rest of the access
|
||||||
|
require.Nil(t, a.ResetAccess("ben", ""))
|
||||||
|
require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "readme", auth.PermissionRead)) // Revoked
|
||||||
|
require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "wrtiteme", auth.PermissionWrite)) // Revoked
|
||||||
|
|
||||||
|
// User list
|
||||||
|
users, err := a.Users()
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 3, len(users))
|
||||||
|
require.Equal(t, "phil", users[0].Name)
|
||||||
|
require.Equal(t, "ben", users[1].Name)
|
||||||
|
require.Equal(t, "*", users[2].Name)
|
||||||
|
|
||||||
|
// Remove user
|
||||||
|
require.Nil(t, a.RemoveUser("ben"))
|
||||||
|
_, err = a.User("ben")
|
||||||
|
require.Equal(t, auth.ErrNotFound, err)
|
||||||
|
|
||||||
|
users, err = a.Users()
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 2, len(users))
|
||||||
|
require.Equal(t, "phil", users[0].Name)
|
||||||
|
require.Equal(t, "*", users[1].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSQLiteAuth_ChangePassword(t *testing.T) {
|
||||||
|
a := newTestAuth(t, false, false)
|
||||||
|
require.Nil(t, a.AddUser("phil", "phil", auth.RoleAdmin))
|
||||||
|
|
||||||
|
_, err := a.Authenticate("phil", "phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
require.Nil(t, a.ChangePassword("phil", "newpass"))
|
||||||
|
_, err = a.Authenticate("phil", "phil")
|
||||||
|
require.Equal(t, auth.ErrUnauthenticated, err)
|
||||||
|
_, err = a.Authenticate("phil", "newpass")
|
||||||
|
require.Nil(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSQLiteAuth_ChangeRole(t *testing.T) {
|
||||||
|
a := newTestAuth(t, false, false)
|
||||||
|
require.Nil(t, a.AddUser("ben", "ben", auth.RoleUser))
|
||||||
|
require.Nil(t, a.AllowAccess("ben", "mytopic", true, true))
|
||||||
|
require.Nil(t, a.AllowAccess("ben", "readme", true, false))
|
||||||
|
|
||||||
|
ben, err := a.User("ben")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, auth.RoleUser, ben.Role)
|
||||||
|
require.Equal(t, 2, len(ben.Grants))
|
||||||
|
|
||||||
|
require.Nil(t, a.ChangeRole("ben", auth.RoleAdmin))
|
||||||
|
|
||||||
|
ben, err = a.User("ben")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, auth.RoleAdmin, ben.Role)
|
||||||
|
require.Equal(t, 0, len(ben.Grants))
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestAuth(t *testing.T, defaultRead, defaultWrite bool) *auth.SQLiteAuth {
|
||||||
|
filename := filepath.Join(t.TempDir(), "user.db")
|
||||||
|
a, err := auth.NewSQLiteAuth(filename, defaultRead, defaultWrite)
|
||||||
|
require.Nil(t, err)
|
||||||
|
return a
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"io"
|
"io"
|
||||||
@@ -17,9 +18,10 @@ import (
|
|||||||
|
|
||||||
// Event type constants
|
// Event type constants
|
||||||
const (
|
const (
|
||||||
MessageEvent = "message"
|
MessageEvent = "message"
|
||||||
KeepaliveEvent = "keepalive"
|
KeepaliveEvent = "keepalive"
|
||||||
OpenEvent = "open"
|
OpenEvent = "open"
|
||||||
|
PollRequestEvent = "poll_request"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -36,14 +38,16 @@ type Client struct {
|
|||||||
|
|
||||||
// Message is a struct that represents a ntfy message
|
// Message is a struct that represents a ntfy message
|
||||||
type Message struct { // TODO combine with server.message
|
type Message struct { // TODO combine with server.message
|
||||||
ID string
|
ID string
|
||||||
Event string
|
Event string
|
||||||
Time int64
|
Time int64
|
||||||
Topic string
|
Topic string
|
||||||
Message string
|
Message string
|
||||||
Title string
|
Title string
|
||||||
Priority int
|
Priority int
|
||||||
Tags []string
|
Tags []string
|
||||||
|
Click string
|
||||||
|
Attachment *Attachment
|
||||||
|
|
||||||
// Additional fields
|
// Additional fields
|
||||||
TopicURL string
|
TopicURL string
|
||||||
@@ -51,6 +55,16 @@ type Message struct { // TODO combine with server.message
|
|||||||
Raw string
|
Raw string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attachment represents a message attachment
|
||||||
|
type Attachment struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Size int64 `json:"size,omitempty"`
|
||||||
|
Expires int64 `json:"expires,omitempty"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Owner string `json:"-"` // IP address of uploader, used for rate limiting
|
||||||
|
}
|
||||||
|
|
||||||
type subscription struct {
|
type subscription struct {
|
||||||
ID string
|
ID string
|
||||||
topicURL string
|
topicURL string
|
||||||
@@ -93,13 +107,13 @@ func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishO
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("unexpected response %d from server", resp.StatusCode)
|
|
||||||
}
|
|
||||||
b, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
|
b, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, errors.New(strings.TrimSpace(string(b)))
|
||||||
|
}
|
||||||
m, err := toMessage(string(b), topicURL, "")
|
m, err := toMessage(string(b), topicURL, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -238,6 +252,13 @@ func performSubscribeRequest(ctx context.Context, msgChan chan *Message, topicUR
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
b, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return errors.New(strings.TrimSpace(string(b)))
|
||||||
|
}
|
||||||
scanner := bufio.NewScanner(resp.Body)
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
m, err := toMessage(scanner.Text(), topicURL, subscriptionID)
|
m, err := toMessage(scanner.Text(), topicURL, subscriptionID)
|
||||||
|
|||||||
@@ -16,6 +16,10 @@
|
|||||||
# command: 'echo "$message"'
|
# command: 'echo "$message"'
|
||||||
# if:
|
# if:
|
||||||
# priority: high,urgent
|
# priority: high,urgent
|
||||||
|
# - topic: secret
|
||||||
|
# command: 'notify-send "$m"'
|
||||||
|
# user: phill
|
||||||
|
# password: mypass
|
||||||
#
|
#
|
||||||
# Variables:
|
# Variables:
|
||||||
# Variable Aliases Description
|
# Variable Aliases Description
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ const (
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
DefaultHost string `yaml:"default-host"`
|
DefaultHost string `yaml:"default-host"`
|
||||||
Subscribe []struct {
|
Subscribe []struct {
|
||||||
Topic string `yaml:"topic"`
|
Topic string `yaml:"topic"`
|
||||||
Command string `yaml:"command"`
|
User string `yaml:"user"`
|
||||||
If map[string]string `yaml:"if"`
|
Password string `yaml:"password"`
|
||||||
|
Command string `yaml:"command"`
|
||||||
|
If map[string]string `yaml:"if"`
|
||||||
} `yaml:"subscribe"`
|
} `yaml:"subscribe"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ func TestConfig_Load(t *testing.T) {
|
|||||||
require.Nil(t, os.WriteFile(filename, []byte(`
|
require.Nil(t, os.WriteFile(filename, []byte(`
|
||||||
default-host: http://localhost
|
default-host: http://localhost
|
||||||
subscribe:
|
subscribe:
|
||||||
- topic: no-command
|
- topic: no-command-with-auth
|
||||||
|
user: phil
|
||||||
|
password: mypass
|
||||||
- topic: echo-this
|
- topic: echo-this
|
||||||
command: 'echo "Message received: $message"'
|
command: 'echo "Message received: $message"'
|
||||||
- topic: alerts
|
- topic: alerts
|
||||||
@@ -26,8 +28,10 @@ subscribe:
|
|||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, "http://localhost", conf.DefaultHost)
|
require.Equal(t, "http://localhost", conf.DefaultHost)
|
||||||
require.Equal(t, 3, len(conf.Subscribe))
|
require.Equal(t, 3, len(conf.Subscribe))
|
||||||
require.Equal(t, "no-command", conf.Subscribe[0].Topic)
|
require.Equal(t, "no-command-with-auth", conf.Subscribe[0].Topic)
|
||||||
require.Equal(t, "", conf.Subscribe[0].Command)
|
require.Equal(t, "", conf.Subscribe[0].Command)
|
||||||
|
require.Equal(t, "phil", conf.Subscribe[0].User)
|
||||||
|
require.Equal(t, "mypass", conf.Subscribe[0].Password)
|
||||||
require.Equal(t, "echo-this", conf.Subscribe[1].Topic)
|
require.Equal(t, "echo-this", conf.Subscribe[1].Topic)
|
||||||
require.Equal(t, `echo "Message received: $message"`, conf.Subscribe[1].Command)
|
require.Equal(t, `echo "Message received: $message"`, conf.Subscribe[1].Command)
|
||||||
require.Equal(t, "alerts", conf.Subscribe[2].Topic)
|
require.Equal(t, "alerts", conf.Subscribe[2].Topic)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -55,6 +56,12 @@ func WithClick(url string) PublishOption {
|
|||||||
return WithHeader("X-Click", url)
|
return WithHeader("X-Click", url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithActions adds custom user actions to the notification. The value can be either a JSON array or the
|
||||||
|
// simple format definition. See https://ntfy.sh/docs/publish/#action-buttons for details.
|
||||||
|
func WithActions(value string) PublishOption {
|
||||||
|
return WithHeader("X-Actions", value)
|
||||||
|
}
|
||||||
|
|
||||||
// WithAttach sets a URL that will be used by the client to download an attachment
|
// WithAttach sets a URL that will be used by the client to download an attachment
|
||||||
func WithAttach(attach string) PublishOption {
|
func WithAttach(attach string) PublishOption {
|
||||||
return WithHeader("X-Attach", attach)
|
return WithHeader("X-Attach", attach)
|
||||||
@@ -70,6 +77,11 @@ func WithEmail(email string) PublishOption {
|
|||||||
return WithHeader("X-Email", email)
|
return WithHeader("X-Email", email)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithBasicAuth adds the Authorization header for basic auth to the request
|
||||||
|
func WithBasicAuth(user, pass string) PublishOption {
|
||||||
|
return WithHeader("Authorization", util.BasicAuth(user, pass))
|
||||||
|
}
|
||||||
|
|
||||||
// WithNoCache instructs the server not to cache the message server-side
|
// WithNoCache instructs the server not to cache the message server-side
|
||||||
func WithNoCache() PublishOption {
|
func WithNoCache() PublishOption {
|
||||||
return WithHeader("X-Cache", "no")
|
return WithHeader("X-Cache", "no")
|
||||||
|
|||||||
212
cmd/access.go
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
"heckel.io/ntfy/auth"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
userEveryone = "everyone"
|
||||||
|
)
|
||||||
|
|
||||||
|
var flagsAccess = append(
|
||||||
|
userCommandFlags(),
|
||||||
|
&cli.BoolFlag{Name: "reset", Aliases: []string{"r"}, Usage: "reset access for user (and topic)"},
|
||||||
|
)
|
||||||
|
|
||||||
|
var cmdAccess = &cli.Command{
|
||||||
|
Name: "access",
|
||||||
|
Usage: "Grant/revoke access to a topic, or show access",
|
||||||
|
UsageText: "ntfy access [USERNAME [TOPIC [PERMISSION]]]",
|
||||||
|
Flags: flagsAccess,
|
||||||
|
Before: initConfigFileInputSource("config", flagsAccess),
|
||||||
|
Action: execUserAccess,
|
||||||
|
Category: categoryServer,
|
||||||
|
Description: `Manage the access control list for the ntfy server.
|
||||||
|
|
||||||
|
This is a server-only command. It directly manages the user.db as defined in the server config
|
||||||
|
file server.yml. The command only works if 'auth-file' is properly defined. Please also refer
|
||||||
|
to the related command 'ntfy user'.
|
||||||
|
|
||||||
|
The command allows you to show the access control list, as well as change it, depending on how
|
||||||
|
it is called.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
ntfy access # Shows access control list (alias: 'ntfy user list')
|
||||||
|
ntfy access USERNAME # Shows access control entries for USERNAME
|
||||||
|
ntfy access USERNAME TOPIC PERMISSION # Allow/deny access for USERNAME to TOPIC
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
USERNAME an existing user, as created with 'ntfy user add', or "everyone"/"*"
|
||||||
|
to define access rules for anonymous/unauthenticated clients
|
||||||
|
TOPIC name of a topic with optional wildcards, e.g. "mytopic*"
|
||||||
|
PERMISSION one of the following:
|
||||||
|
- read-write (alias: rw)
|
||||||
|
- read-only (aliases: read, ro)
|
||||||
|
- write-only (aliases: write, wo)
|
||||||
|
- deny (alias: none)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
ntfy access # Shows access control list (alias: 'ntfy user list')
|
||||||
|
ntfy access phil # Shows access for user phil
|
||||||
|
ntfy access phil mytopic rw # Allow read-write access to mytopic for user phil
|
||||||
|
ntfy access everyone mytopic rw # Allow anonymous read-write access to mytopic
|
||||||
|
ntfy access everyone "up*" write # Allow anonymous write-only access to topics "up..."
|
||||||
|
ntfy access --reset # Reset entire access control list
|
||||||
|
ntfy access --reset phil # Reset all access for user phil
|
||||||
|
ntfy access --reset phil mytopic # Reset access for user phil and topic mytopic
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
|
||||||
|
func execUserAccess(c *cli.Context) error {
|
||||||
|
if c.NArg() > 3 {
|
||||||
|
return errors.New("too many arguments, please check 'ntfy access --help' for usage details")
|
||||||
|
}
|
||||||
|
manager, err := createAuthManager(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
username := c.Args().Get(0)
|
||||||
|
if username == userEveryone {
|
||||||
|
username = auth.Everyone
|
||||||
|
}
|
||||||
|
topic := c.Args().Get(1)
|
||||||
|
perms := c.Args().Get(2)
|
||||||
|
reset := c.Bool("reset")
|
||||||
|
if reset {
|
||||||
|
if perms != "" {
|
||||||
|
return errors.New("too many arguments, please check 'ntfy access --help' for usage details")
|
||||||
|
}
|
||||||
|
return resetAccess(c, manager, username, topic)
|
||||||
|
} else if perms == "" {
|
||||||
|
if topic != "" {
|
||||||
|
return errors.New("invalid syntax, please check 'ntfy access --help' for usage details")
|
||||||
|
}
|
||||||
|
return showAccess(c, manager, username)
|
||||||
|
}
|
||||||
|
return changeAccess(c, manager, username, topic, perms)
|
||||||
|
}
|
||||||
|
|
||||||
|
func changeAccess(c *cli.Context, manager auth.Manager, username string, topic string, perms string) error {
|
||||||
|
if !util.InStringList([]string{"", "read-write", "rw", "read-only", "read", "ro", "write-only", "write", "wo", "none", "deny"}, perms) {
|
||||||
|
return errors.New("permission must be one of: read-write, read-only, write-only, or deny (or the aliases: read, ro, write, wo, none)")
|
||||||
|
}
|
||||||
|
read := util.InStringList([]string{"read-write", "rw", "read-only", "read", "ro"}, perms)
|
||||||
|
write := util.InStringList([]string{"read-write", "rw", "write-only", "write", "wo"}, perms)
|
||||||
|
user, err := manager.User(username)
|
||||||
|
if err == auth.ErrNotFound {
|
||||||
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
|
} else if user.Role == auth.RoleAdmin {
|
||||||
|
return fmt.Errorf("user %s is an admin user, access control entries have no effect", username)
|
||||||
|
}
|
||||||
|
if err := manager.AllowAccess(username, topic, read, write); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if read && write {
|
||||||
|
fmt.Fprintf(c.App.ErrWriter, "granted read-write access to topic %s\n\n", topic)
|
||||||
|
} else if read {
|
||||||
|
fmt.Fprintf(c.App.ErrWriter, "granted read-only access to topic %s\n\n", topic)
|
||||||
|
} else if write {
|
||||||
|
fmt.Fprintf(c.App.ErrWriter, "granted write-only access to topic %s\n\n", topic)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(c.App.ErrWriter, "revoked all access to topic %s\n\n", topic)
|
||||||
|
}
|
||||||
|
return showUserAccess(c, manager, username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetAccess(c *cli.Context, manager auth.Manager, username, topic string) error {
|
||||||
|
if username == "" {
|
||||||
|
return resetAllAccess(c, manager)
|
||||||
|
} else if topic == "" {
|
||||||
|
return resetUserAccess(c, manager, username)
|
||||||
|
}
|
||||||
|
return resetUserTopicAccess(c, manager, username, topic)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetAllAccess(c *cli.Context, manager auth.Manager) error {
|
||||||
|
if err := manager.ResetAccess("", ""); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintln(c.App.ErrWriter, "reset access for all users")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetUserAccess(c *cli.Context, manager auth.Manager, username string) error {
|
||||||
|
if err := manager.ResetAccess(username, ""); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(c.App.ErrWriter, "reset access for user %s\n\n", username)
|
||||||
|
return showUserAccess(c, manager, username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetUserTopicAccess(c *cli.Context, manager auth.Manager, username string, topic string) error {
|
||||||
|
if err := manager.ResetAccess(username, topic); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(c.App.ErrWriter, "reset access for user %s and topic %s\n\n", username, topic)
|
||||||
|
return showUserAccess(c, manager, username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func showAccess(c *cli.Context, manager auth.Manager, username string) error {
|
||||||
|
if username == "" {
|
||||||
|
return showAllAccess(c, manager)
|
||||||
|
}
|
||||||
|
return showUserAccess(c, manager, username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func showAllAccess(c *cli.Context, manager auth.Manager) error {
|
||||||
|
users, err := manager.Users()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return showUsers(c, manager, users)
|
||||||
|
}
|
||||||
|
|
||||||
|
func showUserAccess(c *cli.Context, manager auth.Manager, username string) error {
|
||||||
|
users, err := manager.User(username)
|
||||||
|
if err == auth.ErrNotFound {
|
||||||
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return showUsers(c, manager, []*auth.User{users})
|
||||||
|
}
|
||||||
|
|
||||||
|
func showUsers(c *cli.Context, manager auth.Manager, users []*auth.User) error {
|
||||||
|
for _, user := range users {
|
||||||
|
fmt.Fprintf(c.App.ErrWriter, "user %s (%s)\n", user.Name, user.Role)
|
||||||
|
if user.Role == auth.RoleAdmin {
|
||||||
|
fmt.Fprintf(c.App.ErrWriter, "- read-write access to all topics (admin role)\n")
|
||||||
|
} else if len(user.Grants) > 0 {
|
||||||
|
for _, grant := range user.Grants {
|
||||||
|
if grant.AllowRead && grant.AllowWrite {
|
||||||
|
fmt.Fprintf(c.App.ErrWriter, "- read-write access to topic %s\n", grant.TopicPattern)
|
||||||
|
} else if grant.AllowRead {
|
||||||
|
fmt.Fprintf(c.App.ErrWriter, "- read-only access to topic %s\n", grant.TopicPattern)
|
||||||
|
} else if grant.AllowWrite {
|
||||||
|
fmt.Fprintf(c.App.ErrWriter, "- write-only access to topic %s\n", grant.TopicPattern)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(c.App.ErrWriter, "- no access to topic %s\n", grant.TopicPattern)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(c.App.ErrWriter, "- no topic-specific permissions\n")
|
||||||
|
}
|
||||||
|
if user.Name == auth.Everyone {
|
||||||
|
defaultRead, defaultWrite := manager.DefaultAccess()
|
||||||
|
if defaultRead && defaultWrite {
|
||||||
|
fmt.Fprintln(c.App.ErrWriter, "- read-write access to all (other) topics (server config)")
|
||||||
|
} else if defaultRead {
|
||||||
|
fmt.Fprintln(c.App.ErrWriter, "- read-only access to all (other) topics (server config)")
|
||||||
|
} else if defaultWrite {
|
||||||
|
fmt.Fprintln(c.App.ErrWriter, "- write-only access to all (other) topics (server config)")
|
||||||
|
} else {
|
||||||
|
fmt.Fprintln(c.App.ErrWriter, "- no access to any (other) topics (server config)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
87
cmd/access_test.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
"heckel.io/ntfy/server"
|
||||||
|
"heckel.io/ntfy/test"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCLI_Access_Show(t *testing.T) {
|
||||||
|
s, conf, port := newTestServerWithAuth(t)
|
||||||
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
|
app, _, _, stderr := newTestApp()
|
||||||
|
require.Nil(t, runAccessCommand(app, conf))
|
||||||
|
require.Contains(t, stderr.String(), "user * (anonymous)\n- no topic-specific permissions\n- no access to any (other) topics (server config)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_Access_Grant_And_Publish(t *testing.T) {
|
||||||
|
s, conf, port := newTestServerWithAuth(t)
|
||||||
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
|
app, stdin, _, _ := newTestApp()
|
||||||
|
stdin.WriteString("philpass\nphilpass\nbenpass\nbenpass")
|
||||||
|
require.Nil(t, runUserCommand(app, conf, "add", "--role=admin", "phil"))
|
||||||
|
require.Nil(t, runUserCommand(app, conf, "add", "ben"))
|
||||||
|
require.Nil(t, runAccessCommand(app, conf, "ben", "announcements", "rw"))
|
||||||
|
require.Nil(t, runAccessCommand(app, conf, "ben", "sometopic", "read"))
|
||||||
|
require.Nil(t, runAccessCommand(app, conf, "everyone", "announcements", "read"))
|
||||||
|
|
||||||
|
app, _, _, stderr := newTestApp()
|
||||||
|
require.Nil(t, runAccessCommand(app, conf))
|
||||||
|
expected := `user phil (admin)
|
||||||
|
- read-write access to all topics (admin role)
|
||||||
|
user ben (user)
|
||||||
|
- read-write access to topic announcements
|
||||||
|
- read-only access to topic sometopic
|
||||||
|
user * (anonymous)
|
||||||
|
- read-only access to topic announcements
|
||||||
|
- no access to any (other) topics (server config)
|
||||||
|
`
|
||||||
|
require.Equal(t, expected, stderr.String())
|
||||||
|
|
||||||
|
// See if access permissions match
|
||||||
|
app, _, _, _ = newTestApp()
|
||||||
|
require.Error(t, app.Run([]string{
|
||||||
|
"ntfy",
|
||||||
|
"publish",
|
||||||
|
fmt.Sprintf("http://127.0.0.1:%d/announcements", port),
|
||||||
|
}))
|
||||||
|
require.Nil(t, app.Run([]string{
|
||||||
|
"ntfy",
|
||||||
|
"publish",
|
||||||
|
"-u", "ben:benpass",
|
||||||
|
fmt.Sprintf("http://127.0.0.1:%d/announcements", port),
|
||||||
|
}))
|
||||||
|
require.Nil(t, app.Run([]string{
|
||||||
|
"ntfy",
|
||||||
|
"publish",
|
||||||
|
"-u", "phil:philpass",
|
||||||
|
fmt.Sprintf("http://127.0.0.1:%d/announcements", port),
|
||||||
|
}))
|
||||||
|
require.Nil(t, app.Run([]string{
|
||||||
|
"ntfy",
|
||||||
|
"subscribe",
|
||||||
|
"--poll",
|
||||||
|
fmt.Sprintf("http://127.0.0.1:%d/announcements", port),
|
||||||
|
}))
|
||||||
|
require.Error(t, app.Run([]string{
|
||||||
|
"ntfy",
|
||||||
|
"subscribe",
|
||||||
|
"--poll",
|
||||||
|
fmt.Sprintf("http://127.0.0.1:%d/something-else", port),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAccessCommand(app *cli.App, conf *server.Config, args ...string) error {
|
||||||
|
userArgs := []string{
|
||||||
|
"ntfy",
|
||||||
|
"access",
|
||||||
|
"--auth-file=" + conf.AuthFile,
|
||||||
|
"--auth-default-access=" + confToDefaultAccess(conf),
|
||||||
|
}
|
||||||
|
return app.Run(append(userArgs, args...))
|
||||||
|
}
|
||||||
19
cmd/app.go
@@ -14,6 +14,11 @@ var (
|
|||||||
defaultClientUserConfigFile = "~/.config/ntfy/client.yml"
|
defaultClientUserConfigFile = "~/.config/ntfy/client.yml"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
categoryClient = "Client commands"
|
||||||
|
categoryServer = "Server commands"
|
||||||
|
)
|
||||||
|
|
||||||
// New creates a new CLI application
|
// New creates a new CLI application
|
||||||
func New() *cli.App {
|
func New() *cli.App {
|
||||||
return &cli.App{
|
return &cli.App{
|
||||||
@@ -25,23 +30,19 @@ func New() *cli.App {
|
|||||||
Reader: os.Stdin,
|
Reader: os.Stdin,
|
||||||
Writer: os.Stdout,
|
Writer: os.Stdout,
|
||||||
ErrWriter: os.Stderr,
|
ErrWriter: os.Stderr,
|
||||||
Action: execMainApp,
|
|
||||||
Before: initConfigFileInputSource("config", flagsServe), // DEPRECATED, see deprecation notice
|
|
||||||
Flags: flagsServe, // DEPRECATED, see deprecation notice
|
|
||||||
Commands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
|
// Server commands
|
||||||
cmdServe,
|
cmdServe,
|
||||||
|
cmdUser,
|
||||||
|
cmdAccess,
|
||||||
|
|
||||||
|
// Client commands
|
||||||
cmdPublish,
|
cmdPublish,
|
||||||
cmdSubscribe,
|
cmdSubscribe,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func execMainApp(c *cli.Context) error {
|
|
||||||
fmt.Fprintln(c.App.ErrWriter, "\x1b[1;33mDeprecation notice: Please run the server using 'ntfy serve'; see 'ntfy -h' for help.\x1b[0m")
|
|
||||||
fmt.Fprintln(c.App.ErrWriter, "\x1b[1;33mThis way of running the server will be removed March 2022. See https://ntfy.sh/docs/deprecations/ for details.\x1b[0m")
|
|
||||||
return execServe(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
// initConfigFileInputSource is like altsrc.InitInputSourceWithContext and altsrc.NewYamlSourceFromFlagFunc, but checks
|
// initConfigFileInputSource is like altsrc.InitInputSourceWithContext and altsrc.NewYamlSourceFromFlagFunc, but checks
|
||||||
// if the config flag is exists and only loads it if it does. If the flag is set and the file exists, it fails.
|
// if the config flag is exists and only loads it if it does. If the flag is set and the file exists, it fails.
|
||||||
func initConfigFileInputSource(configFlag string, flags []cli.Flag) cli.BeforeFunc {
|
func initConfigFileInputSource(configFlag string, flags []cli.Flag) cli.BeforeFunc {
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
"heckel.io/ntfy/client"
|
"heckel.io/ntfy/client"
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -15,7 +13,7 @@ import (
|
|||||||
// This only contains helpers so far
|
// This only contains helpers so far
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
log.SetOutput(io.Discard)
|
// log.SetOutput(io.Discard)
|
||||||
os.Exit(m.Run())
|
os.Exit(m.Run())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
"heckel.io/ntfy/client"
|
"heckel.io/ntfy/client"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -15,22 +16,26 @@ var cmdPublish = &cli.Command{
|
|||||||
Name: "publish",
|
Name: "publish",
|
||||||
Aliases: []string{"pub", "send", "trigger"},
|
Aliases: []string{"pub", "send", "trigger"},
|
||||||
Usage: "Send message via a ntfy server",
|
Usage: "Send message via a ntfy server",
|
||||||
UsageText: "ntfy send [OPTIONS..] TOPIC [MESSAGE]",
|
UsageText: "ntfy send [OPTIONS..] TOPIC [MESSAGE]\n NTFY_TOPIC=.. ntfy send [OPTIONS..] -P [MESSAGE]",
|
||||||
Action: execPublish,
|
Action: execPublish,
|
||||||
|
Category: categoryClient,
|
||||||
Flags: []cli.Flag{
|
Flags: []cli.Flag{
|
||||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
|
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG"}, Usage: "client config file"},
|
||||||
&cli.StringFlag{Name: "title", Aliases: []string{"t"}, Usage: "message title"},
|
&cli.StringFlag{Name: "title", Aliases: []string{"t"}, EnvVars: []string{"NTFY_TITLE"}, Usage: "message title"},
|
||||||
&cli.StringFlag{Name: "priority", Aliases: []string{"p"}, Usage: "priority of the message (1=min, 2=low, 3=default, 4=high, 5=max)"},
|
&cli.StringFlag{Name: "priority", Aliases: []string{"p"}, EnvVars: []string{"NTFY_PRIORITY"}, Usage: "priority of the message (1=min, 2=low, 3=default, 4=high, 5=max)"},
|
||||||
&cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, Usage: "comma separated list of tags and emojis"},
|
&cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, EnvVars: []string{"NTFY_TAGS"}, Usage: "comma separated list of tags and emojis"},
|
||||||
&cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, Usage: "delay/schedule message"},
|
&cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, EnvVars: []string{"NTFY_DELAY"}, Usage: "delay/schedule message"},
|
||||||
&cli.StringFlag{Name: "click", Aliases: []string{"U"}, Usage: "URL to open when notification is clicked"},
|
&cli.StringFlag{Name: "click", Aliases: []string{"U"}, EnvVars: []string{"NTFY_CLICK"}, Usage: "URL to open when notification is clicked"},
|
||||||
&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, Usage: "URL to send as an external attachment"},
|
&cli.StringFlag{Name: "actions", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ACTIONS"}, Usage: "actions JSON array or simple definition"},
|
||||||
&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, Usage: "Filename for the attachment"},
|
&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"},
|
||||||
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, Usage: "File to upload as an attachment"},
|
&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"},
|
||||||
&cli.StringFlag{Name: "email", Aliases: []string{"e-mail", "mail", "e"}, Usage: "also send to e-mail address"},
|
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"},
|
||||||
&cli.BoolFlag{Name: "no-cache", Aliases: []string{"C"}, Usage: "do not cache message server-side"},
|
&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"},
|
||||||
&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"F"}, Usage: "do not forward message to Firebase"},
|
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
|
||||||
&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, Usage: "do print message"},
|
&cli.BoolFlag{Name: "no-cache", Aliases: []string{"C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"},
|
||||||
|
&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"F"}, EnvVars: []string{"NTFY_NO_FIREBASE"}, Usage: "do not forward message to Firebase"},
|
||||||
|
&cli.BoolFlag{Name: "env-topic", Aliases: []string{"P"}, EnvVars: []string{"NTFY_ENV_TOPIC"}, Usage: "use topic from NTFY_TOPIC env variable"},
|
||||||
|
&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, EnvVars: []string{"NTFY_QUIET"}, Usage: "do print message"},
|
||||||
},
|
},
|
||||||
Description: `Publish a message to a ntfy server.
|
Description: `Publish a message to a ntfy server.
|
||||||
|
|
||||||
@@ -45,9 +50,12 @@ Examples:
|
|||||||
ntfy pub --click="https://reddit.com" redd 'New msg' # Opens Reddit when notification is clicked
|
ntfy pub --click="https://reddit.com" redd 'New msg' # Opens Reddit when notification is clicked
|
||||||
ntfy pub --attach="http://some.tld/file.zip" files # Send ZIP archive from URL as attachment
|
ntfy pub --attach="http://some.tld/file.zip" files # Send ZIP archive from URL as attachment
|
||||||
ntfy pub --file=flower.jpg flowers 'Nice!' # Send image.jpg as attachment
|
ntfy pub --file=flower.jpg flowers 'Nice!' # Send image.jpg as attachment
|
||||||
|
ntfy pub -u phil:mypass secret Psst # Publish with username/password
|
||||||
|
NTFY_USER=phil:mypass ntfy pub secret Psst # Use env variables to set username/password
|
||||||
|
NTFY_TOPIC=mytopic ntfy pub -P "some message"" # Use NTFY_TOPIC variable as topic
|
||||||
cat flower.jpg | ntfy pub --file=- flowers 'Nice!' # Same as above, send image.jpg as attachment
|
cat flower.jpg | ntfy pub --file=- flowers 'Nice!' # Same as above, send image.jpg as attachment
|
||||||
ntfy trigger mywebhook # Sending without message, useful for webhooks
|
ntfy trigger mywebhook # Sending without message, useful for webhooks
|
||||||
|
|
||||||
Please also check out the docs on publishing messages. Especially for the --tags and --delay options,
|
Please also check out the docs on publishing messages. Especially for the --tags and --delay options,
|
||||||
it has incredibly useful information: https://ntfy.sh/docs/publish/.
|
it has incredibly useful information: https://ntfy.sh/docs/publish/.
|
||||||
|
|
||||||
@@ -56,9 +64,6 @@ or ~/.config/ntfy/client.yml for all other users.`,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func execPublish(c *cli.Context) error {
|
func execPublish(c *cli.Context) error {
|
||||||
if c.NArg() < 1 {
|
|
||||||
return errors.New("must specify topic, type 'ntfy publish --help' for help")
|
|
||||||
}
|
|
||||||
conf, err := loadConfig(c)
|
conf, err := loadConfig(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -68,17 +73,30 @@ func execPublish(c *cli.Context) error {
|
|||||||
tags := c.String("tags")
|
tags := c.String("tags")
|
||||||
delay := c.String("delay")
|
delay := c.String("delay")
|
||||||
click := c.String("click")
|
click := c.String("click")
|
||||||
|
actions := c.String("actions")
|
||||||
attach := c.String("attach")
|
attach := c.String("attach")
|
||||||
filename := c.String("filename")
|
filename := c.String("filename")
|
||||||
file := c.String("file")
|
file := c.String("file")
|
||||||
email := c.String("email")
|
email := c.String("email")
|
||||||
|
user := c.String("user")
|
||||||
noCache := c.Bool("no-cache")
|
noCache := c.Bool("no-cache")
|
||||||
noFirebase := c.Bool("no-firebase")
|
noFirebase := c.Bool("no-firebase")
|
||||||
|
envTopic := c.Bool("env-topic")
|
||||||
quiet := c.Bool("quiet")
|
quiet := c.Bool("quiet")
|
||||||
topic := c.Args().Get(0)
|
var topic, message string
|
||||||
message := ""
|
if envTopic {
|
||||||
if c.NArg() > 1 {
|
topic = os.Getenv("NTFY_TOPIC")
|
||||||
message = strings.Join(c.Args().Slice()[1:], " ")
|
if c.NArg() > 0 {
|
||||||
|
message = strings.Join(c.Args().Slice(), " ")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if c.NArg() < 1 {
|
||||||
|
return errors.New("must specify topic, type 'ntfy publish --help' for help")
|
||||||
|
}
|
||||||
|
topic = c.Args().Get(0)
|
||||||
|
if c.NArg() > 1 {
|
||||||
|
message = strings.Join(c.Args().Slice()[1:], " ")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var options []client.PublishOption
|
var options []client.PublishOption
|
||||||
if title != "" {
|
if title != "" {
|
||||||
@@ -96,6 +114,9 @@ func execPublish(c *cli.Context) error {
|
|||||||
if click != "" {
|
if click != "" {
|
||||||
options = append(options, client.WithClick(click))
|
options = append(options, client.WithClick(click))
|
||||||
}
|
}
|
||||||
|
if actions != "" {
|
||||||
|
options = append(options, client.WithActions(strings.ReplaceAll(actions, "\n", " ")))
|
||||||
|
}
|
||||||
if attach != "" {
|
if attach != "" {
|
||||||
options = append(options, client.WithAttach(attach))
|
options = append(options, client.WithAttach(attach))
|
||||||
}
|
}
|
||||||
@@ -111,6 +132,23 @@ func execPublish(c *cli.Context) error {
|
|||||||
if noFirebase {
|
if noFirebase {
|
||||||
options = append(options, client.WithNoFirebase())
|
options = append(options, client.WithNoFirebase())
|
||||||
}
|
}
|
||||||
|
if user != "" {
|
||||||
|
var pass string
|
||||||
|
parts := strings.SplitN(user, ":", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
user = parts[0]
|
||||||
|
pass = parts[1]
|
||||||
|
} else {
|
||||||
|
fmt.Fprint(c.App.ErrWriter, "Enter Password: ")
|
||||||
|
p, err := util.ReadPassword(c.App.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pass = string(p)
|
||||||
|
fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20))
|
||||||
|
}
|
||||||
|
options = append(options, client.WithBasicAuth(user, pass))
|
||||||
|
}
|
||||||
var body io.Reader
|
var body io.Reader
|
||||||
if file == "" {
|
if file == "" {
|
||||||
body = strings.NewReader(message)
|
body = strings.NewReader(message)
|
||||||
|
|||||||
@@ -34,3 +34,39 @@ func TestCLI_Publish_Subscribe_Poll(t *testing.T) {
|
|||||||
m = toMessage(t, stdout.String())
|
m = toMessage(t, stdout.String())
|
||||||
require.Equal(t, "some message", m.Message)
|
require.Equal(t, "some message", m.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCLI_Publish_All_The_Things(t *testing.T) {
|
||||||
|
s, port := test.StartServer(t)
|
||||||
|
defer test.StopServer(t, s, port)
|
||||||
|
topic := fmt.Sprintf("http://127.0.0.1:%d/mytopic", port)
|
||||||
|
|
||||||
|
app, _, stdout, _ := newTestApp()
|
||||||
|
require.Nil(t, app.Run([]string{
|
||||||
|
"ntfy", "publish",
|
||||||
|
"--title", "this is a title",
|
||||||
|
"--priority", "high",
|
||||||
|
"--tags", "tag1,tag2",
|
||||||
|
// No --delay, --email
|
||||||
|
"--click", "https://ntfy.sh",
|
||||||
|
"--attach", "https://f-droid.org/F-Droid.apk",
|
||||||
|
"--filename", "fdroid.apk",
|
||||||
|
"--no-cache",
|
||||||
|
"--no-firebase",
|
||||||
|
topic,
|
||||||
|
"some message",
|
||||||
|
}))
|
||||||
|
m := toMessage(t, stdout.String())
|
||||||
|
require.Equal(t, "message", m.Event)
|
||||||
|
require.Equal(t, "mytopic", m.Topic)
|
||||||
|
require.Equal(t, "some message", m.Message)
|
||||||
|
require.Equal(t, "this is a title", m.Title)
|
||||||
|
require.Equal(t, 4, m.Priority)
|
||||||
|
require.Equal(t, []string{"tag1", "tag2"}, m.Tags)
|
||||||
|
require.Equal(t, "https://ntfy.sh", m.Click)
|
||||||
|
require.Equal(t, "https://f-droid.org/F-Droid.apk", m.Attachment.URL)
|
||||||
|
require.Equal(t, "fdroid.apk", m.Attachment.Name)
|
||||||
|
require.Equal(t, int64(0), m.Attachment.Size)
|
||||||
|
require.Equal(t, "", m.Attachment.Owner)
|
||||||
|
require.Equal(t, int64(0), m.Attachment.Expires)
|
||||||
|
require.Equal(t, "", m.Attachment.Type)
|
||||||
|
}
|
||||||
|
|||||||
48
cmd/serve.go
@@ -9,6 +9,8 @@ import (
|
|||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"log"
|
"log"
|
||||||
"math"
|
"math"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,17 +19,21 @@ var flagsServe = []cli.Flag{
|
|||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used to as HTTP listen address"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used to as HTTP listen address"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used to as HTTPS listen address"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used to as HTTPS listen address"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-unix", Aliases: []string{"U"}, EnvVars: []string{"NTFY_LISTEN_UNIX"}, Usage: "listen on unix socket path"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "key-file", Aliases: []string{"K"}, EnvVars: []string{"NTFY_KEY_FILE"}, Usage: "private key file, if listen-https is set"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "key-file", Aliases: []string{"K"}, EnvVars: []string{"NTFY_KEY_FILE"}, Usage: "private key file, if listen-https is set"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}),
|
||||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: server.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}),
|
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: server.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, DefaultText: "5G", Usage: "limit of the on-disk attachment cache"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, DefaultText: "5G", Usage: "limit of the on-disk attachment cache"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, DefaultText: "15M", Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, DefaultText: "15M", Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}),
|
||||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "attachment-expiry-duration", Aliases: []string{"X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: server.DefaultAttachmentExpiryDuration, DefaultText: "3h", Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}),
|
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "attachment-expiry-duration", Aliases: []string{"X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: server.DefaultAttachmentExpiryDuration, DefaultText: "3h", Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}),
|
||||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
|
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
|
||||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}),
|
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "app", Usage: "sets web root to landing page (home) or web app (app)"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}),
|
||||||
@@ -41,6 +47,7 @@ var flagsServe = []cli.Flag{
|
|||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload bandwidth limit per visitor"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload bandwidth limit per visitor"}),
|
||||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}),
|
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}),
|
||||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
|
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}),
|
||||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
|
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
|
||||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
|
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
|
||||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
|
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
|
||||||
@@ -51,6 +58,7 @@ var cmdServe = &cli.Command{
|
|||||||
Usage: "Run the ntfy server",
|
Usage: "Run the ntfy server",
|
||||||
UsageText: "ntfy serve [OPTIONS..]",
|
UsageText: "ntfy serve [OPTIONS..]",
|
||||||
Action: execServe,
|
Action: execServe,
|
||||||
|
Category: categoryServer,
|
||||||
Flags: flagsServe,
|
Flags: flagsServe,
|
||||||
Before: initConfigFileInputSource("config", flagsServe),
|
Before: initConfigFileInputSource("config", flagsServe),
|
||||||
Description: `Run the ntfy server and listen for incoming requests
|
Description: `Run the ntfy server and listen for incoming requests
|
||||||
@@ -72,17 +80,21 @@ func execServe(c *cli.Context) error {
|
|||||||
baseURL := c.String("base-url")
|
baseURL := c.String("base-url")
|
||||||
listenHTTP := c.String("listen-http")
|
listenHTTP := c.String("listen-http")
|
||||||
listenHTTPS := c.String("listen-https")
|
listenHTTPS := c.String("listen-https")
|
||||||
|
listenUnix := c.String("listen-unix")
|
||||||
keyFile := c.String("key-file")
|
keyFile := c.String("key-file")
|
||||||
certFile := c.String("cert-file")
|
certFile := c.String("cert-file")
|
||||||
firebaseKeyFile := c.String("firebase-key-file")
|
firebaseKeyFile := c.String("firebase-key-file")
|
||||||
cacheFile := c.String("cache-file")
|
cacheFile := c.String("cache-file")
|
||||||
cacheDuration := c.Duration("cache-duration")
|
cacheDuration := c.Duration("cache-duration")
|
||||||
|
authFile := c.String("auth-file")
|
||||||
|
authDefaultAccess := c.String("auth-default-access")
|
||||||
attachmentCacheDir := c.String("attachment-cache-dir")
|
attachmentCacheDir := c.String("attachment-cache-dir")
|
||||||
attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit")
|
attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit")
|
||||||
attachmentFileSizeLimitStr := c.String("attachment-file-size-limit")
|
attachmentFileSizeLimitStr := c.String("attachment-file-size-limit")
|
||||||
attachmentExpiryDuration := c.Duration("attachment-expiry-duration")
|
attachmentExpiryDuration := c.Duration("attachment-expiry-duration")
|
||||||
keepaliveInterval := c.Duration("keepalive-interval")
|
keepaliveInterval := c.Duration("keepalive-interval")
|
||||||
managerInterval := c.Duration("manager-interval")
|
managerInterval := c.Duration("manager-interval")
|
||||||
|
webRoot := c.String("web-root")
|
||||||
smtpSenderAddr := c.String("smtp-sender-addr")
|
smtpSenderAddr := c.String("smtp-sender-addr")
|
||||||
smtpSenderUser := c.String("smtp-sender-user")
|
smtpSenderUser := c.String("smtp-sender-user")
|
||||||
smtpSenderPass := c.String("smtp-sender-pass")
|
smtpSenderPass := c.String("smtp-sender-pass")
|
||||||
@@ -96,6 +108,7 @@ func execServe(c *cli.Context) error {
|
|||||||
visitorAttachmentDailyBandwidthLimitStr := c.String("visitor-attachment-daily-bandwidth-limit")
|
visitorAttachmentDailyBandwidthLimitStr := c.String("visitor-attachment-daily-bandwidth-limit")
|
||||||
visitorRequestLimitBurst := c.Int("visitor-request-limit-burst")
|
visitorRequestLimitBurst := c.Int("visitor-request-limit-burst")
|
||||||
visitorRequestLimitReplenish := c.Duration("visitor-request-limit-replenish")
|
visitorRequestLimitReplenish := c.Duration("visitor-request-limit-replenish")
|
||||||
|
visitorRequestLimitExemptHosts := util.SplitNoEmpty(c.String("visitor-request-limit-exempt-hosts"), ",")
|
||||||
visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
|
visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
|
||||||
visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish")
|
visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish")
|
||||||
behindProxy := c.Bool("behind-proxy")
|
behindProxy := c.Bool("behind-proxy")
|
||||||
@@ -121,6 +134,22 @@ func execServe(c *cli.Context) error {
|
|||||||
return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set")
|
return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set")
|
||||||
} else if attachmentCacheDir != "" && baseURL == "" {
|
} else if attachmentCacheDir != "" && baseURL == "" {
|
||||||
return errors.New("if attachment-cache-dir is set, base-url must also be set")
|
return errors.New("if attachment-cache-dir is set, base-url must also be set")
|
||||||
|
} else if baseURL != "" && !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
|
||||||
|
return errors.New("if set, base-url must start with http:// or https://")
|
||||||
|
} else if !util.InStringList([]string{"read-write", "read-only", "write-only", "deny-all"}, authDefaultAccess) {
|
||||||
|
return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
|
||||||
|
} else if !util.InStringList([]string{"app", "home"}, webRoot) {
|
||||||
|
return errors.New("if set, web-root must be 'home' or 'app'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default auth permissions
|
||||||
|
webRootIsApp := webRoot == "app"
|
||||||
|
authDefaultRead := authDefaultAccess == "read-write" || authDefaultAccess == "read-only"
|
||||||
|
authDefaultWrite := authDefaultAccess == "read-write" || authDefaultAccess == "write-only"
|
||||||
|
|
||||||
|
// Special case: Unset default
|
||||||
|
if listenHTTP == "-" {
|
||||||
|
listenHTTP = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert sizes to bytes
|
// Convert sizes to bytes
|
||||||
@@ -143,22 +172,40 @@ func execServe(c *cli.Context) error {
|
|||||||
return fmt.Errorf("config option visitor-attachment-daily-bandwidth-limit must be lower than %d", math.MaxInt)
|
return fmt.Errorf("config option visitor-attachment-daily-bandwidth-limit must be lower than %d", math.MaxInt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve hosts
|
||||||
|
visitorRequestLimitExemptIPs := make([]string, 0)
|
||||||
|
for _, host := range visitorRequestLimitExemptHosts {
|
||||||
|
ips, err := net.LookupIP(host)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("cannot resolve host %s: %s, ignoring visitor request exemption", host, err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, ip := range ips {
|
||||||
|
visitorRequestLimitExemptIPs = append(visitorRequestLimitExemptIPs, ip.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Run server
|
// Run server
|
||||||
conf := server.NewConfig()
|
conf := server.NewConfig()
|
||||||
conf.BaseURL = baseURL
|
conf.BaseURL = baseURL
|
||||||
conf.ListenHTTP = listenHTTP
|
conf.ListenHTTP = listenHTTP
|
||||||
conf.ListenHTTPS = listenHTTPS
|
conf.ListenHTTPS = listenHTTPS
|
||||||
|
conf.ListenUnix = listenUnix
|
||||||
conf.KeyFile = keyFile
|
conf.KeyFile = keyFile
|
||||||
conf.CertFile = certFile
|
conf.CertFile = certFile
|
||||||
conf.FirebaseKeyFile = firebaseKeyFile
|
conf.FirebaseKeyFile = firebaseKeyFile
|
||||||
conf.CacheFile = cacheFile
|
conf.CacheFile = cacheFile
|
||||||
conf.CacheDuration = cacheDuration
|
conf.CacheDuration = cacheDuration
|
||||||
|
conf.AuthFile = authFile
|
||||||
|
conf.AuthDefaultRead = authDefaultRead
|
||||||
|
conf.AuthDefaultWrite = authDefaultWrite
|
||||||
conf.AttachmentCacheDir = attachmentCacheDir
|
conf.AttachmentCacheDir = attachmentCacheDir
|
||||||
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
|
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
|
||||||
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
|
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
|
||||||
conf.AttachmentExpiryDuration = attachmentExpiryDuration
|
conf.AttachmentExpiryDuration = attachmentExpiryDuration
|
||||||
conf.KeepaliveInterval = keepaliveInterval
|
conf.KeepaliveInterval = keepaliveInterval
|
||||||
conf.ManagerInterval = managerInterval
|
conf.ManagerInterval = managerInterval
|
||||||
|
conf.WebRootIsApp = webRootIsApp
|
||||||
conf.SMTPSenderAddr = smtpSenderAddr
|
conf.SMTPSenderAddr = smtpSenderAddr
|
||||||
conf.SMTPSenderUser = smtpSenderUser
|
conf.SMTPSenderUser = smtpSenderUser
|
||||||
conf.SMTPSenderPass = smtpSenderPass
|
conf.SMTPSenderPass = smtpSenderPass
|
||||||
@@ -172,6 +219,7 @@ func execServe(c *cli.Context) error {
|
|||||||
conf.VisitorAttachmentDailyBandwidthLimit = int(visitorAttachmentDailyBandwidthLimit)
|
conf.VisitorAttachmentDailyBandwidthLimit = int(visitorAttachmentDailyBandwidthLimit)
|
||||||
conf.VisitorRequestLimitBurst = visitorRequestLimitBurst
|
conf.VisitorRequestLimitBurst = visitorRequestLimitBurst
|
||||||
conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish
|
conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish
|
||||||
|
conf.VisitorRequestExemptIPAddrs = visitorRequestLimitExemptIPs
|
||||||
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
|
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
|
||||||
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
|
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
|
||||||
conf.BehindProxy = behindProxy
|
conf.BehindProxy = behindProxy
|
||||||
|
|||||||
77
cmd/serve_test.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"heckel.io/ntfy/client"
|
||||||
|
"heckel.io/ntfy/test"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rand.Seed(time.Now().UnixMilli())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_Serve_Unix_Curl(t *testing.T) {
|
||||||
|
sockFile := filepath.Join(t.TempDir(), "ntfy.sock")
|
||||||
|
configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system
|
||||||
|
go func() {
|
||||||
|
app, _, _, _ := newTestApp()
|
||||||
|
err := app.Run([]string{"ntfy", "serve", "--config=" + configFile, "--listen-http=-", "--listen-unix=" + sockFile})
|
||||||
|
require.Nil(t, err)
|
||||||
|
}()
|
||||||
|
for i := 0; i < 40 && !util.FileExists(sockFile); i++ {
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
require.True(t, util.FileExists(sockFile))
|
||||||
|
|
||||||
|
cmd := exec.Command("curl", "-s", "--unix-socket", sockFile, "-d", "this is a message", "localhost/mytopic")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
require.Nil(t, err)
|
||||||
|
m := toMessage(t, string(out))
|
||||||
|
require.Equal(t, "this is a message", m.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_Serve_WebSocket(t *testing.T) {
|
||||||
|
port := 10000 + rand.Intn(20000)
|
||||||
|
go func() {
|
||||||
|
configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system
|
||||||
|
app, _, _, _ := newTestApp()
|
||||||
|
err := app.Run([]string{"ntfy", "serve", "--config=" + configFile, fmt.Sprintf("--listen-http=:%d", port)})
|
||||||
|
require.Nil(t, err)
|
||||||
|
}()
|
||||||
|
test.WaitForPortUp(t, port)
|
||||||
|
|
||||||
|
ws, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://127.0.0.1:%d/mytopic/ws", port), nil)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
messageType, data, err := ws.ReadMessage()
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, websocket.TextMessage, messageType)
|
||||||
|
require.Equal(t, "open", toMessage(t, string(data)).Event)
|
||||||
|
|
||||||
|
c := client.New(client.NewConfig())
|
||||||
|
_, err = c.Publish(fmt.Sprintf("http://127.0.0.1:%d/mytopic", port), "my message")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
messageType, data, err = ws.ReadMessage()
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, websocket.TextMessage, messageType)
|
||||||
|
|
||||||
|
m := toMessage(t, string(data))
|
||||||
|
require.Equal(t, "my message", m.Message)
|
||||||
|
require.Equal(t, "mytopic", m.Topic)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEmptyFile(t *testing.T) string {
|
||||||
|
filename := filepath.Join(t.TempDir(), "empty")
|
||||||
|
require.Nil(t, os.WriteFile(filename, []byte{}, 0600))
|
||||||
|
return filename
|
||||||
|
}
|
||||||
@@ -19,9 +19,11 @@ var cmdSubscribe = &cli.Command{
|
|||||||
Usage: "Subscribe to one or more topics on a ntfy server",
|
Usage: "Subscribe to one or more topics on a ntfy server",
|
||||||
UsageText: "ntfy subscribe [OPTIONS..] [TOPIC]",
|
UsageText: "ntfy subscribe [OPTIONS..] [TOPIC]",
|
||||||
Action: execSubscribe,
|
Action: execSubscribe,
|
||||||
|
Category: categoryClient,
|
||||||
Flags: []cli.Flag{
|
Flags: []cli.Flag{
|
||||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
|
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
|
||||||
&cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"},
|
&cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"},
|
||||||
|
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, Usage: "username[:password] used to auth against the server"},
|
||||||
&cli.BoolFlag{Name: "from-config", Aliases: []string{"C"}, Usage: "read subscriptions from config file (service mode)"},
|
&cli.BoolFlag{Name: "from-config", Aliases: []string{"C"}, Usage: "read subscriptions from config file (service mode)"},
|
||||||
&cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"},
|
&cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"},
|
||||||
&cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"},
|
&cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"},
|
||||||
@@ -39,6 +41,7 @@ ntfy subscribe TOPIC
|
|||||||
ntfy subscribe mytopic # Prints JSON for incoming messages for ntfy.sh/mytopic
|
ntfy subscribe mytopic # Prints JSON for incoming messages for ntfy.sh/mytopic
|
||||||
ntfy sub home.lan/backups # Subscribe to topic on different server
|
ntfy sub home.lan/backups # Subscribe to topic on different server
|
||||||
ntfy sub --poll home.lan/backups # Just query for latest messages and exit
|
ntfy sub --poll home.lan/backups # Just query for latest messages and exit
|
||||||
|
ntfy sub -u phil:mypass secret # Subscribe with username/password
|
||||||
|
|
||||||
ntfy subscribe TOPIC COMMAND
|
ntfy subscribe TOPIC COMMAND
|
||||||
This executes COMMAND for every incoming messages. The message fields are passed to the
|
This executes COMMAND for every incoming messages. The message fields are passed to the
|
||||||
@@ -80,6 +83,7 @@ func execSubscribe(c *cli.Context) error {
|
|||||||
}
|
}
|
||||||
cl := client.New(conf)
|
cl := client.New(conf)
|
||||||
since := c.String("since")
|
since := c.String("since")
|
||||||
|
user := c.String("user")
|
||||||
poll := c.Bool("poll")
|
poll := c.Bool("poll")
|
||||||
scheduled := c.Bool("scheduled")
|
scheduled := c.Bool("scheduled")
|
||||||
fromConfig := c.Bool("from-config")
|
fromConfig := c.Bool("from-config")
|
||||||
@@ -92,6 +96,23 @@ func execSubscribe(c *cli.Context) error {
|
|||||||
if since != "" {
|
if since != "" {
|
||||||
options = append(options, client.WithSince(since))
|
options = append(options, client.WithSince(since))
|
||||||
}
|
}
|
||||||
|
if user != "" {
|
||||||
|
var pass string
|
||||||
|
parts := strings.SplitN(user, ":", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
user = parts[0]
|
||||||
|
pass = parts[1]
|
||||||
|
} else {
|
||||||
|
fmt.Fprint(c.App.ErrWriter, "Enter Password: ")
|
||||||
|
p, err := util.ReadPassword(c.App.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pass = string(p)
|
||||||
|
fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20))
|
||||||
|
}
|
||||||
|
options = append(options, client.WithBasicAuth(user, pass))
|
||||||
|
}
|
||||||
if poll {
|
if poll {
|
||||||
options = append(options, client.WithPoll())
|
options = append(options, client.WithPoll())
|
||||||
}
|
}
|
||||||
@@ -141,6 +162,9 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic,
|
|||||||
for filter, value := range s.If {
|
for filter, value := range s.If {
|
||||||
topicOptions = append(topicOptions, client.WithFilter(filter, value))
|
topicOptions = append(topicOptions, client.WithFilter(filter, value))
|
||||||
}
|
}
|
||||||
|
if s.User != "" && s.Password != "" {
|
||||||
|
topicOptions = append(topicOptions, client.WithBasicAuth(s.User, s.Password))
|
||||||
|
}
|
||||||
subscriptionID := cl.Subscribe(s.Topic, topicOptions...)
|
subscriptionID := cl.Subscribe(s.Topic, topicOptions...)
|
||||||
commands[subscriptionID] = s.Command
|
commands[subscriptionID] = s.Command
|
||||||
}
|
}
|
||||||
|
|||||||
272
cmd/user.go
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
"github.com/urfave/cli/v2/altsrc"
|
||||||
|
"heckel.io/ntfy/auth"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var flagsUser = userCommandFlags()
|
||||||
|
var cmdUser = &cli.Command{
|
||||||
|
Name: "user",
|
||||||
|
Usage: "Manage/show users",
|
||||||
|
UsageText: "ntfy user [list|add|remove|change-pass|change-role] ...",
|
||||||
|
Flags: flagsUser,
|
||||||
|
Before: initConfigFileInputSource("config", flagsUser),
|
||||||
|
Category: categoryServer,
|
||||||
|
Subcommands: []*cli.Command{
|
||||||
|
{
|
||||||
|
Name: "add",
|
||||||
|
Aliases: []string{"a"},
|
||||||
|
Usage: "Adds a new user",
|
||||||
|
UsageText: "ntfy user add [--role=admin|user] USERNAME",
|
||||||
|
Action: execUserAdd,
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{Name: "role", Aliases: []string{"r"}, Value: string(auth.RoleUser), Usage: "user role"},
|
||||||
|
},
|
||||||
|
Description: `Add a new user to the ntfy user database.
|
||||||
|
|
||||||
|
A user can be either a regular user, or an admin. A regular user has no read or write access (unless
|
||||||
|
granted otherwise by the auth-default-access setting). An admin user has read and write access to all
|
||||||
|
topics.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
ntfy user add phil # Add regular user phil
|
||||||
|
ntfy user add --role=admin phil # Add admin user phil
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "remove",
|
||||||
|
Aliases: []string{"del", "rm"},
|
||||||
|
Usage: "Removes a user",
|
||||||
|
UsageText: "ntfy user remove USERNAME",
|
||||||
|
Action: execUserDel,
|
||||||
|
Description: `Remove a user from the ntfy user database.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
ntfy user del phil
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "change-pass",
|
||||||
|
Aliases: []string{"chp"},
|
||||||
|
Usage: "Changes a user's password",
|
||||||
|
UsageText: "ntfy user change-pass USERNAME",
|
||||||
|
Action: execUserChangePass,
|
||||||
|
Description: `Change the password for the given user.
|
||||||
|
|
||||||
|
The new password will be read from STDIN, and it'll be confirmed by typing
|
||||||
|
it twice.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
ntfy user change-pass phil
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "change-role",
|
||||||
|
Aliases: []string{"chr"},
|
||||||
|
Usage: "Changes the role of a user",
|
||||||
|
UsageText: "ntfy user change-role USERNAME ROLE",
|
||||||
|
Action: execUserChangeRole,
|
||||||
|
Description: `Change the role for the given user to admin or user.
|
||||||
|
|
||||||
|
This command can be used to change the role of a user either from a regular user
|
||||||
|
to an admin user, or the other way around:
|
||||||
|
|
||||||
|
- admin: an admin has read/write access to all topics
|
||||||
|
- user: a regular user only has access to what was explicitly granted via 'ntfy access'
|
||||||
|
|
||||||
|
When changing the role of a user to "admin", all access control entries for that
|
||||||
|
user are removed, since they are no longer necessary.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
ntfy user change-role phil admin # Make user phil an admin
|
||||||
|
ntfy user change-role phil user # Remove admin role from user phil
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "list",
|
||||||
|
Aliases: []string{"l"},
|
||||||
|
Usage: "Shows a list of users",
|
||||||
|
Action: execUserList,
|
||||||
|
Description: `Shows a list of all configured users, including the everyone ('*') user.
|
||||||
|
|
||||||
|
This is a server-only command. It directly reads from the user.db as defined in the server config
|
||||||
|
file server.yml. The command only works if 'auth-file' is properly defined.
|
||||||
|
|
||||||
|
This command is an alias to calling 'ntfy access' (display access control list).
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Description: `Manage users of the ntfy server.
|
||||||
|
|
||||||
|
This is a server-only command. It directly manages the user.db as defined in the server config
|
||||||
|
file server.yml. The command only works if 'auth-file' is properly defined. Please also refer
|
||||||
|
to the related command 'ntfy access'.
|
||||||
|
|
||||||
|
The command allows you to add/remove/change users in the ntfy user database, as well as change
|
||||||
|
passwords or roles.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
ntfy user list # Shows list of users (alias: 'ntfy access')
|
||||||
|
ntfy user add phil # Add regular user phil
|
||||||
|
ntfy user add --role=admin phil # Add admin user phil
|
||||||
|
ntfy user del phil # Delete user phil
|
||||||
|
ntfy user change-pass phil # Change password for user phil
|
||||||
|
ntfy user change-role phil admin # Make user phil an admin
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
|
||||||
|
func execUserAdd(c *cli.Context) error {
|
||||||
|
username := c.Args().Get(0)
|
||||||
|
role := auth.Role(c.String("role"))
|
||||||
|
if username == "" {
|
||||||
|
return errors.New("username expected, type 'ntfy user add --help' for help")
|
||||||
|
} else if username == userEveryone {
|
||||||
|
return errors.New("username not allowed")
|
||||||
|
} else if !auth.AllowedRole(role) {
|
||||||
|
return errors.New("role must be either 'user' or 'admin'")
|
||||||
|
}
|
||||||
|
manager, err := createAuthManager(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if user, _ := manager.User(username); user != nil {
|
||||||
|
return fmt.Errorf("user %s already exists", username)
|
||||||
|
}
|
||||||
|
password, err := readPasswordAndConfirm(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := manager.AddUser(username, password, role); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(c.App.ErrWriter, "user %s added with role %s\n", username, role)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func execUserDel(c *cli.Context) error {
|
||||||
|
username := c.Args().Get(0)
|
||||||
|
if username == "" {
|
||||||
|
return errors.New("username expected, type 'ntfy user del --help' for help")
|
||||||
|
} else if username == userEveryone {
|
||||||
|
return errors.New("username not allowed")
|
||||||
|
}
|
||||||
|
manager, err := createAuthManager(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := manager.User(username); err == auth.ErrNotFound {
|
||||||
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
|
}
|
||||||
|
if err := manager.RemoveUser(username); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(c.App.ErrWriter, "user %s removed\n", username)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func execUserChangePass(c *cli.Context) error {
|
||||||
|
username := c.Args().Get(0)
|
||||||
|
if username == "" {
|
||||||
|
return errors.New("username expected, type 'ntfy user change-pass --help' for help")
|
||||||
|
} else if username == userEveryone {
|
||||||
|
return errors.New("username not allowed")
|
||||||
|
}
|
||||||
|
manager, err := createAuthManager(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := manager.User(username); err == auth.ErrNotFound {
|
||||||
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
|
}
|
||||||
|
password, err := readPasswordAndConfirm(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := manager.ChangePassword(username, password); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(c.App.ErrWriter, "changed password for user %s\n", username)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func execUserChangeRole(c *cli.Context) error {
|
||||||
|
username := c.Args().Get(0)
|
||||||
|
role := auth.Role(c.Args().Get(1))
|
||||||
|
if username == "" || !auth.AllowedRole(role) {
|
||||||
|
return errors.New("username and new role expected, type 'ntfy user change-role --help' for help")
|
||||||
|
} else if username == userEveryone {
|
||||||
|
return errors.New("username not allowed")
|
||||||
|
}
|
||||||
|
manager, err := createAuthManager(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := manager.User(username); err == auth.ErrNotFound {
|
||||||
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
|
}
|
||||||
|
if err := manager.ChangeRole(username, role); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(c.App.ErrWriter, "changed role for user %s to %s\n", username, role)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func execUserList(c *cli.Context) error {
|
||||||
|
manager, err := createAuthManager(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
users, err := manager.Users()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return showUsers(c, manager, users)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createAuthManager(c *cli.Context) (auth.Manager, error) {
|
||||||
|
authFile := c.String("auth-file")
|
||||||
|
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")
|
||||||
|
} else if !util.InStringList([]string{"read-write", "read-only", "write-only", "deny-all"}, authDefaultAccess) {
|
||||||
|
return nil, errors.New("if set, auth-default-access must start set to 'read-write', 'read-only' or 'deny-all'")
|
||||||
|
}
|
||||||
|
authDefaultRead := authDefaultAccess == "read-write" || authDefaultAccess == "read-only"
|
||||||
|
authDefaultWrite := authDefaultAccess == "read-write" || authDefaultAccess == "write-only"
|
||||||
|
return auth.NewSQLiteAuth(authFile, authDefaultRead, authDefaultWrite)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPasswordAndConfirm(c *cli.Context) (string, error) {
|
||||||
|
fmt.Fprint(c.App.ErrWriter, "password: ")
|
||||||
|
password, err := util.ReadPassword(c.App.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(c.App.ErrWriter, "\r%s\rconfirm: ", strings.Repeat(" ", 25))
|
||||||
|
confirm, err := util.ReadPassword(c.App.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 25))
|
||||||
|
if subtle.ConstantTimeCompare(confirm, password) != 1 {
|
||||||
|
return "", errors.New("passwords do not match: try it again, but this time type slooowwwlly")
|
||||||
|
}
|
||||||
|
return string(password), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func userCommandFlags() []cli.Flag {
|
||||||
|
return []cli.Flag{
|
||||||
|
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/server.yml", DefaultText: "/etc/ntfy/server.yml", Usage: "config file"},
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
|
||||||
|
}
|
||||||
|
}
|
||||||
145
cmd/user_test.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
"heckel.io/ntfy/server"
|
||||||
|
"heckel.io/ntfy/test"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCLI_User_Add(t *testing.T) {
|
||||||
|
s, conf, port := newTestServerWithAuth(t)
|
||||||
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
|
app, stdin, _, stderr := newTestApp()
|
||||||
|
stdin.WriteString("mypass\nmypass")
|
||||||
|
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||||
|
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_User_Add_Exists(t *testing.T) {
|
||||||
|
s, conf, port := newTestServerWithAuth(t)
|
||||||
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
|
app, stdin, _, stderr := newTestApp()
|
||||||
|
stdin.WriteString("mypass\nmypass")
|
||||||
|
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||||
|
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||||
|
|
||||||
|
app, stdin, _, _ = newTestApp()
|
||||||
|
stdin.WriteString("mypass\nmypass")
|
||||||
|
err := runUserCommand(app, conf, "add", "phil")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "user phil already exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_User_Add_Admin(t *testing.T) {
|
||||||
|
s, conf, port := newTestServerWithAuth(t)
|
||||||
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
|
app, stdin, _, stderr := newTestApp()
|
||||||
|
stdin.WriteString("mypass\nmypass")
|
||||||
|
require.Nil(t, runUserCommand(app, conf, "add", "--role=admin", "phil"))
|
||||||
|
require.Contains(t, stderr.String(), "user phil added with role admin")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_User_Add_Password_Mismatch(t *testing.T) {
|
||||||
|
s, conf, port := newTestServerWithAuth(t)
|
||||||
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
|
app, stdin, _, _ := newTestApp()
|
||||||
|
stdin.WriteString("mypass\nNOTMATCH")
|
||||||
|
err := runUserCommand(app, conf, "add", "phil")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "passwords do not match: try it again, but this time type slooowwwlly")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_User_ChangePass(t *testing.T) {
|
||||||
|
s, conf, port := newTestServerWithAuth(t)
|
||||||
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
|
// Add user
|
||||||
|
app, stdin, _, stderr := newTestApp()
|
||||||
|
stdin.WriteString("mypass\nmypass")
|
||||||
|
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||||
|
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||||
|
|
||||||
|
// Change pass
|
||||||
|
app, stdin, _, stderr = newTestApp()
|
||||||
|
stdin.WriteString("newpass\nnewpass")
|
||||||
|
require.Nil(t, runUserCommand(app, conf, "change-pass", "phil"))
|
||||||
|
require.Contains(t, stderr.String(), "changed password for user phil")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_User_ChangeRole(t *testing.T) {
|
||||||
|
s, conf, port := newTestServerWithAuth(t)
|
||||||
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
|
// Add user
|
||||||
|
app, stdin, _, stderr := newTestApp()
|
||||||
|
stdin.WriteString("mypass\nmypass")
|
||||||
|
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||||
|
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||||
|
|
||||||
|
// Change role
|
||||||
|
app, _, _, stderr = newTestApp()
|
||||||
|
require.Nil(t, runUserCommand(app, conf, "change-role", "phil", "admin"))
|
||||||
|
require.Contains(t, stderr.String(), "changed role for user phil to admin")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLI_User_Delete(t *testing.T) {
|
||||||
|
s, conf, port := newTestServerWithAuth(t)
|
||||||
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
|
// Add user
|
||||||
|
app, stdin, _, stderr := newTestApp()
|
||||||
|
stdin.WriteString("mypass\nmypass")
|
||||||
|
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||||
|
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||||
|
|
||||||
|
// Delete user
|
||||||
|
app, _, _, stderr = newTestApp()
|
||||||
|
require.Nil(t, runUserCommand(app, conf, "del", "phil"))
|
||||||
|
require.Contains(t, stderr.String(), "user phil removed")
|
||||||
|
|
||||||
|
// Delete user again (does not exist)
|
||||||
|
app, _, _, _ = newTestApp()
|
||||||
|
err := runUserCommand(app, conf, "del", "phil")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "user phil does not exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, port int) {
|
||||||
|
conf = server.NewConfig()
|
||||||
|
conf.AuthFile = filepath.Join(t.TempDir(), "user.db")
|
||||||
|
conf.AuthDefaultRead = false
|
||||||
|
conf.AuthDefaultWrite = false
|
||||||
|
s, port = test.StartServerWithConfig(t, conf)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func runUserCommand(app *cli.App, conf *server.Config, args ...string) error {
|
||||||
|
userArgs := []string{
|
||||||
|
"ntfy",
|
||||||
|
"user",
|
||||||
|
"--auth-file=" + conf.AuthFile,
|
||||||
|
"--auth-default-access=" + confToDefaultAccess(conf),
|
||||||
|
}
|
||||||
|
return app.Run(append(userArgs, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func confToDefaultAccess(conf *server.Config) string {
|
||||||
|
var defaultAccess string
|
||||||
|
if conf.AuthDefaultRead && conf.AuthDefaultWrite {
|
||||||
|
defaultAccess = "read-write"
|
||||||
|
} else if conf.AuthDefaultRead && !conf.AuthDefaultWrite {
|
||||||
|
defaultAccess = "read-only"
|
||||||
|
} else if !conf.AuthDefaultRead && conf.AuthDefaultWrite {
|
||||||
|
defaultAccess = "write-only"
|
||||||
|
} else if !conf.AuthDefaultRead && !conf.AuthDefaultWrite {
|
||||||
|
defaultAccess = "deny-all"
|
||||||
|
}
|
||||||
|
return defaultAccess
|
||||||
|
}
|
||||||
433
docs/config.md
@@ -1,6 +1,6 @@
|
|||||||
# Configuring the ntfy server
|
# Configuring the ntfy server
|
||||||
The ntfy server can be configured in three ways: using a config file (typically at `/etc/ntfy/server.yml`,
|
The ntfy server can be configured in three ways: using a config file (typically at `/etc/ntfy/server.yml`,
|
||||||
see [server.yml](https://github.com/binwiederhier/ntfy/blob/main/config/server.yml)), via command line arguments
|
see [server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml)), via command line arguments
|
||||||
or using environment variables.
|
or using environment variables.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
@@ -16,6 +16,55 @@ You can immediately start [publishing messages](publish.md), or subscribe via th
|
|||||||
the server further, check out the [config options table](#config-options) or simply type `ntfy serve --help` to
|
the server further, check out the [config options table](#config-options) or simply type `ntfy serve --help` to
|
||||||
get a list of [command line options](#command-line-options).
|
get a list of [command line options](#command-line-options).
|
||||||
|
|
||||||
|
## Example config
|
||||||
|
!!! info
|
||||||
|
Definitely check out the **[server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml)** file.
|
||||||
|
It contains examples and detailed descriptions of all the settings.
|
||||||
|
|
||||||
|
The most basic settings are `base-url` (the external URL of the ntfy server), the HTTP/HTTPS listen address (`listen-http`
|
||||||
|
and `listen-https`), and socket path (`listen-unix`). All the other things are additional features.
|
||||||
|
|
||||||
|
Here are a few working sample configs:
|
||||||
|
|
||||||
|
=== "server.yml (HTTP-only, with cache + attachments)"
|
||||||
|
``` yaml
|
||||||
|
base-url: "http://ntfy.example.com"
|
||||||
|
cache-file: "/var/cache/ntfy/cache.db"
|
||||||
|
attachment-cache-dir: "/var/cache/ntfy/attachments"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "server.yml (HTTP+HTTPS, with cache + attachments)"
|
||||||
|
``` yaml
|
||||||
|
base-url: "http://ntfy.example.com"
|
||||||
|
listen-http: ":80"
|
||||||
|
listen-https: ":443"
|
||||||
|
key-file: "/etc/letsencrypt/live/ntfy.example.com.key"
|
||||||
|
cert-file: "/etc/letsencrypt/live/ntfy.example.com.crt"
|
||||||
|
cache-file: "/var/cache/ntfy/cache.db"
|
||||||
|
attachment-cache-dir: "/var/cache/ntfy/attachments"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "server.yml (ntfy.sh config)"
|
||||||
|
``` yaml
|
||||||
|
# All the things: Behind a proxy, Firebase, cache, attachments,
|
||||||
|
# SMTP publishing & receiving
|
||||||
|
|
||||||
|
base-url: "https://ntfy.sh"
|
||||||
|
listen-http: "127.0.0.1:2586"
|
||||||
|
firebase-key-file: "/etc/ntfy/firebase.json"
|
||||||
|
cache-file: "/var/cache/ntfy/cache.db"
|
||||||
|
behind-proxy: true
|
||||||
|
attachment-cache-dir: "/var/cache/ntfy/attachments"
|
||||||
|
smtp-sender-addr: "email-smtp.us-east-2.amazonaws.com:587"
|
||||||
|
smtp-sender-user: "AKIDEADBEEFAFFE12345"
|
||||||
|
smtp-sender-pass: "Abd13Kf+sfAk2DzifjafldkThisIsNotARealKeyOMG."
|
||||||
|
smtp-sender-from: "ntfy@ntfy.sh"
|
||||||
|
smtp-server-listen: ":25"
|
||||||
|
smtp-server-domain: "ntfy.sh"
|
||||||
|
smtp-server-addr-prefix: "ntfy-"
|
||||||
|
keepalive-interval: "45s"
|
||||||
|
```
|
||||||
|
|
||||||
## Message cache
|
## Message cache
|
||||||
If desired, ntfy can temporarily keep notifications in an in-memory or an on-disk cache. Caching messages for a short period
|
If desired, ntfy can temporarily keep notifications in an in-memory or an on-disk cache. Caching messages for a short period
|
||||||
of time is important to allow [phones](subscribe/phone.md) and other devices with brittle Internet connections to be able to retrieve
|
of time is important to allow [phones](subscribe/phone.md) and other devices with brittle Internet connections to be able to retrieve
|
||||||
@@ -72,6 +121,194 @@ Here's an example config using mostly the defaults (except for the cache directo
|
|||||||
Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-attachment-total-size-limit`
|
Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-attachment-total-size-limit`
|
||||||
and `visitor-attachment-daily-bandwidth-limit`. Setting these conservatively is necessary to avoid abuse.
|
and `visitor-attachment-daily-bandwidth-limit`. Setting these conservatively is necessary to avoid abuse.
|
||||||
|
|
||||||
|
## Access control
|
||||||
|
By default, the ntfy server is open for everyone, meaning **everyone can read and write to any topic** (this is how
|
||||||
|
ntfy.sh is configured). To restrict access to your own server, you can optionally configure authentication and authorization.
|
||||||
|
|
||||||
|
ntfy's auth is implemented with a simple [SQLite](https://www.sqlite.org/)-based backend. It implements two roles
|
||||||
|
(`user` and `admin`) and per-topic `read` and `write` permissions using an [access control list (ACL)](https://en.wikipedia.org/wiki/Access-control_list).
|
||||||
|
Access control entries can be applied to users as well as the special everyone user (`*`), which represents anonymous API access.
|
||||||
|
|
||||||
|
To set up auth, simply **configure the following two options**:
|
||||||
|
|
||||||
|
* `auth-file` is the user/access database; it is created automatically if it doesn't already exist; suggested
|
||||||
|
location `/var/lib/ntfy/user.db` (easiest if deb/rpm package is used)
|
||||||
|
* `auth-default-access` defines the default/fallback access if no access control entry is found; it can be
|
||||||
|
set to `read-write` (default), `read-only`, `write-only` or `deny-all`.
|
||||||
|
|
||||||
|
Once configured, you can use the `ntfy user` command to [add or modify users](#users-and-roles), and the `ntfy access` command
|
||||||
|
lets you [modify the access control list](#access-control-list-acl) for specific users and topic patterns. Both of these
|
||||||
|
commands **directly edit the auth database** (as defined in `auth-file`), so they only work on the server, and only if the user
|
||||||
|
accessing them has the right permissions.
|
||||||
|
|
||||||
|
### Users and roles
|
||||||
|
The `ntfy user` command allows you to add/remove/change users in the ntfy user database, as well as change
|
||||||
|
passwords or roles (`user` or `admin`). In practice, you'll often just create one admin
|
||||||
|
user with `ntfy user add --role=admin ...` and be done with all this (see [example below](#example-private-instance)).
|
||||||
|
|
||||||
|
**Roles:**
|
||||||
|
|
||||||
|
* Role `user` (default): Users with this role have no special permissions. Manage access using `ntfy access`
|
||||||
|
(see [below](#access-control-list-acl)).
|
||||||
|
* Role `admin`: Users with this role can read/write to all topics. Granular access control is not necessary.
|
||||||
|
|
||||||
|
**Example commands** (type `ntfy user --help` or `ntfy user COMMAND --help` for more details):
|
||||||
|
|
||||||
|
```
|
||||||
|
ntfy user list # Shows list of users (alias: 'ntfy access')
|
||||||
|
ntfy user add phil # Add regular user phil
|
||||||
|
ntfy user add --role=admin phil # Add admin user phil
|
||||||
|
ntfy user del phil # Delete user phil
|
||||||
|
ntfy user change-pass phil # Change password for user phil
|
||||||
|
ntfy user change-role phil admin # Make user phil an admin
|
||||||
|
```
|
||||||
|
|
||||||
|
### Access control list (ACL)
|
||||||
|
The access control list (ACL) **manages access to topics for non-admin users, and for anonymous access (`everyone`/`*`)**.
|
||||||
|
Each entry represents the access permissions for a user to a specific topic or topic pattern.
|
||||||
|
|
||||||
|
The ACL can be displayed or modified with the `ntfy access` command:
|
||||||
|
|
||||||
|
```
|
||||||
|
ntfy access # Shows access control list (alias: 'ntfy user list')
|
||||||
|
ntfy access USERNAME # Shows access control entries for USERNAME
|
||||||
|
ntfy access USERNAME TOPIC PERMISSION # Allow/deny access for USERNAME to TOPIC
|
||||||
|
```
|
||||||
|
|
||||||
|
A `USERNAME` is an existing user, as created with `ntfy user add` (see [users and roles](#users-and-roles)), or the
|
||||||
|
anonymous user `everyone` or `*`, which represents clients that access the API without username/password.
|
||||||
|
|
||||||
|
A `TOPIC` is either a specific topic name (e.g. `mytopic`, or `phil_alerts`), or a wildcard pattern that matches any
|
||||||
|
number of topics (e.g. `alerts_*` or `ben-*`). Only the wildcard character `*` is supported. It stands for zero to any
|
||||||
|
number of characters.
|
||||||
|
|
||||||
|
A `PERMISSION` is any of the following supported permissions:
|
||||||
|
|
||||||
|
* `read-write` (alias: `rw`): Allows [publishing messages](publish.md) to the given topic, as well as
|
||||||
|
[subscribing](subscribe/api.md) and reading messages
|
||||||
|
* `read-only` (aliases: `read`, `ro`): Allows only subscribing and reading messages, but not publishing to the topic
|
||||||
|
* `write-only` (aliases: `write`, `wo`): Allows only publishing to the topic, but not subscribing to it
|
||||||
|
* `deny` (alias: `none`): Allows neither publishing nor subscribing to a topic
|
||||||
|
|
||||||
|
**Example commands** (type `ntfy access --help` for more details):
|
||||||
|
```
|
||||||
|
ntfy access # Shows entire access control list
|
||||||
|
ntfy access phil # Shows access for user phil
|
||||||
|
ntfy access phil mytopic rw # Allow read-write access to mytopic for user phil
|
||||||
|
ntfy access everyone mytopic rw # Allow anonymous read-write access to mytopic
|
||||||
|
ntfy access everyone "up*" write # Allow anonymous write-only access to topics "up..."
|
||||||
|
ntfy access --reset # Reset entire access control list
|
||||||
|
ntfy access --reset phil # Reset all access for user phil
|
||||||
|
ntfy access --reset phil mytopic # Reset access for user phil and topic mytopic
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example ACL:**
|
||||||
|
```
|
||||||
|
$ ntfy access
|
||||||
|
user phil (admin)
|
||||||
|
- read-write access to all topics (admin role)
|
||||||
|
user ben (user)
|
||||||
|
- read-write access to topic garagedoor
|
||||||
|
- read-write access to topic alerts*
|
||||||
|
- read-only access to topic furnace
|
||||||
|
user * (anonymous)
|
||||||
|
- read-only access to topic announcements
|
||||||
|
- read-only access to topic server-stats
|
||||||
|
- no access to any (other) topics (server config)
|
||||||
|
```
|
||||||
|
|
||||||
|
In this example, `phil` has the role `admin`, so he has read-write access to all topics (no ACL entries are necessary).
|
||||||
|
User `ben` has three topic-specific entries. He can read, but not write to topic `furnace`, and has read-write access
|
||||||
|
to topic `garagedoor` and all topics starting with the word `alerts` (wildcards). Clients that are not authenticated
|
||||||
|
(called `*`/`everyone`) only have read access to the `announcements` and `server-stats` topics.
|
||||||
|
|
||||||
|
### Example: Private instance
|
||||||
|
The easiest way to configure a private instance is to set `auth-default-access` to `deny-all` in the `server.yml`:
|
||||||
|
|
||||||
|
=== "/etc/ntfy/server.yml"
|
||||||
|
``` yaml
|
||||||
|
auth-file "/var/lib/ntfy/user.db"
|
||||||
|
auth-default-access: "deny-all"
|
||||||
|
```
|
||||||
|
|
||||||
|
After that, simply create an `admin` user:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ ntfy user add --role=admin phil
|
||||||
|
password: mypass
|
||||||
|
confirm: mypass
|
||||||
|
user phil added with role admin
|
||||||
|
```
|
||||||
|
|
||||||
|
Once you've done that, you can publish and subscribe using [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication)
|
||||||
|
with the given username/password. Be sure to use HTTPS to avoid eavesdropping and exposing your password. Here's a simple example:
|
||||||
|
|
||||||
|
=== "Command line (curl)"
|
||||||
|
```
|
||||||
|
curl \
|
||||||
|
-u phil:mypass \
|
||||||
|
-d "Look ma, with auth" \
|
||||||
|
https://ntfy.example.com/mysecrets
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "ntfy CLI"
|
||||||
|
```
|
||||||
|
ntfy publish \
|
||||||
|
-u phil:mypass \
|
||||||
|
ntfy.example.com/mysecrets \
|
||||||
|
"Look ma, with auth"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "HTTP"
|
||||||
|
``` http
|
||||||
|
POST /mysecrets HTTP/1.1
|
||||||
|
Host: ntfy.example.com
|
||||||
|
Authorization: Basic cGhpbDpteXBhc3M=
|
||||||
|
|
||||||
|
Look ma, with auth
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "JavaScript"
|
||||||
|
``` javascript
|
||||||
|
fetch('https://ntfy.example.com/mysecrets', {
|
||||||
|
method: 'POST', // PUT works too
|
||||||
|
body: 'Look ma, with auth',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Basic cGhpbDpteXBhc3M='
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Go"
|
||||||
|
``` go
|
||||||
|
req, _ := http.NewRequest("POST", "https://ntfy.example.com/mysecrets",
|
||||||
|
strings.NewReader("Look ma, with auth"))
|
||||||
|
req.Header.Set("Authorization", "Basic cGhpbDpteXBhc3M=")
|
||||||
|
http.DefaultClient.Do(req)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Python"
|
||||||
|
``` python
|
||||||
|
requests.post("https://ntfy.example.com/mysecrets",
|
||||||
|
data="Look ma, with auth",
|
||||||
|
headers={
|
||||||
|
"Authorization": "Basic cGhpbDpteXBhc3M="
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "PHP"
|
||||||
|
``` php-inline
|
||||||
|
file_get_contents('https://ntfy.example.com/mysecrets', false, stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'method' => 'POST', // PUT also works
|
||||||
|
'header' =>
|
||||||
|
'Content-Type: text/plain\r\n' .
|
||||||
|
'Authorization: Basic cGhpbDpteXBhc3M=',
|
||||||
|
'content' => 'Look ma, with auth'
|
||||||
|
]
|
||||||
|
]));
|
||||||
|
```
|
||||||
|
|
||||||
## E-mail notifications
|
## E-mail notifications
|
||||||
To allow forwarding messages via e-mail, you can configure an **SMTP server for outgoing messages**. Once configured,
|
To allow forwarding messages via e-mail, you can configure an **SMTP server for outgoing messages**. Once configured,
|
||||||
you can set the `X-Email` header to [send messages via e-mail](publish.md#e-mail-notifications) (e.g.
|
you can set the `X-Email` header to [send messages via e-mail](publish.md#e-mail-notifications) (e.g.
|
||||||
@@ -109,7 +346,7 @@ statuspage.io (though these days most services also support webhooks and HTTP ca
|
|||||||
To configure the SMTP server, you must at least set `smtp-server-listen` and `smtp-server-domain`:
|
To configure the SMTP server, you must at least set `smtp-server-listen` and `smtp-server-domain`:
|
||||||
|
|
||||||
* `smtp-server-listen` defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25`
|
* `smtp-server-listen` defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25`
|
||||||
* `smtp-server-domain` is the e-mail domain, e.g. `ntfy.sh`
|
* `smtp-server-domain` is the e-mail domain, e.g. `ntfy.sh` (must be identical to MX record, see below)
|
||||||
* `smtp-server-addr-prefix` is an optional prefix for the e-mail addresses to prevent spam. If set to `ntfy-`, for instance,
|
* `smtp-server-addr-prefix` is an optional prefix for the e-mail addresses to prevent spam. If set to `ntfy-`, for instance,
|
||||||
only e-mails to `ntfy-$topic@ntfy.sh` will be accepted. If this is not set, all emails to `$topic@ntfy.sh` will be
|
only e-mails to `ntfy-$topic@ntfy.sh` will be accepted. If this is not set, all emails to `$topic@ntfy.sh` will be
|
||||||
accepted (which may obviously be a spam problem).
|
accepted (which may obviously be a spam problem).
|
||||||
@@ -132,6 +369,42 @@ configured (in [Amazon Route 53](https://aws.amazon.com/route53/)):
|
|||||||
<figcaption>DNS records for incoming mail</figcaption>
|
<figcaption>DNS records for incoming mail</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
|
You can check if everything is working correctly by sending an email as raw SMTP via `nc`. Create a text file, e.g.
|
||||||
|
`email.txt`
|
||||||
|
|
||||||
|
```
|
||||||
|
EHLO example.com
|
||||||
|
MAIL FROM: phil@example.com
|
||||||
|
RCPT TO: ntfy-mytopic@ntfy.sh
|
||||||
|
DATA
|
||||||
|
Subject: Email for you
|
||||||
|
Content-Type: text/plain; charset="UTF-8"
|
||||||
|
|
||||||
|
Hello from 🇩🇪
|
||||||
|
.
|
||||||
|
```
|
||||||
|
|
||||||
|
And then send the mail via `nc` like this. If you see any lines starting with `451`, those are errors from the
|
||||||
|
ntfy server. Read them carefully.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ cat email.txt | nc -N ntfy.sh 25
|
||||||
|
220 ntfy.sh ESMTP Service Ready
|
||||||
|
250-Hello example.com
|
||||||
|
...
|
||||||
|
250 2.0.0 Roger, accepting mail from <phil@example.com>
|
||||||
|
250 2.0.0 I'll make sure <ntfy-mytopic@ntfy.sh> gets this
|
||||||
|
```
|
||||||
|
|
||||||
|
As for the DNS setup, be sure to verify that `dig MX` and `dig A` are returning results similar to this:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ dig MX ntfy.sh +short
|
||||||
|
10 mx1.ntfy.sh.
|
||||||
|
$ dig A mx1.ntfy.sh +short
|
||||||
|
3.139.215.220
|
||||||
|
```
|
||||||
|
|
||||||
## Behind a proxy (TLS, etc.)
|
## Behind a proxy (TLS, etc.)
|
||||||
!!! warning
|
!!! warning
|
||||||
If you are running ntfy behind a proxy, you must set the `behind-proxy` flag. Otherwise, all visitors are
|
If you are running ntfy behind a proxy, you must set the `behind-proxy` flag. Otherwise, all visitors are
|
||||||
@@ -162,8 +435,10 @@ HTTP challenge. I've found [this guide](https://nandovieira.com/using-lets-encry
|
|||||||
be incredibly helpful.
|
be incredibly helpful.
|
||||||
|
|
||||||
### nginx/Apache2/caddy
|
### nginx/Apache2/caddy
|
||||||
For your convenience, here's a working config that'll help configure things behind a proxy. In this
|
For your convenience, here's a working config that'll help configure things behind a proxy. Be sure to **enable WebSockets**
|
||||||
example, ntfy runs on `:2586` and we proxy traffic to it. We also redirect HTTP to HTTPS for GET requests against a topic
|
by forwarding the `Connection` and `Upgrade` headers accordingly.
|
||||||
|
|
||||||
|
In this example, ntfy runs on `:2586` and we proxy traffic to it. We also redirect HTTP to HTTPS for GET requests against a topic
|
||||||
or the root domain:
|
or the root domain:
|
||||||
|
|
||||||
=== "nginx (/etc/nginx/sites-*/ntfy)"
|
=== "nginx (/etc/nginx/sites-*/ntfy)"
|
||||||
@@ -194,6 +469,8 @@ or the root domain:
|
|||||||
proxy_redirect off;
|
proxy_redirect off;
|
||||||
|
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
||||||
proxy_connect_timeout 3m;
|
proxy_connect_timeout 3m;
|
||||||
@@ -225,6 +502,8 @@ or the root domain:
|
|||||||
proxy_redirect off;
|
proxy_redirect off;
|
||||||
|
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
||||||
proxy_connect_timeout 3m;
|
proxy_connect_timeout 3m;
|
||||||
@@ -240,19 +519,27 @@ or the root domain:
|
|||||||
```
|
```
|
||||||
<VirtualHost *:80>
|
<VirtualHost *:80>
|
||||||
ServerName ntfy.sh
|
ServerName ntfy.sh
|
||||||
|
|
||||||
SetEnv proxy-nokeepalive 1
|
# Proxy connections to ntfy (requires "a2enmod proxy")
|
||||||
SetEnv proxy-sendchunked 1
|
|
||||||
|
|
||||||
ProxyPass / http://127.0.0.1:2586/
|
ProxyPass / http://127.0.0.1:2586/
|
||||||
ProxyPassReverse / http://127.0.0.1:2586/
|
ProxyPassReverse / http://127.0.0.1:2586/
|
||||||
|
|
||||||
|
SetEnv proxy-nokeepalive 1
|
||||||
|
SetEnv proxy-sendchunked 1
|
||||||
|
|
||||||
# Higher than the max message size of 4096 bytes
|
# Higher than the max message size of 4096 bytes
|
||||||
LimitRequestBody 102400
|
LimitRequestBody 102400
|
||||||
|
|
||||||
|
# Enable mod_rewrite (requires "a2enmod rewrite")
|
||||||
|
RewriteEngine on
|
||||||
|
|
||||||
|
# WebSockets support (requires "a2enmod rewrite proxy_wstunnel")
|
||||||
|
RewriteCond %{HTTP:Upgrade} websocket [NC]
|
||||||
|
RewriteCond %{HTTP:Connection} upgrade [NC]
|
||||||
|
RewriteRule ^/?(.*) "ws://127.0.0.1:2586/$1" [P,L]
|
||||||
|
|
||||||
# Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
|
# Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
|
||||||
# it to work with curl without the annoying https:// prefix
|
# it to work with curl without the annoying https:// prefix
|
||||||
RewriteEngine on
|
|
||||||
RewriteCond %{REQUEST_METHOD} GET
|
RewriteCond %{REQUEST_METHOD} GET
|
||||||
RewriteRule ^/([-_A-Za-z0-9]{0,64})$ https://%{SERVER_NAME}/$1 [R,L]
|
RewriteRule ^/([-_A-Za-z0-9]{0,64})$ https://%{SERVER_NAME}/$1 [R,L]
|
||||||
</VirtualHost>
|
</VirtualHost>
|
||||||
@@ -264,21 +551,24 @@ or the root domain:
|
|||||||
SSLCertificateFile /etc/letsencrypt/live/ntfy.sh/fullchain.pem
|
SSLCertificateFile /etc/letsencrypt/live/ntfy.sh/fullchain.pem
|
||||||
SSLCertificateKeyFile /etc/letsencrypt/live/ntfy.sh/privkey.pem
|
SSLCertificateKeyFile /etc/letsencrypt/live/ntfy.sh/privkey.pem
|
||||||
Include /etc/letsencrypt/options-ssl-apache.conf
|
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||||
|
|
||||||
SetEnv proxy-nokeepalive 1
|
# Proxy connections to ntfy (requires "a2enmod proxy")
|
||||||
SetEnv proxy-sendchunked 1
|
|
||||||
|
|
||||||
ProxyPass / http://127.0.0.1:2586/
|
ProxyPass / http://127.0.0.1:2586/
|
||||||
ProxyPassReverse / http://127.0.0.1:2586/
|
ProxyPassReverse / http://127.0.0.1:2586/
|
||||||
|
|
||||||
|
SetEnv proxy-nokeepalive 1
|
||||||
|
SetEnv proxy-sendchunked 1
|
||||||
|
|
||||||
# Higher than the max message size of 4096 bytes
|
# Higher than the max message size of 4096 bytes
|
||||||
LimitRequestBody 102400
|
LimitRequestBody 102400
|
||||||
|
|
||||||
# Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
|
# Enable mod_rewrite (requires "a2enmod rewrite")
|
||||||
# it to work with curl without the annoying https:// prefix
|
|
||||||
RewriteEngine on
|
RewriteEngine on
|
||||||
RewriteCond %{REQUEST_METHOD} GET
|
|
||||||
RewriteRule ^/([-_A-Za-z0-9]{0,64})$ https://%{SERVER_NAME}/$1 [R,L]
|
# WebSockets support (requires "a2enmod rewrite proxy_wstunnel")
|
||||||
|
RewriteCond %{HTTP:Upgrade} websocket [NC]
|
||||||
|
RewriteCond %{HTTP:Connection} upgrade [NC]
|
||||||
|
RewriteRule ^/?(.*) "ws://127.0.0.1:2586/$1" [P,L]
|
||||||
</VirtualHost>
|
</VirtualHost>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -287,11 +577,17 @@ or the root domain:
|
|||||||
# Note that this config is most certainly incomplete. Please help out and let me know what's missing
|
# Note that this config is most certainly incomplete. Please help out and let me know what's missing
|
||||||
# via Discord/Matrix or in a GitHub issue.
|
# via Discord/Matrix or in a GitHub issue.
|
||||||
|
|
||||||
ntfy.sh {
|
ntfy.sh, http://nfty.sh {
|
||||||
reverse_proxy 127.0.0.1:2586
|
|
||||||
}
|
|
||||||
http://nfty.sh {
|
|
||||||
reverse_proxy 127.0.0.1:2586
|
reverse_proxy 127.0.0.1:2586
|
||||||
|
|
||||||
|
# Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
|
||||||
|
# it to work with curl without the annoying https:// prefix
|
||||||
|
@httpget {
|
||||||
|
protocol http
|
||||||
|
method GET
|
||||||
|
path_regexp ^/([-_a-z0-9]{0,64}$|docs/|static/)
|
||||||
|
}
|
||||||
|
redir @httpget https://{host}{uri}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -350,11 +646,13 @@ This limit uses a [token bucket](https://en.wikipedia.org/wiki/Token_bucket) (us
|
|||||||
|
|
||||||
Each visitor has a bucket of 60 requests they can fire against the server (defined by `visitor-request-limit-burst`).
|
Each visitor has a bucket of 60 requests they can fire against the server (defined by `visitor-request-limit-burst`).
|
||||||
After the 60, new requests will encounter a `429 Too Many Requests` response. The visitor request bucket is refilled at a rate of one
|
After the 60, new requests will encounter a `429 Too Many Requests` response. The visitor request bucket is refilled at a rate of one
|
||||||
request every 10s (defined by `visitor-request-limit-replenish`)
|
request every 5s (defined by `visitor-request-limit-replenish`)
|
||||||
|
|
||||||
* `visitor-request-limit-burst` is the initial bucket of requests each visitor has. This defaults to 60.
|
* `visitor-request-limit-burst` is the initial bucket of requests each visitor has. This defaults to 60.
|
||||||
* `visitor-request-limit-replenish` is the rate at which the bucket is refilled (one request per x). Defaults to 10s.
|
* `visitor-request-limit-replenish` is the rate at which the bucket is refilled (one request per x). Defaults to 5s.
|
||||||
|
* `visitor-request-limit-exempt-hosts` is a comma-separated list of hostnames and IPs to be exempt from request rate
|
||||||
|
limiting; hostnames are resolved at the time the server is started. Defaults to an empty list.
|
||||||
|
|
||||||
### Attachment limits
|
### Attachment limits
|
||||||
Aside from the global file size and total attachment cache limits (see [above](#attachments)), there are two relevant
|
Aside from the global file size and total attachment cache limits (see [above](#attachments)), there are two relevant
|
||||||
per-visitor limits:
|
per-visitor limits:
|
||||||
@@ -477,38 +775,43 @@ Each config option can be set in the config file `/etc/ntfy/server.yml` (e.g. `l
|
|||||||
CLI option (e.g. `--listen-http :80`. Here's a list of all available options. Alternatively, you can set an environment
|
CLI option (e.g. `--listen-http :80`. Here's a list of all available options. Alternatively, you can set an environment
|
||||||
variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
||||||
|
|
||||||
| Config option | Env variable | Format | Default | Description |
|
| Config option | Env variable | Format | Default | Description |
|
||||||
|--------------------------------------------|-------------------------------------------------|------------------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|--------------------------------------------|-------------------------------------------------|-----------------------------------------------------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| `base-url` | `NTFY_BASE_URL` | *URL* | - | Public facing base URL of the service (e.g. `https://ntfy.sh`) |
|
| `base-url` | `NTFY_BASE_URL` | *URL* | - | Public facing base URL of the service (e.g. `https://ntfy.sh`) |
|
||||||
| `listen-http` | `NTFY_LISTEN_HTTP` | `[host]:port` | `:80` | Listen address for the HTTP web server |
|
| `listen-http` | `NTFY_LISTEN_HTTP` | `[host]:port` | `:80` | Listen address for the HTTP web server |
|
||||||
| `listen-https` | `NTFY_LISTEN_HTTPS` | `[host]:port` | - | Listen address for the HTTPS web server. If set, you also need to set `key-file` and `cert-file`. |
|
| `listen-https` | `NTFY_LISTEN_HTTPS` | `[host]:port` | - | Listen address for the HTTPS web server. If set, you also need to set `key-file` and `cert-file`. |
|
||||||
| `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. |
|
| `listen-unix` | `NTFY_LISTEN_UNIX` | *filename* | - | Path to a Unix socket to listen on |
|
||||||
| `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. |
|
| `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. |
|
||||||
| `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM](#firebase-fcm). |
|
| `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. |
|
||||||
| `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache). |
|
| `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM](#firebase-fcm). |
|
||||||
| `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely. |
|
| `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache). |
|
||||||
| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection. |
|
| `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely. |
|
||||||
| `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory* | - | Cache directory for attached files. To enable attachments, this has to be set. |
|
| `auth-file` | `NTFY_AUTH_FILE` | *filename* | - | Auth database file used for access control. If set, enables authentication and access control. See [access control](#access-control). |
|
||||||
| `attachment-total-size-limit` | `NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 5G | Limit of the on-disk attachment cache directory. If the limits is exceeded, new attachments will be rejected. |
|
| `auth-default-access` | `NTFY_AUTH_DEFAULT_ACCESS` | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write` | Default permissions if no matching entries in the auth database are found. Default is `read-write`. |
|
||||||
| `attachment-file-size-limit` | `NTFY_ATTACHMENT_FILE_SIZE_LIMIT` | *size* | 15M | Per-file attachment size limit (e.g. 300k, 2M, 100M). Larger attachment will be rejected. |
|
| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection. |
|
||||||
| `attachment-expiry-duration` | `NTFY_ATTACHMENT_EXPIRY_DURATION` | *duration* | 3h | Duration after which uploaded attachments will be deleted (e.g. 3h, 20h). Strongly affects `visitor-attachment-total-size-limit`. |
|
| `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory* | - | Cache directory for attached files. To enable attachments, this has to be set. |
|
||||||
| `smtp-sender-addr` | `NTFY_SMTP_SENDER_ADDR` | `host:port` | - | SMTP server address to allow email sending |
|
| `attachment-total-size-limit` | `NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 5G | Limit of the on-disk attachment cache directory. If the limits is exceeded, new attachments will be rejected. |
|
||||||
| `smtp-sender-user` | `NTFY_SMTP_SENDER_USER` | *string* | - | SMTP user; only used if e-mail sending is enabled |
|
| `attachment-file-size-limit` | `NTFY_ATTACHMENT_FILE_SIZE_LIMIT` | *size* | 15M | Per-file attachment size limit (e.g. 300k, 2M, 100M). Larger attachment will be rejected. |
|
||||||
| `smtp-sender-pass` | `NTFY_SMTP_SENDER_PASS` | *string* | - | SMTP password; only used if e-mail sending is enabled |
|
| `attachment-expiry-duration` | `NTFY_ATTACHMENT_EXPIRY_DURATION` | *duration* | 3h | Duration after which uploaded attachments will be deleted (e.g. 3h, 20h). Strongly affects `visitor-attachment-total-size-limit`. |
|
||||||
| `smtp-sender-from` | `NTFY_SMTP_SENDER_FROM` | *e-mail address* | - | SMTP sender e-mail address; only used if e-mail sending is enabled |
|
| `smtp-sender-addr` | `NTFY_SMTP_SENDER_ADDR` | `host:port` | - | SMTP server address to allow email sending |
|
||||||
| `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` |
|
| `smtp-sender-user` | `NTFY_SMTP_SENDER_USER` | *string* | - | SMTP user; only used if e-mail sending is enabled |
|
||||||
| `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` |
|
| `smtp-sender-pass` | `NTFY_SMTP_SENDER_PASS` | *string* | - | SMTP password; only used if e-mail sending is enabled |
|
||||||
| `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | `[ip]:port` | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` |
|
| `smtp-sender-from` | `NTFY_SMTP_SENDER_FROM` | *e-mail address* | - | SMTP sender e-mail address; only used if e-mail sending is enabled |
|
||||||
| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 55s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. |
|
| `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` |
|
||||||
| `manager-interval` | `$NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. |
|
| `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` |
|
||||||
| `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. |
|
| `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | `[ip]:port` | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` |
|
||||||
| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
|
| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 45s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. |
|
||||||
| `visitor-attachment-total-size-limit` | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 100M | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`. |
|
| `manager-interval` | `$NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. |
|
||||||
| `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size* | 500M | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding. |
|
| `web-root` | `NTFY_WEB_ROOT` | `app` or `home` | `app` | Sets web root to landing page (home) or web app (app) |
|
||||||
| `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has |
|
| `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. |
|
||||||
| `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 10s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled |
|
| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
|
||||||
| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Rate limiting:Initial limit of e-mails per visitor |
|
| `visitor-attachment-total-size-limit` | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 100M | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`. |
|
||||||
| `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled |
|
| `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size* | 500M | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding. |
|
||||||
|
| `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has |
|
||||||
|
| `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 5s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled |
|
||||||
|
| `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting |
|
||||||
|
| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Rate limiting:Initial limit of e-mails per visitor |
|
||||||
|
| `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled |
|
||||||
|
|
||||||
The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.
|
The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.
|
||||||
The format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k.
|
The format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k.
|
||||||
@@ -522,6 +825,9 @@ NAME:
|
|||||||
USAGE:
|
USAGE:
|
||||||
ntfy serve [OPTIONS..]
|
ntfy serve [OPTIONS..]
|
||||||
|
|
||||||
|
CATEGORY:
|
||||||
|
Server commands
|
||||||
|
|
||||||
DESCRIPTION:
|
DESCRIPTION:
|
||||||
Run the ntfy server and listen for incoming requests
|
Run the ntfy server and listen for incoming requests
|
||||||
|
|
||||||
@@ -537,17 +843,21 @@ OPTIONS:
|
|||||||
--base-url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
|
--base-url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
|
||||||
--listen-http value, -l value ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
|
--listen-http value, -l value ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
|
||||||
--listen-https value, -L value ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS]
|
--listen-https value, -L value ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS]
|
||||||
|
--listen-unix value, -U value listen on unix socket path [$NTFY_LISTEN_UNIX]
|
||||||
--key-file value, -K value private key file, if listen-https is set [$NTFY_KEY_FILE]
|
--key-file value, -K value private key file, if listen-https is set [$NTFY_KEY_FILE]
|
||||||
--cert-file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE]
|
--cert-file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE]
|
||||||
--firebase-key-file value, -F value Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE]
|
--firebase-key-file value, -F value Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE]
|
||||||
--cache-file value, -C value cache file used for message caching [$NTFY_CACHE_FILE]
|
--cache-file value, -C value cache file used for message caching [$NTFY_CACHE_FILE]
|
||||||
--cache-duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION]
|
--cache-duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION]
|
||||||
|
--auth-file value, -H value auth database file used for access control [$NTFY_AUTH_FILE]
|
||||||
|
--auth-default-access value, -p value default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS]
|
||||||
--attachment-cache-dir value cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR]
|
--attachment-cache-dir value cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR]
|
||||||
--attachment-total-size-limit value, -A value limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
--attachment-total-size-limit value, -A value limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
||||||
--attachment-file-size-limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
|
--attachment-file-size-limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
|
||||||
--attachment-expiry-duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION]
|
--attachment-expiry-duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION]
|
||||||
--keepalive-interval value, -k value interval of keepalive messages (default: 55s) [$NTFY_KEEPALIVE_INTERVAL]
|
--keepalive-interval value, -k value interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL]
|
||||||
--manager-interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
|
--manager-interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
|
||||||
|
--web-root value sets web root to landing page (home) or web app (app) (default: "app") [$NTFY_WEB_ROOT]
|
||||||
--smtp-sender-addr value SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR]
|
--smtp-sender-addr value SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR]
|
||||||
--smtp-sender-user value SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER]
|
--smtp-sender-user value SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER]
|
||||||
--smtp-sender-pass value SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS]
|
--smtp-sender-pass value SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS]
|
||||||
@@ -560,7 +870,8 @@ OPTIONS:
|
|||||||
--visitor-attachment-total-size-limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
--visitor-attachment-total-size-limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
||||||
--visitor-attachment-daily-bandwidth-limit value total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT]
|
--visitor-attachment-daily-bandwidth-limit value total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT]
|
||||||
--visitor-request-limit-burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
|
--visitor-request-limit-burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
|
||||||
--visitor-request-limit-replenish value interval at which burst limit is replenished (one per x) (default: 10s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
|
--visitor-request-limit-replenish value interval at which burst limit is replenished (one per x) (default: 5s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
|
||||||
|
--visitor-request-limit-exempt-hosts value hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS]
|
||||||
--visitor-email-limit-burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]
|
--visitor-email-limit-burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]
|
||||||
--visitor-email-limit-replenish value interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
|
--visitor-email-limit-replenish value interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
|
||||||
--behind-proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
|
--behind-proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
|
||||||
|
|||||||
@@ -4,8 +4,28 @@ This page is used to list deprecation notices for ntfy. Deprecated commands and
|
|||||||
|
|
||||||
## Active deprecations
|
## Active deprecations
|
||||||
|
|
||||||
|
### Android app: WebSockets will become the default connection protocol
|
||||||
|
> Active since 2022-03-13, behavior will change in **June 2022**
|
||||||
|
|
||||||
|
In future versions of the Android app, instant delivery connections and connections to self-hosted servers will
|
||||||
|
be using the WebSockets protocol. This potentially requires [configuration changes in your proxy](https://ntfy.sh/docs/config/#nginxapache2caddy).
|
||||||
|
|
||||||
|
Due to [reports of varying battery consumption](https://github.com/binwiederhier/ntfy/issues/190) (which entirely
|
||||||
|
seems to depend on the phone), JSON HTTP stream support will not be removed. Instead, I'll just flip the default to
|
||||||
|
WebSocket in June.
|
||||||
|
|
||||||
|
### Android app: Using `since=<timestamp>` instead of `since=<id>`
|
||||||
|
> Active since 2022-02-27, behavior will change in **May 2022**
|
||||||
|
|
||||||
|
In about 3 months, the Android app will start using `since=<id>` instead of `since=<timestamp>`, which means that it will
|
||||||
|
not work with servers older than v1.16.0 anymore. This is to simplify handling of deduplication in the Android app.
|
||||||
|
|
||||||
|
The `since=<timestamp>` endpoint will continue to work. This is merely a notice that the Android app behavior will change.
|
||||||
|
|
||||||
|
## Previous deprecations
|
||||||
|
|
||||||
### Running server via `ntfy` (instead of `ntfy serve`)
|
### Running server via `ntfy` (instead of `ntfy serve`)
|
||||||
> since 2021-12-17
|
> Deprecated 2021-12-17, behavior changed with v1.10.0
|
||||||
|
|
||||||
As more commands are added to the `ntfy` CLI tool, using just `ntfy` to run the server is not practical
|
As more commands are added to the `ntfy` CLI tool, using just `ntfy` to run the server is not practical
|
||||||
anymore. Please use `ntfy serve` instead. This also applies to Docker images, as they can also execute more than
|
anymore. Please use `ntfy serve` instead. This also applies to Docker images, as they can also execute more than
|
||||||
|
|||||||
301
docs/develop.md
@@ -1,51 +1,287 @@
|
|||||||
# Building
|
# Development
|
||||||
|
Hurray 🥳 🎉, you are interested in writing code for ntfy! **That's awesome.** 😎
|
||||||
|
|
||||||
|
I tried my very best to write up detailed instructions, but if at any point in time you run into issues, don't
|
||||||
|
hesitate to **contact me on [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org)**.
|
||||||
|
|
||||||
## ntfy server
|
## ntfy server
|
||||||
The ntfy server source code is available [on GitHub](https://github.com/binwiederhier/ntfy).
|
The ntfy server source code is available [on GitHub](https://github.com/binwiederhier/ntfy). The codebase for the
|
||||||
To quickly build on amd64, you can use `make build-simple`:
|
server consists of three components:
|
||||||
|
|
||||||
```
|
* **The main server/client** is written in [Go](https://go.dev/) (so you'll need Go). Its main entrypoint is at
|
||||||
git clone git@github.com:binwiederhier/ntfy.git
|
[main.go](https://github.com/binwiederhier/ntfy/blob/main/main.go), and the meat you're likely interested in is
|
||||||
cd ntfy
|
in [server.go](https://github.com/binwiederhier/ntfy/blob/main/server/server.go). Notably, the server uses a
|
||||||
make build-simple
|
[SQLite](https://sqlite.org) library called [go-sqlite3](https://github.com/mattn/go-sqlite3), which requires
|
||||||
|
[Cgo](https://go.dev/blog/cgo) and `CGO_ENABLED=1` to be set. Otherwise things will not work (see below).
|
||||||
|
* **The documentation** is generated by [MkDocs](https://www.mkdocs.org/) and [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/),
|
||||||
|
which is written in [Python](https://www.python.org/). You'll need Python and MkDocs (via `pip`) only if you want to
|
||||||
|
build the docs.
|
||||||
|
* **The web app** is written in [React](https://reactjs.org/), using [MUI](https://mui.com/). It uses [Create React App](https://create-react-app.dev/)
|
||||||
|
to build the production build. If you want to modify the web app, you need [nodejs](https://nodejs.org/en/) (for `npm`)
|
||||||
|
and install all the 100,000 dependencies (*sigh*).
|
||||||
|
|
||||||
|
All of these components are built and then **baked into one binary**.
|
||||||
|
|
||||||
|
### Navigating the code
|
||||||
|
Code:
|
||||||
|
|
||||||
|
* [main.go](https://github.com/binwiederhier/ntfy/blob/main/main.go) - Main entrypoint into the CLI, for both server and client
|
||||||
|
* [cmd/](https://github.com/binwiederhier/ntfy/tree/main/cmd) - CLI commands, such as `serve` or `publish`
|
||||||
|
* [server/](https://github.com/binwiederhier/ntfy/tree/main/server) - The meat of the server logic
|
||||||
|
* [docs/](https://github.com/binwiederhier/ntfy/tree/main/docs) - The [MkDocs](https://www.mkdocs.org/) documentation, also see `mkdocs.yml`
|
||||||
|
* [web/](https://github.com/binwiederhier/ntfy/tree/main/web) - The [React](https://reactjs.org/) application, also see `web/package.json`
|
||||||
|
|
||||||
|
Build related:
|
||||||
|
|
||||||
|
* [Makefile](https://github.com/binwiederhier/ntfy/blob/main/Makefile) - Main entrypoint for all things related to building
|
||||||
|
* [.goreleaser.yml](https://github.com/binwiederhier/ntfy/blob/main/.goreleaser.yml) - Describes all build outputs (for [GoReleaser](https://goreleaser.com/))
|
||||||
|
* [go.mod](https://github.com/binwiederhier/ntfy/blob/main/go.mod) - Go modules dependency file
|
||||||
|
* [mkdocs.yml](https://github.com/binwiederhier/ntfy/blob/main/mkdocs.yml) - Config file for the docs (for [MkDocs](https://www.mkdocs.org/))
|
||||||
|
* [web/package.json](https://github.com/binwiederhier/ntfy/blob/main/web/package.json) - Build and dependency file for web app (for npm)
|
||||||
|
|
||||||
|
|
||||||
|
The `web/` and `docs/` folder are the sources for web app and documentation. During the build process,
|
||||||
|
the generated output is copied to `server/site` (web app and landing page) and `server/docs` (documentation).
|
||||||
|
|
||||||
|
### Build requirements
|
||||||
|
|
||||||
|
* [Go](https://go.dev/) (required for main server)
|
||||||
|
* [gcc](https://gcc.gnu.org/) (required main server, for SQLite cgo-based bindings)
|
||||||
|
* [Make](https://www.gnu.org/software/make/) (required for convenience)
|
||||||
|
* [libsqlite3/libsqlite3-dev](https://www.sqlite.org/) (required for main server, for SQLite cgo-based bindings)
|
||||||
|
* [GoReleaser](https://goreleaser.com/) (required for a proper main server build)
|
||||||
|
* [Python](https://www.python.org/) (for `pip`, only to build the docs)
|
||||||
|
* [nodejs](https://nodejs.org/en/) (for `npm`, only to build the web app)
|
||||||
|
|
||||||
|
### Install dependencies
|
||||||
|
These steps **assume Ubuntu**. Steps may vary on different Linux distributions.
|
||||||
|
|
||||||
|
First, install [Go](https://go.dev/) (see [official instructions](https://go.dev/doc/install)):
|
||||||
|
``` shell
|
||||||
|
wget https://go.dev/dl/go1.18.linux-amd64.tar.gz
|
||||||
|
rm -rf /usr/local/go && tar -C /usr/local -xzf go1.18.linux-amd64.tar.gz
|
||||||
|
export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin
|
||||||
|
go version # verifies that it worked
|
||||||
```
|
```
|
||||||
|
|
||||||
That'll generate a statically linked binary in `dist/ntfy_linux_amd64/ntfy`.
|
Install [GoReleaser](https://goreleaser.com/) (see [official instructions](https://goreleaser.com/install/)):
|
||||||
|
``` shell
|
||||||
For all other platforms (including Docker), and for production or other snapshot builds, you should use the amazingly
|
go install github.com/goreleaser/goreleaser@latest
|
||||||
awesome [GoReleaser](https://goreleaser.com/) make targets:
|
goreleaser -v # verifies that it worked
|
||||||
|
|
||||||
```
|
|
||||||
Build:
|
|
||||||
make build - Build
|
|
||||||
make build-snapshot - Build snapshot
|
|
||||||
make build-simple - Build (using go build, without goreleaser)
|
|
||||||
make clean - Clean build folder
|
|
||||||
|
|
||||||
Releasing (requires goreleaser):
|
|
||||||
make release - Create a release
|
|
||||||
make release-snapshot - Create a test release
|
|
||||||
```
|
```
|
||||||
|
|
||||||
There are currently no platform-specific make targets, so they will build for all platforms (which may take a while).
|
Install [nodejs](https://nodejs.org/en/) (see [official instructions](https://nodejs.org/en/download/package-manager/)):
|
||||||
|
``` shell
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_17.x | sudo -E bash -
|
||||||
|
sudo apt-get install -y nodejs
|
||||||
|
npm -v # verifies that it worked
|
||||||
|
```
|
||||||
|
|
||||||
|
Then install a few other things required:
|
||||||
|
``` shell
|
||||||
|
sudo apt install \
|
||||||
|
build-essential \
|
||||||
|
libsqlite3-dev \
|
||||||
|
gcc-arm-linux-gnueabi \
|
||||||
|
gcc-aarch64-linux-gnu \
|
||||||
|
python3-pip \
|
||||||
|
upx \
|
||||||
|
git
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check out code
|
||||||
|
Now check out via git from the [GitHub repository](https://github.com/binwiederhier/ntfy):
|
||||||
|
|
||||||
|
=== "via HTTPS"
|
||||||
|
``` shell
|
||||||
|
git clone https://github.com/binwiederhier/ntfy.git
|
||||||
|
cd ntfy
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "via SSH"
|
||||||
|
``` shell
|
||||||
|
git clone git@github.com:binwiederhier/ntfy.git
|
||||||
|
cd ntfy
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build all the things
|
||||||
|
Now you can finally build everything. There are tons of `make` targets, so maybe just review what's there first
|
||||||
|
by typing `make`:
|
||||||
|
|
||||||
|
``` shell
|
||||||
|
$ make
|
||||||
|
Typical commands (more see below):
|
||||||
|
make build - Build web app, documentation and server/client (sloowwww)
|
||||||
|
make server-amd64 - Build server/client binary (amd64, no web app or docs)
|
||||||
|
make install-amd64 - Install ntfy binary to /usr/bin/ntfy (amd64)
|
||||||
|
make web - Build the web app
|
||||||
|
make docs - Build the documentation
|
||||||
|
make check - Run all tests, vetting/formatting checks and linters
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want to build the **ntfy binary including web app and docs for all supported architectures** (amd64, armv7, and amd64),
|
||||||
|
you can simply run `make build`:
|
||||||
|
|
||||||
|
``` shell
|
||||||
|
$ make build
|
||||||
|
...
|
||||||
|
# This builds web app, docs, and the ntfy binary (for amd64, armv7 and arm64).
|
||||||
|
# This will be SLOW (5+ minutes on my laptop on the first run). Maybe look at the other make targets?
|
||||||
|
```
|
||||||
|
|
||||||
|
You'll see all the outputs in the `dist/` folder afterwards:
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
$ find dist
|
||||||
|
dist
|
||||||
|
dist/metadata.json
|
||||||
|
dist/ntfy_arm64_linux_arm64
|
||||||
|
dist/ntfy_arm64_linux_arm64/ntfy
|
||||||
|
dist/ntfy_armv7_linux_arm_7
|
||||||
|
dist/ntfy_armv7_linux_arm_7/ntfy
|
||||||
|
dist/ntfy_amd64_linux_amd64
|
||||||
|
dist/ntfy_amd64_linux_amd64/ntfy
|
||||||
|
dist/config.yaml
|
||||||
|
dist/artifacts.json
|
||||||
|
```
|
||||||
|
|
||||||
|
If you also want to build the **Debian/RPM packages and the Docker images for all supported architectures**, you can
|
||||||
|
use the `make release-snapshot` target:
|
||||||
|
|
||||||
|
``` shell
|
||||||
|
$ make release-snapshot
|
||||||
|
...
|
||||||
|
# This will be REALLY SLOW (sometimes 5+ minutes on my laptop)
|
||||||
|
```
|
||||||
|
|
||||||
|
During development, you may want to be more picky and build only certain things. Here are a few examples.
|
||||||
|
|
||||||
|
### Build the ntfy binary
|
||||||
|
To build only the `ntfy` binary **without the web app or documentation**, use the `make server-...` targets:
|
||||||
|
|
||||||
|
``` shell
|
||||||
|
$ make
|
||||||
|
Build server & client (not release version):
|
||||||
|
make server - Build server & client (all architectures)
|
||||||
|
make server-amd64 - Build server & client (amd64 only)
|
||||||
|
make server-armv7 - Build server & client (armv7 only)
|
||||||
|
make server-arm64 - Build server & client (arm64 only)
|
||||||
|
```
|
||||||
|
|
||||||
|
So if you're on an amd64/x86_64-based machine, you may just want to run `make server-amd64` during testing. On a modern
|
||||||
|
system, this shouldn't take longer than 5-10 seconds. I often combine it with `install-amd64` so I can run the binary
|
||||||
|
right away:
|
||||||
|
|
||||||
|
``` shell
|
||||||
|
$ make server-amd64 install-amd64
|
||||||
|
$ ntfy serve
|
||||||
|
```
|
||||||
|
|
||||||
|
**During development of the main app, you can also just use `go run main.go`**, as long as you run
|
||||||
|
`make server-deps-static-sites`at least once and `CGO_ENABLED=1`:
|
||||||
|
|
||||||
|
``` shell
|
||||||
|
$ export CGO_ENABLED=1
|
||||||
|
$ make server-deps-static-sites
|
||||||
|
$ go run main.go serve
|
||||||
|
2022/03/18 08:43:55 Listening on :2586[http]
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
If you don't run `server-deps-static-sites`, you may see an error *`pattern ...: no matching files found`*:
|
||||||
|
```
|
||||||
|
$ go run main.go serve
|
||||||
|
server/server.go:85:13: pattern docs: no matching files found
|
||||||
|
```
|
||||||
|
|
||||||
|
This is because we use `go:embed` to embed the documentation and web app, so the Go code expects files to be
|
||||||
|
present at `server/docs` and `server/site`. If they are not, you'll see the above error. The `server-deps-static-sites`
|
||||||
|
target creates dummy files that ensures that you'll be able to build.
|
||||||
|
|
||||||
|
|
||||||
|
### Build the web app
|
||||||
|
The sources for the web app live in `web/`. As long as you have `npm` installed (see above), building the web app
|
||||||
|
is really simple. Just type `make web` and you're in business:
|
||||||
|
|
||||||
|
``` shell
|
||||||
|
$ make web
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
This will build the web app using Create React App and then **copy the production build to the `server/site` folder**, so
|
||||||
|
that when you `make server` (or `make server-amd64`, ...), you will have the web app included in the `ntfy` binary.
|
||||||
|
|
||||||
|
If you're developing on the web app, it's best to just `cd web` and run `npm start` manually. This will open your browser
|
||||||
|
at `http://127.0.0.1:3000` with the web app, and as you edit the source files, they will be recompiled and the browser
|
||||||
|
will automatically refresh:
|
||||||
|
|
||||||
|
``` shell
|
||||||
|
$ cd web
|
||||||
|
$ npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build the docs
|
||||||
|
The sources for the docs live in `docs/`. Similarly to the web app, you can simply run `make docs` to build the
|
||||||
|
documentation. As long as you have `mkdocs` installed (see above), this should work fine:
|
||||||
|
|
||||||
|
``` shell
|
||||||
|
$ make docs
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
If you are changing the documentation, you should be running `mkdocs serve` directly. This will build the documentation,
|
||||||
|
serve the files at `http://127.0.0.1:8000/`, and rebuild every time you save the source files:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ mkdocs serve
|
||||||
|
INFO - Building documentation...
|
||||||
|
INFO - Cleaning site directory
|
||||||
|
INFO - Documentation built in 5.53 seconds
|
||||||
|
INFO - [16:28:14] Serving on http://127.0.0.1:8000/
|
||||||
|
```
|
||||||
|
|
||||||
|
Then you can navigate to http://127.0.0.1:8000/ and whenever you change a markdown file in your text editor it'll automatically update.
|
||||||
|
|
||||||
## Android app
|
## Android app
|
||||||
The ntfy Android app source code is available [on GitHub](https://github.com/binwiederhier/ntfy-android).
|
The ntfy Android app source code is available [on GitHub](https://github.com/binwiederhier/ntfy-android).
|
||||||
The Android app has two flavors:
|
The Android app has two flavors:
|
||||||
|
|
||||||
* **Google Play:** The `play` flavor includes Firebase (FCM) and requires a Firebase account
|
* **Google Play:** The `play` flavor includes [Firebase (FCM)](https://firebase.google.com/) and requires a Firebase account
|
||||||
* **F-Droid:** The `fdroid` flavor does not include Firebase or Google dependencies
|
* **F-Droid:** The `fdroid` flavor does not include Firebase or Google dependencies
|
||||||
|
|
||||||
|
### Navigating the code
|
||||||
|
* [main/](https://github.com/binwiederhier/ntfy-android/tree/main/app/src/main) - Main Android app source code
|
||||||
|
* [play/](https://github.com/binwiederhier/ntfy-android/tree/main/app/src/play) - Google Play / Firebase specific code
|
||||||
|
* [fdroid/](https://github.com/binwiederhier/ntfy-android/tree/main/app/src/fdroid) - F-Droid Firebase stubs
|
||||||
|
* [build.gradle](https://github.com/binwiederhier/ntfy-android/blob/main/app/build.gradle) - Main build file
|
||||||
|
|
||||||
|
### IDE/Environment
|
||||||
|
You should download [Android Studio](https://developer.android.com/studio) (or [IntelliJ IDEA](https://www.jetbrains.com/idea/)
|
||||||
|
with the relevant Android plugins). Everything else will just be a pain for you. Do yourself a favor. 😀
|
||||||
|
|
||||||
|
### Check out the code
|
||||||
First check out the repository:
|
First check out the repository:
|
||||||
|
|
||||||
```
|
=== "via HTTPS"
|
||||||
git clone git@github.com:binwiederhier/ntfy-android.git
|
``` shell
|
||||||
cd ntfy-android
|
git clone https://github.com/binwiederhier/ntfy-android.git
|
||||||
```
|
cd ntfy-android
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "via SSH"
|
||||||
|
``` shell
|
||||||
|
git clone git@github.com:binwiederhier/ntfy-android.git
|
||||||
|
cd ntfy-android
|
||||||
|
```
|
||||||
|
|
||||||
Then either follow the steps for building with or without Firebase.
|
Then either follow the steps for building with or without Firebase.
|
||||||
|
|
||||||
### Building without Firebase (F-Droid flavor)
|
### Build F-Droid flavor (no FCM)
|
||||||
|
!!! info
|
||||||
|
I do build the ntfy Android app using IntelliJ IDEA (Android Studio), so I don't know if these Gradle commands will
|
||||||
|
work without issues. Please give me feedback if it does/doesn't work for you.
|
||||||
|
|
||||||
Without Firebase, you may want to still change the default `app_base_url` in [strings.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/strings.xml)
|
Without Firebase, you may want to still change the default `app_base_url` in [strings.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/strings.xml)
|
||||||
if you're self-hosting the server. Then run:
|
if you're self-hosting the server. Then run:
|
||||||
```
|
```
|
||||||
@@ -56,8 +292,13 @@ if you're self-hosting the server. Then run:
|
|||||||
./gradlew bundleFdroidRelease
|
./gradlew bundleFdroidRelease
|
||||||
```
|
```
|
||||||
|
|
||||||
### Building with Firebase (FCM, Google Play flavor)
|
### Build Play flavor (FCM)
|
||||||
|
!!! info
|
||||||
|
I do build the ntfy Android app using IntelliJ IDEA (Android Studio), so I don't know if these Gradle commands will
|
||||||
|
work without issues. Please give me feedback if it does/doesn't work for you.
|
||||||
|
|
||||||
To build your own version with Firebase, you must:
|
To build your own version with Firebase, you must:
|
||||||
|
|
||||||
* Create a Firebase/FCM account
|
* Create a Firebase/FCM account
|
||||||
* Place your account file at `app/google-services.json`
|
* Place your account file at `app/google-services.json`
|
||||||
* And change `app_base_url` in [strings.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/strings.xml)
|
* And change `app_base_url` in [strings.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/strings.xml)
|
||||||
|
|||||||
291
docs/examples.md
@@ -16,6 +16,27 @@ rsync -a root@laptop /backups/laptop \
|
|||||||
|| curl -H tags:warning -H prio:high -d "Laptop backup failed" ntfy.sh/backups
|
|| curl -H tags:warning -H prio:high -d "Laptop backup failed" ntfy.sh/backups
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Low disk space alerts
|
||||||
|
Here's a simple cronjob that I use to alert me when the disk space on the root disk is running low. It's simple, but
|
||||||
|
effective.
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
mingigs=10
|
||||||
|
avail=$(df | awk '$6 == "/" && $4 < '$mingigs' * 1024*1024 { print $4/1024/1024 }')
|
||||||
|
topicurl=https://ntfy.sh/mytopic
|
||||||
|
|
||||||
|
if [ -n "$avail" ]; then
|
||||||
|
curl \
|
||||||
|
-d "Only $avail GB available on the root disk. Better clean that up." \
|
||||||
|
-H "Title: Low disk space alert on $(hostname)" \
|
||||||
|
-H "Priority: high" \
|
||||||
|
-H "Tags: warning,cd" \
|
||||||
|
$topicurl
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
## Server-sent messages in your web app
|
## Server-sent messages in your web app
|
||||||
Just as you can [subscribe to topics in the Web UI](subscribe/web.md), you can use ntfy in your own
|
Just as you can [subscribe to topics in the Web UI](subscribe/web.md), you can use ntfy in your own
|
||||||
web application. Check out the <a href="/example.html">live example</a> or just look the source of this page.
|
web application. Check out the <a href="/example.html">live example</a> or just look the source of this page.
|
||||||
@@ -75,3 +96,273 @@ One of my co-workers uses the following Ansible task to let him know when things
|
|||||||
method: POST
|
method: POST
|
||||||
body: "{{ inventory_hostname }} reseeding complete"
|
body: "{{ inventory_hostname }} reseeding complete"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Watchtower notifications (shoutrrr)
|
||||||
|
You can use `shoutrrr` generic webhook support to send watchtower notifications to your ntfy topic.
|
||||||
|
|
||||||
|
Example docker-compose.yml:
|
||||||
|
```yml
|
||||||
|
services:
|
||||||
|
watchtower:
|
||||||
|
image: containrrr/watchtower
|
||||||
|
environment:
|
||||||
|
- WATCHTOWER_NOTIFICATIONS=shoutrrr
|
||||||
|
- WATCHTOWER_NOTIFICATION_URL=generic+https://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates
|
||||||
|
```
|
||||||
|
|
||||||
|
Or, if you only want to send notifications using shoutrrr:
|
||||||
|
```
|
||||||
|
shoutrrr send -u "generic+https://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Random cronjobs
|
||||||
|
Alright, here's one for the history books. I desperately want the `github.com/ntfy` organization, but all my tickets with
|
||||||
|
GitHub have been hopeless. In case it ever becomes available, I want to know immediately.
|
||||||
|
|
||||||
|
``` cron
|
||||||
|
# Check github/ntfy user
|
||||||
|
*/6 * * * * if curl -s https://api.github.com/users/ntfy | grep "Not Found"; then curl -d "github.com/ntfy is available" -H "Tags: tada" -H "Prio: high" ntfy.sh/my-alerts; fi
|
||||||
|
~
|
||||||
|
```
|
||||||
|
|
||||||
|
## Download notifications (Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd)
|
||||||
|
It's possible to use custom scripts for all the *arr services, plus SABnzbd. Notifications for downloads, warnings, grabs etc.
|
||||||
|
Some simple bash scripts to achieve this are kindly provided in [nickexyz's repository](https://github.com/nickexyz/ntfy-shellscripts).
|
||||||
|
|
||||||
|
## Node-RED
|
||||||
|
You can use the HTTP request node to send messages with [Node-RED](https://nodered.org), some examples:
|
||||||
|
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Example: Send a message (click to expand)</summary>
|
||||||
|
|
||||||
|
```
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "c956e688cc74ad8e",
|
||||||
|
"type": "http request",
|
||||||
|
"z": "fabdd7a3.4045a",
|
||||||
|
"name": "ntfy.sh",
|
||||||
|
"method": "POST",
|
||||||
|
"ret": "txt",
|
||||||
|
"paytoqs": "ignore",
|
||||||
|
"url": "https://ntfy.sh/mytopic",
|
||||||
|
"tls": "",
|
||||||
|
"persist": false,
|
||||||
|
"proxy": "",
|
||||||
|
"authType": "",
|
||||||
|
"senderr": false,
|
||||||
|
"credentials":
|
||||||
|
{
|
||||||
|
"user": "",
|
||||||
|
"password": ""
|
||||||
|
},
|
||||||
|
"x": 590,
|
||||||
|
"y": 3160,
|
||||||
|
"wires":
|
||||||
|
[
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "32ee1eade51fae50",
|
||||||
|
"type": "function",
|
||||||
|
"z": "fabdd7a3.4045a",
|
||||||
|
"name": "data",
|
||||||
|
"func": "msg.payload = \"Something happened\";\nmsg.headers = {};\nmsg.headers['tags'] = 'house';\nmsg.headers['X-Title'] = 'Home Assistant';\n\nreturn msg;",
|
||||||
|
"outputs": 1,
|
||||||
|
"noerr": 0,
|
||||||
|
"initialize": "",
|
||||||
|
"finalize": "",
|
||||||
|
"libs": [],
|
||||||
|
"x": 470,
|
||||||
|
"y": 3160,
|
||||||
|
"wires":
|
||||||
|
[
|
||||||
|
[
|
||||||
|
"c956e688cc74ad8e"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b287e59cd2311815",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "fabdd7a3.4045a",
|
||||||
|
"name": "Manual start",
|
||||||
|
"props":
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"p": "payload"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "topic",
|
||||||
|
"vt": "str"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"onceDelay": "20",
|
||||||
|
"topic": "",
|
||||||
|
"payload": "",
|
||||||
|
"payloadType": "date",
|
||||||
|
"x": 330,
|
||||||
|
"y": 3160,
|
||||||
|
"wires":
|
||||||
|
[
|
||||||
|
[
|
||||||
|
"32ee1eade51fae50"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
## Gatus service health check
|
||||||
|
|
||||||
|
An example for a custom alert with <a href="https://github.com/TwiN/gatus">Gatus</a>
|
||||||
|
```
|
||||||
|
alerting:
|
||||||
|
custom:
|
||||||
|
url: "https://ntfy.sh"
|
||||||
|
method: "POST"
|
||||||
|
body: |
|
||||||
|
{
|
||||||
|
"topic": "mytopic",
|
||||||
|
"message": "[ENDPOINT_NAME] - [ALERT_DESCRIPTION]",
|
||||||
|
"title": "Gatus",
|
||||||
|
"tags": ["[ALERT_TRIGGERED_OR_RESOLVED]"],
|
||||||
|
"priority": 3
|
||||||
|
}
|
||||||
|
default-alert:
|
||||||
|
enabled: true
|
||||||
|
description: "health check failed"
|
||||||
|
send-on-resolved: true
|
||||||
|
failure-threshold: 3
|
||||||
|
success-threshold: 3
|
||||||
|
placeholders:
|
||||||
|
ALERT_TRIGGERED_OR_RESOLVED:
|
||||||
|
TRIGGERED: "warning"
|
||||||
|
RESOLVED: "white_check_mark"
|
||||||
|
```
|
||||||
|
|||||||
@@ -33,10 +33,11 @@ If you do not care for Firebase, I suggest you install the [F-Droid version](htt
|
|||||||
of the app and [self-host your own ntfy server](install.md).
|
of the app and [self-host your own ntfy server](install.md).
|
||||||
|
|
||||||
## How much battery does the Android app use?
|
## How much battery does the Android app use?
|
||||||
If you use the ntfy.sh server and you don't use the [instant delivery](subscribe/phone.md#instant-delivery) feature,
|
If you use the ntfy.sh server, and you don't use the [instant delivery](subscribe/phone.md#instant-delivery) feature,
|
||||||
the Android app uses no additional battery, since Firebase Cloud Messaging (FCM) is used. If you use your own server,
|
the Android app uses no additional battery, since Firebase Cloud Messaging (FCM) is used. If you use your own server,
|
||||||
or you use *instant delivery*, the app has to maintain a constant connection to the server, which consumes about 4% of
|
or you use *instant delivery*, the app has to maintain a constant connection to the server, which consumes about 0-1% of
|
||||||
battery in 17h of use (on my phone). I use it, and it makes no difference to me.
|
battery in 17h of use (on my phone). There has been a ton of testing and improvement around this. I think it's pretty
|
||||||
|
decent now.
|
||||||
|
|
||||||
## What is instant delivery?
|
## What is instant delivery?
|
||||||
[Instant delivery](subscribe/phone.md#instant-delivery) is a feature in the Android app. If turned on, the app maintains a constant connection to the
|
[Instant delivery](subscribe/phone.md#instant-delivery) is a feature in the Android app. If turned on, the app maintains a constant connection to the
|
||||||
|
|||||||
@@ -26,23 +26,38 @@ deb/rpm packages.
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.1/ntfy_1.12.1_linux_x86_64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.22.0/ntfy_1.22.0_linux_x86_64.tar.gz
|
||||||
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
|
tar zxvf ntfy_1.22.0_linux_x86_64.tar.gz
|
||||||
sudo ./ntfy serve
|
sudo cp -a ntfy_1.22.0_linux_x86_64/ntfy /usr/bin/ntfy
|
||||||
|
sudo mkdir /etc/ntfy && sudo cp ntfy_1.22.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
||||||
|
sudo ntfy serve
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "armv6"
|
||||||
|
```bash
|
||||||
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.22.0/ntfy_1.22.0_linux_armv6.tar.gz
|
||||||
|
tar zxvf ntfy_1.22.0_linux_armv6.tar.gz
|
||||||
|
sudo cp -a ntfy_1.22.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||||
|
sudo mkdir /etc/ntfy && sudo cp ntfy_1.22.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||||
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.1/ntfy_1.12.1_linux_armv7.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.22.0/ntfy_1.22.0_linux_armv7.tar.gz
|
||||||
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
|
tar zxvf ntfy_1.22.0_linux_armv7.tar.gz
|
||||||
sudo ./ntfy serve
|
sudo cp -a ntfy_1.22.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||||
|
sudo mkdir /etc/ntfy && sudo cp ntfy_1.22.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||||
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.1/ntfy_1.12.1_linux_arm64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.22.0/ntfy_1.22.0_linux_arm64.tar.gz
|
||||||
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
|
tar zxvf ntfy_1.22.0_linux_arm64.tar.gz
|
||||||
sudo ./ntfy serve
|
sudo cp -a ntfy_1.22.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||||
|
sudo mkdir /etc/ntfy && sudo cp ntfy_1.22.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||||
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
## Debian/Ubuntu repository
|
## Debian/Ubuntu repository
|
||||||
@@ -88,7 +103,15 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.1/ntfy_1.12.1_linux_amd64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.22.0/ntfy_1.22.0_linux_amd64.deb
|
||||||
|
sudo dpkg -i ntfy_*.deb
|
||||||
|
sudo systemctl enable ntfy
|
||||||
|
sudo systemctl start ntfy
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "armv6"
|
||||||
|
```bash
|
||||||
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.22.0/ntfy_1.22.0_linux_armv6.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -96,7 +119,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.1/ntfy_1.12.1_linux_armv7.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.22.0/ntfy_1.22.0_linux_armv7.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -104,7 +127,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.1/ntfy_1.12.1_linux_arm64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.22.0/ntfy_1.22.0_linux_arm64.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -114,21 +137,28 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.12.1/ntfy_1.12.1_linux_amd64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.22.0/ntfy_1.22.0_linux_amd64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
|
=== "armv6"
|
||||||
|
```bash
|
||||||
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.22.0/ntfy_1.22.0_linux_armv6.rpm
|
||||||
|
sudo systemctl enable ntfy
|
||||||
|
sudo systemctl start ntfy
|
||||||
|
```
|
||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.12.1/ntfy_1.12.1_linux_armv7.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.22.0/ntfy_1.22.0_linux_armv7.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.12.1/ntfy_1.12.1_linux_arm64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.22.0/ntfy_1.22.0_linux_arm64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
@@ -146,10 +176,9 @@ cd ntfysh-bin
|
|||||||
makepkg -si
|
makepkg -si
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv7 and arm64. It should be pretty
|
The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv6, armv7 and arm64. It should
|
||||||
straight forward to use.
|
be pretty straight forward to use.
|
||||||
|
|
||||||
The server exposes its web UI and the API on port 80, so you need to expose that in Docker. To use the persistent
|
The server exposes its web UI and the API on port 80, so you need to expose that in Docker. To use the persistent
|
||||||
[message cache](config.md#message-cache), you also need to map a volume to `/var/cache/ntfy`. To change other settings,
|
[message cache](config.md#message-cache), you also need to map a volume to `/var/cache/ntfy`. To change other settings,
|
||||||
@@ -181,6 +210,24 @@ docker run \
|
|||||||
serve
|
serve
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Using docker-compose:
|
||||||
|
```yaml
|
||||||
|
version: "2.1"
|
||||||
|
|
||||||
|
services:
|
||||||
|
ntfy:
|
||||||
|
image: binwiederhier/ntfy
|
||||||
|
container_name: ntfy
|
||||||
|
command:
|
||||||
|
- serve
|
||||||
|
volumes:
|
||||||
|
- /var/cache/ntfy:/var/cache/ntfy
|
||||||
|
- /etc/ntfy:/etc/ntfy
|
||||||
|
ports:
|
||||||
|
- 80:80
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
Alternatively, you may wish to build a customized Docker image that can be run with fewer command-line arguments and without delivering the configuration file separately.
|
Alternatively, you may wish to build a customized Docker image that can be run with fewer command-line arguments and without delivering the configuration file separately.
|
||||||
```
|
```
|
||||||
FROM binwiederhier/ntfy
|
FROM binwiederhier/ntfy
|
||||||
@@ -188,13 +235,3 @@ COPY server.yml /etc/ntfy/server.yml
|
|||||||
ENTRYPOINT ["ntfy", "serve"]
|
ENTRYPOINT ["ntfy", "serve"]
|
||||||
```
|
```
|
||||||
This image can be pushed to a container registry and shipped independently. All that's needed when running it is mapping ntfy's port to a host port.
|
This image can be pushed to a container registry and shipped independently. All that's needed when running it is mapping ntfy's port to a host port.
|
||||||
|
|
||||||
## Go
|
|
||||||
To install via Go, simply run:
|
|
||||||
```bash
|
|
||||||
go install heckel.io/ntfy@latest
|
|
||||||
```
|
|
||||||
|
|
||||||
!!! info
|
|
||||||
Please [let me know](https://github.com/binwiederhier/ntfy/issues) if there are any issues with this installation
|
|
||||||
method. The SQLite bindings require CGO and it works for me, but I have the feeling it may not work for everyone.
|
|
||||||
|
|||||||
1693
docs/publish.md
495
docs/releases.md
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
# Release notes
|
||||||
|
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 Android app v1.13.0 (UNRELEASED)
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Cards in notification detail view ([#175](https://github.com/binwiederhier/ntfy/issues/224), thanks to [@cmeis](https://github.com/cmeis) for reporting)
|
||||||
|
|
||||||
|
**Bugs:**
|
||||||
|
|
||||||
|
* Accurate naming of "mute notifications" from "pause notifications" ([#224](https://github.com/binwiederhier/ntfy/issues/224), thanks to [@shadow00](https://github.com/shadow00) for reporting)
|
||||||
|
* Make messages with links selectable ([#226](https://github.com/binwiederhier/ntfy/issues/226), thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting)
|
||||||
|
* Restoring topics or settings from backup doesn't work ([#223](https://github.com/binwiederhier/ntfy/issues/223), thanks to [@shadow00](https://github.com/shadow00) for reporting)
|
||||||
|
* Fix app icon on old Android versions ([#128](https://github.com/binwiederhier/ntfy/issues/128), thanks to [@shadow00](https://github.com/shadow00) for reporting)
|
||||||
|
* Fix races in UnifiedPush registration ([#230](https://github.com/binwiederhier/ntfy/issues/230), thanks to @Jakob for reporting)
|
||||||
|
* Prevent view action from crashing the app ([#233](https://github.com/binwiederhier/ntfy/issues/233))
|
||||||
|
* Prevent long topic names and icons from overlappng ([#240](https://github.com/binwiederhier/ntfy/issues/240), thanks to [@cmeis](https://github.com/cmeis) for reporting)
|
||||||
|
|
||||||
|
**Additional translations:**
|
||||||
|
|
||||||
|
* Dutch (*incomplete*, thanks to [@diony](https://hosted.weblate.org/user/diony/))
|
||||||
|
|
||||||
|
**Thanks for testing:**
|
||||||
|
|
||||||
|
Thanks to [@cmeis](https://github.com/cmeis), [@StoyanDimitrov](https://github.com/StoyanDimitrov), [@Fallenbagel](https://github.com/Fallenbagel) for testing, and
|
||||||
|
to [@Joeharrison94](https://github.com/Joeharrison94) for the input.
|
||||||
|
|
||||||
|
-->
|
||||||
|
|
||||||
|
## ntfy server v1.22.0
|
||||||
|
Released May 7, 2022
|
||||||
|
|
||||||
|
This release makes the web app more accessible to people with disabilities, and introduces a "mark as read" icon in the web app.
|
||||||
|
It also fixes a curious bug with WebSockets and Apache and makes the notification sounds in the web app a little quieter.
|
||||||
|
|
||||||
|
We've also improved the documentation a little and added translations for three more languages.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Make web app more accessible ([#217](https://github.com/binwiederhier/ntfy/issues/217))
|
||||||
|
* Better parsing of the user actions, allowing quotes (no ticket)
|
||||||
|
* Add "mark as read" icon button to notification ([#243](https://github.com/binwiederhier/ntfy/pull/243), thanks to [@wunter8](https://github.com/wunter8))
|
||||||
|
|
||||||
|
**Bugs:**
|
||||||
|
|
||||||
|
* `Upgrade` header check is now case in-sensitive ([#228](https://github.com/binwiederhier/ntfy/issues/228), thanks to [@wunter8](https://github.com/wunter8) for finding it)
|
||||||
|
* Made web app sounds quieter ([#222](https://github.com/binwiederhier/ntfy/issues/222))
|
||||||
|
* Add "private browsing"-specific error message for Firefox/Safari ([#208](https://github.com/binwiederhier/ntfy/issues/208), thanks to [@julianfoad](https://github.com/julianfoad) for reporting)
|
||||||
|
|
||||||
|
**Documentation:**
|
||||||
|
|
||||||
|
* Improved caddy configuration (no ticket, thanks to @Stnby)
|
||||||
|
* Additional multi-line examples on the [publish page](https://ntfy.sh/docs/publish/) ([#234](https://github.com/binwiederhier/ntfy/pull/234), thanks to [@aTable](https://github.com/aTable))
|
||||||
|
* Fixed PowerShell auth example to use UTF-8 ([#242](https://github.com/binwiederhier/ntfy/pull/242), thanks to [@SMAW](https://github.com/SMAW))
|
||||||
|
|
||||||
|
**Additional translations:**
|
||||||
|
|
||||||
|
* Czech (thanks to [@waclaw66](https://hosted.weblate.org/user/waclaw66/))
|
||||||
|
* French (thanks to [@nathanaelhoun](https://hosted.weblate.org/user/nathanaelhoun/))
|
||||||
|
* Hungarian (thanks to [@agocsdaniel](https://hosted.weblate.org/user/agocsdaniel/))
|
||||||
|
|
||||||
|
**Thanks for testing:**
|
||||||
|
|
||||||
|
Thanks to [@wunter8](https://github.com/wunter8) for testing.
|
||||||
|
|
||||||
|
## ntfy Android app v1.12.0
|
||||||
|
Released Apr 25, 2022
|
||||||
|
|
||||||
|
The main feature in this Android release is [Action Buttons](https://ntfy.sh/docs/publish/#action-buttons), a feature
|
||||||
|
that allows users to add actions to the notifications. Actions can be to view a website or app, send a broadcast, or
|
||||||
|
send a HTTP request.
|
||||||
|
|
||||||
|
We also added support for [ntfy:// deep links](https://ntfy.sh/docs/subscribe/phone/#ntfy-links), added three more
|
||||||
|
languages and fixed a ton of bugs.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Custom notification [action buttons](https://ntfy.sh/docs/publish/#action-buttons) ([#134](https://github.com/binwiederhier/ntfy/issues/134),
|
||||||
|
thanks to [@mrherman](https://github.com/mrherman) for reporting)
|
||||||
|
* Support for [ntfy:// deep links](https://ntfy.sh/docs/subscribe/phone/#ntfy-links) ([#20](https://github.com/binwiederhier/ntfy/issues/20), thanks
|
||||||
|
to [@Copephobia](https://github.com/Copephobia) for reporting)
|
||||||
|
* [Fastlane metadata](https://hosted.weblate.org/projects/ntfy/android-fastlane/) can now be translated too ([#198](https://github.com/binwiederhier/ntfy/issues/198),
|
||||||
|
thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting)
|
||||||
|
* Channel settings option to configure DND override, sounds, etc. ([#91](https://github.com/binwiederhier/ntfy/issues/91))
|
||||||
|
|
||||||
|
**Bugs:**
|
||||||
|
|
||||||
|
* Validate URLs when changing default server and server in user management ([#193](https://github.com/binwiederhier/ntfy/issues/193),
|
||||||
|
thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting)
|
||||||
|
* Error in sending test notification in different languages ([#209](https://github.com/binwiederhier/ntfy/issues/209),
|
||||||
|
thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting)
|
||||||
|
* "[x] Instant delivery in doze mode" checkbox does not work properly ([#211](https://github.com/binwiederhier/ntfy/issues/211))
|
||||||
|
* Disallow "http" GET/HEAD actions with body ([#221](https://github.com/binwiederhier/ntfy/issues/221), thanks to
|
||||||
|
[@cmeis](https://github.com/cmeis) for reporting)
|
||||||
|
* Action "view" with "clear=true" does not work on some phones ([#220](https://github.com/binwiederhier/ntfy/issues/220), thanks to
|
||||||
|
[@cmeis](https://github.com/cmeis) for reporting)
|
||||||
|
* Do not group foreground service notification with others ([#219](https://github.com/binwiederhier/ntfy/issues/219), thanks to
|
||||||
|
[@s-h-a-r-d](https://github.com/s-h-a-r-d) for reporting)
|
||||||
|
|
||||||
|
**Additional translations:**
|
||||||
|
|
||||||
|
* Czech (thanks to [@waclaw66](https://hosted.weblate.org/user/waclaw66/))
|
||||||
|
* French (thanks to [@nathanaelhoun](https://hosted.weblate.org/user/nathanaelhoun/))
|
||||||
|
* Japanese (thanks to [@shak](https://hosted.weblate.org/user/shak/))
|
||||||
|
* Russian (thanks to [@flamey](https://hosted.weblate.org/user/flamey/) and [@ilya.mikheev.coder](https://hosted.weblate.org/user/ilya.mikheev.coder/))
|
||||||
|
|
||||||
|
**Thanks for testing:**
|
||||||
|
|
||||||
|
Thanks to [@s-h-a-r-d](https://github.com/s-h-a-r-d) (aka @Shard), [@cmeis](https://github.com/cmeis),
|
||||||
|
@poblabs, and everyone I forgot for testing.
|
||||||
|
|
||||||
|
## ntfy server v1.21.2
|
||||||
|
Released Apr 24, 2022
|
||||||
|
|
||||||
|
In this release, the web app got translation support and was translated into 9 languages already 🇧🇬 🇩🇪 🇺🇸 🌎.
|
||||||
|
It also re-adds support for ARMv6, and adds server-side support for Action Buttons. [Action Buttons](https://ntfy.sh/docs/publish/#action-buttons)
|
||||||
|
is a feature that will be released in the Android app soon. It allows users to add actions to the notifications.
|
||||||
|
Limited support is available in the web app.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Custom notification [action buttons](https://ntfy.sh/docs/publish/#action-buttons) ([#134](https://github.com/binwiederhier/ntfy/issues/134),
|
||||||
|
thanks to [@mrherman](https://github.com/mrherman) for reporting)
|
||||||
|
* Added ARMv6 build ([#200](https://github.com/binwiederhier/ntfy/issues/200), thanks to [@jcrubioa](https://github.com/jcrubioa) for reporting)
|
||||||
|
* Web app internationalization support 🇧🇬 🇩🇪 🇺🇸 🌎 ([#189](https://github.com/binwiederhier/ntfy/issues/189))
|
||||||
|
|
||||||
|
**Bugs:**
|
||||||
|
|
||||||
|
* Web app: English language strings fixes, additional descriptions for settings ([#203](https://github.com/binwiederhier/ntfy/issues/203), thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov))
|
||||||
|
* Web app: Show error message snackbar when sending test notification fails ([#205](https://github.com/binwiederhier/ntfy/issues/205), thanks to [@cmeis](https://github.com/cmeis))
|
||||||
|
* Web app: basic URL validation in user management ([#204](https://github.com/binwiederhier/ntfy/issues/204), thanks to [@cmeis](https://github.com/cmeis))
|
||||||
|
* Disallow "http" GET/HEAD actions with body ([#221](https://github.com/binwiederhier/ntfy/issues/221), thanks to
|
||||||
|
[@cmeis](https://github.com/cmeis) for reporting)
|
||||||
|
|
||||||
|
**Translations (web app):**
|
||||||
|
|
||||||
|
* Bulgarian (thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov))
|
||||||
|
* German (thanks to [@cmeis](https://github.com/cmeis))
|
||||||
|
* Indonesian (thanks to [@linerly](https://hosted.weblate.org/user/linerly/))
|
||||||
|
* Japanese (thanks to [@shak](https://hosted.weblate.org/user/shak/))
|
||||||
|
* Norwegian Bokmål (thanks to [@comradekingu](https://github.com/comradekingu))
|
||||||
|
* Russian (thanks to [@flamey](https://hosted.weblate.org/user/flamey/) and [@ilya.mikheev.coder](https://hosted.weblate.org/user/ilya.mikheev.coder/))
|
||||||
|
* Spanish (thanks to [@rogeliodh](https://github.com/rogeliodh))
|
||||||
|
* Turkish (thanks to [@ersen](https://ersen.moe/))
|
||||||
|
|
||||||
|
**Integrations:**
|
||||||
|
|
||||||
|
[Apprise](https://github.com/caronc/apprise) support was fully released in [v0.9.8.2](https://github.com/caronc/apprise/releases/tag/v0.9.8.2)
|
||||||
|
of Apprise. Thanks to [@particledecay](https://github.com/particledecay) and [@caronc](https://github.com/caronc) for their fantastic work.
|
||||||
|
You can try it yourself like this (detailed usage in the [Apprise wiki](https://github.com/caronc/apprise/wiki/Notify_ntfy)):
|
||||||
|
|
||||||
|
```
|
||||||
|
pip3 install apprise
|
||||||
|
apprise -b "Hi there" ntfys://mytopic
|
||||||
|
```
|
||||||
|
|
||||||
|
## ntfy Android app v1.11.0
|
||||||
|
Released Apr 7, 2022
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Download attachments to cache folder ([#181](https://github.com/binwiederhier/ntfy/issues/181))
|
||||||
|
* Regularly delete attachments for deleted notifications ([#142](https://github.com/binwiederhier/ntfy/issues/142))
|
||||||
|
* Translations to different languages ([#188](https://github.com/binwiederhier/ntfy/issues/188), thanks to
|
||||||
|
[@StoyanDimitrov](https://github.com/StoyanDimitrov) for initiating things)
|
||||||
|
|
||||||
|
**Bugs:**
|
||||||
|
|
||||||
|
* IllegalStateException: Failed to build unique file ([#177](https://github.com/binwiederhier/ntfy/issues/177), thanks to [@Fallenbagel](https://github.com/Fallenbagel) for reporting)
|
||||||
|
* SQLiteConstraintException: Crash during UP registration ([#185](https://github.com/binwiederhier/ntfy/issues/185))
|
||||||
|
* Refresh preferences screen after settings import (#183, thanks to [@cmeis](https://github.com/cmeis) for reporting)
|
||||||
|
* Add priority strings to strings.xml to make it translatable (#192, thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov))
|
||||||
|
|
||||||
|
**Translations:**
|
||||||
|
|
||||||
|
* English language improvements (thanks to [@comradekingu](https://github.com/comradekingu))
|
||||||
|
* Bulgarian (thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov))
|
||||||
|
* Chinese/Simplified (thanks to [@poi](https://hosted.weblate.org/user/poi) and [@PeterCxy](https://hosted.weblate.org/user/PeterCxy))
|
||||||
|
* Dutch (*incomplete*, thanks to [@diony](https://hosted.weblate.org/user/diony))
|
||||||
|
* French (thanks to [@Kusoneko](https://kusoneko.moe/) and [@mlcsthor](https://hosted.weblate.org/user/mlcsthor/))
|
||||||
|
* German (thanks to [@cmeis](https://github.com/cmeis))
|
||||||
|
* Italian (thanks to [@theTranslator](https://hosted.weblate.org/user/theTranslator/))
|
||||||
|
* Indonesian (thanks to [@linerly](https://hosted.weblate.org/user/linerly/))
|
||||||
|
* Norwegian Bokmål (*incomplete*, thanks to [@comradekingu](https://github.com/comradekingu))
|
||||||
|
* Portuguese/Brazil (thanks to [@LW](https://hosted.weblate.org/user/LW/))
|
||||||
|
* Spanish (thanks to [@rogeliodh](https://github.com/rogeliodh))
|
||||||
|
* Turkish (thanks to [@ersen](https://ersen.moe/))
|
||||||
|
|
||||||
|
**Thanks:**
|
||||||
|
|
||||||
|
* Many thanks to [@cmeis](https://github.com/cmeis), [@Fallenbagel](https://github.com/Fallenbagel), [@Joeharrison94](https://github.com/Joeharrison94),
|
||||||
|
and [@rogeliodh](https://github.com/rogeliodh) for input on the new attachment logic, and for testing the release
|
||||||
|
|
||||||
|
## ntfy server v1.20.0
|
||||||
|
Released Apr 6, 2022
|
||||||
|
|
||||||
|
**Features:**:
|
||||||
|
|
||||||
|
* Added message bar and publish dialog ([#196](https://github.com/binwiederhier/ntfy/issues/196))
|
||||||
|
|
||||||
|
**Bugs:**
|
||||||
|
|
||||||
|
* Added `EXPOSE 80/tcp` to Dockerfile to support auto-discovery in [Traefik](https://traefik.io/) ([#195](https://github.com/binwiederhier/ntfy/issues/195), thanks to [@s-h-a-r-d](https://github.com/s-h-a-r-d))
|
||||||
|
|
||||||
|
**Documentation:**
|
||||||
|
|
||||||
|
* Added docker-compose example to [install instructions](install.md#docker) ([#194](https://github.com/binwiederhier/ntfy/pull/194), thanks to [@s-h-a-r-d](https://github.com/s-h-a-r-d))
|
||||||
|
|
||||||
|
**Integrations:**
|
||||||
|
|
||||||
|
* [Apprise](https://github.com/caronc/apprise) has added integration into ntfy ([#99](https://github.com/binwiederhier/ntfy/issues/99), [apprise#524](https://github.com/caronc/apprise/pull/524),
|
||||||
|
thanks to [@particledecay](https://github.com/particledecay) and [@caronc](https://github.com/caronc) for their fantastic work)
|
||||||
|
|
||||||
|
## ntfy server v1.19.0
|
||||||
|
Released Mar 30, 2022
|
||||||
|
|
||||||
|
**Bugs:**
|
||||||
|
|
||||||
|
* Do not pack binary with `upx` for armv7/arm64 due to `illegal instruction` errors ([#191](https://github.com/binwiederhier/ntfy/issues/191), thanks to [@iexos](https://github.com/iexos))
|
||||||
|
* Do not allow comma in topic name in publish via GET endpoint (no ticket)
|
||||||
|
* Add "Access-Control-Allow-Origin: *" for attachments (no ticket, thanks to @FrameXX)
|
||||||
|
* Make pruning run again in web app ([#186](https://github.com/binwiederhier/ntfy/issues/186))
|
||||||
|
* Added missing params `delay` and `email` to publish as JSON body (no ticket)
|
||||||
|
|
||||||
|
**Documentation:**
|
||||||
|
|
||||||
|
* Improved [e-mail publishing](config.md#e-mail-publishing) documentation
|
||||||
|
|
||||||
|
## ntfy server v1.18.1
|
||||||
|
Released Mar 21, 2022
|
||||||
|
_This release ships no features or bug fixes. It's merely a documentation update._
|
||||||
|
|
||||||
|
**Documentation:**
|
||||||
|
|
||||||
|
* Overhaul of [developer documentation](https://ntfy.sh/docs/develop/)
|
||||||
|
* PowerShell examples for [publish documentation](https://ntfy.sh/docs/publish/) ([#138](https://github.com/binwiederhier/ntfy/issues/138), thanks to [@Joeharrison94](https://github.com/Joeharrison94))
|
||||||
|
* Additional examples for [NodeRED, Gatus, Sonarr, Radarr, ...](https://ntfy.sh/docs/examples/) (thanks to [@nickexyz](https://github.com/nickexyz))
|
||||||
|
* Fixes in developer instructions (thanks to [@Fallenbagel](https://github.com/Fallenbagel) for reporting)
|
||||||
|
|
||||||
|
## ntfy Android app v1.10.0
|
||||||
|
Released Mar 21, 2022
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Support for UnifiedPush 2.0 specification (bytes messages, [#130](https://github.com/binwiederhier/ntfy/issues/130))
|
||||||
|
* Export/import settings and subscriptions ([#115](https://github.com/binwiederhier/ntfy/issues/115), thanks [@cmeis](https://github.com/cmeis) for reporting)
|
||||||
|
* Open "Click" link when tapping notification ([#110](https://github.com/binwiederhier/ntfy/issues/110), thanks [@cmeis](https://github.com/cmeis) for reporting)
|
||||||
|
* JSON stream deprecation banner ([#164](https://github.com/binwiederhier/ntfy/issues/164))
|
||||||
|
|
||||||
|
**Bug fixes:**
|
||||||
|
|
||||||
|
* Display locale-specific times, with AM/PM or 24h format ([#140](https://github.com/binwiederhier/ntfy/issues/140), thanks [@hl2guide](https://github.com/hl2guide) for reporting)
|
||||||
|
|
||||||
|
## ntfy server v1.18.0
|
||||||
|
Released Mar 16, 2022
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* [Publish messages as JSON](https://ntfy.sh/docs/publish/#publish-as-json) ([#133](https://github.com/binwiederhier/ntfy/issues/133),
|
||||||
|
thanks [@cmeis](https://github.com/cmeis) for reporting, thanks to [@Joeharrison94](https://github.com/Joeharrison94) and
|
||||||
|
[@Fallenbagel](https://github.com/Fallenbagel) for testing)
|
||||||
|
|
||||||
|
**Bug fixes:**
|
||||||
|
|
||||||
|
* rpm: do not overwrite server.yaml on package upgrade ([#166](https://github.com/binwiederhier/ntfy/issues/166), thanks [@waclaw66](https://github.com/waclaw66) for reporting)
|
||||||
|
* Typo in [ntfy.sh/announcements](https://ntfy.sh/announcements) topic ([#170](https://github.com/binwiederhier/ntfy/pull/170), thanks to [@sandebert](https://github.com/sandebert))
|
||||||
|
* Readme image URL fixes ([#156](https://github.com/binwiederhier/ntfy/pull/156), thanks to [@ChaseCares](https://github.com/ChaseCares))
|
||||||
|
|
||||||
|
**Deprecations:**
|
||||||
|
|
||||||
|
* Removed the ability to run server as `ntfy` (as opposed to `ntfy serve`) as per [deprecation](deprecations.md)
|
||||||
|
|
||||||
|
## ntfy server v1.17.1
|
||||||
|
Released Mar 12, 2022
|
||||||
|
|
||||||
|
**Bug fixes:**
|
||||||
|
|
||||||
|
* Replace `crypto.subtle` with `hashCode` to errors with Brave/FF-Windows (#157, thanks for reporting @arminus)
|
||||||
|
|
||||||
|
## ntfy server v1.17.0
|
||||||
|
Released Mar 11, 2022
|
||||||
|
|
||||||
|
**Features & bug fixes:**
|
||||||
|
|
||||||
|
* Replace [web app](https://ntfy.sh/app) with a React/MUI-based web app from the 21st century (#111)
|
||||||
|
* Web UI broken with auth (#132, thanks for reporting @arminus)
|
||||||
|
* Send static web resources as `Content-Encoding: gzip`, i.e. docs and web app (no ticket)
|
||||||
|
* Add support for auth via `?auth=...` query param, used by WebSocket in web app (no ticket)
|
||||||
|
|
||||||
|
## ntfy server v1.16.0
|
||||||
|
Released Feb 27, 2022
|
||||||
|
|
||||||
|
**Features & Bug fixes:**
|
||||||
|
|
||||||
|
* Add [auth support](https://ntfy.sh/docs/subscribe/cli/#authentication) for subscribing with CLI (#147/#148, thanks @lrabane)
|
||||||
|
* Add support for [?since=<id>](https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages) (#151, thanks for reporting @nachotp)
|
||||||
|
|
||||||
|
**Documentation:**
|
||||||
|
|
||||||
|
* Add [watchtower/shoutrr examples](https://ntfy.sh/docs/examples/#watchtower-notifications-shoutrrr) (#150, thanks @rogeliodh)
|
||||||
|
* Add [release notes](https://ntfy.sh/docs/releases/)
|
||||||
|
|
||||||
|
**Technical notes:**
|
||||||
|
|
||||||
|
* As of this release, message IDs will be 12 characters long (as opposed to 10 characters). This is to be able to
|
||||||
|
distinguish them from Unix timestamps for #151.
|
||||||
|
|
||||||
|
## ntfy Android app v1.9.1
|
||||||
|
Released Feb 16, 2022
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Share to topic feature (#131, thanks u/emptymatrix for reporting)
|
||||||
|
* Ability to pick a default server (#127, thanks to @poblabs for reporting and testing)
|
||||||
|
* Automatically delete notifications (#71, thanks @arjan-s for reporting)
|
||||||
|
* Dark theme: Improvements around style and contrast (#119, thanks @kzshantonu for reporting)
|
||||||
|
|
||||||
|
**Bug fixes:**
|
||||||
|
|
||||||
|
* Do not attempt to download attachments if they are already expired (#135)
|
||||||
|
* Fixed crash in AddFragment as seen per stack trace in Play Console (no ticket)
|
||||||
|
|
||||||
|
**Other thanks:**
|
||||||
|
|
||||||
|
* Thanks to @rogeliodh, @cmeis and @poblabs for testing
|
||||||
|
|
||||||
|
## ntfy server v1.15.0
|
||||||
|
Released Feb 14, 2022
|
||||||
|
|
||||||
|
**Features & bug fixes:**
|
||||||
|
|
||||||
|
* Compress binaries with `upx` (#137)
|
||||||
|
* Add `visitor-request-limit-exempt-hosts` to exempt friendly hosts from rate limits (#144)
|
||||||
|
* Double default requests per second limit from 1 per 10s to 1 per 5s (no ticket)
|
||||||
|
* Convert `\n` to new line for `X-Message` header as prep for sharing feature (see #136)
|
||||||
|
* Reduce bcrypt cost to 10 to make auth timing more reasonable on slow servers (no ticket)
|
||||||
|
* Docs update to include [public test topics](https://ntfy.sh/docs/publish/#public-topics) (no ticket)
|
||||||
|
|
||||||
|
## ntfy server v1.14.1
|
||||||
|
Released Feb 9, 2022
|
||||||
|
|
||||||
|
**Bug fixes:**
|
||||||
|
|
||||||
|
* Fix ARMv8 Docker build (#113, thanks to @djmaze)
|
||||||
|
* No other significant changes
|
||||||
|
|
||||||
|
## ntfy Android app v1.8.1
|
||||||
|
Released Feb 6, 2022
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Support [auth / access control](https://ntfy.sh/docs/config/#access-control) (#19, thanks to @cmeis, @drsprite/@poblabs,
|
||||||
|
@gedw99, @karmanyaahm, @Mek101, @gc-ss, @julianfoad, @nmoseman, Jakob, PeterCxy, Techlosopher)
|
||||||
|
* Export/upload log now allows censored/uncensored logs (no ticket)
|
||||||
|
* Removed wake lock (except for notification dispatching, no ticket)
|
||||||
|
* Swipe to remove notifications (#117)
|
||||||
|
|
||||||
|
**Bug fixes:**
|
||||||
|
|
||||||
|
* Fix download issues on SDK 29 "Movement not allowed" (#116, thanks Jakob)
|
||||||
|
* Fix for Android 12 crashes (#124, thanks @eskilop)
|
||||||
|
* Fix WebSocket retry logic bug with multiple servers (no ticket)
|
||||||
|
* Fix race in refresh logic leading to duplicate connections (no ticket)
|
||||||
|
* Fix scrolling issue in subscribe to topic dialog (#131, thanks @arminus)
|
||||||
|
* Fix base URL text field color in dark mode, and size with large fonts (no ticket)
|
||||||
|
* Fix action bar color in dark mode (make black, no ticket)
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
|
||||||
|
* Foundational work for per-subscription settings
|
||||||
|
|
||||||
|
## ntfy server v1.14.0
|
||||||
|
Released Feb 3, 2022
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
|
||||||
|
* Server-side for [authentication & authorization](https://ntfy.sh/docs/config/#access-control) (#19, thanks for testing @cmeis, and for input from @gedw99, @karmanyaahm, @Mek101, @gc-ss, @julianfoad, @nmoseman, Jakob, PeterCxy, Techlosopher)
|
||||||
|
* Support `NTFY_TOPIC` env variable in `ntfy publish` (#103)
|
||||||
|
|
||||||
|
**Bug fixes**:
|
||||||
|
|
||||||
|
* Binary UnifiedPush messages should not be converted to attachments (part 1, #101)
|
||||||
|
|
||||||
|
**Docs**:
|
||||||
|
|
||||||
|
* Clarification regarding attachments (#118, thanks @xnumad)
|
||||||
|
|
||||||
|
## ntfy Android app v1.7.1
|
||||||
|
Released Jan 21, 2022
|
||||||
|
|
||||||
|
**New features:**
|
||||||
|
|
||||||
|
* Battery improvements: wakelock disabled by default (#76)
|
||||||
|
* Dark mode: Allow changing app appearance (#102)
|
||||||
|
* Report logs: Copy/export logs to help troubleshooting (#94)
|
||||||
|
* WebSockets (experimental): Use WebSockets to subscribe to topics (#96, #100, #97)
|
||||||
|
* Show battery optimization banner (#105)
|
||||||
|
|
||||||
|
**Bug fixes:**
|
||||||
|
|
||||||
|
* (Partial) support for binary UnifiedPush messages (#101)
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
|
||||||
|
* The foreground wakelock is now disabled by default
|
||||||
|
* The service restarter is now scheduled every 3h instead of every 6h
|
||||||
|
|
||||||
|
## ntfy server v1.13.0
|
||||||
|
Released Jan 16, 2022
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* [Websockets](https://ntfy.sh/docs/subscribe/api/#websockets) endpoint
|
||||||
|
* Listen on Unix socket, see [config option](https://ntfy.sh/docs/config/#config-options) `listen-unix`
|
||||||
|
|
||||||
|
## ntfy Android app v1.6.0
|
||||||
|
Released Jan 14, 2022
|
||||||
|
|
||||||
|
**New features:**
|
||||||
|
|
||||||
|
* Attachments: Send files to the phone (#25, #15)
|
||||||
|
* Click action: Add a click action URL to notifications (#85)
|
||||||
|
* Battery optimization: Allow disabling persistent wake-lock (#76, thanks @MatMaul)
|
||||||
|
* Recognize imported user CA certificate for self-hosted servers (#87, thanks @keith24)
|
||||||
|
* Remove mentions of "instant delivery" from F-Droid to make it less confusing (no ticket)
|
||||||
|
|
||||||
|
**Bug fixes:**
|
||||||
|
|
||||||
|
* Subscription "muted until" was not always respected (#90)
|
||||||
|
* Fix two stack traces reported by Play console vitals (no ticket)
|
||||||
|
* Truncate FCM messages >4,000 bytes, prefer instant messages (#84)
|
||||||
|
|
||||||
|
## ntfy server v1.12.1
|
||||||
|
Released Jan 14, 2022
|
||||||
|
|
||||||
|
**Bug fixes:**
|
||||||
|
|
||||||
|
* Fix security issue with attachment peaking (#93)
|
||||||
|
|
||||||
|
## ntfy server v1.12.0
|
||||||
|
Released Jan 13, 2022
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* [Attachments](https://ntfy.sh/docs/publish/#attachments) (#25, #15)
|
||||||
|
* [Click action](https://ntfy.sh/docs/publish/#click-action) (#85)
|
||||||
|
* Increase FCM priority for high/max priority messages (#70)
|
||||||
|
|
||||||
|
**Bug fixes:**
|
||||||
|
|
||||||
|
* Make postinst script work properly for rpm-based systems (#83, thanks @cmeis)
|
||||||
|
* Truncate FCM messages longer than 4000 bytes (#84)
|
||||||
|
* Fix `listen-https` port (no ticket)
|
||||||
|
|
||||||
|
## ntfy Android app v1.5.2
|
||||||
|
Released Jan 3, 2022
|
||||||
|
|
||||||
|
**New features:**
|
||||||
|
|
||||||
|
* Allow using ntfy as UnifiedPush distributor (#9)
|
||||||
|
* Support for longer message up to 4096 bytes (#77)
|
||||||
|
* Minimum priority: show notifications only if priority X or higher (#79)
|
||||||
|
* Allowing disabling broadcasts in global settings (#80)
|
||||||
|
|
||||||
|
**Bug fixes:**
|
||||||
|
|
||||||
|
* Allow int/long extras for SEND_MESSAGE intent (#57)
|
||||||
|
* Various battery improvement fixes (#76)
|
||||||
|
|
||||||
|
## ntfy server v1.11.2
|
||||||
|
Released Jan 1, 2022
|
||||||
|
|
||||||
|
**Features & bug fixes:**
|
||||||
|
|
||||||
|
* Increase message limit to 4096 bytes (4k) #77
|
||||||
|
* Docs for [UnifiedPush](https://unifiedpush.org) #9
|
||||||
|
* Increase keepalive interval to 55s #76
|
||||||
|
* Increase Firebase keepalive to 3 hours #76
|
||||||
|
|
||||||
|
## ntfy server v1.10.0
|
||||||
|
Released Dec 28, 2021
|
||||||
|
|
||||||
|
**Features & bug fixes:**
|
||||||
|
|
||||||
|
* [Publish messages via e-mail](ntfy.sh/docs/publish/#e-mail-publishing) #66
|
||||||
|
* Server-side work to support [unifiedpush.org](https://unifiedpush.org) #64
|
||||||
|
* Fixing the Santa bug #65
|
||||||
|
|
||||||
|
## Older releases
|
||||||
|
For older releases, check out 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).
|
||||||
10
docs/static/css/extra.css
vendored
@@ -1,13 +1,17 @@
|
|||||||
:root {
|
:root {
|
||||||
--md-primary-fg-color: #3a9784;
|
--md-primary-fg-color: #338574;
|
||||||
--md-primary-fg-color--light: #3a9784;
|
--md-primary-fg-color--light: #338574;
|
||||||
--md-primary-fg-color--dark: #3a9784;
|
--md-primary-fg-color--dark: #338574;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-header__button.md-logo :is(img, svg) {
|
.md-header__button.md-logo :is(img, svg) {
|
||||||
width: unset !important;
|
width: unset !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.md-header__topic:first-child {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
.md-typeset h4 {
|
.md-typeset h4 {
|
||||||
font-weight: 500 !important;
|
font-weight: 500 !important;
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
|
|||||||
BIN
docs/static/img/android-screenshot-notification-actions.png
vendored
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
docs/static/img/android-screenshot-notification-details.jpg
vendored
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
docs/static/img/android-screenshot-notification-multiline.jpg
vendored
Normal file
|
After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
BIN
docs/static/img/android-screenshot-share-1.jpg
vendored
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
docs/static/img/android-screenshot-share-2.jpg
vendored
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
docs/static/img/nodered-message.png
vendored
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
docs/static/img/nodered-picture.png
vendored
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
docs/static/img/web-detail.png
vendored
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 473 KiB |
BIN
docs/static/img/web-subscribe.png
vendored
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 76 KiB |
@@ -3,7 +3,11 @@ You can create and subscribe to a topic in the [web UI](web.md), via the [phone
|
|||||||
or in your own app or script by subscribing the API. This page describes how to subscribe via API. You may also want to
|
or in your own app or script by subscribing the API. This page describes how to subscribe via API. You may also want to
|
||||||
check out the page that describes how to [publish messages](../publish.md).
|
check out the page that describes how to [publish messages](../publish.md).
|
||||||
|
|
||||||
The subscription API relies on a simple HTTP GET request with a streaming HTTP response, i.e **you open a GET request and
|
You can consume the subscription API as either a **[simple HTTP stream (JSON, SSE or raw)](#http-stream)**, or
|
||||||
|
**[via WebSockets](#websockets)**. Both are incredibly simple to use.
|
||||||
|
|
||||||
|
## HTTP stream
|
||||||
|
The HTTP stream-based API relies on a simple GET request with a streaming HTTP response, i.e **you open a GET request and
|
||||||
the connection stays open forever**, sending messages back as they come in. There are three different API endpoints, which
|
the connection stays open forever**, sending messages back as they come in. There are three different API endpoints, which
|
||||||
only differ in the response format:
|
only differ in the response format:
|
||||||
|
|
||||||
@@ -12,7 +16,7 @@ only differ in the response format:
|
|||||||
can be used with [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource)
|
can be used with [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource)
|
||||||
* [Raw stream](#subscribe-as-raw-stream): `<topic>/raw` returns messages as raw text, with one line per message
|
* [Raw stream](#subscribe-as-raw-stream): `<topic>/raw` returns messages as raw text, with one line per message
|
||||||
|
|
||||||
## Subscribe as JSON stream
|
### Subscribe as JSON stream
|
||||||
Here are a few examples of how to consume the JSON endpoint (`<topic>/json`). For almost all languages, **this is the
|
Here are a few examples of how to consume the JSON endpoint (`<topic>/json`). For almost all languages, **this is the
|
||||||
recommended way to subscribe to a topic**. The notable exception is JavaScript, for which the
|
recommended way to subscribe to a topic**. The notable exception is JavaScript, for which the
|
||||||
[SSE/EventSource stream](#subscribe-as-sse-stream) is much easier to work with.
|
[SSE/EventSource stream](#subscribe-as-sse-stream) is much easier to work with.
|
||||||
@@ -80,7 +84,7 @@ recommended way to subscribe to a topic**. The notable exception is JavaScript,
|
|||||||
fclose($fp);
|
fclose($fp);
|
||||||
```
|
```
|
||||||
|
|
||||||
## Subscribe as SSE stream
|
### Subscribe as SSE stream
|
||||||
Using [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) in JavaScript, you can consume
|
Using [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) in JavaScript, you can consume
|
||||||
notifications via a [Server-Sent Events (SSE)](https://en.wikipedia.org/wiki/Server-sent_events) stream. It's incredibly
|
notifications via a [Server-Sent Events (SSE)](https://en.wikipedia.org/wiki/Server-sent_events) stream. It's incredibly
|
||||||
easy to use. Here's what it looks like. You may also want to check out the [live example](/example.html).
|
easy to use. Here's what it looks like. You may also want to check out the [live example](/example.html).
|
||||||
@@ -125,7 +129,7 @@ easy to use. Here's what it looks like. You may also want to check out the [live
|
|||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
## Subscribe as raw stream
|
### Subscribe as raw stream
|
||||||
The `/raw` endpoint will output one line per message, and **will only include the message body**. It's useful for extremely
|
The `/raw` endpoint will output one line per message, and **will only include the message body**. It's useful for extremely
|
||||||
simple scripts, and doesn't include all the data. Additional fields such as [priority](../publish.md#message-priority),
|
simple scripts, and doesn't include all the data. Additional fields such as [priority](../publish.md#message-priority),
|
||||||
[tags](../publish.md#tags--emojis--) or [message title](../publish.md#message-title) are not included in this output
|
[tags](../publish.md#tags--emojis--) or [message title](../publish.md#message-title) are not included in this output
|
||||||
@@ -184,6 +188,51 @@ format. Keepalive messages are sent as empty lines.
|
|||||||
fclose($fp);
|
fclose($fp);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## WebSockets
|
||||||
|
You may also subscribe to topics via [WebSockets](https://en.wikipedia.org/wiki/WebSocket), which is also widely
|
||||||
|
supported in many languages. Most notably, WebSockets are natively supported in JavaScript. On the command line,
|
||||||
|
I recommend [websocat](https://github.com/vi/websocat), a fantastic tool similar to `socat` or `curl`, but specifically
|
||||||
|
for WebSockets.
|
||||||
|
|
||||||
|
The WebSockets endpoint is available at `<topic>/ws` and returns messages as JSON objects similar to the
|
||||||
|
[JSON stream endpoint](#subscribe-as-json-stream).
|
||||||
|
|
||||||
|
=== "Command line (websocat)"
|
||||||
|
```
|
||||||
|
$ websocat wss://ntfy.sh/mytopic/ws
|
||||||
|
{"id":"qRHUCCvjj8","time":1642307388,"event":"open","topic":"mytopic"}
|
||||||
|
{"id":"eOWoUBJ14x","time":1642307754,"event":"message","topic":"mytopic","message":"hi there"}
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "HTTP"
|
||||||
|
``` http
|
||||||
|
GET /disk-alerts/ws HTTP/1.1
|
||||||
|
Host: ntfy.sh
|
||||||
|
Upgrade: websocket
|
||||||
|
Connection: Upgrade
|
||||||
|
|
||||||
|
HTTP/1.1 101 Switching Protocols
|
||||||
|
Upgrade: websocket
|
||||||
|
Connection: Upgrade
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Go"
|
||||||
|
``` go
|
||||||
|
import "github.com/gorilla/websocket"
|
||||||
|
ws, _, _ := websocket.DefaultDialer.Dial("wss://ntfy.sh/mytopic/ws", nil)
|
||||||
|
messageType, data, err := ws.ReadMessage()
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "JavaScript"
|
||||||
|
``` javascript
|
||||||
|
const socket = new WebSocket('wss://ntfy.sh/mytopic/ws');
|
||||||
|
socket.addEventListener('message', function (event) {
|
||||||
|
console.log(event.data);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
## Advanced features
|
## Advanced features
|
||||||
|
|
||||||
### Poll for messages
|
### Poll for messages
|
||||||
@@ -198,11 +247,13 @@ curl -s "ntfy.sh/mytopic/json?poll=1"
|
|||||||
### Fetch cached messages
|
### Fetch cached messages
|
||||||
Messages may be cached for a couple of hours (see [message caching](../config.md#message-cache)) to account for network
|
Messages may be cached for a couple of hours (see [message caching](../config.md#message-cache)) to account for network
|
||||||
interruptions of subscribers. If the server has configured message caching, you can read back what you missed by using
|
interruptions of subscribers. If the server has configured message caching, you can read back what you missed by using
|
||||||
the `since=` query parameter. It takes either a duration (e.g. `10m` or `30s`), a Unix timestamp (e.g. `1635528757`)
|
the `since=` query parameter. It takes a duration (e.g. `10m` or `30s`), a Unix timestamp (e.g. `1635528757`),
|
||||||
or `all` (all cached messages).
|
a message ID (e.g. `nFS3knfcQ1xe`), or `all` (all cached messages).
|
||||||
|
|
||||||
```
|
```
|
||||||
curl -s "ntfy.sh/mytopic/json?since=10m"
|
curl -s "ntfy.sh/mytopic/json?since=10m"
|
||||||
|
curl -s "ntfy.sh/mytopic/json?since=1645970742"
|
||||||
|
curl -s "ntfy.sh/mytopic/json?since=nFS3knfcQ1xe"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Fetch scheduled messages
|
### Fetch scheduled messages
|
||||||
@@ -229,12 +280,12 @@ $ curl "ntfy.sh/alerts/json?priority=high&tags=zfs-error"
|
|||||||
|
|
||||||
Available filters (all case-insensitive):
|
Available filters (all case-insensitive):
|
||||||
|
|
||||||
| Filter variable | Alias | Example | Description |
|
| Filter variable | Alias | Example | Description |
|
||||||
|---|---|---|---|
|
|-----------------|---------------------------|------------------------------------|-------------------------------------------------------------------------|
|
||||||
| `message` | `X-Message`, `m` | `ntfy.sh/mytopic?message=lalala` | Only return messages that match this exact message string |
|
| `message` | `X-Message`, `m` | `ntfy.sh/mytopic?message=lalala` | Only return messages that match this exact message string |
|
||||||
| `title` | `X-Title`, `t` | `ntfy.sh/mytopic?title=some+title` | Only return messages that match this exact title string |
|
| `title` | `X-Title`, `t` | `ntfy.sh/mytopic?title=some+title` | Only return messages that match this exact title string |
|
||||||
| `priority` | `X-Priority`, `prio`, `p` | `ntfy.sh/mytopic?p=high,urgent` | Only return messages that match *any priority listed* (comma-separated) |
|
| `priority` | `X-Priority`, `prio`, `p` | `ntfy.sh/mytopic?p=high,urgent` | Only return messages that match *any priority listed* (comma-separated) |
|
||||||
| `tags` | `X-Tags`, `tag`, `ta` | `ntfy.sh/mytopic?tags=error,alert` | Only return messages that match *all listed tags* (comma-separated) |
|
| `tags` | `X-Tags`, `tag`, `ta` | `ntfy.sh/mytopic?tags=error,alert` | Only return messages that match *all listed tags* (comma-separated) |
|
||||||
|
|
||||||
### Subscribe to multiple topics
|
### Subscribe to multiple topics
|
||||||
It's possible to subscribe to multiple topics in one HTTP call by providing a comma-separated list of topics
|
It's possible to subscribe to multiple topics in one HTTP call by providing a comma-separated list of topics
|
||||||
@@ -247,37 +298,70 @@ $ curl -s ntfy.sh/mytopic1,mytopic2/json
|
|||||||
{"id":"Cm02DsxUHb","time":1637182643,"event":"message","topic":"mytopic2","message":"for topic 2"}
|
{"id":"Cm02DsxUHb","time":1637182643,"event":"message","topic":"mytopic2","message":"for topic 2"}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
Depending on whether the server is configured to support [access control](../config.md#access-control), some topics
|
||||||
|
may be read/write protected so that only users with the correct credentials can subscribe or publish to them.
|
||||||
|
To publish/subscribe to protected topics, you can use [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication)
|
||||||
|
with a valid username/password. For your self-hosted server, **be sure to use HTTPS to avoid eavesdropping** and exposing
|
||||||
|
your password.
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -u phil:mypass -s "https://ntfy.example.com/mytopic/json"
|
||||||
|
```
|
||||||
|
|
||||||
## JSON message format
|
## JSON message format
|
||||||
Both the [`/json` endpoint](#subscribe-as-json-stream) and the [`/sse` endpoint](#subscribe-as-sse-stream) return a JSON
|
Both the [`/json` endpoint](#subscribe-as-json-stream) and the [`/sse` endpoint](#subscribe-as-sse-stream) return a JSON
|
||||||
format of the message. It's very straight forward:
|
format of the message. It's very straight forward:
|
||||||
|
|
||||||
| Field | Required | Type | Example | Description |
|
**Message**:
|
||||||
|---|---|---|---|---|
|
|
||||||
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
|
| Field | Required | Type | Example | Description |
|
||||||
| `time` | ✔️ | *int* | `1635528741` | Message date time, as Unix time stamp |
|
|--------------|----------|---------------------------------------------------|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| `event` | ✔️ | `open`, `keepalive` or `message` | `message` | Message type, typically you'd be only interested in `message` |
|
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
|
||||||
| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events |
|
| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp |
|
||||||
| `message` | - | *string* | `Some message` | Message body; always present in `message` events |
|
| `event` | ✔️ | `open`, `keepalive`, `message`, or `poll_request` | `message` | Message type, typically you'd be only interested in `message` |
|
||||||
| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/<topic>` |
|
| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events |
|
||||||
| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis |
|
| `message` | - | *string* | `Some message` | Message body; always present in `message` events |
|
||||||
| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/<topic>` |
|
||||||
|
| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis |
|
||||||
|
| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
||||||
|
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) |
|
||||||
|
| `attachment` | - | *JSON object* | *see below* | Details about an attachment (name, URL, size, ...) |
|
||||||
|
|
||||||
|
**Attachment** (part of the message, see [attachments](../publish.md#attachments) for details):
|
||||||
|
|
||||||
|
| Field | Required | Type | Example | Description |
|
||||||
|
|-----------|----------|-------------|--------------------------------|-----------------------------------------------------------------------------------------------------------|
|
||||||
|
| `name` | ✔️ | *string* | `attachment.jpg` | Name of the attachment, can be overridden with `X-Filename`, see [attachments](../publish.md#attachments) |
|
||||||
|
| `url` | ✔️ | *URL* | `https://example.com/file.jpg` | URL of the attachment |
|
||||||
|
| `type` | -️ | *mime type* | `image/jpeg` | Mime type of the attachment, only defined if attachment was uploaded to ntfy server |
|
||||||
|
| `size` | -️ | *number* | `33848` | Size of the attachment in bytes, only defined if attachment was uploaded to ntfy server |
|
||||||
|
| `expires` | -️ | *number* | `1635528741` | Attachment expiry date as Unix time stamp, only defined if attachment was uploaded to ntfy server |
|
||||||
|
|
||||||
Here's an example for each message type:
|
Here's an example for each message type:
|
||||||
|
|
||||||
=== "Notification message"
|
=== "Notification message"
|
||||||
``` json
|
``` json
|
||||||
{
|
{
|
||||||
"id": "wze9zgqK41",
|
"id": "sPs71M8A2T",
|
||||||
"time": 1638542110,
|
"time": 1643935928,
|
||||||
"event": "message",
|
"event": "message",
|
||||||
"topic": "phil_alerts",
|
"topic": "mytopic",
|
||||||
"priority": 5,
|
"priority": 5,
|
||||||
"tags": [
|
"tags": [
|
||||||
"warning",
|
"warning",
|
||||||
"skull"
|
"skull"
|
||||||
],
|
],
|
||||||
|
"click": "https://homecam.mynet.lan/incident/1234",
|
||||||
|
"attachment": {
|
||||||
|
"name": "camera.jpg",
|
||||||
|
"type": "image/png",
|
||||||
|
"size": 33848,
|
||||||
|
"expires": 1643946728,
|
||||||
|
"url": "https://ntfy.sh/file/sPs71M8A2T.png"
|
||||||
|
},
|
||||||
"title": "Unauthorized access detected",
|
"title": "Unauthorized access detected",
|
||||||
"message": "Remote access to phils-laptop detected. Act right away."
|
"message": "Movement detected in the yard. You better go check"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -313,15 +397,26 @@ Here's an example for each message type:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
=== "Poll request message"
|
||||||
|
``` json
|
||||||
|
{
|
||||||
|
"id": "371sevb0pD",
|
||||||
|
"time": 1638542275,
|
||||||
|
"event": "poll_request",
|
||||||
|
"topic": "phil_alerts"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## List of all parameters
|
## List of all parameters
|
||||||
The following is a list of all parameters that can be passed when subscribing to a message. Parameter names are **case-insensitive**,
|
The following is a list of all parameters that can be passed **when subscribing to a message**. Parameter names are **case-insensitive**,
|
||||||
and can be passed as **HTTP headers** or **query parameters in the URL**. They are listed in the table in their canonical form.
|
and can be passed as **HTTP headers** or **query parameters in the URL**. They are listed in the table in their canonical form.
|
||||||
|
|
||||||
| Parameter | Aliases (case-insensitive) | Description |
|
| Parameter | Aliases (case-insensitive) | Description |
|
||||||
|---|---|---|
|
|-------------|----------------------------|---------------------------------------------------------------------------------|
|
||||||
| `poll` | `X-Poll`, `po` | Return cached messages and close connection |
|
| `poll` | `X-Poll`, `po` | Return cached messages and close connection |
|
||||||
| `scheduled` | `X-Scheduled`, `sched` | Include scheduled/delayed messages in message list |
|
| `since` | `X-Since`, `si` | Return cached messages since timestamp, duration or message ID |
|
||||||
| `message` | `X-Message`, `m` | Filter: Only return messages that match this exact message string |
|
| `scheduled` | `X-Scheduled`, `sched` | Include scheduled/delayed messages in message list |
|
||||||
| `title` | `X-Title`, `t` | Filter: Only return messages that match this exact title string |
|
| `message` | `X-Message`, `m` | Filter: Only return messages that match this exact message string |
|
||||||
| `priority` | `X-Priority`, `prio`, `p` | Filter: Only return messages that match *any priority listed* (comma-separated) |
|
| `title` | `X-Title`, `t` | Filter: Only return messages that match this exact title string |
|
||||||
| `tags` | `X-Tags`, `tag`, `ta` | Filter: Only return messages that match *all listed tags* (comma-separated) |
|
| `priority` | `X-Priority`, `prio`, `p` | Filter: Only return messages that match *any priority listed* (comma-separated) |
|
||||||
|
| `tags` | `X-Tags`, `tag`, `ta` | Filter: Only return messages that match *all listed tags* (comma-separated) |
|
||||||
|
|||||||
@@ -103,16 +103,16 @@ The message fields are passed to the command as environment variables and can be
|
|||||||
these are environment variables, you typically don't have to worry about quoting too much, as long as you enclose them
|
these are environment variables, you typically don't have to worry about quoting too much, as long as you enclose them
|
||||||
in double-quotes, you should be fine:
|
in double-quotes, you should be fine:
|
||||||
|
|
||||||
| Variable | Aliases | Description |
|
| Variable | Aliases | Description |
|
||||||
|---|---|---
|
|------------------|----------------------------|----------------------------------------|
|
||||||
| `$NTFY_ID` | `$id` | Unique message ID |
|
| `$NTFY_ID` | `$id` | Unique message ID |
|
||||||
| `$NTFY_TIME` | `$time` | Unix timestamp of the message delivery |
|
| `$NTFY_TIME` | `$time` | Unix timestamp of the message delivery |
|
||||||
| `$NTFY_TOPIC` | `$topic` | Topic name |
|
| `$NTFY_TOPIC` | `$topic` | Topic name |
|
||||||
| `$NTFY_MESSAGE` | `$message`, `$m` | Message body |
|
| `$NTFY_MESSAGE` | `$message`, `$m` | Message body |
|
||||||
| `$NTFY_TITLE` | `$title`, `$t` | Message title |
|
| `$NTFY_TITLE` | `$title`, `$t` | Message title |
|
||||||
| `$NTFY_PRIORITY` | `$priority`, `$prio`, `$p` | Message priority (1=min, 5=max) |
|
| `$NTFY_PRIORITY` | `$priority`, `$prio`, `$p` | Message priority (1=min, 5=max) |
|
||||||
| `$NTFY_TAGS` | `$tags`, `$tag`, `$ta` | Message tags (comma separated list) |
|
| `$NTFY_TAGS` | `$tags`, `$tag`, `$ta` | Message tags (comma separated list) |
|
||||||
| `$NTFY_RAW` | `$raw` | Raw JSON message |
|
| `$NTFY_RAW` | `$raw` | Raw JSON message |
|
||||||
|
|
||||||
### Subscribe to multiple topics
|
### Subscribe to multiple topics
|
||||||
```
|
```
|
||||||
@@ -196,3 +196,27 @@ EOF
|
|||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl restart ntfy-client
|
sudo systemctl restart ntfy-client
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
Depending on whether the server is configured to support [access control](../config.md#access-control), some topics
|
||||||
|
may be read/write protected so that only users with the correct credentials can subscribe or publish to them.
|
||||||
|
To publish/subscribe to protected topics, you can use [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication)
|
||||||
|
with a valid username/password. For your self-hosted server, **be sure to use HTTPS to avoid eavesdropping** and exposing
|
||||||
|
your password.
|
||||||
|
|
||||||
|
You can either add your username and password to the configuration file:
|
||||||
|
=== "~/.config/ntfy/client.yml"
|
||||||
|
```yaml
|
||||||
|
- topic: secret
|
||||||
|
command: 'notify-send "$m"'
|
||||||
|
user: phill
|
||||||
|
password: mypass
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with the `ntfy subscibe` command:
|
||||||
|
```
|
||||||
|
ntfy subscribe \
|
||||||
|
-u phil:mypass \
|
||||||
|
ntfy.example.com/mysecrets
|
||||||
|
```
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ If those screenshots are still not enough, here's a video:
|
|||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
## Message priority
|
## Message priority
|
||||||
When you [publish messages](../publish.md#message-priority) to a topic, you can define a priority. This priority defines
|
When you [publish messages](../publish.md#message-priority) to a topic, you can **define a priority**. This priority defines
|
||||||
how urgently Android will notify you about the notification, and whether they make a sound and/or vibrate.
|
how urgently Android will notify you about the notification, and whether they make a sound and/or vibrate.
|
||||||
|
|
||||||
By default, messages with default priority or higher (>= 3) will vibrate and make a sound. Messages with high or urgent
|
By default, messages with default priority or higher (>= 3) will vibrate and make a sound. Messages with high or urgent
|
||||||
@@ -42,11 +42,19 @@ priority (>= 4) will also show as pop-over, like so:
|
|||||||
<figcaption>High and urgent notifications show as pop-over</figcaption>
|
<figcaption>High and urgent notifications show as pop-over</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
You can change these settings in Android by long-pressing on the app, and tapping "Notifications". You can then configure
|
You can change these settings in Android by long-pressing on the app, and tapping "Notifications", or from the "Settings"
|
||||||
the settings (and custom sounds or vibration) for each of the priorities:
|
menu under "Channel settings". There is one notification channel for each priority:
|
||||||
|
|
||||||
<figure markdown>
|
<figure markdown>
|
||||||
{ width=500 }
|
{ width=500 }
|
||||||
|
<figcaption>Per-priority channels</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
Per notification channel, you can configure a **channel-specific sound**, whether to **override the Do Not Disturb (DND)**
|
||||||
|
setting, and other settings such as popover or notification dot:
|
||||||
|
|
||||||
|
<figure markdown>
|
||||||
|
{ width=500 }
|
||||||
<figcaption>Per-priority sound/vibration settings</figcaption>
|
<figcaption>Per-priority sound/vibration settings</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
@@ -80,6 +88,34 @@ notifications. Firebase is overall pretty bad at delivering messages in time, bu
|
|||||||
The ntfy Android app uses Firebase only for the main host `ntfy.sh`, and only in the Google Play flavor of the app.
|
The ntfy Android app uses Firebase only for the main host `ntfy.sh`, and only in the Google Play flavor of the app.
|
||||||
It won't use Firebase for any self-hosted servers, and not at all in the the F-Droid flavor.
|
It won't use Firebase for any self-hosted servers, and not at all in the the F-Droid flavor.
|
||||||
|
|
||||||
|
## Share to topic
|
||||||
|
You can share files to a topic using Android's "Share" feature. This works in almost any app that supports sharing files
|
||||||
|
or text, and it's useful for sending yourself links, files or other things. The feature remembers a few of the last topics
|
||||||
|
you shared content to and lists them at the bottom.
|
||||||
|
|
||||||
|
The feature is pretty self-explanatory, and one picture says more than a thousand words. So here are two pictures:
|
||||||
|
|
||||||
|
<div id="share-to-topic-screenshots" class="screenshots">
|
||||||
|
<a href="../../static/img/android-screenshot-share-1.jpg"><img src="../../static/img/android-screenshot-share-1.jpg"/></a>
|
||||||
|
<a href="../../static/img/android-screenshot-share-2.jpg"><img src="../../static/img/android-screenshot-share-2.jpg"/></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## ntfy:// links
|
||||||
|
The ntfy Android app supports deep linking directly to topics. This is useful when integrating with [automation apps](#automation-apps)
|
||||||
|
such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm),
|
||||||
|
or to simply directly link to a topic from a mobile website.
|
||||||
|
|
||||||
|
!!! info
|
||||||
|
Android deep linking of http/https links is very brittle and limited, which is why something like `https://<host>/<topic>/subscribe` is
|
||||||
|
**not possible**, and instead `ntfy://` links have to be used. More details in [issue #20](https://github.com/binwiederhier/ntfy/issues/20).
|
||||||
|
|
||||||
|
**Supported link formats:**
|
||||||
|
|
||||||
|
| Link format | Example | Description |
|
||||||
|
|-------------------------------------------------------------------------------|-------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| <span style="white-space: nowrap">`ntfy://<host>/<topic>`</span> | `ntfy://ntfy.sh/mytopic` | Directly opens the Android app detail view for the given topic and server. Subscribes to the topic if not already subscribed. This is equivalent to the web view `https://ntfy.sh/mytopic` (HTTPS!) |
|
||||||
|
| <span style="white-space: nowrap">`ntfy://<host>/<topic>?secure=false`</span> | `ntfy://example.com/mytopic?secure=false` | Same as above, except that this will use HTTP instead of HTTPS as topic URL. This is equivalent to the web view `http://example.com/mytopic` (HTTP!) |
|
||||||
|
|
||||||
## Integrations
|
## Integrations
|
||||||
|
|
||||||
### UnifiedPush
|
### UnifiedPush
|
||||||
@@ -130,19 +166,21 @@ notification popups:
|
|||||||
|
|
||||||
Here's a list of extras you can access. Most likely, you'll want to filter for `topic` and react on `message`:
|
Here's a list of extras you can access. Most likely, you'll want to filter for `topic` and react on `message`:
|
||||||
|
|
||||||
| Extra name | Type | Example | Description |
|
| Extra name | Type | Example | Description |
|
||||||
|---|---|---|---|
|
|-----------------|------------------------------|--------------------|------------------------------------------------------------------------------------|
|
||||||
| `id` | *string* | `bP8dMjO8ig` | Randomly chosen message identifier (likely not very useful for task automation) |
|
| `id` | *String* | `bP8dMjO8ig` | Randomly chosen message identifier (likely not very useful for task automation) |
|
||||||
| `base_url` | *string* | `https://ntfy.sh` | Root URL of the ntfy server this message came from |
|
| `base_url` | *String* | `https://ntfy.sh` | Root URL of the ntfy server this message came from |
|
||||||
| `topic` ❤️ | *string* | `mytopic` | Topic name; **you'll likely want to filter for a specific topic** |
|
| `topic` ❤️ | *String* | `mytopic` | Topic name; **you'll likely want to filter for a specific topic** |
|
||||||
| `muted` | *bool* | `true` | Indicates whether the subscription was muted in the app |
|
| `muted` | *Boolean* | `true` | Indicates whether the subscription was muted in the app |
|
||||||
| `muted_str` | *string (`true` or `false`)* | `true` | Same as `muted`, but as string `true` or `false` |
|
| `muted_str` | *String (`true` or `false`)* | `true` | Same as `muted`, but as string `true` or `false` |
|
||||||
| `time` | *int* | `1635528741` | Message date time, as Unix time stamp |
|
| `time` | *Int* | `1635528741` | Message date time, as Unix time stamp |
|
||||||
| `title` | *string* | `Some title` | Message [title](../publish.md#message-title); may be empty if not set |
|
| `title` | *String* | `Some title` | Message [title](../publish.md#message-title); may be empty if not set |
|
||||||
| `message` ❤️ | *string* | `Some message` | Message body; **this is likely what you're interested in** |
|
| `message` ❤️ | *String* | `Some message` | Message body; **this is likely what you're interested in** |
|
||||||
| `tags` | *string* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
|
| `message_bytes` | *ByteArray* | `(binary data)` | Message body as binary data |
|
||||||
| `tags_map` | *string* | `0=tag1,1=tag2,..` | Map of tags to make it easier to map first, second, ... tag |
|
| `encoding`️ | *String* | - | Message encoding (empty or "base64") |
|
||||||
| `priority` | *int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
| `tags` | *String* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
|
||||||
|
| `tags_map` | *String* | `0=tag1,1=tag2,..` | Map of tags to make it easier to map first, second, ... tag |
|
||||||
|
| `priority` | *Int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
||||||
|
|
||||||
#### Send messages using intents
|
#### Send messages using intents
|
||||||
To send messages from other apps (such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
To send messages from other apps (such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
||||||
@@ -164,14 +202,14 @@ Here's what that looks like:
|
|||||||
|
|
||||||
The following intent extras are supported when for the intent with the `io.heckel.ntfy.SEND_MESSAGE` action:
|
The following intent extras are supported when for the intent with the `io.heckel.ntfy.SEND_MESSAGE` action:
|
||||||
|
|
||||||
| Extra name | Required | Type | Example | Description |
|
| Extra name | Required | Type | Example | Description |
|
||||||
|---|---|---|---|---|
|
|--------------|----------|-------------------------------|-------------------|------------------------------------------------------------------------------------|
|
||||||
| `base_url` | - | *string* | `https://ntfy.sh` | Root URL of the ntfy server this message came from, defaults to `https://ntfy.sh` |
|
| `base_url` | - | *String* | `https://ntfy.sh` | Root URL of the ntfy server this message came from, defaults to `https://ntfy.sh` |
|
||||||
| `topic` ❤️ | ✔ | *string* | `mytopic` | Topic name; **you must set this** |
|
| `topic` ❤️ | ✔ | *String* | `mytopic` | Topic name; **you must set this** |
|
||||||
| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); may be empty if not set |
|
| `title` | - | *String* | `Some title` | Message [title](../publish.md#message-title); may be empty if not set |
|
||||||
| `message` ❤️ | ✔ | *string* | `Some message` | Message body; **you must set this** |
|
| `message` ❤️ | ✔ | *String* | `Some message` | Message body; **you must set this** |
|
||||||
| `tags` | - | *string* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
|
| `tags` | - | *String* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
|
||||||
| `priority` | - | *string or int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
| `priority` | - | *String or Int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
||||||
|
|
||||||
## iPhone/iOS
|
## iPhone/iOS
|
||||||
I almost feel devious for putting the *Download on the App Store* button on this page. Currently, there is no iOS app
|
I almost feel devious for putting the *Download on the App Store* button on this page. Currently, there is no iOS app
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ keep a connection open and listen for incoming notifications.
|
|||||||
To learn how to send messages, check out the [publishing page](../publish.md).
|
To learn how to send messages, check out the [publishing page](../publish.md).
|
||||||
|
|
||||||
<div id="web-screenshots" class="screenshots">
|
<div id="web-screenshots" class="screenshots">
|
||||||
<a href="../../static/img/web-subscribe.png"><img src="../../static/img/web-subscribe.png"/></a>
|
|
||||||
<a href="../../static/img/web-notification.png"><img src="../../static/img/web-notification.png"/></a>
|
|
||||||
<a href="../../static/img/web-detail.png"><img src="../../static/img/web-detail.png"/></a>
|
<a href="../../static/img/web-detail.png"><img src="../../static/img/web-detail.png"/></a>
|
||||||
|
<a href="../../static/img/web-notification.png"><img src="../../static/img/web-notification.png"/></a>
|
||||||
|
<a href="../../static/img/web-subscribe.png"><img src="../../static/img/web-subscribe.png"/></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
To keep receiving desktop notifications from ntfy, you need to keep the website open. What I do, and what I highly recommend,
|
To keep receiving desktop notifications from ntfy, you need to keep the website open. What I do, and what I highly recommend,
|
||||||
|
|||||||
54
go.mod
@@ -4,48 +4,50 @@ go 1.17
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go/firestore v1.6.1 // indirect
|
cloud.google.com/go/firestore v1.6.1 // indirect
|
||||||
cloud.google.com/go/storage v1.18.2 // indirect
|
cloud.google.com/go/storage v1.22.0 // indirect
|
||||||
firebase.google.com/go v3.13.0+incompatible
|
firebase.google.com/go v3.13.0+incompatible
|
||||||
github.com/BurntSushi/toml v0.4.1 // indirect
|
github.com/BurntSushi/toml v1.1.0 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
|
||||||
github.com/emersion/go-smtp v0.15.0
|
github.com/emersion/go-smtp v0.15.0
|
||||||
github.com/mattn/go-sqlite3 v1.14.9
|
github.com/gabriel-vasile/mimetype v1.4.0
|
||||||
|
github.com/gorilla/websocket v1.5.0
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.12
|
||||||
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6
|
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6
|
||||||
github.com/stretchr/testify v1.7.0
|
github.com/stretchr/testify v1.7.0
|
||||||
github.com/urfave/cli/v2 v2.3.0
|
github.com/urfave/cli/v2 v2.4.7
|
||||||
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect
|
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4
|
||||||
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11
|
golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 // indirect
|
||||||
google.golang.org/api v0.63.0
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
|
||||||
|
golang.org/x/term v0.0.0-20220411215600-e5f449aeb171
|
||||||
|
golang.org/x/time v0.0.0-20220411224347-583f2d630306
|
||||||
|
google.golang.org/api v0.75.0
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go v0.99.0 // indirect
|
cloud.google.com/go v0.101.0 // indirect
|
||||||
github.com/AlekSi/pointer v1.0.0 // indirect
|
cloud.google.com/go/compute v1.6.1 // indirect
|
||||||
github.com/census-instrumentation/opencensus-proto v0.3.0 // indirect
|
cloud.google.com/go/iam v0.3.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
github.com/AlekSi/pointer v1.2.0 // indirect
|
||||||
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 // indirect
|
|
||||||
github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490 // indirect
|
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
|
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac // indirect
|
||||||
github.com/envoyproxy/go-control-plane v0.10.1 // indirect
|
|
||||||
github.com/envoyproxy/protoc-gen-validate v0.6.2 // indirect
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.0 // indirect
|
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
github.com/golang/protobuf v1.5.2 // indirect
|
github.com/golang/protobuf v1.5.2 // indirect
|
||||||
github.com/google/go-cmp v0.5.6 // indirect
|
github.com/google/go-cmp v0.5.7 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.1.1 // indirect
|
github.com/googleapis/gax-go/v2 v2.3.0 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/googleapis/go-type-adapters v1.0.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
go.opencensus.io v0.23.0 // indirect
|
go.opencensus.io v0.23.0 // indirect
|
||||||
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d // indirect
|
golang.org/x/net v0.0.0-20220421235706-1d1ef9303861 // indirect
|
||||||
golang.org/x/sys v0.0.0-20211210111614-af8b64212486 // indirect
|
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 // indirect
|
||||||
golang.org/x/text v0.3.7 // indirect
|
golang.org/x/text v0.3.7 // indirect
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f // indirect
|
||||||
google.golang.org/appengine v1.6.7 // indirect
|
google.golang.org/appengine v1.6.7 // indirect
|
||||||
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa // indirect
|
google.golang.org/genproto v0.0.0-20220422154200-b37d22cd5731 // indirect
|
||||||
google.golang.org/grpc v1.43.0 // indirect
|
google.golang.org/grpc v1.46.0 // indirect
|
||||||
google.golang.org/protobuf v1.27.1 // indirect
|
google.golang.org/protobuf v1.28.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
148
go.sum
@@ -25,18 +25,28 @@ cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aD
|
|||||||
cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
|
cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
|
||||||
cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
|
cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
|
||||||
cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
|
cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
|
||||||
cloud.google.com/go v0.99.0 h1:y/cM2iqGgGi5D5DQZl6D9STN/3dR/Vx5Mp8s752oJTY=
|
|
||||||
cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
|
cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
|
||||||
|
cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A=
|
||||||
|
cloud.google.com/go v0.101.0 h1:g+LL+JvpvdyGtcaD2xw2mSByE/6F9s471eJSoaysM84=
|
||||||
|
cloud.google.com/go v0.101.0/go.mod h1:hEiddgDb77jDQ+I80tURYNJEnuwPzFU8awCFFRLKjW0=
|
||||||
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
||||||
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
||||||
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
||||||
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
|
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
|
||||||
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
|
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
|
||||||
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
|
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
|
||||||
|
cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow=
|
||||||
|
cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM=
|
||||||
|
cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M=
|
||||||
|
cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s=
|
||||||
|
cloud.google.com/go/compute v1.6.1 h1:2sMmt8prCn7DPaG4Pmh0N3Inmc8cT8ae5k1M6VJ9Wqc=
|
||||||
|
cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU=
|
||||||
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
||||||
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
||||||
cloud.google.com/go/firestore v1.6.1 h1:8rBq3zRjnHx8UtBvaOWqBB1xq9jH6/wltfQLlTMh2Fw=
|
cloud.google.com/go/firestore v1.6.1 h1:8rBq3zRjnHx8UtBvaOWqBB1xq9jH6/wltfQLlTMh2Fw=
|
||||||
cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
|
cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
|
||||||
|
cloud.google.com/go/iam v0.3.0 h1:exkAomrVUuzx9kWFI1wm3KI0uoDeUFPB4kKGzx6x+Gc=
|
||||||
|
cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY=
|
||||||
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
||||||
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
||||||
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
|
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
|
||||||
@@ -46,27 +56,23 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo
|
|||||||
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
||||||
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
||||||
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
||||||
cloud.google.com/go/storage v1.18.2 h1:5NQw6tOn3eMm0oE8vTkfjau18kjL79FlMjy/CHTpmoY=
|
cloud.google.com/go/storage v1.22.0 h1:NUV0NNp9nkBuW66BFRLuMgldN60C57ET3dhbwLIYio8=
|
||||||
cloud.google.com/go/storage v1.18.2/go.mod h1:AiIj7BWXyhO5gGVmYJ+S8tbkCx3yb0IMjua8Aw4naVM=
|
cloud.google.com/go/storage v1.22.0/go.mod h1:GbaLEoMqbVm6sx3Z0R++gSiBlgMv6yUi2q1DeGFKQgE=
|
||||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||||
firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=
|
firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=
|
||||||
firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=
|
firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=
|
||||||
github.com/AlekSi/pointer v1.0.0 h1:KWCWzsvFxNLcmM5XmiqHsGTTsuwZMsLFwWF9Y+//bNE=
|
|
||||||
github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8=
|
github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8=
|
||||||
|
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
|
||||||
|
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw=
|
github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I=
|
||||||
github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
github.com/census-instrumentation/opencensus-proto v0.3.0 h1:t/LhUZLVitR1Ow2YOnduCsavhwFUklBMoGVYUCqmCqk=
|
|
||||||
github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
|
||||||
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
|
|
||||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
|
|
||||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
|
||||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||||
@@ -74,23 +80,20 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk
|
|||||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||||
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||||
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||||
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 h1:hzAQntlaYRkVSFEfj9OTWlVV1H155FMD8BTKktLv0QI=
|
|
||||||
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
|
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
|
||||||
github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||||
github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||||
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||||
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||||
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||||
github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490 h1:KwaoQzs/WeUxxJqiJsZ4euOly1Az/IgZXXSxlD/UBNk=
|
|
||||||
github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU=
|
github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
|
|
||||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac h1:tn/OQ2PmwQ0XFVgAHfjlLyqMewry25Rz7jWnVoh4Ggs=
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8=
|
github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8=
|
||||||
github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
@@ -101,11 +104,8 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.m
|
|||||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
|
github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
|
github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
|
||||||
github.com/envoyproxy/go-control-plane v0.10.1 h1:cgDRLG7bs59Zd+apAWuzLQL95obVYAymNJek76W3mgw=
|
github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE=
|
||||||
github.com/envoyproxy/go-control-plane v0.10.1/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPOWUZ7hQAEvzN5Pf27BkQQ=
|
|
||||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
github.com/envoyproxy/protoc-gen-validate v0.6.2 h1:JiO+kJTpmYGjEodY7O1Zk8oZcNz1+f30UtwtXoFUPzE=
|
|
||||||
github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws=
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.0 h1:Cn9dkdYsMIu56tGho+fqzh7XmvY2YyGU0FnbhiOsEro=
|
github.com/gabriel-vasile/mimetype v1.4.0 h1:Cn9dkdYsMIu56tGho+fqzh7XmvY2YyGU0FnbhiOsEro=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.0/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmxRtOJlERCzSmRvr8=
|
github.com/gabriel-vasile/mimetype v1.4.0/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmxRtOJlERCzSmRvr8=
|
||||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||||
@@ -159,14 +159,16 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
|||||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
|
|
||||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o=
|
||||||
|
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
|
||||||
github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
|
github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
|
||||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||||
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||||
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||||
github.com/google/martian/v3 v3.2.1 h1:d8MncMlErDFTwQGBK1xhv026j9kqhvw1Qv9IbWT1VLQ=
|
|
||||||
github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
|
github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
|
||||||
|
github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
|
||||||
|
github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
|
||||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||||
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||||
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
@@ -182,49 +184,46 @@ github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLe
|
|||||||
github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||||
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||||
github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=
|
|
||||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||||
github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
|
github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
|
||||||
github.com/googleapis/gax-go/v2 v2.1.1 h1:dp3bWCh+PPO1zjRRiCSczJav13sBvG4UhNyVTa1KqdU=
|
|
||||||
github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
|
github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
|
||||||
|
github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM=
|
||||||
|
github.com/googleapis/gax-go/v2 v2.3.0 h1:nRJtk3y8Fm770D42QV6T90ZnvFZyk7agSo3Q+Z9p3WI=
|
||||||
|
github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM=
|
||||||
|
github.com/googleapis/go-type-adapters v1.0.0 h1:9XdMn+d/G57qq1s8dNc5IesGCXHf6V2HZ2JwRxfA2tA=
|
||||||
|
github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4=
|
||||||
|
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||||
|
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
|
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
|
||||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
|
|
||||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||||
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
|
||||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w=
|
github.com/mattn/go-sqlite3 v1.14.12 h1:TJ1bhYJPV44phC+IMu1u2K/i5RriLTPe+yc68XDJ1Z0=
|
||||||
github.com/mattn/go-sqlite3 v1.14.9 h1:10HX2Td0ocZpYEjhilsuo6WWtUqttj2Kb0KtD86/KYA=
|
github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||||
github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
|
||||||
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6 h1:oDSPaYiL2dbjcArLrFS8ANtwgJMyOLzvQCZon+XmFsk=
|
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6 h1:oDSPaYiL2dbjcArLrFS8ANtwgJMyOLzvQCZon+XmFsk=
|
||||||
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6/go.mod h1:DPucAeQGDPUzYUt+NaWw6qsF5SFapWWToxEiVDh2aV0=
|
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6/go.mod h1:DPucAeQGDPUzYUt+NaWw6qsF5SFapWWToxEiVDh2aV0=
|
||||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
||||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
|
||||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||||
github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4=
|
|
||||||
github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
@@ -232,8 +231,8 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
|
|||||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
|
github.com/urfave/cli/v2 v2.4.7 h1:nUgKLTC/InVYwUx26HZUBGIBZaptiW97W8vVlhuYawo=
|
||||||
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
|
github.com/urfave/cli/v2 v2.4.7/go.mod h1:oDzoM7pVwz6wHn5ogWgFUU1s4VJayeQS+aEZDqXIEJs=
|
||||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
@@ -251,9 +250,10 @@ go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqe
|
|||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 h1:kUhD7nTDoI3fVd9G4ORWrbV5NY0liEs/Jg2pv5f+bBA=
|
||||||
|
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||||
@@ -289,7 +289,6 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|||||||
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
|
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
@@ -326,8 +325,13 @@ golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLd
|
|||||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||||
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d h1:LO7XpTYMwTqxjLcGWPijK3vRXg1aWdlNOVOHRq45d7c=
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||||
|
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||||
|
golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||||
|
golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||||
|
golang.org/x/net v0.0.0-20220421235706-1d1ef9303861 h1:yssD99+7tqHWO5Gwh81phT+67hg+KttniBr6UnEXOY8=
|
||||||
|
golang.org/x/net v0.0.0-20220421235706-1d1ef9303861/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
@@ -344,8 +348,11 @@ golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ
|
|||||||
golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 h1:RerP+noqYHUQ8CMRcPlC2nvTa4dcBIjegkuWdcUDuqg=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 h1:OSnWWcOd/CtWQC2cYSBgbTSJv3ciqd8r54ySIW2y3RE=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -356,6 +363,7 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
|
|||||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
|
||||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
@@ -397,18 +405,27 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20211210111614-af8b64212486 h1:5hpz5aRr+W1erYCL5JRhSUBJRph7l9XkNveoExlrKYk=
|
|
||||||
golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 h1:xHms4gcpe1YE7A3yIllJXP16CMAGuqwO2lX1mTyyRRc=
|
||||||
|
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 h1:EH1Deb8WZJ0xc0WK//leUHXcX9aLE5SymusoTmMZye8=
|
||||||
|
golang.org/x/term v0.0.0-20220411215600-e5f449aeb171/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
@@ -422,8 +439,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
|||||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 h1:GZokNIeuVkl3aZHJchRrr13WCsols02MLUcz1U9is6M=
|
golang.org/x/time v0.0.0-20220411224347-583f2d630306 h1:+gHMid33q6pen7kv9xvT+JRinntgeXO2AeZVd0AWD3w=
|
||||||
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20220411224347-583f2d630306/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
@@ -478,8 +495,9 @@ golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
|||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f h1:GGU+dLjvlC3qDwqYgL6UgRmHXhOOgns0bZu2Ty5mm6U=
|
||||||
|
golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||||
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||||
@@ -509,11 +527,15 @@ google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6
|
|||||||
google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
|
google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
|
||||||
google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
|
google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
|
||||||
google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI=
|
google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI=
|
||||||
google.golang.org/api v0.58.0/go.mod h1:cAbP2FsxoGVNwtgNAmmn3y5G1TWAiVYRmg4yku3lv+E=
|
|
||||||
google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU=
|
google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU=
|
||||||
google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
|
google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
|
||||||
google.golang.org/api v0.63.0 h1:n2bqqK895ygnBpdPDYetfy23K7fJ22wsrZKCyfuRkkA=
|
|
||||||
google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo=
|
google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo=
|
||||||
|
google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g=
|
||||||
|
google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA=
|
||||||
|
google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8=
|
||||||
|
google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs=
|
||||||
|
google.golang.org/api v0.75.0 h1:0AYh/ae6l9TDUvIQrDw5QRpM100P6oHgD+o3dYHMzJg=
|
||||||
|
google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA=
|
||||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
@@ -561,6 +583,7 @@ google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6D
|
|||||||
google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20210329143202-679c6ae281ee/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
|
||||||
google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
|
google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
|
||||||
google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
|
google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
|
||||||
google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
|
google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
|
||||||
@@ -577,15 +600,27 @@ google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEc
|
|||||||
google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
|
google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
|
||||||
google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
|
google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
|
||||||
google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
|
google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
|
||||||
google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
|
|
||||||
google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||||
google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||||
google.golang.org/genproto v0.0.0-20211016002631-37fc39342514/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
|
||||||
google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||||
google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||||
google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||||
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa h1:I0YcKz0I7OAhddo7ya8kMnvprhcWM045PmkBdMO9zN0=
|
|
||||||
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||||
|
google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||||
|
google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||||
|
google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||||
|
google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
||||||
|
google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
||||||
|
google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
||||||
|
google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
||||||
|
google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E=
|
||||||
|
google.golang.org/genproto v0.0.0-20220405205423-9d709892a2bf/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
||||||
|
google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
||||||
|
google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
||||||
|
google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
||||||
|
google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
||||||
|
google.golang.org/genproto v0.0.0-20220422154200-b37d22cd5731 h1:nquqdM9+ps0JZcIiI70+tqoaIFS5Ql4ZuK8UXnz3HfE=
|
||||||
|
google.golang.org/genproto v0.0.0-20220422154200-b37d22cd5731/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
||||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||||
@@ -612,8 +647,10 @@ google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnD
|
|||||||
google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
|
google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
|
||||||
google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
|
google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
|
||||||
google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
|
google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
|
||||||
google.golang.org/grpc v1.43.0 h1:Eeu7bZtDZ2DpRCsLhUlcrLnvYaMK1Gz86a+hMVvELmM=
|
google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
|
||||||
google.golang.org/grpc v1.43.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
|
google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
|
||||||
|
google.golang.org/grpc v1.46.0 h1:oCjezcn6g6A75TGoKYBPgKmVBLexhYLM6MebdrPApP8=
|
||||||
|
google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
|
||||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
|
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
|
||||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
@@ -627,8 +664,9 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
|
|||||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
|
|
||||||
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
|
google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
|
||||||
|
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
5
main.go
@@ -19,10 +19,11 @@ func main() {
|
|||||||
Try 'ntfy COMMAND --help' or https://ntfy.sh/docs/ for more information.
|
Try 'ntfy COMMAND --help' or https://ntfy.sh/docs/ for more information.
|
||||||
|
|
||||||
To report a bug, open an issue on GitHub: https://github.com/binwiederhier/ntfy/issues.
|
To report a bug, open an issue on GitHub: https://github.com/binwiederhier/ntfy/issues.
|
||||||
If you want to chat, simply join the Discord server: https://discord.gg/cT7ECsZj9w.
|
If you want to chat, simply join the Discord server (https://discord.gg/cT7ECsZj9w), or
|
||||||
|
the Matrix room (https://matrix.to/#/#ntfy:matrix.org).
|
||||||
|
|
||||||
ntfy %s (%s), runtime %s, built at %s
|
ntfy %s (%s), runtime %s, built at %s
|
||||||
Copyright (C) 2021 Philipp C. Heckel, licensed under Apache License 2.0 & GPLv2
|
Copyright (C) 2022 Philipp C. Heckel, licensed under Apache License 2.0 & GPLv2
|
||||||
`, version, commit[:7], runtime.Version(), date)
|
`, version, commit[:7], runtime.Version(), date)
|
||||||
|
|
||||||
app := cmd.New()
|
app := cmd.New()
|
||||||
|
|||||||
@@ -82,8 +82,9 @@ nav:
|
|||||||
- "Other things":
|
- "Other things":
|
||||||
- "FAQs": faq.md
|
- "FAQs": faq.md
|
||||||
- "Examples": examples.md
|
- "Examples": examples.md
|
||||||
- "Emojis 🥳 🎉": emojis.md
|
- "Release notes": releases.md
|
||||||
- "Deprecation notices": deprecations.md
|
- "Deprecation notices": deprecations.md
|
||||||
|
- "Emojis 🥳 🎉": emojis.md
|
||||||
- "Development": develop.md
|
- "Development": develop.md
|
||||||
- "Privacy policy": privacy.md
|
- "Privacy policy": privacy.md
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
# The documentation uses 'mkdocs', which is written in Python
|
# The documentation uses 'mkdocs', which is written in Python
|
||||||
|
|
||||||
# See https://github.com/squidfunk/mkdocs-material/issues/2030
|
|
||||||
jinja2>=2.11.1
|
|
||||||
|
|
||||||
# mkdocs
|
|
||||||
mkdocs
|
|
||||||
mkdocs-material
|
mkdocs-material
|
||||||
mkdocs-minify-plugin
|
mkdocs-minify-plugin
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ if [ -z "$1" ]; then
|
|||||||
echo "Syntax: $0 FILE.(js|json|md)"
|
echo "Syntax: $0 FILE.(js|json|md)"
|
||||||
echo "Example:"
|
echo "Example:"
|
||||||
echo " $0 emoji-converted.json"
|
echo " $0 emoji-converted.json"
|
||||||
echo " $0 $ROOTDIR/server/static/js/emoji.js"
|
echo " $0 $ROOTDIR/web/src/app/emojis.js"
|
||||||
echo " $0 $ROOTDIR/docs/emojis.md"
|
echo " $0 $ROOTDIR/docs/emojis.md"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -18,8 +18,8 @@ fi
|
|||||||
if [[ "$1" == *.js ]]; then
|
if [[ "$1" == *.js ]]; then
|
||||||
echo -n "// This file is generated by scripts/emoji-convert.sh to reduce the size
|
echo -n "// This file is generated by scripts/emoji-convert.sh to reduce the size
|
||||||
// Original data source: https://github.com/github/gemoji/blob/master/db/emoji.json
|
// Original data source: https://github.com/github/gemoji/blob/master/db/emoji.json
|
||||||
const rawEmojis = " > "$1"
|
export const rawEmojis = " > "$1"
|
||||||
cat "$SCRIPTDIR/emoji.json" | jq -rc 'map({emoji: .emoji,aliases: .aliases})' >> "$1"
|
cat "$SCRIPTDIR/emoji.json" | jq -rc 'map({emoji: .emoji, aliases: .aliases, tags: .tags, category: .category, description: .description, unicode_version: .unicode_version})' >> "$1"
|
||||||
elif [[ "$1" == *.md ]]; then
|
elif [[ "$1" == *.md ]]; then
|
||||||
echo "# Emoji reference
|
echo "# Emoji reference
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ if [ "$1" = "configure" ] || [ "$1" -ge 1 ]; then
|
|||||||
if [ -d /run/systemd/system ]; then
|
if [ -d /run/systemd/system ]; then
|
||||||
# Create ntfy user/group
|
# Create ntfy user/group
|
||||||
id ntfy >/dev/null 2>&1 || useradd --system --no-create-home ntfy
|
id ntfy >/dev/null 2>&1 || useradd --system --no-create-home ntfy
|
||||||
chown ntfy.ntfy /var/cache/ntfy /var/cache/ntfy/attachments
|
chown ntfy.ntfy /var/cache/ntfy /var/cache/ntfy/attachments /var/lib/ntfy
|
||||||
chmod 700 /var/cache/ntfy /var/cache/ntfy/attachments
|
chmod 700 /var/cache/ntfy /var/cache/ntfy/attachments /var/lib/ntfy
|
||||||
|
|
||||||
# Hack to change permissions on cache file
|
# Hack to change permissions on cache file
|
||||||
configfile="/etc/ntfy/server.yml"
|
configfile="/etc/ntfy/server.yml"
|
||||||
|
|||||||
307
server/actions.go
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
actionIDLength = 10
|
||||||
|
actionEOF = rune(0)
|
||||||
|
actionsMax = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
actionView = "view"
|
||||||
|
actionBroadcast = "broadcast"
|
||||||
|
actionHTTP = "http"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
actionsAll = []string{actionView, actionBroadcast, actionHTTP}
|
||||||
|
actionsWithURL = []string{actionView, actionHTTP}
|
||||||
|
actionsKeyRegex = regexp.MustCompile(`^([-.\w]+)\s*=\s*`)
|
||||||
|
)
|
||||||
|
|
||||||
|
type actionParser struct {
|
||||||
|
input string
|
||||||
|
pos int
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseActions 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) {
|
||||||
|
// Parse JSON or simple format
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if strings.HasPrefix(s, "[") {
|
||||||
|
actions, err = parseActionsFromJSON(s)
|
||||||
|
} else {
|
||||||
|
actions, err = parseActionsFromSimple(s)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add ID field, ensure correct uppercase/lowercase
|
||||||
|
for i := range actions {
|
||||||
|
actions[i].ID = util.RandomString(actionIDLength)
|
||||||
|
actions[i].Action = strings.ToLower(actions[i].Action)
|
||||||
|
actions[i].Method = strings.ToUpper(actions[i].Method)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
if len(actions) > actionsMax {
|
||||||
|
return nil, fmt.Errorf("only %d actions allowed", actionsMax)
|
||||||
|
}
|
||||||
|
for _, action := range actions {
|
||||||
|
if !util.InStringList(actionsAll, action.Action) {
|
||||||
|
return nil, fmt.Errorf("parameter 'action' cannot be '%s', valid values are 'view', 'broadcast' and 'http'", action.Action)
|
||||||
|
} else if action.Label == "" {
|
||||||
|
return nil, fmt.Errorf("parameter 'label' is required")
|
||||||
|
} else if util.InStringList(actionsWithURL, action.Action) && action.URL == "" {
|
||||||
|
return nil, fmt.Errorf("parameter 'url' is required for action '%s'", action.Action)
|
||||||
|
} else if action.Action == actionHTTP && util.InStringList([]string{"GET", "HEAD"}, action.Method) && action.Body != "" {
|
||||||
|
return nil, fmt.Errorf("parameter 'body' cannot be set if method is %s", action.Method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseActionsFromJSON converts a JSON array into an array of actions
|
||||||
|
func parseActionsFromJSON(s string) ([]*action, error) {
|
||||||
|
actions := make([]*action, 0)
|
||||||
|
if err := json.Unmarshal([]byte(s), &actions); err != nil {
|
||||||
|
return nil, fmt.Errorf("JSON error: %w", err)
|
||||||
|
}
|
||||||
|
return actions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseActionsFromSimple parses the "simple" actions string (as described in
|
||||||
|
// https://ntfy.sh/docs/publish/#action-buttons), into an array of actions.
|
||||||
|
//
|
||||||
|
// It can parse an actions string like this:
|
||||||
|
// view, "Look ma, commas and \"quotes\" too", url=https://..; action=broadcast, ...
|
||||||
|
//
|
||||||
|
// It works by advancing the position ("pos") through the input string ("input").
|
||||||
|
//
|
||||||
|
// The parser is heavily inspired by https://go.dev/src/text/template/parse/lex.go (which
|
||||||
|
// is described by Rob Pike in this video: https://www.youtube.com/watch?v=HxaD_trXwRE),
|
||||||
|
// though it does not use state functions at all.
|
||||||
|
//
|
||||||
|
// Other resources:
|
||||||
|
// https://adampresley.github.io/2015/04/12/writing-a-lexer-and-parser-in-go-part-1.html
|
||||||
|
// https://github.com/adampresley/sample-ini-parser/blob/master/services/lexer/lexer/Lexer.go
|
||||||
|
// https://github.com/benbjohnson/sql-parser/blob/master/scanner.go
|
||||||
|
// https://blog.gopheracademy.com/advent-2014/parsers-lexers/
|
||||||
|
func parseActionsFromSimple(s string) ([]*action, error) {
|
||||||
|
if !utf8.ValidString(s) {
|
||||||
|
return nil, errors.New("invalid utf-8 string")
|
||||||
|
}
|
||||||
|
parser := &actionParser{
|
||||||
|
pos: 0,
|
||||||
|
input: s,
|
||||||
|
}
|
||||||
|
return parser.Parse()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse loops trough parseAction() until the end of the string is reached
|
||||||
|
func (p *actionParser) Parse() ([]*action, error) {
|
||||||
|
actions := make([]*action, 0)
|
||||||
|
for !p.eof() {
|
||||||
|
a, err := p.parseAction()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
actions = append(actions, a)
|
||||||
|
}
|
||||||
|
return actions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
section := 0
|
||||||
|
for {
|
||||||
|
key, value, last, err := p.parseSection()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := populateAction(a, section, key, value); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.slurpSpaces()
|
||||||
|
if last {
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
section++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
// Auto-expand keys based on their index
|
||||||
|
if key == "" && section == 0 {
|
||||||
|
key = "action"
|
||||||
|
} else if key == "" && section == 1 {
|
||||||
|
key = "label"
|
||||||
|
} else if key == "" && section == 2 && util.InStringList(actionsWithURL, newAction.Action) {
|
||||||
|
key = "url"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
if key == "" {
|
||||||
|
return fmt.Errorf("term '%s' unknown", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate
|
||||||
|
if strings.HasPrefix(key, "headers.") {
|
||||||
|
newAction.Headers[strings.TrimPrefix(key, "headers.")] = value
|
||||||
|
} else if strings.HasPrefix(key, "extras.") {
|
||||||
|
newAction.Extras[strings.TrimPrefix(key, "extras.")] = value
|
||||||
|
} else {
|
||||||
|
switch strings.ToLower(key) {
|
||||||
|
case "action":
|
||||||
|
newAction.Action = value
|
||||||
|
case "label":
|
||||||
|
newAction.Label = value
|
||||||
|
case "clear":
|
||||||
|
lvalue := strings.ToLower(value)
|
||||||
|
if !util.InStringList([]string{"true", "yes", "1", "false", "no", "0"}, lvalue) {
|
||||||
|
return fmt.Errorf("parameter 'clear' cannot be '%s', only boolean values are allowed (true/yes/1/false/no/0)", value)
|
||||||
|
}
|
||||||
|
newAction.Clear = lvalue == "true" || lvalue == "yes" || lvalue == "1"
|
||||||
|
case "url":
|
||||||
|
newAction.URL = value
|
||||||
|
case "method":
|
||||||
|
newAction.Method = value
|
||||||
|
case "body":
|
||||||
|
newAction.Body = value
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("key '%s' unknown", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSection parses a section ("key=value") and returns a key/value pair. It terminates
|
||||||
|
// when EOF or "," is reached.
|
||||||
|
func (p *actionParser) parseSection() (key string, value string, last bool, err error) {
|
||||||
|
p.slurpSpaces()
|
||||||
|
key = p.parseKey()
|
||||||
|
r, w := p.peek()
|
||||||
|
if isSectionEnd(r) {
|
||||||
|
p.pos += w
|
||||||
|
last = isLastSection(r)
|
||||||
|
return
|
||||||
|
} else if r == '"' || r == '\'' {
|
||||||
|
value, last, err = p.parseQuotedValue(r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
value, last = p.parseValue()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseKey uses a regex to determine whether the current position is a key definition ("key =")
|
||||||
|
// and returns the key if it is, or an empty string otherwise.
|
||||||
|
func (p *actionParser) parseKey() string {
|
||||||
|
matches := actionsKeyRegex.FindStringSubmatch(p.input[p.pos:])
|
||||||
|
if len(matches) == 2 {
|
||||||
|
p.pos += len(matches[0])
|
||||||
|
return matches[1]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseValue reads the input until EOF, "," or ";" and returns the value string. Unlike parseQuotedValue,
|
||||||
|
// this function does not support "," or ";" in the value itself, and spaces in the beginning and end of the
|
||||||
|
// string are trimmed.
|
||||||
|
func (p *actionParser) parseValue() (value string, last bool) {
|
||||||
|
start := p.pos
|
||||||
|
for {
|
||||||
|
r, w := p.peek()
|
||||||
|
if isSectionEnd(r) {
|
||||||
|
last = isLastSection(r)
|
||||||
|
value = strings.TrimSpace(p.input[start:p.pos])
|
||||||
|
p.pos += w
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.pos += w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseQuotedValue reads the input until it finds an unescaped end quote character ("), and then
|
||||||
|
// advances the position beyond the section end. It supports quoting strings using backslash (\).
|
||||||
|
func (p *actionParser) parseQuotedValue(quote rune) (value string, last bool, err error) {
|
||||||
|
p.pos++
|
||||||
|
start := p.pos
|
||||||
|
var prev rune
|
||||||
|
for {
|
||||||
|
r, w := p.peek()
|
||||||
|
if r == actionEOF {
|
||||||
|
err = fmt.Errorf("unexpected end of input, quote started at position %d", start)
|
||||||
|
return
|
||||||
|
} else if r == quote && prev != '\\' {
|
||||||
|
value = strings.ReplaceAll(p.input[start:p.pos], "\\"+string(quote), string(quote)) // \" -> "
|
||||||
|
p.pos += w
|
||||||
|
|
||||||
|
// Advance until section end (after "," or ";")
|
||||||
|
p.slurpSpaces()
|
||||||
|
r, w := p.peek()
|
||||||
|
last = isLastSection(r)
|
||||||
|
if !isSectionEnd(r) {
|
||||||
|
err = fmt.Errorf("unexpected character '%c' at position %d", r, p.pos)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.pos += w
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prev = r
|
||||||
|
p.pos += w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// slurpSpaces reads all space characters and advances the position
|
||||||
|
func (p *actionParser) slurpSpaces() {
|
||||||
|
for {
|
||||||
|
r, w := p.peek()
|
||||||
|
if r == actionEOF || !isSpace(r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.pos += w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// peek returns the next run and its width
|
||||||
|
func (p *actionParser) peek() (rune, int) {
|
||||||
|
if p.eof() {
|
||||||
|
return actionEOF, 0
|
||||||
|
}
|
||||||
|
return utf8.DecodeRuneInString(p.input[p.pos:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// eof returns true if the end of the input has been reached
|
||||||
|
func (p *actionParser) eof() bool {
|
||||||
|
return p.pos >= len(p.input)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSpace(r rune) bool {
|
||||||
|
return r == ' ' || r == '\t' || r == '\r' || r == '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSectionEnd(r rune) bool {
|
||||||
|
return r == actionEOF || r == ';' || r == ','
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLastSection(r rune) bool {
|
||||||
|
return r == actionEOF || r == ';'
|
||||||
|
}
|
||||||
176
server/actions_test.go
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseActions(t *testing.T) {
|
||||||
|
actions, err := parseActions("[]")
|
||||||
|
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")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 2, len(actions))
|
||||||
|
require.Equal(t, "http", actions[0].Action)
|
||||||
|
require.Equal(t, "Open door", actions[0].Label)
|
||||||
|
require.Equal(t, "https://door.lan/open", actions[0].URL)
|
||||||
|
require.Equal(t, "view", actions[1].Action)
|
||||||
|
require.Equal(t, "Show portal", actions[1].Label)
|
||||||
|
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"}]`)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 2, len(actions))
|
||||||
|
require.Equal(t, "http", actions[0].Action)
|
||||||
|
require.Equal(t, "Open door", actions[0].Label)
|
||||||
|
require.Equal(t, "https://door.lan/open", actions[0].URL)
|
||||||
|
require.Equal(t, "view", actions[1].Action)
|
||||||
|
require.Equal(t, "Show portal", actions[1].Label)
|
||||||
|
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")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(actions))
|
||||||
|
require.Equal(t, "http", actions[0].Action)
|
||||||
|
require.Equal(t, "Open door", actions[0].Label)
|
||||||
|
require.Equal(t, "https://door.lan/open", actions[0].URL)
|
||||||
|
require.Equal(t, "PUT", actions[0].Method)
|
||||||
|
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")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(actions))
|
||||||
|
require.Equal(t, "broadcast", actions[0].Action)
|
||||||
|
require.Equal(t, "Do a thing", actions[0].Label)
|
||||||
|
require.Equal(t, 2, len(actions[0].Extras))
|
||||||
|
require.Equal(t, "some command", actions[0].Extras["command"])
|
||||||
|
require.Equal(t, "a parameter", actions[0].Extras["some_param"])
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(actions))
|
||||||
|
require.Equal(t, "http", actions[0].Action)
|
||||||
|
require.Equal(t, "Send request", actions[0].Label)
|
||||||
|
require.Equal(t, 2, len(actions[0].Headers))
|
||||||
|
require.Equal(t, "application/json", actions[0].Headers["Content-Type"])
|
||||||
|
require.Equal(t, "Basic sdasffsf", actions[0].Headers["Authorization"])
|
||||||
|
|
||||||
|
// Quotes
|
||||||
|
actions, err = parseActions(`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)
|
||||||
|
require.Equal(t, `Look ma, "quotes"; and semicolons`, actions[0].Label)
|
||||||
|
require.Equal(t, `http://example.com`, actions[0].URL)
|
||||||
|
|
||||||
|
// Single quotes
|
||||||
|
actions, err = parseActions(`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)
|
||||||
|
require.Equal(t, `"quotes" and 'single quotes'`, actions[0].Label)
|
||||||
|
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}'`)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(actions))
|
||||||
|
require.Equal(t, "http", actions[0].Action)
|
||||||
|
require.Equal(t, "Post it", actions[0].Label)
|
||||||
|
require.Equal(t, `http://example.com`, actions[0].URL)
|
||||||
|
require.Equal(t, `{"temperature": 65}`, actions[0].Body)
|
||||||
|
|
||||||
|
// Out of order
|
||||||
|
actions, err = parseActions(`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)
|
||||||
|
require.Equal(t, `Out of order!`, actions[0].Label)
|
||||||
|
require.Equal(t, `http://example.com`, actions[0].URL)
|
||||||
|
|
||||||
|
// Spaces
|
||||||
|
actions, err = parseActions(`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)
|
||||||
|
require.Equal(t, `this is a label`, actions[0].Label)
|
||||||
|
require.Equal(t, `http://google.com`, actions[0].URL)
|
||||||
|
|
||||||
|
// Non-ASCII
|
||||||
|
actions, err = parseActions(`action = http, 'Кохайтеся а не воюйте, 💙🫤', url = "http://google.com"`)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(actions))
|
||||||
|
require.Equal(t, "http", actions[0].Action)
|
||||||
|
require.Equal(t, `Кохайтеся а не воюйте, 💙🫤`, actions[0].Label)
|
||||||
|
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`)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 2, len(actions))
|
||||||
|
require.Equal(t, "http", actions[0].Action)
|
||||||
|
require.Equal(t, `Make love, not war 💙🫤`, actions[0].Label)
|
||||||
|
require.Equal(t, `https://ntfy.sh`, actions[0].URL)
|
||||||
|
require.Equal(t, false, actions[0].Clear)
|
||||||
|
require.Equal(t, "view", actions[1].Action)
|
||||||
|
require.Equal(t, " yo ", actions[1].Label)
|
||||||
|
require.Equal(t, `https://x.org`, actions[1].URL)
|
||||||
|
require.Equal(t, true, actions[1].Clear)
|
||||||
|
|
||||||
|
// Invalid syntax
|
||||||
|
_, err = parseActions(`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`)
|
||||||
|
require.EqualError(t, err, "parameter 'label' is required")
|
||||||
|
|
||||||
|
_, err = parseActions(`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`)
|
||||||
|
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 = parseActions(`aaa=a, "bbb, 'ccc, ddd, eee "`)
|
||||||
|
require.EqualError(t, err, "key 'aaa' unknown")
|
||||||
|
|
||||||
|
_, err = parseActions(`action=http, label="omg the end quote is missing`)
|
||||||
|
require.EqualError(t, err, "unexpected end of input, quote started at position 20")
|
||||||
|
|
||||||
|
_, err = parseActions(`;;;;`)
|
||||||
|
require.EqualError(t, err, "only 3 actions allowed")
|
||||||
|
|
||||||
|
_, err = parseActions(`,,,,,,;;`)
|
||||||
|
require.EqualError(t, err, "term '' unknown")
|
||||||
|
|
||||||
|
_, err = parseActions(`''";,;"`)
|
||||||
|
require.EqualError(t, err, "unexpected character '\"' at position 2")
|
||||||
|
|
||||||
|
_, err = parseActions(`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`)
|
||||||
|
require.EqualError(t, err, "parameter 'body' cannot be set if method is HEAD")
|
||||||
|
|
||||||
|
_, err = parseActions(`[ 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 = parseActions("\x00\x01\xFFx\xFE")
|
||||||
|
require.EqualError(t, err, "invalid utf-8 string")
|
||||||
|
|
||||||
|
_, err = parseActions(`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)")
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
errUnexpectedMessageType = errors.New("unexpected message type")
|
|
||||||
)
|
|
||||||
|
|
||||||
// cache implements a cache for messages of type "message" events,
|
|
||||||
// i.e. message structs with the Event messageEvent.
|
|
||||||
type cache interface {
|
|
||||||
AddMessage(m *message) error
|
|
||||||
Messages(topic string, since sinceTime, scheduled bool) ([]*message, error)
|
|
||||||
MessagesDue() ([]*message, error)
|
|
||||||
MessageCount(topic string) (int, error)
|
|
||||||
Topics() (map[string]*topic, error)
|
|
||||||
Prune(olderThan time.Time) error
|
|
||||||
MarkPublished(m *message) error
|
|
||||||
AttachmentsSize(owner string) (int64, error)
|
|
||||||
AttachmentsExpired() ([]string, error)
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sort"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type memCache struct {
|
|
||||||
messages map[string][]*message
|
|
||||||
scheduled map[string]*message // Message ID -> message
|
|
||||||
nop bool
|
|
||||||
mu sync.Mutex
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ cache = (*memCache)(nil)
|
|
||||||
|
|
||||||
// newMemCache creates an in-memory cache
|
|
||||||
func newMemCache() *memCache {
|
|
||||||
return &memCache{
|
|
||||||
messages: make(map[string][]*message),
|
|
||||||
scheduled: make(map[string]*message),
|
|
||||||
nop: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// newNopCache creates an in-memory cache that discards all messages;
|
|
||||||
// it is always empty and can be used if caching is entirely disabled
|
|
||||||
func newNopCache() *memCache {
|
|
||||||
return &memCache{
|
|
||||||
messages: make(map[string][]*message),
|
|
||||||
scheduled: make(map[string]*message),
|
|
||||||
nop: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *memCache) AddMessage(m *message) error {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
if c.nop {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if m.Event != messageEvent {
|
|
||||||
return errUnexpectedMessageType
|
|
||||||
}
|
|
||||||
if _, ok := c.messages[m.Topic]; !ok {
|
|
||||||
c.messages[m.Topic] = make([]*message, 0)
|
|
||||||
}
|
|
||||||
delayed := m.Time > time.Now().Unix()
|
|
||||||
if delayed {
|
|
||||||
c.scheduled[m.ID] = m
|
|
||||||
}
|
|
||||||
c.messages[m.Topic] = append(c.messages[m.Topic], m)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *memCache) Messages(topic string, since sinceTime, scheduled bool) ([]*message, error) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
if _, ok := c.messages[topic]; !ok || since.IsNone() {
|
|
||||||
return make([]*message, 0), nil
|
|
||||||
}
|
|
||||||
messages := make([]*message, 0)
|
|
||||||
for _, m := range c.messages[topic] {
|
|
||||||
_, messageScheduled := c.scheduled[m.ID]
|
|
||||||
include := m.Time >= since.Time().Unix() && (!messageScheduled || scheduled)
|
|
||||||
if include {
|
|
||||||
messages = append(messages, m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Slice(messages, func(i, j int) bool {
|
|
||||||
return messages[i].Time < messages[j].Time
|
|
||||||
})
|
|
||||||
return messages, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *memCache) MessagesDue() ([]*message, error) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
messages := make([]*message, 0)
|
|
||||||
for _, m := range c.scheduled {
|
|
||||||
due := time.Now().Unix() >= m.Time
|
|
||||||
if due {
|
|
||||||
messages = append(messages, m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Slice(messages, func(i, j int) bool {
|
|
||||||
return messages[i].Time < messages[j].Time
|
|
||||||
})
|
|
||||||
return messages, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *memCache) MarkPublished(m *message) error {
|
|
||||||
c.mu.Lock()
|
|
||||||
delete(c.scheduled, m.ID)
|
|
||||||
c.mu.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *memCache) MessageCount(topic string) (int, error) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
if _, ok := c.messages[topic]; !ok {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
return len(c.messages[topic]), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *memCache) Topics() (map[string]*topic, error) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
topics := make(map[string]*topic)
|
|
||||||
for topic := range c.messages {
|
|
||||||
topics[topic] = newTopic(topic)
|
|
||||||
}
|
|
||||||
return topics, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *memCache) Prune(olderThan time.Time) error {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
for topic := range c.messages {
|
|
||||||
c.pruneTopic(topic, olderThan)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *memCache) AttachmentsSize(owner string) (int64, error) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
var size int64
|
|
||||||
for topic := range c.messages {
|
|
||||||
for _, m := range c.messages[topic] {
|
|
||||||
counted := m.Attachment != nil && m.Attachment.Owner == owner && m.Attachment.Expires > time.Now().Unix()
|
|
||||||
if counted {
|
|
||||||
size += m.Attachment.Size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return size, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *memCache) AttachmentsExpired() ([]string, error) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
ids := make([]string, 0)
|
|
||||||
for topic := range c.messages {
|
|
||||||
for _, m := range c.messages[topic] {
|
|
||||||
if m.Attachment != nil && m.Attachment.Expires > 0 && m.Attachment.Expires < time.Now().Unix() {
|
|
||||||
ids = append(ids, m.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ids, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *memCache) pruneTopic(topic string, olderThan time.Time) {
|
|
||||||
messages := make([]*message, 0)
|
|
||||||
for _, m := range c.messages[topic] {
|
|
||||||
if m.Time >= olderThan.Unix() {
|
|
||||||
messages = append(messages, m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.messages[topic] = messages
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestMemCache_Messages(t *testing.T) {
|
|
||||||
testCacheMessages(t, newMemCache())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMemCache_MessagesScheduled(t *testing.T) {
|
|
||||||
testCacheMessagesScheduled(t, newMemCache())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMemCache_Topics(t *testing.T) {
|
|
||||||
testCacheTopics(t, newMemCache())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMemCache_MessagesTagsPrioAndTitle(t *testing.T) {
|
|
||||||
testCacheMessagesTagsPrioAndTitle(t, newMemCache())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMemCache_Prune(t *testing.T) {
|
|
||||||
testCachePrune(t, newMemCache())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMemCache_Attachments(t *testing.T) {
|
|
||||||
testCacheAttachments(t, newMemCache())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMemCache_NopCache(t *testing.T) {
|
|
||||||
c := newNopCache()
|
|
||||||
assert.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "my message")))
|
|
||||||
|
|
||||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Empty(t, messages)
|
|
||||||
|
|
||||||
topics, err := c.Topics()
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Empty(t, topics)
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSqliteCache_Messages(t *testing.T) {
|
|
||||||
testCacheMessages(t, newSqliteTestCache(t))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSqliteCache_MessagesScheduled(t *testing.T) {
|
|
||||||
testCacheMessagesScheduled(t, newSqliteTestCache(t))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSqliteCache_Topics(t *testing.T) {
|
|
||||||
testCacheTopics(t, newSqliteTestCache(t))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSqliteCache_MessagesTagsPrioAndTitle(t *testing.T) {
|
|
||||||
testCacheMessagesTagsPrioAndTitle(t, newSqliteTestCache(t))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSqliteCache_Prune(t *testing.T) {
|
|
||||||
testCachePrune(t, newSqliteTestCache(t))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSqliteCache_Attachments(t *testing.T) {
|
|
||||||
testCacheAttachments(t, newSqliteTestCache(t))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSqliteCache_Migration_From0(t *testing.T) {
|
|
||||||
filename := newSqliteTestCacheFile(t)
|
|
||||||
db, err := sql.Open("sqlite3", filename)
|
|
||||||
require.Nil(t, err)
|
|
||||||
|
|
||||||
// Create "version 0" schema
|
|
||||||
_, err = db.Exec(`
|
|
||||||
BEGIN;
|
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
|
||||||
id VARCHAR(20) PRIMARY KEY,
|
|
||||||
time INT NOT NULL,
|
|
||||||
topic VARCHAR(64) NOT NULL,
|
|
||||||
message VARCHAR(1024) NOT NULL
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
|
||||||
COMMIT;
|
|
||||||
`)
|
|
||||||
require.Nil(t, err)
|
|
||||||
|
|
||||||
// Insert a bunch of messages
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
_, err = db.Exec(`INSERT INTO messages (id, time, topic, message) VALUES (?, ?, ?, ?)`,
|
|
||||||
fmt.Sprintf("abcd%d", i), time.Now().Unix(), "mytopic", fmt.Sprintf("some message %d", i))
|
|
||||||
require.Nil(t, err)
|
|
||||||
}
|
|
||||||
require.Nil(t, db.Close())
|
|
||||||
|
|
||||||
// Create cache to trigger migration
|
|
||||||
c := newSqliteTestCacheFromFile(t, filename)
|
|
||||||
checkSchemaVersion(t, c.db)
|
|
||||||
|
|
||||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, 10, len(messages))
|
|
||||||
require.Equal(t, "some message 5", messages[5].Message)
|
|
||||||
require.Equal(t, "", messages[5].Title)
|
|
||||||
require.Nil(t, messages[5].Tags)
|
|
||||||
require.Equal(t, 0, messages[5].Priority)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSqliteCache_Migration_From1(t *testing.T) {
|
|
||||||
filename := newSqliteTestCacheFile(t)
|
|
||||||
db, err := sql.Open("sqlite3", filename)
|
|
||||||
require.Nil(t, err)
|
|
||||||
|
|
||||||
// Create "version 1" schema
|
|
||||||
_, err = db.Exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
|
||||||
id VARCHAR(20) PRIMARY KEY,
|
|
||||||
time INT NOT NULL,
|
|
||||||
topic VARCHAR(64) NOT NULL,
|
|
||||||
message VARCHAR(512) NOT NULL,
|
|
||||||
title VARCHAR(256) NOT NULL,
|
|
||||||
priority INT NOT NULL,
|
|
||||||
tags VARCHAR(256) NOT NULL
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
|
||||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
|
||||||
id INT PRIMARY KEY,
|
|
||||||
version INT NOT NULL
|
|
||||||
);
|
|
||||||
INSERT INTO schemaVersion (id, version) VALUES (1, 1);
|
|
||||||
`)
|
|
||||||
require.Nil(t, err)
|
|
||||||
|
|
||||||
// Insert a bunch of messages
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
_, err = db.Exec(`INSERT INTO messages (id, time, topic, message, title, priority, tags) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
fmt.Sprintf("abcd%d", i), time.Now().Unix(), "mytopic", fmt.Sprintf("some message %d", i), "", 0, "")
|
|
||||||
require.Nil(t, err)
|
|
||||||
}
|
|
||||||
require.Nil(t, db.Close())
|
|
||||||
|
|
||||||
// Create cache to trigger migration
|
|
||||||
c := newSqliteTestCacheFromFile(t, filename)
|
|
||||||
checkSchemaVersion(t, c.db)
|
|
||||||
|
|
||||||
// Add delayed message
|
|
||||||
delayedMessage := newDefaultMessage("mytopic", "some delayed message")
|
|
||||||
delayedMessage.Time = time.Now().Add(time.Minute).Unix()
|
|
||||||
require.Nil(t, c.AddMessage(delayedMessage))
|
|
||||||
|
|
||||||
// 10, not 11!
|
|
||||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, 10, len(messages))
|
|
||||||
|
|
||||||
// 11!
|
|
||||||
messages, err = c.Messages("mytopic", sinceAllMessages, true)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, 11, len(messages))
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkSchemaVersion(t *testing.T, db *sql.DB) {
|
|
||||||
rows, err := db.Query(`SELECT version FROM schemaVersion`)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.True(t, rows.Next())
|
|
||||||
|
|
||||||
var schemaVersion int
|
|
||||||
require.Nil(t, rows.Scan(&schemaVersion))
|
|
||||||
require.Equal(t, currentSchemaVersion, schemaVersion)
|
|
||||||
require.Nil(t, rows.Close())
|
|
||||||
}
|
|
||||||
|
|
||||||
func newSqliteTestCache(t *testing.T) *sqliteCache {
|
|
||||||
c, err := newSqliteCache(newSqliteTestCacheFile(t))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
func newSqliteTestCacheFile(t *testing.T) string {
|
|
||||||
return filepath.Join(t.TempDir(), "cache.db")
|
|
||||||
}
|
|
||||||
|
|
||||||
func newSqliteTestCacheFromFile(t *testing.T, filename string) *sqliteCache {
|
|
||||||
c, err := newSqliteCache(filename)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func testCacheMessages(t *testing.T, c cache) {
|
|
||||||
m1 := newDefaultMessage("mytopic", "my message")
|
|
||||||
m1.Time = 1
|
|
||||||
|
|
||||||
m2 := newDefaultMessage("mytopic", "my other message")
|
|
||||||
m2.Time = 2
|
|
||||||
|
|
||||||
require.Nil(t, c.AddMessage(m1))
|
|
||||||
require.Nil(t, c.AddMessage(newDefaultMessage("example", "my example message")))
|
|
||||||
require.Nil(t, c.AddMessage(m2))
|
|
||||||
|
|
||||||
// Adding invalid
|
|
||||||
require.Equal(t, errUnexpectedMessageType, c.AddMessage(newKeepaliveMessage("mytopic"))) // These should not be added!
|
|
||||||
require.Equal(t, errUnexpectedMessageType, c.AddMessage(newOpenMessage("example"))) // These should not be added!
|
|
||||||
|
|
||||||
// mytopic: count
|
|
||||||
count, err := c.MessageCount("mytopic")
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, 2, count)
|
|
||||||
|
|
||||||
// mytopic: since all
|
|
||||||
messages, _ := c.Messages("mytopic", sinceAllMessages, false)
|
|
||||||
require.Equal(t, 2, len(messages))
|
|
||||||
require.Equal(t, "my message", messages[0].Message)
|
|
||||||
require.Equal(t, "mytopic", messages[0].Topic)
|
|
||||||
require.Equal(t, messageEvent, messages[0].Event)
|
|
||||||
require.Equal(t, "", messages[0].Title)
|
|
||||||
require.Equal(t, 0, messages[0].Priority)
|
|
||||||
require.Nil(t, messages[0].Tags)
|
|
||||||
require.Equal(t, "my other message", messages[1].Message)
|
|
||||||
|
|
||||||
// mytopic: since none
|
|
||||||
messages, _ = c.Messages("mytopic", sinceNoMessages, false)
|
|
||||||
require.Empty(t, messages)
|
|
||||||
|
|
||||||
// mytopic: since 2
|
|
||||||
messages, _ = c.Messages("mytopic", sinceTime(time.Unix(2, 0)), false)
|
|
||||||
require.Equal(t, 1, len(messages))
|
|
||||||
require.Equal(t, "my other message", messages[0].Message)
|
|
||||||
|
|
||||||
// example: count
|
|
||||||
count, err = c.MessageCount("example")
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, 1, count)
|
|
||||||
|
|
||||||
// example: since all
|
|
||||||
messages, _ = c.Messages("example", sinceAllMessages, false)
|
|
||||||
require.Equal(t, "my example message", messages[0].Message)
|
|
||||||
|
|
||||||
// non-existing: count
|
|
||||||
count, err = c.MessageCount("doesnotexist")
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, 0, count)
|
|
||||||
|
|
||||||
// non-existing: since all
|
|
||||||
messages, _ = c.Messages("doesnotexist", sinceAllMessages, false)
|
|
||||||
require.Empty(t, messages)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testCacheTopics(t *testing.T, c cache) {
|
|
||||||
require.Nil(t, c.AddMessage(newDefaultMessage("topic1", "my example message")))
|
|
||||||
require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 1")))
|
|
||||||
require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 2")))
|
|
||||||
require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 3")))
|
|
||||||
|
|
||||||
topics, err := c.Topics()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
require.Equal(t, 2, len(topics))
|
|
||||||
require.Equal(t, "topic1", topics["topic1"].ID)
|
|
||||||
require.Equal(t, "topic2", topics["topic2"].ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testCachePrune(t *testing.T, c cache) {
|
|
||||||
m1 := newDefaultMessage("mytopic", "my message")
|
|
||||||
m1.Time = 1
|
|
||||||
|
|
||||||
m2 := newDefaultMessage("mytopic", "my other message")
|
|
||||||
m2.Time = 2
|
|
||||||
|
|
||||||
m3 := newDefaultMessage("another_topic", "and another one")
|
|
||||||
m3.Time = 1
|
|
||||||
|
|
||||||
require.Nil(t, c.AddMessage(m1))
|
|
||||||
require.Nil(t, c.AddMessage(m2))
|
|
||||||
require.Nil(t, c.AddMessage(m3))
|
|
||||||
require.Nil(t, c.Prune(time.Unix(2, 0)))
|
|
||||||
|
|
||||||
count, err := c.MessageCount("mytopic")
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, 1, count)
|
|
||||||
|
|
||||||
count, err = c.MessageCount("another_topic")
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, 0, count)
|
|
||||||
|
|
||||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, 1, len(messages))
|
|
||||||
require.Equal(t, "my other message", messages[0].Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testCacheMessagesTagsPrioAndTitle(t *testing.T, c cache) {
|
|
||||||
m := newDefaultMessage("mytopic", "some message")
|
|
||||||
m.Tags = []string{"tag1", "tag2"}
|
|
||||||
m.Priority = 5
|
|
||||||
m.Title = "some title"
|
|
||||||
require.Nil(t, c.AddMessage(m))
|
|
||||||
|
|
||||||
messages, _ := c.Messages("mytopic", sinceAllMessages, false)
|
|
||||||
require.Equal(t, []string{"tag1", "tag2"}, messages[0].Tags)
|
|
||||||
require.Equal(t, 5, messages[0].Priority)
|
|
||||||
require.Equal(t, "some title", messages[0].Title)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testCacheMessagesScheduled(t *testing.T, c cache) {
|
|
||||||
m1 := newDefaultMessage("mytopic", "message 1")
|
|
||||||
m2 := newDefaultMessage("mytopic", "message 2")
|
|
||||||
m2.Time = time.Now().Add(time.Hour).Unix()
|
|
||||||
m3 := newDefaultMessage("mytopic", "message 3")
|
|
||||||
m3.Time = time.Now().Add(time.Minute).Unix() // earlier than m2!
|
|
||||||
m4 := newDefaultMessage("mytopic2", "message 4")
|
|
||||||
m4.Time = time.Now().Add(time.Minute).Unix()
|
|
||||||
require.Nil(t, c.AddMessage(m1))
|
|
||||||
require.Nil(t, c.AddMessage(m2))
|
|
||||||
require.Nil(t, c.AddMessage(m3))
|
|
||||||
|
|
||||||
messages, _ := c.Messages("mytopic", sinceAllMessages, false) // exclude scheduled
|
|
||||||
require.Equal(t, 1, len(messages))
|
|
||||||
require.Equal(t, "message 1", messages[0].Message)
|
|
||||||
|
|
||||||
messages, _ = c.Messages("mytopic", sinceAllMessages, true) // include scheduled
|
|
||||||
require.Equal(t, 3, len(messages))
|
|
||||||
require.Equal(t, "message 1", messages[0].Message)
|
|
||||||
require.Equal(t, "message 3", messages[1].Message) // Order!
|
|
||||||
require.Equal(t, "message 2", messages[2].Message)
|
|
||||||
|
|
||||||
messages, _ = c.MessagesDue()
|
|
||||||
require.Empty(t, messages)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testCacheAttachments(t *testing.T, c cache) {
|
|
||||||
expires1 := time.Now().Add(-4 * time.Hour).Unix()
|
|
||||||
m := newDefaultMessage("mytopic", "flower for you")
|
|
||||||
m.ID = "m1"
|
|
||||||
m.Attachment = &attachment{
|
|
||||||
Name: "flower.jpg",
|
|
||||||
Type: "image/jpeg",
|
|
||||||
Size: 5000,
|
|
||||||
Expires: expires1,
|
|
||||||
URL: "https://ntfy.sh/file/AbDeFgJhal.jpg",
|
|
||||||
Owner: "1.2.3.4",
|
|
||||||
}
|
|
||||||
require.Nil(t, c.AddMessage(m))
|
|
||||||
|
|
||||||
expires2 := time.Now().Add(2 * time.Hour).Unix() // Future
|
|
||||||
m = newDefaultMessage("mytopic", "sending you a car")
|
|
||||||
m.ID = "m2"
|
|
||||||
m.Attachment = &attachment{
|
|
||||||
Name: "car.jpg",
|
|
||||||
Type: "image/jpeg",
|
|
||||||
Size: 10000,
|
|
||||||
Expires: expires2,
|
|
||||||
URL: "https://ntfy.sh/file/aCaRURL.jpg",
|
|
||||||
Owner: "1.2.3.4",
|
|
||||||
}
|
|
||||||
require.Nil(t, c.AddMessage(m))
|
|
||||||
|
|
||||||
expires3 := time.Now().Add(1 * time.Hour).Unix() // Future
|
|
||||||
m = newDefaultMessage("another-topic", "sending you another car")
|
|
||||||
m.ID = "m3"
|
|
||||||
m.Attachment = &attachment{
|
|
||||||
Name: "another-car.jpg",
|
|
||||||
Type: "image/jpeg",
|
|
||||||
Size: 20000,
|
|
||||||
Expires: expires3,
|
|
||||||
URL: "https://ntfy.sh/file/zakaDHFW.jpg",
|
|
||||||
Owner: "1.2.3.4",
|
|
||||||
}
|
|
||||||
require.Nil(t, c.AddMessage(m))
|
|
||||||
|
|
||||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, 2, len(messages))
|
|
||||||
|
|
||||||
require.Equal(t, "flower for you", messages[0].Message)
|
|
||||||
require.Equal(t, "flower.jpg", messages[0].Attachment.Name)
|
|
||||||
require.Equal(t, "image/jpeg", messages[0].Attachment.Type)
|
|
||||||
require.Equal(t, int64(5000), messages[0].Attachment.Size)
|
|
||||||
require.Equal(t, expires1, messages[0].Attachment.Expires)
|
|
||||||
require.Equal(t, "https://ntfy.sh/file/AbDeFgJhal.jpg", messages[0].Attachment.URL)
|
|
||||||
require.Equal(t, "1.2.3.4", messages[0].Attachment.Owner)
|
|
||||||
|
|
||||||
require.Equal(t, "sending you a car", messages[1].Message)
|
|
||||||
require.Equal(t, "car.jpg", messages[1].Attachment.Name)
|
|
||||||
require.Equal(t, "image/jpeg", messages[1].Attachment.Type)
|
|
||||||
require.Equal(t, int64(10000), messages[1].Attachment.Size)
|
|
||||||
require.Equal(t, expires2, messages[1].Attachment.Expires)
|
|
||||||
require.Equal(t, "https://ntfy.sh/file/aCaRURL.jpg", messages[1].Attachment.URL)
|
|
||||||
require.Equal(t, "1.2.3.4", messages[1].Attachment.Owner)
|
|
||||||
|
|
||||||
size, err := c.AttachmentsSize("1.2.3.4")
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, int64(30000), size)
|
|
||||||
|
|
||||||
size, err = c.AttachmentsSize("5.6.7.8")
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, int64(0), size)
|
|
||||||
|
|
||||||
ids, err := c.AttachmentsExpired()
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, []string{"m1"}, ids)
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
DefaultListenHTTP = ":80"
|
DefaultListenHTTP = ":80"
|
||||||
DefaultCacheDuration = 12 * time.Hour
|
DefaultCacheDuration = 12 * time.Hour
|
||||||
DefaultKeepaliveInterval = 55 * time.Second // Not too frequently to save battery (Android read timeout is 77s!)
|
DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!)
|
||||||
DefaultManagerInterval = time.Minute
|
DefaultManagerInterval = time.Minute
|
||||||
DefaultAtSenderInterval = 10 * time.Second
|
DefaultAtSenderInterval = 10 * time.Second
|
||||||
DefaultMinDelay = 10 * time.Second
|
DefaultMinDelay = 10 * time.Second
|
||||||
@@ -30,14 +30,14 @@ const (
|
|||||||
|
|
||||||
// Defines all per-visitor limits
|
// Defines all per-visitor limits
|
||||||
// - per visitor subscription limit: max number of subscriptions (active HTTP connections) per per-visitor/IP
|
// - per visitor subscription limit: max number of subscriptions (active HTTP connections) per per-visitor/IP
|
||||||
// - per visitor request limit: max number of PUT/GET/.. requests (here: 60 requests bucket, replenished at a rate of one per 10 seconds)
|
// - per visitor request limit: max number of PUT/GET/.. requests (here: 60 requests bucket, replenished at a rate of one per 5 seconds)
|
||||||
// - per visitor email limit: max number of emails (here: 16 email bucket, replenished at a rate of one per hour)
|
// - per visitor email limit: max number of emails (here: 16 email bucket, replenished at a rate of one per hour)
|
||||||
// - per visitor attachment size limit: total per-visitor attachment size in bytes to be stored on the server
|
// - per visitor attachment size limit: total per-visitor attachment size in bytes to be stored on the server
|
||||||
// - per visitor attachment daily bandwidth limit: number of bytes that can be transferred to/from the server
|
// - per visitor attachment daily bandwidth limit: number of bytes that can be transferred to/from the server
|
||||||
const (
|
const (
|
||||||
DefaultVisitorSubscriptionLimit = 30
|
DefaultVisitorSubscriptionLimit = 30
|
||||||
DefaultVisitorRequestLimitBurst = 60
|
DefaultVisitorRequestLimitBurst = 60
|
||||||
DefaultVisitorRequestLimitReplenish = 10 * time.Second
|
DefaultVisitorRequestLimitReplenish = 5 * time.Second
|
||||||
DefaultVisitorEmailLimitBurst = 16
|
DefaultVisitorEmailLimitBurst = 16
|
||||||
DefaultVisitorEmailLimitReplenish = time.Hour
|
DefaultVisitorEmailLimitReplenish = time.Hour
|
||||||
DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB
|
DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB
|
||||||
@@ -49,17 +49,22 @@ type Config struct {
|
|||||||
BaseURL string
|
BaseURL string
|
||||||
ListenHTTP string
|
ListenHTTP string
|
||||||
ListenHTTPS string
|
ListenHTTPS string
|
||||||
|
ListenUnix string
|
||||||
KeyFile string
|
KeyFile string
|
||||||
CertFile string
|
CertFile string
|
||||||
FirebaseKeyFile string
|
FirebaseKeyFile string
|
||||||
CacheFile string
|
CacheFile string
|
||||||
CacheDuration time.Duration
|
CacheDuration time.Duration
|
||||||
|
AuthFile string
|
||||||
|
AuthDefaultRead bool
|
||||||
|
AuthDefaultWrite bool
|
||||||
AttachmentCacheDir string
|
AttachmentCacheDir string
|
||||||
AttachmentTotalSizeLimit int64
|
AttachmentTotalSizeLimit int64
|
||||||
AttachmentFileSizeLimit int64
|
AttachmentFileSizeLimit int64
|
||||||
AttachmentExpiryDuration time.Duration
|
AttachmentExpiryDuration time.Duration
|
||||||
KeepaliveInterval time.Duration
|
KeepaliveInterval time.Duration
|
||||||
ManagerInterval time.Duration
|
ManagerInterval time.Duration
|
||||||
|
WebRootIsApp bool
|
||||||
AtSenderInterval time.Duration
|
AtSenderInterval time.Duration
|
||||||
FirebaseKeepaliveInterval time.Duration
|
FirebaseKeepaliveInterval time.Duration
|
||||||
SMTPSenderAddr string
|
SMTPSenderAddr string
|
||||||
@@ -79,6 +84,7 @@ type Config struct {
|
|||||||
VisitorAttachmentDailyBandwidthLimit int
|
VisitorAttachmentDailyBandwidthLimit int
|
||||||
VisitorRequestLimitBurst int
|
VisitorRequestLimitBurst int
|
||||||
VisitorRequestLimitReplenish time.Duration
|
VisitorRequestLimitReplenish time.Duration
|
||||||
|
VisitorRequestExemptIPAddrs []string
|
||||||
VisitorEmailLimitBurst int
|
VisitorEmailLimitBurst int
|
||||||
VisitorEmailLimitReplenish time.Duration
|
VisitorEmailLimitReplenish time.Duration
|
||||||
BehindProxy bool
|
BehindProxy bool
|
||||||
@@ -90,11 +96,15 @@ func NewConfig() *Config {
|
|||||||
BaseURL: "",
|
BaseURL: "",
|
||||||
ListenHTTP: DefaultListenHTTP,
|
ListenHTTP: DefaultListenHTTP,
|
||||||
ListenHTTPS: "",
|
ListenHTTPS: "",
|
||||||
|
ListenUnix: "",
|
||||||
KeyFile: "",
|
KeyFile: "",
|
||||||
CertFile: "",
|
CertFile: "",
|
||||||
FirebaseKeyFile: "",
|
FirebaseKeyFile: "",
|
||||||
CacheFile: "",
|
CacheFile: "",
|
||||||
CacheDuration: DefaultCacheDuration,
|
CacheDuration: DefaultCacheDuration,
|
||||||
|
AuthFile: "",
|
||||||
|
AuthDefaultRead: true,
|
||||||
|
AuthDefaultWrite: true,
|
||||||
AttachmentCacheDir: "",
|
AttachmentCacheDir: "",
|
||||||
AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit,
|
AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit,
|
||||||
AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit,
|
AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit,
|
||||||
@@ -112,6 +122,7 @@ func NewConfig() *Config {
|
|||||||
VisitorAttachmentDailyBandwidthLimit: DefaultVisitorAttachmentDailyBandwidthLimit,
|
VisitorAttachmentDailyBandwidthLimit: DefaultVisitorAttachmentDailyBandwidthLimit,
|
||||||
VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst,
|
VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst,
|
||||||
VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish,
|
VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish,
|
||||||
|
VisitorRequestExemptIPAddrs: make([]string, 0),
|
||||||
VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst,
|
VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst,
|
||||||
VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish,
|
VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish,
|
||||||
BehindProxy: false,
|
BehindProxy: false,
|
||||||
|
|||||||
64
server/errors.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errHTTP is a generic HTTP error for any non-200 HTTP error
|
||||||
|
type errHTTP struct {
|
||||||
|
Code int `json:"code,omitempty"`
|
||||||
|
HTTPCode int `json:"http"`
|
||||||
|
Message string `json:"error"`
|
||||||
|
Link string `json:"link,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e errHTTP) Error() string {
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e errHTTP) JSON() string {
|
||||||
|
b, _ := json.Marshal(&e)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapErrHTTP(err *errHTTP, message string, args ...interface{}) *errHTTP {
|
||||||
|
return &errHTTP{
|
||||||
|
Code: err.Code,
|
||||||
|
HTTPCode: err.HTTPCode,
|
||||||
|
Message: fmt.Sprintf("%s, %s", err.Message, fmt.Sprintf(message, args...)),
|
||||||
|
Link: err.Link,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications"}
|
||||||
|
errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", ""}
|
||||||
|
errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", ""}
|
||||||
|
errHTTPBadRequestDelayCannotParse = &errHTTP{40004, http.StatusBadRequest, "invalid delay parameter: unable to parse delay", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
|
||||||
|
errHTTPBadRequestDelayTooSmall = &errHTTP{40005, http.StatusBadRequest, "invalid delay parameter: too small, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
|
||||||
|
errHTTPBadRequestDelayTooLarge = &errHTTP{40006, http.StatusBadRequest, "invalid delay parameter: too large, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
|
||||||
|
errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority"}
|
||||||
|
errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages"}
|
||||||
|
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid topic: path invalid", ""}
|
||||||
|
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid topic: topic name is disallowed", ""}
|
||||||
|
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", ""}
|
||||||
|
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments"}
|
||||||
|
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments"}
|
||||||
|
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
|
||||||
|
errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets"}
|
||||||
|
errHTTPBadRequestJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json"}
|
||||||
|
errHTTPBadRequestActionsInvalid = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions invalid", "https://ntfy.sh/docs/publish/#action-buttons"}
|
||||||
|
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
|
||||||
|
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"}
|
||||||
|
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"}
|
||||||
|
errHTTPEntityTooLargeAttachmentTooLarge = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
|
errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
|
errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
|
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
|
errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
|
errHTTPTooManyRequestsAttachmentBandwidthLimit = &errHTTP{42905, http.StatusTooManyRequests, "too many requests: daily bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
|
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""}
|
||||||
|
errHTTPInternalErrorInvalidFilePath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid file path", ""}
|
||||||
|
)
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"heckel.io/ntfy/util"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// List of possible events
|
|
||||||
const (
|
|
||||||
openEvent = "open"
|
|
||||||
keepaliveEvent = "keepalive"
|
|
||||||
messageEvent = "message"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
messageIDLength = 10
|
|
||||||
)
|
|
||||||
|
|
||||||
// message represents a message published to a topic
|
|
||||||
type message struct {
|
|
||||||
ID string `json:"id"` // Random message ID
|
|
||||||
Time int64 `json:"time"` // Unix time in seconds
|
|
||||||
Event string `json:"event"` // One of the above
|
|
||||||
Topic string `json:"topic"`
|
|
||||||
Priority int `json:"priority,omitempty"`
|
|
||||||
Tags []string `json:"tags,omitempty"`
|
|
||||||
Click string `json:"click,omitempty"`
|
|
||||||
Attachment *attachment `json:"attachment,omitempty"`
|
|
||||||
Title string `json:"title,omitempty"`
|
|
||||||
Message string `json:"message,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type attachment struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Type string `json:"type,omitempty"`
|
|
||||||
Size int64 `json:"size,omitempty"`
|
|
||||||
Expires int64 `json:"expires,omitempty"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
Owner string `json:"-"` // IP address of uploader, used for rate limiting
|
|
||||||
}
|
|
||||||
|
|
||||||
// messageEncoder is a function that knows how to encode a message
|
|
||||||
type messageEncoder func(msg *message) (string, error)
|
|
||||||
|
|
||||||
// newMessage creates a new message with the current timestamp
|
|
||||||
func newMessage(event, topic, msg string) *message {
|
|
||||||
return &message{
|
|
||||||
ID: util.RandomString(messageIDLength),
|
|
||||||
Time: time.Now().Unix(),
|
|
||||||
Event: event,
|
|
||||||
Topic: topic,
|
|
||||||
Priority: 0,
|
|
||||||
Tags: nil,
|
|
||||||
Title: "",
|
|
||||||
Message: msg,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// newOpenMessage is a convenience method to create an open message
|
|
||||||
func newOpenMessage(topic string) *message {
|
|
||||||
return newMessage(openEvent, topic, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
// newKeepaliveMessage is a convenience method to create a keepalive message
|
|
||||||
func newKeepaliveMessage(topic string) *message {
|
|
||||||
return newMessage(keepaliveEvent, topic, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
// newDefaultMessage is a convenience method to create a notification message
|
|
||||||
func newDefaultMessage(topic, msg string) *message {
|
|
||||||
return newMessage(messageEvent, topic, msg)
|
|
||||||
}
|
|
||||||
@@ -2,20 +2,27 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errUnexpectedMessageType = errors.New("unexpected message type")
|
||||||
|
)
|
||||||
|
|
||||||
// Messages cache
|
// Messages cache
|
||||||
const (
|
const (
|
||||||
createMessagesTableQuery = `
|
createMessagesTableQuery = `
|
||||||
BEGIN;
|
BEGIN;
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
id TEXT PRIMARY KEY,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
mid TEXT NOT NULL,
|
||||||
time INT NOT NULL,
|
time INT NOT NULL,
|
||||||
topic TEXT NOT NULL,
|
topic TEXT NOT NULL,
|
||||||
message TEXT NOT NULL,
|
message TEXT NOT NULL,
|
||||||
@@ -23,50 +30,67 @@ const (
|
|||||||
priority INT NOT NULL,
|
priority INT NOT NULL,
|
||||||
tags TEXT NOT NULL,
|
tags TEXT NOT NULL,
|
||||||
click TEXT NOT NULL,
|
click TEXT NOT NULL,
|
||||||
|
actions TEXT NOT NULL,
|
||||||
attachment_name TEXT NOT NULL,
|
attachment_name TEXT NOT NULL,
|
||||||
attachment_type TEXT NOT NULL,
|
attachment_type TEXT NOT NULL,
|
||||||
attachment_size INT NOT NULL,
|
attachment_size INT NOT NULL,
|
||||||
attachment_expires INT NOT NULL,
|
attachment_expires INT NOT NULL,
|
||||||
attachment_url TEXT NOT NULL,
|
attachment_url TEXT NOT NULL,
|
||||||
attachment_owner TEXT NOT NULL,
|
attachment_owner TEXT NOT NULL,
|
||||||
|
encoding TEXT NOT NULL,
|
||||||
published INT NOT NULL
|
published INT NOT NULL
|
||||||
);
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid);
|
||||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||||
COMMIT;
|
COMMIT;
|
||||||
`
|
`
|
||||||
insertMessageQuery = `
|
insertMessageQuery = `
|
||||||
INSERT INTO messages (id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, published)
|
INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
pruneMessagesQuery = `DELETE FROM messages WHERE time < ? AND published = 1`
|
pruneMessagesQuery = `DELETE FROM messages WHERE time < ? AND published = 1`
|
||||||
|
selectRowIDFromMessageID = `SELECT id FROM messages WHERE topic = ? AND mid = ?`
|
||||||
selectMessagesSinceTimeQuery = `
|
selectMessagesSinceTimeQuery = `
|
||||||
SELECT id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner
|
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND time >= ? AND published = 1
|
WHERE topic = ? AND time >= ? AND published = 1
|
||||||
ORDER BY time ASC
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
selectMessagesSinceTimeIncludeScheduledQuery = `
|
selectMessagesSinceTimeIncludeScheduledQuery = `
|
||||||
SELECT id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner
|
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND time >= ?
|
WHERE topic = ? AND time >= ?
|
||||||
ORDER BY time ASC
|
ORDER BY time, id
|
||||||
|
`
|
||||||
|
selectMessagesSinceIDQuery = `
|
||||||
|
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||||
|
FROM messages
|
||||||
|
WHERE topic = ? AND id > ? AND published = 1
|
||||||
|
ORDER BY time, id
|
||||||
|
`
|
||||||
|
selectMessagesSinceIDIncludeScheduledQuery = `
|
||||||
|
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||||
|
FROM messages
|
||||||
|
WHERE topic = ? AND (id > ? OR published = 0)
|
||||||
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
selectMessagesDueQuery = `
|
selectMessagesDueQuery = `
|
||||||
SELECT id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner
|
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE time <= ? AND published = 0
|
WHERE time <= ? AND published = 0
|
||||||
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE id = ?`
|
updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?`
|
||||||
selectMessagesCountQuery = `SELECT COUNT(*) FROM messages`
|
selectMessagesCountQuery = `SELECT COUNT(*) FROM messages`
|
||||||
selectMessageCountForTopicQuery = `SELECT COUNT(*) FROM messages WHERE topic = ?`
|
selectMessageCountForTopicQuery = `SELECT COUNT(*) FROM messages WHERE topic = ?`
|
||||||
selectTopicsQuery = `SELECT topic FROM messages GROUP BY topic`
|
selectTopicsQuery = `SELECT topic FROM messages GROUP BY topic`
|
||||||
selectAttachmentsSizeQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE attachment_owner = ? AND attachment_expires >= ?`
|
selectAttachmentsSizeQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE attachment_owner = ? AND attachment_expires >= ?`
|
||||||
selectAttachmentsExpiredQuery = `SELECT id FROM messages WHERE attachment_expires > 0 AND attachment_expires < ?`
|
selectAttachmentsExpiredQuery = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires < ?`
|
||||||
)
|
)
|
||||||
|
|
||||||
// Schema management queries
|
// Schema management queries
|
||||||
const (
|
const (
|
||||||
currentSchemaVersion = 3
|
currentSchemaVersion = 6
|
||||||
createSchemaVersionTableQuery = `
|
createSchemaVersionTableQuery = `
|
||||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||||
id INT PRIMARY KEY,
|
id INT PRIMARY KEY,
|
||||||
@@ -103,31 +127,102 @@ const (
|
|||||||
ALTER TABLE messages ADD COLUMN attachment_url TEXT NOT NULL DEFAULT('');
|
ALTER TABLE messages ADD COLUMN attachment_url TEXT NOT NULL DEFAULT('');
|
||||||
COMMIT;
|
COMMIT;
|
||||||
`
|
`
|
||||||
|
// 3 -> 4
|
||||||
|
migrate3To4AlterMessagesTableQuery = `
|
||||||
|
ALTER TABLE messages ADD COLUMN encoding TEXT NOT NULL DEFAULT('');
|
||||||
|
`
|
||||||
|
|
||||||
|
// 4 -> 5
|
||||||
|
migrate4To5AlterMessagesTableQuery = `
|
||||||
|
BEGIN;
|
||||||
|
CREATE TABLE IF NOT EXISTS messages_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
mid TEXT NOT NULL,
|
||||||
|
time INT NOT NULL,
|
||||||
|
topic TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
priority INT NOT NULL,
|
||||||
|
tags TEXT NOT NULL,
|
||||||
|
click TEXT NOT NULL,
|
||||||
|
attachment_name TEXT NOT NULL,
|
||||||
|
attachment_type TEXT NOT NULL,
|
||||||
|
attachment_size INT NOT NULL,
|
||||||
|
attachment_expires INT NOT NULL,
|
||||||
|
attachment_url TEXT NOT NULL,
|
||||||
|
attachment_owner TEXT NOT NULL,
|
||||||
|
encoding TEXT NOT NULL,
|
||||||
|
published INT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mid ON messages_new (mid);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_topic ON messages_new (topic);
|
||||||
|
INSERT
|
||||||
|
INTO messages_new (
|
||||||
|
mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type,
|
||||||
|
attachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published)
|
||||||
|
SELECT
|
||||||
|
id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type,
|
||||||
|
attachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published
|
||||||
|
FROM messages;
|
||||||
|
DROP TABLE messages;
|
||||||
|
ALTER TABLE messages_new RENAME TO messages;
|
||||||
|
COMMIT;
|
||||||
|
`
|
||||||
|
|
||||||
|
// 5 -> 6
|
||||||
|
migrate5To6AlterMessagesTableQuery = `
|
||||||
|
ALTER TABLE messages ADD COLUMN actions TEXT NOT NULL DEFAULT('');
|
||||||
|
`
|
||||||
)
|
)
|
||||||
|
|
||||||
type sqliteCache struct {
|
type messageCache struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
|
nop bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ cache = (*sqliteCache)(nil)
|
// newSqliteCache creates a SQLite file-backed cache
|
||||||
|
func newSqliteCache(filename string, nop bool) (*messageCache, error) {
|
||||||
func newSqliteCache(filename string) (*sqliteCache, error) {
|
|
||||||
db, err := sql.Open("sqlite3", filename)
|
db, err := sql.Open("sqlite3", filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := setupDB(db); err != nil {
|
if err := setupCacheDB(db); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &sqliteCache{
|
return &messageCache{
|
||||||
db: db,
|
db: db,
|
||||||
|
nop: nop,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *sqliteCache) AddMessage(m *message) error {
|
// newMemCache creates an in-memory cache
|
||||||
|
func newMemCache() (*messageCache, error) {
|
||||||
|
return newSqliteCache(createMemoryFilename(), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newNopCache creates an in-memory cache that discards all messages;
|
||||||
|
// it is always empty and can be used if caching is entirely disabled
|
||||||
|
func newNopCache() (*messageCache, error) {
|
||||||
|
return newSqliteCache(createMemoryFilename(), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// createMemoryFilename creates a unique memory filename to use for the SQLite backend.
|
||||||
|
// From mattn/go-sqlite3: "Each connection to ":memory:" opens a brand new in-memory
|
||||||
|
// sql database, so if the stdlib's sql engine happens to open another connection and
|
||||||
|
// you've only specified ":memory:", that connection will see a brand new database.
|
||||||
|
// A workaround is to use "file::memory:?cache=shared" (or "file:foobar?mode=memory&cache=shared").
|
||||||
|
// Every connection to this string will point to the same in-memory database."
|
||||||
|
func createMemoryFilename() string {
|
||||||
|
return fmt.Sprintf("file:%s?mode=memory&cache=shared", util.RandomString(10))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *messageCache) AddMessage(m *message) error {
|
||||||
if m.Event != messageEvent {
|
if m.Event != messageEvent {
|
||||||
return errUnexpectedMessageType
|
return errUnexpectedMessageType
|
||||||
}
|
}
|
||||||
|
if c.nop {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
published := m.Time <= time.Now().Unix()
|
published := m.Time <= time.Now().Unix()
|
||||||
tags := strings.Join(m.Tags, ",")
|
tags := strings.Join(m.Tags, ",")
|
||||||
var attachmentName, attachmentType, attachmentURL, attachmentOwner string
|
var attachmentName, attachmentType, attachmentURL, attachmentOwner string
|
||||||
@@ -140,6 +235,14 @@ func (c *sqliteCache) AddMessage(m *message) error {
|
|||||||
attachmentURL = m.Attachment.URL
|
attachmentURL = m.Attachment.URL
|
||||||
attachmentOwner = m.Attachment.Owner
|
attachmentOwner = m.Attachment.Owner
|
||||||
}
|
}
|
||||||
|
var actionsStr string
|
||||||
|
if len(m.Actions) > 0 {
|
||||||
|
actionsBytes, err := json.Marshal(m.Actions)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
actionsStr = string(actionsBytes)
|
||||||
|
}
|
||||||
_, err := c.db.Exec(
|
_, err := c.db.Exec(
|
||||||
insertMessageQuery,
|
insertMessageQuery,
|
||||||
m.ID,
|
m.ID,
|
||||||
@@ -150,21 +253,29 @@ func (c *sqliteCache) AddMessage(m *message) error {
|
|||||||
m.Priority,
|
m.Priority,
|
||||||
tags,
|
tags,
|
||||||
m.Click,
|
m.Click,
|
||||||
|
actionsStr,
|
||||||
attachmentName,
|
attachmentName,
|
||||||
attachmentType,
|
attachmentType,
|
||||||
attachmentSize,
|
attachmentSize,
|
||||||
attachmentExpires,
|
attachmentExpires,
|
||||||
attachmentURL,
|
attachmentURL,
|
||||||
attachmentOwner,
|
attachmentOwner,
|
||||||
|
m.Encoding,
|
||||||
published,
|
published,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *sqliteCache) Messages(topic string, since sinceTime, scheduled bool) ([]*message, error) {
|
func (c *messageCache) Messages(topic string, since sinceMarker, scheduled bool) ([]*message, error) {
|
||||||
if since.IsNone() {
|
if since.IsNone() {
|
||||||
return make([]*message, 0), nil
|
return make([]*message, 0), nil
|
||||||
|
} else if since.IsID() {
|
||||||
|
return c.messagesSinceID(topic, since, scheduled)
|
||||||
}
|
}
|
||||||
|
return c.messagesSinceTime(topic, since, scheduled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *messageCache) messagesSinceTime(topic string, since sinceMarker, scheduled bool) ([]*message, error) {
|
||||||
var rows *sql.Rows
|
var rows *sql.Rows
|
||||||
var err error
|
var err error
|
||||||
if scheduled {
|
if scheduled {
|
||||||
@@ -178,7 +289,33 @@ func (c *sqliteCache) Messages(topic string, since sinceTime, scheduled bool) ([
|
|||||||
return readMessages(rows)
|
return readMessages(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *sqliteCache) MessagesDue() ([]*message, error) {
|
func (c *messageCache) messagesSinceID(topic string, since sinceMarker, scheduled bool) ([]*message, error) {
|
||||||
|
idrows, err := c.db.Query(selectRowIDFromMessageID, topic, since.ID())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer idrows.Close()
|
||||||
|
if !idrows.Next() {
|
||||||
|
return c.messagesSinceTime(topic, sinceAllMessages, scheduled)
|
||||||
|
}
|
||||||
|
var rowID int64
|
||||||
|
if err := idrows.Scan(&rowID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
idrows.Close()
|
||||||
|
var rows *sql.Rows
|
||||||
|
if scheduled {
|
||||||
|
rows, err = c.db.Query(selectMessagesSinceIDIncludeScheduledQuery, topic, rowID)
|
||||||
|
} else {
|
||||||
|
rows, err = c.db.Query(selectMessagesSinceIDQuery, topic, rowID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return readMessages(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *messageCache) MessagesDue() ([]*message, error) {
|
||||||
rows, err := c.db.Query(selectMessagesDueQuery, time.Now().Unix())
|
rows, err := c.db.Query(selectMessagesDueQuery, time.Now().Unix())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -186,12 +323,12 @@ func (c *sqliteCache) MessagesDue() ([]*message, error) {
|
|||||||
return readMessages(rows)
|
return readMessages(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *sqliteCache) MarkPublished(m *message) error {
|
func (c *messageCache) MarkPublished(m *message) error {
|
||||||
_, err := c.db.Exec(updateMessagePublishedQuery, m.ID)
|
_, err := c.db.Exec(updateMessagePublishedQuery, m.ID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *sqliteCache) MessageCount(topic string) (int, error) {
|
func (c *messageCache) MessageCount(topic string) (int, error) {
|
||||||
rows, err := c.db.Query(selectMessageCountForTopicQuery, topic)
|
rows, err := c.db.Query(selectMessageCountForTopicQuery, topic)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -209,7 +346,7 @@ func (c *sqliteCache) MessageCount(topic string) (int, error) {
|
|||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *sqliteCache) Topics() (map[string]*topic, error) {
|
func (c *messageCache) Topics() (map[string]*topic, error) {
|
||||||
rows, err := c.db.Query(selectTopicsQuery)
|
rows, err := c.db.Query(selectTopicsQuery)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -229,12 +366,12 @@ func (c *sqliteCache) Topics() (map[string]*topic, error) {
|
|||||||
return topics, nil
|
return topics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *sqliteCache) Prune(olderThan time.Time) error {
|
func (c *messageCache) Prune(olderThan time.Time) error {
|
||||||
_, err := c.db.Exec(pruneMessagesQuery, olderThan.Unix())
|
_, err := c.db.Exec(pruneMessagesQuery, olderThan.Unix())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *sqliteCache) AttachmentsSize(owner string) (int64, error) {
|
func (c *messageCache) AttachmentBytesUsed(owner string) (int64, error) {
|
||||||
rows, err := c.db.Query(selectAttachmentsSizeQuery, owner, time.Now().Unix())
|
rows, err := c.db.Query(selectAttachmentsSizeQuery, owner, time.Now().Unix())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -252,7 +389,7 @@ func (c *sqliteCache) AttachmentsSize(owner string) (int64, error) {
|
|||||||
return size, nil
|
return size, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *sqliteCache) AttachmentsExpired() ([]string, error) {
|
func (c *messageCache) AttachmentsExpired() ([]string, error) {
|
||||||
rows, err := c.db.Query(selectAttachmentsExpiredQuery, time.Now().Unix())
|
rows, err := c.db.Query(selectAttachmentsExpiredQuery, time.Now().Unix())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -278,14 +415,38 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var timestamp, attachmentSize, attachmentExpires int64
|
var timestamp, attachmentSize, attachmentExpires int64
|
||||||
var priority int
|
var priority int
|
||||||
var id, topic, msg, title, tagsStr, click, attachmentName, attachmentType, attachmentURL, attachmentOwner string
|
var id, topic, msg, title, tagsStr, click, actionsStr, attachmentName, attachmentType, attachmentURL, attachmentOwner, encoding string
|
||||||
if err := rows.Scan(&id, ×tamp, &topic, &msg, &title, &priority, &tagsStr, &click, &attachmentName, &attachmentType, &attachmentSize, &attachmentExpires, &attachmentURL, &attachmentOwner); err != nil {
|
err := rows.Scan(
|
||||||
|
&id,
|
||||||
|
×tamp,
|
||||||
|
&topic,
|
||||||
|
&msg,
|
||||||
|
&title,
|
||||||
|
&priority,
|
||||||
|
&tagsStr,
|
||||||
|
&click,
|
||||||
|
&actionsStr,
|
||||||
|
&attachmentName,
|
||||||
|
&attachmentType,
|
||||||
|
&attachmentSize,
|
||||||
|
&attachmentExpires,
|
||||||
|
&attachmentURL,
|
||||||
|
&attachmentOwner,
|
||||||
|
&encoding,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var tags []string
|
var tags []string
|
||||||
if tagsStr != "" {
|
if tagsStr != "" {
|
||||||
tags = strings.Split(tagsStr, ",")
|
tags = strings.Split(tagsStr, ",")
|
||||||
}
|
}
|
||||||
|
var actions []*action
|
||||||
|
if actionsStr != "" {
|
||||||
|
if err := json.Unmarshal([]byte(actionsStr), &actions); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
var att *attachment
|
var att *attachment
|
||||||
if attachmentName != "" && attachmentURL != "" {
|
if attachmentName != "" && attachmentURL != "" {
|
||||||
att = &attachment{
|
att = &attachment{
|
||||||
@@ -307,7 +468,9 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
|||||||
Priority: priority,
|
Priority: priority,
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
Click: click,
|
Click: click,
|
||||||
|
Actions: actions,
|
||||||
Attachment: att,
|
Attachment: att,
|
||||||
|
Encoding: encoding,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
@@ -316,11 +479,11 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
|||||||
return messages, nil
|
return messages, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupDB(db *sql.DB) error {
|
func setupCacheDB(db *sql.DB) error {
|
||||||
// If 'messages' table does not exist, this must be a new database
|
// If 'messages' table does not exist, this must be a new database
|
||||||
rowsMC, err := db.Query(selectMessagesCountQuery)
|
rowsMC, err := db.Query(selectMessagesCountQuery)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return setupNewDB(db)
|
return setupNewCacheDB(db)
|
||||||
}
|
}
|
||||||
rowsMC.Close()
|
rowsMC.Close()
|
||||||
|
|
||||||
@@ -347,11 +510,17 @@ func setupDB(db *sql.DB) error {
|
|||||||
return migrateFrom1(db)
|
return migrateFrom1(db)
|
||||||
} else if schemaVersion == 2 {
|
} else if schemaVersion == 2 {
|
||||||
return migrateFrom2(db)
|
return migrateFrom2(db)
|
||||||
|
} else if schemaVersion == 3 {
|
||||||
|
return migrateFrom3(db)
|
||||||
|
} else if schemaVersion == 4 {
|
||||||
|
return migrateFrom4(db)
|
||||||
|
} else if schemaVersion == 5 {
|
||||||
|
return migrateFrom5(db)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("unexpected schema version found: %d", schemaVersion)
|
return fmt.Errorf("unexpected schema version found: %d", schemaVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupNewDB(db *sql.DB) error {
|
func setupNewCacheDB(db *sql.DB) error {
|
||||||
if _, err := db.Exec(createMessagesTableQuery); err != nil {
|
if _, err := db.Exec(createMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -397,5 +566,38 @@ func migrateFrom2(db *sql.DB) error {
|
|||||||
if _, err := db.Exec(updateSchemaVersion, 3); err != nil {
|
if _, err := db.Exec(updateSchemaVersion, 3); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return migrateFrom3(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateFrom3(db *sql.DB) error {
|
||||||
|
log.Print("Migrating cache database schema: from 3 to 4")
|
||||||
|
if _, err := db.Exec(migrate3To4AlterMessagesTableQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(updateSchemaVersion, 4); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return migrateFrom4(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateFrom4(db *sql.DB) error {
|
||||||
|
log.Print("Migrating cache database schema: from 4 to 5")
|
||||||
|
if _, err := db.Exec(migrate4To5AlterMessagesTableQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(updateSchemaVersion, 5); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return migrateFrom5(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateFrom5(db *sql.DB) error {
|
||||||
|
log.Print("Migrating cache database schema: from 5 to 6")
|
||||||
|
if _, err := db.Exec(migrate5To6AlterMessagesTableQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(updateSchemaVersion, 6); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil // Update this when a new version is added
|
return nil // Update this when a new version is added
|
||||||
}
|
}
|
||||||
496
server/message_cache_test.go
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSqliteCache_Messages(t *testing.T) {
|
||||||
|
testCacheMessages(t, newSqliteTestCache(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemCache_Messages(t *testing.T) {
|
||||||
|
testCacheMessages(t, newMemTestCache(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCacheMessages(t *testing.T, c *messageCache) {
|
||||||
|
m1 := newDefaultMessage("mytopic", "my message")
|
||||||
|
m1.Time = 1
|
||||||
|
|
||||||
|
m2 := newDefaultMessage("mytopic", "my other message")
|
||||||
|
m2.Time = 2
|
||||||
|
|
||||||
|
require.Nil(t, c.AddMessage(m1))
|
||||||
|
require.Nil(t, c.AddMessage(newDefaultMessage("example", "my example message")))
|
||||||
|
require.Nil(t, c.AddMessage(m2))
|
||||||
|
|
||||||
|
// Adding invalid
|
||||||
|
require.Equal(t, errUnexpectedMessageType, c.AddMessage(newKeepaliveMessage("mytopic"))) // These should not be added!
|
||||||
|
require.Equal(t, errUnexpectedMessageType, c.AddMessage(newOpenMessage("example"))) // These should not be added!
|
||||||
|
|
||||||
|
// mytopic: count
|
||||||
|
count, err := c.MessageCount("mytopic")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 2, count)
|
||||||
|
|
||||||
|
// mytopic: since all
|
||||||
|
messages, _ := c.Messages("mytopic", sinceAllMessages, false)
|
||||||
|
require.Equal(t, 2, len(messages))
|
||||||
|
require.Equal(t, "my message", messages[0].Message)
|
||||||
|
require.Equal(t, "mytopic", messages[0].Topic)
|
||||||
|
require.Equal(t, messageEvent, messages[0].Event)
|
||||||
|
require.Equal(t, "", messages[0].Title)
|
||||||
|
require.Equal(t, 0, messages[0].Priority)
|
||||||
|
require.Nil(t, messages[0].Tags)
|
||||||
|
require.Equal(t, "my other message", messages[1].Message)
|
||||||
|
|
||||||
|
// mytopic: since none
|
||||||
|
messages, _ = c.Messages("mytopic", sinceNoMessages, false)
|
||||||
|
require.Empty(t, messages)
|
||||||
|
|
||||||
|
// mytopic: since m1 (by ID)
|
||||||
|
messages, _ = c.Messages("mytopic", newSinceID(m1.ID), false)
|
||||||
|
require.Equal(t, 1, len(messages))
|
||||||
|
require.Equal(t, m2.ID, messages[0].ID)
|
||||||
|
require.Equal(t, "my other message", messages[0].Message)
|
||||||
|
require.Equal(t, "mytopic", messages[0].Topic)
|
||||||
|
|
||||||
|
// mytopic: since 2
|
||||||
|
messages, _ = c.Messages("mytopic", newSinceTime(2), false)
|
||||||
|
require.Equal(t, 1, len(messages))
|
||||||
|
require.Equal(t, "my other message", messages[0].Message)
|
||||||
|
|
||||||
|
// example: count
|
||||||
|
count, err = c.MessageCount("example")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, count)
|
||||||
|
|
||||||
|
// example: since all
|
||||||
|
messages, _ = c.Messages("example", sinceAllMessages, false)
|
||||||
|
require.Equal(t, "my example message", messages[0].Message)
|
||||||
|
|
||||||
|
// non-existing: count
|
||||||
|
count, err = c.MessageCount("doesnotexist")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 0, count)
|
||||||
|
|
||||||
|
// non-existing: since all
|
||||||
|
messages, _ = c.Messages("doesnotexist", sinceAllMessages, false)
|
||||||
|
require.Empty(t, messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqliteCache_MessagesScheduled(t *testing.T) {
|
||||||
|
testCacheMessagesScheduled(t, newSqliteTestCache(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemCache_MessagesScheduled(t *testing.T) {
|
||||||
|
testCacheMessagesScheduled(t, newMemTestCache(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCacheMessagesScheduled(t *testing.T, c *messageCache) {
|
||||||
|
m1 := newDefaultMessage("mytopic", "message 1")
|
||||||
|
m2 := newDefaultMessage("mytopic", "message 2")
|
||||||
|
m2.Time = time.Now().Add(time.Hour).Unix()
|
||||||
|
m3 := newDefaultMessage("mytopic", "message 3")
|
||||||
|
m3.Time = time.Now().Add(time.Minute).Unix() // earlier than m2!
|
||||||
|
m4 := newDefaultMessage("mytopic2", "message 4")
|
||||||
|
m4.Time = time.Now().Add(time.Minute).Unix()
|
||||||
|
require.Nil(t, c.AddMessage(m1))
|
||||||
|
require.Nil(t, c.AddMessage(m2))
|
||||||
|
require.Nil(t, c.AddMessage(m3))
|
||||||
|
|
||||||
|
messages, _ := c.Messages("mytopic", sinceAllMessages, false) // exclude scheduled
|
||||||
|
require.Equal(t, 1, len(messages))
|
||||||
|
require.Equal(t, "message 1", messages[0].Message)
|
||||||
|
|
||||||
|
messages, _ = c.Messages("mytopic", sinceAllMessages, true) // include scheduled
|
||||||
|
require.Equal(t, 3, len(messages))
|
||||||
|
require.Equal(t, "message 1", messages[0].Message)
|
||||||
|
require.Equal(t, "message 3", messages[1].Message) // Order!
|
||||||
|
require.Equal(t, "message 2", messages[2].Message)
|
||||||
|
|
||||||
|
messages, _ = c.MessagesDue()
|
||||||
|
require.Empty(t, messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqliteCache_Topics(t *testing.T) {
|
||||||
|
testCacheTopics(t, newSqliteTestCache(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemCache_Topics(t *testing.T) {
|
||||||
|
testCacheTopics(t, newMemTestCache(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCacheTopics(t *testing.T, c *messageCache) {
|
||||||
|
require.Nil(t, c.AddMessage(newDefaultMessage("topic1", "my example message")))
|
||||||
|
require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 1")))
|
||||||
|
require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 2")))
|
||||||
|
require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 3")))
|
||||||
|
|
||||||
|
topics, err := c.Topics()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
require.Equal(t, 2, len(topics))
|
||||||
|
require.Equal(t, "topic1", topics["topic1"].ID)
|
||||||
|
require.Equal(t, "topic2", topics["topic2"].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqliteCache_MessagesTagsPrioAndTitle(t *testing.T) {
|
||||||
|
testCacheMessagesTagsPrioAndTitle(t, newSqliteTestCache(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemCache_MessagesTagsPrioAndTitle(t *testing.T) {
|
||||||
|
testCacheMessagesTagsPrioAndTitle(t, newMemTestCache(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCacheMessagesTagsPrioAndTitle(t *testing.T, c *messageCache) {
|
||||||
|
m := newDefaultMessage("mytopic", "some message")
|
||||||
|
m.Tags = []string{"tag1", "tag2"}
|
||||||
|
m.Priority = 5
|
||||||
|
m.Title = "some title"
|
||||||
|
require.Nil(t, c.AddMessage(m))
|
||||||
|
|
||||||
|
messages, _ := c.Messages("mytopic", sinceAllMessages, false)
|
||||||
|
require.Equal(t, []string{"tag1", "tag2"}, messages[0].Tags)
|
||||||
|
require.Equal(t, 5, messages[0].Priority)
|
||||||
|
require.Equal(t, "some title", messages[0].Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqliteCache_MessagesSinceID(t *testing.T) {
|
||||||
|
testCacheMessagesSinceID(t, newSqliteTestCache(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemCache_MessagesSinceID(t *testing.T) {
|
||||||
|
testCacheMessagesSinceID(t, newMemTestCache(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCacheMessagesSinceID(t *testing.T, c *messageCache) {
|
||||||
|
m1 := newDefaultMessage("mytopic", "message 1")
|
||||||
|
m1.Time = 100
|
||||||
|
m2 := newDefaultMessage("mytopic", "message 2")
|
||||||
|
m2.Time = 200
|
||||||
|
m3 := newDefaultMessage("mytopic", "message 3")
|
||||||
|
m3.Time = time.Now().Add(time.Hour).Unix() // Scheduled, in the future, later than m7 and m5
|
||||||
|
m4 := newDefaultMessage("mytopic", "message 4")
|
||||||
|
m4.Time = 400
|
||||||
|
m5 := newDefaultMessage("mytopic", "message 5")
|
||||||
|
m5.Time = time.Now().Add(time.Minute).Unix() // Scheduled, in the future, later than m7
|
||||||
|
m6 := newDefaultMessage("mytopic", "message 6")
|
||||||
|
m6.Time = 600
|
||||||
|
m7 := newDefaultMessage("mytopic", "message 7")
|
||||||
|
m7.Time = 700
|
||||||
|
|
||||||
|
require.Nil(t, c.AddMessage(m1))
|
||||||
|
require.Nil(t, c.AddMessage(m2))
|
||||||
|
require.Nil(t, c.AddMessage(m3))
|
||||||
|
require.Nil(t, c.AddMessage(m4))
|
||||||
|
require.Nil(t, c.AddMessage(m5))
|
||||||
|
require.Nil(t, c.AddMessage(m6))
|
||||||
|
require.Nil(t, c.AddMessage(m7))
|
||||||
|
|
||||||
|
// Case 1: Since ID exists, exclude scheduled
|
||||||
|
messages, _ := c.Messages("mytopic", newSinceID(m2.ID), false)
|
||||||
|
require.Equal(t, 3, len(messages))
|
||||||
|
require.Equal(t, "message 4", messages[0].Message)
|
||||||
|
require.Equal(t, "message 6", messages[1].Message) // Not scheduled m3/m5!
|
||||||
|
require.Equal(t, "message 7", messages[2].Message)
|
||||||
|
|
||||||
|
// Case 2: Since ID exists, include scheduled
|
||||||
|
messages, _ = c.Messages("mytopic", newSinceID(m2.ID), true)
|
||||||
|
require.Equal(t, 5, len(messages))
|
||||||
|
require.Equal(t, "message 4", messages[0].Message)
|
||||||
|
require.Equal(t, "message 6", messages[1].Message)
|
||||||
|
require.Equal(t, "message 7", messages[2].Message)
|
||||||
|
require.Equal(t, "message 5", messages[3].Message) // Order!
|
||||||
|
require.Equal(t, "message 3", messages[4].Message) // Order!
|
||||||
|
|
||||||
|
// Case 3: Since ID does not exist (-> Return all messages), include scheduled
|
||||||
|
messages, _ = c.Messages("mytopic", newSinceID("doesntexist"), true)
|
||||||
|
require.Equal(t, 7, len(messages))
|
||||||
|
require.Equal(t, "message 1", messages[0].Message)
|
||||||
|
require.Equal(t, "message 2", messages[1].Message)
|
||||||
|
require.Equal(t, "message 4", messages[2].Message)
|
||||||
|
require.Equal(t, "message 6", messages[3].Message)
|
||||||
|
require.Equal(t, "message 7", messages[4].Message)
|
||||||
|
require.Equal(t, "message 5", messages[5].Message) // Order!
|
||||||
|
require.Equal(t, "message 3", messages[6].Message) // Order!
|
||||||
|
|
||||||
|
// Case 4: Since ID exists and is last message (-> Return no messages), exclude scheduled
|
||||||
|
messages, _ = c.Messages("mytopic", newSinceID(m7.ID), false)
|
||||||
|
require.Equal(t, 0, len(messages))
|
||||||
|
|
||||||
|
// Case 5: Since ID exists and is last message (-> Return no messages), include scheduled
|
||||||
|
messages, _ = c.Messages("mytopic", newSinceID(m7.ID), true)
|
||||||
|
require.Equal(t, 2, len(messages))
|
||||||
|
require.Equal(t, "message 5", messages[0].Message)
|
||||||
|
require.Equal(t, "message 3", messages[1].Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqliteCache_Prune(t *testing.T) {
|
||||||
|
testCachePrune(t, newSqliteTestCache(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemCache_Prune(t *testing.T) {
|
||||||
|
testCachePrune(t, newMemTestCache(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCachePrune(t *testing.T, c *messageCache) {
|
||||||
|
m1 := newDefaultMessage("mytopic", "my message")
|
||||||
|
m1.Time = 1
|
||||||
|
|
||||||
|
m2 := newDefaultMessage("mytopic", "my other message")
|
||||||
|
m2.Time = 2
|
||||||
|
|
||||||
|
m3 := newDefaultMessage("another_topic", "and another one")
|
||||||
|
m3.Time = 1
|
||||||
|
|
||||||
|
require.Nil(t, c.AddMessage(m1))
|
||||||
|
require.Nil(t, c.AddMessage(m2))
|
||||||
|
require.Nil(t, c.AddMessage(m3))
|
||||||
|
require.Nil(t, c.Prune(time.Unix(2, 0)))
|
||||||
|
|
||||||
|
count, err := c.MessageCount("mytopic")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, count)
|
||||||
|
|
||||||
|
count, err = c.MessageCount("another_topic")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 0, count)
|
||||||
|
|
||||||
|
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(messages))
|
||||||
|
require.Equal(t, "my other message", messages[0].Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqliteCache_Attachments(t *testing.T) {
|
||||||
|
testCacheAttachments(t, newSqliteTestCache(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemCache_Attachments(t *testing.T) {
|
||||||
|
testCacheAttachments(t, newMemTestCache(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCacheAttachments(t *testing.T, c *messageCache) {
|
||||||
|
expires1 := time.Now().Add(-4 * time.Hour).Unix()
|
||||||
|
m := newDefaultMessage("mytopic", "flower for you")
|
||||||
|
m.ID = "m1"
|
||||||
|
m.Attachment = &attachment{
|
||||||
|
Name: "flower.jpg",
|
||||||
|
Type: "image/jpeg",
|
||||||
|
Size: 5000,
|
||||||
|
Expires: expires1,
|
||||||
|
URL: "https://ntfy.sh/file/AbDeFgJhal.jpg",
|
||||||
|
Owner: "1.2.3.4",
|
||||||
|
}
|
||||||
|
require.Nil(t, c.AddMessage(m))
|
||||||
|
|
||||||
|
expires2 := time.Now().Add(2 * time.Hour).Unix() // Future
|
||||||
|
m = newDefaultMessage("mytopic", "sending you a car")
|
||||||
|
m.ID = "m2"
|
||||||
|
m.Attachment = &attachment{
|
||||||
|
Name: "car.jpg",
|
||||||
|
Type: "image/jpeg",
|
||||||
|
Size: 10000,
|
||||||
|
Expires: expires2,
|
||||||
|
URL: "https://ntfy.sh/file/aCaRURL.jpg",
|
||||||
|
Owner: "1.2.3.4",
|
||||||
|
}
|
||||||
|
require.Nil(t, c.AddMessage(m))
|
||||||
|
|
||||||
|
expires3 := time.Now().Add(1 * time.Hour).Unix() // Future
|
||||||
|
m = newDefaultMessage("another-topic", "sending you another car")
|
||||||
|
m.ID = "m3"
|
||||||
|
m.Attachment = &attachment{
|
||||||
|
Name: "another-car.jpg",
|
||||||
|
Type: "image/jpeg",
|
||||||
|
Size: 20000,
|
||||||
|
Expires: expires3,
|
||||||
|
URL: "https://ntfy.sh/file/zakaDHFW.jpg",
|
||||||
|
Owner: "1.2.3.4",
|
||||||
|
}
|
||||||
|
require.Nil(t, c.AddMessage(m))
|
||||||
|
|
||||||
|
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 2, len(messages))
|
||||||
|
|
||||||
|
require.Equal(t, "flower for you", messages[0].Message)
|
||||||
|
require.Equal(t, "flower.jpg", messages[0].Attachment.Name)
|
||||||
|
require.Equal(t, "image/jpeg", messages[0].Attachment.Type)
|
||||||
|
require.Equal(t, int64(5000), messages[0].Attachment.Size)
|
||||||
|
require.Equal(t, expires1, messages[0].Attachment.Expires)
|
||||||
|
require.Equal(t, "https://ntfy.sh/file/AbDeFgJhal.jpg", messages[0].Attachment.URL)
|
||||||
|
require.Equal(t, "1.2.3.4", messages[0].Attachment.Owner)
|
||||||
|
|
||||||
|
require.Equal(t, "sending you a car", messages[1].Message)
|
||||||
|
require.Equal(t, "car.jpg", messages[1].Attachment.Name)
|
||||||
|
require.Equal(t, "image/jpeg", messages[1].Attachment.Type)
|
||||||
|
require.Equal(t, int64(10000), messages[1].Attachment.Size)
|
||||||
|
require.Equal(t, expires2, messages[1].Attachment.Expires)
|
||||||
|
require.Equal(t, "https://ntfy.sh/file/aCaRURL.jpg", messages[1].Attachment.URL)
|
||||||
|
require.Equal(t, "1.2.3.4", messages[1].Attachment.Owner)
|
||||||
|
|
||||||
|
size, err := c.AttachmentBytesUsed("1.2.3.4")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, int64(30000), size)
|
||||||
|
|
||||||
|
size, err = c.AttachmentBytesUsed("5.6.7.8")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, int64(0), size)
|
||||||
|
|
||||||
|
ids, err := c.AttachmentsExpired()
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, []string{"m1"}, ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqliteCache_Migration_From0(t *testing.T) {
|
||||||
|
filename := newSqliteTestCacheFile(t)
|
||||||
|
db, err := sql.Open("sqlite3", filename)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// Create "version 0" schema
|
||||||
|
_, err = db.Exec(`
|
||||||
|
BEGIN;
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id VARCHAR(20) PRIMARY KEY,
|
||||||
|
time INT NOT NULL,
|
||||||
|
topic VARCHAR(64) NOT NULL,
|
||||||
|
message VARCHAR(1024) NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||||
|
COMMIT;
|
||||||
|
`)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// Insert a bunch of messages
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
_, err = db.Exec(`INSERT INTO messages (id, time, topic, message) VALUES (?, ?, ?, ?)`,
|
||||||
|
fmt.Sprintf("abcd%d", i), time.Now().Unix(), "mytopic", fmt.Sprintf("some message %d", i))
|
||||||
|
require.Nil(t, err)
|
||||||
|
}
|
||||||
|
require.Nil(t, db.Close())
|
||||||
|
|
||||||
|
// Create cache to trigger migration
|
||||||
|
c := newSqliteTestCacheFromFile(t, filename)
|
||||||
|
checkSchemaVersion(t, c.db)
|
||||||
|
|
||||||
|
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 10, len(messages))
|
||||||
|
require.Equal(t, "some message 5", messages[5].Message)
|
||||||
|
require.Equal(t, "", messages[5].Title)
|
||||||
|
require.Nil(t, messages[5].Tags)
|
||||||
|
require.Equal(t, 0, messages[5].Priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqliteCache_Migration_From1(t *testing.T) {
|
||||||
|
filename := newSqliteTestCacheFile(t)
|
||||||
|
db, err := sql.Open("sqlite3", filename)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// Create "version 1" schema
|
||||||
|
_, err = db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id VARCHAR(20) PRIMARY KEY,
|
||||||
|
time INT NOT NULL,
|
||||||
|
topic VARCHAR(64) NOT NULL,
|
||||||
|
message VARCHAR(512) NOT NULL,
|
||||||
|
title VARCHAR(256) NOT NULL,
|
||||||
|
priority INT NOT NULL,
|
||||||
|
tags VARCHAR(256) NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||||
|
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
version INT NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO schemaVersion (id, version) VALUES (1, 1);
|
||||||
|
`)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// Insert a bunch of messages
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
_, err = db.Exec(`INSERT INTO messages (id, time, topic, message, title, priority, tags) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
fmt.Sprintf("abcd%d", i), time.Now().Unix(), "mytopic", fmt.Sprintf("some message %d", i), "", 0, "")
|
||||||
|
require.Nil(t, err)
|
||||||
|
}
|
||||||
|
require.Nil(t, db.Close())
|
||||||
|
|
||||||
|
// Create cache to trigger migration
|
||||||
|
c := newSqliteTestCacheFromFile(t, filename)
|
||||||
|
checkSchemaVersion(t, c.db)
|
||||||
|
|
||||||
|
// Add delayed message
|
||||||
|
delayedMessage := newDefaultMessage("mytopic", "some delayed message")
|
||||||
|
delayedMessage.Time = time.Now().Add(time.Minute).Unix()
|
||||||
|
require.Nil(t, c.AddMessage(delayedMessage))
|
||||||
|
|
||||||
|
// 10, not 11!
|
||||||
|
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 10, len(messages))
|
||||||
|
|
||||||
|
// 11!
|
||||||
|
messages, err = c.Messages("mytopic", sinceAllMessages, true)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 11, len(messages))
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkSchemaVersion(t *testing.T, db *sql.DB) {
|
||||||
|
rows, err := db.Query(`SELECT version FROM schemaVersion`)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.True(t, rows.Next())
|
||||||
|
|
||||||
|
var schemaVersion int
|
||||||
|
require.Nil(t, rows.Scan(&schemaVersion))
|
||||||
|
require.Equal(t, currentSchemaVersion, schemaVersion)
|
||||||
|
require.Nil(t, rows.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemCache_NopCache(t *testing.T) {
|
||||||
|
c, _ := newNopCache()
|
||||||
|
assert.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "my message")))
|
||||||
|
|
||||||
|
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Empty(t, messages)
|
||||||
|
|
||||||
|
topics, err := c.Topics()
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Empty(t, topics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSqliteTestCache(t *testing.T) *messageCache {
|
||||||
|
c, err := newSqliteCache(newSqliteTestCacheFile(t), false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSqliteTestCacheFile(t *testing.T) string {
|
||||||
|
return filepath.Join(t.TempDir(), "cache.db")
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSqliteTestCacheFromFile(t *testing.T, filename string) *messageCache {
|
||||||
|
c, err := newSqliteCache(filename, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMemTestCache(t *testing.T) *messageCache {
|
||||||
|
c, err := newMemCache()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
909
server/server.go
@@ -1,20 +1,28 @@
|
|||||||
# ntfy server config file
|
# ntfy server config file
|
||||||
|
|
||||||
# Public facing base URL of the service (e.g. https://ntfy.sh or https://ntfy.example.com)
|
# Public facing base URL of the service (e.g. https://ntfy.sh or https://ntfy.example.com)
|
||||||
# This setting is currently only used by the e-mail sending feature (outgoing mail only).
|
# This setting is currently only used by the attachments and e-mail sending feature (outgoing mail only).
|
||||||
#
|
#
|
||||||
# base-url:
|
# base-url:
|
||||||
|
|
||||||
# Listen address for the HTTP & HTTPS web server. If "listen-https" is set, you must also
|
# Listen address for the HTTP & HTTPS web server. If "listen-https" is set, you must also
|
||||||
# set "key-file" and "cert-file". Format: <hostname>:<port>
|
# set "key-file" and "cert-file". Format: [<ip>]:<port>, e.g. "1.2.3.4:8080".
|
||||||
|
#
|
||||||
|
# To listen on all interfaces, you may omit the IP address, e.g. ":443".
|
||||||
|
# To disable HTTP, set "listen-http" to "-".
|
||||||
#
|
#
|
||||||
# listen-http: ":80"
|
# listen-http: ":80"
|
||||||
# listen-https:
|
# listen-https:
|
||||||
|
|
||||||
|
# Listen on a Unix socket, e.g. /var/lib/ntfy/ntfy.sock
|
||||||
|
# This can be useful to avoid port issues on local systems, and to simplify permissions.
|
||||||
|
#
|
||||||
|
# listen-unix: <socket-path>
|
||||||
|
|
||||||
# Path to the private key & cert file for the HTTPS web server. Not used if "listen-https" is not set.
|
# Path to the private key & cert file for the HTTPS web server. Not used if "listen-https" is not set.
|
||||||
#
|
#
|
||||||
# key-file:
|
# key-file: <filename>
|
||||||
# cert-file:
|
# cert-file: <filename>
|
||||||
|
|
||||||
# If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app.
|
# If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app.
|
||||||
# This is optional and only required to save battery when using the Android app.
|
# This is optional and only required to save battery when using the Android app.
|
||||||
@@ -24,20 +32,40 @@
|
|||||||
# If set, messages are cached in a local SQLite database instead of only in-memory. This
|
# If set, messages are cached in a local SQLite database instead of only in-memory. This
|
||||||
# allows for service restarts without losing messages in support of the since= parameter.
|
# allows for service restarts without losing messages in support of the since= parameter.
|
||||||
#
|
#
|
||||||
|
# The "cache-duration" parameter defines the duration for which messages will be buffered
|
||||||
|
# before they are deleted. This is required to support the "since=..." and "poll=1" parameter.
|
||||||
# To disable the cache entirely (on-disk/in-memory), set "cache-duration" to 0.
|
# To disable the cache entirely (on-disk/in-memory), set "cache-duration" to 0.
|
||||||
|
# The cache file is created automatically, provided that the correct permissions are set.
|
||||||
#
|
#
|
||||||
# Note: If you are running ntfy with systemd, make sure this cache file is owned by the
|
# Debian/RPM package users:
|
||||||
# ntfy user and group by running: chown ntfy.ntfy <filename>.
|
# Use /var/cache/ntfy/cache.db as cache file to avoid permission issues. The package
|
||||||
|
# creates this folder for you.
|
||||||
|
#
|
||||||
|
# Check your permissions:
|
||||||
|
# If you are running ntfy with systemd, make sure this cache file is owned by the
|
||||||
|
# ntfy user and group by running: chown ntfy.ntfy <filename>.
|
||||||
#
|
#
|
||||||
# cache-file: <filename>
|
# cache-file: <filename>
|
||||||
|
|
||||||
# Duration for which messages will be buffered before they are deleted.
|
|
||||||
# This is required to support the "since=..." and "poll=1" parameter.
|
|
||||||
#
|
|
||||||
# You can disable the cache entirely by setting this to 0.
|
|
||||||
#
|
|
||||||
# cache-duration: "12h"
|
# cache-duration: "12h"
|
||||||
|
|
||||||
|
# If set, access to the ntfy server and API can be controlled on a granular level using
|
||||||
|
# the 'ntfy user' and 'ntfy access' commands. See the --help pages for details, or check the docs.
|
||||||
|
#
|
||||||
|
# - auth-file is the SQLite user/access database; it is created automatically if it doesn't already exist
|
||||||
|
# - auth-default-access defines the default/fallback access if no access control entry is found; it can be
|
||||||
|
# set to "read-write" (default), "read-only", "write-only" or "deny-all".
|
||||||
|
#
|
||||||
|
# Debian/RPM package users:
|
||||||
|
# Use /var/lib/ntfy/user.db as user database to avoid permission issues. The package
|
||||||
|
# creates this folder for you.
|
||||||
|
#
|
||||||
|
# Check your permissions:
|
||||||
|
# If you are running ntfy with systemd, make sure this user database file is owned by the
|
||||||
|
# ntfy user and group by running: chown ntfy.ntfy <filename>.
|
||||||
|
#
|
||||||
|
# auth-file: <filename>
|
||||||
|
# auth-default-access: "read-write"
|
||||||
|
|
||||||
# If set, the X-Forwarded-For header is used to determine the visitor IP address
|
# If set, the X-Forwarded-For header is used to determine the visitor IP address
|
||||||
# instead of the remote address of the connection.
|
# instead of the remote address of the connection.
|
||||||
#
|
#
|
||||||
@@ -91,13 +119,18 @@
|
|||||||
#
|
#
|
||||||
# Note that the Android app has a hardcoded timeout at 77s, so it should be less than that.
|
# Note that the Android app has a hardcoded timeout at 77s, so it should be less than that.
|
||||||
#
|
#
|
||||||
# keepalive-interval: "30s"
|
# keepalive-interval: "45s"
|
||||||
|
|
||||||
# Interval in which the manager prunes old messages, deletes topics
|
# Interval in which the manager prunes old messages, deletes topics
|
||||||
# and prints the stats.
|
# and prints the stats.
|
||||||
#
|
#
|
||||||
# manager-interval: "1m"
|
# manager-interval: "1m"
|
||||||
|
|
||||||
|
# Defines if the root route (/) is pointing to the landing page (as on ntfy.sh) or the
|
||||||
|
# web app. If you self-host, you don't want to change this. Can be "app" (default) or "home".
|
||||||
|
#
|
||||||
|
# web-root: app
|
||||||
|
|
||||||
# Rate limiting: Total number of topics before the server rejects new topics.
|
# Rate limiting: Total number of topics before the server rejects new topics.
|
||||||
#
|
#
|
||||||
# global-topic-limit: 15000
|
# global-topic-limit: 15000
|
||||||
@@ -109,9 +142,12 @@
|
|||||||
# Rate limiting: Allowed GET/PUT/POST requests per second, per visitor:
|
# Rate limiting: Allowed GET/PUT/POST requests per second, per visitor:
|
||||||
# - visitor-request-limit-burst is the initial bucket of requests each visitor has
|
# - visitor-request-limit-burst is the initial bucket of requests each visitor has
|
||||||
# - visitor-request-limit-replenish is the rate at which the bucket is refilled
|
# - visitor-request-limit-replenish is the rate at which the bucket is refilled
|
||||||
|
# - visitor-request-limit-exempt-hosts is a comma-separated list of hostnames and IPs to be
|
||||||
|
# exempt from request rate limiting; hostnames are resolved at the time the server is started
|
||||||
#
|
#
|
||||||
# visitor-request-limit-burst: 60
|
# visitor-request-limit-burst: 60
|
||||||
# visitor-request-limit-replenish: "10s"
|
# visitor-request-limit-replenish: "5s"
|
||||||
|
# visitor-request-limit-exempt-hosts: ""
|
||||||
|
|
||||||
# Rate limiting: Allowed emails per visitor:
|
# Rate limiting: Allowed emails per visitor:
|
||||||
# - visitor-email-limit-burst is the initial bucket of emails each visitor has
|
# - visitor-email-limit-burst is the initial bucket of emails each visitor has
|
||||||
|
|||||||
120
server/server_firebase.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
firebase "firebase.google.com/go"
|
||||||
|
"firebase.google.com/go/messaging"
|
||||||
|
"fmt"
|
||||||
|
"google.golang.org/api/option"
|
||||||
|
"heckel.io/ntfy/auth"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
fcmMessageLimit = 4000
|
||||||
|
)
|
||||||
|
|
||||||
|
// maybeTruncateFCMMessage performs best-effort truncation of FCM messages.
|
||||||
|
// The docs say the limit is 4000 characters, but during testing it wasn't quite clear
|
||||||
|
// what fields matter; so we're just capping the serialized JSON to 4000 bytes.
|
||||||
|
func maybeTruncateFCMMessage(m *messaging.Message) *messaging.Message {
|
||||||
|
s, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
if len(s) > fcmMessageLimit {
|
||||||
|
over := len(s) - fcmMessageLimit + 16 // = len("truncated":"1",), sigh ...
|
||||||
|
message, ok := m.Data["message"]
|
||||||
|
if ok && len(message) > over {
|
||||||
|
m.Data["truncated"] = "1"
|
||||||
|
m.Data["message"] = message[:len(message)-over]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func createFirebaseSubscriber(credentialsFile string, auther auth.Auther) (subscriber, error) {
|
||||||
|
fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(credentialsFile))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
msg, err := fb.Messaging(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return func(m *message) error {
|
||||||
|
fbm, err := toFirebaseMessage(m, auther)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = msg.Send(context.Background(), fbm)
|
||||||
|
return err
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, error) {
|
||||||
|
var data map[string]string // Mostly matches https://ntfy.sh/docs/subscribe/api/#json-message-format
|
||||||
|
switch m.Event {
|
||||||
|
case keepaliveEvent, openEvent:
|
||||||
|
data = map[string]string{
|
||||||
|
"id": m.ID,
|
||||||
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
|
"event": m.Event,
|
||||||
|
"topic": m.Topic,
|
||||||
|
}
|
||||||
|
case messageEvent:
|
||||||
|
allowForward := true
|
||||||
|
if auther != nil {
|
||||||
|
allowForward = auther.Authorize(nil, m.Topic, auth.PermissionRead) == nil
|
||||||
|
}
|
||||||
|
if allowForward {
|
||||||
|
data = map[string]string{
|
||||||
|
"id": m.ID,
|
||||||
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
|
"event": m.Event,
|
||||||
|
"topic": m.Topic,
|
||||||
|
"priority": fmt.Sprintf("%d", m.Priority),
|
||||||
|
"tags": strings.Join(m.Tags, ","),
|
||||||
|
"click": m.Click,
|
||||||
|
"title": m.Title,
|
||||||
|
"message": m.Message,
|
||||||
|
"encoding": m.Encoding,
|
||||||
|
}
|
||||||
|
if len(m.Actions) > 0 {
|
||||||
|
actions, err := json.Marshal(m.Actions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data["actions"] = string(actions)
|
||||||
|
}
|
||||||
|
if m.Attachment != nil {
|
||||||
|
data["attachment_name"] = m.Attachment.Name
|
||||||
|
data["attachment_type"] = m.Attachment.Type
|
||||||
|
data["attachment_size"] = fmt.Sprintf("%d", m.Attachment.Size)
|
||||||
|
data["attachment_expires"] = fmt.Sprintf("%d", m.Attachment.Expires)
|
||||||
|
data["attachment_url"] = m.Attachment.URL
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If anonymous read for a topic is not allowed, we cannot send the message along
|
||||||
|
// via Firebase. Instead, we send a "poll_request" message, asking the client to poll.
|
||||||
|
data = map[string]string{
|
||||||
|
"id": m.ID,
|
||||||
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
|
"event": pollRequestEvent,
|
||||||
|
"topic": m.Topic,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var androidConfig *messaging.AndroidConfig
|
||||||
|
if m.Priority >= 4 {
|
||||||
|
androidConfig = &messaging.AndroidConfig{
|
||||||
|
Priority: "high",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maybeTruncateFCMMessage(&messaging.Message{
|
||||||
|
Topic: m.Topic,
|
||||||
|
Data: data,
|
||||||
|
Android: androidConfig,
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
170
server/server_firebase_test.go
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"firebase.google.com/go/messaging"
|
||||||
|
"fmt"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"heckel.io/ntfy/auth"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testAuther struct {
|
||||||
|
Allow bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t testAuther) Authenticate(_, _ string) (*auth.User, error) {
|
||||||
|
return nil, errors.New("not used")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t testAuther) Authorize(_ *auth.User, _ string, _ auth.Permission) error {
|
||||||
|
if t.Allow {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToFirebaseMessage_Keepalive(t *testing.T) {
|
||||||
|
m := newKeepaliveMessage("mytopic")
|
||||||
|
fbm, err := toFirebaseMessage(m, nil)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "mytopic", fbm.Topic)
|
||||||
|
require.Nil(t, fbm.Android)
|
||||||
|
require.Equal(t, map[string]string{
|
||||||
|
"id": m.ID,
|
||||||
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
|
"event": m.Event,
|
||||||
|
"topic": m.Topic,
|
||||||
|
}, fbm.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToFirebaseMessage_Open(t *testing.T) {
|
||||||
|
m := newOpenMessage("mytopic")
|
||||||
|
fbm, err := toFirebaseMessage(m, nil)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "mytopic", fbm.Topic)
|
||||||
|
require.Nil(t, fbm.Android)
|
||||||
|
require.Equal(t, map[string]string{
|
||||||
|
"id": m.ID,
|
||||||
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
|
"event": m.Event,
|
||||||
|
"topic": m.Topic,
|
||||||
|
}, fbm.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
|
||||||
|
m := newDefaultMessage("mytopic", "this is a message")
|
||||||
|
m.Priority = 4
|
||||||
|
m.Tags = []string{"tag 1", "tag2"}
|
||||||
|
m.Click = "https://google.com"
|
||||||
|
m.Title = "some title"
|
||||||
|
m.Attachment = &attachment{
|
||||||
|
Name: "some file.jpg",
|
||||||
|
Type: "image/jpeg",
|
||||||
|
Size: 12345,
|
||||||
|
Expires: 98765543,
|
||||||
|
URL: "https://example.com/file.jpg",
|
||||||
|
Owner: "some-owner",
|
||||||
|
}
|
||||||
|
fbm, err := toFirebaseMessage(m, &testAuther{Allow: true})
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "mytopic", fbm.Topic)
|
||||||
|
require.Equal(t, &messaging.AndroidConfig{
|
||||||
|
Priority: "high",
|
||||||
|
}, fbm.Android)
|
||||||
|
require.Equal(t, map[string]string{
|
||||||
|
"id": m.ID,
|
||||||
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
|
"event": "message",
|
||||||
|
"topic": "mytopic",
|
||||||
|
"priority": "4",
|
||||||
|
"tags": strings.Join(m.Tags, ","),
|
||||||
|
"click": "https://google.com",
|
||||||
|
"title": "some title",
|
||||||
|
"message": "this is a message",
|
||||||
|
"encoding": "",
|
||||||
|
"attachment_name": "some file.jpg",
|
||||||
|
"attachment_type": "image/jpeg",
|
||||||
|
"attachment_size": "12345",
|
||||||
|
"attachment_expires": "98765543",
|
||||||
|
"attachment_url": "https://example.com/file.jpg",
|
||||||
|
}, fbm.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToFirebaseMessage_Message_Normal_Not_Allowed(t *testing.T) {
|
||||||
|
m := newDefaultMessage("mytopic", "this is a message")
|
||||||
|
m.Priority = 5
|
||||||
|
fbm, err := toFirebaseMessage(m, &testAuther{Allow: false}) // Not allowed!
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "mytopic", fbm.Topic)
|
||||||
|
require.Equal(t, &messaging.AndroidConfig{
|
||||||
|
Priority: "high",
|
||||||
|
}, fbm.Android)
|
||||||
|
require.Equal(t, "", fbm.Data["message"])
|
||||||
|
require.Equal(t, "", fbm.Data["priority"])
|
||||||
|
require.Equal(t, map[string]string{
|
||||||
|
"id": m.ID,
|
||||||
|
"time": fmt.Sprintf("%d", m.Time),
|
||||||
|
"event": "poll_request",
|
||||||
|
"topic": "mytopic",
|
||||||
|
}, fbm.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMaybeTruncateFCMMessage(t *testing.T) {
|
||||||
|
origMessage := strings.Repeat("this is a long string", 300)
|
||||||
|
origFCMMessage := &messaging.Message{
|
||||||
|
Topic: "mytopic",
|
||||||
|
Data: map[string]string{
|
||||||
|
"id": "abcdefg",
|
||||||
|
"time": "1641324761",
|
||||||
|
"event": "message",
|
||||||
|
"topic": "mytopic",
|
||||||
|
"priority": "0",
|
||||||
|
"tags": "",
|
||||||
|
"title": "",
|
||||||
|
"message": origMessage,
|
||||||
|
},
|
||||||
|
Android: &messaging.AndroidConfig{
|
||||||
|
Priority: "high",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
origMessageLength := len(origFCMMessage.Data["message"])
|
||||||
|
serializedOrigFCMMessage, _ := json.Marshal(origFCMMessage)
|
||||||
|
require.Greater(t, len(serializedOrigFCMMessage), fcmMessageLimit) // Pre-condition
|
||||||
|
|
||||||
|
truncatedFCMMessage := maybeTruncateFCMMessage(origFCMMessage)
|
||||||
|
truncatedMessageLength := len(truncatedFCMMessage.Data["message"])
|
||||||
|
serializedTruncatedFCMMessage, _ := json.Marshal(truncatedFCMMessage)
|
||||||
|
require.Equal(t, fcmMessageLimit, len(serializedTruncatedFCMMessage))
|
||||||
|
require.Equal(t, "1", truncatedFCMMessage.Data["truncated"])
|
||||||
|
require.NotEqual(t, origMessageLength, truncatedMessageLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMaybeTruncateFCMMessage_NotTooLong(t *testing.T) {
|
||||||
|
origMessage := "not really a long string"
|
||||||
|
origFCMMessage := &messaging.Message{
|
||||||
|
Topic: "mytopic",
|
||||||
|
Data: map[string]string{
|
||||||
|
"id": "abcdefg",
|
||||||
|
"time": "1641324761",
|
||||||
|
"event": "message",
|
||||||
|
"topic": "mytopic",
|
||||||
|
"priority": "0",
|
||||||
|
"tags": "",
|
||||||
|
"title": "",
|
||||||
|
"message": origMessage,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
origMessageLength := len(origFCMMessage.Data["message"])
|
||||||
|
serializedOrigFCMMessage, _ := json.Marshal(origFCMMessage)
|
||||||
|
require.LessOrEqual(t, len(serializedOrigFCMMessage), fcmMessageLimit) // Pre-condition
|
||||||
|
|
||||||
|
notTruncatedFCMMessage := maybeTruncateFCMMessage(origFCMMessage)
|
||||||
|
notTruncatedMessageLength := len(notTruncatedFCMMessage.Data["message"])
|
||||||
|
serializedNotTruncatedFCMMessage, _ := json.Marshal(notTruncatedFCMMessage)
|
||||||
|
require.Equal(t, origMessageLength, notTruncatedMessageLength)
|
||||||
|
require.Equal(t, len(serializedOrigFCMMessage), len(serializedNotTruncatedFCMMessage))
|
||||||
|
require.Equal(t, "", notTruncatedFCMMessage.Data["truncated"])
|
||||||
|
}
|
||||||
@@ -3,11 +3,13 @@ package server
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"firebase.google.com/go/messaging"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"heckel.io/ntfy/auth"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -144,19 +146,16 @@ func TestServer_StaticSites(t *testing.T) {
|
|||||||
|
|
||||||
rr = request(t, s, "GET", "/mytopic", "", nil)
|
rr = request(t, s, "GET", "/mytopic", "", nil)
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
require.Contains(t, rr.Body.String(), `<meta name="robots" content="noindex, nofollow" />`)
|
require.Contains(t, rr.Body.String(), `<meta name="robots" content="noindex, nofollow"/>`)
|
||||||
|
|
||||||
rr = request(t, s, "GET", "/static/css/app.css", "", nil)
|
rr = request(t, s, "GET", "/static/css/home.css", "", nil)
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
require.Contains(t, rr.Body.String(), `html, body {`)
|
require.Contains(t, rr.Body.String(), `html, body {`)
|
||||||
|
|
||||||
rr = request(t, s, "GET", "/docs", "", nil)
|
rr = request(t, s, "GET", "/docs", "", nil)
|
||||||
require.Equal(t, 301, rr.Code)
|
require.Equal(t, 301, rr.Code)
|
||||||
|
|
||||||
rr = request(t, s, "GET", "/docs/", "", nil)
|
// Docs test removed, it was failing annoyingly.
|
||||||
require.Equal(t, 200, rr.Code)
|
|
||||||
require.Contains(t, rr.Body.String(), `Made with ❤️ by Philipp C. Heckel`)
|
|
||||||
require.Contains(t, rr.Body.String(), `<script src=static/js/extra.js></script>`)
|
|
||||||
|
|
||||||
rr = request(t, s, "GET", "/example.html", "", nil)
|
rr = request(t, s, "GET", "/example.html", "", nil)
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
@@ -204,6 +203,14 @@ func TestServer_PublishPriority(t *testing.T) {
|
|||||||
require.Equal(t, 40007, toHTTPError(t, response.Body.String()).Code)
|
require.Equal(t, 40007, toHTTPError(t, response.Body.String()).Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishGETOnlyOneTopic(t *testing.T) {
|
||||||
|
// This tests a bug that allowed publishing topics with a comma in the name (no ticket)
|
||||||
|
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
response := request(t, s, "GET", "/mytopic,mytopic2/publish?m=hi", "", nil)
|
||||||
|
require.Equal(t, 404, response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_PublishNoCache(t *testing.T) {
|
func TestServer_PublishNoCache(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
@@ -401,6 +408,17 @@ func TestServer_PublishViaGET(t *testing.T) {
|
|||||||
require.Greater(t, msg.Time, time.Now().Add(23*time.Hour).Unix())
|
require.Greater(t, msg.Time, time.Now().Add(23*time.Hour).Unix())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishMessageInHeaderWithNewlines(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "", map[string]string{
|
||||||
|
"Message": "Line 1\\nLine 2",
|
||||||
|
})
|
||||||
|
msg := toMessage(t, response.Body.String())
|
||||||
|
require.NotEmpty(t, msg.ID)
|
||||||
|
require.Equal(t, "Line 1\nLine 2", msg.Message) // \\n -> \n !
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_PublishFirebase(t *testing.T) {
|
func TestServer_PublishFirebase(t *testing.T) {
|
||||||
// This is unfortunately not much of a test, since it merely fires the messages towards Firebase,
|
// This is unfortunately not much of a test, since it merely fires the messages towards Firebase,
|
||||||
// but cannot re-read them. There is no way from Go to read the messages back, or even get an error back.
|
// but cannot re-read them. There is no way from Go to read the messages back, or even get an error back.
|
||||||
@@ -523,6 +541,146 @@ func TestServer_SubscribeWithQueryFilters(t *testing.T) {
|
|||||||
require.Equal(t, keepaliveEvent, messages[2].Event)
|
require.Equal(t, keepaliveEvent, messages[2].Event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_Auth_Success_Admin(t *testing.T) {
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.AuthFile = filepath.Join(t.TempDir(), "user.db")
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
|
manager := s.auth.(auth.Manager)
|
||||||
|
require.Nil(t, manager.AddUser("phil", "phil", auth.RoleAdmin))
|
||||||
|
|
||||||
|
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
|
||||||
|
"Authorization": basicAuth("phil:phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_Auth_Success_User(t *testing.T) {
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.AuthFile = filepath.Join(t.TempDir(), "user.db")
|
||||||
|
c.AuthDefaultRead = false
|
||||||
|
c.AuthDefaultWrite = false
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
|
manager := s.auth.(auth.Manager)
|
||||||
|
require.Nil(t, manager.AddUser("ben", "ben", auth.RoleUser))
|
||||||
|
require.Nil(t, manager.AllowAccess("ben", "mytopic", true, true))
|
||||||
|
|
||||||
|
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
|
||||||
|
"Authorization": basicAuth("ben:ben"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_Auth_Success_User_MultipleTopics(t *testing.T) {
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.AuthFile = filepath.Join(t.TempDir(), "user.db")
|
||||||
|
c.AuthDefaultRead = false
|
||||||
|
c.AuthDefaultWrite = false
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
|
manager := s.auth.(auth.Manager)
|
||||||
|
require.Nil(t, manager.AddUser("ben", "ben", auth.RoleUser))
|
||||||
|
require.Nil(t, manager.AllowAccess("ben", "mytopic", true, true))
|
||||||
|
require.Nil(t, manager.AllowAccess("ben", "anothertopic", true, true))
|
||||||
|
|
||||||
|
response := request(t, s, "GET", "/mytopic,anothertopic/auth", "", map[string]string{
|
||||||
|
"Authorization": basicAuth("ben:ben"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
|
response = request(t, s, "GET", "/mytopic,anothertopic,NOT-THIS-ONE/auth", "", map[string]string{
|
||||||
|
"Authorization": basicAuth("ben:ben"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 403, response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_Auth_Fail_InvalidPass(t *testing.T) {
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.AuthFile = filepath.Join(t.TempDir(), "user.db")
|
||||||
|
c.AuthDefaultRead = false
|
||||||
|
c.AuthDefaultWrite = false
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
|
manager := s.auth.(auth.Manager)
|
||||||
|
require.Nil(t, manager.AddUser("phil", "phil", auth.RoleAdmin))
|
||||||
|
|
||||||
|
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
|
||||||
|
"Authorization": basicAuth("phil:INVALID"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 401, response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_Auth_Fail_Unauthorized(t *testing.T) {
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.AuthFile = filepath.Join(t.TempDir(), "user.db")
|
||||||
|
c.AuthDefaultRead = false
|
||||||
|
c.AuthDefaultWrite = false
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
|
manager := s.auth.(auth.Manager)
|
||||||
|
require.Nil(t, manager.AddUser("ben", "ben", auth.RoleUser))
|
||||||
|
require.Nil(t, manager.AllowAccess("ben", "sometopic", true, true)) // Not mytopic!
|
||||||
|
|
||||||
|
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
|
||||||
|
"Authorization": basicAuth("ben:ben"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 403, response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_Auth_Fail_CannotPublish(t *testing.T) {
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.AuthFile = filepath.Join(t.TempDir(), "user.db")
|
||||||
|
c.AuthDefaultRead = true // Open by default
|
||||||
|
c.AuthDefaultWrite = true // Open by default
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
|
manager := s.auth.(auth.Manager)
|
||||||
|
require.Nil(t, manager.AddUser("phil", "phil", auth.RoleAdmin))
|
||||||
|
require.Nil(t, manager.AllowAccess(auth.Everyone, "private", false, false))
|
||||||
|
require.Nil(t, manager.AllowAccess(auth.Everyone, "announcements", true, false))
|
||||||
|
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "test", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
|
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
|
response = request(t, s, "PUT", "/announcements", "test", nil)
|
||||||
|
require.Equal(t, 403, response.Code) // Cannot write as anonymous
|
||||||
|
|
||||||
|
response = request(t, s, "PUT", "/announcements", "test", map[string]string{
|
||||||
|
"Authorization": basicAuth("phil:phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
|
response = request(t, s, "GET", "/announcements/json?poll=1", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code) // Anonymous read allowed
|
||||||
|
|
||||||
|
response = request(t, s, "GET", "/private/json?poll=1", "", nil)
|
||||||
|
require.Equal(t, 403, response.Code) // Anonymous read not allowed
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_Auth_ViaQuery(t *testing.T) {
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.AuthFile = filepath.Join(t.TempDir(), "user.db")
|
||||||
|
c.AuthDefaultRead = false
|
||||||
|
c.AuthDefaultWrite = false
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
|
manager := s.auth.(auth.Manager)
|
||||||
|
require.Nil(t, manager.AddUser("ben", "some pass", auth.RoleAdmin))
|
||||||
|
|
||||||
|
u := fmt.Sprintf("/mytopic/json?poll=1&auth=%s", base64.RawURLEncoding.EncodeToString([]byte(basicAuth("ben:some pass"))))
|
||||||
|
response := request(t, s, "GET", u, "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
|
u = fmt.Sprintf("/mytopic/json?poll=1&auth=%s", base64.RawURLEncoding.EncodeToString([]byte(basicAuth("ben:WRONNNGGGG"))))
|
||||||
|
response = request(t, s, "GET", u, "", nil)
|
||||||
|
require.Equal(t, 401, response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
func TestServer_Curl_Publish_Poll(t *testing.T) {
|
func TestServer_Curl_Publish_Poll(t *testing.T) {
|
||||||
s, port := test.StartServer(t)
|
s, port := test.StartServer(t)
|
||||||
@@ -556,6 +714,49 @@ func (t *testMailer) Send(from, to string, m *message) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *testMailer) Count() int {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
return t.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishTooRequests_Defaults(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
for i := 0; i < 60; i++ {
|
||||||
|
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
}
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "message", nil)
|
||||||
|
require.Equal(t, 429, response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishTooRequests_Defaults_ExemptHosts(t *testing.T) {
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.VisitorRequestExemptIPAddrs = []string{"9.9.9.9"} // see request()
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
for i := 0; i < 65; i++ { // > 60
|
||||||
|
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishTooRequests_ShortReplenish(t *testing.T) {
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.VisitorRequestLimitBurst = 60
|
||||||
|
c.VisitorRequestLimitReplenish = 500 * time.Millisecond
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
for i := 0; i < 60; i++ {
|
||||||
|
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
}
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "message", nil)
|
||||||
|
require.Equal(t, 429, response.Code)
|
||||||
|
|
||||||
|
time.Sleep(510 * time.Millisecond)
|
||||||
|
response = request(t, s, "PUT", "/mytopic", "message", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_PublishTooManyEmails_Defaults(t *testing.T) {
|
func TestServer_PublishTooManyEmails_Defaults(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
s.mailer = &testMailer{}
|
s.mailer = &testMailer{}
|
||||||
@@ -624,61 +825,152 @@ func TestServer_UnifiedPushDiscovery(t *testing.T) {
|
|||||||
require.Equal(t, `{"unifiedpush":{"version":1}}`+"\n", response.Body.String())
|
require.Equal(t, `{"unifiedpush":{"version":1}}`+"\n", response.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_MaybeTruncateFCMMessage(t *testing.T) {
|
func TestServer_PublishUnifiedPushBinary_AndPoll(t *testing.T) {
|
||||||
origMessage := strings.Repeat("this is a long string", 300)
|
b := make([]byte, 12) // Max length
|
||||||
origFCMMessage := &messaging.Message{
|
_, err := rand.Read(b)
|
||||||
Topic: "mytopic",
|
require.Nil(t, err)
|
||||||
Data: map[string]string{
|
|
||||||
"id": "abcdefg",
|
|
||||||
"time": "1641324761",
|
|
||||||
"event": "message",
|
|
||||||
"topic": "mytopic",
|
|
||||||
"priority": "0",
|
|
||||||
"tags": "",
|
|
||||||
"title": "",
|
|
||||||
"message": origMessage,
|
|
||||||
},
|
|
||||||
Android: &messaging.AndroidConfig{
|
|
||||||
Priority: "high",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
origMessageLength := len(origFCMMessage.Data["message"])
|
|
||||||
serializedOrigFCMMessage, _ := json.Marshal(origFCMMessage)
|
|
||||||
require.Greater(t, len(serializedOrigFCMMessage), fcmMessageLimit) // Pre-condition
|
|
||||||
|
|
||||||
truncatedFCMMessage := maybeTruncateFCMMessage(origFCMMessage)
|
s := newTestServer(t, newTestConfig(t))
|
||||||
truncatedMessageLength := len(truncatedFCMMessage.Data["message"])
|
response := request(t, s, "PUT", "/mytopic?up=1", string(b), nil)
|
||||||
serializedTruncatedFCMMessage, _ := json.Marshal(truncatedFCMMessage)
|
require.Equal(t, 200, response.Code)
|
||||||
require.Equal(t, fcmMessageLimit, len(serializedTruncatedFCMMessage))
|
|
||||||
require.Equal(t, "1", truncatedFCMMessage.Data["truncated"])
|
m := toMessage(t, response.Body.String())
|
||||||
require.NotEqual(t, origMessageLength, truncatedMessageLength)
|
require.Equal(t, "base64", m.Encoding)
|
||||||
|
b2, err := base64.StdEncoding.DecodeString(m.Message)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, b, b2)
|
||||||
|
|
||||||
|
response = request(t, s, "GET", "/mytopic/json?poll=1", string(b), nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
m = toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "base64", m.Encoding)
|
||||||
|
b2, err = base64.StdEncoding.DecodeString(m.Message)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, b, b2)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_MaybeTruncateFCMMessage_NotTooLong(t *testing.T) {
|
func TestServer_PublishUnifiedPushBinary_Truncated(t *testing.T) {
|
||||||
origMessage := "not really a long string"
|
b := make([]byte, 5000) // Longer than max length
|
||||||
origFCMMessage := &messaging.Message{
|
_, err := rand.Read(b)
|
||||||
Topic: "mytopic",
|
require.Nil(t, err)
|
||||||
Data: map[string]string{
|
|
||||||
"id": "abcdefg",
|
|
||||||
"time": "1641324761",
|
|
||||||
"event": "message",
|
|
||||||
"topic": "mytopic",
|
|
||||||
"priority": "0",
|
|
||||||
"tags": "",
|
|
||||||
"title": "",
|
|
||||||
"message": origMessage,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
origMessageLength := len(origFCMMessage.Data["message"])
|
|
||||||
serializedOrigFCMMessage, _ := json.Marshal(origFCMMessage)
|
|
||||||
require.LessOrEqual(t, len(serializedOrigFCMMessage), fcmMessageLimit) // Pre-condition
|
|
||||||
|
|
||||||
notTruncatedFCMMessage := maybeTruncateFCMMessage(origFCMMessage)
|
s := newTestServer(t, newTestConfig(t))
|
||||||
notTruncatedMessageLength := len(notTruncatedFCMMessage.Data["message"])
|
response := request(t, s, "PUT", "/mytopic?up=1", string(b), nil)
|
||||||
serializedNotTruncatedFCMMessage, _ := json.Marshal(notTruncatedFCMMessage)
|
require.Equal(t, 200, response.Code)
|
||||||
require.Equal(t, origMessageLength, notTruncatedMessageLength)
|
|
||||||
require.Equal(t, len(serializedOrigFCMMessage), len(serializedNotTruncatedFCMMessage))
|
m := toMessage(t, response.Body.String())
|
||||||
require.Equal(t, "", notTruncatedFCMMessage.Data["truncated"])
|
require.Equal(t, "base64", m.Encoding)
|
||||||
|
b2, err := base64.StdEncoding.DecodeString(m.Message)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 4096, len(b2))
|
||||||
|
require.Equal(t, b[:4096], b2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishUnifiedPushText(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
response := request(t, s, "PUT", "/mytopic?up=1", "this is a unifiedpush text message", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
|
m := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "", m.Encoding)
|
||||||
|
require.Equal(t, "this is a unifiedpush text message", m.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishActions_AndPoll(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "my message", map[string]string{
|
||||||
|
"Actions": "view, Open portal, https://home.nest.com/; http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65",
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
|
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
m := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, 2, len(m.Actions))
|
||||||
|
require.Equal(t, "view", m.Actions[0].Action)
|
||||||
|
require.Equal(t, "Open portal", m.Actions[0].Label)
|
||||||
|
require.Equal(t, "https://home.nest.com/", m.Actions[0].URL)
|
||||||
|
require.Equal(t, "http", m.Actions[1].Action)
|
||||||
|
require.Equal(t, "Turn down", m.Actions[1].Label)
|
||||||
|
require.Equal(t, "https://api.nest.com/device/XZ1D2", m.Actions[1].URL)
|
||||||
|
require.Equal(t, "target_temp_f=65", m.Actions[1].Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishAsJSON(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
body := `{"topic":"mytopic","message":"A message","title":"a title\nwith lines","tags":["tag1","tag 2"],` +
|
||||||
|
`"not-a-thing":"ok", "attach":"http://google.com","filename":"google.pdf", "click":"http://ntfy.sh","priority":4,` +
|
||||||
|
`"delay":"30min"}`
|
||||||
|
response := request(t, s, "PUT", "/", body, nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
|
m := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "mytopic", m.Topic)
|
||||||
|
require.Equal(t, "A message", m.Message)
|
||||||
|
require.Equal(t, "a title\nwith lines", m.Title)
|
||||||
|
require.Equal(t, []string{"tag1", "tag 2"}, m.Tags)
|
||||||
|
require.Equal(t, "http://google.com", m.Attachment.URL)
|
||||||
|
require.Equal(t, "google.pdf", m.Attachment.Name)
|
||||||
|
require.Equal(t, "http://ntfy.sh", m.Click)
|
||||||
|
require.Equal(t, 4, m.Priority)
|
||||||
|
require.True(t, m.Time > time.Now().Unix()+29*60)
|
||||||
|
require.True(t, m.Time < time.Now().Unix()+31*60)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishAsJSON_WithEmail(t *testing.T) {
|
||||||
|
mailer := &testMailer{}
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
s.mailer = mailer
|
||||||
|
body := `{"topic":"mytopic","message":"A message","email":"phil@example.com"}`
|
||||||
|
response := request(t, s, "PUT", "/", body, nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
|
m := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "mytopic", m.Topic)
|
||||||
|
require.Equal(t, "A message", m.Message)
|
||||||
|
require.Equal(t, 1, mailer.Count())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishAsJSON_WithActions(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
body := `{
|
||||||
|
"topic":"mytopic",
|
||||||
|
"message":"A message",
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"action": "view",
|
||||||
|
"label": "Open portal",
|
||||||
|
"url": "https://home.nest.com/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "http",
|
||||||
|
"label": "Turn down",
|
||||||
|
"url": "https://api.nest.com/device/XZ1D2",
|
||||||
|
"body": "target_temp_f=65"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
response := request(t, s, "POST", "/", body, nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
|
m := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "mytopic", m.Topic)
|
||||||
|
require.Equal(t, "A message", m.Message)
|
||||||
|
require.Equal(t, 2, len(m.Actions))
|
||||||
|
require.Equal(t, "view", m.Actions[0].Action)
|
||||||
|
require.Equal(t, "Open portal", m.Actions[0].Label)
|
||||||
|
require.Equal(t, "https://home.nest.com/", m.Actions[0].URL)
|
||||||
|
require.Equal(t, "http", m.Actions[1].Action)
|
||||||
|
require.Equal(t, "Turn down", m.Actions[1].Label)
|
||||||
|
require.Equal(t, "https://api.nest.com/device/XZ1D2", m.Actions[1].URL)
|
||||||
|
require.Equal(t, "target_temp_f=65", m.Actions[1].Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishAsJSON_Invalid(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
body := `{"topic":"mytopic",INVALID`
|
||||||
|
response := request(t, s, "PUT", "/", body, nil)
|
||||||
|
require.Equal(t, 400, response.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_PublishAttachment(t *testing.T) {
|
func TestServer_PublishAttachment(t *testing.T) {
|
||||||
@@ -689,7 +981,7 @@ func TestServer_PublishAttachment(t *testing.T) {
|
|||||||
require.Equal(t, "attachment.txt", msg.Attachment.Name)
|
require.Equal(t, "attachment.txt", msg.Attachment.Name)
|
||||||
require.Equal(t, "text/plain; charset=utf-8", msg.Attachment.Type)
|
require.Equal(t, "text/plain; charset=utf-8", msg.Attachment.Type)
|
||||||
require.Equal(t, int64(5000), msg.Attachment.Size)
|
require.Equal(t, int64(5000), msg.Attachment.Size)
|
||||||
require.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(3*time.Hour).Unix())
|
require.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(179*time.Minute).Unix()) // Almost 3 hours
|
||||||
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
||||||
require.Equal(t, "", msg.Attachment.Owner) // Should never be returned
|
require.Equal(t, "", msg.Attachment.Owner) // Should never be returned
|
||||||
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID))
|
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID))
|
||||||
@@ -701,7 +993,7 @@ func TestServer_PublishAttachment(t *testing.T) {
|
|||||||
require.Equal(t, content, response.Body.String())
|
require.Equal(t, content, response.Body.String())
|
||||||
|
|
||||||
// Slightly unrelated cross-test: make sure we add an owner for internal attachments
|
// Slightly unrelated cross-test: make sure we add an owner for internal attachments
|
||||||
size, err := s.cache.AttachmentsSize("9.9.9.9") // See request()
|
size, err := s.messageCache.AttachmentBytesUsed("9.9.9.9") // See request()
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, int64(5000), size)
|
require.Equal(t, int64(5000), size)
|
||||||
}
|
}
|
||||||
@@ -730,7 +1022,7 @@ func TestServer_PublishAttachmentShortWithFilename(t *testing.T) {
|
|||||||
require.Equal(t, content, response.Body.String())
|
require.Equal(t, content, response.Body.String())
|
||||||
|
|
||||||
// Slightly unrelated cross-test: make sure we add an owner for internal attachments
|
// Slightly unrelated cross-test: make sure we add an owner for internal attachments
|
||||||
size, err := s.cache.AttachmentsSize("1.2.3.4")
|
size, err := s.messageCache.AttachmentBytesUsed("1.2.3.4")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, int64(21), size)
|
require.Equal(t, int64(21), size)
|
||||||
}
|
}
|
||||||
@@ -750,7 +1042,7 @@ func TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) {
|
|||||||
require.Equal(t, "", msg.Attachment.Owner)
|
require.Equal(t, "", msg.Attachment.Owner)
|
||||||
|
|
||||||
// Slightly unrelated cross-test: make sure we don't add an owner for external attachments
|
// Slightly unrelated cross-test: make sure we don't add an owner for external attachments
|
||||||
size, err := s.cache.AttachmentsSize("127.0.0.1")
|
size, err := s.messageCache.AttachmentBytesUsed("127.0.0.1")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, int64(0), size)
|
require.Equal(t, int64(0), size)
|
||||||
}
|
}
|
||||||
@@ -787,9 +1079,9 @@ func TestServer_PublishAttachmentTooLargeContentLength(t *testing.T) {
|
|||||||
"Content-Length": "20000000",
|
"Content-Length": "20000000",
|
||||||
})
|
})
|
||||||
err := toHTTPError(t, response.Body.String())
|
err := toHTTPError(t, response.Body.String())
|
||||||
require.Equal(t, 400, response.Code)
|
require.Equal(t, 413, response.Code)
|
||||||
require.Equal(t, 400, err.HTTPCode)
|
require.Equal(t, 413, err.HTTPCode)
|
||||||
require.Equal(t, 40012, err.Code)
|
require.Equal(t, 41301, err.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_PublishAttachmentTooLargeBodyAttachmentFileSizeLimit(t *testing.T) {
|
func TestServer_PublishAttachmentTooLargeBodyAttachmentFileSizeLimit(t *testing.T) {
|
||||||
@@ -799,9 +1091,9 @@ func TestServer_PublishAttachmentTooLargeBodyAttachmentFileSizeLimit(t *testing.
|
|||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
response := request(t, s, "PUT", "/mytopic", content, nil)
|
response := request(t, s, "PUT", "/mytopic", content, nil)
|
||||||
err := toHTTPError(t, response.Body.String())
|
err := toHTTPError(t, response.Body.String())
|
||||||
require.Equal(t, 400, response.Code)
|
require.Equal(t, 413, response.Code)
|
||||||
require.Equal(t, 400, err.HTTPCode)
|
require.Equal(t, 413, err.HTTPCode)
|
||||||
require.Equal(t, 40012, err.Code)
|
require.Equal(t, 41301, err.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_PublishAttachmentExpiryBeforeDelivery(t *testing.T) {
|
func TestServer_PublishAttachmentExpiryBeforeDelivery(t *testing.T) {
|
||||||
@@ -831,9 +1123,9 @@ func TestServer_PublishAttachmentTooLargeBodyVisitorAttachmentTotalSizeLimit(t *
|
|||||||
content := util.RandomString(5001) // 5000+5001 > , see below
|
content := util.RandomString(5001) // 5000+5001 > , see below
|
||||||
response = request(t, s, "PUT", "/mytopic", content, nil)
|
response = request(t, s, "PUT", "/mytopic", content, nil)
|
||||||
err := toHTTPError(t, response.Body.String())
|
err := toHTTPError(t, response.Body.String())
|
||||||
require.Equal(t, 400, response.Code)
|
require.Equal(t, 413, response.Code)
|
||||||
require.Equal(t, 400, err.HTTPCode)
|
require.Equal(t, 413, err.HTTPCode)
|
||||||
require.Equal(t, 40012, err.Code)
|
require.Equal(t, 41301, err.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_PublishAttachmentAndPrune(t *testing.T) {
|
func TestServer_PublishAttachmentAndPrune(t *testing.T) {
|
||||||
@@ -907,8 +1199,32 @@ func TestServer_PublishAttachmentBandwidthLimitUploadOnly(t *testing.T) {
|
|||||||
// And a failed one
|
// And a failed one
|
||||||
response := request(t, s, "PUT", "/mytopic", content, nil)
|
response := request(t, s, "PUT", "/mytopic", content, nil)
|
||||||
err := toHTTPError(t, response.Body.String())
|
err := toHTTPError(t, response.Body.String())
|
||||||
require.Equal(t, 400, response.Code)
|
require.Equal(t, 413, response.Code)
|
||||||
require.Equal(t, 40012, err.Code)
|
require.Equal(t, 41301, err.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishAttachmentUserStats(t *testing.T) {
|
||||||
|
content := util.RandomString(4999) // > 4096
|
||||||
|
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.AttachmentFileSizeLimit = 5000
|
||||||
|
c.VisitorAttachmentTotalSizeLimit = 6000
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
|
// Upload one attachment
|
||||||
|
response := request(t, s, "PUT", "/mytopic", content, nil)
|
||||||
|
msg := toMessage(t, response.Body.String())
|
||||||
|
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
||||||
|
|
||||||
|
// User stats
|
||||||
|
response = request(t, s, "GET", "/user/stats", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
var stats visitorStats
|
||||||
|
require.Nil(t, json.NewDecoder(strings.NewReader(response.Body.String())).Decode(&stats))
|
||||||
|
require.Equal(t, int64(5000), stats.AttachmentFileSizeLimit)
|
||||||
|
require.Equal(t, int64(6000), stats.VisitorAttachmentBytesTotal)
|
||||||
|
require.Equal(t, int64(4999), stats.VisitorAttachmentBytesUsed)
|
||||||
|
require.Equal(t, int64(1001), stats.VisitorAttachmentBytesRemaining)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestConfig(t *testing.T) *Config {
|
func newTestConfig(t *testing.T) *Config {
|
||||||
@@ -993,3 +1309,7 @@ func firebaseServiceAccountFile(t *testing.T) string {
|
|||||||
t.SkipNow()
|
t.SkipNow()
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func basicAuth(s string) string {
|
||||||
|
return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(s)))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,531 +0,0 @@
|
|||||||
/* general styling */
|
|
||||||
|
|
||||||
html, body {
|
|
||||||
font-family: 'Roboto', sans-serif;
|
|
||||||
font-weight: 400;
|
|
||||||
font-size: 1.1em;
|
|
||||||
color: #444;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
/* prevent scrollbar from repositioning website:
|
|
||||||
* https://www.w3docs.com/snippets/css/how-to-prevent-scrollbar-from-repositioning-web-page.html */
|
|
||||||
overflow-y: scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
a, a:visited {
|
|
||||||
color: #3a9784;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
text-decoration: none;
|
|
||||||
color: #317f6f;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin-top: 35px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
font-size: 2.5em;
|
|
||||||
word-wrap: break-word; /* For very long topics */
|
|
||||||
padding-right: 40px; /* For the X on the detail page */
|
|
||||||
font-weight: 300;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin-top: 30px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
font-size: 1.8em;
|
|
||||||
font-weight: 300;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin-top: 25px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
font-size: 1.3em;
|
|
||||||
font-weight: 300;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin-top: 10px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
line-height: 160%;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
p.smallMarginBottom {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
b {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
tt {
|
|
||||||
background: #eee;
|
|
||||||
padding: 2px 7px;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
display: block;
|
|
||||||
background: #eee;
|
|
||||||
font-family: monospace;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 3px;
|
|
||||||
margin-top: 10px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
overflow-x: auto;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Roboto font, embedded with the help of https://google-webfonts-helper.herokuapp.com/fonts/roboto?subsets=latin */
|
|
||||||
|
|
||||||
/* roboto-300 - latin */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 300;
|
|
||||||
src: local(''),
|
|
||||||
url('../font/roboto-v29-latin-300.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
|
|
||||||
url('../font/roboto-v29-latin-300.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* roboto-regular - latin */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
src: local(''),
|
|
||||||
url('../font/roboto-v29-latin-regular.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
|
|
||||||
url('../font/roboto-v29-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* roboto-500 - latin */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Roboto';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
src: local(''),
|
|
||||||
url('../font/roboto-v29-latin-500.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
|
|
||||||
url('../font/roboto-v29-latin-500.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Main page */
|
|
||||||
|
|
||||||
#main {
|
|
||||||
max-width: 900px;
|
|
||||||
margin: 0 auto 50px auto;
|
|
||||||
padding: 0 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#error {
|
|
||||||
color: darkred;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
#ironicCenterTagDontFreakOut {
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Anchors */
|
|
||||||
|
|
||||||
.anchor .anchorLink {
|
|
||||||
color: #ccc;
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 0 5px;
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.anchor:hover .anchorLink {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.anchor .anchorLink:hover {
|
|
||||||
color: #3a9784;
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Figures */
|
|
||||||
|
|
||||||
figure {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
figure img, figure video {
|
|
||||||
filter: drop-shadow(3px 3px 3px #ccc);
|
|
||||||
border-radius: 7px;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
figure video {
|
|
||||||
width: 100%;
|
|
||||||
max-height: 450px;
|
|
||||||
}
|
|
||||||
|
|
||||||
figcaption {
|
|
||||||
text-align: center;
|
|
||||||
font-style: italic;
|
|
||||||
padding-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Screenshots */
|
|
||||||
|
|
||||||
#screenshots {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#screenshots img {
|
|
||||||
height: 190px;
|
|
||||||
margin: 3px;
|
|
||||||
border-radius: 5px;
|
|
||||||
filter: drop-shadow(2px 2px 2px #ddd);
|
|
||||||
}
|
|
||||||
|
|
||||||
#screenshots .nowrap {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Lightbox; thanks to https://yossiabramov.com/blog/vanilla-js-lightbox */
|
|
||||||
|
|
||||||
.lightbox {
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
position: fixed;
|
|
||||||
left:0;
|
|
||||||
right: 0;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: -1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all 0.15s ease-in;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lightbox.show {
|
|
||||||
background-color: rgba(0,0,0, 0.75);
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lightbox img {
|
|
||||||
max-width: 90%;
|
|
||||||
max-height: 90%;
|
|
||||||
filter: drop-shadow(5px 5px 10px #222);
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lightbox .close-lightbox {
|
|
||||||
cursor: pointer;
|
|
||||||
position: absolute;
|
|
||||||
top: 30px;
|
|
||||||
right: 30px;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lightbox .close-lightbox::after,
|
|
||||||
.lightbox .close-lightbox::before {
|
|
||||||
content: '';
|
|
||||||
width: 3px;
|
|
||||||
height: 20px;
|
|
||||||
background-color: #ddd;
|
|
||||||
position: absolute;
|
|
||||||
border-radius: 5px;
|
|
||||||
transform: rotate(45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.lightbox .close-lightbox::before {
|
|
||||||
transform: rotate(-45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.lightbox .close-lightbox:hover::after,
|
|
||||||
.lightbox .close-lightbox:hover::before {
|
|
||||||
background-color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
|
|
||||||
#header {
|
|
||||||
background: #3a9784;
|
|
||||||
height: 130px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#header #headerBox {
|
|
||||||
max-width: 900px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#header #logo {
|
|
||||||
margin-top: 23px;
|
|
||||||
float: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
#header #name {
|
|
||||||
float: left;
|
|
||||||
color: white;
|
|
||||||
font-size: 2.6em;
|
|
||||||
font-weight: 300;
|
|
||||||
margin: 35px 0 0 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#header ol {
|
|
||||||
list-style-type: none;
|
|
||||||
float: right;
|
|
||||||
margin-top: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#header ol li {
|
|
||||||
display: inline-block;
|
|
||||||
margin: 0 10px;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
#header ol li a, nav ol li a:visited {
|
|
||||||
color: white;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#header ol li a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Subscribe box */
|
|
||||||
|
|
||||||
button {
|
|
||||||
background: #3a9784;
|
|
||||||
border: none;
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 3px 5px;
|
|
||||||
color: white;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:hover {
|
|
||||||
background: #317f6f;
|
|
||||||
padding: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
padding-left: 1em;
|
|
||||||
list-style-type: circle;
|
|
||||||
padding-bottom: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
padding: 4px 0;
|
|
||||||
margin: 4px 0;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Hide top menu SMALL SCREEN */
|
|
||||||
@media only screen and (max-width: 780px) {
|
|
||||||
#header ol {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Subscribe box SMALL SCREEN */
|
|
||||||
@media only screen and (max-width: 1599px) {
|
|
||||||
#subscribeBox #subscribeForm {
|
|
||||||
border-left: 4px solid #3a9784;
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#subscribeBox #topicsHeader {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#subscribeBox input {
|
|
||||||
height: 24px;
|
|
||||||
min-width: 200px;
|
|
||||||
max-width: 300px;
|
|
||||||
border-radius: 3px;
|
|
||||||
border: none;
|
|
||||||
border-bottom: 1px solid #aaa;
|
|
||||||
font-size: 0.8em;
|
|
||||||
}
|
|
||||||
|
|
||||||
#subscribeBox input:focus {
|
|
||||||
border-bottom: 2px solid #3a9784;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#subscribeBox ul {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#subscribeBox li {
|
|
||||||
margin: 3px 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#subscribeBox li img {
|
|
||||||
width: 15px;
|
|
||||||
height: 15px;
|
|
||||||
vertical-align: bottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
#subscribeBox li a {
|
|
||||||
padding: 0 5px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#subscribeBox button {
|
|
||||||
font-size: 0.8em;
|
|
||||||
background: #3a9784;
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 5px;
|
|
||||||
color: white;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
#subscribeBox button:hover {
|
|
||||||
background: #317f6f;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Subscribe box BIG SCREEN */
|
|
||||||
@media only screen and (min-width: 1600px) {
|
|
||||||
#subscribeBox {
|
|
||||||
position: fixed;
|
|
||||||
top: 170px;
|
|
||||||
right: 10px;
|
|
||||||
width: 300px;
|
|
||||||
border-left: 4px solid #3a9784;
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#subscribeBox h3 {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
font-size: 1.1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
#subscribeBox #topicsHeader {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#subscribeBox p {
|
|
||||||
font-size: 0.9em;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#subscribeBox ul {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#subscribeBox input {
|
|
||||||
height: 18px;
|
|
||||||
border-radius: 3px;
|
|
||||||
border: none;
|
|
||||||
border-bottom: 1px solid #aaa;
|
|
||||||
}
|
|
||||||
|
|
||||||
#subscribeBox input:focus {
|
|
||||||
border-bottom: 2px solid #3a9784;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#subscribeBox li {
|
|
||||||
margin: 3px 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#subscribeBox li img {
|
|
||||||
width: 15px;
|
|
||||||
height: 15px;
|
|
||||||
vertical-align: bottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
#subscribeBox li a {
|
|
||||||
padding: 0 5px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#subscribeBox button {
|
|
||||||
font-size: 0.7em;
|
|
||||||
background: #3a9784;
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 5px;
|
|
||||||
color: white;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
#subscribeBox button:hover {
|
|
||||||
background: #317f6f;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Detail view */
|
|
||||||
|
|
||||||
#detail .detailEntry {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#detail .detailDate {
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#detail .detailDate, #detail .detailTags {
|
|
||||||
color: #888;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
#detail .detailTags {
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#detail .detailDate img {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
vertical-align: bottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
#detail .detailTitle {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
#detail #detailMain {
|
|
||||||
max-width: 900px;
|
|
||||||
margin: 0 auto;
|
|
||||||
position: relative; /* required for close button's "position: absolute" */
|
|
||||||
padding: 0 10px 50px 10px; /* Chrome and Firefox behave differently regarding bottom margin */
|
|
||||||
}
|
|
||||||
|
|
||||||
#detail #detailCloseButton {
|
|
||||||
background: #eee;
|
|
||||||
border-radius: 5px;
|
|
||||||
border: none;
|
|
||||||
padding: 5px;
|
|
||||||
position: absolute;
|
|
||||||
right: 10px;
|
|
||||||
top: 10px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
#detail #detailCloseButton:hover {
|
|
||||||
padding: 5px;
|
|
||||||
background: #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
#detail #detailCloseButton img {
|
|
||||||
display: block; /* get rid of the weird bottom border */
|
|
||||||
}
|
|
||||||
|
|
||||||
#detail #detailNotificationsDisallowed {
|
|
||||||
display: none;
|
|
||||||
color: darkred;
|
|
||||||
}
|
|
||||||
|
|
||||||
#detail #events {
|
|
||||||
max-width: 900px;
|
|
||||||
margin: 0 auto 50px auto;
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 24 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 268 B |
@@ -1,47 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<svg
|
|
||||||
height="24px"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24px"
|
|
||||||
fill="#000000"
|
|
||||||
version="1.1"
|
|
||||||
id="svg1428"
|
|
||||||
sodipodi:docname="priority_1_24dp.svg"
|
|
||||||
inkscape:version="1.1.1 (3bf5ae0, 2021-09-20)"
|
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:svg="http://www.w3.org/2000/svg">
|
|
||||||
<defs
|
|
||||||
id="defs1432" />
|
|
||||||
<sodipodi:namedview
|
|
||||||
id="namedview1430"
|
|
||||||
pagecolor="#505050"
|
|
||||||
bordercolor="#eeeeee"
|
|
||||||
borderopacity="1"
|
|
||||||
inkscape:pageshadow="0"
|
|
||||||
inkscape:pageopacity="0"
|
|
||||||
inkscape:pagecheckerboard="0"
|
|
||||||
showgrid="false"
|
|
||||||
inkscape:zoom="20.517358"
|
|
||||||
inkscape:cx="22.834324"
|
|
||||||
inkscape:cy="15.742768"
|
|
||||||
inkscape:window-width="1863"
|
|
||||||
inkscape:window-height="1025"
|
|
||||||
inkscape:window-x="57"
|
|
||||||
inkscape:window-y="27"
|
|
||||||
inkscape:window-maximized="1"
|
|
||||||
inkscape:current-layer="svg1428" />
|
|
||||||
<path
|
|
||||||
style="color:#000000;fill:#999999;fill-opacity:1;stroke-width:0.0919748;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none"
|
|
||||||
d="m 12.195014,20.828316 a 1.2747098,1.2747098 0 0 0 0.661605,-0.185206 l 6.646593,-4.037178 a 1.2745823,1.2745823 0 0 0 0.427537,-1.751107 1.2745823,1.2745823 0 0 0 -1.750928,-0.427718 l -5.984807,3.635327 -5.9848086,-3.635327 a 1.2745823,1.2745823 0 0 0 -1.750927,0.427718 1.2745823,1.2745823 0 0 0 0.427536,1.751107 l 6.6464146,4.037178 a 1.2747098,1.2747098 0 0 0 0.661785,0.185206 z"
|
|
||||||
id="rect3554" />
|
|
||||||
<path
|
|
||||||
style="color:#000000;fill:#b3b3b3;fill-opacity:1;stroke:none;stroke-width:0.0919748;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none"
|
|
||||||
d="m 12.195014,15.694014 a 1.2747098,1.2747098 0 0 0 0.661605,-0.185206 l 6.646593,-4.037176 A 1.2745823,1.2745823 0 0 0 19.930749,9.7205243 1.2745823,1.2745823 0 0 0 18.179821,9.2928073 L 12.195014,12.928134 6.2102054,9.2928073 a 1.2745823,1.2745823 0 0 0 -1.750927,0.427717 1.2745823,1.2745823 0 0 0 0.427536,1.7511077 l 6.6464146,4.037176 a 1.2747098,1.2747098 0 0 0 0.661785,0.185206 z"
|
|
||||||
id="path9314" />
|
|
||||||
<path
|
|
||||||
style="color:#000000;fill:#cccccc;fill-opacity:1;stroke:none;stroke-width:0.0919748;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none"
|
|
||||||
d="m 12.116784,10.426777 a 1.2747098,1.2747098 0 0 0 0.661606,-0.185205 l 6.646593,-4.0371767 a 1.2745823,1.2745823 0 0 0 0.427537,-1.751108 1.2745823,1.2745823 0 0 0 -1.750928,-0.427718 l -5.984808,3.635327 -5.9848066,-3.635327 a 1.2745823,1.2745823 0 0 0 -1.750928,0.427718 1.2745823,1.2745823 0 0 0 0.427537,1.751108 L 11.455,10.241572 a 1.2747098,1.2747098 0 0 0 0.661784,0.185205 z"
|
|
||||||
id="path9316" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.7 KiB |
@@ -1,43 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<svg
|
|
||||||
height="24px"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24px"
|
|
||||||
fill="#000000"
|
|
||||||
version="1.1"
|
|
||||||
id="svg1428"
|
|
||||||
sodipodi:docname="priority_2_24dp.svg"
|
|
||||||
inkscape:version="1.1.1 (3bf5ae0, 2021-09-20)"
|
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:svg="http://www.w3.org/2000/svg">
|
|
||||||
<defs
|
|
||||||
id="defs1432" />
|
|
||||||
<sodipodi:namedview
|
|
||||||
id="namedview1430"
|
|
||||||
pagecolor="#505050"
|
|
||||||
bordercolor="#eeeeee"
|
|
||||||
borderopacity="1"
|
|
||||||
inkscape:pageshadow="0"
|
|
||||||
inkscape:pageopacity="0"
|
|
||||||
inkscape:pagecheckerboard="0"
|
|
||||||
showgrid="false"
|
|
||||||
inkscape:zoom="20.517358"
|
|
||||||
inkscape:cx="22.834324"
|
|
||||||
inkscape:cy="15.742768"
|
|
||||||
inkscape:window-width="1863"
|
|
||||||
inkscape:window-height="1025"
|
|
||||||
inkscape:window-x="57"
|
|
||||||
inkscape:window-y="27"
|
|
||||||
inkscape:window-maximized="1"
|
|
||||||
inkscape:current-layer="svg1428" />
|
|
||||||
<path
|
|
||||||
style="color:#000000;fill:#999999;fill-opacity:1;stroke-width:0.0919748;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none"
|
|
||||||
d="m 12.172712,17.774352 a 1.2747098,1.2747098 0 0 0 0.661605,-0.185206 l 6.646593,-4.037178 a 1.2745823,1.2745823 0 0 0 0.427537,-1.751107 1.2745823,1.2745823 0 0 0 -1.750928,-0.427718 L 12.172712,15.00847 6.1879033,11.373143 a 1.2745823,1.2745823 0 0 0 -1.750927,0.427718 1.2745823,1.2745823 0 0 0 0.427536,1.751107 l 6.6464147,4.037178 a 1.2747098,1.2747098 0 0 0 0.661785,0.185206 z"
|
|
||||||
id="rect3554" />
|
|
||||||
<path
|
|
||||||
style="color:#000000;fill:#b3b3b3;fill-opacity:1;stroke:none;stroke-width:0.0919748;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none"
|
|
||||||
d="m 12.172712,12.64005 a 1.2747098,1.2747098 0 0 0 0.661605,-0.185206 L 19.48091,8.4176679 A 1.2745823,1.2745823 0 0 0 19.908447,6.6665602 1.2745823,1.2745823 0 0 0 18.157519,6.2388432 L 12.172712,9.8741699 6.1879033,6.2388432 a 1.2745823,1.2745823 0 0 0 -1.750927,0.427717 1.2745823,1.2745823 0 0 0 0.427536,1.7511077 l 6.6464147,4.0371761 a 1.2747098,1.2747098 0 0 0 0.661785,0.185206 z"
|
|
||||||
id="path9314" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,43 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<svg
|
|
||||||
height="24px"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24px"
|
|
||||||
fill="#000000"
|
|
||||||
version="1.1"
|
|
||||||
id="svg1428"
|
|
||||||
sodipodi:docname="priority_4_24dp.svg"
|
|
||||||
inkscape:version="1.1.1 (3bf5ae0, 2021-09-20)"
|
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:svg="http://www.w3.org/2000/svg">
|
|
||||||
<defs
|
|
||||||
id="defs1432" />
|
|
||||||
<sodipodi:namedview
|
|
||||||
id="namedview1430"
|
|
||||||
pagecolor="#505050"
|
|
||||||
bordercolor="#eeeeee"
|
|
||||||
borderopacity="1"
|
|
||||||
inkscape:pageshadow="0"
|
|
||||||
inkscape:pageopacity="0"
|
|
||||||
inkscape:pagecheckerboard="0"
|
|
||||||
showgrid="false"
|
|
||||||
inkscape:zoom="20.517358"
|
|
||||||
inkscape:cx="22.834324"
|
|
||||||
inkscape:cy="15.742768"
|
|
||||||
inkscape:window-width="1863"
|
|
||||||
inkscape:window-height="1025"
|
|
||||||
inkscape:window-x="57"
|
|
||||||
inkscape:window-y="27"
|
|
||||||
inkscape:window-maximized="1"
|
|
||||||
inkscape:current-layer="svg1428" />
|
|
||||||
<path
|
|
||||||
style="color:#000000;fill:#c60000;fill-opacity:1;stroke:none;stroke-width:0.0919748;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none"
|
|
||||||
d="M 12.116784,6.5394415 A 1.2747098,1.2747098 0 0 0 11.455179,6.724648 l -6.6465926,4.037176 a 1.2745823,1.2745823 0 0 0 -0.427537,1.751108 1.2745823,1.2745823 0 0 0 1.7509281,0.427717 l 5.9848065,-3.635327 5.984809,3.635327 A 1.2745823,1.2745823 0 0 0 19.85252,12.512932 1.2745823,1.2745823 0 0 0 19.424984,10.761824 L 12.778569,6.724648 A 1.2747098,1.2747098 0 0 0 12.116784,6.5394415 Z"
|
|
||||||
id="path9314" />
|
|
||||||
<path
|
|
||||||
style="color:#000000;fill:#de0000;fill-opacity:1;stroke:none;stroke-width:0.0919748;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none"
|
|
||||||
d="m 12.195014,11.806679 a 1.2747098,1.2747098 0 0 0 -0.661606,0.185205 l -6.6465924,4.037177 a 1.2745823,1.2745823 0 0 0 -0.427537,1.751108 1.2745823,1.2745823 0 0 0 1.750928,0.427718 l 5.9848074,-3.635327 5.984807,3.635327 a 1.2745823,1.2745823 0 0 0 1.750928,-0.427718 1.2745823,1.2745823 0 0 0 -0.427537,-1.751108 l -6.646414,-4.037177 a 1.2747098,1.2747098 0 0 0 -0.661784,-0.185205 z"
|
|
||||||
id="path9316" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,47 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<svg
|
|
||||||
height="24px"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24px"
|
|
||||||
fill="#000000"
|
|
||||||
version="1.1"
|
|
||||||
id="svg1428"
|
|
||||||
sodipodi:docname="priority_5_24dp.svg"
|
|
||||||
inkscape:version="1.1.1 (3bf5ae0, 2021-09-20)"
|
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:svg="http://www.w3.org/2000/svg">
|
|
||||||
<defs
|
|
||||||
id="defs1432" />
|
|
||||||
<sodipodi:namedview
|
|
||||||
id="namedview1430"
|
|
||||||
pagecolor="#505050"
|
|
||||||
bordercolor="#eeeeee"
|
|
||||||
borderopacity="1"
|
|
||||||
inkscape:pageshadow="0"
|
|
||||||
inkscape:pageopacity="0"
|
|
||||||
inkscape:pagecheckerboard="0"
|
|
||||||
showgrid="false"
|
|
||||||
inkscape:zoom="20.517358"
|
|
||||||
inkscape:cx="22.834323"
|
|
||||||
inkscape:cy="15.742767"
|
|
||||||
inkscape:window-width="1863"
|
|
||||||
inkscape:window-height="1025"
|
|
||||||
inkscape:window-x="57"
|
|
||||||
inkscape:window-y="27"
|
|
||||||
inkscape:window-maximized="1"
|
|
||||||
inkscape:current-layer="svg1428" />
|
|
||||||
<path
|
|
||||||
style="color:#000000;fill:#aa0000;fill-opacity:1;stroke-width:0.0919748;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none"
|
|
||||||
d="M 12.116784,3.40514 A 1.2747098,1.2747098 0 0 0 11.455179,3.5903463 L 4.8085864,7.6275238 A 1.2745823,1.2745823 0 0 0 4.3810494,9.3786313 1.2745823,1.2745823 0 0 0 6.1319775,9.8063489 L 12.116784,6.1710217 18.101593,9.8063489 A 1.2745823,1.2745823 0 0 0 19.85252,9.3786313 1.2745823,1.2745823 0 0 0 19.424984,7.6275238 L 12.778569,3.5903463 A 1.2747098,1.2747098 0 0 0 12.116784,3.40514 Z"
|
|
||||||
id="rect3554" />
|
|
||||||
<path
|
|
||||||
style="color:#000000;fill:#c60000;fill-opacity:1;stroke:none;stroke-width:0.0919748;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none"
|
|
||||||
d="M 12.116784,8.5394415 A 1.2747098,1.2747098 0 0 0 11.455179,8.724648 l -6.6465926,4.037176 a 1.2745823,1.2745823 0 0 0 -0.427537,1.751108 1.2745823,1.2745823 0 0 0 1.7509281,0.427717 l 5.9848065,-3.635327 5.984809,3.635327 A 1.2745823,1.2745823 0 0 0 19.85252,14.512932 1.2745823,1.2745823 0 0 0 19.424984,12.761824 L 12.778569,8.724648 A 1.2747098,1.2747098 0 0 0 12.116784,8.5394415 Z"
|
|
||||||
id="path9314" />
|
|
||||||
<path
|
|
||||||
style="color:#000000;fill:#de0000;fill-opacity:1;stroke:none;stroke-width:0.0919748;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none"
|
|
||||||
d="m 12.195014,13.806679 a 1.2747098,1.2747098 0 0 0 -0.661606,0.185205 l -6.6465924,4.037177 a 1.2745823,1.2745823 0 0 0 -0.427537,1.751108 1.2745823,1.2745823 0 0 0 1.750928,0.427718 l 5.9848074,-3.635327 5.984807,3.635327 a 1.2745823,1.2745823 0 0 0 1.750928,-0.427718 1.2745823,1.2745823 0 0 0 -0.427537,-1.751108 l -6.646414,-4.037177 a 1.2747098,1.2747098 0 0 0 -0.661784,-0.185205 z"
|
|
||||||
id="path9316" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 116 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#ffffff"><path d="M0 0h24v24H0z" fill="none"/><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 195 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#FFFFFF"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 269 B |
@@ -1,435 +0,0 @@
|
|||||||
|
|
||||||
/**
|
|
||||||
* Hello, dear curious visitor. I am not a web-guy, so please don't judge my horrible JS code.
|
|
||||||
* In fact, please do tell me about all the things I did wrong and that I could improve. I've been trying
|
|
||||||
* to read up on modern JS, but it's just a little much.
|
|
||||||
*
|
|
||||||
* Feel free to open tickets at https://github.com/binwiederhier/ntfy/issues. Thank you!
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* All the things */
|
|
||||||
|
|
||||||
let topics = {};
|
|
||||||
let currentTopic = "";
|
|
||||||
let currentTopicUnsubscribeOnClose = false;
|
|
||||||
let currentUrl = window.location.hostname;
|
|
||||||
if (window.location.port) {
|
|
||||||
currentUrl += ':' + window.location.port
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Main view */
|
|
||||||
const main = document.getElementById("main");
|
|
||||||
const topicsHeader = document.getElementById("topicsHeader");
|
|
||||||
const topicsList = document.getElementById("topicsList");
|
|
||||||
const topicField = document.getElementById("topicField");
|
|
||||||
const notifySound = document.getElementById("notifySound");
|
|
||||||
const subscribeButton = document.getElementById("subscribeButton");
|
|
||||||
const errorField = document.getElementById("error");
|
|
||||||
const originalTitle = document.title;
|
|
||||||
|
|
||||||
/* Detail view */
|
|
||||||
const detailView = document.getElementById("detail");
|
|
||||||
const detailTitle = document.getElementById("detailTitle");
|
|
||||||
const detailEventsList = document.getElementById("detailEventsList");
|
|
||||||
const detailTopicUrl = document.getElementById("detailTopicUrl");
|
|
||||||
const detailNoNotifications = document.getElementById("detailNoNotifications");
|
|
||||||
const detailCloseButton = document.getElementById("detailCloseButton");
|
|
||||||
const detailNotificationsDisallowed = document.getElementById("detailNotificationsDisallowed");
|
|
||||||
|
|
||||||
/* Screenshots */
|
|
||||||
const lightbox = document.getElementById("lightbox");
|
|
||||||
|
|
||||||
const subscribe = (topic) => {
|
|
||||||
if (Notification.permission !== "granted") {
|
|
||||||
Notification.requestPermission().then((permission) => {
|
|
||||||
if (permission === "granted") {
|
|
||||||
subscribeInternal(topic, true, 0);
|
|
||||||
} else {
|
|
||||||
showNotificationDeniedError();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
subscribeInternal(topic, true,0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const subscribeInternal = (topic, persist, delaySec) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
// Render list entry
|
|
||||||
let topicEntry = document.getElementById(`topic-${topic}`);
|
|
||||||
if (!topicEntry) {
|
|
||||||
topicEntry = document.createElement('li');
|
|
||||||
topicEntry.id = `topic-${topic}`;
|
|
||||||
topicEntry.innerHTML = `<a href="/${topic}" onclick="return showDetail('${topic}')">${topic}</a> <button onclick="test('${topic}'); return false;"> <img src="static/img/send.svg"> Test</button> <button onclick="unsubscribe('${topic}'); return false;"> <img src="static/img/unsubscribe.svg"> Unsubscribe</button>`;
|
|
||||||
topicsList.appendChild(topicEntry);
|
|
||||||
}
|
|
||||||
topicsHeader.style.display = '';
|
|
||||||
|
|
||||||
// Open event source
|
|
||||||
let eventSource = new EventSource(`${topic}/sse`);
|
|
||||||
eventSource.onopen = () => {
|
|
||||||
topicEntry.innerHTML = `<a href="/${topic}" onclick="return showDetail('${topic}')">${topic}</a> <button onclick="test('${topic}'); return false;"> <img src="static/img/send.svg"> Test</button> <button onclick="unsubscribe('${topic}'); return false;"> <img src="static/img/unsubscribe.svg"> Unsubscribe</button>`;
|
|
||||||
delaySec = 0; // Reset on successful connection
|
|
||||||
};
|
|
||||||
eventSource.onerror = (e) => {
|
|
||||||
topicEntry.innerHTML = `<a href="/${topic}" onclick="return showDetail('${topic}')">${topic}</a> <i>(Reconnecting)</i> <button disabled="disabled">Test</button> <button onclick="unsubscribe('${topic}'); return false;">Unsubscribe</button>`;
|
|
||||||
eventSource.close();
|
|
||||||
const newDelaySec = (delaySec + 5 <= 15) ? delaySec + 5 : 15;
|
|
||||||
subscribeInternal(topic, persist, newDelaySec);
|
|
||||||
};
|
|
||||||
eventSource.onmessage = (e) => {
|
|
||||||
const event = JSON.parse(e.data);
|
|
||||||
topics[topic]['messages'].push(event);
|
|
||||||
topics[topic]['messages'].sort((a, b) => { return a.time < b.time ? 1 : -1; }); // Newest first
|
|
||||||
if (currentTopic === topic) {
|
|
||||||
rerenderDetailView();
|
|
||||||
}
|
|
||||||
if (Notification.permission === "granted") {
|
|
||||||
notifySound.play();
|
|
||||||
const title = formatTitle(event);
|
|
||||||
const message = formatMessage(event);
|
|
||||||
const notification = new Notification(title, {
|
|
||||||
body: message,
|
|
||||||
icon: '/static/img/favicon.png'
|
|
||||||
});
|
|
||||||
notification.onclick = (e) => {
|
|
||||||
showDetail(event.topic);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
topics[topic] = {
|
|
||||||
'eventSource': eventSource,
|
|
||||||
'messages': [],
|
|
||||||
'persist': persist
|
|
||||||
};
|
|
||||||
fetchCachedMessages(topic).then(() => {
|
|
||||||
if (currentTopic === topic) {
|
|
||||||
rerenderDetailView();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
let persistedTopicKeys = Object.keys(topics).filter(t => topics[t].persist);
|
|
||||||
localStorage.setItem('topics', JSON.stringify(persistedTopicKeys));
|
|
||||||
}, delaySec * 1000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const unsubscribe = (topic) => {
|
|
||||||
topics[topic]['eventSource'].close();
|
|
||||||
delete topics[topic];
|
|
||||||
localStorage.setItem('topics', JSON.stringify(Object.keys(topics)));
|
|
||||||
document.getElementById(`topic-${topic}`).remove();
|
|
||||||
if (Object.keys(topics).length === 0) {
|
|
||||||
topicsHeader.style.display = 'none';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const test = (topic) => {
|
|
||||||
fetch(`/${topic}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: `This is a test notification sent by the ntfy.sh Web UI at ${new Date().toString()}.`
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchCachedMessages = async (topic) => {
|
|
||||||
const topicJsonUrl = `/${topic}/json?poll=1`; // Poll!
|
|
||||||
for await (let line of makeTextFileLineIterator(topicJsonUrl)) {
|
|
||||||
const message = JSON.parse(line);
|
|
||||||
topics[topic]['messages'].push(message);
|
|
||||||
}
|
|
||||||
topics[topic]['messages'].sort((a, b) => { return a.time < b.time ? 1 : -1; }); // Newest first
|
|
||||||
};
|
|
||||||
|
|
||||||
const showDetail = (topic) => {
|
|
||||||
currentTopic = topic;
|
|
||||||
history.replaceState(topic, `${currentUrl}/${topic}`, `/${topic}`);
|
|
||||||
window.scrollTo(0, 0);
|
|
||||||
rerenderDetailView();
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const rerenderDetailView = () => {
|
|
||||||
detailTitle.innerHTML = `${currentUrl}/${currentTopic}`; // document.location.replaceAll(..)
|
|
||||||
detailTopicUrl.innerHTML = `${currentUrl}/${currentTopic}`;
|
|
||||||
while (detailEventsList.firstChild) {
|
|
||||||
detailEventsList.removeChild(detailEventsList.firstChild);
|
|
||||||
}
|
|
||||||
topics[currentTopic]['messages'].forEach(m => {
|
|
||||||
const entryDiv = document.createElement('div');
|
|
||||||
const dateDiv = document.createElement('div');
|
|
||||||
const titleDiv = document.createElement('div');
|
|
||||||
const messageDiv = document.createElement('div');
|
|
||||||
const tagsDiv = document.createElement('div');
|
|
||||||
|
|
||||||
entryDiv.classList.add('detailEntry');
|
|
||||||
dateDiv.classList.add('detailDate');
|
|
||||||
titleDiv.classList.add('detailTitle');
|
|
||||||
messageDiv.classList.add('detailMessage');
|
|
||||||
tagsDiv.classList.add('detailTags');
|
|
||||||
|
|
||||||
const dateStr = new Date(m.time * 1000).toLocaleString();
|
|
||||||
if (m.priority && [1,2,4,5].includes(m.priority)) {
|
|
||||||
dateDiv.innerHTML = `${dateStr} <img src="static/img/priority-${m.priority}.svg"/>`;
|
|
||||||
} else {
|
|
||||||
dateDiv.innerHTML = `${dateStr}`;
|
|
||||||
}
|
|
||||||
messageDiv.innerText = formatMessage(m);
|
|
||||||
entryDiv.appendChild(dateDiv);
|
|
||||||
if (m.title) {
|
|
||||||
titleDiv.innerText = formatTitleA(m);
|
|
||||||
entryDiv.appendChild(titleDiv);
|
|
||||||
}
|
|
||||||
entryDiv.appendChild(messageDiv);
|
|
||||||
const otherTags = unmatchedTags(m.tags);
|
|
||||||
if (otherTags.length > 0) {
|
|
||||||
tagsDiv.innerText = `Tags: ${otherTags.join(", ")}`;
|
|
||||||
entryDiv.appendChild(tagsDiv);
|
|
||||||
}
|
|
||||||
detailEventsList.appendChild(entryDiv);
|
|
||||||
})
|
|
||||||
if (topics[currentTopic]['messages'].length === 0) {
|
|
||||||
detailNoNotifications.style.display = '';
|
|
||||||
} else {
|
|
||||||
detailNoNotifications.style.display = 'none';
|
|
||||||
}
|
|
||||||
if (Notification.permission === "granted") {
|
|
||||||
detailNotificationsDisallowed.style.display = 'none';
|
|
||||||
} else {
|
|
||||||
detailNotificationsDisallowed.style.display = 'block';
|
|
||||||
}
|
|
||||||
detailView.style.display = 'block';
|
|
||||||
main.style.display = 'none';
|
|
||||||
};
|
|
||||||
|
|
||||||
const hideDetailView = () => {
|
|
||||||
if (currentTopicUnsubscribeOnClose) {
|
|
||||||
unsubscribe(currentTopic);
|
|
||||||
currentTopicUnsubscribeOnClose = false;
|
|
||||||
}
|
|
||||||
currentTopic = "";
|
|
||||||
history.replaceState('', originalTitle, '/');
|
|
||||||
detailView.style.display = 'none';
|
|
||||||
main.style.display = 'block';
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const requestPermission = () => {
|
|
||||||
if (Notification.permission !== "granted") {
|
|
||||||
Notification.requestPermission().then((permission) => {
|
|
||||||
if (permission === "granted") {
|
|
||||||
detailNotificationsDisallowed.style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const showError = (msg) => {
|
|
||||||
errorField.innerHTML = msg;
|
|
||||||
topicField.disabled = true;
|
|
||||||
subscribeButton.disabled = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const showBrowserIncompatibleError = () => {
|
|
||||||
showError("Your browser is not compatible to use the web-based desktop notifications.");
|
|
||||||
};
|
|
||||||
|
|
||||||
const showNotificationDeniedError = () => {
|
|
||||||
showError("You have blocked desktop notifications for this website. Please unblock them and refresh to use the web-based desktop notifications.");
|
|
||||||
};
|
|
||||||
|
|
||||||
const showScreenshotOverlay = (e, el, index) => {
|
|
||||||
lightbox.classList.add('show');
|
|
||||||
document.addEventListener('keydown', nextScreenshotKeyboardListener);
|
|
||||||
return showScreenshot(e, index);
|
|
||||||
};
|
|
||||||
|
|
||||||
const showScreenshot = (e, index) => {
|
|
||||||
const actualIndex = resolveScreenshotIndex(index);
|
|
||||||
lightbox.innerHTML = '<div class="close-lightbox"></div>' + screenshots[actualIndex].innerHTML;
|
|
||||||
lightbox.querySelector('img').onclick = (e) => { return showScreenshot(e,actualIndex+1); };
|
|
||||||
currentScreenshotIndex = actualIndex;
|
|
||||||
e.stopPropagation();
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextScreenshot = (e) => {
|
|
||||||
return showScreenshot(e, currentScreenshotIndex+1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const previousScreenshot = (e) => {
|
|
||||||
return showScreenshot(e, currentScreenshotIndex-1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveScreenshotIndex = (index) => {
|
|
||||||
if (index < 0) {
|
|
||||||
return screenshots.length - 1;
|
|
||||||
} else if (index > screenshots.length - 1) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return index;
|
|
||||||
};
|
|
||||||
|
|
||||||
const hideScreenshotOverlay = (e) => {
|
|
||||||
lightbox.classList.remove('show');
|
|
||||||
document.removeEventListener('keydown', nextScreenshotKeyboardListener);
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextScreenshotKeyboardListener = (e) => {
|
|
||||||
switch (e.keyCode) {
|
|
||||||
case 37:
|
|
||||||
previousScreenshot(e);
|
|
||||||
break;
|
|
||||||
case 39:
|
|
||||||
nextScreenshot(e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTitle = (m) => {
|
|
||||||
if (m.title) {
|
|
||||||
return formatTitleA(m);
|
|
||||||
} else {
|
|
||||||
return `${location.host}/${m.topic}`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTitleA = (m) => {
|
|
||||||
const emojiList = toEmojis(m.tags);
|
|
||||||
if (emojiList.length > 0) {
|
|
||||||
return `${emojiList.join(" ")} ${m.title}`;
|
|
||||||
} else {
|
|
||||||
return m.title;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatMessage = (m) => {
|
|
||||||
if (m.title) {
|
|
||||||
return m.message;
|
|
||||||
} else {
|
|
||||||
const emojiList = toEmojis(m.tags);
|
|
||||||
if (emojiList.length > 0) {
|
|
||||||
return `${emojiList.join(" ")} ${m.message}`;
|
|
||||||
} else {
|
|
||||||
return m.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toEmojis = (tags) => {
|
|
||||||
if (!tags) return [];
|
|
||||||
else return tags.filter(tag => tag in emojis).map(tag => emojis[tag]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const unmatchedTags = (tags) => {
|
|
||||||
if (!tags) return [];
|
|
||||||
else return tags.filter(tag => !(tag in emojis));
|
|
||||||
}
|
|
||||||
|
|
||||||
// From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
|
|
||||||
async function* makeTextFileLineIterator(fileURL) {
|
|
||||||
const utf8Decoder = new TextDecoder('utf-8');
|
|
||||||
const response = await fetch(fileURL);
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
let { value: chunk, done: readerDone } = await reader.read();
|
|
||||||
chunk = chunk ? utf8Decoder.decode(chunk) : '';
|
|
||||||
|
|
||||||
const re = /\n|\r|\r\n/gm;
|
|
||||||
let startIndex = 0;
|
|
||||||
let result;
|
|
||||||
|
|
||||||
for (;;) {
|
|
||||||
let result = re.exec(chunk);
|
|
||||||
if (!result) {
|
|
||||||
if (readerDone) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let remainder = chunk.substr(startIndex);
|
|
||||||
({ value: chunk, done: readerDone } = await reader.read());
|
|
||||||
chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : '');
|
|
||||||
startIndex = re.lastIndex = 0;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
yield chunk.substring(startIndex, result.index);
|
|
||||||
startIndex = re.lastIndex;
|
|
||||||
}
|
|
||||||
if (startIndex < chunk.length) {
|
|
||||||
yield chunk.substr(startIndex); // last line didn't end in a newline char
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribeButton.onclick = () => {
|
|
||||||
if (!topicField.value) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
subscribe(topicField.value);
|
|
||||||
topicField.value = "";
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
detailCloseButton.onclick = () => {
|
|
||||||
hideDetailView();
|
|
||||||
};
|
|
||||||
|
|
||||||
let currentScreenshotIndex = 0;
|
|
||||||
const screenshots = [...document.querySelectorAll("#screenshots a")];
|
|
||||||
screenshots.forEach((el, index) => {
|
|
||||||
el.onclick = (e) => { return showScreenshotOverlay(e, el, index); };
|
|
||||||
});
|
|
||||||
|
|
||||||
lightbox.onclick = hideScreenshotOverlay;
|
|
||||||
|
|
||||||
// Disable Web UI if notifications of EventSource are not available
|
|
||||||
if (!window["Notification"] || !window["EventSource"]) {
|
|
||||||
showBrowserIncompatibleError();
|
|
||||||
} else if (Notification.permission === "denied") {
|
|
||||||
showNotificationDeniedError();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset UI
|
|
||||||
topicField.value = "";
|
|
||||||
|
|
||||||
// Restore topics
|
|
||||||
const storedTopics = JSON.parse(localStorage.getItem('topics') || "[]");
|
|
||||||
if (storedTopics) {
|
|
||||||
storedTopics.forEach((topic) => { subscribeInternal(topic, true, 0); });
|
|
||||||
if (storedTopics.length === 0) {
|
|
||||||
topicsHeader.style.display = 'none';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
topicsHeader.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// (Temporarily) subscribe topic if we navigated to /sometopic URL
|
|
||||||
const match = location.pathname.match(/^\/([-_a-zA-Z0-9]{1,64})$/) // Regex must match Go & Android app!
|
|
||||||
if (match) {
|
|
||||||
currentTopic = match[1];
|
|
||||||
if (!storedTopics.includes(currentTopic)) {
|
|
||||||
subscribeInternal(currentTopic, false,0);
|
|
||||||
currentTopicUnsubscribeOnClose = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add anchor links
|
|
||||||
document.querySelectorAll('.anchor').forEach((el) => {
|
|
||||||
if (el.hasAttribute('id')) {
|
|
||||||
const id = el.getAttribute('id');
|
|
||||||
const anchor = document.createElement('a');
|
|
||||||
anchor.innerHTML = `<a href="#${id}" class="anchorLink">#</a>`;
|
|
||||||
el.appendChild(anchor);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Change ntfy.sh url and protocol to match self-hosted one
|
|
||||||
document.querySelectorAll('.ntfyUrl').forEach((el) => {
|
|
||||||
el.innerHTML = currentUrl;
|
|
||||||
});
|
|
||||||
document.querySelectorAll('.ntfyProtocol').forEach((el) => {
|
|
||||||
el.innerHTML = window.location.protocol + "//";
|
|
||||||
});
|
|
||||||
|
|
||||||
// Format emojis (see emoji.js)
|
|
||||||
const emojis = {};
|
|
||||||
rawEmojis.forEach(emoji => {
|
|
||||||
emoji.aliases.forEach(alias => {
|
|
||||||
emojis[alias] = emoji.emoji;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
203
server/types.go
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// List of possible events
|
||||||
|
const (
|
||||||
|
openEvent = "open"
|
||||||
|
keepaliveEvent = "keepalive"
|
||||||
|
messageEvent = "message"
|
||||||
|
pollRequestEvent = "poll_request"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
messageIDLength = 12
|
||||||
|
)
|
||||||
|
|
||||||
|
// message represents a message published to a topic
|
||||||
|
type message struct {
|
||||||
|
ID string `json:"id"` // Random message ID
|
||||||
|
Time int64 `json:"time"` // Unix time in seconds
|
||||||
|
Event string `json:"event"` // One of the above
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
Priority int `json:"priority,omitempty"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
Click string `json:"click,omitempty"`
|
||||||
|
Actions []*action `json:"actions,omitempty"`
|
||||||
|
Attachment *attachment `json:"attachment,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
type attachment struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Size int64 `json:"size,omitempty"`
|
||||||
|
Expires int64 `json:"expires,omitempty"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Owner string `json:"-"` // IP address of uploader, used for rate limiting
|
||||||
|
}
|
||||||
|
|
||||||
|
type action struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Action string `json:"action"` // "view", "broadcast", or "http"
|
||||||
|
Label string `json:"label"` // action button label
|
||||||
|
Clear bool `json:"clear"` // clear notification after successful execution
|
||||||
|
URL string `json:"url,omitempty"` // used in "view" and "http" actions
|
||||||
|
Method string `json:"method,omitempty"` // used in "http" action, default is POST (!)
|
||||||
|
Headers map[string]string `json:"headers,omitempty"` // used in "http" action
|
||||||
|
Body string `json:"body,omitempty"` // used in "http" action
|
||||||
|
Intent string `json:"intent,omitempty"` // used in "broadcast" action
|
||||||
|
Extras map[string]string `json:"extras,omitempty"` // used in "broadcast" action
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAction() *action {
|
||||||
|
return &action{
|
||||||
|
Headers: make(map[string]string),
|
||||||
|
Extras: make(map[string]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishMessage is used as input when publishing as JSON
|
||||||
|
type publishMessage struct {
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Click string `json:"click"`
|
||||||
|
Actions []action `json:"actions"`
|
||||||
|
Attach string `json:"attach"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Delay string `json:"delay"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// messageEncoder is a function that knows how to encode a message
|
||||||
|
type messageEncoder func(msg *message) (string, error)
|
||||||
|
|
||||||
|
// newMessage creates a new message with the current timestamp
|
||||||
|
func newMessage(event, topic, msg string) *message {
|
||||||
|
return &message{
|
||||||
|
ID: util.RandomString(messageIDLength),
|
||||||
|
Time: time.Now().Unix(),
|
||||||
|
Event: event,
|
||||||
|
Topic: topic,
|
||||||
|
Priority: 0,
|
||||||
|
Tags: nil,
|
||||||
|
Title: "",
|
||||||
|
Message: msg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newOpenMessage is a convenience method to create an open message
|
||||||
|
func newOpenMessage(topic string) *message {
|
||||||
|
return newMessage(openEvent, topic, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// newKeepaliveMessage is a convenience method to create a keepalive message
|
||||||
|
func newKeepaliveMessage(topic string) *message {
|
||||||
|
return newMessage(keepaliveEvent, topic, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// newDefaultMessage is a convenience method to create a notification message
|
||||||
|
func newDefaultMessage(topic, msg string) *message {
|
||||||
|
return newMessage(messageEvent, topic, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validMessageID(s string) bool {
|
||||||
|
return util.ValidRandomString(s, messageIDLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
type sinceMarker struct {
|
||||||
|
time time.Time
|
||||||
|
id string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSinceTime(timestamp int64) sinceMarker {
|
||||||
|
return sinceMarker{time.Unix(timestamp, 0), ""}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSinceID(id string) sinceMarker {
|
||||||
|
return sinceMarker{time.Unix(0, 0), id}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t sinceMarker) IsAll() bool {
|
||||||
|
return t == sinceAllMessages
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t sinceMarker) IsNone() bool {
|
||||||
|
return t == sinceNoMessages
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t sinceMarker) IsID() bool {
|
||||||
|
return t.id != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t sinceMarker) Time() time.Time {
|
||||||
|
return t.time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t sinceMarker) ID() string {
|
||||||
|
return t.id
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
sinceAllMessages = sinceMarker{time.Unix(0, 0), ""}
|
||||||
|
sinceNoMessages = sinceMarker{time.Unix(1, 0), ""}
|
||||||
|
)
|
||||||
|
|
||||||
|
type queryFilter struct {
|
||||||
|
Message string
|
||||||
|
Title string
|
||||||
|
Tags []string
|
||||||
|
Priority []int
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseQueryFilters(r *http.Request) (*queryFilter, error) {
|
||||||
|
messageFilter := readParam(r, "x-message", "message", "m")
|
||||||
|
titleFilter := readParam(r, "x-title", "title", "t")
|
||||||
|
tagsFilter := util.SplitNoEmpty(readParam(r, "x-tags", "tags", "tag", "ta"), ",")
|
||||||
|
priorityFilter := make([]int, 0)
|
||||||
|
for _, p := range util.SplitNoEmpty(readParam(r, "x-priority", "priority", "prio", "p"), ",") {
|
||||||
|
priority, err := util.ParsePriority(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
priorityFilter = append(priorityFilter, priority)
|
||||||
|
}
|
||||||
|
return &queryFilter{
|
||||||
|
Message: messageFilter,
|
||||||
|
Title: titleFilter,
|
||||||
|
Tags: tagsFilter,
|
||||||
|
Priority: priorityFilter,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *queryFilter) Pass(msg *message) bool {
|
||||||
|
if msg.Event != messageEvent {
|
||||||
|
return true // filters only apply to messages
|
||||||
|
}
|
||||||
|
if q.Message != "" && msg.Message != q.Message {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if q.Title != "" && msg.Title != q.Title {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
messagePriority := msg.Priority
|
||||||
|
if messagePriority == 0 {
|
||||||
|
messagePriority = 3 // For query filters, default priority (3) is the same as "not set" (0)
|
||||||
|
}
|
||||||
|
if len(q.Priority) > 0 && !util.InIntList(q.Priority, messagePriority) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(q.Tags) > 0 && !util.InStringListAll(msg.Tags, q.Tags) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
42
server/util.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
|
||||||
|
value := strings.ToLower(readParam(r, names...))
|
||||||
|
if value == "" {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
return value == "1" || value == "yes" || value == "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
func readParam(r *http.Request, names ...string) string {
|
||||||
|
value := readHeaderParam(r, names...)
|
||||||
|
if value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return readQueryParam(r, names...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readHeaderParam(r *http.Request, names ...string) string {
|
||||||
|
for _, name := range names {
|
||||||
|
value := r.Header.Get(name)
|
||||||
|
if value != "" {
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func readQueryParam(r *http.Request, names ...string) string {
|
||||||
|
for _, name := range names {
|
||||||
|
value := r.URL.Query().Get(strings.ToLower(name))
|
||||||
|
if value != "" {
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
29
server/util_test.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReadBoolParam(t *testing.T) {
|
||||||
|
r, _ := http.NewRequest("GET", "https://ntfy.sh/mytopic?up=1&firebase=no", nil)
|
||||||
|
up := readBoolParam(r, false, "x-up", "up")
|
||||||
|
firebase := readBoolParam(r, true, "x-firebase", "firebase")
|
||||||
|
require.Equal(t, true, up)
|
||||||
|
require.Equal(t, false, firebase)
|
||||||
|
|
||||||
|
r, _ = http.NewRequest("GET", "https://ntfy.sh/mytopic", nil)
|
||||||
|
r.Header.Set("X-Up", "yes")
|
||||||
|
r.Header.Set("X-Firebase", "0")
|
||||||
|
up = readBoolParam(r, false, "x-up", "up")
|
||||||
|
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
||||||
|
require.Equal(t, true, up)
|
||||||
|
require.Equal(t, false, firebase)
|
||||||
|
|
||||||
|
r, _ = http.NewRequest("GET", "https://ntfy.sh/mytopic", nil)
|
||||||
|
up = readBoolParam(r, false, "x-up", "up")
|
||||||
|
firebase = readBoolParam(r, true, "x-up", "up")
|
||||||
|
require.Equal(t, false, up)
|
||||||
|
require.Equal(t, true, firebase)
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ var (
|
|||||||
// visitor represents an API user, and its associated rate.Limiter used for rate limiting
|
// visitor represents an API user, and its associated rate.Limiter used for rate limiting
|
||||||
type visitor struct {
|
type visitor struct {
|
||||||
config *Config
|
config *Config
|
||||||
|
messageCache *messageCache
|
||||||
ip string
|
ip string
|
||||||
requests *rate.Limiter
|
requests *rate.Limiter
|
||||||
emails *rate.Limiter
|
emails *rate.Limiter
|
||||||
@@ -31,9 +32,17 @@ type visitor struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func newVisitor(conf *Config, ip string) *visitor {
|
type visitorStats struct {
|
||||||
|
AttachmentFileSizeLimit int64 `json:"attachmentFileSizeLimit"`
|
||||||
|
VisitorAttachmentBytesTotal int64 `json:"visitorAttachmentBytesTotal"`
|
||||||
|
VisitorAttachmentBytesUsed int64 `json:"visitorAttachmentBytesUsed"`
|
||||||
|
VisitorAttachmentBytesRemaining int64 `json:"visitorAttachmentBytesRemaining"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newVisitor(conf *Config, messageCache *messageCache, ip string) *visitor {
|
||||||
return &visitor{
|
return &visitor{
|
||||||
config: conf,
|
config: conf,
|
||||||
|
messageCache: messageCache,
|
||||||
ip: ip,
|
ip: ip,
|
||||||
requests: rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst),
|
requests: rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst),
|
||||||
emails: rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst),
|
emails: rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst),
|
||||||
@@ -91,3 +100,20 @@ func (v *visitor) Stale() bool {
|
|||||||
defer v.mu.Unlock()
|
defer v.mu.Unlock()
|
||||||
return time.Since(v.seen) > visitorExpungeAfter
|
return time.Since(v.seen) > visitorExpungeAfter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v *visitor) Stats() (*visitorStats, error) {
|
||||||
|
attachmentsBytesUsed, err := v.messageCache.AttachmentBytesUsed(v.ip)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
attachmentsBytesRemaining := v.config.VisitorAttachmentTotalSizeLimit - attachmentsBytesUsed
|
||||||
|
if attachmentsBytesRemaining < 0 {
|
||||||
|
attachmentsBytesRemaining = 0
|
||||||
|
}
|
||||||
|
return &visitorStats{
|
||||||
|
AttachmentFileSizeLimit: v.config.AttachmentFileSizeLimit,
|
||||||
|
VisitorAttachmentBytesTotal: v.config.VisitorAttachmentTotalSizeLimit,
|
||||||
|
VisitorAttachmentBytesUsed: attachmentsBytesUsed,
|
||||||
|
VisitorAttachmentBytesRemaining: attachmentsBytesRemaining,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"heckel.io/ntfy/server"
|
"heckel.io/ntfy/server"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -22,6 +23,8 @@ func StartServer(t *testing.T) (*server.Server, int) {
|
|||||||
func StartServerWithConfig(t *testing.T, conf *server.Config) (*server.Server, int) {
|
func StartServerWithConfig(t *testing.T, conf *server.Config) (*server.Server, int) {
|
||||||
port := 10000 + rand.Intn(20000)
|
port := 10000 + rand.Intn(20000)
|
||||||
conf.ListenHTTP = fmt.Sprintf(":%d", port)
|
conf.ListenHTTP = fmt.Sprintf(":%d", port)
|
||||||
|
conf.AttachmentCacheDir = t.TempDir()
|
||||||
|
conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")
|
||||||
s, err := server.New(conf)
|
s, err := server.New(conf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func TestSniffWriter_WriteUnknownMimeType(t *testing.T) {
|
|||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
sw := NewContentTypeWriter(rr, "")
|
sw := NewContentTypeWriter(rr, "")
|
||||||
randomBytes := make([]byte, 199)
|
randomBytes := make([]byte, 199)
|
||||||
rand.Read(randomBytes)
|
rand.Read(randomBytes[5:]) // Start at an offset; the test kept failing randomly because it hit random magic strings
|
||||||
sw.Write(randomBytes)
|
sw.Write(randomBytes)
|
||||||
require.Equal(t, "application/octet-stream", rr.Header().Get("Content-Type"))
|
require.Equal(t, "application/octet-stream", rr.Header().Get("Content-Type"))
|
||||||
}
|
}
|
||||||
|
|||||||
52
util/gzip_handler.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"compress/gzip"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Gzip is a HTTP middleware to transparently compress responses using gzip.
|
||||||
|
// Original code from https://gist.github.com/CJEnright/bc2d8b8dc0c1389a9feeddb110f822d7 (MIT)
|
||||||
|
func Gzip(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Encoding", "gzip")
|
||||||
|
|
||||||
|
gz := gzPool.Get().(*gzip.Writer)
|
||||||
|
defer gzPool.Put(gz)
|
||||||
|
|
||||||
|
gz.Reset(w)
|
||||||
|
defer gz.Close()
|
||||||
|
|
||||||
|
r.Header.Del("Accept-Encoding") // prevent double-gzipping
|
||||||
|
next.ServeHTTP(&gzipResponseWriter{ResponseWriter: w, Writer: gz}, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var gzPool = sync.Pool{
|
||||||
|
New: func() interface{} {
|
||||||
|
w := gzip.NewWriter(ioutil.Discard)
|
||||||
|
return w
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type gzipResponseWriter struct {
|
||||||
|
io.Writer
|
||||||
|
http.ResponseWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *gzipResponseWriter) WriteHeader(status int) {
|
||||||
|
w.Header().Del("Content-Length")
|
||||||
|
w.ResponseWriter.WriteHeader(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *gzipResponseWriter) Write(b []byte) (int, error) {
|
||||||
|
return w.Writer.Write(b)
|
||||||
|
}
|
||||||
40
util/gzip_handler_test.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"compress/gzip"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGzipHandler(t *testing.T) {
|
||||||
|
s := Gzip(http.FileServer(http.FS(testFs)))
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/embedfs/test.txt", nil)
|
||||||
|
req.Header.Set("Accept-Encoding", "gzip, deflate")
|
||||||
|
s.ServeHTTP(rr, req)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
require.Equal(t, "gzip", rr.Header().Get("Content-Encoding"))
|
||||||
|
require.Equal(t, "", rr.Header().Get("Content-Length"))
|
||||||
|
|
||||||
|
gz, _ := gzip.NewReader(rr.Body)
|
||||||
|
b, _ := io.ReadAll(gz)
|
||||||
|
require.Equal(t, "This is a test file for embedfs_test.go\n", string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGzipHandler_NoGzip(t *testing.T) {
|
||||||
|
s := Gzip(http.FileServer(http.FS(testFs)))
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/embedfs/test.txt", nil)
|
||||||
|
s.ServeHTTP(rr, req)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
require.Equal(t, "", rr.Header().Get("Content-Encoding"))
|
||||||
|
require.Equal(t, "40", rr.Header().Get("Content-Length"))
|
||||||
|
|
||||||
|
b, _ := io.ReadAll(rr.Body)
|
||||||
|
require.Equal(t, "This is a test file for embedfs_test.go\n", string(b))
|
||||||
|
}
|
||||||
61
util/peak.go
@@ -1,61 +0,0 @@
|
|||||||
package util
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"io"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PeakedReadCloser is a ReadCloser that allows peaking into a stream and buffering it in memory.
|
|
||||||
// It can be instantiated using the Peak function. After a stream has been peaked, it can still be fully
|
|
||||||
// read by reading the PeakedReadCloser. It first drained from the memory buffer, and then from the remaining
|
|
||||||
// underlying reader.
|
|
||||||
type PeakedReadCloser struct {
|
|
||||||
PeakedBytes []byte
|
|
||||||
LimitReached bool
|
|
||||||
peaked io.Reader
|
|
||||||
underlying io.ReadCloser
|
|
||||||
closed bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// Peak reads the underlying ReadCloser into memory up until the limit and returns a PeakedReadCloser
|
|
||||||
func Peak(underlying io.ReadCloser, limit int) (*PeakedReadCloser, error) {
|
|
||||||
if underlying == nil {
|
|
||||||
underlying = io.NopCloser(strings.NewReader(""))
|
|
||||||
}
|
|
||||||
peaked := make([]byte, limit)
|
|
||||||
read, err := io.ReadFull(underlying, peaked)
|
|
||||||
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &PeakedReadCloser{
|
|
||||||
PeakedBytes: peaked[:read],
|
|
||||||
LimitReached: read == limit,
|
|
||||||
underlying: underlying,
|
|
||||||
peaked: bytes.NewReader(peaked[:read]),
|
|
||||||
closed: false,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read reads from the peaked bytes and then from the underlying stream
|
|
||||||
func (r *PeakedReadCloser) Read(p []byte) (n int, err error) {
|
|
||||||
if r.closed {
|
|
||||||
return 0, io.EOF
|
|
||||||
}
|
|
||||||
n, err = r.peaked.Read(p)
|
|
||||||
if err == io.EOF {
|
|
||||||
return r.underlying.Read(p)
|
|
||||||
} else if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close closes the underlying stream
|
|
||||||
func (r *PeakedReadCloser) Close() error {
|
|
||||||
if r.closed {
|
|
||||||
return io.EOF
|
|
||||||
}
|
|
||||||
r.closed = true
|
|
||||||
return r.underlying.Close()
|
|
||||||
}
|
|
||||||
61
util/peek.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PeekedReadCloser is a ReadCloser that allows peeking into a stream and buffering it in memory.
|
||||||
|
// It can be instantiated using the Peek function. After a stream has been peeked, it can still be fully
|
||||||
|
// read by reading the PeekedReadCloser. It first drained from the memory buffer, and then from the remaining
|
||||||
|
// underlying reader.
|
||||||
|
type PeekedReadCloser struct {
|
||||||
|
PeekedBytes []byte
|
||||||
|
LimitReached bool
|
||||||
|
peeked io.Reader
|
||||||
|
underlying io.ReadCloser
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peek reads the underlying ReadCloser into memory up until the limit and returns a PeekedReadCloser
|
||||||
|
func Peek(underlying io.ReadCloser, limit int) (*PeekedReadCloser, error) {
|
||||||
|
if underlying == nil {
|
||||||
|
underlying = io.NopCloser(strings.NewReader(""))
|
||||||
|
}
|
||||||
|
peeked := make([]byte, limit)
|
||||||
|
read, err := io.ReadFull(underlying, peeked)
|
||||||
|
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &PeekedReadCloser{
|
||||||
|
PeekedBytes: peeked[:read],
|
||||||
|
LimitReached: read == limit,
|
||||||
|
underlying: underlying,
|
||||||
|
peeked: bytes.NewReader(peeked[:read]),
|
||||||
|
closed: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read reads from the peeked bytes and then from the underlying stream
|
||||||
|
func (r *PeekedReadCloser) Read(p []byte) (n int, err error) {
|
||||||
|
if r.closed {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
n, err = r.peeked.Read(p)
|
||||||
|
if err == io.EOF {
|
||||||
|
return r.underlying.Read(p)
|
||||||
|
} else if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the underlying stream
|
||||||
|
func (r *PeekedReadCloser) Close() error {
|
||||||
|
if r.closed {
|
||||||
|
return io.EOF
|
||||||
|
}
|
||||||
|
r.closed = true
|
||||||
|
return r.underlying.Close()
|
||||||
|
}
|
||||||
@@ -9,11 +9,11 @@ import (
|
|||||||
|
|
||||||
func TestPeak_LimitReached(t *testing.T) {
|
func TestPeak_LimitReached(t *testing.T) {
|
||||||
underlying := io.NopCloser(strings.NewReader("1234567890"))
|
underlying := io.NopCloser(strings.NewReader("1234567890"))
|
||||||
peaked, err := Peak(underlying, 5)
|
peaked, err := Peek(underlying, 5)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
require.Equal(t, []byte("12345"), peaked.PeakedBytes)
|
require.Equal(t, []byte("12345"), peaked.PeekedBytes)
|
||||||
require.Equal(t, true, peaked.LimitReached)
|
require.Equal(t, true, peaked.LimitReached)
|
||||||
|
|
||||||
all, err := io.ReadAll(peaked)
|
all, err := io.ReadAll(peaked)
|
||||||
@@ -21,13 +21,13 @@ func TestPeak_LimitReached(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
require.Equal(t, []byte("1234567890"), all)
|
require.Equal(t, []byte("1234567890"), all)
|
||||||
require.Equal(t, []byte("12345"), peaked.PeakedBytes)
|
require.Equal(t, []byte("12345"), peaked.PeekedBytes)
|
||||||
require.Equal(t, true, peaked.LimitReached)
|
require.Equal(t, true, peaked.LimitReached)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPeak_LimitNotReached(t *testing.T) {
|
func TestPeak_LimitNotReached(t *testing.T) {
|
||||||
underlying := io.NopCloser(strings.NewReader("1234567890"))
|
underlying := io.NopCloser(strings.NewReader("1234567890"))
|
||||||
peaked, err := Peak(underlying, 15)
|
peaked, err := Peek(underlying, 15)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -36,12 +36,12 @@ func TestPeak_LimitNotReached(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
require.Equal(t, []byte("1234567890"), all)
|
require.Equal(t, []byte("1234567890"), all)
|
||||||
require.Equal(t, []byte("1234567890"), peaked.PeakedBytes)
|
require.Equal(t, []byte("1234567890"), peaked.PeekedBytes)
|
||||||
require.Equal(t, false, peaked.LimitReached)
|
require.Equal(t, false, peaked.LimitReached)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPeak_Nil(t *testing.T) {
|
func TestPeak_Nil(t *testing.T) {
|
||||||
peaked, err := Peak(nil, 15)
|
peaked, err := Peek(nil, 15)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -50,6 +50,6 @@ func TestPeak_Nil(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
require.Equal(t, []byte(""), all)
|
require.Equal(t, []byte(""), all)
|
||||||
require.Equal(t, []byte(""), peaked.PeakedBytes)
|
require.Equal(t, []byte(""), peaked.PeekedBytes)
|
||||||
require.Equal(t, false, peaked.LimitReached)
|
require.Equal(t, false, peaked.LimitReached)
|
||||||
}
|
}
|
||||||
69
util/util.go
@@ -1,9 +1,12 @@
|
|||||||
package util
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/gabriel-vasile/mimetype"
|
"github.com/gabriel-vasile/mimetype"
|
||||||
|
"golang.org/x/term"
|
||||||
|
"io"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -74,6 +77,16 @@ func SplitNoEmpty(s string, sep string) []string {
|
|||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SplitKV splits a string into a key/value pair using a separator, and trimming space. If the separator
|
||||||
|
// is not found, key is empty.
|
||||||
|
func SplitKV(s string, sep string) (key string, value string) {
|
||||||
|
kv := strings.SplitN(strings.TrimSpace(s), sep, 2)
|
||||||
|
if len(kv) == 2 {
|
||||||
|
return strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1])
|
||||||
|
}
|
||||||
|
return "", strings.TrimSpace(kv[0])
|
||||||
|
}
|
||||||
|
|
||||||
// RandomString returns a random string with a given length
|
// RandomString returns a random string with a given length
|
||||||
func RandomString(length int) string {
|
func RandomString(length int) string {
|
||||||
randomMutex.Lock() // Who would have thought that random.Intn() is not thread-safe?!
|
randomMutex.Lock() // Who would have thought that random.Intn() is not thread-safe?!
|
||||||
@@ -85,7 +98,20 @@ func RandomString(length int) string {
|
|||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DurationToHuman converts a duration to a human readable format
|
// ValidRandomString returns true if the given string matches the format created by RandomString
|
||||||
|
func ValidRandomString(s string, length int) bool {
|
||||||
|
if len(s) != length {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range strings.Split(s, "") {
|
||||||
|
if !strings.Contains(randomStringCharset, c) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// DurationToHuman converts a duration to a human-readable format
|
||||||
func DurationToHuman(d time.Duration) (str string) {
|
func DurationToHuman(d time.Duration) (str string) {
|
||||||
if d == 0 {
|
if d == 0 {
|
||||||
return "0"
|
return "0"
|
||||||
@@ -202,3 +228,44 @@ func ParseSize(s string) (int64, error) {
|
|||||||
return int64(value), nil
|
return int64(value), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReadPassword will read a password from STDIN. If the terminal supports it, it will not print the
|
||||||
|
// input characters to the screen. If not, it'll just read using normal readline semantics (useful for testing).
|
||||||
|
func ReadPassword(in io.Reader) ([]byte, error) {
|
||||||
|
// If in is a file and a character device (a TTY), use term.ReadPassword
|
||||||
|
if f, ok := in.(*os.File); ok {
|
||||||
|
stat, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if (stat.Mode() & os.ModeCharDevice) == os.ModeCharDevice {
|
||||||
|
password, err := term.ReadPassword(int(f.Fd())) // This is always going to be 0
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return password, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Manually read util \n if found, see #69 for details why this is so manual
|
||||||
|
password := make([]byte, 0)
|
||||||
|
buf := make([]byte, 1)
|
||||||
|
for {
|
||||||
|
_, err := in.Read(buf)
|
||||||
|
if err == io.EOF || buf[0] == '\n' {
|
||||||
|
break
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if len(password) > 10240 {
|
||||||
|
return nil, errors.New("passwords this long are not supported")
|
||||||
|
}
|
||||||
|
password = append(password, buf[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
return password, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BasicAuth encodes the Authorization header value for basic auth
|
||||||
|
func BasicAuth(user, pass string) string {
|
||||||
|
return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", user, pass))))
|
||||||
|
}
|
||||||
|
|||||||
@@ -152,3 +152,17 @@ func TestParseSize_FailureInvalid(t *testing.T) {
|
|||||||
t.Fatalf("expected error, but got none")
|
t.Fatalf("expected error, but got none")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSplitKV(t *testing.T) {
|
||||||
|
key, value := SplitKV(" key = value ", "=")
|
||||||
|
require.Equal(t, "key", key)
|
||||||
|
require.Equal(t, "value", value)
|
||||||
|
|
||||||
|
key, value = SplitKV(" value ", "=")
|
||||||
|
require.Equal(t, "", key)
|
||||||
|
require.Equal(t, "value", value)
|
||||||
|
|
||||||
|
key, value = SplitKV("mykey=value=with=separator ", "=")
|
||||||
|
require.Equal(t, "mykey", key)
|
||||||
|
require.Equal(t, "value=with=separator", value)
|
||||||
|
}
|
||||||
|
|||||||