Compare commits
429 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e16899ae3 | ||
|
|
7475879712 | ||
|
|
997828aa72 | ||
|
|
f6ffb393f8 | ||
|
|
850c6725f5 | ||
|
|
39b1de3320 | ||
|
|
e12995e218 | ||
|
|
5cc0b194d3 | ||
|
|
7845eb0124 | ||
|
|
3fa825b104 | ||
|
|
732537eaba | ||
|
|
a898a2ebe8 | ||
|
|
430f985fca | ||
|
|
ab955d4d1c | ||
|
|
41fd8454cf | ||
|
|
bd865fd55d | ||
|
|
b9e5079399 | ||
|
|
eb0847392c | ||
|
|
17eabed11c | ||
|
|
ad55de784d | ||
|
|
48538d149e | ||
|
|
b60f0afb8f | ||
|
|
8c32f029fb | ||
|
|
5b9391be39 | ||
|
|
a04cf5fcb6 | ||
|
|
9202d85532 | ||
|
|
769e071593 | ||
|
|
c80e4e1aa9 | ||
|
|
f9284a098a | ||
|
|
8283b6be97 | ||
|
|
8a81c8e95b | ||
|
|
670ea67052 | ||
|
|
aaa004847c | ||
|
|
717d6287c8 | ||
|
|
dcfb19bfc9 | ||
|
|
dc0e699fb5 | ||
|
|
1f38a4a531 | ||
|
|
970ca3a68e | ||
|
|
ce7c8c43b5 | ||
|
|
4a6f4e0044 | ||
|
|
7e3ac9b76b | ||
|
|
3939599014 | ||
|
|
15aed00387 | ||
|
|
d1544991bf | ||
|
|
d24f2d9d46 | ||
|
|
b2c2bd1e4b | ||
|
|
b003d79ae4 | ||
|
|
a52b024807 | ||
|
|
12b83828bd | ||
|
|
96bb357435 | ||
|
|
6a43c1a126 | ||
|
|
4dabc56952 | ||
|
|
5e510a19a1 | ||
|
|
b627a327d1 | ||
|
|
0b38efd761 | ||
|
|
983dec801a | ||
|
|
01eeb71b9d | ||
|
|
6ba1d7b2a5 | ||
|
|
ff202a042b | ||
|
|
af76a2606d | ||
|
|
98b56c2f06 | ||
|
|
b6afa2fd49 | ||
|
|
e1c07228e5 | ||
|
|
a949748d91 | ||
|
|
125fcd85bb | ||
|
|
2abd6a57ee | ||
|
|
35a691a1bc | ||
|
|
2bf6b8bb28 | ||
|
|
cb82e2690c | ||
|
|
ab1f9220a3 | ||
|
|
4c5d40e4c9 | ||
|
|
c33065151e | ||
|
|
ab01d0f04e | ||
|
|
42c3c6eb29 | ||
|
|
da7a1f656f | ||
|
|
63719ca0a0 | ||
|
|
cd27d47f4b | ||
|
|
60c5ccf34b | ||
|
|
d819de2626 | ||
|
|
79cb082879 | ||
|
|
632bf8d0b6 | ||
|
|
5e1d1698ff | ||
|
|
396497fff7 | ||
|
|
94bfe029d5 | ||
|
|
7f736eb93e | ||
|
|
414e283b46 | ||
|
|
a52e628f7b | ||
|
|
3eea97109e | ||
|
|
1950fc518f | ||
|
|
b93d654aca | ||
|
|
91594faf28 | ||
|
|
6c2aa0c3c2 | ||
|
|
db613f81cc | ||
|
|
51769c4094 | ||
|
|
cb768ca3f8 | ||
|
|
433e8e5b99 | ||
|
|
6cb42fbca1 | ||
|
|
406c172230 | ||
|
|
b4fbe81bb4 | ||
|
|
28f211bfef | ||
|
|
891257cce8 | ||
|
|
4cae237b36 | ||
|
|
c684a39191 | ||
|
|
797e4640df | ||
|
|
9684629549 | ||
|
|
a2825880bc | ||
|
|
577cd0fcea | ||
|
|
4b86085a8c | ||
|
|
0ee99e10c8 | ||
|
|
fe96110e6b | ||
|
|
35f173e17c | ||
|
|
87f8af9b97 | ||
|
|
4dd215d3d8 | ||
|
|
5a8818ac92 | ||
|
|
3baa93a0d4 | ||
|
|
72ec2f9988 | ||
|
|
ae3d063c2d | ||
|
|
d0bb27cf0c | ||
|
|
67be8e3ff8 | ||
|
|
4571ba1c24 | ||
|
|
6d601ad141 | ||
|
|
f63b15ba5a | ||
|
|
5c01d13fe3 | ||
|
|
19d2a46457 | ||
|
|
613348d37e | ||
|
|
7d473488de | ||
|
|
6e4b31b4e9 | ||
|
|
88474957a2 | ||
|
|
9dc532de30 | ||
|
|
fe37258bc2 | ||
|
|
5291e9be7f | ||
|
|
6ab02a31a2 | ||
|
|
14d9d120e6 | ||
|
|
f5981b851d | ||
|
|
c357979f11 | ||
|
|
6ee3349cca | ||
|
|
91e6eaab19 | ||
|
|
3973f1e5ed | ||
|
|
15ac5ed23b | ||
|
|
344da326cd | ||
|
|
cacfb704a4 | ||
|
|
040bb53383 | ||
|
|
5cac63bfbe | ||
|
|
8d908fe438 | ||
|
|
7db99d18c7 | ||
|
|
2bb5d6f934 | ||
|
|
bb13011046 | ||
|
|
8cc12e12da | ||
|
|
6e2b300d9e | ||
|
|
1197d72523 | ||
|
|
c1517e259d | ||
|
|
66d30fb42a | ||
|
|
ea30132763 | ||
|
|
1c237435ec | ||
|
|
c37793d06f | ||
|
|
57c7a353b5 | ||
|
|
8154705f0b | ||
|
|
fd2c6ef590 | ||
|
|
6ece25e7f3 | ||
|
|
a2ef6180bb | ||
|
|
c3c4c9e9aa | ||
|
|
031c848984 | ||
|
|
1f70ff1b06 | ||
|
|
1af9a85847 | ||
|
|
e474d1e8b0 | ||
|
|
4b117a790a | ||
|
|
72a2a8c82e | ||
|
|
a7af16beb1 | ||
|
|
7d38bc7654 | ||
|
|
bcbcbf12ac | ||
|
|
8b5d2a8ca0 | ||
|
|
9b8e637618 | ||
|
|
15a45d9eb7 | ||
|
|
8a7bc38861 | ||
|
|
2d96560375 | ||
|
|
bb5e0e3fed | ||
|
|
4a8678bf39 | ||
|
|
ed28082c01 | ||
|
|
0d3dcfdc7a | ||
|
|
672203467d | ||
|
|
4ce619f9cb | ||
|
|
5344337b43 | ||
|
|
cf3238859c | ||
|
|
9a03a9e81b | ||
|
|
edfed24c27 | ||
|
|
7118dcc124 | ||
|
|
5bcb35f756 | ||
|
|
eaf3c42227 | ||
|
|
16a4feaeb6 | ||
|
|
b60458318c | ||
|
|
b10c88afd7 | ||
|
|
f0cae0fbac | ||
|
|
28bb8d4446 | ||
|
|
adea3c38be | ||
|
|
fb56ab9a06 | ||
|
|
72aea2613a | ||
|
|
6bd4e4bd7c | ||
|
|
1f6118f068 | ||
|
|
574e72a974 | ||
|
|
53646737e8 | ||
|
|
26b9cc75ca | ||
|
|
66e46aaded | ||
|
|
ddf5d49895 | ||
|
|
899d895f29 | ||
|
|
27588b8a48 | ||
|
|
c3f4adb777 | ||
|
|
3633503549 | ||
|
|
5494bcce88 | ||
|
|
b824a1f17f | ||
|
|
5a6b6dacc0 | ||
|
|
0c6e9d4fca | ||
|
|
0d0ca188bf | ||
|
|
9c91ae2744 | ||
|
|
882f027f6c | ||
|
|
b805d49cfd | ||
|
|
12f85cceb1 | ||
|
|
8f4a1db1f0 | ||
|
|
58bde32bfb | ||
|
|
26b3aa27ae | ||
|
|
26ebd23bfd | ||
|
|
12d347976c | ||
|
|
a779434bab | ||
|
|
c5ec3b48b4 | ||
|
|
712c292183 | ||
|
|
8900df27c9 | ||
|
|
0eb511c714 | ||
|
|
d48eec5e66 | ||
|
|
3a7fd7a620 | ||
|
|
85043b34a4 | ||
|
|
2df0e98749 | ||
|
|
3c3b2477af | ||
|
|
5a9b2122c2 | ||
|
|
37e72e078d | ||
|
|
a2dafc11f2 | ||
|
|
55869f551e | ||
|
|
967cde1fb5 | ||
|
|
26efd481e3 | ||
|
|
06fd7327de | ||
|
|
a08d57ca0f | ||
|
|
c60c51871d | ||
|
|
04c4150283 | ||
|
|
1feb038385 | ||
|
|
61a5b0dbe9 | ||
|
|
690cd683f0 | ||
|
|
c87c81f663 | ||
|
|
8190d5b1f4 | ||
|
|
75c11371e6 | ||
|
|
ffa0bf05cd | ||
|
|
8e1c57af25 | ||
|
|
c62916a43c | ||
|
|
f5145ffaae | ||
|
|
0a6aba1ac7 | ||
|
|
5d30246c35 | ||
|
|
e9386ecfe3 | ||
|
|
04f5d4acb7 | ||
|
|
841c08fcb6 | ||
|
|
2d7c354723 | ||
|
|
6fec79055e | ||
|
|
f61a8f82a7 | ||
|
|
136883fd94 | ||
|
|
9c3f5929c7 | ||
|
|
39bd1fe164 | ||
|
|
67ea467501 | ||
|
|
ed946195e2 | ||
|
|
84bf95fa85 | ||
|
|
cf9ba9b1f9 | ||
|
|
1a18ce9e21 | ||
|
|
044b717f86 | ||
|
|
8777718afc | ||
|
|
8e3910c76d | ||
|
|
448444eccf | ||
|
|
65cd380527 | ||
|
|
71a49ac1a6 | ||
|
|
1fba62276c | ||
|
|
29f265be30 | ||
|
|
4c9011f391 | ||
|
|
155475422e | ||
|
|
32353e0f02 | ||
|
|
69159b9aae | ||
|
|
b47d0ac240 | ||
|
|
d14af78403 | ||
|
|
9cb08036ef | ||
|
|
e0da6b1302 | ||
|
|
fcb1f938b9 | ||
|
|
9c094c1cc3 | ||
|
|
69c6f24d97 | ||
|
|
e8b020ff45 | ||
|
|
2ec9a7307e | ||
|
|
738ee5cf35 | ||
|
|
8144d39e29 | ||
|
|
788d5e9f9b | ||
|
|
d399d2fe1c | ||
|
|
615b09a774 | ||
|
|
7a5e8cc44b | ||
|
|
291b49488b | ||
|
|
aa58242551 | ||
|
|
b08ea2c416 | ||
|
|
98f02f78db | ||
|
|
d2f933e15f | ||
|
|
d672969840 | ||
|
|
8c4f0c1253 | ||
|
|
18c88e567c | ||
|
|
2c5505852e | ||
|
|
bc8f245064 | ||
|
|
30726144b8 | ||
|
|
893701c07b | ||
|
|
0ec9a4c89b | ||
|
|
96fb7e2296 | ||
|
|
750e390b5d | ||
|
|
7a8cfb5f66 | ||
|
|
d761ce929c | ||
|
|
29969582e9 | ||
|
|
749e334396 | ||
|
|
78a681f277 | ||
|
|
e22ec2c505 | ||
|
|
3f96fad7ce | ||
|
|
83bb9951b0 | ||
|
|
4a5f34801a | ||
|
|
2cd7839da3 | ||
|
|
35ddcb27f0 | ||
|
|
328aca48ab | ||
|
|
4eba641ec3 | ||
|
|
f2d4af04e3 | ||
|
|
d44ee2bbf6 | ||
|
|
6f07944442 | ||
|
|
7716b1e81e | ||
|
|
8914809775 | ||
|
|
dcb5531038 | ||
|
|
0c666f96b1 | ||
|
|
d9c3c20350 | ||
|
|
73349cd423 | ||
|
|
b3667a916b | ||
|
|
6791c7395b | ||
|
|
aba7e86cbc | ||
|
|
12f973f61b | ||
|
|
f98743dd9b | ||
|
|
2c8b258ae7 | ||
|
|
00520a7a38 | ||
|
|
611894fd05 | ||
|
|
aabae53e5d | ||
|
|
85cf7bb687 | ||
|
|
44b9358c60 | ||
|
|
ee6188d100 | ||
|
|
2bdae49425 | ||
|
|
9814a9f792 | ||
|
|
5aedfd3898 | ||
|
|
59ec2de8bd | ||
|
|
d154d3936d | ||
|
|
5125aac91c | ||
|
|
7ff34364a3 | ||
|
|
3d0d70dc17 | ||
|
|
62512b7a1a | ||
|
|
c5a1344e8a | ||
|
|
402b05a27b | ||
|
|
b67d9fc85d | ||
|
|
3e121f5d3c | ||
|
|
b6426f0417 | ||
|
|
59b341dfb8 | ||
|
|
e2834a7c4d | ||
|
|
e0b3068a5e | ||
|
|
2280031a80 | ||
|
|
8f2851e20a | ||
|
|
2eeb7d63a0 | ||
|
|
b20df55b88 | ||
|
|
de1b97bbce | ||
|
|
3b4a4108e5 | ||
|
|
dc1c0ddd4e | ||
|
|
182e21a9c3 | ||
|
|
187c19f3b2 | ||
|
|
d5eff0cd34 | ||
|
|
d4fe2052c7 | ||
|
|
2e92be0f23 | ||
|
|
94b0e6f690 | ||
|
|
202051bbbf | ||
|
|
a693975526 | ||
|
|
4cd4e890fe | ||
|
|
5dc8031ec9 | ||
|
|
03ad5dcff6 | ||
|
|
5f508e1839 | ||
|
|
c5642799df | ||
|
|
5a99fe8ba2 | ||
|
|
ee0f448d86 | ||
|
|
a222f64ee4 | ||
|
|
140daec0d3 | ||
|
|
b409c89d3b | ||
|
|
806893962c | ||
|
|
14d3c5e93e | ||
|
|
37e14b13a4 | ||
|
|
d7fa51be2c | ||
|
|
a3e28e71aa | ||
|
|
35cef8386c | ||
|
|
38072c9cdd | ||
|
|
13d741b89e | ||
|
|
cc90a1af15 | ||
|
|
21fc1245eb | ||
|
|
2511ba7627 | ||
|
|
23547f4504 | ||
|
|
e6f19d050f | ||
|
|
3ec8084450 | ||
|
|
2edb722c0e | ||
|
|
1f75498dca | ||
|
|
ab19c4d688 | ||
|
|
15265d9ef3 | ||
|
|
2839a7228f | ||
|
|
c2036975fa | ||
|
|
7aa0f87376 | ||
|
|
df372d1a7e | ||
|
|
6cd31502e7 | ||
|
|
bade88079f | ||
|
|
20ab05afc8 | ||
|
|
5b10f51af1 | ||
|
|
470d11f442 | ||
|
|
4952f0fbd2 | ||
|
|
0a3292566c | ||
|
|
f4e8ebc053 | ||
|
|
ad77bde8c8 | ||
|
|
5241b29cc6 | ||
|
|
8fcc40942f | ||
|
|
37d4d5d647 | ||
|
|
b67b9e83ff | ||
|
|
4c3dcec19e | ||
|
|
53375ff559 | ||
|
|
53e08988e7 | ||
|
|
d0bbda555f | ||
|
|
207e990798 | ||
|
|
b0a07af28d | ||
|
|
1a8bac7ab1 | ||
|
|
dc03c13642 | ||
|
|
739b20583d |
39
.github/workflows/build.yaml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
name: build
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Install Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: '1.18.x'
|
||||
-
|
||||
name: Install node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '16'
|
||||
-
|
||||
name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
-
|
||||
name: Cache Go and npm modules
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
~/go/bin
|
||||
~/.npm
|
||||
web/node_modules
|
||||
key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }}
|
||||
restore-keys: ${{ runner.os }}-ntfy-
|
||||
-
|
||||
name: Install dependencies
|
||||
run: make build-deps-ubuntu
|
||||
-
|
||||
name: Build all the things
|
||||
run: make build
|
||||
-
|
||||
name: Print build results and checksums
|
||||
run: make cli-build-results
|
||||
50
.github/workflows/release.yaml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v[0-9]+.[0-9]+.[0-9]+'
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Install Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: '1.18.x'
|
||||
-
|
||||
name: Install node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '16'
|
||||
-
|
||||
name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
-
|
||||
name: Cache Go and npm modules
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
~/go/bin
|
||||
~/.npm
|
||||
web/node_modules
|
||||
key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }}
|
||||
restore-keys: ${{ runner.os }}-ntfy-
|
||||
-
|
||||
name: Docker login
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
-
|
||||
name: Install dependencies
|
||||
run: make build-deps-ubuntu
|
||||
-
|
||||
name: Build and publish
|
||||
run: make release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
-
|
||||
name: Print build results and checksums
|
||||
run: make cli-build-results
|
||||
44
.github/workflows/test.yaml
vendored
@@ -3,26 +3,46 @@ on: [push, pull_request]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install Go
|
||||
steps:
|
||||
-
|
||||
name: Install Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: '1.17.x'
|
||||
- name: Install node
|
||||
go-version: '1.18.x'
|
||||
-
|
||||
name: Install node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '16'
|
||||
- name: Checkout code
|
||||
-
|
||||
name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Install dependencies
|
||||
run: sudo apt update && sudo apt install -y python3-pip curl
|
||||
- name: Build docs (required for tests)
|
||||
-
|
||||
name: Cache Go and npm modules
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
~/go/bin
|
||||
~/.npm
|
||||
web/node_modules
|
||||
key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }}
|
||||
restore-keys: ${{ runner.os }}-ntfy-
|
||||
-
|
||||
name: Install dependencies
|
||||
run: make build-deps-ubuntu
|
||||
-
|
||||
name: Build docs (required for tests)
|
||||
run: make docs
|
||||
- name: Build web app (required for tests)
|
||||
-
|
||||
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
|
||||
- name: Run coverage
|
||||
-
|
||||
name: Run coverage
|
||||
run: make coverage
|
||||
- name: Upload coverage to codecov.io
|
||||
-
|
||||
name: Upload coverage to codecov.io
|
||||
run: make coverage-upload
|
||||
|
||||
4
.gitignore
vendored
@@ -1,9 +1,13 @@
|
||||
dist/
|
||||
build/
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
server/docs/
|
||||
server/site/
|
||||
tools/fbsend/fbsend
|
||||
playground/
|
||||
secrets/
|
||||
*.iml
|
||||
node_modules/
|
||||
.DS_Store
|
||||
|
||||
@@ -4,7 +4,7 @@ before:
|
||||
- go mod tidy
|
||||
builds:
|
||||
-
|
||||
id: ntfy
|
||||
id: ntfy_linux_amd64
|
||||
binary: ntfy
|
||||
env:
|
||||
- CGO_ENABLED=1 # required for go-sqlite3
|
||||
@@ -17,7 +17,20 @@ builds:
|
||||
post:
|
||||
- upx "{{ .Path }}" # apt install upx
|
||||
-
|
||||
id: ntfy_armv7
|
||||
id: ntfy_linux_armv6
|
||||
binary: ntfy
|
||||
env:
|
||||
- CGO_ENABLED=1 # required for go-sqlite3
|
||||
- CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi
|
||||
tags: [sqlite_omit_load_extension,osusergo,netgo]
|
||||
ldflags:
|
||||
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||
goos: [linux]
|
||||
goarch: [arm]
|
||||
goarm: [6]
|
||||
# No "upx" for ARM, see https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
|
||||
-
|
||||
id: ntfy_linux_armv7
|
||||
binary: ntfy
|
||||
env:
|
||||
- CGO_ENABLED=1 # required for go-sqlite3
|
||||
@@ -28,11 +41,9 @@ builds:
|
||||
goos: [linux]
|
||||
goarch: [arm]
|
||||
goarm: [7]
|
||||
hooks:
|
||||
post:
|
||||
- upx "{{ .Path }}" # apt install upx
|
||||
# No "upx" for ARM, see https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
|
||||
-
|
||||
id: ntfy_arm64
|
||||
id: ntfy_linux_arm64
|
||||
binary: ntfy
|
||||
env:
|
||||
- CGO_ENABLED=1 # required for go-sqlite3
|
||||
@@ -42,9 +53,30 @@ builds:
|
||||
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||
goos: [linux]
|
||||
goarch: [arm64]
|
||||
# No "upx" for ARM, see https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
|
||||
-
|
||||
id: ntfy_windows_amd64
|
||||
binary: ntfy
|
||||
env:
|
||||
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
|
||||
tags: [noserver] # don't include server files
|
||||
ldflags:
|
||||
- "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||
goos: [windows]
|
||||
goarch: [amd64]
|
||||
hooks:
|
||||
post:
|
||||
- upx "{{ .Path }}" # apt install upx
|
||||
-
|
||||
id: ntfy_darwin_all
|
||||
binary: ntfy
|
||||
env:
|
||||
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
|
||||
tags: [noserver] # don't include server files
|
||||
ldflags:
|
||||
- "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||
goos: [darwin]
|
||||
goarch: [amd64, arm64] # will be combined to "universal binary" (see below)
|
||||
nfpms:
|
||||
-
|
||||
package_name: ntfy
|
||||
@@ -59,12 +91,12 @@ nfpms:
|
||||
contents:
|
||||
- src: server/server.yml
|
||||
dst: /etc/ntfy/server.yml
|
||||
type: config
|
||||
type: "config|noreplace"
|
||||
- src: server/ntfy.service
|
||||
dst: /lib/systemd/system/ntfy.service
|
||||
- src: client/client.yml
|
||||
dst: /etc/ntfy/client.yml
|
||||
type: config
|
||||
type: "config|noreplace"
|
||||
- src: client/ntfy-client.service
|
||||
dst: /lib/systemd/system/ntfy-client.service
|
||||
- dst: /var/cache/ntfy
|
||||
@@ -82,6 +114,12 @@ nfpms:
|
||||
postremove: "scripts/postrm.sh"
|
||||
archives:
|
||||
-
|
||||
id: ntfy_linux
|
||||
builds:
|
||||
- ntfy_linux_amd64
|
||||
- ntfy_linux_armv6
|
||||
- ntfy_linux_armv7
|
||||
- ntfy_linux_arm64
|
||||
wrap_in_directory: true
|
||||
files:
|
||||
- LICENSE
|
||||
@@ -91,8 +129,35 @@ archives:
|
||||
- client/client.yml
|
||||
- client/ntfy-client.service
|
||||
replacements:
|
||||
386: i386
|
||||
amd64: x86_64
|
||||
-
|
||||
id: ntfy_windows
|
||||
builds:
|
||||
- ntfy_windows_amd64
|
||||
format: zip
|
||||
wrap_in_directory: true
|
||||
files:
|
||||
- LICENSE
|
||||
- README.md
|
||||
- client/client.yml
|
||||
replacements:
|
||||
amd64: x86_64
|
||||
-
|
||||
id: ntfy_darwin
|
||||
builds:
|
||||
- ntfy_darwin_all
|
||||
wrap_in_directory: true
|
||||
files:
|
||||
- LICENSE
|
||||
- README.md
|
||||
- client/client.yml
|
||||
replacements:
|
||||
darwin: macOS
|
||||
universal_binaries:
|
||||
-
|
||||
id: ntfy_darwin_all
|
||||
replace: true
|
||||
name_template: ntfy
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
snapshot:
|
||||
@@ -126,14 +191,24 @@ dockers:
|
||||
goarm: 7
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm/v7"
|
||||
- image_templates:
|
||||
- &armv6_image "binwiederhier/ntfy:{{ .Tag }}-armv6"
|
||||
use: buildx
|
||||
dockerfile: Dockerfile
|
||||
goarch: arm
|
||||
goarm: 6
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm/v6"
|
||||
docker_manifests:
|
||||
- name_template: "binwiederhier/ntfy:latest"
|
||||
image_templates:
|
||||
- *amd64_image
|
||||
- *arm64v8_image
|
||||
- *armv7_image
|
||||
- *armv6_image
|
||||
- name_template: "binwiederhier/ntfy:{{ .Tag }}"
|
||||
image_templates:
|
||||
- *amd64_image
|
||||
- *arm64v8_image
|
||||
- *armv7_image
|
||||
- *armv6_image
|
||||
|
||||
@@ -2,4 +2,6 @@ FROM alpine
|
||||
MAINTAINER Philipp C. Heckel <philipp.heckel@gmail.com>
|
||||
|
||||
COPY ntfy /usr/bin
|
||||
|
||||
EXPOSE 80/tcp
|
||||
ENTRYPOINT ["ntfy"]
|
||||
|
||||
293
Makefile
@@ -1,57 +1,113 @@
|
||||
MAKEFLAGS := --jobs=1
|
||||
VERSION := $(shell git describe --tag)
|
||||
|
||||
.PHONY:
|
||||
|
||||
help:
|
||||
@echo "Typical commands:"
|
||||
@echo " make check - Run all tests, vetting/formatting checks and linters"
|
||||
@echo " make fmt build-snapshot install - Build latest and install to local system"
|
||||
@echo "Typical commands (more see below):"
|
||||
@echo " make build - Build web app, documentation and server/client (sloowwww)"
|
||||
@echo " make cli-linux-amd64 - Build server/client binary (amd64, no web app or docs)"
|
||||
@echo " make install-linux-amd64 - Install ntfy binary to /usr/bin/ntfy (amd64)"
|
||||
@echo " make web - Build the web app"
|
||||
@echo " make docs - Build the documentation"
|
||||
@echo " make check - Run all tests, vetting/formatting checks and linters"
|
||||
@echo
|
||||
@echo "Build everything:"
|
||||
@echo " make build - Build web app, documentation and server/client"
|
||||
@echo " make clean - Clean build/dist folders"
|
||||
@echo
|
||||
@echo "Build server & client (using GoReleaser, not release version):"
|
||||
@echo " make cli - Build server & client (all architectures)"
|
||||
@echo " make cli-linux-amd64 - Build server & client (Linux, amd64 only)"
|
||||
@echo " make cli-linux-armv6 - Build server & client (Linux, armv6 only)"
|
||||
@echo " make cli-linux-armv7 - Build server & client (Linux, armv7 only)"
|
||||
@echo " make cli-linux-arm64 - Build server & client (Linux, arm64 only)"
|
||||
@echo " make cli-windows-amd64 - Build client (Windows, amd64 only)"
|
||||
@echo " make cli-darwin-all - Build client (macOS, arm64+amd64 universal binary)"
|
||||
@echo
|
||||
@echo "Build server & client (without GoReleaser):"
|
||||
@echo " make cli-linux-server - Build client & server (no GoReleaser, current arch, Linux)"
|
||||
@echo " make cli-darwin-server - Build client & server (no GoReleaser, current arch, macOS)"
|
||||
@echo " make cli-client - Build client only (no GoReleaser, current arch, Linux/macOS/Windows)"
|
||||
@echo
|
||||
@echo "Build web app:"
|
||||
@echo " make web - Build the web app"
|
||||
@echo " make web-deps - Install web app dependencies (npm install the universe)"
|
||||
@echo " make web-build - Actually build the web app"
|
||||
@echo
|
||||
@echo "Build documentation:"
|
||||
@echo " make docs - Build the documentation"
|
||||
@echo " make docs-deps - Install Python dependencies (pip3 install)"
|
||||
@echo " make docs-build - Actually build the documentation"
|
||||
@echo
|
||||
@echo "Test/check:"
|
||||
@echo " make test - Run tests"
|
||||
@echo " make race - Run tests with -race flag"
|
||||
@echo " make coverage - Run tests and show coverage"
|
||||
@echo " make coverage-html - Run tests and show coverage (as HTML)"
|
||||
@echo " make coverage-upload - Upload coverage results to codecov.io"
|
||||
@echo " make test - Run tests"
|
||||
@echo " make race - Run tests with -race flag"
|
||||
@echo " make coverage - Run tests and show coverage"
|
||||
@echo " make coverage-html - Run tests and show coverage (as HTML)"
|
||||
@echo " make coverage-upload - Upload coverage results to codecov.io"
|
||||
@echo
|
||||
@echo "Lint/format:"
|
||||
@echo " make fmt - Run 'go fmt'"
|
||||
@echo " make fmt-check - Run 'go fmt', but don't change anything"
|
||||
@echo " make vet - Run 'go vet'"
|
||||
@echo " make lint - Run 'golint'"
|
||||
@echo " make staticcheck - Run 'staticcheck'"
|
||||
@echo " make fmt - Run 'go fmt'"
|
||||
@echo " make fmt-check - Run 'go fmt', but don't change anything"
|
||||
@echo " make vet - Run 'go vet'"
|
||||
@echo " make lint - Run 'golint'"
|
||||
@echo " make staticcheck - Run 'staticcheck'"
|
||||
@echo
|
||||
@echo "Build:"
|
||||
@echo " make build - Build"
|
||||
@echo " make build-snapshot - Build snapshot"
|
||||
@echo " make build-simple - Build (using go build, without goreleaser)"
|
||||
@echo " make clean - Clean build folder"
|
||||
@echo
|
||||
@echo "Releasing (requires goreleaser):"
|
||||
@echo " make release - Create a release"
|
||||
@echo " make release-snapshot - Create a test release"
|
||||
@echo "Releasing:"
|
||||
@echo " make release - Create a release"
|
||||
@echo " make release-snapshot - Create a test release"
|
||||
@echo
|
||||
@echo "Install locally (requires sudo):"
|
||||
@echo " make install - Copy binary from dist/ to /usr/bin"
|
||||
@echo " make install-deb - Install .deb from dist/"
|
||||
@echo " make install-lint - Install golint"
|
||||
@echo " make install-linux-amd64 - Copy amd64 binary from dist/ to /usr/bin/ntfy"
|
||||
@echo " make install-linux-armv6 - Copy armv6 binary from dist/ to /usr/bin/ntfy"
|
||||
@echo " make install-linux-armv7 - Copy armv7 binary from dist/ to /usr/bin/ntfy"
|
||||
@echo " make install-linux-arm64 - Copy arm64 binary from dist/ to /usr/bin/ntfy"
|
||||
@echo " make install-linux-deb-amd64 - Install .deb from dist/ (amd64 only)"
|
||||
@echo " make install-linux-deb-armv6 - Install .deb from dist/ (armv6 only)"
|
||||
@echo " make install-linux-deb-armv7 - Install .deb from dist/ (armv7 only)"
|
||||
@echo " make install-linux-deb-arm64 - Install .deb from dist/ (arm64 only)"
|
||||
|
||||
|
||||
# Building everything
|
||||
|
||||
clean: .PHONY
|
||||
rm -rf dist build server/docs server/site
|
||||
|
||||
build: web docs cli
|
||||
|
||||
update: web-deps-update cli-deps-update docs-deps-update
|
||||
docker pull alpine
|
||||
|
||||
# Ubuntu-specific
|
||||
|
||||
build-deps-ubuntu:
|
||||
sudo apt update
|
||||
sudo apt install -y \
|
||||
curl \
|
||||
gcc-aarch64-linux-gnu \
|
||||
gcc-arm-linux-gnueabi \
|
||||
upx \
|
||||
jq
|
||||
which pip3 || sudo apt install -y python3-pip
|
||||
|
||||
# Documentation
|
||||
|
||||
docs: docs-deps docs-build
|
||||
|
||||
docs-build: .PHONY
|
||||
mkdocs build
|
||||
|
||||
docs-deps: .PHONY
|
||||
pip3 install -r requirements.txt
|
||||
|
||||
docs: docs-deps
|
||||
mkdocs build
|
||||
docs-deps-update: .PHONY
|
||||
pip3 install -r requirements.txt --upgrade
|
||||
|
||||
|
||||
# Web app
|
||||
|
||||
web-deps:
|
||||
cd web \
|
||||
&& npm install \
|
||||
&& node_modules/svgo/bin/svgo src/img/*.svg
|
||||
web: web-deps web-build
|
||||
|
||||
web-build:
|
||||
cd web \
|
||||
@@ -63,8 +119,100 @@ web-build:
|
||||
../server/site/config.js \
|
||||
../server/site/asset-manifest.json
|
||||
|
||||
web: web-deps web-build
|
||||
web-deps:
|
||||
cd web && npm install
|
||||
# If this fails for .svg files, optimize them with svgo
|
||||
|
||||
web-deps-update:
|
||||
cd web && npm update
|
||||
|
||||
|
||||
# Main server/client build
|
||||
|
||||
cli: cli-deps
|
||||
goreleaser build --snapshot --rm-dist
|
||||
|
||||
cli-linux-amd64: cli-deps-static-sites
|
||||
goreleaser build --snapshot --rm-dist --id ntfy_linux_amd64
|
||||
|
||||
cli-linux-armv6: cli-deps-static-sites cli-deps-gcc-armv6-armv7
|
||||
goreleaser build --snapshot --rm-dist --id ntfy_linux_armv6
|
||||
|
||||
cli-linux-armv7: cli-deps-static-sites cli-deps-gcc-armv6-armv7
|
||||
goreleaser build --snapshot --rm-dist --id ntfy_linux_armv7
|
||||
|
||||
cli-linux-arm64: cli-deps-static-sites cli-deps-gcc-arm64
|
||||
goreleaser build --snapshot --rm-dist --id ntfy_linux_arm64
|
||||
|
||||
cli-windows-amd64: cli-deps-static-sites
|
||||
goreleaser build --snapshot --rm-dist --id ntfy_windows_amd64
|
||||
|
||||
cli-darwin-all: cli-deps-static-sites
|
||||
goreleaser build --snapshot --rm-dist --id ntfy_darwin_all
|
||||
|
||||
cli-linux-server: cli-deps-static-sites
|
||||
# This is a target to build the CLI (including the server) manually.
|
||||
# Use this for development, if you really don't want to install GoReleaser ...
|
||||
mkdir -p dist/ntfy_linux_server server/docs
|
||||
CGO_ENABLED=1 go build \
|
||||
-o dist/ntfy_linux_server/ntfy \
|
||||
-tags sqlite_omit_load_extension,osusergo,netgo \
|
||||
-ldflags \
|
||||
"-linkmode=external -extldflags=-static -s -w -X main.version=$(VERSION) -X main.commit=$(shell git rev-parse --short HEAD) -X main.date=$(shell date +%s)"
|
||||
|
||||
cli-darwin-server: cli-deps-static-sites
|
||||
# This is a target to build the CLI (including the server) manually.
|
||||
# Use this for macOS/iOS development, so you have a local server to test with.
|
||||
mkdir -p dist/ntfy_darwin_server server/docs
|
||||
CGO_ENABLED=1 go build \
|
||||
-o dist/ntfy_darwin_server/ntfy \
|
||||
-tags sqlite_omit_load_extension,osusergo,netgo \
|
||||
-ldflags \
|
||||
"-linkmode=external -s -w -X main.version=$(VERSION) -X main.commit=$(shell git rev-parse --short HEAD) -X main.date=$(shell date +%s)"
|
||||
|
||||
cli-client: cli-deps-static-sites
|
||||
# This is a target to build the CLI (excluding the server) manually. This should work on Linux/macOS/Windows.
|
||||
# Use this for development, if you really don't want to install GoReleaser ...
|
||||
mkdir -p dist/ntfy_client server/docs
|
||||
CGO_ENABLED=0 go build \
|
||||
-o dist/ntfy_client/ntfy \
|
||||
-tags noserver \
|
||||
-ldflags \
|
||||
"-X main.version=$(VERSION) -X main.commit=$(shell git rev-parse --short HEAD) -X main.date=$(shell date +%s)"
|
||||
|
||||
cli-deps: cli-deps-static-sites cli-deps-all cli-deps-gcc
|
||||
|
||||
cli-deps-gcc: cli-deps-gcc-armv6-armv7 cli-deps-gcc-arm64
|
||||
|
||||
cli-deps-static-sites:
|
||||
mkdir -p server/docs server/site
|
||||
touch server/docs/index.html server/site/app.html
|
||||
|
||||
cli-deps-all:
|
||||
which upx || { echo "ERROR: upx not installed. On Ubuntu, run: apt install upx"; exit 1; }
|
||||
go install github.com/goreleaser/goreleaser@latest
|
||||
|
||||
cli-deps-gcc-armv6-armv7:
|
||||
which arm-linux-gnueabi-gcc || { echo "ERROR: ARMv6/ARMv7 cross compiler not installed. On Ubuntu, run: apt install gcc-arm-linux-gnueabi"; exit 1; }
|
||||
|
||||
cli-deps-gcc-arm64:
|
||||
which aarch64-linux-gnu-gcc || { echo "ERROR: ARM64 cross compiler not installed. On Ubuntu, run: apt install gcc-aarch64-linux-gnu"; exit 1; }
|
||||
|
||||
cli-deps-update:
|
||||
go get -u
|
||||
go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||
go install golang.org/x/lint/golint@latest
|
||||
go install github.com/goreleaser/goreleaser@latest
|
||||
|
||||
cli-build-results:
|
||||
cat dist/config.yaml
|
||||
[ -f dist/artifacts.json ] && cat dist/artifacts.json | jq . || true
|
||||
[ -f dist/metadata.json ] && cat dist/metadata.json | jq . || true
|
||||
[ -f dist/checksums.txt ] && cat dist/checksums.txt || true
|
||||
find dist -maxdepth 2 -type f \
|
||||
\( -name '*.deb' -or -name '*.rpm' -or -name '*.zip' -or -name '*.tar.gz' -or -name 'ntfy' \) \
|
||||
-and -not -path 'dist/goreleaserdocker*' \
|
||||
-exec sha256sum {} \;
|
||||
|
||||
# Test/check targets
|
||||
|
||||
@@ -114,56 +262,59 @@ staticcheck: .PHONY
|
||||
rm -rf build/staticcheck
|
||||
|
||||
|
||||
# Building targets
|
||||
|
||||
build-deps: docs web
|
||||
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 server/site
|
||||
touch server/docs/index.html
|
||||
touch server/site/app.html
|
||||
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 server/site
|
||||
|
||||
|
||||
# Releasing targets
|
||||
|
||||
release-check-tags:
|
||||
release: clean update cli-deps release-checks docs web check
|
||||
goreleaser release --rm-dist
|
||||
|
||||
release-snapshot: clean update cli-deps docs web check
|
||||
goreleaser release --snapshot --skip-publish --rm-dist
|
||||
|
||||
release-checks:
|
||||
$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-))
|
||||
if ! grep -q $(LATEST_TAG) docs/install.md; then\
|
||||
echo "ERROR: Must update docs/install.md with latest tag first.";\
|
||||
exit 1;\
|
||||
fi
|
||||
|
||||
release: build-deps release-check-tags check
|
||||
goreleaser release --rm-dist --debug
|
||||
|
||||
release-snapshot: build-deps
|
||||
goreleaser release --snapshot --skip-publish --rm-dist --debug
|
||||
if ! grep -q $(LATEST_TAG) docs/releases.md; then\
|
||||
echo "ERROR: Must update docs/releases.md with latest tag first.";\
|
||||
exit 1;\
|
||||
fi
|
||||
if [ -n "$(shell git status -s)" ]; then\
|
||||
echo "ERROR: Git repository is in an unclean state.";\
|
||||
exit 1;\
|
||||
fi
|
||||
|
||||
|
||||
# Installing targets
|
||||
|
||||
install:
|
||||
sudo rm -f /usr/bin/ntfy
|
||||
sudo cp -a dist/ntfy_linux_amd64/ntfy /usr/bin/ntfy
|
||||
install-linux-amd64: remove-binary
|
||||
sudo cp -a dist/ntfy_amd64_linux_amd64_v1/ntfy /usr/bin/ntfy
|
||||
|
||||
install-deb:
|
||||
install-linux-armv6: remove-binary
|
||||
sudo cp -a dist/ntfy_armv6_linux_arm_6/ntfy /usr/bin/ntfy
|
||||
|
||||
install-linux-armv7: remove-binary
|
||||
sudo cp -a dist/ntfy_armv7_linux_arm_7/ntfy /usr/bin/ntfy
|
||||
|
||||
install-linux-arm64: remove-binary
|
||||
sudo cp -a dist/ntfy_arm64_linux_arm64/ntfy /usr/bin/ntfy
|
||||
|
||||
remove-binary:
|
||||
sudo rm -f /usr/bin/ntfy
|
||||
|
||||
install-linux-amd64-deb: purge-package
|
||||
sudo dpkg -i dist/ntfy_*_linux_amd64.deb
|
||||
|
||||
install-linux-armv6-deb: purge-package
|
||||
sudo dpkg -i dist/ntfy_*_linux_armv6.deb
|
||||
|
||||
install-linux-armv7-deb: purge-package
|
||||
sudo dpkg -i dist/ntfy_*_linux_armv7.deb
|
||||
|
||||
install-linux-arm64-deb: purge-package
|
||||
sudo dpkg -i dist/ntfy_*_linux_arm64.deb
|
||||
|
||||
purge-package:
|
||||
sudo systemctl stop ntfy || true
|
||||
sudo apt-get purge ntfy || true
|
||||
sudo dpkg -i dist/ntfy_*_linux_amd64.deb
|
||||
|
||||
22
README.md
@@ -1,4 +1,4 @@
|
||||

|
||||

|
||||
|
||||
# ntfy.sh | Send push notifications to your phone or desktop via PUT/POST
|
||||
[](https://github.com/binwiederhier/ntfy/releases/latest)
|
||||
@@ -33,14 +33,26 @@ too.
|
||||
[Install / Self-hosting](https://ntfy.sh/docs/install/) |
|
||||
[Building](https://ntfy.sh/docs/develop/)
|
||||
|
||||
## Contributing
|
||||
I welcome any and all contributions. Just create a PR or an issue.
|
||||
|
||||
## Contact me
|
||||
## Chat
|
||||
You can directly contact me **[on Discord](https://discord.gg/cT7ECsZj9w)** or [on Matrix](https://matrix.to/#/#ntfy:matrix.org)
|
||||
(bridged from Discord), or via the [GitHub issues](https://github.com/binwiederhier/ntfy/issues), or find more contact information
|
||||
[on my website](https://heckel.io/about).
|
||||
|
||||
## Announcements / beta testers
|
||||
For announcements of new releases and cutting-edge beta versions, please subscribe to the [ntfy.sh/announcements](https://ntfy.sh/announcements)
|
||||
topic. If you'd like to test the iOS app, join [TestFlight](https://testflight.apple.com/join/P1fFnAm9). For Android betas,
|
||||
join Discord/Matrix (I'll eventually make a testing channel in Google Play).
|
||||
|
||||
## Contributing
|
||||
I welcome any and all contributions. Just create a PR or an issue. 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>
|
||||
|
||||
## License
|
||||
Made with ❤️ by [Philipp C. Heckel](https://heckel.io).
|
||||
The project is dual licensed under the [Apache License 2.0](LICENSE) and the [GPLv2 License](LICENSE.GPLv2).
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/log"
|
||||
"heckel.io/ntfy/util"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -102,6 +102,7 @@ func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishO
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
log.Debug("%s Publishing message with headers %s", util.ShortTopicURL(topicURL), req.Header)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -136,6 +137,7 @@ func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, err
|
||||
msgChan := make(chan *Message)
|
||||
errChan := make(chan error)
|
||||
topicURL := c.expandTopicURL(topic)
|
||||
log.Debug("%s Polling from topic", util.ShortTopicURL(topicURL))
|
||||
options = append(options, WithPoll())
|
||||
go func() {
|
||||
err := performSubscribeRequest(ctx, msgChan, topicURL, "", options...)
|
||||
@@ -171,6 +173,7 @@ func (c *Client) Subscribe(topic string, options ...SubscribeOption) string {
|
||||
defer c.mu.Unlock()
|
||||
subscriptionID := util.RandomString(10)
|
||||
topicURL := c.expandTopicURL(topic)
|
||||
log.Debug("%s Subscribing to topic", util.ShortTopicURL(topicURL))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
c.subscriptions[subscriptionID] = &subscription{
|
||||
ID: subscriptionID,
|
||||
@@ -226,11 +229,11 @@ func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicUR
|
||||
// TODO The retry logic is crude and may lose messages. It should record the last message like the
|
||||
// Android client, use since=, and do incremental backoff too
|
||||
if err := performSubscribeRequest(ctx, msgChan, topicURL, subcriptionID, options...); err != nil {
|
||||
log.Printf("Connection to %s failed: %s", topicURL, err.Error())
|
||||
log.Warn("%s Connection failed: %s", util.ShortTopicURL(topicURL), err.Error())
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Printf("Connection to %s exited", topicURL)
|
||||
log.Info("%s Connection exited", util.ShortTopicURL(topicURL))
|
||||
return
|
||||
case <-time.After(10 * time.Second): // TODO Add incremental backoff
|
||||
}
|
||||
@@ -238,7 +241,9 @@ func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicUR
|
||||
}
|
||||
|
||||
func performSubscribeRequest(ctx context.Context, msgChan chan *Message, topicURL string, subscriptionID string, options ...SubscribeOption) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/json", topicURL), nil)
|
||||
streamURL := fmt.Sprintf("%s/json", topicURL)
|
||||
log.Debug("%s Listening to %s", util.ShortTopicURL(topicURL), streamURL)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, streamURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -261,10 +266,12 @@ func performSubscribeRequest(ctx context.Context, msgChan chan *Message, topicUR
|
||||
}
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
for scanner.Scan() {
|
||||
m, err := toMessage(scanner.Text(), topicURL, subscriptionID)
|
||||
messageJSON := scanner.Text()
|
||||
m, err := toMessage(messageJSON, topicURL, subscriptionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Trace("%s Message received: %s", util.ShortTopicURL(topicURL), messageJSON)
|
||||
if m.Event == MessageEvent {
|
||||
msgChan <- m
|
||||
}
|
||||
|
||||
@@ -56,6 +56,12 @@ func WithClick(url string) PublishOption {
|
||||
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
|
||||
func WithAttach(attach string) PublishOption {
|
||||
return WithHeader("X-Attach", attach)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build !noserver
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
@@ -8,12 +10,16 @@ import (
|
||||
"heckel.io/ntfy/util"
|
||||
)
|
||||
|
||||
func init() {
|
||||
commands = append(commands, cmdAccess)
|
||||
}
|
||||
|
||||
const (
|
||||
userEveryone = "everyone"
|
||||
)
|
||||
|
||||
var flagsAccess = append(
|
||||
userCommandFlags(),
|
||||
flagsUser,
|
||||
&cli.BoolFlag{Name: "reset", Aliases: []string{"r"}, Usage: "reset access for user (and topic)"},
|
||||
)
|
||||
|
||||
@@ -22,7 +28,7 @@ var cmdAccess = &cli.Command{
|
||||
Usage: "Grant/revoke access to a topic, or show access",
|
||||
UsageText: "ntfy access [USERNAME [TOPIC [PERMISSION]]]",
|
||||
Flags: flagsAccess,
|
||||
Before: initConfigFileInputSource("config", flagsAccess),
|
||||
Before: initConfigFileInputSourceFunc("config", flagsAccess, initLogFunc),
|
||||
Action: execUserAccess,
|
||||
Category: categoryServer,
|
||||
Description: `Manage the access control list for the ntfy server.
|
||||
|
||||
65
cmd/app.go
@@ -2,23 +2,26 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
"heckel.io/ntfy/util"
|
||||
"heckel.io/ntfy/log"
|
||||
"os"
|
||||
)
|
||||
|
||||
var (
|
||||
defaultClientRootConfigFile = "/etc/ntfy/client.yml"
|
||||
defaultClientUserConfigFile = "~/.config/ntfy/client.yml"
|
||||
)
|
||||
|
||||
const (
|
||||
categoryClient = "Client commands"
|
||||
categoryServer = "Server commands"
|
||||
)
|
||||
|
||||
var commands = make([]*cli.Command, 0)
|
||||
|
||||
var flagsDefault = []cli.Flag{
|
||||
&cli.BoolFlag{Name: "debug", Aliases: []string{"d"}, EnvVars: []string{"NTFY_DEBUG"}, Usage: "enable debug logging"},
|
||||
&cli.BoolFlag{Name: "trace", EnvVars: []string{"NTFY_TRACE"}, Usage: "enable tracing (very verbose, be careful)"},
|
||||
&cli.BoolFlag{Name: "no-log-dates", Aliases: []string{"no_log_dates"}, EnvVars: []string{"NTFY_NO_LOG_DATES"}, Usage: "disable the date/time prefix"},
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "log-level", Aliases: []string{"log_level"}, Value: log.InfoLevel.String(), EnvVars: []string{"NTFY_LOG_LEVEL"}, Usage: "set log level"}),
|
||||
}
|
||||
|
||||
// New creates a new CLI application
|
||||
func New() *cli.App {
|
||||
return &cli.App{
|
||||
@@ -30,42 +33,22 @@ func New() *cli.App {
|
||||
Reader: os.Stdin,
|
||||
Writer: os.Stdout,
|
||||
ErrWriter: os.Stderr,
|
||||
Action: execMainApp,
|
||||
Before: initConfigFileInputSource("config", flagsServe), // DEPRECATED, see deprecation notice
|
||||
Flags: flagsServe, // DEPRECATED, see deprecation notice
|
||||
Commands: []*cli.Command{
|
||||
// Server commands
|
||||
cmdServe,
|
||||
cmdUser,
|
||||
cmdAccess,
|
||||
|
||||
// Client commands
|
||||
cmdPublish,
|
||||
cmdSubscribe,
|
||||
},
|
||||
Commands: commands,
|
||||
Flags: flagsDefault,
|
||||
Before: initLogFunc,
|
||||
}
|
||||
}
|
||||
|
||||
func execMainApp(c *cli.Context) error {
|
||||
fmt.Fprintln(c.App.ErrWriter, "\x1b[1;33mDeprecation notice: Please run the server using 'ntfy serve'; see 'ntfy -h' for help.\x1b[0m")
|
||||
fmt.Fprintln(c.App.ErrWriter, "\x1b[1;33mThis way of running the server will be removed March 2022. See https://ntfy.sh/docs/deprecations/ for details.\x1b[0m")
|
||||
return execServe(c)
|
||||
}
|
||||
|
||||
// initConfigFileInputSource is like altsrc.InitInputSourceWithContext and altsrc.NewYamlSourceFromFlagFunc, but checks
|
||||
// if the config flag is exists and only loads it if it does. If the flag is set and the file exists, it fails.
|
||||
func initConfigFileInputSource(configFlag string, flags []cli.Flag) cli.BeforeFunc {
|
||||
return func(context *cli.Context) error {
|
||||
configFile := context.String(configFlag)
|
||||
if context.IsSet(configFlag) && !util.FileExists(configFile) {
|
||||
return fmt.Errorf("config file %s does not exist", configFile)
|
||||
} else if !context.IsSet(configFlag) && !util.FileExists(configFile) {
|
||||
return nil
|
||||
}
|
||||
inputSource, err := altsrc.NewYamlSourceFromFile(configFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return altsrc.ApplyInputSourceValues(context, inputSource, flags)
|
||||
func initLogFunc(c *cli.Context) error {
|
||||
if c.Bool("trace") {
|
||||
log.SetLevel(log.TraceLevel)
|
||||
} else if c.Bool("debug") {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
} else {
|
||||
log.SetLevel(log.ToLevel(c.String("log-level")))
|
||||
}
|
||||
if c.Bool("no-log-dates") {
|
||||
log.DisableDates()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
60
cmd/config_loader.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
"gopkg.in/yaml.v2"
|
||||
"heckel.io/ntfy/util"
|
||||
"os"
|
||||
)
|
||||
|
||||
// initConfigFileInputSourceFunc is like altsrc.InitInputSourceWithContext and altsrc.NewYamlSourceFromFlagFunc, but checks
|
||||
// if the config flag is exists and only loads it if it does. If the flag is set and the file exists, it fails.
|
||||
func initConfigFileInputSourceFunc(configFlag string, flags []cli.Flag, next cli.BeforeFunc) cli.BeforeFunc {
|
||||
return func(context *cli.Context) error {
|
||||
configFile := context.String(configFlag)
|
||||
if context.IsSet(configFlag) && !util.FileExists(configFile) {
|
||||
return fmt.Errorf("config file %s does not exist", configFile)
|
||||
} else if !context.IsSet(configFlag) && !util.FileExists(configFile) {
|
||||
return nil
|
||||
}
|
||||
inputSource, err := newYamlSourceFromFile(configFile, flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := altsrc.ApplyInputSourceValues(context, inputSource, flags); err != nil {
|
||||
return err
|
||||
}
|
||||
if next != nil {
|
||||
if err := next(context); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// newYamlSourceFromFile creates a new Yaml InputSourceContext from a filepath.
|
||||
//
|
||||
// This function also maps aliases, so a .yml file can contain short options, or options with underscores
|
||||
// instead of dashes. See https://github.com/binwiederhier/ntfy/issues/255.
|
||||
func newYamlSourceFromFile(file string, flags []cli.Flag) (altsrc.InputSourceContext, error) {
|
||||
var rawConfig map[interface{}]interface{}
|
||||
b, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := yaml.Unmarshal(b, &rawConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, f := range flags {
|
||||
flagName := f.Names()[0]
|
||||
for _, flagAlias := range f.Names()[1:] {
|
||||
if _, ok := rawConfig[flagAlias]; ok {
|
||||
rawConfig[flagName] = rawConfig[flagAlias]
|
||||
}
|
||||
}
|
||||
}
|
||||
return altsrc.NewMapInputSource(file, rawConfig), nil
|
||||
}
|
||||
38
cmd/config_loader_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewYamlSourceFromFile(t *testing.T) {
|
||||
filename := filepath.Join(t.TempDir(), "server.yml")
|
||||
contents := `
|
||||
# Normal options
|
||||
listen-https: ":10443"
|
||||
|
||||
# Note the underscore!
|
||||
listen_http: ":1080"
|
||||
|
||||
# OMG this is allowed now ...
|
||||
K: /some/file.pem
|
||||
`
|
||||
require.Nil(t, os.WriteFile(filename, []byte(contents), 0600))
|
||||
|
||||
ctx, err := newYamlSourceFromFile(filename, flagsServe)
|
||||
require.Nil(t, err)
|
||||
|
||||
listenHTTPS, err := ctx.String("listen-https")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, ":10443", listenHTTPS)
|
||||
|
||||
listenHTTP, err := ctx.String("listen-http") // No underscore!
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, ":1080", listenHTTP)
|
||||
|
||||
keyFile, err := ctx.String("key-file") // Long option!
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "/some/file.pem", keyFile)
|
||||
}
|
||||
@@ -12,30 +12,39 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
commands = append(commands, cmdPublish)
|
||||
}
|
||||
|
||||
var flagsPublish = append(
|
||||
flagsDefault,
|
||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG"}, Usage: "client config file"},
|
||||
&cli.StringFlag{Name: "title", Aliases: []string{"t"}, EnvVars: []string{"NTFY_TITLE"}, Usage: "message title"},
|
||||
&cli.StringFlag{Name: "priority", Aliases: []string{"p"}, EnvVars: []string{"NTFY_PRIORITY"}, Usage: "priority of the message (1=min, 2=low, 3=default, 4=high, 5=max)"},
|
||||
&cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, EnvVars: []string{"NTFY_TAGS"}, Usage: "comma separated list of tags and emojis"},
|
||||
&cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, EnvVars: []string{"NTFY_DELAY"}, Usage: "delay/schedule message"},
|
||||
&cli.StringFlag{Name: "click", Aliases: []string{"U"}, EnvVars: []string{"NTFY_CLICK"}, Usage: "URL to open when notification is clicked"},
|
||||
&cli.StringFlag{Name: "actions", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ACTIONS"}, Usage: "actions JSON array or simple definition"},
|
||||
&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"},
|
||||
&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"},
|
||||
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"},
|
||||
&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"},
|
||||
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
|
||||
&cli.BoolFlag{Name: "no-cache", Aliases: []string{"C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"},
|
||||
&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"F"}, EnvVars: []string{"NTFY_NO_FIREBASE"}, Usage: "do not forward message to Firebase"},
|
||||
&cli.BoolFlag{Name: "env-topic", Aliases: []string{"P"}, EnvVars: []string{"NTFY_ENV_TOPIC"}, Usage: "use topic from NTFY_TOPIC env variable"},
|
||||
&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, EnvVars: []string{"NTFY_QUIET"}, Usage: "do not print message"},
|
||||
)
|
||||
|
||||
var cmdPublish = &cli.Command{
|
||||
Name: "publish",
|
||||
Aliases: []string{"pub", "send", "trigger"},
|
||||
Usage: "Send message via a ntfy server",
|
||||
UsageText: "ntfy send [OPTIONS..] TOPIC [MESSAGE]\n NTFY_TOPIC=.. ntfy send [OPTIONS..] -P [MESSAGE]",
|
||||
UsageText: "ntfy publish [OPTIONS..] TOPIC [MESSAGE]\nNTFY_TOPIC=.. ntfy publish [OPTIONS..] -P [MESSAGE]",
|
||||
Action: execPublish,
|
||||
Category: categoryClient,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG"}, Usage: "client config file"},
|
||||
&cli.StringFlag{Name: "title", Aliases: []string{"t"}, EnvVars: []string{"NTFY_TITLE"}, Usage: "message title"},
|
||||
&cli.StringFlag{Name: "priority", Aliases: []string{"p"}, EnvVars: []string{"NTFY_PRIORITY"}, Usage: "priority of the message (1=min, 2=low, 3=default, 4=high, 5=max)"},
|
||||
&cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, EnvVars: []string{"NTFY_TAGS"}, Usage: "comma separated list of tags and emojis"},
|
||||
&cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, EnvVars: []string{"NTFY_DELAY"}, Usage: "delay/schedule message"},
|
||||
&cli.StringFlag{Name: "click", Aliases: []string{"U"}, EnvVars: []string{"NTFY_CLICK"}, Usage: "URL to open when notification is clicked"},
|
||||
&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"},
|
||||
&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"},
|
||||
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"},
|
||||
&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"},
|
||||
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
|
||||
&cli.BoolFlag{Name: "no-cache", Aliases: []string{"C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"},
|
||||
&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"F"}, EnvVars: []string{"NTFY_NO_FIREBASE"}, Usage: "do not forward message to Firebase"},
|
||||
&cli.BoolFlag{Name: "env-topic", Aliases: []string{"P"}, EnvVars: []string{"NTFY_ENV_TOPIC"}, Usage: "use topic from NTFY_TOPIC env variable"},
|
||||
&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, EnvVars: []string{"NTFY_QUIET"}, Usage: "do print message"},
|
||||
},
|
||||
Flags: flagsPublish,
|
||||
Before: initLogFunc,
|
||||
Description: `Publish a message to a ntfy server.
|
||||
|
||||
Examples:
|
||||
@@ -58,8 +67,7 @@ Examples:
|
||||
Please also check out the docs on publishing messages. Especially for the --tags and --delay options,
|
||||
it has incredibly useful information: https://ntfy.sh/docs/publish/.
|
||||
|
||||
The default config file for all client commands is /etc/ntfy/client.yml (if root user),
|
||||
or ~/.config/ntfy/client.yml for all other users.`,
|
||||
` + clientCommandDescriptionSuffix,
|
||||
}
|
||||
|
||||
func execPublish(c *cli.Context) error {
|
||||
@@ -72,6 +80,7 @@ func execPublish(c *cli.Context) error {
|
||||
tags := c.String("tags")
|
||||
delay := c.String("delay")
|
||||
click := c.String("click")
|
||||
actions := c.String("actions")
|
||||
attach := c.String("attach")
|
||||
filename := c.String("filename")
|
||||
file := c.String("file")
|
||||
@@ -112,6 +121,9 @@ func execPublish(c *cli.Context) error {
|
||||
if click != "" {
|
||||
options = append(options, client.WithClick(click))
|
||||
}
|
||||
if actions != "" {
|
||||
options = append(options, client.WithActions(strings.ReplaceAll(actions, "\n", " ")))
|
||||
}
|
||||
if attach != "" {
|
||||
options = append(options, client.WithAttach(attach))
|
||||
}
|
||||
|
||||
157
cmd/serve.go
@@ -1,58 +1,74 @@
|
||||
//go:build !noserver
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/log"
|
||||
"math"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
"heckel.io/ntfy/server"
|
||||
"heckel.io/ntfy/util"
|
||||
"log"
|
||||
"math"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var flagsServe = []cli.Flag{
|
||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/server.yml", DefaultText: "/etc/ntfy/server.yml", Usage: "config file"},
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used to as HTTP listen address"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used to as HTTPS listen address"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-unix", Aliases: []string{"U"}, EnvVars: []string{"NTFY_LISTEN_UNIX"}, Usage: "listen on unix socket path"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "key-file", Aliases: []string{"K"}, EnvVars: []string{"NTFY_KEY_FILE"}, Usage: "private key file, if listen-https is set"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: server.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, DefaultText: "5G", Usage: "limit of the on-disk attachment cache"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, DefaultText: "15M", Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "attachment-expiry-duration", Aliases: []string{"X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: server.DefaultAttachmentExpiryDuration, DefaultText: "3h", Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "app", Usage: "sets web root to landing page (home) or web app (app)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-from", EnvVars: []string{"NTFY_SMTP_SENDER_FROM"}, Usage: "SMTP sender address (if e-mail sending is enabled)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload bandwidth limit per visitor"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
|
||||
func init() {
|
||||
commands = append(commands, cmdServe)
|
||||
}
|
||||
|
||||
const (
|
||||
defaultServerConfigFile = "/etc/ntfy/server.yml"
|
||||
)
|
||||
|
||||
var flagsServe = append(
|
||||
flagsDefault,
|
||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, Usage: "config file"},
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used to as HTTP listen address"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"listen_https", "L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used to as HTTPS listen address"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-unix", Aliases: []string{"listen_unix", "U"}, EnvVars: []string{"NTFY_LISTEN_UNIX"}, Usage: "listen on unix socket path"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "key-file", Aliases: []string{"key_file", "K"}, EnvVars: []string{"NTFY_KEY_FILE"}, Usage: "private key file, if listen-https is set"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"cert_file", "E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"firebase_key_file", "F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"cache_file", "C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"cache_duration", "b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: server.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, DefaultText: "5G", Usage: "limit of the on-disk attachment cache"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, DefaultText: "15M", Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: server.DefaultAttachmentExpiryDuration, DefaultText: "3h", Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", Aliases: []string{"web_root"}, EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "app", Usage: "sets web root to landing page (home), web app (app) or disabled (disable)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-base-url", Aliases: []string{"upstream_base_url"}, EnvVars: []string{"NTFY_UPSTREAM_BASE_URL"}, Value: "", Usage: "forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", Aliases: []string{"smtp_sender_addr"}, EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", Aliases: []string{"smtp_sender_user"}, EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", Aliases: []string{"smtp_sender_pass"}, EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-from", Aliases: []string{"smtp_sender_from"}, EnvVars: []string{"NTFY_SMTP_SENDER_FROM"}, Usage: "SMTP sender address (if e-mail sending is enabled)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", Aliases: []string{"smtp_server_listen"}, EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", Aliases: []string{"smtp_server_domain"}, EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", Aliases: []string{"smtp_server_addr_prefix"}, EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", Aliases: []string{"visitor_attachment_daily_bandwidth_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload bandwidth limit per visitor"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", Aliases: []string{"visitor_request_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", Aliases: []string{"visitor_request_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", Aliases: []string{"visitor_request_limit_exempt_hosts"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
|
||||
)
|
||||
|
||||
var cmdServe = &cli.Command{
|
||||
Name: "serve",
|
||||
Usage: "Run the ntfy server",
|
||||
@@ -60,7 +76,7 @@ var cmdServe = &cli.Command{
|
||||
Action: execServe,
|
||||
Category: categoryServer,
|
||||
Flags: flagsServe,
|
||||
Before: initConfigFileInputSource("config", flagsServe),
|
||||
Before: initConfigFileInputSourceFunc("config", flagsServe, initLogFunc),
|
||||
Description: `Run the ntfy server and listen for incoming requests
|
||||
|
||||
The command will load the configuration from /etc/ntfy/server.yml. Config options can
|
||||
@@ -77,6 +93,7 @@ func execServe(c *cli.Context) error {
|
||||
}
|
||||
|
||||
// Read all the options
|
||||
config := c.String("config")
|
||||
baseURL := c.String("base-url")
|
||||
listenHTTP := c.String("listen-http")
|
||||
listenHTTPS := c.String("listen-https")
|
||||
@@ -95,6 +112,7 @@ func execServe(c *cli.Context) error {
|
||||
keepaliveInterval := c.Duration("keepalive-interval")
|
||||
managerInterval := c.Duration("manager-interval")
|
||||
webRoot := c.String("web-root")
|
||||
upstreamBaseURL := c.String("upstream-base-url")
|
||||
smtpSenderAddr := c.String("smtp-sender-addr")
|
||||
smtpSenderUser := c.String("smtp-sender-user")
|
||||
smtpSenderPass := c.String("smtp-sender-pass")
|
||||
@@ -138,12 +156,18 @@ func execServe(c *cli.Context) error {
|
||||
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) {
|
||||
} else if !util.InStringList([]string{"app", "home", "disable"}, webRoot) {
|
||||
return errors.New("if set, web-root must be 'home' or 'app'")
|
||||
} else if upstreamBaseURL != "" && !strings.HasPrefix(upstreamBaseURL, "http://") && !strings.HasPrefix(upstreamBaseURL, "https://") {
|
||||
return errors.New("if set, upstream-base-url must start with http:// or https://")
|
||||
} else if upstreamBaseURL != "" && baseURL == "" {
|
||||
return errors.New("if upstream-base-url is set, base-url must also be set")
|
||||
}
|
||||
|
||||
// Default auth permissions
|
||||
webRootIsApp := webRoot == "app"
|
||||
enableWeb := webRoot != "disable"
|
||||
|
||||
// Default auth permissions
|
||||
authDefaultRead := authDefaultAccess == "read-write" || authDefaultAccess == "read-only"
|
||||
authDefaultWrite := authDefaultAccess == "read-write" || authDefaultAccess == "write-only"
|
||||
|
||||
@@ -177,7 +201,7 @@ func execServe(c *cli.Context) error {
|
||||
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())
|
||||
log.Warn("cannot resolve host %s: %s, ignoring visitor request exemption", host, err.Error())
|
||||
continue
|
||||
}
|
||||
for _, ip := range ips {
|
||||
@@ -206,6 +230,7 @@ func execServe(c *cli.Context) error {
|
||||
conf.KeepaliveInterval = keepaliveInterval
|
||||
conf.ManagerInterval = managerInterval
|
||||
conf.WebRootIsApp = webRootIsApp
|
||||
conf.UpstreamBaseURL = upstreamBaseURL
|
||||
conf.SMTPSenderAddr = smtpSenderAddr
|
||||
conf.SMTPSenderUser = smtpSenderUser
|
||||
conf.SMTPSenderPass = smtpSenderPass
|
||||
@@ -223,14 +248,19 @@ func execServe(c *cli.Context) error {
|
||||
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
|
||||
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
|
||||
conf.BehindProxy = behindProxy
|
||||
conf.EnableWeb = enableWeb
|
||||
|
||||
// Set up hot-reloading of config
|
||||
go sigHandlerConfigReload(config)
|
||||
|
||||
// Run server
|
||||
s, err := server.New(conf)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
log.Fatal(err)
|
||||
} else if err := s.Run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := s.Run(); err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
log.Printf("Exiting.")
|
||||
log.Info("Exiting.")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -244,3 +274,28 @@ func parseSize(s string, defaultValue int64) (v int64, err error) {
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func sigHandlerConfigReload(config string) {
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, syscall.SIGHUP)
|
||||
for range sigs {
|
||||
log.Info("Partially hot reloading configuration ...")
|
||||
inputSource, err := newYamlSourceFromFile(config, flagsServe)
|
||||
if err != nil {
|
||||
log.Warn("Hot reload failed: %s", err.Error())
|
||||
continue
|
||||
}
|
||||
reloadLogLevel(inputSource)
|
||||
}
|
||||
}
|
||||
|
||||
func reloadLogLevel(inputSource altsrc.InputSourceContext) {
|
||||
newLevelStr, err := inputSource.String("log-level")
|
||||
if err != nil {
|
||||
log.Warn("Cannot load log level: %s", err.Error())
|
||||
return
|
||||
}
|
||||
newLevel := log.ToLevel(newLevelStr)
|
||||
log.SetLevel(newLevel)
|
||||
log.Info("Log level is %s", newLevel.String())
|
||||
}
|
||||
|
||||
122
cmd/subscribe.go
@@ -5,14 +5,36 @@ import (
|
||||
"fmt"
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/client"
|
||||
"heckel.io/ntfy/log"
|
||||
"heckel.io/ntfy/util"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
commands = append(commands, cmdSubscribe)
|
||||
}
|
||||
|
||||
const (
|
||||
clientRootConfigFileUnixAbsolute = "/etc/ntfy/client.yml"
|
||||
clientUserConfigFileUnixRelative = "ntfy/client.yml"
|
||||
clientUserConfigFileWindowsRelative = "ntfy\\client.yml"
|
||||
)
|
||||
|
||||
var flagsSubscribe = append(
|
||||
flagsDefault,
|
||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
|
||||
&cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"},
|
||||
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, Usage: "username[:password] used to auth against the server"},
|
||||
&cli.BoolFlag{Name: "from-config", Aliases: []string{"C"}, Usage: "read subscriptions from config file (service mode)"},
|
||||
&cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"},
|
||||
&cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"},
|
||||
)
|
||||
|
||||
var cmdSubscribe = &cli.Command{
|
||||
Name: "subscribe",
|
||||
Aliases: []string{"sub"},
|
||||
@@ -20,15 +42,8 @@ var cmdSubscribe = &cli.Command{
|
||||
UsageText: "ntfy subscribe [OPTIONS..] [TOPIC]",
|
||||
Action: execSubscribe,
|
||||
Category: categoryClient,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
|
||||
&cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"},
|
||||
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, Usage: "username[:password] used to auth against the server"},
|
||||
&cli.BoolFlag{Name: "from-config", Aliases: []string{"C"}, Usage: "read subscriptions from config file (service mode)"},
|
||||
&cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"},
|
||||
&cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"},
|
||||
&cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}, Usage: "print verbose output"},
|
||||
},
|
||||
Flags: flagsSubscribe,
|
||||
Before: initLogFunc,
|
||||
Description: `Subscribe to a topic from a ntfy server, and either print or execute a command for
|
||||
every arriving message. There are 3 modes in which the command can be run:
|
||||
|
||||
@@ -60,19 +75,17 @@ ntfy subscribe TOPIC COMMAND
|
||||
|
||||
Examples:
|
||||
ntfy sub mytopic 'notify-send "$m"' # Execute command for incoming messages
|
||||
ntfy sub topic1 /my/script.sh # Execute script for incoming messages
|
||||
ntfy sub topic1 myscript.sh # Execute script for incoming messages
|
||||
|
||||
ntfy subscribe --from-config
|
||||
Service mode (used in ntfy-client.service). This reads the config file (/etc/ntfy/client.yml
|
||||
or ~/.config/ntfy/client.yml) and sets up subscriptions for every topic in the "subscribe:"
|
||||
block (see config file).
|
||||
Service mode (used in ntfy-client.service). This reads the config file and sets up
|
||||
subscriptions for every topic in the "subscribe:" block (see config file).
|
||||
|
||||
Examples:
|
||||
ntfy sub --from-config # Read topics from config file
|
||||
ntfy sub --config=/my/client.yml --from-config # Read topics from alternate config file
|
||||
ntfy sub --config=myclient.yml --from-config # Read topics from alternate config file
|
||||
|
||||
The default config file for all client commands is /etc/ntfy/client.yml (if root user),
|
||||
or ~/.config/ntfy/client.yml for all other users.`,
|
||||
` + clientCommandDescriptionSuffix,
|
||||
}
|
||||
|
||||
func execSubscribe(c *cli.Context) error {
|
||||
@@ -156,8 +169,8 @@ func doPollSingle(c *cli.Context, cl *client.Client, topic, command string, opti
|
||||
}
|
||||
|
||||
func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error {
|
||||
commands := make(map[string]string) // Subscription ID -> command
|
||||
for _, s := range conf.Subscribe { // May be nil
|
||||
cmds := make(map[string]string) // Subscription ID -> command
|
||||
for _, s := range conf.Subscribe { // May be nil
|
||||
topicOptions := append(make([]client.SubscribeOption, 0), options...)
|
||||
for filter, value := range s.If {
|
||||
topicOptions = append(topicOptions, client.WithFilter(filter, value))
|
||||
@@ -166,18 +179,19 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic,
|
||||
topicOptions = append(topicOptions, client.WithBasicAuth(s.User, s.Password))
|
||||
}
|
||||
subscriptionID := cl.Subscribe(s.Topic, topicOptions...)
|
||||
commands[subscriptionID] = s.Command
|
||||
cmds[subscriptionID] = s.Command
|
||||
}
|
||||
if topic != "" {
|
||||
subscriptionID := cl.Subscribe(topic, options...)
|
||||
commands[subscriptionID] = command
|
||||
cmds[subscriptionID] = command
|
||||
}
|
||||
for m := range cl.Messages {
|
||||
command, ok := commands[m.SubscriptionID]
|
||||
cmd, ok := cmds[m.SubscriptionID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
printMessageOrRunCommand(c, m, command)
|
||||
log.Debug("%s Dispatching received message: %s", logMessagePrefix(m), m.Raw)
|
||||
printMessageOrRunCommand(c, m, cmd)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -186,27 +200,27 @@ func printMessageOrRunCommand(c *cli.Context, m *client.Message, command string)
|
||||
if command != "" {
|
||||
runCommand(c, command, m)
|
||||
} else {
|
||||
log.Debug("%s Printing raw message", logMessagePrefix(m))
|
||||
fmt.Fprintln(c.App.Writer, m.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func runCommand(c *cli.Context, command string, m *client.Message) {
|
||||
if err := runCommandInternal(c, command, m); err != nil {
|
||||
fmt.Fprintf(c.App.ErrWriter, "Command failed: %s\n", err.Error())
|
||||
log.Warn("%s Command failed: %s", logMessagePrefix(m), err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func runCommandInternal(c *cli.Context, command string, m *client.Message) error {
|
||||
scriptFile, err := createTmpScript(command)
|
||||
if err != nil {
|
||||
func runCommandInternal(c *cli.Context, script string, m *client.Message) error {
|
||||
scriptFile := fmt.Sprintf("%s/ntfy-subscribe-%s.%s", os.TempDir(), util.RandomString(10), scriptExt)
|
||||
log.Debug("%s Running command '%s' via temporary script %s", logMessagePrefix(m), script, scriptFile)
|
||||
script = scriptHeader + script
|
||||
if err := os.WriteFile(scriptFile, []byte(script), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(scriptFile)
|
||||
verbose := c.Bool("verbose")
|
||||
if verbose {
|
||||
log.Printf("[%s] Executing: %s (for message: %s)", util.ShortTopicURL(m.TopicURL), command, m.Raw)
|
||||
}
|
||||
cmd := exec.Command("sh", "-c", scriptFile)
|
||||
log.Debug("%s Executing script %s", logMessagePrefix(m), scriptFile)
|
||||
cmd := exec.Command(scriptLauncher[0], append(scriptLauncher[1:], scriptFile)...)
|
||||
cmd.Stdin = c.App.Reader
|
||||
cmd.Stdout = c.App.Writer
|
||||
cmd.Stderr = c.App.ErrWriter
|
||||
@@ -214,17 +228,8 @@ func runCommandInternal(c *cli.Context, command string, m *client.Message) error
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func createTmpScript(command string) (string, error) {
|
||||
scriptFile := fmt.Sprintf("%s/ntfy-subscribe-%s.sh.tmp", os.TempDir(), util.RandomString(10))
|
||||
script := fmt.Sprintf("#!/bin/sh\n%s", command)
|
||||
if err := os.WriteFile(scriptFile, []byte(script), 0700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return scriptFile, nil
|
||||
}
|
||||
|
||||
func envVars(m *client.Message) []string {
|
||||
env := os.Environ()
|
||||
env := make([]string, 0)
|
||||
env = append(env, envVar(m.ID, "NTFY_ID", "id")...)
|
||||
env = append(env, envVar(m.Topic, "NTFY_TOPIC", "topic")...)
|
||||
env = append(env, envVar(fmt.Sprintf("%d", m.Time), "NTFY_TIME", "time")...)
|
||||
@@ -233,7 +238,11 @@ func envVars(m *client.Message) []string {
|
||||
env = append(env, envVar(fmt.Sprintf("%d", m.Priority), "NTFY_PRIORITY", "priority", "prio", "p")...)
|
||||
env = append(env, envVar(strings.Join(m.Tags, ","), "NTFY_TAGS", "tags", "tag", "ta")...)
|
||||
env = append(env, envVar(m.Raw, "NTFY_RAW", "raw")...)
|
||||
return env
|
||||
sort.Strings(env)
|
||||
if log.IsTrace() {
|
||||
log.Trace("%s With environment:\n%s", logMessagePrefix(m), strings.Join(env, "\n"))
|
||||
}
|
||||
return append(os.Environ(), env...)
|
||||
}
|
||||
|
||||
func envVar(value string, vars ...string) []string {
|
||||
@@ -249,13 +258,30 @@ func loadConfig(c *cli.Context) (*client.Config, error) {
|
||||
if filename != "" {
|
||||
return client.LoadConfig(filename)
|
||||
}
|
||||
u, _ := user.Current()
|
||||
configFile := defaultClientRootConfigFile
|
||||
if u.Uid != "0" {
|
||||
configFile = util.ExpandHome(defaultClientUserConfigFile)
|
||||
}
|
||||
configFile := defaultClientConfigFile()
|
||||
if s, _ := os.Stat(configFile); s != nil {
|
||||
return client.LoadConfig(configFile)
|
||||
}
|
||||
return client.NewConfig(), nil
|
||||
}
|
||||
|
||||
//lint:ignore U1000 Conditionally used in different builds
|
||||
func defaultClientConfigFileUnix() string {
|
||||
u, _ := user.Current()
|
||||
configFile := clientRootConfigFileUnixAbsolute
|
||||
if u.Uid != "0" {
|
||||
homeDir, _ := os.UserConfigDir()
|
||||
return filepath.Join(homeDir, clientUserConfigFileUnixRelative)
|
||||
}
|
||||
return configFile
|
||||
}
|
||||
|
||||
//lint:ignore U1000 Conditionally used in different builds
|
||||
func defaultClientConfigFileWindows() string {
|
||||
homeDir, _ := os.UserConfigDir()
|
||||
return filepath.Join(homeDir, clientUserConfigFileWindowsRelative)
|
||||
}
|
||||
|
||||
func logMessagePrefix(m *client.Message) string {
|
||||
return fmt.Sprintf("%s/%s", util.ShortTopicURL(m.TopicURL), m.ID)
|
||||
}
|
||||
|
||||
16
cmd/subscribe_darwin.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package cmd
|
||||
|
||||
const (
|
||||
scriptExt = "sh"
|
||||
scriptHeader = "#!/bin/sh\n"
|
||||
clientCommandDescriptionSuffix = `The default config file for all client commands is /etc/ntfy/client.yml (if root user),
|
||||
or "~/Library/Application Support/ntfy/client.yml" for all other users.`
|
||||
)
|
||||
|
||||
var (
|
||||
scriptLauncher = []string{"sh", "-c"}
|
||||
)
|
||||
|
||||
func defaultClientConfigFile() string {
|
||||
return defaultClientConfigFileUnix()
|
||||
}
|
||||
16
cmd/subscribe_linux.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package cmd
|
||||
|
||||
const (
|
||||
scriptExt = "sh"
|
||||
scriptHeader = "#!/bin/sh\n"
|
||||
clientCommandDescriptionSuffix = `The default config file for all client commands is /etc/ntfy/client.yml (if root user),
|
||||
or ~/.config/ntfy/client.yml for all other users.`
|
||||
)
|
||||
|
||||
var (
|
||||
scriptLauncher = []string{"sh", "-c"}
|
||||
)
|
||||
|
||||
func defaultClientConfigFile() string {
|
||||
return defaultClientConfigFileUnix()
|
||||
}
|
||||
15
cmd/subscribe_windows.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package cmd
|
||||
|
||||
const (
|
||||
scriptExt = "bat"
|
||||
scriptHeader = ""
|
||||
clientCommandDescriptionSuffix = `The default config file for all client commands is %AppData%\ntfy\client.yml.`
|
||||
)
|
||||
|
||||
var (
|
||||
scriptLauncher = []string{"cmd.exe", "/Q", "/C"}
|
||||
)
|
||||
|
||||
func defaultClientConfigFile() string {
|
||||
return defaultClientConfigFileWindows()
|
||||
}
|
||||
40
cmd/user.go
@@ -1,3 +1,5 @@
|
||||
//go:build !noserver
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
@@ -11,13 +13,23 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
var flagsUser = userCommandFlags()
|
||||
func init() {
|
||||
commands = append(commands, cmdUser)
|
||||
}
|
||||
|
||||
var flagsUser = append(
|
||||
flagsDefault,
|
||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/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"}),
|
||||
)
|
||||
|
||||
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),
|
||||
Before: initConfigFileInputSourceFunc("config", flagsUser, initLogFunc),
|
||||
Category: categoryServer,
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
@@ -25,7 +37,6 @@ var cmdUser = &cli.Command{
|
||||
Aliases: []string{"a"},
|
||||
Usage: "Adds a new user",
|
||||
UsageText: "ntfy user add [--role=admin|user] USERNAME",
|
||||
Before: inheritRootReaderFunc,
|
||||
Action: execUserAdd,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "role", Aliases: []string{"r"}, Value: string(auth.RoleUser), Usage: "user role"},
|
||||
@@ -46,7 +57,6 @@ Examples:
|
||||
Aliases: []string{"del", "rm"},
|
||||
Usage: "Removes a user",
|
||||
UsageText: "ntfy user remove USERNAME",
|
||||
Before: inheritRootReaderFunc,
|
||||
Action: execUserDel,
|
||||
Description: `Remove a user from the ntfy user database.
|
||||
|
||||
@@ -59,7 +69,6 @@ Example:
|
||||
Aliases: []string{"chp"},
|
||||
Usage: "Changes a user's password",
|
||||
UsageText: "ntfy user change-pass USERNAME",
|
||||
Before: inheritRootReaderFunc,
|
||||
Action: execUserChangePass,
|
||||
Description: `Change the password for the given user.
|
||||
|
||||
@@ -75,7 +84,6 @@ Example:
|
||||
Aliases: []string{"chr"},
|
||||
Usage: "Changes the role of a user",
|
||||
UsageText: "ntfy user change-role USERNAME ROLE",
|
||||
Before: inheritRootReaderFunc,
|
||||
Action: execUserChangeRole,
|
||||
Description: `Change the role for the given user to admin or user.
|
||||
|
||||
@@ -97,7 +105,6 @@ Example:
|
||||
Name: "list",
|
||||
Aliases: []string{"l"},
|
||||
Usage: "Shows a list of users",
|
||||
Before: inheritRootReaderFunc,
|
||||
Action: execUserList,
|
||||
Description: `Shows a list of all configured users, including the everyone ('*') user.
|
||||
|
||||
@@ -267,22 +274,3 @@ func readPasswordAndConfirm(c *cli.Context) (string, error) {
|
||||
}
|
||||
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"}),
|
||||
}
|
||||
}
|
||||
|
||||
// inheritRootReaderFunc is a workaround for a urfave/cli bug that makes subcommands not inherit the App.Reader.
|
||||
// This bug was fixed in master, but not in v2.3.0.
|
||||
func inheritRootReaderFunc(ctx *cli.Context) error {
|
||||
for _, c := range ctx.Lineage() {
|
||||
if c.App != nil && c.App.Reader != nil {
|
||||
ctx.App.Reader = c.App.Reader
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
15
docker-compose.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
version: "2.1"
|
||||
services:
|
||||
ntfy:
|
||||
image: binwiederhier/ntfy
|
||||
container_name: ntfy
|
||||
command:
|
||||
- serve
|
||||
user: UID:GID # optional. Set custom user/group or uid/gid
|
||||
volumes:
|
||||
- /var/cache/ntfy:/var/cache/ntfy
|
||||
- /etc/ntfy:/etc/ntfy
|
||||
ports:
|
||||
- 80:80
|
||||
restart: unless-stopped
|
||||
|
||||
330
docs/config.md
@@ -227,7 +227,7 @@ The easiest way to configure a private instance is to set `auth-default-access`
|
||||
|
||||
=== "/etc/ntfy/server.yml"
|
||||
``` yaml
|
||||
auth-file "/var/lib/ntfy/user.db"
|
||||
auth-file: "/var/lib/ntfy/user.db"
|
||||
auth-default-access: "deny-all"
|
||||
```
|
||||
|
||||
@@ -346,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`:
|
||||
|
||||
* `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,
|
||||
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).
|
||||
@@ -369,6 +369,42 @@ configured (in [Amazon Route 53](https://aws.amazon.com/route53/)):
|
||||
<figcaption>DNS records for incoming mail</figcaption>
|
||||
</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.)
|
||||
!!! warning
|
||||
If you are running ntfy behind a proxy, you must set the `behind-proxy` flag. Otherwise, all visitors are
|
||||
@@ -399,8 +435,10 @@ HTTP challenge. I've found [this guide](https://nandovieira.com/using-lets-encry
|
||||
be incredibly helpful.
|
||||
|
||||
### nginx/Apache2/caddy
|
||||
For your convenience, here's a working config that'll help configure things behind a proxy. 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
|
||||
For your convenience, here's a working config that'll help configure things behind a proxy. Be sure to **enable WebSockets**
|
||||
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:
|
||||
|
||||
=== "nginx (/etc/nginx/sites-*/ntfy)"
|
||||
@@ -481,19 +519,27 @@ or the root domain:
|
||||
```
|
||||
<VirtualHost *:80>
|
||||
ServerName ntfy.sh
|
||||
|
||||
SetEnv proxy-nokeepalive 1
|
||||
SetEnv proxy-sendchunked 1
|
||||
|
||||
|
||||
# Proxy connections to ntfy (requires "a2enmod proxy")
|
||||
ProxyPass / 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
|
||||
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
|
||||
# it to work with curl without the annoying https:// prefix
|
||||
RewriteEngine on
|
||||
RewriteCond %{REQUEST_METHOD} GET
|
||||
RewriteRule ^/([-_A-Za-z0-9]{0,64})$ https://%{SERVER_NAME}/$1 [R,L]
|
||||
</VirtualHost>
|
||||
@@ -505,21 +551,24 @@ or the root domain:
|
||||
SSLCertificateFile /etc/letsencrypt/live/ntfy.sh/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/ntfy.sh/privkey.pem
|
||||
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||
|
||||
SetEnv proxy-nokeepalive 1
|
||||
SetEnv proxy-sendchunked 1
|
||||
|
||||
|
||||
# Proxy connections to ntfy (requires "a2enmod proxy")
|
||||
ProxyPass / 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
|
||||
LimitRequestBody 102400
|
||||
|
||||
# Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
|
||||
# it to work with curl without the annoying https:// prefix
|
||||
|
||||
# Enable mod_rewrite (requires "a2enmod rewrite")
|
||||
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>
|
||||
```
|
||||
|
||||
@@ -530,6 +579,15 @@ or the root domain:
|
||||
|
||||
ntfy.sh, http://nfty.sh {
|
||||
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}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -560,6 +618,43 @@ Example:
|
||||
firebase-key-file: "/etc/ntfy/ntfy-sh-firebase-adminsdk-ahnce-9f4d6f14b5.json"
|
||||
```
|
||||
|
||||
## iOS instant notifications
|
||||
Unlike Android, iOS heavily restricts background processing, which sadly makes it impossible to implement instant
|
||||
push notifications without a central server.
|
||||
|
||||
To still support instant notifications on iOS through your self-hosted ntfy server, you have to forward so called `poll_request`
|
||||
messages to the main ntfy.sh server (or any upstream server that's APNS/Firebase connected, if you build your own iOS app),
|
||||
which will then forward it to Firebase/APNS.
|
||||
|
||||
To configure it, simply set `upstream-base-url` like so:
|
||||
|
||||
``` yaml
|
||||
upstream-base-url: "https://ntfy.sh"
|
||||
```
|
||||
|
||||
If set, all incoming messages will publish a poll request to the configured upstream server, containing
|
||||
the message ID of the original message, instructing the iOS app to poll this server for the actual message contents.
|
||||
|
||||
If `upstream-base-url` is not set, notifications will still eventually get to your device, but delivery can take hours,
|
||||
depending on the state of the phone. If you are using your phone, it shouldn't take more than 20-30 minutes though.
|
||||
|
||||
In case you're curious, here's an example of the entire flow:
|
||||
|
||||
- In the iOS app, you subscribe to `https://ntfy.example.com/mytopic`
|
||||
- The app subscribes to the Firebase topic `6de73be8dfb7d69e...` (the SHA256 of the topic URL)
|
||||
- When you publish a message to `https://ntfy.example.com/mytopic`, your ntfy server will publish a
|
||||
poll request to `https://ntfy.sh/6de73be8dfb7d69e...`. The request from your server to the upstream server
|
||||
contains only the message ID (in the `X-Poll-ID` header), and the SHA256 checksum of the topic URL (as upstream topic).
|
||||
- The ntfy.sh server publishes the poll request message to Firebase, which forwards it to APNS, which forwards it to your iOS device
|
||||
- Your iOS device receives the poll request, and fetches the actual message from your server, and then displays it
|
||||
|
||||
Here's an example of what the self-hosted server forwards to the upstream server. The request is equivalent to this curl:
|
||||
|
||||
```
|
||||
curl -X POST -H "X-Poll-ID: s4PdJozxM8na" https://ntfy.sh/6de73be8dfb7d69e32fb2c00c23fe7adbd8b5504406e3068c273aa24cef4055b
|
||||
{"id":"4HsClFEuCIcs","time":1654087955,"event":"poll_request","topic":"6de73be8dfb7d69e32fb2c00c23fe7adbd8b5504406e3068c273aa24cef4055b","message":"New message","poll_id":"s4PdJozxM8na"}
|
||||
```
|
||||
|
||||
## Rate limiting
|
||||
!!! info
|
||||
Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag.
|
||||
@@ -613,6 +708,23 @@ are enabled):
|
||||
* `visitor-email-limit-burst` is the initial bucket of emails each visitor has. This defaults to 16.
|
||||
* `visitor-email-limit-replenish` is the rate at which the bucket is refilled (one email per x). Defaults to 1h.
|
||||
|
||||
### Firebase limits
|
||||
If [Firebase is configured](#firebase-fcm), all messages are also published to a Firebase topic (unless `Firebase: no`
|
||||
is set). Firebase enforces [its own limits](https://firebase.google.com/docs/cloud-messaging/concept-options#topics_throttling)
|
||||
on how many messages can be published. Unfortunately these limits are a little vague and can change depending on the time
|
||||
of day. In practice, I have only ever observed `429 Quota exceeded` responses from Firebase if **too many messages are published to
|
||||
the same topic**.
|
||||
|
||||
In ntfy, if Firebase responds with a 429 after publishing to a topic, the visitor (= IP address) who published the message
|
||||
is **banned from publishing to Firebase for 10 minutes** (not configurable). Because publishing to Firebase happens asynchronously,
|
||||
there is no indication of the user that this has happened. Non-Firebase subscribers (WebSocket or HTTP stream) are not affected.
|
||||
After the 10 minutes are up, messages forwarding to Firebase is resumed for this visitor.
|
||||
|
||||
If this ever happens, there will be a log message that looks something like this:
|
||||
```
|
||||
WARN Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor
|
||||
```
|
||||
|
||||
## Tuning for scale
|
||||
If you're running ntfy for your home server, you probably don't need to worry about scale at all. In its default config,
|
||||
if it's not behind a proxy, the ntfy server can keep about **as many connections as the open file limit allows**.
|
||||
@@ -712,48 +824,74 @@ and [here](https://easyengine.io/tutorials/nginx/block-wp-login-php-bruteforce-a
|
||||
maxretry = 10
|
||||
```
|
||||
|
||||
## Debugging/tracing
|
||||
If something's not working right, you can debug/trace through what the ntfy server is doing by setting the `log-level`
|
||||
to `DEBUG` or `TRACE`. The `DEBUG` setting will output information about each published message, but not the message
|
||||
contents. The `TRACE` setting will also print the message contents.
|
||||
|
||||
!!! warning
|
||||
Both options are very verbose and should only be enabled in production for short periods of time. Otherwise,
|
||||
you're going to run out of disk space pretty quickly.
|
||||
|
||||
You can also hot-reload the `log-level` by sending the `SIGHUP` signal to the process after editing the `server.yml` file.
|
||||
You can do so by calling `systemctl reload ntfy` (if ntfy is running inside systemd), or by calling `kill -HUP $(pidof ntfy)`.
|
||||
If successful, you'll see something like this:
|
||||
|
||||
```
|
||||
$ ntfy serve
|
||||
2022/06/02 10:29:28 INFO Listening on :2586[http] :1025[smtp], log level is INFO
|
||||
2022/06/02 10:29:34 INFO Partially hot reloading configuration ...
|
||||
2022/06/02 10:29:34 INFO Log level is TRACE
|
||||
```
|
||||
|
||||
## Config options
|
||||
Each config option can be set in the config file `/etc/ntfy/server.yml` (e.g. `listen-http: :80`) or as a
|
||||
CLI option (e.g. `--listen-http :80`. Here's a list of all available options. Alternatively, you can set an environment
|
||||
variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
||||
|
||||
| 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`) |
|
||||
| `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-unix` | `NTFY_LISTEN_UNIX` | *filename* | - | Path to a Unix socket to listen on |
|
||||
| `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. |
|
||||
| `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. |
|
||||
| `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM](#firebase-fcm). |
|
||||
| `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache). |
|
||||
| `cache-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. |
|
||||
| `auth-file` | `NTFY_AUTH_FILE` | *filename* | - | Auth database file used for access control. If set, enables authentication and access control. See [access control](#access-control). |
|
||||
| `auth-default-access` | `NTFY_AUTH_DEFAULT_ACCESS` | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write` | Default permissions if no matching entries in the auth database are found. Default is `read-write`. |
|
||||
| `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-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory* | - | Cache directory for attached files. To enable attachments, this has to be set. |
|
||||
| `attachment-total-size-limit` | `NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 5G | Limit of the on-disk attachment cache directory. If the limits is exceeded, new attachments will be rejected. |
|
||||
| `attachment-file-size-limit` | `NTFY_ATTACHMENT_FILE_SIZE_LIMIT` | *size* | 15M | Per-file attachment size limit (e.g. 300k, 2M, 100M). Larger attachment will be rejected. |
|
||||
| `attachment-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-addr` | `NTFY_SMTP_SENDER_ADDR` | `host:port` | - | SMTP server address to allow email sending |
|
||||
| `smtp-sender-user` | `NTFY_SMTP_SENDER_USER` | *string* | - | SMTP user; only used if e-mail sending is enabled |
|
||||
| `smtp-sender-pass` | `NTFY_SMTP_SENDER_PASS` | *string* | - | SMTP password; only used if e-mail sending is enabled |
|
||||
| `smtp-sender-from` | `NTFY_SMTP_SENDER_FROM` | *e-mail address* | - | SMTP sender e-mail address; only used if e-mail sending is enabled |
|
||||
| `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-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` |
|
||||
| `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | `[ip]:port` | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` |
|
||||
| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 45s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. |
|
||||
| `manager-interval` | `$NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. |
|
||||
| `web-root` | `NTFY_WEB_ROOT` | `app` or `home` | `app` | Sets web root to landing page (home) or web app (app) |
|
||||
| `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. |
|
||||
| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
|
||||
| `visitor-attachment-total-size-limit` | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 100M | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`. |
|
||||
| `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size* | 500M | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding. |
|
||||
| `visitor-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 |
|
||||
!!! info
|
||||
All config options can also be defined in the `server.yml` file using underscores instead of dashes, e.g.
|
||||
`cache_duration` and `cache-duration` are both supported. This is to support stricter YAML parsers that do
|
||||
not support dashes.
|
||||
|
||||
| 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`) |
|
||||
| `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-unix` | `NTFY_LISTEN_UNIX` | *filename* | - | Path to a Unix socket to listen on |
|
||||
| `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. |
|
||||
| `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. |
|
||||
| `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM](#firebase-fcm). |
|
||||
| `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache). |
|
||||
| `cache-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. |
|
||||
| `auth-file` | `NTFY_AUTH_FILE` | *filename* | - | Auth database file used for access control. If set, enables authentication and access control. See [access control](#access-control). |
|
||||
| `auth-default-access` | `NTFY_AUTH_DEFAULT_ACCESS` | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write` | Default permissions if no matching entries in the auth database are found. Default is `read-write`. |
|
||||
| `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-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory* | - | Cache directory for attached files. To enable attachments, this has to be set. |
|
||||
| `attachment-total-size-limit` | `NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 5G | Limit of the on-disk attachment cache directory. If the limits is exceeded, new attachments will be rejected. |
|
||||
| `attachment-file-size-limit` | `NTFY_ATTACHMENT_FILE_SIZE_LIMIT` | *size* | 15M | Per-file attachment size limit (e.g. 300k, 2M, 100M). Larger attachment will be rejected. |
|
||||
| `attachment-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-addr` | `NTFY_SMTP_SENDER_ADDR` | `host:port` | - | SMTP server address to allow email sending |
|
||||
| `smtp-sender-user` | `NTFY_SMTP_SENDER_USER` | *string* | - | SMTP user; only used if e-mail sending is enabled |
|
||||
| `smtp-sender-pass` | `NTFY_SMTP_SENDER_PASS` | *string* | - | SMTP password; only used if e-mail sending is enabled |
|
||||
| `smtp-sender-from` | `NTFY_SMTP_SENDER_FROM` | *e-mail address* | - | SMTP sender e-mail address; only used if e-mail sending is enabled |
|
||||
| `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-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` |
|
||||
| `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | `[ip]:port` | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` |
|
||||
| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 45s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. |
|
||||
| `manager-interval` | `$NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. |
|
||||
| `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. |
|
||||
| `upstream-base-url` | `NTFY_UPSTREAM_BASE_URL` | *URL* | `https://ntfy.sh` | Forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers |
|
||||
| `visitor-attachment-total-size-limit` | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 100M | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`. |
|
||||
| `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size* | 500M | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding. |
|
||||
| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Rate limiting:Initial limit of e-mails per visitor |
|
||||
| `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled |
|
||||
| `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has |
|
||||
| `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 5s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled |
|
||||
| `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting |
|
||||
| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
|
||||
| `web-root` | `NTFY_WEB_ROOT` | `app`, `home` or `disable` | `app` | Sets web root to landing page (home), web app (app) or disables the web app entirely (disable) |
|
||||
|
||||
The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.
|
||||
The format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k.
|
||||
@@ -781,42 +919,46 @@ DESCRIPTION:
|
||||
ntfy serve --listen-http :8080 # Starts server with alternate port
|
||||
|
||||
OPTIONS:
|
||||
--config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
|
||||
--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-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]
|
||||
--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]
|
||||
--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]
|
||||
--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-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-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: 45s) [$NTFY_KEEPALIVE_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-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-from value SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM]
|
||||
--smtp-server-listen value SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN]
|
||||
--smtp-server-domain value SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN]
|
||||
--smtp-server-addr-prefix value SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX]
|
||||
--global-topic-limit value, -T value total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT]
|
||||
--visitor-subscription-limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_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-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: 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-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]
|
||||
--help, -h show help (default: false)
|
||||
--attachment-cache-dir value, --attachment_cache_dir value cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR]
|
||||
--attachment-expiry-duration value, --attachment_expiry_duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION]
|
||||
--attachment-file-size-limit value, --attachment_file_size_limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
|
||||
--attachment-total-size-limit value, --attachment_total_size_limit value, -A value limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
||||
--auth-default-access value, --auth_default_access value, -p value default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS]
|
||||
--auth-file value, --auth_file value, -H value auth database file used for access control [$NTFY_AUTH_FILE]
|
||||
--base-url value, --base_url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
|
||||
--behind-proxy, --behind_proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
|
||||
--cache-duration since, --cache_duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION]
|
||||
--cache-file value, --cache_file value, -C value cache file used for message caching [$NTFY_CACHE_FILE]
|
||||
--cert-file value, --cert_file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE]
|
||||
--config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
|
||||
--debug, -d enable debug logging (default: false) [$NTFY_DEBUG]
|
||||
--firebase-key-file value, --firebase_key_file value, -F value Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE]
|
||||
--global-topic-limit value, --global_topic_limit value, -T value total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT]
|
||||
--keepalive-interval value, --keepalive_interval value, -k value interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL]
|
||||
--key-file value, --key_file value, -K value private key file, if listen-https is set [$NTFY_KEY_FILE]
|
||||
--listen-http value, --listen_http value, -l value ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
|
||||
--listen-https value, --listen_https value, -L value ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS]
|
||||
--listen-unix value, --listen_unix value, -U value listen on unix socket path [$NTFY_LISTEN_UNIX]
|
||||
--log-level value, --log_level value set log level (default: "INFO") [$NTFY_LOG_LEVEL]
|
||||
--manager-interval value, --manager_interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
|
||||
--no-log-dates, --no_log_dates disable the date/time prefix (default: false) [$NTFY_NO_LOG_DATES]
|
||||
--smtp-sender-addr value, --smtp_sender_addr value SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR]
|
||||
--smtp-sender-from value, --smtp_sender_from value SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM]
|
||||
--smtp-sender-pass value, --smtp_sender_pass value SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS]
|
||||
--smtp-sender-user value, --smtp_sender_user value SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER]
|
||||
--smtp-server-addr-prefix value, --smtp_server_addr_prefix value SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX]
|
||||
--smtp-server-domain value, --smtp_server_domain value SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN]
|
||||
--smtp-server-listen value, --smtp_server_listen value SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN]
|
||||
--trace enable tracing (very verbose, be careful) (default: false) [$NTFY_TRACE]
|
||||
--upstream-base-url value, --upstream_base_url value forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers [$NTFY_UPSTREAM_BASE_URL]
|
||||
--visitor-attachment-daily-bandwidth-limit value, --visitor_attachment_daily_bandwidth_limit value total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT]
|
||||
--visitor-attachment-total-size-limit value, --visitor_attachment_total_size_limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
||||
--visitor-email-limit-burst value, --visitor_email_limit_burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]
|
||||
--visitor-email-limit-replenish value, --visitor_email_limit_replenish value interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
|
||||
--visitor-request-limit-burst value, --visitor_request_limit_burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
|
||||
--visitor-request-limit-exempt-hosts value, --visitor_request_limit_exempt_hosts value hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS]
|
||||
--visitor-request-limit-replenish value, --visitor_request_limit_replenish value interval at which burst limit is replenished (one per x) (default: 5s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
|
||||
--visitor-subscription-limit value, --visitor_subscription_limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
|
||||
--web-root value, --web_root value sets web root to landing page (home), web app (app) or disabled (disable) (default: "app") [$NTFY_WEB_ROOT]
|
||||
```
|
||||
|
||||
|
||||
@@ -4,16 +4,28 @@ This page is used to list deprecation notices for ntfy. Deprecated commands and
|
||||
|
||||
## Active deprecations
|
||||
|
||||
### Android app: Using `since=<timestamp>` instead of `since=<id>`
|
||||
> since 2022-02-27
|
||||
### 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`)
|
||||
> since 2021-12-17
|
||||
> Deprecated 2021-12-17, behavior changed with v1.10.0
|
||||
|
||||
As more commands are added to the `ntfy` CLI tool, using just `ntfy` to run the server is not practical
|
||||
anymore. Please use `ntfy serve` instead. This also applies to Docker images, as they can also execute more than
|
||||
|
||||
320
docs/develop.md
@@ -1,54 +1,299 @@
|
||||
# Building
|
||||
# Development
|
||||
Hurray 🥳 🎉, you are interested in writing code for ntfy! **That's awesome.** 😎
|
||||
|
||||
I tried my very best to write up detailed instructions, but if at any point in time you run into issues, don't
|
||||
hesitate to **contact me on [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org)**.
|
||||
|
||||
## ntfy server
|
||||
The ntfy server source code is available [on GitHub](https://github.com/binwiederhier/ntfy).
|
||||
To quickly build on amd64, you can use `make build-simple`:
|
||||
The ntfy server source code is available [on GitHub](https://github.com/binwiederhier/ntfy). The codebase for the
|
||||
server consists of three components:
|
||||
|
||||
```
|
||||
git clone git@github.com:binwiederhier/ntfy.git
|
||||
cd ntfy
|
||||
make build-simple
|
||||
* **The main server/client** is written in [Go](https://go.dev/) (so you'll need Go). Its main entrypoint is at
|
||||
[main.go](https://github.com/binwiederhier/ntfy/blob/main/main.go), and the meat you're likely interested in is
|
||||
in [server.go](https://github.com/binwiederhier/ntfy/blob/main/server/server.go). Notably, the server uses a
|
||||
[SQLite](https://sqlite.org) library called [go-sqlite3](https://github.com/mattn/go-sqlite3), which requires
|
||||
[Cgo](https://go.dev/blog/cgo) and `CGO_ENABLED=1` to be set. Otherwise things will not work (see below).
|
||||
* **The documentation** is generated by [MkDocs](https://www.mkdocs.org/) and [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/),
|
||||
which is written in [Python](https://www.python.org/). You'll need Python and MkDocs (via `pip`) only if you want to
|
||||
build the docs.
|
||||
* **The web app** is written in [React](https://reactjs.org/), using [MUI](https://mui.com/). It uses [Create React App](https://create-react-app.dev/)
|
||||
to build the production build. If you want to modify the web app, you need [nodejs](https://nodejs.org/en/) (for `npm`)
|
||||
and install all the 100,000 dependencies (*sigh*).
|
||||
|
||||
All of these components are built and then **baked into one binary**.
|
||||
|
||||
### Navigating the code
|
||||
Code:
|
||||
|
||||
* [main.go](https://github.com/binwiederhier/ntfy/blob/main/main.go) - Main entrypoint into the CLI, for both server and client
|
||||
* [cmd/](https://github.com/binwiederhier/ntfy/tree/main/cmd) - CLI commands, such as `serve` or `publish`
|
||||
* [server/](https://github.com/binwiederhier/ntfy/tree/main/server) - The meat of the server logic
|
||||
* [docs/](https://github.com/binwiederhier/ntfy/tree/main/docs) - The [MkDocs](https://www.mkdocs.org/) documentation, also see `mkdocs.yml`
|
||||
* [web/](https://github.com/binwiederhier/ntfy/tree/main/web) - The [React](https://reactjs.org/) application, also see `web/package.json`
|
||||
|
||||
Build related:
|
||||
|
||||
* [Makefile](https://github.com/binwiederhier/ntfy/blob/main/Makefile) - Main entrypoint for all things related to building
|
||||
* [.goreleaser.yml](https://github.com/binwiederhier/ntfy/blob/main/.goreleaser.yml) - Describes all build outputs (for [GoReleaser](https://goreleaser.com/))
|
||||
* [go.mod](https://github.com/binwiederhier/ntfy/blob/main/go.mod) - Go modules dependency file
|
||||
* [mkdocs.yml](https://github.com/binwiederhier/ntfy/blob/main/mkdocs.yml) - Config file for the docs (for [MkDocs](https://www.mkdocs.org/))
|
||||
* [web/package.json](https://github.com/binwiederhier/ntfy/blob/main/web/package.json) - Build and dependency file for web app (for npm)
|
||||
|
||||
|
||||
The `web/` and `docs/` folder are the sources for web app and documentation. During the build process,
|
||||
the generated output is copied to `server/site` (web app and landing page) and `server/docs` (documentation).
|
||||
|
||||
### Build 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`.
|
||||
|
||||
For all other platforms (including Docker), and for production or other snapshot builds, you should use the amazingly
|
||||
awesome [GoReleaser](https://goreleaser.com/) make targets:
|
||||
|
||||
```
|
||||
Build:
|
||||
make build - Build
|
||||
make build-snapshot - Build snapshot
|
||||
make build-simple - Build (using go build, without goreleaser)
|
||||
make clean - Clean build folder
|
||||
|
||||
Releasing (requires goreleaser):
|
||||
make release - Create a release
|
||||
make release-snapshot - Create a test release
|
||||
Install [GoReleaser](https://goreleaser.com/) (see [official instructions](https://goreleaser.com/install/)):
|
||||
``` shell
|
||||
go install github.com/goreleaser/goreleaser@latest
|
||||
goreleaser -v # verifies that it worked
|
||||
```
|
||||
|
||||
There are currently no platform-specific make targets, so they will build for all platforms (which may take a while).
|
||||
Install [nodejs](https://nodejs.org/en/) (see [official instructions](https://nodejs.org/en/download/package-manager/)):
|
||||
``` shell
|
||||
curl -fsSL https://deb.nodesource.com/setup_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 cli-linux-amd64 - Build server/client binary (amd64, no web app or docs)
|
||||
make install-linux-amd64 - Install ntfy binary to /usr/bin/ntfy (amd64)
|
||||
make web - Build the web app
|
||||
make docs - Build the documentation
|
||||
make check - Run all tests, vetting/formatting checks and linters
|
||||
...
|
||||
```
|
||||
|
||||
If you want to build the **ntfy binary including web app and docs for all supported architectures** (amd64, armv7, and arm64),
|
||||
you can simply run `make build`:
|
||||
|
||||
``` shell
|
||||
$ make build
|
||||
...
|
||||
# This builds web app, docs, and the ntfy binary (for amd64, armv7 and arm64).
|
||||
# This will be SLOW (5+ minutes on my laptop on the first run). Maybe look at the other make targets?
|
||||
```
|
||||
|
||||
You'll see all the outputs in the `dist/` folder afterwards:
|
||||
|
||||
``` bash
|
||||
$ find dist
|
||||
dist
|
||||
dist/metadata.json
|
||||
dist/ntfy_arm64_linux_arm64
|
||||
dist/ntfy_arm64_linux_arm64/ntfy
|
||||
dist/ntfy_armv7_linux_arm_7
|
||||
dist/ntfy_armv7_linux_arm_7/ntfy
|
||||
dist/ntfy_amd64_linux_amd64
|
||||
dist/ntfy_amd64_linux_amd64/ntfy
|
||||
dist/config.yaml
|
||||
dist/artifacts.json
|
||||
```
|
||||
|
||||
If you also want to build the **Debian/RPM packages and the Docker images for all supported architectures**, you can
|
||||
use the `make release-snapshot` target:
|
||||
|
||||
``` shell
|
||||
$ make release-snapshot
|
||||
...
|
||||
# This will be REALLY SLOW (sometimes 5+ minutes on my laptop)
|
||||
```
|
||||
|
||||
During development, you may want to be more picky and build only certain things. Here are a few examples.
|
||||
|
||||
### Build the ntfy binary
|
||||
To build only the `ntfy` binary **without the web app or documentation**, use the `make cli-...` targets:
|
||||
|
||||
``` shell
|
||||
$ make
|
||||
Build server & client (using GoReleaser, not release version):
|
||||
make cli - Build server & client (all architectures)
|
||||
make cli-linux-amd64 - Build server & client (Linux, amd64 only)
|
||||
make cli-linux-armv6 - Build server & client (Linux, armv6 only)
|
||||
make cli-linux-armv7 - Build server & client (Linux, armv7 only)
|
||||
make cli-linux-arm64 - Build server & client (Linux, arm64 only)
|
||||
make cli-windows-amd64 - Build client (Windows, amd64 only)
|
||||
make cli-darwin-all - Build client (macOS, arm64+amd64 universal binary)
|
||||
```
|
||||
|
||||
So if you're on an amd64/x86_64-based machine, you may just want to run `make cli-linux-amd64` during testing. On a modern
|
||||
system, this shouldn't take longer than 5-10 seconds. I often combine it with `install-linux-amd64` so I can run the binary
|
||||
right away:
|
||||
|
||||
``` shell
|
||||
$ make cli-linux-amd64 install-linux-amd64
|
||||
$ ntfy serve
|
||||
```
|
||||
|
||||
**During development of the main app, you can also just use `go run main.go`**, as long as you run
|
||||
`make cli-deps-static-sites`at least once and `CGO_ENABLED=1`:
|
||||
|
||||
``` shell
|
||||
$ export CGO_ENABLED=1
|
||||
$ make cli-deps-static-sites
|
||||
$ go run main.go serve
|
||||
2022/03/18 08:43:55 Listening on :2586[http]
|
||||
...
|
||||
```
|
||||
|
||||
If you don't run `cli-deps-static-sites`, you may see an error *`pattern ...: no matching files found`*:
|
||||
```
|
||||
$ go run main.go serve
|
||||
server/server.go:85:13: pattern docs: no matching files found
|
||||
```
|
||||
|
||||
This is because we use `go:embed` to embed the documentation and web app, so the Go code expects files to be
|
||||
present at `server/docs` and `server/site`. If they are not, you'll see the above error. The `cli-deps-static-sites`
|
||||
target creates dummy files that ensure that you'll be able to build.
|
||||
|
||||
While not officially supported (or released), you can build and run the server **on macOS** as well. Simply run
|
||||
`make cli-darwin-server` to build a binary, or `go run main.go serve` (see above) to run it.
|
||||
|
||||
### Build the web app
|
||||
The sources for the web app live in `web/`. As long as you have `npm` installed (see above), building the web app
|
||||
is really simple. Just type `make web` and you're in business:
|
||||
|
||||
``` shell
|
||||
$ make web
|
||||
...
|
||||
```
|
||||
|
||||
This will build the web app using Create React App and then **copy the production build to the `server/site` folder**, so
|
||||
that when you `make cli` (or `make cli-linux-amd64`, ...), you will have the web app included in the `ntfy` binary.
|
||||
|
||||
If you're developing on the web app, it's best to just `cd web` and run `npm start` manually. This will open your browser
|
||||
at `http://127.0.0.1:3000` with the web app, and as you edit the source files, they will be recompiled and the browser
|
||||
will automatically refresh:
|
||||
|
||||
``` shell
|
||||
$ cd web
|
||||
$ npm start
|
||||
```
|
||||
|
||||
### Build the docs
|
||||
The sources for the docs live in `docs/`. Similarly to the web app, you can simply run `make docs` to build the
|
||||
documentation. As long as you have `mkdocs` installed (see above), this should work fine:
|
||||
|
||||
``` shell
|
||||
$ make docs
|
||||
...
|
||||
```
|
||||
|
||||
If you are changing the documentation, you should be running `mkdocs serve` directly. This will build the documentation,
|
||||
serve the files at `http://127.0.0.1:8000/`, and rebuild every time you save the source files:
|
||||
|
||||
```
|
||||
$ mkdocs serve
|
||||
INFO - Building documentation...
|
||||
INFO - Cleaning site directory
|
||||
INFO - Documentation built in 5.53 seconds
|
||||
INFO - [16:28:14] Serving on http://127.0.0.1:8000/
|
||||
```
|
||||
|
||||
Then you can navigate to http://127.0.0.1:8000/ and whenever you change a markdown file in your text editor it'll automatically update.
|
||||
|
||||
## Android app
|
||||
The ntfy Android app source code is available [on GitHub](https://github.com/binwiederhier/ntfy-android).
|
||||
The Android app has two flavors:
|
||||
|
||||
* **Google Play:** The `play` flavor includes Firebase (FCM) and requires a Firebase account
|
||||
* **Google Play:** The `play` flavor includes [Firebase (FCM)](https://firebase.google.com/) and requires a Firebase account
|
||||
* **F-Droid:** The `fdroid` flavor does not include Firebase or Google dependencies
|
||||
|
||||
### Navigating the code
|
||||
* [main/](https://github.com/binwiederhier/ntfy-android/tree/main/app/src/main) - Main Android app source code
|
||||
* [play/](https://github.com/binwiederhier/ntfy-android/tree/main/app/src/play) - Google Play / Firebase specific code
|
||||
* [fdroid/](https://github.com/binwiederhier/ntfy-android/tree/main/app/src/fdroid) - F-Droid Firebase stubs
|
||||
* [build.gradle](https://github.com/binwiederhier/ntfy-android/blob/main/app/build.gradle) - Main build file
|
||||
|
||||
### IDE/Environment
|
||||
You should download [Android Studio](https://developer.android.com/studio) (or [IntelliJ IDEA](https://www.jetbrains.com/idea/)
|
||||
with the relevant Android plugins). Everything else will just be a pain for you. Do yourself a favor. 😀
|
||||
|
||||
### Check out the code
|
||||
First check out the repository:
|
||||
|
||||
```
|
||||
git clone git@github.com:binwiederhier/ntfy-android.git
|
||||
cd ntfy-android
|
||||
```
|
||||
=== "via HTTPS"
|
||||
``` shell
|
||||
git clone https://github.com/binwiederhier/ntfy-android.git
|
||||
cd ntfy-android
|
||||
```
|
||||
|
||||
=== "via SSH"
|
||||
``` shell
|
||||
git clone git@github.com:binwiederhier/ntfy-android.git
|
||||
cd ntfy-android
|
||||
```
|
||||
|
||||
Then either follow the steps for building with or without Firebase.
|
||||
|
||||
### Building without Firebase (F-Droid flavor)
|
||||
Without Firebase, you may want to still change the default `app_base_url` in [strings.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/strings.xml)
|
||||
### Build F-Droid flavor (no FCM)
|
||||
!!! info
|
||||
I do build the ntfy Android app using IntelliJ IDEA (Android Studio), so I don't know if these Gradle commands will
|
||||
work without issues. Please give me feedback if it does/doesn't work for you.
|
||||
|
||||
Without Firebase, you may want to still change the default `app_base_url` in [values.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/values.xml)
|
||||
if you're self-hosting the server. Then run:
|
||||
```
|
||||
# Remove Google dependencies (FCM)
|
||||
sed -i -e '/google-services/d' build.gradle
|
||||
sed -i -e '/google-services/d' app/build.gradle
|
||||
|
||||
# To build an unsigned .apk (app/build/outputs/apk/fdroid/*.apk)
|
||||
./gradlew assembleFdroidRelease
|
||||
|
||||
@@ -56,11 +301,16 @@ if you're self-hosting the server. Then run:
|
||||
./gradlew bundleFdroidRelease
|
||||
```
|
||||
|
||||
### Building with Firebase (FCM, Google Play flavor)
|
||||
### Build Play flavor (FCM)
|
||||
!!! info
|
||||
I do build the ntfy Android app using IntelliJ IDEA (Android Studio), so I don't know if these Gradle commands will
|
||||
work without issues. Please give me feedback if it does/doesn't work for you.
|
||||
|
||||
To build your own version with Firebase, you must:
|
||||
|
||||
* Create a Firebase/FCM account
|
||||
* Place your account file at `app/google-services.json`
|
||||
* And change `app_base_url` in [strings.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/strings.xml)
|
||||
* And change `app_base_url` in [values.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/values.xml)
|
||||
* Then run:
|
||||
```
|
||||
# To build an unsigned .apk (app/build/outputs/apk/play/*.apk)
|
||||
@@ -69,3 +319,9 @@ To build your own version with Firebase, you must:
|
||||
# To build a bundle .aab (app/play/release/*.aab)
|
||||
./gradlew bundlePlayRelease
|
||||
```
|
||||
|
||||
## iOS app
|
||||
The ntfy iOS app source code is available [on GitHub](https://github.com/binwiederhier/ntfy-ios).
|
||||
|
||||
!!! info
|
||||
I haven't had time to move the build instructions here. Please check out the repository instead.
|
||||
|
||||
344
docs/examples.md
@@ -4,6 +4,11 @@ There are a million ways to use ntfy, but here are some inspirations. I try to c
|
||||
<a href="https://github.com/binwiederhier/ntfy/tree/main/examples">examples on GitHub</a>, so be sure to check
|
||||
those out, too.
|
||||
|
||||
!!! info
|
||||
Many of these examples were contributed by ntfy users. If you have other examples of how you use ntfy, please
|
||||
[create a pull request](https://github.com/binwiederhier/ntfy/pulls), and I'll happily include it. Also note, that
|
||||
I cannot guarantee that all of these examples are functional. Many of them I have not tried myself.
|
||||
|
||||
## A long process is done: backups, copying data, pipelines, ...
|
||||
I started adding notifications pretty much all of my scripts. Typically, I just chain the <tt>curl</tt> call
|
||||
directly to the command I'm running. The following example will either send <i>Laptop backup succeeded</i>
|
||||
@@ -16,9 +21,30 @@ rsync -a root@laptop /backups/laptop \
|
||||
|| 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
|
||||
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>.
|
||||
|
||||
## Notify on SSH login
|
||||
Years ago my home server was broken into. That shook me hard, so every time someone logs into any machine that I
|
||||
@@ -77,7 +103,8 @@ One of my co-workers uses the following Ansible task to let him know when things
|
||||
```
|
||||
|
||||
## Watchtower notifications (shoutrrr)
|
||||
You can use `shoutrrr` generic webhook support to send watchtower notifications to your ntfy topic.
|
||||
You can use [shoutrrr](https://github.com/containrrr/shoutrrr) generic webhook support to send
|
||||
[Watchtower](https://github.com/containrrr/watchtower/) notifications to your ntfy topic.
|
||||
|
||||
Example docker-compose.yml:
|
||||
```yml
|
||||
@@ -93,3 +120,316 @@ 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>
|
||||
|
||||
``` json
|
||||
[
|
||||
{
|
||||
"id": "c956e688cc74ad8e",
|
||||
"type": "http request",
|
||||
"z": "fabdd7a3.4045a",
|
||||
"name": "ntfy.sh",
|
||||
"method": "POST",
|
||||
"ret": "txt",
|
||||
"paytoqs": "ignore",
|
||||
"url": "https://ntfy.sh/mytopic",
|
||||
"tls": "",
|
||||
"persist": false,
|
||||
"proxy": "",
|
||||
"authType": "",
|
||||
"senderr": false,
|
||||
"credentials":
|
||||
{
|
||||
"user": "",
|
||||
"password": ""
|
||||
},
|
||||
"x": 590,
|
||||
"y": 3160,
|
||||
"wires":
|
||||
[
|
||||
[]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "32ee1eade51fae50",
|
||||
"type": "function",
|
||||
"z": "fabdd7a3.4045a",
|
||||
"name": "data",
|
||||
"func": "msg.payload = \"Something happened\";\nmsg.headers = {};\nmsg.headers['tags'] = 'house';\nmsg.headers['X-Title'] = 'Home Assistant';\n\nreturn msg;",
|
||||
"outputs": 1,
|
||||
"noerr": 0,
|
||||
"initialize": "",
|
||||
"finalize": "",
|
||||
"libs": [],
|
||||
"x": 470,
|
||||
"y": 3160,
|
||||
"wires":
|
||||
[
|
||||
[
|
||||
"c956e688cc74ad8e"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b287e59cd2311815",
|
||||
"type": "inject",
|
||||
"z": "fabdd7a3.4045a",
|
||||
"name": "Manual start",
|
||||
"props":
|
||||
[
|
||||
{
|
||||
"p": "payload"
|
||||
},
|
||||
{
|
||||
"p": "topic",
|
||||
"vt": "str"
|
||||
}
|
||||
],
|
||||
"repeat": "",
|
||||
"crontab": "",
|
||||
"once": false,
|
||||
"onceDelay": "20",
|
||||
"topic": "",
|
||||
"payload": "",
|
||||
"payloadType": "date",
|
||||
"x": 330,
|
||||
"y": 3160,
|
||||
"wires":
|
||||
[
|
||||
[
|
||||
"32ee1eade51fae50"
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||

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

|
||||
|
||||
## Gatus service health check
|
||||
|
||||
An example for a custom alert with [Gatus](https://github.com/TwiN/gatus):
|
||||
``` yaml
|
||||
alerting:
|
||||
custom:
|
||||
url: "https://ntfy.sh"
|
||||
method: "POST"
|
||||
body: |
|
||||
{
|
||||
"topic": "mytopic",
|
||||
"message": "[ENDPOINT_NAME] - [ALERT_DESCRIPTION]",
|
||||
"title": "Gatus",
|
||||
"tags": ["[ALERT_TRIGGERED_OR_RESOLVED]"],
|
||||
"priority": 3
|
||||
}
|
||||
default-alert:
|
||||
enabled: true
|
||||
description: "health check failed"
|
||||
send-on-resolved: true
|
||||
failure-threshold: 3
|
||||
success-threshold: 3
|
||||
placeholders:
|
||||
ALERT_TRIGGERED_OR_RESOLVED:
|
||||
TRIGGERED: "warning"
|
||||
RESOLVED: "white_check_mark"
|
||||
```
|
||||
|
||||
## Jellyseerr/Overseerr webhook
|
||||
Here is an example for [jellyseerr](https://github.com/Fallenbagel/jellyseerr)/[overseerr](https://overseerr.dev/) webhook
|
||||
JSON payload. Remember to change the `https://requests.example.com` to your jellyseerr/overseerr URL.
|
||||
|
||||
``` json
|
||||
{
|
||||
"topic": "requests",
|
||||
"title": "{{event}}",
|
||||
"message": "{{subject}}\n{{message}}\n\nRequested by: {{requestedBy_username}}\n\nStatus: {{media_status}}\nRequest Id: {{request_id}}",
|
||||
"priority": 4,
|
||||
"attach": "{{image}}",
|
||||
"click": "https://requests.example.com/{{media_type}}/{{media_tmdbid}}"
|
||||
}
|
||||
```
|
||||
|
||||
## Home Assistant
|
||||
Here is an example for the configuration.yml file to setup a REST notify component.
|
||||
Since Home Assistant is going to POST JSON, you need to specify the root of your ntfy resource.
|
||||
|
||||
```yaml
|
||||
notify:
|
||||
- name: ntfy
|
||||
platform: rest
|
||||
method: POST_JSON
|
||||
data:
|
||||
topic: YOUR_NTFY_TOPIC
|
||||
title_param_name: title
|
||||
message_param_name: message
|
||||
resource: https://ntfy.sh
|
||||
```
|
||||
|
||||
If you need to authenticate to your ntfy resource, define the authentication, username and password as below:
|
||||
|
||||
```yaml
|
||||
notify:
|
||||
- name: ntfy
|
||||
platform: rest
|
||||
method: POST_JSON
|
||||
authentication: basic
|
||||
username: YOUR_USERNAME
|
||||
password: YOUR_PASSWORD
|
||||
data:
|
||||
topic: YOUR_NTFY_TOPIC
|
||||
title_param_name: title
|
||||
message_param_name: message
|
||||
resource: https://ntfy.sh
|
||||
```
|
||||
|
||||
If you need to add any other [ntfy specific parameters](https://ntfy.sh/docs/publish/#publish-as-json) such as priority, tags, etc., add them to the `data` array in the example yml. For example:
|
||||
|
||||
```yaml
|
||||
notify:
|
||||
- name: ntfy
|
||||
platform: rest
|
||||
method: POST_JSON
|
||||
data:
|
||||
topic: YOUR_NTFY_TOPIC
|
||||
priority: 4
|
||||
title_param_name: title
|
||||
message_param_name: message
|
||||
resource: https://ntfy.sh
|
||||
```
|
||||
|
||||
@@ -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).
|
||||
|
||||
## 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,
|
||||
or you use *instant delivery*, the app has to maintain a constant connection to the server, which consumes about 4% of
|
||||
battery in 17h of use (on my phone). I use it, and it makes no difference to me.
|
||||
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). There has been a ton of testing and improvement around this. I think it's pretty
|
||||
decent now.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -5,7 +5,7 @@ or POST requests. I use it to notify myself when scripts fail, or long-running c
|
||||
## Step 1: Get the app
|
||||
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="../../static/img/badge-googleplay.png"></a>
|
||||
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="../../static/img/badge-fdroid.png"></a>
|
||||
<a href="https://github.com/binwiederhier/ntfy/issues/4"><img src="../../static/img/badge-appstore.png"></a>
|
||||
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img src="../../static/img/badge-appstore.png"></a>
|
||||
|
||||
To [receive notifications on your phone](subscribe/phone.md), install the app, either via Google Play or F-Droid.
|
||||
Once installed, open it and subscribe to a topic of your choosing. Topics don't have to explicitly be created, so just
|
||||
@@ -83,7 +83,7 @@ This will create a notification that looks like this:
|
||||
</figure>
|
||||
|
||||
That's it. You're all set. Go play and read the rest of the docs. I highly recommend reading at least the page on
|
||||
[publishing messages](publish.md), as well as the detailed page on the [Android app](subscribe/phone.md).
|
||||
[publishing messages](publish.md), as well as the detailed page on the [Android/iOS app](subscribe/phone.md).
|
||||
|
||||
Here's another video showing the entire process:
|
||||
|
||||
|
||||
140
docs/install.md
@@ -13,36 +13,51 @@ The ntfy server comes as a statically linked binary and is shipped as tarball, d
|
||||
We support amd64, armv7 and arm64.
|
||||
|
||||
1. Install ntfy using one of the methods described below
|
||||
2. Then (optionally) edit `/etc/ntfy/server.yml` for the server (see [configuration](config.md) or [sample server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml))
|
||||
2. Then (optionally) edit `/etc/ntfy/server.yml` for the server (Linux only, see [configuration](config.md) or [sample server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml))
|
||||
3. Or (optionally) create/edit `~/.config/ntfy/client.yml` (or `/etc/ntfy/client.yml`, see [sample client.yml](https://github.com/binwiederhier/ntfy/blob/main/client/client.yml))
|
||||
|
||||
To run the ntfy server, then just run `ntfy serve` (or `systemctl start ntfy` when using the deb/rpm).
|
||||
To send messages, use `ntfy publish`. To subscribe to topics, use `ntfy subscribe` (see [subscribing via CLI][subscribe/cli.md]
|
||||
for details).
|
||||
|
||||
## Binaries and packages
|
||||
## Linux binaries
|
||||
Please check out the [releases page](https://github.com/binwiederhier/ntfy/releases) for binaries and
|
||||
deb/rpm packages.
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.17.1/ntfy_1.17.1_linux_x86_64.tar.gz
|
||||
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
|
||||
sudo ./ntfy serve
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_x86_64.tar.gz
|
||||
tar zxvf ntfy_1.25.0_linux_x86_64.tar.gz
|
||||
sudo cp -a ntfy_1.25.0_linux_x86_64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.25.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_1.25.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_1.25.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.25.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.17.1/ntfy_1.17.1_linux_armv7.tar.gz
|
||||
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
|
||||
sudo ./ntfy serve
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_1.25.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_1.25.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.25.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.17.1/ntfy_1.17.1_linux_arm64.tar.gz
|
||||
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
|
||||
sudo ./ntfy serve
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_1.25.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_1.25.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.25.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
## Debian/Ubuntu repository
|
||||
@@ -88,7 +103,15 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.17.1/ntfy_1.17.1_linux_amd64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.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.25.0/ntfy_1.25.0_linux_armv6.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -96,7 +119,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.17.1/ntfy_1.17.1_linux_armv7.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_armv7.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -104,7 +127,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.17.1/ntfy_1.17.1_linux_arm64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_arm64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -114,21 +137,28 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.17.1/ntfy_1.17.1_linux_amd64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_amd64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_armv6.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.17.1/ntfy_1.17.1_linux_armv7.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_armv7.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.17.1/ntfy_1.17.1_linux_arm64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_arm64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
@@ -146,10 +176,48 @@ cd ntfysh-bin
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
## NixOS / Nix
|
||||
ntfy is packaged in nixpkgs as `ntfy-sh`. It can be installed by adding the package name to the configuration file and calling `nixos-rebuild`. Alternatively, the following command can be used to install ntfy in the current user environment:
|
||||
```
|
||||
nix-env -iA ntfy-sh
|
||||
```
|
||||
|
||||
## macOS
|
||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
|
||||
To install, please download the tarball, extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
|
||||
|
||||
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
|
||||
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
|
||||
|
||||
```bash
|
||||
curl https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_v1.25.0_macOS_all.tar.gz > ntfy_v1.25.0_macOS_all.tar.gz
|
||||
tar zxvf ntfy_v1.25.0_macOS_all.tar.gz
|
||||
sudo cp -a ntfy_v1.25.0_macOS_all/ntfy /usr/local/bin/ntfy
|
||||
mkdir ~/Library/Application\ Support/ntfy
|
||||
cp ntfy_v1.25.0_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
ntfy --help
|
||||
```
|
||||
|
||||
!!! info
|
||||
If there is a desire to install ntfy via [Homebrew](https://brew.sh/), please create a
|
||||
[GitHub issue](https://github.com/binwiederhier/ntfy/issues) to let me know. Also, you can build and run the
|
||||
ntfy server on macOS as well, though I don't officially support that. Check out the [build instructions](develop.md)
|
||||
for details.
|
||||
|
||||
## Windows
|
||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
|
||||
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_v1.25.0_windows_x86_64.zip),
|
||||
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
|
||||
|
||||
The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
|
||||
|
||||
!!! info
|
||||
There is currently no installer for Windows, and the binary is not signed. If this is desired, please create a
|
||||
[GitHub issue](https://github.com/binwiederhier/ntfy/issues) to let me know.
|
||||
|
||||
## Docker
|
||||
The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv7 and arm64. It should be pretty
|
||||
straight forward to use.
|
||||
The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv6, armv7 and arm64. It should
|
||||
be pretty straight forward to use.
|
||||
|
||||
The server exposes its web UI and the API on port 80, so you need to expose that in Docker. To use the persistent
|
||||
[message cache](config.md#message-cache), you also need to map a volume to `/var/cache/ntfy`. To change other settings,
|
||||
@@ -171,16 +239,38 @@ docker run \
|
||||
serve
|
||||
```
|
||||
|
||||
With other config options (configured via `/etc/ntfy/server.yml`, see [configuration](config.md) for details):
|
||||
With other config options and non-root user (configured via `/etc/ntfy/server.yml`, see [configuration](config.md) for details):
|
||||
```bash
|
||||
docker run \
|
||||
-v /etc/ntfy:/etc/ntfy \
|
||||
-p 80:80 \
|
||||
-u UID:GID \
|
||||
-it \
|
||||
binwiederhier/ntfy \
|
||||
serve
|
||||
```
|
||||
|
||||
Using docker-compose with non-root user:
|
||||
```yaml
|
||||
version: "2.1"
|
||||
|
||||
services:
|
||||
ntfy:
|
||||
image: binwiederhier/ntfy
|
||||
container_name: ntfy
|
||||
command:
|
||||
- serve
|
||||
user: UID:GID # optional. replace with your own user/group or uid/gid
|
||||
volumes:
|
||||
- /var/cache/ntfy:/var/cache/ntfy
|
||||
- /etc/ntfy:/etc/ntfy
|
||||
ports:
|
||||
- 80:80
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
If using a non-root user when running the docker version, be sure to chown the server.yml, user.db, and cache.db files to the same uid/gid.
|
||||
|
||||
Alternatively, you may wish to build a customized Docker image that can be run with fewer command-line arguments and without delivering the configuration file separately.
|
||||
```
|
||||
FROM binwiederhier/ntfy
|
||||
@@ -188,13 +278,3 @@ COPY server.yml /etc/ntfy/server.yml
|
||||
ENTRYPOINT ["ntfy", "serve"]
|
||||
```
|
||||
This image can be pushed to a container registry and shipped independently. All that's needed when running it is mapping ntfy's port to a host port.
|
||||
|
||||
## Go
|
||||
To install via Go, simply run:
|
||||
```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.
|
||||
|
||||
@@ -8,5 +8,5 @@ any outside service. All data is exclusively used to make the service function p
|
||||
I use is Firebase Cloud Messaging (FCM) service, which is required to provide instant Android notifications (see
|
||||
[FAQ](faq.md) for details). To avoid FCM altogether, download the F-Droid version.
|
||||
|
||||
The web server does not log or otherwise store request paths, remote IP addresses or even topics or messages,
|
||||
aside from a short on-disk cache to support service restarts.
|
||||
For debugging purposes, the ntfy server may temporarily log request paths, remote IP addresses or even topics
|
||||
or messages, though typically this is turned off.
|
||||
|
||||
1585
docs/publish.md
409
docs/releases.md
@@ -2,8 +2,415 @@
|
||||
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.14.0 (UNRELEASED)
|
||||
|
||||
**Additional translations:**
|
||||
|
||||
* Italian (thanks to [@Genio2003](https://hosted.weblate.org/user/Genio2003/))
|
||||
|
||||
-->
|
||||
|
||||
## ntfy server v1.25.0
|
||||
Released June 2, 2022
|
||||
|
||||
**Features:**
|
||||
|
||||
* Advanced logging, with different log levels and hot reloading of the log level ([#284](https://github.com/binwiederhier/ntfy/pull/284))
|
||||
|
||||
**Bugs**:
|
||||
|
||||
* Respect Firebase "quota exceeded" response for topics, block Firebase publishing for user for 10min ([#289](https://github.com/binwiederhier/ntfy/issues/289))
|
||||
|
||||
**Maintenance:**
|
||||
|
||||
* Upgrade Firebase Admin SDK to 4.x ([#274](https://github.com/binwiederhier/ntfy/issues/274))
|
||||
* CI: Build from pipeline instead of locally ([#36](https://github.com/binwiederhier/ntfy/issues/36))
|
||||
|
||||
**Documentation**:
|
||||
|
||||
* ⚠️ [Privacy policy](privacy.md) updated to reflect additional debug/tracing feature (no ticket)
|
||||
* [Examples](examples.md) for [Home Assistant](https://www.home-assistant.io/) ([#282](https://github.com/binwiederhier/ntfy/pull/282), thanks to [@poblabs](https://github.com/poblabs))
|
||||
* Install instructions for [NixOS/Nix](https://ntfy.sh/docs/install/#nixos-nix) ([#282](https://github.com/binwiederhier/ntfy/pull/282), thanks to [@arjan-s](https://github.com/arjan-s))
|
||||
* Clarify `poll_request` wording for [iOS push notifications](https://ntfy.sh/docs/config/#ios-instant-notifications) ([#300](https://github.com/binwiederhier/ntfy/issues/300), thanks to [@prabirshrestha](https://github.com/prabirshrestha) for reporting)
|
||||
* Example for using ntfy with docker-compose.yml without root privileges ([#304](https://github.com/binwiederhier/ntfy/pull/304), thanks to [@ksurl](https://github.com/ksurl))
|
||||
|
||||
**Additional translations:**
|
||||
|
||||
* Chinese/Simplified (thanks to [@yufei.im](https://hosted.weblate.org/user/yufei.im/))
|
||||
* Dutch (thanks to [@SchoNie](https://hosted.weblate.org/user/SchoNie/))
|
||||
|
||||
## ntfy iOS app v1.1
|
||||
Released May 31, 2022
|
||||
|
||||
In this release of the iOS app, we add message priorities (mapped to iOS interruption levels), tags and emojis,
|
||||
action buttons to open websites or perform HTTP requests (in the notification and the detail view), a custom click
|
||||
action when the notification is tapped, and various other fixes.
|
||||
|
||||
It also adds support for self-hosted servers (albeit not supporting auth yet). The self-hosted server needs to be
|
||||
configured to forward poll requests to upstream ntfy.sh for push notifications to work (see [iOS push notifications](https://ntfy.sh/docs/config/#ios-instant-notifications)
|
||||
for details).
|
||||
|
||||
**Features:**
|
||||
|
||||
* [Message priority](https://ntfy.sh/docs/publish/#message-priority) support (no ticket)
|
||||
* [Tags/emojis](https://ntfy.sh/docs/publish/#tags-emojis) support (no ticket)
|
||||
* [Action buttons](https://ntfy.sh/docs/publish/#action-buttons) support (no ticket)
|
||||
* [Click action](https://ntfy.sh/docs/publish/#click-action) support (no ticket)
|
||||
* Open topic when notification clicked (no ticket)
|
||||
* Notification now makes a sound and vibrates (no ticket)
|
||||
* Cancel notifications when navigating to topic (no ticket)
|
||||
* iOS 14.0 support (no ticket, [PR#1](https://github.com/binwiederhier/ntfy-ios/pull/1), thanks to [@callum-99](https://github.com/callum-99))
|
||||
|
||||
**Bugs:**
|
||||
|
||||
* iOS UI not always updating properly ([#267](https://github.com/binwiederhier/ntfy/issues/267))
|
||||
|
||||
## ntfy server v1.24.0
|
||||
Released May 28, 2022
|
||||
|
||||
This release of the ntfy server brings supporting features for the ntfy iOS app. Most importantly, it
|
||||
enables support for self-hosted servers in combination with the iOS app. This is to overcome the restrictive
|
||||
Apple development environment.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Regularly send Firebase keepalive messages to ~poll topic to support self-hosted servers (no ticket)
|
||||
* Add subscribe filter to query exact messages by ID (no ticket)
|
||||
* Support for `poll_request` messages to support [iOS push notifications](https://ntfy.sh/docs/config/#ios-instant-notifications) for self-hosted servers (no ticket)
|
||||
|
||||
**Bugs:**
|
||||
|
||||
* Support emails without `Content-Type` ([#265](https://github.com/binwiederhier/ntfy/issues/265), thanks to [@dmbonsall](https://github.com/dmbonsall))
|
||||
|
||||
**Additional translations:**
|
||||
|
||||
* Italian (thanks to [@Genio2003](https://hosted.weblate.org/user/Genio2003/))
|
||||
|
||||
## ntfy iOS app v1.0
|
||||
Released May 25, 2022
|
||||
|
||||
This is the first version of the ntfy iOS app. It supports only ntfy.sh (no selfhosted servers) and only messages + title
|
||||
(no priority, tags, attachments, ...). I'll rapidly add (hopefully) most of the other ntfy features, and then I'll focus
|
||||
on self-hosted servers.
|
||||
|
||||
The app is now available in the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).
|
||||
|
||||
**Tickets:**
|
||||
|
||||
* iOS app ([#4](https://github.com/binwiederhier/ntfy/issues/4), see also: [TestFlight summary](https://github.com/binwiederhier/ntfy/issues/4#issuecomment-1133767150))
|
||||
|
||||
**Thanks:**
|
||||
|
||||
* Thank you to all the testers who tried out the app. You guys gave me the confidence that it's ready to release (albeit with
|
||||
some known issues which will be addressed in follow-up releases).
|
||||
|
||||
## ntfy server v1.23.0
|
||||
Released May 21, 2022
|
||||
|
||||
This release ships a CLI for Windows and macOS, as well as the ability to disable the web app entirely. On top of that,
|
||||
it adds support for APNs, the iOS messaging service. This is needed for the (soon to be released) iOS app.
|
||||
|
||||
**Features:**
|
||||
|
||||
* [Windows](https://ntfy.sh/docs/install/#windows) and [macOS](https://ntfy.sh/docs/install/#macos) builds for the [ntfy CLI](https://ntfy.sh/docs/subscribe/cli/) ([#112](https://github.com/binwiederhier/ntfy/issues/112))
|
||||
* Ability to disable the web app entirely ([#238](https://github.com/binwiederhier/ntfy/issues/238)/[#249](https://github.com/binwiederhier/ntfy/pull/249), thanks to [@Curid](https://github.com/Curid))
|
||||
* Add APNs config to Firebase messages to support [iOS app](https://github.com/binwiederhier/ntfy/issues/4) ([#247](https://github.com/binwiederhier/ntfy/pull/247), thanks to [@Copephobia](https://github.com/Copephobia))
|
||||
|
||||
**Bugs:**
|
||||
|
||||
* Support underscores in server.yml config options ([#255](https://github.com/binwiederhier/ntfy/issues/255), thanks to [@ajdelgado](https://github.com/ajdelgado))
|
||||
* Force MAKEFLAGS to --jobs=1 in `Makefile` ([#257](https://github.com/binwiederhier/ntfy/pull/257), thanks to [@oddlama](https://github.com/oddlama))
|
||||
|
||||
**Documentation:**
|
||||
|
||||
* Typo in install instructions ([#252](https://github.com/binwiederhier/ntfy/pull/252)/[#251](https://github.com/binwiederhier/ntfy/issues/251), thanks to [@oddlama](https://github.com/oddlama))
|
||||
* Fix typo in private server example ([#262](https://github.com/binwiederhier/ntfy/pull/262), thanks to [@MayeulC](https://github.com/MayeulC))
|
||||
* [Examples](examples.md) for [jellyseerr](https://github.com/Fallenbagel/jellyseerr)/[overseerr](https://overseerr.dev/) ([#264](https://github.com/binwiederhier/ntfy/pull/264), thanks to [@Fallenbagel](https://github.com/Fallenbagel))
|
||||
|
||||
**Additional translations:**
|
||||
|
||||
* Portuguese/Brazil (thanks to [@tiagotriques](https://hosted.weblate.org/user/tiagotriques/) and [@pireshenrique22](https://hosted.weblate.org/user/pireshenrique22/))
|
||||
|
||||
Thank you to the many translators, who helped translate the new strings so quickly. I am humbled and amazed by your help.
|
||||
|
||||
## ntfy Android app v1.13.0
|
||||
Released May 11, 2022
|
||||
|
||||
This release brings a slightly altered design for the detail view, featuring a card layout to make notifications more easily
|
||||
distinguishable from one another. It also ships per-topic settings that allow overriding minimum priority, auto delete threshold
|
||||
and custom icons. Aside from that, we've got tons of bug fixes as usual.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Per-subscription settings, custom subscription icons ([#155](https://github.com/binwiederhier/ntfy/issues/155), thanks to [@mztiq](https://github.com/mztiq) for reporting)
|
||||
* Cards in notification detail view ([#175](https://github.com/binwiederhier/ntfy/issues/175), thanks to [@cmeis](https://github.com/cmeis) for reporting)
|
||||
|
||||
**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 overlapping ([#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/))
|
||||
|
||||
**Thank you:**
|
||||
|
||||
Thanks to [@cmeis](https://github.com/cmeis), [@StoyanDimitrov](https://github.com/StoyanDimitrov), [@Fallenbagel](https://github.com/Fallenbagel) for testing, and
|
||||
to [@Joeharrison94](https://github.com/Joeharrison94) for the input. And thank you very much to all the translators for catching up so quickly.
|
||||
|
||||
## ntfy server v1.22.0
|
||||
Released May 7, 2022
|
||||
|
||||
This release makes the web app more accessible to people with disabilities, and introduces a "mark as read" icon in the web app.
|
||||
It also fixes a curious bug with WebSockets and Apache and makes the notification sounds in the web app a little quieter.
|
||||
|
||||
We've also improved the documentation a little and added translations for three more languages.
|
||||
|
||||
**Features:**
|
||||
|
||||
* 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 XXXXXXXXXXXXXXXXXXXXXXXXx
|
||||
Released Mar 12, 2022
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
|
||||
4
docs/static/css/extra.css
vendored
@@ -8,6 +8,10 @@
|
||||
width: unset !important;
|
||||
}
|
||||
|
||||
.md-header__topic:first-child {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.md-typeset h4 {
|
||||
font-weight: 500 !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 |
@@ -267,7 +267,7 @@ curl -s "ntfy.sh/mytopic/json?poll=1&sched=1"
|
||||
```
|
||||
|
||||
### Filter messages
|
||||
You can filter which messages are returned based on the well-known message fields `message`, `title`, `priority` and
|
||||
You can filter which messages are returned based on the well-known message fields `id`, `message`, `title`, `priority` and
|
||||
`tags`. Here's an example that only returns messages of high or urgent priority that contains the both tags
|
||||
"zfs-error" and "error". Note that the `priority` filter is a logical OR and the `tags` filter is a logical AND.
|
||||
|
||||
@@ -280,12 +280,13 @@ $ curl "ntfy.sh/alerts/json?priority=high&tags=zfs-error"
|
||||
|
||||
Available filters (all case-insensitive):
|
||||
|
||||
| Filter variable | Alias | Example | Description |
|
||||
|-----------------|---------------------------|------------------------------------|-------------------------------------------------------------------------|
|
||||
| `message` | `X-Message`, `m` | `ntfy.sh/mytopic?message=lalala` | Only return messages that match this exact message string |
|
||||
| `title` | `X-Title`, `t` | `ntfy.sh/mytopic?title=some+title` | Only return messages that match this exact title string |
|
||||
| `priority` | `X-Priority`, `prio`, `p` | `ntfy.sh/mytopic?p=high,urgent` | Only return messages that match *any priority listed* (comma-separated) |
|
||||
| `tags` | `X-Tags`, `tag`, `ta` | `ntfy.sh/mytopic?tags=error,alert` | Only return messages that match *all listed tags* (comma-separated) |
|
||||
| Filter variable | Alias | Example | Description |
|
||||
|-----------------|---------------------------|-----------------------------------------------|-------------------------------------------------------------------------|
|
||||
| `id` | `X-ID` | `ntfy.sh/mytopic/json?poll=1&id=pbkiz8SD7ZxG` | Only return messages that match this exact message ID |
|
||||
| `message` | `X-Message`, `m` | `ntfy.sh/mytopic/json?message=lalala` | Only return messages that match this exact message string |
|
||||
| `title` | `X-Title`, `t` | `ntfy.sh/mytopic/json?title=some+title` | Only return messages that match this exact title string |
|
||||
| `priority` | `X-Priority`, `prio`, `p` | `ntfy.sh/mytopic/json?p=high,urgent` | Only return messages that match *any priority listed* (comma-separated) |
|
||||
| `tags` | `X-Tags`, `tag`, `ta` | `ntfy.sh/mytopic?/jsontags=error,alert` | Only return messages that match *all listed tags* (comma-separated) |
|
||||
|
||||
### Subscribe to multiple topics
|
||||
It's possible to subscribe to multiple topics in one HTTP call by providing a comma-separated list of topics
|
||||
@@ -315,18 +316,19 @@ format of the message. It's very straight forward:
|
||||
|
||||
**Message**:
|
||||
|
||||
| Field | Required | Type | Example | Description |
|
||||
|--------------|----------|---------------------------------------------------|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
|
||||
| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp |
|
||||
| `event` | ✔️ | `open`, `keepalive`, `message`, or `poll_request` | `message` | Message type, typically you'd be only interested in `message` |
|
||||
| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events |
|
||||
| `message` | - | *string* | `Some message` | Message body; always present in `message` events |
|
||||
| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/<topic>` |
|
||||
| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis |
|
||||
| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
||||
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) |
|
||||
| `attachment` | - | *JSON object* | *see below* | Details about an attachment (name, URL, size, ...) |
|
||||
| Field | Required | Type | Example | Description |
|
||||
|--------------|----------|---------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
|
||||
| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp |
|
||||
| `event` | ✔️ | `open`, `keepalive`, `message`, or `poll_request` | `message` | Message type, typically you'd be only interested in `message` |
|
||||
| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events |
|
||||
| `message` | - | *string* | `Some message` | Message body; always present in `message` events |
|
||||
| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/<topic>` |
|
||||
| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis |
|
||||
| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
||||
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) |
|
||||
| `actions` | - | *JSON array* | *see [actions buttons](../publish.md#action-buttons)* | [Action buttons](../publish.md#action-buttons) that can be displayed in the notification |
|
||||
| `attachment` | - | *JSON object* | *see below* | Details about an attachment (name, URL, size, ...) |
|
||||
|
||||
**Attachment** (part of the message, see [attachments](../publish.md#attachments) for details):
|
||||
|
||||
@@ -416,6 +418,7 @@ and can be passed as **HTTP headers** or **query parameters in the URL**. They a
|
||||
| `poll` | `X-Poll`, `po` | Return cached messages and close connection |
|
||||
| `since` | `X-Since`, `si` | Return cached messages since timestamp, duration or message ID |
|
||||
| `scheduled` | `X-Scheduled`, `sched` | Include scheduled/delayed messages in message list |
|
||||
| `id` | `X-ID` | Filter: Only return messages that match this exact message ID |
|
||||
| `message` | `X-Message`, `m` | Filter: Only return messages that match this exact message string |
|
||||
| `title` | `X-Title`, `t` | Filter: Only return messages that match this exact title string |
|
||||
| `priority` | `X-Priority`, `prio`, `p` | Filter: Only return messages that match *any priority listed* (comma-separated) |
|
||||
|
||||
@@ -123,7 +123,7 @@ which will read the `subscribe` config from the config file. Please also check o
|
||||
|
||||
Here's an example config file that subscribes to three different topics, executing a different command for each of them:
|
||||
|
||||
=== "~/.config/ntfy/client.yml"
|
||||
=== "~/.config/ntfy/client.yml (Linux)"
|
||||
```yaml
|
||||
subscribe:
|
||||
- topic: echo-this
|
||||
@@ -145,12 +145,42 @@ Here's an example config file that subscribes to three different topics, executi
|
||||
fi
|
||||
```
|
||||
|
||||
|
||||
=== "~/Library/Application Support/ntfy/client.yml (macOS)"
|
||||
```yaml
|
||||
subscribe:
|
||||
- topic: echo-this
|
||||
command: 'echo "Message received: $message"'
|
||||
- topic: alerts
|
||||
command: osascript -e "display notification \"$message\""
|
||||
if:
|
||||
priority: high,urgent
|
||||
- topic: calc
|
||||
command: open -a Calculator
|
||||
```
|
||||
|
||||
=== "%AppData%\ntfy\client.yml (Windows)"
|
||||
```yaml
|
||||
subscribe:
|
||||
- topic: echo-this
|
||||
command: 'echo Message received: %message%'
|
||||
- topic: alerts
|
||||
command: |
|
||||
notifu /m "%NTFY_MESSAGE%"
|
||||
exit 0
|
||||
if:
|
||||
priority: high,urgent
|
||||
- topic: calc
|
||||
command: calc
|
||||
```
|
||||
|
||||
In this example, when `ntfy subscribe --from-config` is executed:
|
||||
|
||||
* Messages to `echo-this` simply echos to standard out
|
||||
* Messages to `alerts` display as desktop notification for high priority messages using [notify-send](https://manpages.ubuntu.com/manpages/focal/man1/notify-send.1.html)
|
||||
* Messages to `calc` open the gnome calculator 😀 (*because, why not*)
|
||||
* Messages to `print-temp` execute an inline script and print the CPU temperature
|
||||
* Messages to `alerts` display as desktop notification for high priority messages using [notify-send](https://manpages.ubuntu.com/manpages/focal/man1/notify-send.1.html) (Linux),
|
||||
[notifu](https://www.paralint.com/projects/notifu/) (Windows) or `osascript` (macOS)
|
||||
* Messages to `calc` open the calculator 😀 (*because, why not*)
|
||||
* Messages to `print-temp` execute an inline script and print the CPU temperature (Linux version only)
|
||||
|
||||
I hope this shows how powerful this command is. Here's a short video that demonstrates the above example:
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
# Subscribe from your phone
|
||||
You can use the [ntfy Android App](https://play.google.com/store/apps/details?id=io.heckel.ntfy) to receive
|
||||
notifications directly on your phone. Just like the server, this app is also [open source](https://github.com/binwiederhier/ntfy-android).
|
||||
Since I don't have an iPhone or a Mac, I didn't make an iOS app yet. I'd be awesome if [someone else could help out](https://github.com/binwiederhier/ntfy/issues/4).
|
||||
You can use the ntfy [Android App](https://play.google.com/store/apps/details?id=io.heckel.ntfy) or [iOS app](https://apps.apple.com/us/app/ntfy/id1625396347)
|
||||
to receive notifications directly on your phone. Just like the server, this app is also open source, and the code is available
|
||||
on GitHub ([Android](https://github.com/binwiederhier/ntfy-android), [iOS](https://github.com/binwiederhier/ntfy-ios)). Feel free to
|
||||
contribute, or [build your own](../develop.md).
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="../../static/img/badge-googleplay.png"></a>
|
||||
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="../../static/img/badge-fdroid.png"></a>
|
||||
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img src="../../static/img/badge-appstore.png"></a>
|
||||
|
||||
You can get the Android app from both [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) and
|
||||
from [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/). Both are largely identical, with the one exception that
|
||||
the F-Droid flavor does not use Firebase.
|
||||
the F-Droid flavor does not use Firebase. The iOS app can be downloaded from the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).
|
||||
|
||||
## Overview
|
||||
A picture is worth a thousand words. Here are a few screenshots showing what the app looks like. It's all pretty
|
||||
@@ -31,7 +33,9 @@ If those screenshots are still not enough, here's a video:
|
||||
</figure>
|
||||
|
||||
## Message priority
|
||||
When you [publish messages](../publish.md#message-priority) to a topic, you can define a priority. This priority defines
|
||||
_Supported on:_ :material-android: :material-apple:
|
||||
|
||||
When you [publish messages](../publish.md#message-priority) to a topic, you can **define a priority**. This priority defines
|
||||
how urgently Android will notify you about the notification, and whether they make a sound and/or vibrate.
|
||||
|
||||
By default, messages with default priority or higher (>= 3) will vibrate and make a sound. Messages with high or urgent
|
||||
@@ -42,15 +46,25 @@ priority (>= 4) will also show as pop-over, like so:
|
||||
<figcaption>High and urgent notifications show as pop-over</figcaption>
|
||||
</figure>
|
||||
|
||||
You can change these settings in Android by long-pressing on the app, and tapping "Notifications". You can then configure
|
||||
the settings (and custom sounds or vibration) for each of the priorities:
|
||||
You can change these settings in Android by long-pressing on the app, and tapping "Notifications", or from the "Settings"
|
||||
menu under "Channel settings". There is one notification channel for each priority:
|
||||
|
||||
<figure markdown>
|
||||
{ width=500 }
|
||||
{ width=500 }
|
||||
<figcaption>Per-priority channels</figcaption>
|
||||
</figure>
|
||||
|
||||
Per notification channel, you can configure a **channel-specific sound**, whether to **override the Do Not Disturb (DND)**
|
||||
setting, and other settings such as popover or notification dot:
|
||||
|
||||
<figure markdown>
|
||||
{ width=500 }
|
||||
<figcaption>Per-priority sound/vibration settings</figcaption>
|
||||
</figure>
|
||||
|
||||
## Instant delivery
|
||||
_Supported on:_ :material-android:
|
||||
|
||||
Instant delivery allows you to receive messages on your phone instantly, **even when your phone is in doze mode**, i.e.
|
||||
when the screen turns off, and you leave it on the desk for a while. This is achieved with a foreground service, which
|
||||
you'll see as a permanent notification that looks like this:
|
||||
@@ -80,9 +94,43 @@ notifications. Firebase is overall pretty bad at delivering messages in time, bu
|
||||
The ntfy Android app uses Firebase only for the main host `ntfy.sh`, and only in the Google Play flavor of the app.
|
||||
It won't use Firebase for any self-hosted servers, and not at all in the the F-Droid flavor.
|
||||
|
||||
## Share to topic
|
||||
_Supported on:_ :material-android:
|
||||
|
||||
You can share files to a topic using Android's "Share" feature. This works in almost any app that supports sharing files
|
||||
or text, and it's useful for sending yourself links, files or other things. The feature remembers a few of the last topics
|
||||
you shared content to and lists them at the bottom.
|
||||
|
||||
The feature is pretty self-explanatory, and one picture says more than a thousand words. So here are two pictures:
|
||||
|
||||
<div id="share-to-topic-screenshots" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-share-1.jpg"><img src="../../static/img/android-screenshot-share-1.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-share-2.jpg"><img src="../../static/img/android-screenshot-share-2.jpg"/></a>
|
||||
</div>
|
||||
|
||||
## ntfy:// links
|
||||
_Supported on:_ :material-android:
|
||||
|
||||
The ntfy Android app supports deep linking directly to topics. This is useful when integrating with [automation apps](#automation-apps)
|
||||
such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm),
|
||||
or to simply directly link to a topic from a mobile website.
|
||||
|
||||
!!! info
|
||||
Android deep linking of http/https links is very brittle and limited, which is why something like `https://<host>/<topic>/subscribe` is
|
||||
**not possible**, and instead `ntfy://` links have to be used. More details in [issue #20](https://github.com/binwiederhier/ntfy/issues/20).
|
||||
|
||||
**Supported link formats:**
|
||||
|
||||
| Link format | Example | Description |
|
||||
|-------------------------------------------------------------------------------|-------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| <span style="white-space: nowrap">`ntfy://<host>/<topic>`</span> | `ntfy://ntfy.sh/mytopic` | Directly opens the Android app detail view for the given topic and server. Subscribes to the topic if not already subscribed. This is equivalent to the web view `https://ntfy.sh/mytopic` (HTTPS!) |
|
||||
| <span style="white-space: nowrap">`ntfy://<host>/<topic>?secure=false`</span> | `ntfy://example.com/mytopic?secure=false` | Same as above, except that this will use HTTP instead of HTTPS as topic URL. This is equivalent to the web view `http://example.com/mytopic` (HTTP!) |
|
||||
|
||||
## Integrations
|
||||
|
||||
### UnifiedPush
|
||||
_Supported on:_ :material-android:
|
||||
|
||||
[UnifiedPush](https://unifiedpush.org) is a standard for receiving push notifications without using the Google-owned
|
||||
[Firebase Cloud Messaging (FCM)](https://firebase.google.com/docs/cloud-messaging) service. It puts push notifications
|
||||
in the control of the user. ntfy can act as a **UnifiedPush distributor**, forwarding messages to apps that support it.
|
||||
@@ -98,6 +146,8 @@ to handle messages. Here's an example with [FluffyChat](https://fluffychat.im/):
|
||||
</div>
|
||||
|
||||
### Automation apps
|
||||
_Supported on:_ :material-android:
|
||||
|
||||
The ntfy Android app integrates nicely with automation apps such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
||||
or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm). Using Android intents, you can
|
||||
**react to incoming messages**, as well as **send messages**.
|
||||
@@ -130,19 +180,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`:
|
||||
|
||||
| Extra name | Type | Example | Description |
|
||||
|---|---|---|---|
|
||||
| `id` | *string* | `bP8dMjO8ig` | Randomly chosen message identifier (likely not very useful for task automation) |
|
||||
| `base_url` | *string* | `https://ntfy.sh` | Root URL of the ntfy server this message came from |
|
||||
| `topic` ❤️ | *string* | `mytopic` | Topic name; **you'll likely want to filter for a specific topic** |
|
||||
| `muted` | *bool* | `true` | Indicates whether the subscription was muted in the app |
|
||||
| `muted_str` | *string (`true` or `false`)* | `true` | Same as `muted`, but as string `true` or `false` |
|
||||
| `time` | *int* | `1635528741` | Message date time, as Unix time stamp |
|
||||
| `title` | *string* | `Some title` | Message [title](../publish.md#message-title); may be empty if not set |
|
||||
| `message` ❤️ | *string* | `Some message` | Message body; **this is likely what you're interested in** |
|
||||
| `tags` | *string* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
|
||||
| `tags_map` | *string* | `0=tag1,1=tag2,..` | Map of tags to make it easier to map first, second, ... tag |
|
||||
| `priority` | *int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
||||
| Extra name | Type | Example | Description |
|
||||
|-----------------|------------------------------|--------------------|------------------------------------------------------------------------------------|
|
||||
| `id` | *String* | `bP8dMjO8ig` | Randomly chosen message identifier (likely not very useful for task automation) |
|
||||
| `base_url` | *String* | `https://ntfy.sh` | Root URL of the ntfy server this message came from |
|
||||
| `topic` ❤️ | *String* | `mytopic` | Topic name; **you'll likely want to filter for a specific topic** |
|
||||
| `muted` | *Boolean* | `true` | Indicates whether the subscription was muted in the app |
|
||||
| `muted_str` | *String (`true` or `false`)* | `true` | Same as `muted`, but as string `true` or `false` |
|
||||
| `time` | *Int* | `1635528741` | Message date time, as Unix time stamp |
|
||||
| `title` | *String* | `Some title` | Message [title](../publish.md#message-title); may be empty if not set |
|
||||
| `message` ❤️ | *String* | `Some message` | Message body; **this is likely what you're interested in** |
|
||||
| `message_bytes` | *ByteArray* | `(binary data)` | Message body as binary data |
|
||||
| `encoding`️ | *String* | - | Message encoding (empty or "base64") |
|
||||
| `tags` | *String* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
|
||||
| `tags_map` | *String* | `0=tag1,1=tag2,..` | Map of tags to make it easier to map first, second, ... tag |
|
||||
| `priority` | *Int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
||||
|
||||
#### Send messages using intents
|
||||
To send messages from other apps (such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
||||
@@ -164,17 +216,11 @@ Here's what that looks like:
|
||||
|
||||
The following intent extras are supported when for the intent with the `io.heckel.ntfy.SEND_MESSAGE` action:
|
||||
|
||||
| Extra name | Required | Type | Example | Description |
|
||||
|---|---|---|---|---|
|
||||
| `base_url` | - | *string* | `https://ntfy.sh` | Root URL of the ntfy server this message came from, defaults to `https://ntfy.sh` |
|
||||
| `topic` ❤️ | ✔ | *string* | `mytopic` | Topic name; **you must set this** |
|
||||
| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); may be empty if not set |
|
||||
| `message` ❤️ | ✔ | *string* | `Some message` | Message body; **you must set this** |
|
||||
| `tags` | - | *string* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
|
||||
| `priority` | - | *string or int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
||||
|
||||
## iPhone/iOS
|
||||
I almost feel devious for putting the *Download on the App Store* button on this page. Currently, there is no iOS app
|
||||
for ntfy, but it's in the works. You can track the status on GitHub.
|
||||
|
||||
<a href="https://github.com/binwiederhier/ntfy/issues/4"><img src="../../static/img/badge-appstore.png"></a>
|
||||
| Extra name | Required | Type | Example | Description |
|
||||
|--------------|----------|-------------------------------|-------------------|------------------------------------------------------------------------------------|
|
||||
| `base_url` | - | *String* | `https://ntfy.sh` | Root URL of the ntfy server this message came from, defaults to `https://ntfy.sh` |
|
||||
| `topic` ❤️ | ✔ | *String* | `mytopic` | Topic name; **you must set this** |
|
||||
| `title` | - | *String* | `Some title` | Message [title](../publish.md#message-title); may be empty if not set |
|
||||
| `message` ❤️ | ✔ | *String* | `Some message` | Message body; **you must set this** |
|
||||
| `tags` | - | *String* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
|
||||
| `priority` | - | *String or Int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
||||
|
||||
58
go.mod
@@ -4,48 +4,54 @@ go 1.17
|
||||
|
||||
require (
|
||||
cloud.google.com/go/firestore v1.6.1 // indirect
|
||||
cloud.google.com/go/storage v1.19.0 // indirect
|
||||
firebase.google.com/go v3.13.0+incompatible
|
||||
github.com/BurntSushi/toml v1.0.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
|
||||
cloud.google.com/go/storage v1.22.1 // indirect
|
||||
github.com/BurntSushi/toml v1.1.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
||||
github.com/emersion/go-smtp v0.15.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.0
|
||||
github.com/gorilla/websocket v1.4.2
|
||||
github.com/mattn/go-sqlite3 v1.14.11
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/mattn/go-sqlite3 v1.14.13
|
||||
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6
|
||||
github.com/stretchr/testify v1.7.0
|
||||
github.com/urfave/cli/v2 v2.3.0
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
|
||||
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
|
||||
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11
|
||||
google.golang.org/api v0.67.0
|
||||
github.com/urfave/cli/v2 v2.8.1
|
||||
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e
|
||||
golang.org/x/oauth2 v0.0.0-20220524215830-622c5d57e401 // indirect
|
||||
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f
|
||||
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467
|
||||
golang.org/x/time v0.0.0-20220411224347-583f2d630306
|
||||
google.golang.org/api v0.82.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
require github.com/pkg/errors v0.9.1 // indirect
|
||||
|
||||
require firebase.google.com/go/v4 v4.8.0
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.100.2 // indirect
|
||||
cloud.google.com/go/compute v1.2.0 // indirect
|
||||
cloud.google.com/go/iam v0.1.1 // indirect
|
||||
github.com/AlekSi/pointer v1.0.0 // indirect
|
||||
cloud.google.com/go v0.102.0 // indirect
|
||||
cloud.google.com/go/compute v1.6.1 // indirect
|
||||
cloud.google.com/go/iam v0.3.0 // indirect
|
||||
github.com/AlekSi/pointer v1.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/google/go-cmp v0.5.7 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.1.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/google/go-cmp v0.5.8 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.4.0 // indirect
|
||||
github.com/googleapis/go-type-adapters v1.0.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||
go.opencensus.io v0.23.0 // indirect
|
||||
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d // indirect
|
||||
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27 // indirect
|
||||
golang.org/x/net v0.0.0-20220531201128-c960675eff93 // indirect
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // 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-20220517211312-f3a8303e98df // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/genproto v0.0.0-20220203182621-f4ae394cde3f // indirect
|
||||
google.golang.org/grpc v1.44.0 // indirect
|
||||
google.golang.org/protobuf v1.27.1 // indirect
|
||||
google.golang.org/appengine/v2 v2.0.1 // indirect
|
||||
google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8 // indirect
|
||||
google.golang.org/grpc v1.47.0 // indirect
|
||||
google.golang.org/protobuf v1.28.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
|
||||
)
|
||||
|
||||
154
go.sum
@@ -27,8 +27,9 @@ cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW
|
||||
cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
|
||||
cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
|
||||
cloud.google.com/go v0.100.1/go.mod h1:fs4QogzfH5n2pBXBP9vRiU+eCny7lD2vmFZy79Iuw1U=
|
||||
cloud.google.com/go v0.100.2 h1:t9Iw5QH5v4XtlEQaCtUY7x6sCABps8sW0acw7e2WQ6Y=
|
||||
cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A=
|
||||
cloud.google.com/go v0.102.0 h1:DAq3r8y4mDgyB/ZPJ9v/5VJNqjgJAxTn6ZYLlUywOu8=
|
||||
cloud.google.com/go v0.102.0/go.mod h1:oWcCzKlqJ5zgHQt9YsaeTY9KzIvjyy0ArmiBUgpQ+nc=
|
||||
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
||||
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
||||
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
||||
@@ -36,14 +37,19 @@ cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUM
|
||||
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
|
||||
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
|
||||
cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow=
|
||||
cloud.google.com/go/compute v1.2.0 h1:EKki8sSdvDU0OO9mAXGwPXOTOgPz2l08R0/IutDH11I=
|
||||
cloud.google.com/go/compute v1.2.0/go.mod h1:xlogom/6gr8RJGBe7nT2eGsQYAFUbbv8dbC29qE3Xmw=
|
||||
cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM=
|
||||
cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M=
|
||||
cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s=
|
||||
cloud.google.com/go/compute v1.6.1 h1:2sMmt8prCn7DPaG4Pmh0N3Inmc8cT8ae5k1M6VJ9Wqc=
|
||||
cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU=
|
||||
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
||||
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
||||
cloud.google.com/go/firestore v1.6.1 h1:8rBq3zRjnHx8UtBvaOWqBB1xq9jH6/wltfQLlTMh2Fw=
|
||||
cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
|
||||
cloud.google.com/go/iam v0.1.1 h1:4CapQyNFjiksks1/x7jsvsygFPhihslYk5GptIrlX68=
|
||||
cloud.google.com/go/iam v0.1.1/go.mod h1:CKqrcnI/suGpybEHxZ7BMehL0oA4LpdyJdUlTl9jVMw=
|
||||
cloud.google.com/go/iam v0.3.0 h1:exkAomrVUuzx9kWFI1wm3KI0uoDeUFPB4kKGzx6x+Gc=
|
||||
cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY=
|
||||
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
||||
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
||||
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
|
||||
@@ -53,16 +59,18 @@ 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.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.19.0 h1:XOQSnPJD8hRtZJ3VdCyK0mBZsGGImrzPAMbSWcHSe6Q=
|
||||
cloud.google.com/go/storage v1.19.0/go.mod h1:6rgiTRjOqI/Zd9YKimub5TIB4d+p3LH33V3ZE1DMuUM=
|
||||
cloud.google.com/go/storage v1.21.0/go.mod h1:XmRlxkgPjlBONznT2dDUU/5XlpU2OjMnKuqnZI01LAA=
|
||||
cloud.google.com/go/storage v1.22.1 h1:F6IlQJZrZM++apn9V5/VfS3gbTUYg98PS3EMQAzqtfg=
|
||||
cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq6kuBTW58Y=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=
|
||||
firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=
|
||||
github.com/AlekSi/pointer v1.0.0 h1:KWCWzsvFxNLcmM5XmiqHsGTTsuwZMsLFwWF9Y+//bNE=
|
||||
firebase.google.com/go/v4 v4.8.0 h1:ooJqjFEh1G6DQ5+wyb/RAXAgku0E2RzJeH6WauSpWSo=
|
||||
firebase.google.com/go/v4 v4.8.0/go.mod h1:y+j6xX7BgBco/XaN+YExIBVm6pzvYutheDV3nprvbWc=
|
||||
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 v1.0.0 h1:dtDWrepsVPfW9H/4y7dDgFc2MBUSeJhlaDtK13CxFlU=
|
||||
github.com/BurntSushi/toml v1.0.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||
github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I=
|
||||
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/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
@@ -80,10 +88,11 @@ github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XP
|
||||
github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.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/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -100,6 +109,7 @@ 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.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
|
||||
github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
|
||||
github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/gabriel-vasile/mimetype v1.4.0 h1:Cn9dkdYsMIu56tGho+fqzh7XmvY2YyGU0FnbhiOsEro=
|
||||
github.com/gabriel-vasile/mimetype v1.4.0/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmxRtOJlERCzSmRvr8=
|
||||
@@ -155,8 +165,9 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
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.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/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
|
||||
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||
@@ -179,13 +190,20 @@ github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLe
|
||||
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/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.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.1 h1:dp3bWCh+PPO1zjRRiCSczJav13sBvG4UhNyVTa1KqdU=
|
||||
github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
|
||||
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM=
|
||||
github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM=
|
||||
github.com/googleapis/gax-go/v2 v2.4.0 h1:dS9eYAjhrE2RjmzYw2XAPvcXfmcQLtFEQWn0CR82awk=
|
||||
github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK9wbMD5+iXC6c=
|
||||
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/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=
|
||||
@@ -199,8 +217,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/mattn/go-sqlite3 v1.14.11 h1:gt+cp9c0XGqe9S/wAHTL3n/7MqY+siPWgWJgqdsFrzQ=
|
||||
github.com/mattn/go-sqlite3 v1.14.11/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/mattn/go-sqlite3 v1.14.13 h1:1tj15ngiFfcZzii7yd82foL+ks+ouQcj8j/TPq3fk1I=
|
||||
github.com/mattn/go-sqlite3 v1.14.13/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/go.mod h1:DPucAeQGDPUzYUt+NaWw6qsF5SFapWWToxEiVDh2aV0=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@@ -211,10 +229,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/russross/blackfriday/v2 v2.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/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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
@@ -223,8 +239,10 @@ 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.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
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.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
|
||||
github.com/urfave/cli/v2 v2.8.1 h1:CGuYNZF9IKZY/rfBe3lJpccSoIY1ytfvmgQT90cNOl4=
|
||||
github.com/urfave/cli/v2 v2.8.1/go.mod h1:Z41J9TPoffeoqP0Iza0YbAhGvymRdZAd2uPmZ5JxRdY=
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
||||
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.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
@@ -243,8 +261,9 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e h1:T8NU3HyQ8ClP4SEE+KbFlg6n0NhuTsN4MyznaarGsZM=
|
||||
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/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-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@@ -316,8 +335,15 @@ 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-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-20210813160813-60bc85c4be6d h1:LO7XpTYMwTqxjLcGWPijK3vRXg1aWdlNOVOHRq45d7c=
|
||||
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220526153639-5463443f8c37/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.0.0-20220531201128-c960675eff93 h1:MYimHLfoXEpOhqd/zgoA/uoXzHB86AEky4LAx5ij9xA=
|
||||
golang.org/x/net v0.0.0-20220531201128-c960675eff93/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
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-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@@ -334,8 +360,12 @@ 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-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 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-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/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
||||
golang.org/x/oauth2 v0.0.0-20220524215830-622c5d57e401 h1:zwrSfklXn0gxyLRX/aR+q6cgHbV/ItVyzbPlbA+dkAw=
|
||||
golang.org/x/oauth2 v0.0.0-20220524215830-622c5d57e401/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-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -346,8 +376,10 @@ 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-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220513210516-0976fa681c29/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f h1:Ax0t5p6N38Ga0dThY21weqDEyz2oklo4IvDkpigvkD8=
|
||||
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -399,11 +431,20 @@ golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
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-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27 h1:XDXtA5hveEEV8JB2l7nhMTp3t3cHp9ZpwcdjqyEWLlo=
|
||||
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-20220310020820-b874c991c1a5/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-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM=
|
||||
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/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.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -417,8 +458,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-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-20211116232009-f0f3c7e86c11 h1:GZokNIeuVkl3aZHJchRrr13WCsols02MLUcz1U9is6M=
|
||||
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20220411224347-583f2d630306 h1:+gHMid33q6pen7kv9xvT+JRinntgeXO2AeZVd0AWD3w=
|
||||
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-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
@@ -473,8 +514,10 @@ golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-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-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df h1:5Pf6pFKu98ODmgnpvkJ3kFUOQGGLIzLIkbzUHp47618=
|
||||
golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
|
||||
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.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
@@ -508,10 +551,18 @@ google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUb
|
||||
google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
|
||||
google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo=
|
||||
google.golang.org/api v0.64.0/go.mod h1:931CdxA8Rm4t6zqTFGSsgwbAEZ2+GMYurbndwSimebM=
|
||||
google.golang.org/api v0.65.0/go.mod h1:ArYhxgGadlWmqO1IqVujw6Cs8IdD33bTmzKo2Sh+cbg=
|
||||
google.golang.org/api v0.66.0/go.mod h1:I1dmXYpX7HGwz/ejRxwQp2qj5bFAz93HiCU1C1oYd9M=
|
||||
google.golang.org/api v0.67.0 h1:lYaaLa+x3VVUhtosaK9xihwQ9H9KRa557REHwwZ2orM=
|
||||
google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g=
|
||||
google.golang.org/api v0.69.0/go.mod h1:boanBiw+h5c3s+tBPgEzLDRHfFLWV0qXxRHz3ws7C80=
|
||||
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.73.0/go.mod h1:lbd/q6BRFJbdpV6OUCXstVeiI5mL/d3/WifG7iNKnjI=
|
||||
google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs=
|
||||
google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA=
|
||||
google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw=
|
||||
google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3Hyg=
|
||||
google.golang.org/api v0.82.0 h1:h6EGeZuzhoKSS7BUznzkW+2wHZ+4Ubd6rsVvvh3dRkw=
|
||||
google.golang.org/api v0.82.0/go.mod h1:Ld58BeTlL9DIYr2M2ajvoSqmGLei0BMn+kVBmkam1os=
|
||||
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.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
@@ -520,6 +571,8 @@ google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCID
|
||||
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/appengine/v2 v2.0.1 h1:jTGfiRmR5qoInpT3CXJ72GJEB4owDGEKN+xRDA6ekBY=
|
||||
google.golang.org/appengine/v2 v2.0.1/go.mod h1:XgltgQxPOF3ShivrVrZyfvYCx8Dunh73bKjUuXUZb8Q=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
@@ -559,6 +612,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-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20210329143202-679c6ae281ee/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
|
||||
google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
|
||||
google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
|
||||
google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
|
||||
@@ -583,14 +637,29 @@ google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ6
|
||||
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-20211223182754-3ac035c7e7cb/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20220107163113-42d7afdf6368/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20220111164026-67b88f271998/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20220114231437-d2e6a121cae0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20220118154757-00ab72f36ad5/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20220201184016-50beb8ab5c44/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20220203182621-f4ae394cde3f h1:w9Sx4FBkwsN0jMZz8E42tMdmhZ5b2Z/vFx2LKAxxI9o=
|
||||
google.golang.org/genproto v0.0.0-20220203182621-f4ae394cde3f/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20220211171837-173942840c17/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
||||
google.golang.org/genproto v0.0.0-20220216160803-4663080d8bc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
|
||||
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-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-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
||||
google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
|
||||
google.golang.org/genproto v0.0.0-20220518221133-4f43b3371335/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
|
||||
google.golang.org/genproto v0.0.0-20220523171625-347a074981d8/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
|
||||
google.golang.org/genproto v0.0.0-20220527130721-00d5c0f3be58/go.mod h1:yKyY4AMRwFiC8yMMNaMi+RkCnjZJt9LoWuvhXjMs+To=
|
||||
google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8 h1:qRu95HZ148xXw+XeZ3dvqe85PxH4X8+jIo0iRPKcEnM=
|
||||
google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8/go.mod h1:yKyY4AMRwFiC8yMMNaMi+RkCnjZJt9LoWuvhXjMs+To=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
@@ -617,8 +686,12 @@ 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.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
|
||||
google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
|
||||
google.golang.org/grpc v1.44.0 h1:weqSxi/TMs1SqFRMHCtBgXRs8k3X39QIDEZ0pRcttUg=
|
||||
google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
|
||||
google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
|
||||
google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
|
||||
google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
|
||||
google.golang.org/grpc v1.47.0 h1:9n77onPX5F3qfFCqjy9dhn8PbNQsIKeVU04J9G7umt8=
|
||||
google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
|
||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
@@ -632,8 +705,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.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.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
|
||||
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 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
129
log/log.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Level is a well-known log level, as defined below
|
||||
type Level int
|
||||
|
||||
// Well known log levels
|
||||
const (
|
||||
TraceLevel Level = iota
|
||||
DebugLevel
|
||||
InfoLevel
|
||||
WarnLevel
|
||||
ErrorLevel
|
||||
)
|
||||
|
||||
func (l Level) String() string {
|
||||
switch l {
|
||||
case TraceLevel:
|
||||
return "TRACE"
|
||||
case DebugLevel:
|
||||
return "DEBUG"
|
||||
case InfoLevel:
|
||||
return "INFO"
|
||||
case WarnLevel:
|
||||
return "WARN"
|
||||
case ErrorLevel:
|
||||
return "ERROR"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
var (
|
||||
level = InfoLevel
|
||||
mu = &sync.Mutex{}
|
||||
)
|
||||
|
||||
// Trace prints the given message, if the current log level is TRACE
|
||||
func Trace(message string, v ...interface{}) {
|
||||
logIf(TraceLevel, message, v...)
|
||||
}
|
||||
|
||||
// Debug prints the given message, if the current log level is DEBUG or lower
|
||||
func Debug(message string, v ...interface{}) {
|
||||
logIf(DebugLevel, message, v...)
|
||||
}
|
||||
|
||||
// Info prints the given message, if the current log level is INFO or lower
|
||||
func Info(message string, v ...interface{}) {
|
||||
logIf(InfoLevel, message, v...)
|
||||
}
|
||||
|
||||
// Warn prints the given message, if the current log level is WARN or lower
|
||||
func Warn(message string, v ...interface{}) {
|
||||
logIf(WarnLevel, message, v...)
|
||||
}
|
||||
|
||||
// Error prints the given message, if the current log level is ERROR or lower
|
||||
func Error(message string, v ...interface{}) {
|
||||
logIf(ErrorLevel, message, v...)
|
||||
}
|
||||
|
||||
// Fatal prints the given message, and exits the program
|
||||
func Fatal(v ...interface{}) {
|
||||
log.Fatalln(v...)
|
||||
}
|
||||
|
||||
// CurrentLevel returns the current log level
|
||||
func CurrentLevel() Level {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return level
|
||||
}
|
||||
|
||||
// SetLevel sets a new log level
|
||||
func SetLevel(newLevel Level) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
level = newLevel
|
||||
}
|
||||
|
||||
// DisableDates disables the date/time prefix
|
||||
func DisableDates() {
|
||||
log.SetFlags(0)
|
||||
}
|
||||
|
||||
// ToLevel converts a string to a Level. It returns InfoLevel if the string
|
||||
// does not match any known log levels.
|
||||
func ToLevel(s string) Level {
|
||||
switch strings.ToUpper(s) {
|
||||
case "TRACE":
|
||||
return TraceLevel
|
||||
case "DEBUG":
|
||||
return DebugLevel
|
||||
case "INFO":
|
||||
return InfoLevel
|
||||
case "WARN", "WARNING":
|
||||
return WarnLevel
|
||||
case "ERROR":
|
||||
return ErrorLevel
|
||||
default:
|
||||
return InfoLevel
|
||||
}
|
||||
}
|
||||
|
||||
// Loggable returns true if the given log level is lower or equal to the current log level
|
||||
func Loggable(l Level) bool {
|
||||
return CurrentLevel() <= l
|
||||
}
|
||||
|
||||
// IsTrace returns true if the current log level is TraceLevel
|
||||
func IsTrace() bool {
|
||||
return Loggable(TraceLevel)
|
||||
}
|
||||
|
||||
// IsDebug returns true if the current log level is DebugLevel or below
|
||||
func IsDebug() bool {
|
||||
return Loggable(DebugLevel)
|
||||
}
|
||||
|
||||
func logIf(l Level, message string, v ...interface{}) {
|
||||
if CurrentLevel() <= l {
|
||||
log.Printf(l.String()+" "+message, v...)
|
||||
}
|
||||
}
|
||||
5
main.go
@@ -19,10 +19,11 @@ func main() {
|
||||
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.
|
||||
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
|
||||
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)
|
||||
|
||||
app := cmd.New()
|
||||
|
||||
@@ -61,6 +61,9 @@ markdown_extensions:
|
||||
custom_checkbox: true
|
||||
- attr_list
|
||||
- md_in_html
|
||||
- pymdownx.emoji:
|
||||
emoji_index: !!python/name:materialx.emoji.twemoji
|
||||
emoji_generator: !!python/name:materialx.emoji.to_svg
|
||||
|
||||
plugins:
|
||||
- search
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
# 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-minify-plugin
|
||||
|
||||
@@ -10,7 +10,7 @@ if [ -z "$1" ]; then
|
||||
echo "Syntax: $0 FILE.(js|json|md)"
|
||||
echo "Example:"
|
||||
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"
|
||||
exit 1
|
||||
fi
|
||||
@@ -19,7 +19,7 @@ if [[ "$1" == *.js ]]; then
|
||||
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
|
||||
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
|
||||
echo "# Emoji reference
|
||||
|
||||
|
||||
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)")
|
||||
|
||||
}
|
||||
@@ -6,14 +6,16 @@ import (
|
||||
|
||||
// Defines default config settings (excluding limits, see below)
|
||||
const (
|
||||
DefaultListenHTTP = ":80"
|
||||
DefaultCacheDuration = 12 * time.Hour
|
||||
DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!)
|
||||
DefaultManagerInterval = time.Minute
|
||||
DefaultAtSenderInterval = 10 * time.Second
|
||||
DefaultMinDelay = 10 * time.Second
|
||||
DefaultMaxDelay = 3 * 24 * time.Hour
|
||||
DefaultFirebaseKeepaliveInterval = 3 * time.Hour // Not too frequently to save battery
|
||||
DefaultListenHTTP = ":80"
|
||||
DefaultCacheDuration = 12 * time.Hour
|
||||
DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!)
|
||||
DefaultManagerInterval = time.Minute
|
||||
DefaultDelayedSenderInterval = 10 * time.Second
|
||||
DefaultMinDelay = 10 * time.Second
|
||||
DefaultMaxDelay = 3 * 24 * time.Hour
|
||||
DefaultFirebaseKeepaliveInterval = 3 * time.Hour // ~control topic (Android), not too frequently to save battery
|
||||
DefaultFirebasePollInterval = 20 * time.Minute // ~poll topic (iOS), max. 2-3 times per hour (see docs)
|
||||
DefaultFirebaseQuotaExceededPenaltyDuration = 10 * time.Minute // Time that over-users are locked out of Firebase if it returns "quota exceeded"
|
||||
)
|
||||
|
||||
// Defines all global and per-visitor limits
|
||||
@@ -65,8 +67,11 @@ type Config struct {
|
||||
KeepaliveInterval time.Duration
|
||||
ManagerInterval time.Duration
|
||||
WebRootIsApp bool
|
||||
AtSenderInterval time.Duration
|
||||
DelayedSenderInterval time.Duration
|
||||
FirebaseKeepaliveInterval time.Duration
|
||||
FirebasePollInterval time.Duration
|
||||
FirebaseQuotaExceededPenaltyDuration time.Duration
|
||||
UpstreamBaseURL string
|
||||
SMTPSenderAddr string
|
||||
SMTPSenderUser string
|
||||
SMTPSenderPass string
|
||||
@@ -88,6 +93,7 @@ type Config struct {
|
||||
VisitorEmailLimitBurst int
|
||||
VisitorEmailLimitReplenish time.Duration
|
||||
BehindProxy bool
|
||||
EnableWeb bool
|
||||
}
|
||||
|
||||
// NewConfig instantiates a default new server config
|
||||
@@ -114,8 +120,10 @@ func NewConfig() *Config {
|
||||
MessageLimit: DefaultMessageLengthLimit,
|
||||
MinDelay: DefaultMinDelay,
|
||||
MaxDelay: DefaultMaxDelay,
|
||||
AtSenderInterval: DefaultAtSenderInterval,
|
||||
DelayedSenderInterval: DefaultDelayedSenderInterval,
|
||||
FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval,
|
||||
FirebasePollInterval: DefaultFirebasePollInterval,
|
||||
FirebaseQuotaExceededPenaltyDuration: DefaultFirebaseQuotaExceededPenaltyDuration,
|
||||
TotalTopicLimit: DefaultTotalTopicLimit,
|
||||
VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit,
|
||||
VisitorAttachmentTotalSizeLimit: DefaultVisitorAttachmentTotalSizeLimit,
|
||||
@@ -126,5 +134,6 @@ func NewConfig() *Config {
|
||||
VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst,
|
||||
VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish,
|
||||
BehindProxy: false,
|
||||
EnableWeb: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
@@ -22,6 +23,15 @@ func (e errHTTP) JSON() string {
|
||||
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", ""}
|
||||
@@ -34,14 +44,16 @@ var (
|
||||
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", ""}
|
||||
errHTTPBadRequestAttachmentTooLarge = &errHTTP{40012, http.StatusBadRequest, "invalid request: attachment too large, or bandwidth limit reached", ""}
|
||||
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", ""}
|
||||
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", ""}
|
||||
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", ""}
|
||||
errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", ""}
|
||||
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"}
|
||||
|
||||
@@ -2,11 +2,12 @@ package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||
"heckel.io/ntfy/log"
|
||||
"heckel.io/ntfy/util"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -29,12 +30,13 @@ const (
|
||||
priority INT NOT NULL,
|
||||
tags TEXT NOT NULL,
|
||||
click TEXT NOT NULL,
|
||||
actions 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,
|
||||
sender TEXT NOT NULL,
|
||||
encoding TEXT NOT NULL,
|
||||
published INT NOT NULL
|
||||
);
|
||||
@@ -43,37 +45,37 @@ const (
|
||||
COMMIT;
|
||||
`
|
||||
insertMessageQuery = `
|
||||
INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, published)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
pruneMessagesQuery = `DELETE FROM messages WHERE time < ? AND published = 1`
|
||||
selectRowIDFromMessageID = `SELECT id FROM messages WHERE topic = ? AND mid = ?`
|
||||
selectMessagesSinceTimeQuery = `
|
||||
SELECT mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
|
||||
FROM messages
|
||||
WHERE topic = ? AND time >= ? AND published = 1
|
||||
ORDER BY time, id
|
||||
`
|
||||
selectMessagesSinceTimeIncludeScheduledQuery = `
|
||||
SELECT mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
|
||||
FROM messages
|
||||
WHERE topic = ? AND time >= ?
|
||||
ORDER BY time, id
|
||||
`
|
||||
selectMessagesSinceIDQuery = `
|
||||
SELECT mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
|
||||
FROM messages
|
||||
WHERE topic = ? AND id > ? AND published = 1
|
||||
ORDER BY time, id
|
||||
`
|
||||
selectMessagesSinceIDIncludeScheduledQuery = `
|
||||
SELECT mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
|
||||
FROM messages
|
||||
WHERE topic = ? AND (id > ? OR published = 0)
|
||||
ORDER BY time, id
|
||||
`
|
||||
selectMessagesDueQuery = `
|
||||
SELECT mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
|
||||
FROM messages
|
||||
WHERE time <= ? AND published = 0
|
||||
ORDER BY time, id
|
||||
@@ -82,13 +84,13 @@ const (
|
||||
selectMessagesCountQuery = `SELECT COUNT(*) FROM messages`
|
||||
selectMessageCountForTopicQuery = `SELECT COUNT(*) FROM messages WHERE topic = ?`
|
||||
selectTopicsQuery = `SELECT topic FROM messages GROUP BY topic`
|
||||
selectAttachmentsSizeQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE attachment_owner = ? AND attachment_expires >= ?`
|
||||
selectAttachmentsSizeQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE sender = ? AND attachment_expires >= ?`
|
||||
selectAttachmentsExpiredQuery = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires < ?`
|
||||
)
|
||||
|
||||
// Schema management queries
|
||||
const (
|
||||
currentSchemaVersion = 5
|
||||
currentSchemaVersion = 7
|
||||
createSchemaVersionTableQuery = `
|
||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||
id INT PRIMARY KEY,
|
||||
@@ -166,6 +168,16 @@ const (
|
||||
ALTER TABLE messages_new RENAME TO messages;
|
||||
COMMIT;
|
||||
`
|
||||
|
||||
// 5 -> 6
|
||||
migrate5To6AlterMessagesTableQuery = `
|
||||
ALTER TABLE messages ADD COLUMN actions TEXT NOT NULL DEFAULT('');
|
||||
`
|
||||
|
||||
// 6 -> 7
|
||||
migrate6To7AlterMessagesTableQuery = `
|
||||
ALTER TABLE messages RENAME COLUMN attachment_owner TO sender;
|
||||
`
|
||||
)
|
||||
|
||||
type messageCache struct {
|
||||
@@ -218,7 +230,7 @@ func (c *messageCache) AddMessage(m *message) error {
|
||||
}
|
||||
published := m.Time <= time.Now().Unix()
|
||||
tags := strings.Join(m.Tags, ",")
|
||||
var attachmentName, attachmentType, attachmentURL, attachmentOwner string
|
||||
var attachmentName, attachmentType, attachmentURL string
|
||||
var attachmentSize, attachmentExpires int64
|
||||
if m.Attachment != nil {
|
||||
attachmentName = m.Attachment.Name
|
||||
@@ -226,7 +238,14 @@ func (c *messageCache) AddMessage(m *message) error {
|
||||
attachmentSize = m.Attachment.Size
|
||||
attachmentExpires = m.Attachment.Expires
|
||||
attachmentURL = m.Attachment.URL
|
||||
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(
|
||||
insertMessageQuery,
|
||||
@@ -238,12 +257,13 @@ func (c *messageCache) AddMessage(m *message) error {
|
||||
m.Priority,
|
||||
tags,
|
||||
m.Click,
|
||||
actionsStr,
|
||||
attachmentName,
|
||||
attachmentType,
|
||||
attachmentSize,
|
||||
attachmentExpires,
|
||||
attachmentURL,
|
||||
attachmentOwner,
|
||||
m.Sender,
|
||||
m.Encoding,
|
||||
published,
|
||||
)
|
||||
@@ -355,8 +375,8 @@ func (c *messageCache) Prune(olderThan time.Time) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *messageCache) AttachmentsSize(owner string) (int64, error) {
|
||||
rows, err := c.db.Query(selectAttachmentsSizeQuery, owner, time.Now().Unix())
|
||||
func (c *messageCache) AttachmentBytesUsed(sender string) (int64, error) {
|
||||
rows, err := c.db.Query(selectAttachmentsSizeQuery, sender, time.Now().Unix())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -399,7 +419,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
||||
for rows.Next() {
|
||||
var timestamp, attachmentSize, attachmentExpires int64
|
||||
var priority int
|
||||
var id, topic, msg, title, tagsStr, click, attachmentName, attachmentType, attachmentURL, attachmentOwner, encoding string
|
||||
var id, topic, msg, title, tagsStr, click, actionsStr, attachmentName, attachmentType, attachmentURL, sender, encoding string
|
||||
err := rows.Scan(
|
||||
&id,
|
||||
×tamp,
|
||||
@@ -409,12 +429,13 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
||||
&priority,
|
||||
&tagsStr,
|
||||
&click,
|
||||
&actionsStr,
|
||||
&attachmentName,
|
||||
&attachmentType,
|
||||
&attachmentSize,
|
||||
&attachmentExpires,
|
||||
&attachmentURL,
|
||||
&attachmentOwner,
|
||||
&sender,
|
||||
&encoding,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -424,6 +445,12 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
||||
if 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
|
||||
if attachmentName != "" && attachmentURL != "" {
|
||||
att = &attachment{
|
||||
@@ -432,7 +459,6 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
||||
Size: attachmentSize,
|
||||
Expires: attachmentExpires,
|
||||
URL: attachmentURL,
|
||||
Owner: attachmentOwner,
|
||||
}
|
||||
}
|
||||
messages = append(messages, &message{
|
||||
@@ -445,7 +471,9 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
||||
Priority: priority,
|
||||
Tags: tags,
|
||||
Click: click,
|
||||
Actions: actions,
|
||||
Attachment: att,
|
||||
Sender: sender,
|
||||
Encoding: encoding,
|
||||
})
|
||||
}
|
||||
@@ -490,6 +518,10 @@ func setupCacheDB(db *sql.DB) error {
|
||||
return migrateFrom3(db)
|
||||
} else if schemaVersion == 4 {
|
||||
return migrateFrom4(db)
|
||||
} else if schemaVersion == 5 {
|
||||
return migrateFrom5(db)
|
||||
} else if schemaVersion == 6 {
|
||||
return migrateFrom6(db)
|
||||
}
|
||||
return fmt.Errorf("unexpected schema version found: %d", schemaVersion)
|
||||
}
|
||||
@@ -508,7 +540,7 @@ func setupNewCacheDB(db *sql.DB) error {
|
||||
}
|
||||
|
||||
func migrateFrom0(db *sql.DB) error {
|
||||
log.Print("Migrating cache database schema: from 0 to 1")
|
||||
log.Info("Migrating cache database schema: from 0 to 1")
|
||||
if _, err := db.Exec(migrate0To1AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -522,7 +554,7 @@ func migrateFrom0(db *sql.DB) error {
|
||||
}
|
||||
|
||||
func migrateFrom1(db *sql.DB) error {
|
||||
log.Print("Migrating cache database schema: from 1 to 2")
|
||||
log.Info("Migrating cache database schema: from 1 to 2")
|
||||
if _, err := db.Exec(migrate1To2AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -533,7 +565,7 @@ func migrateFrom1(db *sql.DB) error {
|
||||
}
|
||||
|
||||
func migrateFrom2(db *sql.DB) error {
|
||||
log.Print("Migrating cache database schema: from 2 to 3")
|
||||
log.Info("Migrating cache database schema: from 2 to 3")
|
||||
if _, err := db.Exec(migrate2To3AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -544,7 +576,7 @@ func migrateFrom2(db *sql.DB) error {
|
||||
}
|
||||
|
||||
func migrateFrom3(db *sql.DB) error {
|
||||
log.Print("Migrating cache database schema: from 3 to 4")
|
||||
log.Info("Migrating cache database schema: from 3 to 4")
|
||||
if _, err := db.Exec(migrate3To4AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -555,12 +587,34 @@ func migrateFrom3(db *sql.DB) error {
|
||||
}
|
||||
|
||||
func migrateFrom4(db *sql.DB) error {
|
||||
log.Print("Migrating cache database schema: from 4 to 5")
|
||||
log.Info("Migrating cache database schema: from 4 to 5")
|
||||
if _, err := db.Exec(migrate4To5AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(updateSchemaVersion, 5); err != nil {
|
||||
return err
|
||||
}
|
||||
return migrateFrom5(db)
|
||||
}
|
||||
|
||||
func migrateFrom5(db *sql.DB) error {
|
||||
log.Info("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 migrateFrom6(db)
|
||||
}
|
||||
|
||||
func migrateFrom6(db *sql.DB) error {
|
||||
log.Info("Migrating cache database schema: from 6 to 7")
|
||||
if _, err := db.Exec(migrate6To7AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(updateSchemaVersion, 7); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil // Update this when a new version is added
|
||||
}
|
||||
|
||||
@@ -281,39 +281,39 @@ 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.Sender = "1.2.3.4"
|
||||
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.Sender = "1.2.3.4"
|
||||
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.Sender = "1.2.3.4"
|
||||
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))
|
||||
|
||||
@@ -327,7 +327,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
|
||||
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, "1.2.3.4", messages[0].Sender)
|
||||
|
||||
require.Equal(t, "sending you a car", messages[1].Message)
|
||||
require.Equal(t, "car.jpg", messages[1].Attachment.Name)
|
||||
@@ -335,13 +335,13 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
|
||||
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)
|
||||
require.Equal(t, "1.2.3.4", messages[1].Sender)
|
||||
|
||||
size, err := c.AttachmentsSize("1.2.3.4")
|
||||
size, err := c.AttachmentBytesUsed("1.2.3.4")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(30000), size)
|
||||
|
||||
size, err = c.AttachmentsSize("5.6.7.8")
|
||||
size, err = c.AttachmentBytesUsed("5.6.7.8")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(0), size)
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ After=network.target
|
||||
[Service]
|
||||
User=ntfy
|
||||
Group=ntfy
|
||||
ExecStart=/usr/bin/ntfy serve
|
||||
ExecStart=/usr/bin/ntfy serve --no-log-dates
|
||||
ExecReload=/bin/kill --signal HUP $MAINPID
|
||||
Restart=on-failure
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
LimitNOFILE=10000
|
||||
|
||||
561
server/server.go
@@ -3,21 +3,15 @@ package server
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/emersion/go-smtp"
|
||||
"github.com/gorilla/websocket"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"heckel.io/ntfy/auth"
|
||||
"heckel.io/ntfy/util"
|
||||
"heckel.io/ntfy/log"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
@@ -28,26 +22,32 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/emersion/go-smtp"
|
||||
"github.com/gorilla/websocket"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"heckel.io/ntfy/auth"
|
||||
"heckel.io/ntfy/util"
|
||||
)
|
||||
|
||||
// Server is the main server, providing the UI and API for ntfy
|
||||
type Server struct {
|
||||
config *Config
|
||||
httpServer *http.Server
|
||||
httpsServer *http.Server
|
||||
unixListener net.Listener
|
||||
smtpServer *smtp.Server
|
||||
smtpBackend *smtpBackend
|
||||
topics map[string]*topic
|
||||
visitors map[string]*visitor
|
||||
firebase subscriber
|
||||
mailer mailer
|
||||
messages int64
|
||||
auth auth.Auther
|
||||
messageCache *messageCache
|
||||
fileCache *fileCache
|
||||
closeChan chan bool
|
||||
mu sync.Mutex
|
||||
config *Config
|
||||
httpServer *http.Server
|
||||
httpsServer *http.Server
|
||||
unixListener net.Listener
|
||||
smtpServer *smtp.Server
|
||||
smtpServerBackend *smtpBackend
|
||||
smtpSender mailer
|
||||
topics map[string]*topic
|
||||
visitors map[string]*visitor
|
||||
firebaseClient *firebaseClient
|
||||
messages int64
|
||||
auth auth.Auther
|
||||
messageCache *messageCache
|
||||
fileCache *fileCache
|
||||
closeChan chan bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// handleFunc extends the normal http.HandlerFunc to be able to easily return errors
|
||||
@@ -55,17 +55,18 @@ type handleFunc func(http.ResponseWriter, *http.Request, *visitor) error
|
||||
|
||||
var (
|
||||
// If changed, don't forget to update Android App and auth_sqlite.go
|
||||
topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /!
|
||||
topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app!
|
||||
extTopicPathRegex = regexp.MustCompile(`^/[^/]+\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic
|
||||
jsonPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
|
||||
ssePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
|
||||
rawPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`)
|
||||
wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`)
|
||||
authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
|
||||
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/(publish|send|trigger)$`)
|
||||
topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /!
|
||||
topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app!
|
||||
externalTopicPathRegex = regexp.MustCompile(`^/[^/]+\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic
|
||||
jsonPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
|
||||
ssePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
|
||||
rawPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`)
|
||||
wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`)
|
||||
authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
|
||||
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
|
||||
|
||||
webConfigPath = "/config.js"
|
||||
userStatsPath = "/user/stats"
|
||||
staticRegex = regexp.MustCompile(`^/static/.+`)
|
||||
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
|
||||
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
|
||||
@@ -89,7 +90,9 @@ var (
|
||||
|
||||
const (
|
||||
firebaseControlTopic = "~control" // See Android if changed
|
||||
firebasePollTopic = "~poll" // See iOS if changed
|
||||
emptyMessageBody = "triggered" // Used if message body is empty
|
||||
newMessageBody = "New message" // Used in poll requests as generic message
|
||||
defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
|
||||
encodingBase64 = "base64"
|
||||
)
|
||||
@@ -131,23 +134,23 @@ func New(conf *Config) (*Server, error) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var firebaseSubscriber subscriber
|
||||
var firebaseClient *firebaseClient
|
||||
if conf.FirebaseKeyFile != "" {
|
||||
var err error
|
||||
firebaseSubscriber, err = createFirebaseSubscriber(conf.FirebaseKeyFile, auther)
|
||||
sender, err := newFirebaseSender(conf.FirebaseKeyFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
firebaseClient = newFirebaseClient(sender, auther)
|
||||
}
|
||||
return &Server{
|
||||
config: conf,
|
||||
messageCache: messageCache,
|
||||
fileCache: fileCache,
|
||||
firebase: firebaseSubscriber,
|
||||
mailer: mailer,
|
||||
topics: topics,
|
||||
auth: auther,
|
||||
visitors: make(map[string]*visitor),
|
||||
config: conf,
|
||||
messageCache: messageCache,
|
||||
fileCache: fileCache,
|
||||
firebaseClient: firebaseClient,
|
||||
smtpSender: mailer,
|
||||
topics: topics,
|
||||
auth: auther,
|
||||
visitors: make(map[string]*visitor),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -176,7 +179,7 @@ func (s *Server) Run() error {
|
||||
if s.config.SMTPServerListen != "" {
|
||||
listenStr += fmt.Sprintf(" %s[smtp]", s.config.SMTPServerListen)
|
||||
}
|
||||
log.Printf("Listening on%s", listenStr)
|
||||
log.Info("Listening on%s, log level is %s", listenStr, log.CurrentLevel().String())
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", s.handle)
|
||||
errChan := make(chan error)
|
||||
@@ -216,7 +219,7 @@ func (s *Server) Run() error {
|
||||
}
|
||||
s.mu.Unlock()
|
||||
go s.runManager()
|
||||
go s.runAtSender()
|
||||
go s.runDelayedSender()
|
||||
go s.runFirebaseKeepaliver()
|
||||
|
||||
return <-errChan
|
||||
@@ -243,16 +246,27 @@ func (s *Server) Stop() {
|
||||
|
||||
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
||||
v := s.visitor(r)
|
||||
log.Debug("%s Dispatching request", logHTTPPrefix(v, r))
|
||||
if err := s.handleInternal(w, r, v); err != nil {
|
||||
if websocket.IsWebSocketUpgrade(r) {
|
||||
log.Printf("[%s] WS %s %s - %s", v.ip, r.Method, r.URL.Path, err.Error())
|
||||
isNormalError := strings.Contains(err.Error(), "i/o timeout")
|
||||
if isNormalError {
|
||||
log.Debug("%s WebSocket error (this error is okay, it happens a lot): %s", logHTTPPrefix(v, r), err.Error())
|
||||
} else {
|
||||
log.Info("%s WebSocket error: %s", logHTTPPrefix(v, r), err.Error())
|
||||
}
|
||||
return // Do not attempt to write to upgraded connection
|
||||
}
|
||||
httpErr, ok := err.(*errHTTP)
|
||||
if !ok {
|
||||
httpErr = errHTTPInternalError
|
||||
}
|
||||
log.Printf("[%s] HTTP %s %s - %d - %d - %s", v.ip, r.Method, r.URL.Path, httpErr.HTTPCode, httpErr.Code, err.Error())
|
||||
isNormalError := httpErr.HTTPCode == http.StatusNotFound
|
||||
if isNormalError {
|
||||
log.Debug("%s Connection closed with HTTP %d (ntfy error %d): %s", logHTTPPrefix(v, r), httpErr.HTTPCode, httpErr.Code, err.Error())
|
||||
} else {
|
||||
log.Info("%s Connection closed with HTTP %d (ntfy error %d): %s", logHTTPPrefix(v, r), httpErr.HTTPCode, httpErr.Code, err.Error())
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
|
||||
w.WriteHeader(httpErr.HTTPCode)
|
||||
@@ -262,21 +276,25 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/" {
|
||||
return s.handleHome(w, r)
|
||||
return s.ensureWebEnabled(s.handleHome)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == "/example.html" {
|
||||
return s.handleExample(w, r)
|
||||
return s.ensureWebEnabled(s.handleExample)(w, r, v)
|
||||
} else if r.Method == http.MethodHead && r.URL.Path == "/" {
|
||||
return s.handleEmpty(w, r, v)
|
||||
return s.ensureWebEnabled(s.handleEmpty)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
|
||||
return s.handleWebConfig(w, r)
|
||||
return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == userStatsPath {
|
||||
return s.handleUserStats(w, r, v)
|
||||
} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
|
||||
return s.handleStatic(w, r)
|
||||
return s.ensureWebEnabled(s.handleStatic)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
|
||||
return s.handleDocs(w, r)
|
||||
return s.ensureWebEnabled(s.handleDocs)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && fileRegex.MatchString(r.URL.Path) && s.config.AttachmentCacheDir != "" {
|
||||
return s.limitRequests(s.handleFile)(w, r, v)
|
||||
} else if r.Method == http.MethodOptions {
|
||||
return s.handleOptions(w, r)
|
||||
return s.ensureWebEnabled(s.handleOptions)(w, r, v)
|
||||
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == "/" {
|
||||
return s.limitRequests(s.transformBodyJSON(s.authWrite(s.handlePublish)))(w, r, v)
|
||||
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicPathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequests(s.authWrite(s.handlePublish))(w, r, v)
|
||||
} else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) {
|
||||
@@ -291,22 +309,22 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||
return s.limitRequests(s.authRead(s.handleSubscribeWS))(w, r, v)
|
||||
} else if r.Method == http.MethodGet && authPathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequests(s.authRead(s.handleTopicAuth))(w, r, v)
|
||||
} else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || extTopicPathRegex.MatchString(r.URL.Path)) {
|
||||
return s.handleTopic(w, r)
|
||||
} else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || externalTopicPathRegex.MatchString(r.URL.Path)) {
|
||||
return s.ensureWebEnabled(s.handleTopic)(w, r, v)
|
||||
}
|
||||
return errHTTPNotFound
|
||||
}
|
||||
|
||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) error {
|
||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
if s.config.WebRootIsApp {
|
||||
r.URL.Path = webAppIndex
|
||||
} else {
|
||||
r.URL.Path = webHomeIndex
|
||||
}
|
||||
return s.handleStatic(w, r)
|
||||
return s.handleStatic(w, r, v)
|
||||
}
|
||||
|
||||
func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request) error {
|
||||
func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
unifiedpush := readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see PUT/POST too!
|
||||
if unifiedpush {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -315,7 +333,7 @@ func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request) error {
|
||||
return err
|
||||
}
|
||||
r.URL.Path = webAppIndex
|
||||
return s.handleStatic(w, r)
|
||||
return s.handleStatic(w, r, v)
|
||||
}
|
||||
|
||||
func (s *Server) handleEmpty(_ http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
@@ -329,12 +347,12 @@ func (s *Server) handleTopicAuth(w http.ResponseWriter, _ *http.Request, _ *visi
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Server) handleExample(w http.ResponseWriter, _ *http.Request) error {
|
||||
func (s *Server) handleExample(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
_, err := io.WriteString(w, exampleSource)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Server) handleWebConfig(w http.ResponseWriter, r *http.Request) error {
|
||||
func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
appRoot := "/"
|
||||
if !s.config.WebRootIsApp {
|
||||
appRoot = "/app"
|
||||
@@ -349,13 +367,26 @@ var config = {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) error {
|
||||
func (s *Server) handleUserStats(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
stats, err := v.Stats()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
|
||||
if err := json.NewEncoder(w).Encode(stats); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
||||
r.URL.Path = webSiteDir + r.URL.Path
|
||||
util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleDocs(w http.ResponseWriter, r *http.Request) error {
|
||||
func (s *Server) handleDocs(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
||||
util.Gzip(http.FileServer(http.FS(docsStaticCached))).ServeHTTP(w, r)
|
||||
return nil
|
||||
}
|
||||
@@ -378,6 +409,7 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
|
||||
return errHTTPTooManyRequestsAttachmentBandwidthLimit
|
||||
}
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size()))
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -392,7 +424,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := util.Peak(r.Body, s.config.MessageLimit)
|
||||
body, err := util.Peek(r.Body, s.config.MessageLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -401,6 +433,9 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if m.PollID != "" {
|
||||
m = newPollRequestMessage(t.ID, m.PollID)
|
||||
}
|
||||
if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -408,24 +443,26 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
|
||||
m.Message = emptyMessageBody
|
||||
}
|
||||
delayed := m.Time > time.Now().Unix()
|
||||
log.Debug("%s Received message: event=%s, body=%d byte(s), delayed=%t, firebase=%t, cache=%t, up=%t, email=%s",
|
||||
logMessagePrefix(v, m), m.Event, len(m.Message), delayed, firebase, cache, unifiedpush, email)
|
||||
if log.IsTrace() {
|
||||
log.Trace("%s Message body: %s", logMessagePrefix(v, m), util.MaybeMarshalJSON(m))
|
||||
}
|
||||
if !delayed {
|
||||
if err := t.Publish(m); err != nil {
|
||||
if err := t.Publish(v, m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if s.firebase != nil && firebase && !delayed {
|
||||
go func() {
|
||||
if err := s.firebase(m); err != nil {
|
||||
log.Printf("[%s] FB - Unable to publish to Firebase: %v", v.ip, err.Error())
|
||||
}
|
||||
}()
|
||||
}
|
||||
if s.mailer != nil && email != "" && !delayed {
|
||||
go func() {
|
||||
if err := s.mailer.Send(v.ip, email, m); err != nil {
|
||||
log.Printf("[%s] MAIL - Unable to send email: %v", v.ip, err.Error())
|
||||
}
|
||||
}()
|
||||
if s.firebaseClient != nil && firebase {
|
||||
go s.sendToFirebase(v, m)
|
||||
}
|
||||
if s.smtpSender != nil && email != "" {
|
||||
go s.sendEmail(v, m, email)
|
||||
}
|
||||
if s.config.UpstreamBaseURL != "" {
|
||||
go s.forwardPollRequest(v, m)
|
||||
}
|
||||
} else {
|
||||
log.Debug("%s Message delayed, will process later", logMessagePrefix(v, m))
|
||||
}
|
||||
if cache {
|
||||
if err := s.messageCache.AddMessage(m); err != nil {
|
||||
@@ -443,6 +480,44 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) sendToFirebase(v *visitor, m *message) {
|
||||
log.Debug("%s Publishing to Firebase", logMessagePrefix(v, m))
|
||||
if err := s.firebaseClient.Send(v, m); err != nil {
|
||||
log.Warn("%s Unable to publish to Firebase: %v", logMessagePrefix(v, m), err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) sendEmail(v *visitor, m *message, email string) {
|
||||
log.Debug("%s Sending email to %s", logMessagePrefix(v, m), email)
|
||||
if err := s.smtpSender.Send(v, m, email); err != nil {
|
||||
log.Warn("%s Unable to send email to %s: %v", logMessagePrefix(v, m), email, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) forwardPollRequest(v *visitor, m *message) {
|
||||
topicURL := fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic)
|
||||
topicHash := fmt.Sprintf("%x", sha256.Sum256([]byte(topicURL)))
|
||||
forwardURL := fmt.Sprintf("%s/%s", s.config.UpstreamBaseURL, topicHash)
|
||||
log.Debug("%s Publishing poll request to %s", logMessagePrefix(v, m), forwardURL)
|
||||
req, err := http.NewRequest("POST", forwardURL, strings.NewReader(""))
|
||||
if err != nil {
|
||||
log.Warn("%s Unable to publish poll request: %v", logMessagePrefix(v, m), err.Error())
|
||||
return
|
||||
}
|
||||
req.Header.Set("X-Poll-ID", m.ID)
|
||||
var httpClient = &http.Client{
|
||||
Timeout: time.Second * 10,
|
||||
}
|
||||
response, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
log.Warn("%s Unable to publish poll request: %v", logMessagePrefix(v, m), err.Error())
|
||||
return
|
||||
} else if response.StatusCode != http.StatusOK {
|
||||
log.Warn("%s Unable to publish poll request, unexpected HTTP status: %d", logMessagePrefix(v, m), response.StatusCode)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (cache bool, firebase bool, email string, unifiedpush bool, err error) {
|
||||
cache = readBoolParam(r, true, "x-cache", "cache")
|
||||
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
||||
@@ -480,7 +555,7 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
|
||||
return false, false, "", false, errHTTPTooManyRequestsLimitEmails
|
||||
}
|
||||
}
|
||||
if s.mailer == nil && email != "" {
|
||||
if s.smtpSender == nil && email != "" {
|
||||
return false, false, "", false, errHTTPBadRequestEmailDisabled
|
||||
}
|
||||
messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
|
||||
@@ -515,56 +590,80 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
|
||||
return false, false, "", false, errHTTPBadRequestDelayTooLarge
|
||||
}
|
||||
m.Time = delay.Unix()
|
||||
m.Sender = v.ip // Important for rate limiting
|
||||
}
|
||||
actionsStr := readParam(r, "x-actions", "actions", "action")
|
||||
if actionsStr != "" {
|
||||
m.Actions, err = parseActions(actionsStr)
|
||||
if err != nil {
|
||||
return false, false, "", false, wrapErrHTTP(errHTTPBadRequestActionsInvalid, err.Error())
|
||||
}
|
||||
}
|
||||
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
|
||||
if unifiedpush {
|
||||
firebase = false
|
||||
unifiedpush = true
|
||||
}
|
||||
m.PollID = readParam(r, "x-poll-id", "poll-id")
|
||||
if m.PollID != "" {
|
||||
unifiedpush = false
|
||||
cache = false
|
||||
email = ""
|
||||
}
|
||||
return cache, firebase, email, unifiedpush, nil
|
||||
}
|
||||
|
||||
// handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message.
|
||||
//
|
||||
// 1. curl -T somebinarydata.bin "ntfy.sh/mytopic?up=1"
|
||||
// 1. curl -X POST -H "Poll: 1234" ntfy.sh/...
|
||||
// If a message is flagged as poll request, the body does not matter and is discarded
|
||||
// 2. curl -T somebinarydata.bin "ntfy.sh/mytopic?up=1"
|
||||
// If body is binary, encode as base64, if not do not encode
|
||||
// 2. curl -H "Attach: http://example.com/file.jpg" ntfy.sh/mytopic
|
||||
// 3. curl -H "Attach: http://example.com/file.jpg" ntfy.sh/mytopic
|
||||
// Body must be a message, because we attached an external URL
|
||||
// 3. curl -T short.txt -H "Filename: short.txt" ntfy.sh/mytopic
|
||||
// 4. curl -T short.txt -H "Filename: short.txt" ntfy.sh/mytopic
|
||||
// Body must be attachment, because we passed a filename
|
||||
// 4. curl -T file.txt ntfy.sh/mytopic
|
||||
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
|
||||
// 5. curl -T file.txt ntfy.sh/mytopic
|
||||
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
|
||||
// 6. curl -T file.txt ntfy.sh/mytopic
|
||||
// If file.txt is > message limit, treat it as an attachment
|
||||
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeakedReadCloser, unifiedpush bool) error {
|
||||
if unifiedpush {
|
||||
return s.handleBodyAsMessageAutoDetect(m, body) // Case 1
|
||||
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, unifiedpush bool) error {
|
||||
if m.Event == pollRequestEvent { // Case 1
|
||||
return s.handleBodyDiscard(body)
|
||||
} else if unifiedpush {
|
||||
return s.handleBodyAsMessageAutoDetect(m, body) // Case 2
|
||||
} else if m.Attachment != nil && m.Attachment.URL != "" {
|
||||
return s.handleBodyAsTextMessage(m, body) // Case 2
|
||||
return s.handleBodyAsTextMessage(m, body) // Case 3
|
||||
} else if m.Attachment != nil && m.Attachment.Name != "" {
|
||||
return s.handleBodyAsAttachment(r, v, m, body) // Case 3
|
||||
} else if !body.LimitReached && utf8.Valid(body.PeakedBytes) {
|
||||
return s.handleBodyAsTextMessage(m, body) // Case 4
|
||||
return s.handleBodyAsAttachment(r, v, m, body) // Case 4
|
||||
} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
|
||||
return s.handleBodyAsTextMessage(m, body) // Case 5
|
||||
}
|
||||
return s.handleBodyAsAttachment(r, v, m, body) // Case 5
|
||||
return s.handleBodyAsAttachment(r, v, m, body) // Case 6
|
||||
}
|
||||
|
||||
func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeakedReadCloser) error {
|
||||
if utf8.Valid(body.PeakedBytes) {
|
||||
m.Message = string(body.PeakedBytes) // Do not trim
|
||||
func (s *Server) handleBodyDiscard(body *util.PeekedReadCloser) error {
|
||||
_, err := io.Copy(io.Discard, body)
|
||||
_ = body.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeekedReadCloser) error {
|
||||
if utf8.Valid(body.PeekedBytes) {
|
||||
m.Message = string(body.PeekedBytes) // Do not trim
|
||||
} else {
|
||||
m.Message = base64.StdEncoding.EncodeToString(body.PeakedBytes)
|
||||
m.Message = base64.StdEncoding.EncodeToString(body.PeekedBytes)
|
||||
m.Encoding = encodingBase64
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeakedReadCloser) error {
|
||||
if !utf8.Valid(body.PeakedBytes) {
|
||||
func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser) error {
|
||||
if !utf8.Valid(body.PeekedBytes) {
|
||||
return errHTTPBadRequestMessageNotUTF8
|
||||
}
|
||||
if len(body.PeakedBytes) > 0 { // Empty body should not override message (publish via GET!)
|
||||
m.Message = strings.TrimSpace(string(body.PeakedBytes)) // Truncates the message to the peak limit if required
|
||||
if len(body.PeekedBytes) > 0 { // Empty body should not override message (publish via GET!)
|
||||
m.Message = strings.TrimSpace(string(body.PeekedBytes)) // Truncates the message to the peek limit if required
|
||||
}
|
||||
if m.Attachment != nil && m.Attachment.Name != "" && m.Message == "" {
|
||||
m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name)
|
||||
@@ -572,31 +671,30 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeakedReadCloser
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeakedReadCloser) error {
|
||||
func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) error {
|
||||
if s.fileCache == nil || s.config.BaseURL == "" || s.config.AttachmentCacheDir == "" {
|
||||
return errHTTPBadRequestAttachmentsDisallowed
|
||||
} else if m.Time > time.Now().Add(s.config.AttachmentExpiryDuration).Unix() {
|
||||
return errHTTPBadRequestAttachmentsExpiryBeforeDelivery
|
||||
}
|
||||
visitorAttachmentsSize, err := s.messageCache.AttachmentsSize(v.ip)
|
||||
visitorStats, err := v.Stats()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
remainingVisitorAttachmentSize := s.config.VisitorAttachmentTotalSizeLimit - visitorAttachmentsSize
|
||||
contentLengthStr := r.Header.Get("Content-Length")
|
||||
if contentLengthStr != "" { // Early "do-not-trust" check, hard limit see below
|
||||
contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64)
|
||||
if err == nil && (contentLength > remainingVisitorAttachmentSize || contentLength > s.config.AttachmentFileSizeLimit) {
|
||||
return errHTTPBadRequestAttachmentTooLarge
|
||||
if err == nil && (contentLength > visitorStats.VisitorAttachmentBytesRemaining || contentLength > s.config.AttachmentFileSizeLimit) {
|
||||
return errHTTPEntityTooLargeAttachmentTooLarge
|
||||
}
|
||||
}
|
||||
if m.Attachment == nil {
|
||||
m.Attachment = &attachment{}
|
||||
}
|
||||
var ext string
|
||||
m.Attachment.Owner = v.ip // Important for attachment rate limiting
|
||||
m.Sender = v.ip // Important for attachment rate limiting
|
||||
m.Attachment.Expires = time.Now().Add(s.config.AttachmentExpiryDuration).Unix()
|
||||
m.Attachment.Type, ext = util.DetectContentType(body.PeakedBytes, m.Attachment.Name)
|
||||
m.Attachment.Type, ext = util.DetectContentType(body.PeekedBytes, m.Attachment.Name)
|
||||
m.Attachment.URL = fmt.Sprintf("%s/file/%s%s", s.config.BaseURL, m.ID, ext)
|
||||
if m.Attachment.Name == "" {
|
||||
m.Attachment.Name = fmt.Sprintf("attachment%s", ext)
|
||||
@@ -604,9 +702,9 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
|
||||
if m.Message == "" {
|
||||
m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name)
|
||||
}
|
||||
m.Attachment.Size, err = s.fileCache.Write(m.ID, body, v.BandwidthLimiter(), util.NewFixedLimiter(remainingVisitorAttachmentSize))
|
||||
m.Attachment.Size, err = s.fileCache.Write(m.ID, body, v.BandwidthLimiter(), util.NewFixedLimiter(visitorStats.VisitorAttachmentBytesRemaining))
|
||||
if err == util.ErrLimitReached {
|
||||
return errHTTPBadRequestAttachmentTooLarge
|
||||
return errHTTPEntityTooLargeAttachmentTooLarge
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -649,6 +747,8 @@ func (s *Server) handleSubscribeRaw(w http.ResponseWriter, r *http.Request, v *v
|
||||
}
|
||||
|
||||
func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *visitor, contentType string, encoder messageEncoder) error {
|
||||
log.Debug("%s HTTP stream connection opened", logHTTPPrefix(v, r))
|
||||
defer log.Debug("%s HTTP stream connection closed", logHTTPPrefix(v, r))
|
||||
if err := v.SubscriptionAllowed(); err != nil {
|
||||
return errHTTPTooManyRequestsLimitSubscriptions
|
||||
}
|
||||
@@ -662,7 +762,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
|
||||
return err
|
||||
}
|
||||
var wlock sync.Mutex
|
||||
sub := func(msg *message) error {
|
||||
sub := func(v *visitor, msg *message) error {
|
||||
if !filters.Pass(msg) {
|
||||
return nil
|
||||
}
|
||||
@@ -683,7 +783,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
|
||||
w.Header().Set("Content-Type", contentType+"; charset=utf-8") // Android/Volley client needs charset!
|
||||
if poll {
|
||||
return s.sendOldMessages(topics, since, scheduled, sub)
|
||||
return s.sendOldMessages(topics, since, scheduled, v, sub)
|
||||
}
|
||||
subscriberIDs := make([]int, 0)
|
||||
for _, t := range topics {
|
||||
@@ -694,10 +794,10 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
|
||||
topics[i].Unsubscribe(subscriberID) // Order!
|
||||
}
|
||||
}()
|
||||
if err := sub(newOpenMessage(topicsStr)); err != nil { // Send out open message
|
||||
if err := sub(v, newOpenMessage(topicsStr)); err != nil { // Send out open message
|
||||
return err
|
||||
}
|
||||
if err := s.sendOldMessages(topics, since, scheduled, sub); err != nil {
|
||||
if err := s.sendOldMessages(topics, since, scheduled, v, sub); err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
@@ -705,8 +805,9 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
|
||||
case <-r.Context().Done():
|
||||
return nil
|
||||
case <-time.After(s.config.KeepaliveInterval):
|
||||
log.Trace("%s Sending keepalive message", logHTTPPrefix(v, r))
|
||||
v.Keepalive()
|
||||
if err := sub(newKeepaliveMessage(topicsStr)); err != nil { // Send keepalive message
|
||||
if err := sub(v, newKeepaliveMessage(topicsStr)); err != nil { // Send keepalive message
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -714,13 +815,15 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
|
||||
}
|
||||
|
||||
func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
if r.Header.Get("Upgrade") != "websocket" {
|
||||
if strings.ToLower(r.Header.Get("Upgrade")) != "websocket" {
|
||||
return errHTTPBadRequestWebSocketsUpgradeHeaderMissing
|
||||
}
|
||||
if err := v.SubscriptionAllowed(); err != nil {
|
||||
return errHTTPTooManyRequestsLimitSubscriptions
|
||||
}
|
||||
defer v.RemoveSubscription()
|
||||
log.Debug("%s WebSocket connection opened", logHTTPPrefix(v, r))
|
||||
defer log.Debug("%s WebSocket connection closed", logHTTPPrefix(v, r))
|
||||
topics, topicsStr, err := s.topicsFromPath(r.URL.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -750,6 +853,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
|
||||
return err
|
||||
}
|
||||
conn.SetPongHandler(func(appData string) error {
|
||||
log.Trace("%s Received WebSocket pong", logHTTPPrefix(v, r))
|
||||
return conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
})
|
||||
for {
|
||||
@@ -766,6 +870,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
|
||||
if err := conn.SetWriteDeadline(time.Now().Add(wsWriteWait)); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Trace("%s Sending WebSocket ping", logHTTPPrefix(v, r))
|
||||
return conn.WriteMessage(websocket.PingMessage, nil)
|
||||
}
|
||||
for {
|
||||
@@ -780,7 +885,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
|
||||
}
|
||||
}
|
||||
})
|
||||
sub := func(msg *message) error {
|
||||
sub := func(v *visitor, msg *message) error {
|
||||
if !filters.Pass(msg) {
|
||||
return nil
|
||||
}
|
||||
@@ -793,7 +898,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
|
||||
if poll {
|
||||
return s.sendOldMessages(topics, since, scheduled, sub)
|
||||
return s.sendOldMessages(topics, since, scheduled, v, sub)
|
||||
}
|
||||
subscriberIDs := make([]int, 0)
|
||||
for _, t := range topics {
|
||||
@@ -804,15 +909,16 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
|
||||
topics[i].Unsubscribe(subscriberID) // Order!
|
||||
}
|
||||
}()
|
||||
if err := sub(newOpenMessage(topicsStr)); err != nil { // Send out open message
|
||||
if err := sub(v, newOpenMessage(topicsStr)); err != nil { // Send out open message
|
||||
return err
|
||||
}
|
||||
if err := s.sendOldMessages(topics, since, scheduled, sub); err != nil {
|
||||
if err := s.sendOldMessages(topics, since, scheduled, v, sub); err != nil {
|
||||
return err
|
||||
}
|
||||
err = g.Wait()
|
||||
if err != nil && websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
|
||||
return nil // Normal closures are not errors
|
||||
if err != nil && websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||
log.Trace("%s WebSocket connection closed: %s", logHTTPPrefix(v, r), err.Error())
|
||||
return nil // Normal closures are not errors; note: "1006 (abnormal closure)" is treated as normal, because people disconnect a lot
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -831,7 +937,7 @@ func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, schedu
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) sendOldMessages(topics []*topic, since sinceMarker, scheduled bool, sub subscriber) error {
|
||||
func (s *Server) sendOldMessages(topics []*topic, since sinceMarker, scheduled bool, v *visitor, sub subscriber) error {
|
||||
if since.IsNone() {
|
||||
return nil
|
||||
}
|
||||
@@ -841,7 +947,7 @@ func (s *Server) sendOldMessages(topics []*topic, since sinceMarker, scheduled b
|
||||
return err
|
||||
}
|
||||
for _, m := range messages {
|
||||
if err := sub(m); err != nil {
|
||||
if err := sub(v, m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -879,7 +985,7 @@ func parseSince(r *http.Request, poll bool) (sinceMarker, error) {
|
||||
return sinceNoMessages, errHTTPBadRequestSinceInvalid
|
||||
}
|
||||
|
||||
func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request) error {
|
||||
func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
|
||||
w.Header().Set("Access-Control-Allow-Headers", "*") // CORS, allow auth via JS // FIXME is this terrible?
|
||||
@@ -935,28 +1041,36 @@ func (s *Server) updateStatsAndPrune() {
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Expire visitors from rate visitors map
|
||||
staleVisitors := 0
|
||||
for ip, v := range s.visitors {
|
||||
if v.Stale() {
|
||||
log.Debug("Deleting stale visitor %s", v.ip)
|
||||
delete(s.visitors, ip)
|
||||
staleVisitors++
|
||||
}
|
||||
}
|
||||
log.Debug("Manager: Deleted %d stale visitor(s)", staleVisitors)
|
||||
|
||||
// Delete expired attachments
|
||||
if s.fileCache != nil {
|
||||
ids, err := s.messageCache.AttachmentsExpired()
|
||||
if err == nil {
|
||||
if err != nil {
|
||||
log.Warn("Error retrieving expired attachments: %s", err.Error())
|
||||
} else if len(ids) > 0 {
|
||||
log.Debug("Manager: Deleting expired attachments: %v", ids)
|
||||
if err := s.fileCache.Remove(ids...); err != nil {
|
||||
log.Printf("error while deleting attachments: %s", err.Error())
|
||||
log.Warn("Error deleting attachments: %s", err.Error())
|
||||
}
|
||||
} else {
|
||||
log.Printf("error retrieving expired attachments: %s", err.Error())
|
||||
log.Debug("Manager: No expired attachments to delete")
|
||||
}
|
||||
}
|
||||
|
||||
// Prune message cache
|
||||
olderThan := time.Now().Add(-1 * s.config.CacheDuration)
|
||||
log.Debug("Manager: Pruning messages older than %s", olderThan.Format("2006-01-02 15:04:05"))
|
||||
if err := s.messageCache.Prune(olderThan); err != nil {
|
||||
log.Printf("error pruning cache: %s", err.Error())
|
||||
log.Warn("Manager: Error pruning cache: %s", err.Error())
|
||||
}
|
||||
|
||||
// Prune old topics, remove subscriptions without subscribers
|
||||
@@ -965,7 +1079,7 @@ func (s *Server) updateStatsAndPrune() {
|
||||
subs := t.Subscribers()
|
||||
msgs, err := s.messageCache.MessageCount(t.ID)
|
||||
if err != nil {
|
||||
log.Printf("cannot get stats for topic %s: %s", t.ID, err.Error())
|
||||
log.Warn("Manager: Cannot get stats for topic %s: %s", t.ID, err.Error())
|
||||
continue
|
||||
}
|
||||
if msgs == 0 && subs == 0 {
|
||||
@@ -977,35 +1091,25 @@ func (s *Server) updateStatsAndPrune() {
|
||||
}
|
||||
|
||||
// Mail stats
|
||||
var mailSuccess, mailFailure int64
|
||||
if s.smtpBackend != nil {
|
||||
mailSuccess, mailFailure = s.smtpBackend.Counts()
|
||||
var receivedMailTotal, receivedMailSuccess, receivedMailFailure int64
|
||||
if s.smtpServerBackend != nil {
|
||||
receivedMailTotal, receivedMailSuccess, receivedMailFailure = s.smtpServerBackend.Counts()
|
||||
}
|
||||
var sentMailTotal, sentMailSuccess, sentMailFailure int64
|
||||
if s.smtpSender != nil {
|
||||
sentMailTotal, sentMailSuccess, sentMailFailure = s.smtpSender.Counts()
|
||||
}
|
||||
|
||||
// Print stats
|
||||
log.Printf("Stats: %d message(s) published, %d in cache, %d successful mails, %d failed, %d topic(s) active, %d subscriber(s), %d visitor(s)",
|
||||
s.messages, messages, mailSuccess, mailFailure, len(s.topics), subscribers, len(s.visitors))
|
||||
log.Info("Stats: %d messages published, %d in cache, %d topic(s) active, %d subscriber(s), %d visitor(s), %d mails received (%d successful, %d failed), %d mails sent (%d successful, %d failed)",
|
||||
s.messages, messages, len(s.topics), subscribers, len(s.visitors),
|
||||
receivedMailTotal, receivedMailSuccess, receivedMailFailure,
|
||||
sentMailTotal, sentMailSuccess, sentMailFailure)
|
||||
}
|
||||
|
||||
func (s *Server) runSMTPServer() error {
|
||||
sub := func(m *message) error {
|
||||
url := fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic)
|
||||
req, err := http.NewRequest("PUT", url, strings.NewReader(m.Message))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Title != "" {
|
||||
req.Header.Set("Title", m.Title)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
s.handle(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
return errors.New("error: " + rr.Body.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
s.smtpBackend = newMailBackend(s.config, sub)
|
||||
s.smtpServer = smtp.NewServer(s.smtpBackend)
|
||||
s.smtpServerBackend = newMailBackend(s.config, s.handle)
|
||||
s.smtpServer = smtp.NewServer(s.smtpServerBackend)
|
||||
s.smtpServer.Addr = s.config.SMTPServerListen
|
||||
s.smtpServer.Domain = s.config.SMTPServerDomain
|
||||
s.smtpServer.ReadTimeout = 10 * time.Second
|
||||
@@ -1027,28 +1131,29 @@ func (s *Server) runManager() {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) runAtSender() {
|
||||
func (s *Server) runFirebaseKeepaliver() {
|
||||
if s.firebaseClient == nil {
|
||||
return
|
||||
}
|
||||
v := newVisitor(s.config, s.messageCache, "0.0.0.0") // Background process, not a real visitor
|
||||
for {
|
||||
select {
|
||||
case <-time.After(s.config.AtSenderInterval):
|
||||
if err := s.sendDelayedMessages(); err != nil {
|
||||
log.Printf("error sending scheduled messages: %s", err.Error())
|
||||
}
|
||||
case <-time.After(s.config.FirebaseKeepaliveInterval):
|
||||
s.sendToFirebase(v, newKeepaliveMessage(firebaseControlTopic))
|
||||
case <-time.After(s.config.FirebasePollInterval):
|
||||
s.sendToFirebase(v, newKeepaliveMessage(firebasePollTopic))
|
||||
case <-s.closeChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) runFirebaseKeepaliver() {
|
||||
if s.firebase == nil {
|
||||
return
|
||||
}
|
||||
func (s *Server) runDelayedSender() {
|
||||
for {
|
||||
select {
|
||||
case <-time.After(s.config.FirebaseKeepaliveInterval):
|
||||
if err := s.firebase(newKeepaliveMessage(firebaseControlTopic)); err != nil {
|
||||
log.Printf("error sending Firebase keepalive message: %s", err.Error())
|
||||
case <-time.After(s.config.DelayedSenderInterval):
|
||||
if err := s.sendDelayedMessages(); err != nil {
|
||||
log.Warn("Error sending delayed messages: %s", err.Error())
|
||||
}
|
||||
case <-s.closeChan:
|
||||
return
|
||||
@@ -1057,27 +1162,40 @@ func (s *Server) runFirebaseKeepaliver() {
|
||||
}
|
||||
|
||||
func (s *Server) sendDelayedMessages() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
messages, err := s.messageCache.MessagesDue()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, m := range messages {
|
||||
t, ok := s.topics[m.Topic] // If no subscribers, just mark message as published
|
||||
if ok {
|
||||
if err := t.Publish(m); err != nil {
|
||||
log.Printf("unable to publish message %s to topic %s: %v", m.ID, m.Topic, err.Error())
|
||||
v := s.visitorFromIP(m.Sender)
|
||||
if err := s.sendDelayedMessage(v, m); err != nil {
|
||||
log.Warn("%s Error sending delayed message: %s", logMessagePrefix(v, m), err.Error())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) sendDelayedMessage(v *visitor, m *message) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
log.Debug("%s Sending delayed message", logMessagePrefix(v, m))
|
||||
t, ok := s.topics[m.Topic] // If no subscribers, just mark message as published
|
||||
if ok {
|
||||
go func() {
|
||||
// We do not rate-limit messages here, since we've rate limited them in the PUT/POST handler
|
||||
if err := t.Publish(v, m); err != nil {
|
||||
log.Warn("%s Unable to publish message: %v", logMessagePrefix(v, m), err.Error())
|
||||
}
|
||||
}
|
||||
if s.firebase != nil { // Firebase subscribers may not show up in topics map
|
||||
if err := s.firebase(m); err != nil {
|
||||
log.Printf("unable to publish to Firebase: %v", err.Error())
|
||||
}
|
||||
}
|
||||
if err := s.messageCache.MarkPublished(m); err != nil {
|
||||
return err
|
||||
}
|
||||
}()
|
||||
}
|
||||
if s.firebaseClient != nil { // Firebase subscribers may not show up in topics map
|
||||
go s.sendToFirebase(v, m)
|
||||
}
|
||||
if s.config.UpstreamBaseURL != "" {
|
||||
go s.forwardPollRequest(v, m)
|
||||
}
|
||||
if err := s.messageCache.MarkPublished(m); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1093,6 +1211,71 @@ func (s *Server) limitRequests(next handleFunc) handleFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) ensureWebEnabled(next handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
if !s.config.EnableWeb {
|
||||
return errHTTPNotFound
|
||||
}
|
||||
return next(w, r, v)
|
||||
}
|
||||
}
|
||||
|
||||
// transformBodyJSON peeks the request body, reads the JSON, and converts it to headers
|
||||
// before passing it on to the next handler. This is meant to be used in combination with handlePublish.
|
||||
func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
body, err := util.Peek(r.Body, s.config.MessageLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Body.Close()
|
||||
var m publishMessage
|
||||
if err := json.NewDecoder(body).Decode(&m); err != nil {
|
||||
return errHTTPBadRequestJSONInvalid
|
||||
}
|
||||
if !topicRegex.MatchString(m.Topic) {
|
||||
return errHTTPBadRequestTopicInvalid
|
||||
}
|
||||
if m.Message == "" {
|
||||
m.Message = emptyMessageBody
|
||||
}
|
||||
r.URL.Path = "/" + m.Topic
|
||||
r.Body = io.NopCloser(strings.NewReader(m.Message))
|
||||
if m.Title != "" {
|
||||
r.Header.Set("X-Title", m.Title)
|
||||
}
|
||||
if m.Priority != 0 {
|
||||
r.Header.Set("X-Priority", fmt.Sprintf("%d", m.Priority))
|
||||
}
|
||||
if m.Tags != nil && len(m.Tags) > 0 {
|
||||
r.Header.Set("X-Tags", strings.Join(m.Tags, ","))
|
||||
}
|
||||
if m.Attach != "" {
|
||||
r.Header.Set("X-Attach", m.Attach)
|
||||
}
|
||||
if m.Filename != "" {
|
||||
r.Header.Set("X-Filename", m.Filename)
|
||||
}
|
||||
if m.Click != "" {
|
||||
r.Header.Set("X-Click", m.Click)
|
||||
}
|
||||
if len(m.Actions) > 0 {
|
||||
actionsStr, err := json.Marshal(m.Actions)
|
||||
if err != nil {
|
||||
return errHTTPBadRequestJSONInvalid
|
||||
}
|
||||
r.Header.Set("X-Actions", string(actionsStr))
|
||||
}
|
||||
if m.Email != "" {
|
||||
r.Header.Set("X-Email", m.Email)
|
||||
}
|
||||
if m.Delay != "" {
|
||||
r.Header.Set("X-Delay", m.Delay)
|
||||
}
|
||||
return next(w, r, v)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) authWrite(next handleFunc) handleFunc {
|
||||
return s.withAuth(next, auth.PermissionWrite)
|
||||
}
|
||||
@@ -1114,13 +1297,13 @@ func (s *Server) withAuth(next handleFunc, perm auth.Permission) handleFunc {
|
||||
username, password, ok := extractUserPass(r)
|
||||
if ok {
|
||||
if user, err = s.auth.Authenticate(username, password); err != nil {
|
||||
log.Printf("authentication failed: %s", err.Error())
|
||||
log.Info("authentication failed: %s", err.Error())
|
||||
return errHTTPUnauthorized
|
||||
}
|
||||
}
|
||||
for _, t := range topics {
|
||||
if err := s.auth.Authorize(user, t.ID, perm); err != nil {
|
||||
log.Printf("unauthorized: %s", err.Error())
|
||||
log.Info("unauthorized: %s", err.Error())
|
||||
return errHTTPForbidden
|
||||
}
|
||||
}
|
||||
@@ -1152,8 +1335,6 @@ func extractUserPass(r *http.Request) (username string, password string, ok bool
|
||||
// visitor creates or retrieves a rate.Limiter for the given visitor.
|
||||
// This function was taken from https://www.alexedwards.net/blog/how-to-rate-limit-http-requests (MIT).
|
||||
func (s *Server) visitor(r *http.Request) *visitor {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
remoteAddr := r.RemoteAddr
|
||||
ip, _, err := net.SplitHostPort(remoteAddr)
|
||||
if err != nil {
|
||||
@@ -1162,9 +1343,15 @@ func (s *Server) visitor(r *http.Request) *visitor {
|
||||
if s.config.BehindProxy && r.Header.Get("X-Forwarded-For") != "" {
|
||||
ip = r.Header.Get("X-Forwarded-For")
|
||||
}
|
||||
return s.visitorFromIP(ip)
|
||||
}
|
||||
|
||||
func (s *Server) visitorFromIP(ip string) *visitor {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
v, exists := s.visitors[ip]
|
||||
if !exists {
|
||||
s.visitors[ip] = newVisitor(s.config, ip)
|
||||
s.visitors[ip] = newVisitor(s.config, s.messageCache, ip)
|
||||
return s.visitors[ip]
|
||||
}
|
||||
v.Keepalive()
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# ntfy server config file
|
||||
#
|
||||
# Please refer to the documentation at https://ntfy.sh/docs/config/ for details.
|
||||
# All options also support underscores (_) instead of dashes (-) to comply with the YAML spec.
|
||||
|
||||
# Public facing base URL of the service (e.g. https://ntfy.sh or https://ntfy.example.com)
|
||||
# This setting is currently only used by the attachments and e-mail sending feature (outgoing mail only).
|
||||
@@ -127,10 +130,23 @@
|
||||
# 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 app. If you self-host, you don't want to change this.
|
||||
# Can be "app" (default), "home" or "disable" to disable the web app entirely.
|
||||
#
|
||||
# web-root: app
|
||||
|
||||
# Server URL of a Firebase/APNS-connected ntfy server (likely "https://ntfy.sh").
|
||||
#
|
||||
# iOS users:
|
||||
# If you use the iOS ntfy app, you MUST configure this to receive timely notifications. You'll like want this:
|
||||
# upstream-base-url: "https://ntfy.sh"
|
||||
#
|
||||
# If set, all incoming messages will publish a "poll_request" message to the configured upstream server, containing
|
||||
# the message ID of the original message, instructing the iOS app to poll this server for the actual message contents.
|
||||
# This is to prevent the upstream server and Firebase/APNS from being able to read the message.
|
||||
#
|
||||
# upstream-base-url:
|
||||
|
||||
# Rate limiting: Total number of topics before the server rejects new topics.
|
||||
#
|
||||
# global-topic-limit: 15000
|
||||
@@ -162,3 +178,11 @@
|
||||
#
|
||||
# visitor-attachment-total-size-limit: "100M"
|
||||
# visitor-attachment-daily-bandwidth-limit: "500M"
|
||||
|
||||
# Log level, can be TRACE, DEBUG, INFO, WARN or ERROR
|
||||
# This option can be hot-reloaded by calling "kill -HUP $pid" or "systemctl reload ntfy".
|
||||
#
|
||||
# Be aware that DEBUG (and particularly TRACE) can be VERY CHATTY. Only turn them on for
|
||||
# debugging purposes, or your disk will fill up quickly.
|
||||
#
|
||||
# log-level: INFO
|
||||
|
||||
@@ -3,18 +3,195 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
firebase "firebase.google.com/go"
|
||||
"firebase.google.com/go/messaging"
|
||||
"errors"
|
||||
firebase "firebase.google.com/go/v4"
|
||||
"firebase.google.com/go/v4/messaging"
|
||||
"fmt"
|
||||
"google.golang.org/api/option"
|
||||
"heckel.io/ntfy/auth"
|
||||
"heckel.io/ntfy/log"
|
||||
"heckel.io/ntfy/util"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
fcmMessageLimit = 4000
|
||||
fcmMessageLimit = 4000
|
||||
fcmApnsBodyMessageLimit = 100
|
||||
)
|
||||
|
||||
var (
|
||||
errFirebaseQuotaExceeded = errors.New("quota exceeded for Firebase messages to topic")
|
||||
)
|
||||
|
||||
// firebaseClient is a generic client that formats and sends messages to Firebase.
|
||||
// The actual Firebase implementation is implemented in firebaseSenderImpl, to make it testable.
|
||||
type firebaseClient struct {
|
||||
sender firebaseSender
|
||||
auther auth.Auther
|
||||
}
|
||||
|
||||
func newFirebaseClient(sender firebaseSender, auther auth.Auther) *firebaseClient {
|
||||
return &firebaseClient{
|
||||
sender: sender,
|
||||
auther: auther,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *firebaseClient) Send(v *visitor, m *message) error {
|
||||
if err := v.FirebaseAllowed(); err != nil {
|
||||
return errFirebaseQuotaExceeded
|
||||
}
|
||||
fbm, err := toFirebaseMessage(m, c.auther)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if log.IsTrace() {
|
||||
log.Trace("%s Firebase message: %s", logMessagePrefix(v, m), util.MaybeMarshalJSON(fbm))
|
||||
}
|
||||
err = c.sender.Send(fbm)
|
||||
if err == errFirebaseQuotaExceeded {
|
||||
log.Warn("%s Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor", logMessagePrefix(v, m))
|
||||
v.FirebaseTemporarilyDeny()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// firebaseSender is an interface that represents a client that can send to Firebase Cloud Messaging.
|
||||
// In tests, this can be implemented with a mock.
|
||||
type firebaseSender interface {
|
||||
// Send sends a message to Firebase, or returns an error. It returns errFirebaseQuotaExceeded
|
||||
// if a rate limit has reached.
|
||||
Send(m *messaging.Message) error
|
||||
}
|
||||
|
||||
// firebaseSenderImpl is a firebaseSender that actually talks to Firebase
|
||||
type firebaseSenderImpl struct {
|
||||
client *messaging.Client
|
||||
}
|
||||
|
||||
func newFirebaseSender(credentialsFile string) (*firebaseSenderImpl, error) {
|
||||
fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(credentialsFile))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err := fb.Messaging(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &firebaseSenderImpl{
|
||||
client: client,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *firebaseSenderImpl) Send(m *messaging.Message) error {
|
||||
_, err := c.client.Send(context.Background(), m)
|
||||
if err != nil && messaging.IsQuotaExceeded(err) {
|
||||
return errFirebaseQuotaExceeded
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// toFirebaseMessage converts a message to a Firebase message.
|
||||
//
|
||||
// Normal messages ("message"):
|
||||
// - For Android, we can receive data messages from Firebase and process them as code, so we just send all fields
|
||||
// in the "data" attribute. In the Android app, we then turn those into a notification and display it.
|
||||
// - On iOS, we are not allowed to receive data-only messages, so we build messages with an "alert" (with title and
|
||||
// message), and still send the rest of the data along in the "aps" attribute. We can then locally modify the
|
||||
// message in the Notification Service Extension.
|
||||
//
|
||||
// Keepalive messages ("keepalive"):
|
||||
// - On Android, we subscribe to the "~control" topic, which is used to restart the foreground service (if it died,
|
||||
// e.g. after an app update). We send these keepalive messages regularly (see Config.FirebaseKeepaliveInterval).
|
||||
// - On iOS, we subscribe to the "~poll" topic, which is used to poll all topics regularly. This is because iOS
|
||||
// does not allow any background or scheduled activity at all.
|
||||
//
|
||||
// Poll request messages ("poll_request"):
|
||||
// - Normal messages are turned into poll request messages if anonymous users are not allowed to read the message.
|
||||
// On Android, this will trigger the app to poll the topic and thereby displaying new messages.
|
||||
// - If UpstreamBaseURL is set, messages are forwarded as poll requests to an upstream server and then forwarded
|
||||
// to Firebase here. This is mainly for iOS to support self-hosted servers.
|
||||
func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, error) {
|
||||
var data map[string]string // Mostly matches https://ntfy.sh/docs/subscribe/api/#json-message-format
|
||||
var apnsConfig *messaging.APNSConfig
|
||||
switch m.Event {
|
||||
case keepaliveEvent, openEvent:
|
||||
data = map[string]string{
|
||||
"id": m.ID,
|
||||
"time": fmt.Sprintf("%d", m.Time),
|
||||
"event": m.Event,
|
||||
"topic": m.Topic,
|
||||
}
|
||||
apnsConfig = createAPNSBackgroundConfig(data)
|
||||
case pollRequestEvent:
|
||||
data = map[string]string{
|
||||
"id": m.ID,
|
||||
"time": fmt.Sprintf("%d", m.Time),
|
||||
"event": m.Event,
|
||||
"topic": m.Topic,
|
||||
"message": m.Message,
|
||||
"poll_id": m.PollID,
|
||||
}
|
||||
apnsConfig = createAPNSAlertConfig(m, data)
|
||||
case messageEvent:
|
||||
allowForward := true
|
||||
if auther != nil {
|
||||
allowForward = auther.Authorize(nil, m.Topic, auth.PermissionRead) == nil
|
||||
}
|
||||
if allowForward {
|
||||
data = map[string]string{
|
||||
"id": m.ID,
|
||||
"time": fmt.Sprintf("%d", m.Time),
|
||||
"event": m.Event,
|
||||
"topic": m.Topic,
|
||||
"priority": fmt.Sprintf("%d", m.Priority),
|
||||
"tags": strings.Join(m.Tags, ","),
|
||||
"click": m.Click,
|
||||
"title": m.Title,
|
||||
"message": m.Message,
|
||||
"encoding": m.Encoding,
|
||||
}
|
||||
if len(m.Actions) > 0 {
|
||||
actions, err := json.Marshal(m.Actions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data["actions"] = string(actions)
|
||||
}
|
||||
if m.Attachment != nil {
|
||||
data["attachment_name"] = m.Attachment.Name
|
||||
data["attachment_type"] = m.Attachment.Type
|
||||
data["attachment_size"] = fmt.Sprintf("%d", m.Attachment.Size)
|
||||
data["attachment_expires"] = fmt.Sprintf("%d", m.Attachment.Expires)
|
||||
data["attachment_url"] = m.Attachment.URL
|
||||
}
|
||||
apnsConfig = createAPNSAlertConfig(m, data)
|
||||
} else {
|
||||
// If anonymous read for a topic is not allowed, we cannot send the message along
|
||||
// via Firebase. Instead, we send a "poll_request" message, asking the client to poll.
|
||||
data = map[string]string{
|
||||
"id": m.ID,
|
||||
"time": fmt.Sprintf("%d", m.Time),
|
||||
"event": pollRequestEvent,
|
||||
"topic": m.Topic,
|
||||
}
|
||||
// TODO Handle APNS?
|
||||
}
|
||||
}
|
||||
var androidConfig *messaging.AndroidConfig
|
||||
if m.Priority >= 4 {
|
||||
androidConfig = &messaging.AndroidConfig{
|
||||
Priority: "high",
|
||||
}
|
||||
}
|
||||
return maybeTruncateFCMMessage(&messaging.Message{
|
||||
Topic: m.Topic,
|
||||
Data: data,
|
||||
Android: androidConfig,
|
||||
APNS: apnsConfig,
|
||||
}), nil
|
||||
}
|
||||
|
||||
// maybeTruncateFCMMessage performs best-effort truncation of FCM messages.
|
||||
// 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.
|
||||
@@ -34,80 +211,62 @@ func maybeTruncateFCMMessage(m *messaging.Message) *messaging.Message {
|
||||
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
|
||||
// createAPNSAlertConfig creates an APNS config for iOS notifications that show up as an alert (only relevant for iOS).
|
||||
// We must set the Alert struct ("alert"), and we need to set MutableContent ("mutable-content"), so the Notification Service
|
||||
// Extension in iOS can modify the message.
|
||||
func createAPNSAlertConfig(m *message, data map[string]string) *messaging.APNSConfig {
|
||||
apnsData := make(map[string]interface{})
|
||||
for k, v := range data {
|
||||
apnsData[k] = v
|
||||
}
|
||||
msg, err := fb.Messaging(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return &messaging.APNSConfig{
|
||||
Payload: &messaging.APNSPayload{
|
||||
CustomData: apnsData,
|
||||
Aps: &messaging.Aps{
|
||||
MutableContent: true,
|
||||
Alert: &messaging.ApsAlert{
|
||||
Title: m.Title,
|
||||
Body: maybeTruncateAPNSBodyMessage(m.Message),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
return func(m *message) error {
|
||||
fbm, err := toFirebaseMessage(m, auther)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = msg.Send(context.Background(), fbm)
|
||||
return err
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, error) {
|
||||
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 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,
|
||||
}
|
||||
}
|
||||
// createAPNSBackgroundConfig creates an APNS config for a silent background message (only relevant for iOS). Apple only
|
||||
// allows us to send 2-3 of these notifications per hour, and delivery not guaranteed. We use this only for the ~poll
|
||||
// topic, which triggers the iOS app to poll all topics for changes.
|
||||
//
|
||||
// See https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/pushing_background_updates_to_your_app
|
||||
func createAPNSBackgroundConfig(data map[string]string) *messaging.APNSConfig {
|
||||
apnsData := make(map[string]interface{})
|
||||
for k, v := range data {
|
||||
apnsData[k] = v
|
||||
}
|
||||
var androidConfig *messaging.AndroidConfig
|
||||
if m.Priority >= 4 {
|
||||
androidConfig = &messaging.AndroidConfig{
|
||||
Priority: "high",
|
||||
}
|
||||
return &messaging.APNSConfig{
|
||||
Headers: map[string]string{
|
||||
"apns-push-type": "background",
|
||||
"apns-priority": "5",
|
||||
},
|
||||
Payload: &messaging.APNSPayload{
|
||||
Aps: &messaging.Aps{
|
||||
ContentAvailable: true,
|
||||
},
|
||||
CustomData: apnsData,
|
||||
},
|
||||
}
|
||||
return maybeTruncateFCMMessage(&messaging.Message{
|
||||
Topic: m.Topic,
|
||||
Data: data,
|
||||
Android: androidConfig,
|
||||
}), nil
|
||||
}
|
||||
|
||||
// maybeTruncateAPNSBodyMessage truncates the body for APNS.
|
||||
//
|
||||
// The "body" of the push notification can contain the entire message, which would count doubly for the overall length
|
||||
// of the APNS payload. I set a limit of 100 characters before truncating the notification "body" with ellipsis.
|
||||
// The message would not be changed (unless truncated for being too long). Note: if the payload is too large (>4KB),
|
||||
// APNS will simply reject / discard the notification, meaning it will never arrive on the iOS device.
|
||||
func maybeTruncateAPNSBodyMessage(s string) string {
|
||||
if len(s) >= fcmApnsBodyMessageLimit {
|
||||
over := len(s) - fcmApnsBodyMessageLimit + 3 // len("...")
|
||||
return s[:len(s)-over] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ package server
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"firebase.google.com/go/messaging"
|
||||
"firebase.google.com/go/v4/messaging"
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/auth"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -26,12 +27,58 @@ func (t testAuther) Authorize(_ *auth.User, _ string, _ auth.Permission) error {
|
||||
return errors.New("unauthorized")
|
||||
}
|
||||
|
||||
type testFirebaseSender struct {
|
||||
allowed int
|
||||
messages []*messaging.Message
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newTestFirebaseSender(allowed int) *testFirebaseSender {
|
||||
return &testFirebaseSender{
|
||||
allowed: allowed,
|
||||
messages: make([]*messaging.Message, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *testFirebaseSender) Send(m *messaging.Message) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.messages)+1 > s.allowed {
|
||||
return errFirebaseQuotaExceeded
|
||||
}
|
||||
s.messages = append(s.messages, m)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *testFirebaseSender) Messages() []*messaging.Message {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append(make([]*messaging.Message, 0), s.messages...)
|
||||
}
|
||||
|
||||
func TestToFirebaseMessage_Keepalive(t *testing.T) {
|
||||
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, &messaging.APNSConfig{
|
||||
Headers: map[string]string{
|
||||
"apns-push-type": "background",
|
||||
"apns-priority": "5",
|
||||
},
|
||||
Payload: &messaging.APNSPayload{
|
||||
Aps: &messaging.Aps{
|
||||
ContentAvailable: true,
|
||||
},
|
||||
CustomData: map[string]interface{}{
|
||||
"id": m.ID,
|
||||
"time": fmt.Sprintf("%d", m.Time),
|
||||
"event": m.Event,
|
||||
"topic": m.Topic,
|
||||
},
|
||||
},
|
||||
}, fbm.APNS)
|
||||
require.Equal(t, map[string]string{
|
||||
"id": m.ID,
|
||||
"time": fmt.Sprintf("%d", m.Time),
|
||||
@@ -46,6 +93,23 @@ func TestToFirebaseMessage_Open(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "mytopic", fbm.Topic)
|
||||
require.Nil(t, fbm.Android)
|
||||
require.Equal(t, &messaging.APNSConfig{
|
||||
Headers: map[string]string{
|
||||
"apns-push-type": "background",
|
||||
"apns-priority": "5",
|
||||
},
|
||||
Payload: &messaging.APNSPayload{
|
||||
Aps: &messaging.Aps{
|
||||
ContentAvailable: true,
|
||||
},
|
||||
CustomData: map[string]interface{}{
|
||||
"id": m.ID,
|
||||
"time": fmt.Sprintf("%d", m.Time),
|
||||
"event": m.Event,
|
||||
"topic": m.Topic,
|
||||
},
|
||||
},
|
||||
}, fbm.APNS)
|
||||
require.Equal(t, map[string]string{
|
||||
"id": m.ID,
|
||||
"time": fmt.Sprintf("%d", m.Time),
|
||||
@@ -60,13 +124,31 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
|
||||
m.Tags = []string{"tag 1", "tag2"}
|
||||
m.Click = "https://google.com"
|
||||
m.Title = "some title"
|
||||
m.Actions = []*action{
|
||||
{
|
||||
ID: "123",
|
||||
Action: "view",
|
||||
Label: "Open page",
|
||||
Clear: true,
|
||||
URL: "https://ntfy.sh",
|
||||
},
|
||||
{
|
||||
ID: "456",
|
||||
Action: "http",
|
||||
Label: "Close door",
|
||||
URL: "https://door.com/close",
|
||||
Method: "PUT",
|
||||
Headers: map[string]string{
|
||||
"really": "yes",
|
||||
},
|
||||
},
|
||||
}
|
||||
m.Attachment = &attachment{
|
||||
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)
|
||||
@@ -74,6 +156,35 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
|
||||
require.Equal(t, &messaging.AndroidConfig{
|
||||
Priority: "high",
|
||||
}, fbm.Android)
|
||||
require.Equal(t, &messaging.APNSConfig{
|
||||
Payload: &messaging.APNSPayload{
|
||||
Aps: &messaging.Aps{
|
||||
MutableContent: true,
|
||||
Alert: &messaging.ApsAlert{
|
||||
Title: "some title",
|
||||
Body: "this is a message",
|
||||
},
|
||||
},
|
||||
CustomData: map[string]interface{}{
|
||||
"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",
|
||||
"actions": `[{"id":"123","action":"view","label":"Open page","clear":true,"url":"https://ntfy.sh"},{"id":"456","action":"http","label":"Close door","clear":false,"url":"https://door.com/close","method":"PUT","headers":{"really":"yes"}}]`,
|
||||
"encoding": "",
|
||||
"attachment_name": "some file.jpg",
|
||||
"attachment_type": "image/jpeg",
|
||||
"attachment_size": "12345",
|
||||
"attachment_expires": "98765543",
|
||||
"attachment_url": "https://example.com/file.jpg",
|
||||
},
|
||||
},
|
||||
}, fbm.APNS)
|
||||
require.Equal(t, map[string]string{
|
||||
"id": m.ID,
|
||||
"time": fmt.Sprintf("%d", m.Time),
|
||||
@@ -84,6 +195,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
|
||||
"click": "https://google.com",
|
||||
"title": "some title",
|
||||
"message": "this is a message",
|
||||
"actions": `[{"id":"123","action":"view","label":"Open page","clear":true,"url":"https://ntfy.sh"},{"id":"456","action":"http","label":"Close door","clear":false,"url":"https://door.com/close","method":"PUT","headers":{"really":"yes"}}]`,
|
||||
"encoding": "",
|
||||
"attachment_name": "some file.jpg",
|
||||
"attachment_type": "image/jpeg",
|
||||
@@ -112,6 +224,41 @@ func TestToFirebaseMessage_Message_Normal_Not_Allowed(t *testing.T) {
|
||||
}, fbm.Data)
|
||||
}
|
||||
|
||||
func TestToFirebaseMessage_PollRequest(t *testing.T) {
|
||||
m := newPollRequestMessage("mytopic", "fOv6k1QbCzo6")
|
||||
fbm, err := toFirebaseMessage(m, nil)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "mytopic", fbm.Topic)
|
||||
require.Nil(t, fbm.Android)
|
||||
require.Equal(t, &messaging.APNSConfig{
|
||||
Payload: &messaging.APNSPayload{
|
||||
Aps: &messaging.Aps{
|
||||
MutableContent: true,
|
||||
Alert: &messaging.ApsAlert{
|
||||
Title: "",
|
||||
Body: "New message",
|
||||
},
|
||||
},
|
||||
CustomData: map[string]interface{}{
|
||||
"id": m.ID,
|
||||
"time": fmt.Sprintf("%d", m.Time),
|
||||
"event": "poll_request",
|
||||
"topic": "mytopic",
|
||||
"message": "New message",
|
||||
"poll_id": "fOv6k1QbCzo6",
|
||||
},
|
||||
},
|
||||
}, fbm.APNS)
|
||||
require.Equal(t, map[string]string{
|
||||
"id": m.ID,
|
||||
"time": fmt.Sprintf("%d", m.Time),
|
||||
"event": "poll_request",
|
||||
"topic": "mytopic",
|
||||
"message": "New message",
|
||||
"poll_id": "fOv6k1QbCzo6",
|
||||
}, fbm.Data)
|
||||
}
|
||||
|
||||
func TestMaybeTruncateFCMMessage(t *testing.T) {
|
||||
origMessage := strings.Repeat("this is a long string", 300)
|
||||
origFCMMessage := &messaging.Message{
|
||||
@@ -168,3 +315,22 @@ func TestMaybeTruncateFCMMessage_NotTooLong(t *testing.T) {
|
||||
require.Equal(t, len(serializedOrigFCMMessage), len(serializedNotTruncatedFCMMessage))
|
||||
require.Equal(t, "", notTruncatedFCMMessage.Data["truncated"])
|
||||
}
|
||||
|
||||
func TestToFirebaseSender_Abuse(t *testing.T) {
|
||||
sender := &testFirebaseSender{allowed: 2}
|
||||
client := newFirebaseClient(sender, &testAuther{})
|
||||
visitor := newVisitor(newTestConfig(t), newMemTestCache(t), "1.2.3.4")
|
||||
|
||||
require.Nil(t, client.Send(visitor, &message{Topic: "mytopic"}))
|
||||
require.Equal(t, 1, len(sender.Messages()))
|
||||
|
||||
require.Nil(t, client.Send(visitor, &message{Topic: "mytopic"}))
|
||||
require.Equal(t, 2, len(sender.Messages()))
|
||||
|
||||
require.Equal(t, errFirebaseQuotaExceeded, client.Send(visitor, &message{Topic: "mytopic"}))
|
||||
require.Equal(t, 2, len(sender.Messages()))
|
||||
|
||||
sender.messages = make([]*messaging.Message, 0) // Reset to test that time limit is working
|
||||
require.Equal(t, errFirebaseQuotaExceeded, client.Send(visitor, &message{Topic: "mytopic"}))
|
||||
require.Equal(t, 0, len(sender.Messages()))
|
||||
}
|
||||
|
||||
@@ -6,18 +6,18 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/auth"
|
||||
"heckel.io/ntfy/util"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/auth"
|
||||
"heckel.io/ntfy/util"
|
||||
)
|
||||
|
||||
func TestServer_PublishAndPoll(t *testing.T) {
|
||||
@@ -54,6 +54,21 @@ func TestServer_PublishAndPoll(t *testing.T) {
|
||||
require.Equal(t, "my second message", lines[1]) // \n -> " "
|
||||
}
|
||||
|
||||
func TestServer_PublishWithFirebase(t *testing.T) {
|
||||
sender := newTestFirebaseSender(10)
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true})
|
||||
|
||||
response := request(t, s, "PUT", "/mytopic", "my first message", nil)
|
||||
msg1 := toMessage(t, response.Body.String())
|
||||
require.NotEmpty(t, msg1.ID)
|
||||
require.Equal(t, "my first message", msg1.Message)
|
||||
require.Equal(t, 1, len(sender.Messages()))
|
||||
require.Equal(t, "my first message", sender.Messages()[0].Data["message"])
|
||||
require.Equal(t, "my first message", sender.Messages()[0].APNS.Payload.Aps.Alert.Body)
|
||||
require.Equal(t, "my first message", sender.Messages()[0].APNS.Payload.CustomData["message"])
|
||||
}
|
||||
|
||||
func TestServer_SubscribeOpenAndKeepalive(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
c.KeepaliveInterval = time.Second
|
||||
@@ -162,6 +177,40 @@ func TestServer_StaticSites(t *testing.T) {
|
||||
require.Contains(t, rr.Body.String(), "</html>")
|
||||
}
|
||||
|
||||
func TestServer_WebEnabled(t *testing.T) {
|
||||
conf := newTestConfig(t)
|
||||
conf.EnableWeb = false
|
||||
s := newTestServer(t, conf)
|
||||
|
||||
rr := request(t, s, "GET", "/", "", nil)
|
||||
require.Equal(t, 404, rr.Code)
|
||||
|
||||
rr = request(t, s, "GET", "/example.html", "", nil)
|
||||
require.Equal(t, 404, rr.Code)
|
||||
|
||||
rr = request(t, s, "GET", "/config.js", "", nil)
|
||||
require.Equal(t, 404, rr.Code)
|
||||
|
||||
rr = request(t, s, "GET", "/static/css/home.css", "", nil)
|
||||
require.Equal(t, 404, rr.Code)
|
||||
|
||||
conf2 := newTestConfig(t)
|
||||
conf2.EnableWeb = true
|
||||
s2 := newTestServer(t, conf2)
|
||||
|
||||
rr = request(t, s2, "GET", "/", "", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
rr = request(t, s2, "GET", "/example.html", "", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
rr = request(t, s2, "GET", "/config.js", "", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
rr = request(t, s2, "GET", "/static/css/home.css", "", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishLargeMessage(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
c.AttachmentCacheDir = "" // Disable attachments
|
||||
@@ -203,6 +252,14 @@ func TestServer_PublishPriority(t *testing.T) {
|
||||
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) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
@@ -221,7 +278,7 @@ func TestServer_PublishNoCache(t *testing.T) {
|
||||
func TestServer_PublishAt(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
c.MinDelay = time.Second
|
||||
c.AtSenderInterval = 100 * time.Millisecond
|
||||
c.DelayedSenderInterval = 100 * time.Millisecond
|
||||
s := newTestServer(t, c)
|
||||
|
||||
response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{
|
||||
@@ -240,6 +297,13 @@ func TestServer_PublishAt(t *testing.T) {
|
||||
messages = toMessages(t, response.Body.String())
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "a message", messages[0].Message)
|
||||
require.Equal(t, "", messages[0].Sender) // Never return the sender!
|
||||
|
||||
messages, err := s.messageCache.Messages("mytopic", sinceAllMessages, true)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "a message", messages[0].Message)
|
||||
require.Equal(t, "9.9.9.9", messages[0].Sender) // It's stored in the DB though!
|
||||
}
|
||||
|
||||
func TestServer_PublishAtWithCacheError(t *testing.T) {
|
||||
@@ -411,29 +475,9 @@ func TestServer_PublishMessageInHeaderWithNewlines(t *testing.T) {
|
||||
require.Equal(t, "Line 1\nLine 2", msg.Message) // \\n -> \n !
|
||||
}
|
||||
|
||||
func TestServer_PublishFirebase(t *testing.T) {
|
||||
// This is unfortunately not much of a test, since it merely fires the messages towards Firebase,
|
||||
// but cannot re-read them. There is no way from Go to read the messages back, or even get an error back.
|
||||
// I tried everything. I already had written the test, and it increases the code coverage, so I'll leave it ... :shrug: ...
|
||||
|
||||
c := newTestConfig(t)
|
||||
c.FirebaseKeyFile = firebaseServiceAccountFile(t) // May skip the test!
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Normal message
|
||||
response := request(t, s, "PUT", "/mytopic", "This is a message for firebase", nil)
|
||||
msg := toMessage(t, response.Body.String())
|
||||
require.NotEmpty(t, msg.ID)
|
||||
|
||||
// Keepalive message
|
||||
require.Nil(t, s.firebase(newKeepaliveMessage(firebaseControlTopic)))
|
||||
|
||||
time.Sleep(500 * time.Millisecond) // Time for sends
|
||||
}
|
||||
|
||||
func TestServer_PublishInvalidTopic(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
s.mailer = &testMailer{}
|
||||
s.smtpSender = &testMailer{}
|
||||
response := request(t, s, "PUT", "/docs", "fail", nil)
|
||||
require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
||||
@@ -699,13 +743,23 @@ type testMailer struct {
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (t *testMailer) Send(from, to string, m *message) error {
|
||||
func (t *testMailer) Send(v *visitor, m *message, to string) error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.count++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *testMailer) Counts() (total int64, success int64, failure int64) {
|
||||
return 0, 0, 0
|
||||
}
|
||||
|
||||
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++ {
|
||||
@@ -745,7 +799,7 @@ func TestServer_PublishTooRequests_ShortReplenish(t *testing.T) {
|
||||
|
||||
func TestServer_PublishTooManyEmails_Defaults(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
s.mailer = &testMailer{}
|
||||
s.smtpSender = &testMailer{}
|
||||
for i := 0; i < 16; i++ {
|
||||
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
|
||||
"E-Mail": "test@example.com",
|
||||
@@ -762,7 +816,7 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
c.VisitorEmailLimitReplenish = 500 * time.Millisecond
|
||||
s := newTestServer(t, c)
|
||||
s.mailer = &testMailer{}
|
||||
s.smtpSender = &testMailer{}
|
||||
for i := 0; i < 16; i++ {
|
||||
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
|
||||
"E-Mail": "test@example.com",
|
||||
@@ -788,7 +842,7 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) {
|
||||
|
||||
func TestServer_PublishDelayedEmail_Fail(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
s.mailer = &testMailer{}
|
||||
s.smtpSender = &testMailer{}
|
||||
response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{
|
||||
"E-Mail": "test@example.com",
|
||||
"Delay": "20 min",
|
||||
@@ -862,6 +916,103 @@ func TestServer_PublishUnifiedPushText(t *testing.T) {
|
||||
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.smtpSender = 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) {
|
||||
content := util.RandomString(5000) // > 4096
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
@@ -870,9 +1021,9 @@ func TestServer_PublishAttachment(t *testing.T) {
|
||||
require.Equal(t, "attachment.txt", msg.Attachment.Name)
|
||||
require.Equal(t, "text/plain; charset=utf-8", msg.Attachment.Type)
|
||||
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.Equal(t, "", msg.Attachment.Owner) // Should never be returned
|
||||
require.Equal(t, "", msg.Sender) // Should never be returned
|
||||
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID))
|
||||
|
||||
path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345")
|
||||
@@ -882,7 +1033,7 @@ func TestServer_PublishAttachment(t *testing.T) {
|
||||
require.Equal(t, content, response.Body.String())
|
||||
|
||||
// Slightly unrelated cross-test: make sure we add an owner for internal attachments
|
||||
size, err := s.messageCache.AttachmentsSize("9.9.9.9") // See request()
|
||||
size, err := s.messageCache.AttachmentBytesUsed("9.9.9.9") // See request()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(5000), size)
|
||||
}
|
||||
@@ -901,7 +1052,7 @@ func TestServer_PublishAttachmentShortWithFilename(t *testing.T) {
|
||||
require.Equal(t, int64(21), msg.Attachment.Size)
|
||||
require.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(3*time.Hour).Unix())
|
||||
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
||||
require.Equal(t, "", msg.Attachment.Owner) // Should never be returned
|
||||
require.Equal(t, "", msg.Sender) // Should never be returned
|
||||
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID))
|
||||
|
||||
path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345")
|
||||
@@ -911,7 +1062,7 @@ func TestServer_PublishAttachmentShortWithFilename(t *testing.T) {
|
||||
require.Equal(t, content, response.Body.String())
|
||||
|
||||
// Slightly unrelated cross-test: make sure we add an owner for internal attachments
|
||||
size, err := s.messageCache.AttachmentsSize("1.2.3.4")
|
||||
size, err := s.messageCache.AttachmentBytesUsed("1.2.3.4")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(21), size)
|
||||
}
|
||||
@@ -928,10 +1079,10 @@ func TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) {
|
||||
require.Equal(t, "", msg.Attachment.Type)
|
||||
require.Equal(t, int64(0), msg.Attachment.Size)
|
||||
require.Equal(t, int64(0), msg.Attachment.Expires)
|
||||
require.Equal(t, "", msg.Attachment.Owner)
|
||||
require.Equal(t, "", msg.Sender)
|
||||
|
||||
// Slightly unrelated cross-test: make sure we don't add an owner for external attachments
|
||||
size, err := s.messageCache.AttachmentsSize("127.0.0.1")
|
||||
size, err := s.messageCache.AttachmentBytesUsed("127.0.0.1")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(0), size)
|
||||
}
|
||||
@@ -949,7 +1100,7 @@ func TestServer_PublishAttachmentExternalWithFilename(t *testing.T) {
|
||||
require.Equal(t, "", msg.Attachment.Type)
|
||||
require.Equal(t, int64(0), msg.Attachment.Size)
|
||||
require.Equal(t, int64(0), msg.Attachment.Expires)
|
||||
require.Equal(t, "", msg.Attachment.Owner)
|
||||
require.Equal(t, "", msg.Sender)
|
||||
}
|
||||
|
||||
func TestServer_PublishAttachmentBadURL(t *testing.T) {
|
||||
@@ -968,9 +1119,9 @@ func TestServer_PublishAttachmentTooLargeContentLength(t *testing.T) {
|
||||
"Content-Length": "20000000",
|
||||
})
|
||||
err := toHTTPError(t, response.Body.String())
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 400, err.HTTPCode)
|
||||
require.Equal(t, 40012, err.Code)
|
||||
require.Equal(t, 413, response.Code)
|
||||
require.Equal(t, 413, err.HTTPCode)
|
||||
require.Equal(t, 41301, err.Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishAttachmentTooLargeBodyAttachmentFileSizeLimit(t *testing.T) {
|
||||
@@ -980,9 +1131,9 @@ func TestServer_PublishAttachmentTooLargeBodyAttachmentFileSizeLimit(t *testing.
|
||||
s := newTestServer(t, c)
|
||||
response := request(t, s, "PUT", "/mytopic", content, nil)
|
||||
err := toHTTPError(t, response.Body.String())
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 400, err.HTTPCode)
|
||||
require.Equal(t, 40012, err.Code)
|
||||
require.Equal(t, 413, response.Code)
|
||||
require.Equal(t, 413, err.HTTPCode)
|
||||
require.Equal(t, 41301, err.Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishAttachmentExpiryBeforeDelivery(t *testing.T) {
|
||||
@@ -1012,9 +1163,9 @@ func TestServer_PublishAttachmentTooLargeBodyVisitorAttachmentTotalSizeLimit(t *
|
||||
content := util.RandomString(5001) // 5000+5001 > , see below
|
||||
response = request(t, s, "PUT", "/mytopic", content, nil)
|
||||
err := toHTTPError(t, response.Body.String())
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 400, err.HTTPCode)
|
||||
require.Equal(t, 40012, err.Code)
|
||||
require.Equal(t, 413, response.Code)
|
||||
require.Equal(t, 413, err.HTTPCode)
|
||||
require.Equal(t, 41301, err.Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishAttachmentAndPrune(t *testing.T) {
|
||||
@@ -1088,8 +1239,32 @@ func TestServer_PublishAttachmentBandwidthLimitUploadOnly(t *testing.T) {
|
||||
// And a failed one
|
||||
response := request(t, s, "PUT", "/mytopic", content, nil)
|
||||
err := toHTTPError(t, response.Body.String())
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40012, err.Code)
|
||||
require.Equal(t, 413, response.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 {
|
||||
@@ -1163,18 +1338,6 @@ func toHTTPError(t *testing.T, s string) *errHTTP {
|
||||
return &e
|
||||
}
|
||||
|
||||
func firebaseServiceAccountFile(t *testing.T) string {
|
||||
if os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE") != "" {
|
||||
return os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE")
|
||||
} else if os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT") != "" {
|
||||
filename := filepath.Join(t.TempDir(), "firebase.json")
|
||||
require.NotNil(t, os.WriteFile(filename, []byte(os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT")), 0600))
|
||||
return filename
|
||||
}
|
||||
t.SkipNow()
|
||||
return ""
|
||||
}
|
||||
|
||||
func basicAuth(s string) string {
|
||||
return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(s)))
|
||||
}
|
||||
|
||||
@@ -4,33 +4,62 @@ import (
|
||||
_ "embed" // required by go:embed
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/log"
|
||||
"heckel.io/ntfy/util"
|
||||
"mime"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type mailer interface {
|
||||
Send(from, to string, m *message) error
|
||||
Send(v *visitor, m *message, to string) error
|
||||
Counts() (total int64, success int64, failure int64)
|
||||
}
|
||||
|
||||
type smtpSender struct {
|
||||
config *Config
|
||||
config *Config
|
||||
success int64
|
||||
failure int64
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (s *smtpSender) Send(senderIP, to string, m *message) error {
|
||||
host, _, err := net.SplitHostPort(s.config.SMTPSenderAddr)
|
||||
func (s *smtpSender) Send(v *visitor, m *message, to string) error {
|
||||
return s.withCount(v, m, func() error {
|
||||
host, _, err := net.SplitHostPort(s.config.SMTPSenderAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
message, err := formatMail(s.config.BaseURL, v.ip, s.config.SMTPSenderFrom, to, m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
auth := smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host)
|
||||
log.Debug("%s Sending mail: via=%s, user=%s, pass=***, to=%s", logMessagePrefix(v, m), s.config.SMTPSenderAddr, s.config.SMTPSenderUser, to)
|
||||
log.Trace("%s Mail body: %s", logMessagePrefix(v, m), message)
|
||||
return smtp.SendMail(s.config.SMTPSenderAddr, auth, s.config.SMTPSenderFrom, []string{to}, []byte(message))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *smtpSender) Counts() (total int64, success int64, failure int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.success + s.failure, s.success, s.failure
|
||||
}
|
||||
|
||||
func (s *smtpSender) withCount(v *visitor, m *message, fn func() error) error {
|
||||
err := fn()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
log.Debug("%s Sending mail failed: %s", logMessagePrefix(v, m), err.Error())
|
||||
s.failure++
|
||||
} else {
|
||||
s.success++
|
||||
}
|
||||
message, err := formatMail(s.config.BaseURL, senderIP, s.config.SMTPSenderFrom, to, m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
auth := smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host)
|
||||
return smtp.SendMail(s.config.SMTPSenderAddr, auth, s.config.SMTPSenderFrom, []string{to}, []byte(message))
|
||||
return err
|
||||
}
|
||||
|
||||
func formatMail(baseURL, senderIP, from, to string, m *message) (string, error) {
|
||||
|
||||
@@ -3,10 +3,15 @@ package server
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/emersion/go-smtp"
|
||||
"heckel.io/ntfy/log"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -23,49 +28,55 @@ var (
|
||||
// smtpBackend implements SMTP server methods.
|
||||
type smtpBackend struct {
|
||||
config *Config
|
||||
sub subscriber
|
||||
handler func(http.ResponseWriter, *http.Request)
|
||||
success int64
|
||||
failure int64
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newMailBackend(conf *Config, sub subscriber) *smtpBackend {
|
||||
func newMailBackend(conf *Config, handler func(http.ResponseWriter, *http.Request)) *smtpBackend {
|
||||
return &smtpBackend{
|
||||
config: conf,
|
||||
sub: sub,
|
||||
config: conf,
|
||||
handler: handler,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *smtpBackend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) {
|
||||
return &smtpSession{backend: b}, nil
|
||||
log.Debug("%s Incoming mail, login with user %s", logSMTPPrefix(state), username)
|
||||
return &smtpSession{backend: b, state: state}, nil
|
||||
}
|
||||
|
||||
func (b *smtpBackend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) {
|
||||
return &smtpSession{backend: b}, nil
|
||||
log.Debug("%s Incoming mail, anonymous login", logSMTPPrefix(state))
|
||||
return &smtpSession{backend: b, state: state}, nil
|
||||
}
|
||||
|
||||
func (b *smtpBackend) Counts() (success int64, failure int64) {
|
||||
func (b *smtpBackend) Counts() (total int64, success int64, failure int64) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.success, b.failure
|
||||
return b.success + b.failure, b.success, b.failure
|
||||
}
|
||||
|
||||
// smtpSession is returned after EHLO.
|
||||
type smtpSession struct {
|
||||
backend *smtpBackend
|
||||
state *smtp.ConnectionState
|
||||
topic string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (s *smtpSession) AuthPlain(username, password string) error {
|
||||
log.Debug("%s AUTH PLAIN (with username %s)", logSMTPPrefix(s.state), username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *smtpSession) Mail(from string, opts smtp.MailOptions) error {
|
||||
log.Debug("%s MAIL FROM: %s (with options: %#v)", logSMTPPrefix(s.state), from, opts)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *smtpSession) Rcpt(to string) error {
|
||||
log.Debug("%s RCPT TO: %s", logSMTPPrefix(s.state), to)
|
||||
return s.withFailCount(func() error {
|
||||
conf := s.backend.config
|
||||
addressList, err := mail.ParseAddressList(to)
|
||||
@@ -102,6 +113,11 @@ func (s *smtpSession) Data(r io.Reader) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if log.IsTrace() {
|
||||
log.Trace("%s DATA: %s", logSMTPPrefix(s.state), string(b))
|
||||
} else if log.IsDebug() {
|
||||
log.Debug("%s DATA: %d byte(s)", logSMTPPrefix(s.state), len(b))
|
||||
}
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -128,7 +144,7 @@ func (s *smtpSession) Data(r io.Reader) error {
|
||||
m.Message = m.Title // Flip them, this makes more sense
|
||||
m.Title = ""
|
||||
}
|
||||
if err := s.backend.sub(m); err != nil {
|
||||
if err := s.publishMessage(m); err != nil {
|
||||
return err
|
||||
}
|
||||
s.backend.mu.Lock()
|
||||
@@ -138,6 +154,33 @@ func (s *smtpSession) Data(r io.Reader) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *smtpSession) publishMessage(m *message) error {
|
||||
// Extract remote address (for rate limiting)
|
||||
remoteAddr, _, err := net.SplitHostPort(s.state.RemoteAddr.String())
|
||||
if err != nil {
|
||||
remoteAddr = s.state.RemoteAddr.String()
|
||||
}
|
||||
|
||||
// Call HTTP handler with fake HTTP request
|
||||
url := fmt.Sprintf("%s/%s", s.backend.config.BaseURL, m.Topic)
|
||||
req, err := http.NewRequest("POST", url, strings.NewReader(m.Message))
|
||||
req.RequestURI = "/" + m.Topic // just for the logs
|
||||
req.RemoteAddr = remoteAddr // rate limiting!!
|
||||
req.Header.Set("X-Forwarded-For", remoteAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Title != "" {
|
||||
req.Header.Set("Title", m.Title)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
s.backend.handler(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
return errors.New("error: " + rr.Body.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *smtpSession) Reset() {
|
||||
s.mu.Lock()
|
||||
s.topic = ""
|
||||
@@ -153,43 +196,56 @@ func (s *smtpSession) withFailCount(fn func() error) error {
|
||||
s.backend.mu.Lock()
|
||||
defer s.backend.mu.Unlock()
|
||||
if err != nil {
|
||||
// Almost all of these errors are parse errors, and user input errors.
|
||||
// We do not want to spam the log with WARN messages.
|
||||
log.Debug("%s Incoming mail error: %s", logSMTPPrefix(s.state), err.Error())
|
||||
s.backend.failure++
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func readMailBody(msg *mail.Message) (string, error) {
|
||||
if msg.Header.Get("Content-Type") == "" {
|
||||
return readPlainTextMailBody(msg)
|
||||
}
|
||||
contentType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if contentType == "text/plain" {
|
||||
body, err := io.ReadAll(msg.Body)
|
||||
return readPlainTextMailBody(msg)
|
||||
} else if strings.HasPrefix(contentType, "multipart/") {
|
||||
return readMultipartMailBody(msg, params)
|
||||
}
|
||||
return "", errUnsupportedContentType
|
||||
}
|
||||
|
||||
func readPlainTextMailBody(msg *mail.Message) (string, error) {
|
||||
body, err := io.ReadAll(msg.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
func readMultipartMailBody(msg *mail.Message, params map[string]string) (string, error) {
|
||||
mr := multipart.NewReader(msg.Body, params["boundary"])
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err != nil { // may be io.EOF
|
||||
return "", err
|
||||
}
|
||||
partContentType, _, err := mime.ParseMediaType(part.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if partContentType != "text/plain" {
|
||||
continue
|
||||
}
|
||||
body, err := io.ReadAll(part)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
if strings.HasPrefix(contentType, "multipart/") {
|
||||
mr := multipart.NewReader(msg.Body, params["boundary"])
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err != nil { // may be io.EOF
|
||||
return "", err
|
||||
}
|
||||
partContentType, _, err := mime.ParseMediaType(part.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if partContentType != "text/plain" {
|
||||
continue
|
||||
}
|
||||
body, err := io.ReadAll(part)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
}
|
||||
return "", errUnsupportedContentType
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ package server
|
||||
import (
|
||||
"github.com/emersion/go-smtp"
|
||||
"github.com/stretchr/testify/require"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -27,13 +30,12 @@ Content-Type: text/html; charset="UTF-8"
|
||||
<div dir="ltr">what's up<br clear="all"><div><br></div></div>
|
||||
|
||||
--000000000000f3320b05d42915c9--`
|
||||
_, backend := newTestBackend(t, func(m *message) error {
|
||||
require.Equal(t, "mytopic", m.Topic)
|
||||
require.Equal(t, "and one more", m.Title)
|
||||
require.Equal(t, "what's up", m.Message)
|
||||
return nil
|
||||
_, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic", r.URL.Path)
|
||||
require.Equal(t, "and one more", r.Header.Get("Title"))
|
||||
require.Equal(t, "what's up", readAll(t, r.Body))
|
||||
})
|
||||
session, _ := backend.AnonymousLogin(nil)
|
||||
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
|
||||
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||
require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh"))
|
||||
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||
@@ -59,13 +61,12 @@ Content-Type: text/html; charset="UTF-8"
|
||||
<div dir="ltr"><br></div>
|
||||
|
||||
--000000000000bcf4a405d429f8d4--`
|
||||
_, backend := newTestBackend(t, func(m *message) error {
|
||||
require.Equal(t, "emailtest", m.Topic)
|
||||
require.Equal(t, "", m.Title) // We flipped message and body
|
||||
require.Equal(t, "This email has a subject but no body", m.Message)
|
||||
return nil
|
||||
_, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/emailtest", r.URL.Path)
|
||||
require.Equal(t, "", r.Header.Get("Title")) // We flipped message and body
|
||||
require.Equal(t, "This email has a subject but no body", readAll(t, r.Body))
|
||||
})
|
||||
session, _ := backend.AnonymousLogin(nil)
|
||||
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
|
||||
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||
require.Nil(t, session.Rcpt("ntfy-emailtest@ntfy.sh"))
|
||||
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||
@@ -81,14 +82,30 @@ Content-Type: text/plain; charset="UTF-8"
|
||||
|
||||
what's up
|
||||
`
|
||||
conf, backend := newTestBackend(t, func(m *message) error {
|
||||
require.Equal(t, "mytopic", m.Topic)
|
||||
require.Equal(t, "and one more", m.Title)
|
||||
require.Equal(t, "what's up", m.Message)
|
||||
return nil
|
||||
conf, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic", r.URL.Path)
|
||||
require.Equal(t, "and one more", r.Header.Get("Title"))
|
||||
require.Equal(t, "what's up", readAll(t, r.Body))
|
||||
})
|
||||
conf.SMTPServerAddrPrefix = ""
|
||||
session, _ := backend.AnonymousLogin(nil)
|
||||
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
|
||||
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
|
||||
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||
}
|
||||
|
||||
func TestSmtpBackend_Plaintext_No_ContentType(t *testing.T) {
|
||||
email := `Subject: Very short mail
|
||||
|
||||
what's up
|
||||
`
|
||||
conf, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic", r.URL.Path)
|
||||
require.Equal(t, "Very short mail", r.Header.Get("Title"))
|
||||
require.Equal(t, "what's up", readAll(t, r.Body))
|
||||
})
|
||||
conf.SMTPServerAddrPrefix = ""
|
||||
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
|
||||
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
|
||||
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||
@@ -103,11 +120,10 @@ Content-Type: text/plain; charset="UTF-8"
|
||||
|
||||
what's up
|
||||
`
|
||||
_, backend := newTestBackend(t, func(m *message) error {
|
||||
require.Equal(t, "Three santas 🎅🎅🎅", m.Title)
|
||||
return nil
|
||||
_, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "Three santas 🎅🎅🎅", r.Header.Get("Title"))
|
||||
})
|
||||
session, _ := backend.AnonymousLogin(nil)
|
||||
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
|
||||
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||
require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh"))
|
||||
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||
@@ -122,7 +138,7 @@ To: mytopic@ntfy.sh
|
||||
Content-Type: text/plain; charset="UTF-8"
|
||||
|
||||
you know this is a string.
|
||||
it's a long string.
|
||||
it's a long string.
|
||||
it's supposed to be longer than the max message length
|
||||
which is 4096 bytes,
|
||||
it used to be 512 bytes, but I increased that for the UnifiedPush support
|
||||
@@ -186,9 +202,9 @@ BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
||||
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
||||
that should do it
|
||||
`
|
||||
conf, backend := newTestBackend(t, func(m *message) error {
|
||||
conf, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
expected := `you know this is a string.
|
||||
it's a long string.
|
||||
it's a long string.
|
||||
it's supposed to be longer than the max message length
|
||||
which is 4096 bytes,
|
||||
it used to be 512 bytes, but I increased that for the UnifiedPush support
|
||||
@@ -248,13 +264,12 @@ AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||
......................................................................
|
||||
......................................................................
|
||||
and with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
||||
BBBBBBBBBBBBBBBBBBBBBBBB`
|
||||
BBBBBBBBBBBBBBBBBBBBBBBBB`
|
||||
require.Equal(t, 4096, len(expected)) // Sanity check
|
||||
require.Equal(t, expected, m.Message)
|
||||
return nil
|
||||
require.Equal(t, expected, readAll(t, r.Body))
|
||||
})
|
||||
conf.SMTPServerAddrPrefix = ""
|
||||
session, _ := backend.AnonymousLogin(nil)
|
||||
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
|
||||
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
|
||||
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||
@@ -270,21 +285,41 @@ Content-Type: text/SOMETHINGELSE
|
||||
|
||||
what's up
|
||||
`
|
||||
conf, backend := newTestBackend(t, func(m *message) error {
|
||||
return nil
|
||||
conf, backend := newTestBackend(t, func(http.ResponseWriter, *http.Request) {
|
||||
// Nothing.
|
||||
})
|
||||
conf.SMTPServerAddrPrefix = ""
|
||||
session, _ := backend.Login(nil, "user", "pass")
|
||||
session, _ := backend.Login(fakeConnState(t, "1.2.3.4"), "user", "pass")
|
||||
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
|
||||
require.Equal(t, errUnsupportedContentType, session.Data(strings.NewReader(email)))
|
||||
}
|
||||
|
||||
func newTestBackend(t *testing.T, sub subscriber) (*Config, *smtpBackend) {
|
||||
func newTestBackend(t *testing.T, handler func(http.ResponseWriter, *http.Request)) (*Config, *smtpBackend) {
|
||||
conf := newTestConfig(t)
|
||||
conf.SMTPServerListen = ":25"
|
||||
conf.SMTPServerDomain = "ntfy.sh"
|
||||
conf.SMTPServerAddrPrefix = "ntfy-"
|
||||
backend := newMailBackend(conf, sub)
|
||||
backend := newMailBackend(conf, handler)
|
||||
return conf, backend
|
||||
}
|
||||
|
||||
func readAll(t *testing.T, rc io.ReadCloser) string {
|
||||
b, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func fakeConnState(t *testing.T, remoteAddr string) *smtp.ConnectionState {
|
||||
ip, err := net.ResolveIPAddr("ip", remoteAddr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &smtp.ConnectionState{
|
||||
Hostname: "myhostname",
|
||||
LocalAddr: ip,
|
||||
RemoteAddr: ip,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"log"
|
||||
"heckel.io/ntfy/log"
|
||||
"math/rand"
|
||||
"sync"
|
||||
)
|
||||
@@ -15,7 +15,7 @@ type topic struct {
|
||||
}
|
||||
|
||||
// subscriber is a function that is called for every new message on a topic
|
||||
type subscriber func(msg *message) error
|
||||
type subscriber func(v *visitor, msg *message) error
|
||||
|
||||
// newTopic creates a new topic
|
||||
func newTopic(id string) *topic {
|
||||
@@ -42,14 +42,19 @@ func (t *topic) Unsubscribe(id int) {
|
||||
}
|
||||
|
||||
// Publish asynchronously publishes to all subscribers
|
||||
func (t *topic) Publish(m *message) error {
|
||||
func (t *topic) Publish(v *visitor, m *message) error {
|
||||
go func() {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
for _, s := range t.subscribers {
|
||||
if err := s(m); err != nil {
|
||||
log.Printf("error publishing message to subscriber")
|
||||
if len(t.subscribers) > 0 {
|
||||
log.Debug("%s Forwarding to %d subscriber(s)", logMessagePrefix(v, m), len(t.subscribers))
|
||||
for _, s := range t.subscribers {
|
||||
if err := s(v, m); err != nil {
|
||||
log.Warn("%s Error forwarding to subscriber", logMessagePrefix(v, m))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Trace("%s No stream or WebSocket subscribers, not forwarding", logMessagePrefix(v, m))
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
|
||||
@@ -24,12 +24,15 @@ type message struct {
|
||||
Time int64 `json:"time"` // Unix time in seconds
|
||||
Event string `json:"event"` // One of the above
|
||||
Topic string `json:"topic"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
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"`
|
||||
PollID string `json:"poll_id,omitempty"`
|
||||
Sender string `json:"-"` // IP address of uploader, used for rate limiting
|
||||
Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes
|
||||
}
|
||||
|
||||
@@ -39,7 +42,41 @@ type attachment struct {
|
||||
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
|
||||
@@ -48,14 +85,11 @@ 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,
|
||||
ID: util.RandomString(messageIDLength),
|
||||
Time: time.Now().Unix(),
|
||||
Event: event,
|
||||
Topic: topic,
|
||||
Message: msg,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +108,13 @@ func newDefaultMessage(topic, msg string) *message {
|
||||
return newMessage(messageEvent, topic, msg)
|
||||
}
|
||||
|
||||
// newPollRequestMessage is a convenience method to create a poll request message
|
||||
func newPollRequestMessage(topic, pollID string) *message {
|
||||
m := newMessage(pollRequestEvent, topic, newMessageBody)
|
||||
m.PollID = pollID
|
||||
return m
|
||||
}
|
||||
|
||||
func validMessageID(s string) bool {
|
||||
return util.ValidRandomString(s, messageIDLength)
|
||||
}
|
||||
@@ -117,6 +158,7 @@ var (
|
||||
)
|
||||
|
||||
type queryFilter struct {
|
||||
ID string
|
||||
Message string
|
||||
Title string
|
||||
Tags []string
|
||||
@@ -124,6 +166,7 @@ type queryFilter struct {
|
||||
}
|
||||
|
||||
func parseQueryFilters(r *http.Request) (*queryFilter, error) {
|
||||
idFilter := readParam(r, "x-id", "id")
|
||||
messageFilter := readParam(r, "x-message", "message", "m")
|
||||
titleFilter := readParam(r, "x-title", "title", "t")
|
||||
tagsFilter := util.SplitNoEmpty(readParam(r, "x-tags", "tags", "tag", "ta"), ",")
|
||||
@@ -136,6 +179,7 @@ func parseQueryFilters(r *http.Request) (*queryFilter, error) {
|
||||
priorityFilter = append(priorityFilter, priority)
|
||||
}
|
||||
return &queryFilter{
|
||||
ID: idFilter,
|
||||
Message: messageFilter,
|
||||
Title: titleFilter,
|
||||
Tags: tagsFilter,
|
||||
@@ -146,11 +190,11 @@ func parseQueryFilters(r *http.Request) (*queryFilter, error) {
|
||||
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 {
|
||||
} else if q.ID != "" && msg.ID != q.ID {
|
||||
return false
|
||||
}
|
||||
if q.Title != "" && msg.Title != q.Title {
|
||||
} else if q.Message != "" && msg.Message != q.Message {
|
||||
return false
|
||||
} else if q.Title != "" && msg.Title != q.Title {
|
||||
return false
|
||||
}
|
||||
messagePriority := msg.Priority
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/emersion/go-smtp"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
@@ -40,3 +42,19 @@ func readQueryParam(r *http.Request, names ...string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func logMessagePrefix(v *visitor, m *message) string {
|
||||
return fmt.Sprintf("%s/%s/%s", v.ip, m.Topic, m.ID)
|
||||
}
|
||||
|
||||
func logHTTPPrefix(v *visitor, r *http.Request) string {
|
||||
requestURI := r.RequestURI
|
||||
if requestURI == "" {
|
||||
requestURI = r.URL.Path
|
||||
}
|
||||
return fmt.Sprintf("%s HTTP %s %s", v.ip, r.Method, requestURI)
|
||||
}
|
||||
|
||||
func logSMTPPrefix(state *smtp.ConnectionState) string {
|
||||
return fmt.Sprintf("%s/%s SMTP", state.Hostname, state.RemoteAddr.String())
|
||||
}
|
||||
|
||||
@@ -22,31 +22,38 @@ var (
|
||||
// visitor represents an API user, and its associated rate.Limiter used for rate limiting
|
||||
type visitor struct {
|
||||
config *Config
|
||||
messageCache *messageCache
|
||||
ip string
|
||||
requests *rate.Limiter
|
||||
emails *rate.Limiter
|
||||
subscriptions util.Limiter
|
||||
bandwidth util.Limiter
|
||||
firebase time.Time // Next allowed Firebase message
|
||||
seen time.Time
|
||||
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{
|
||||
config: conf,
|
||||
messageCache: messageCache,
|
||||
ip: ip,
|
||||
requests: rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst),
|
||||
emails: rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst),
|
||||
subscriptions: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)),
|
||||
bandwidth: util.NewBytesLimiter(conf.VisitorAttachmentDailyBandwidthLimit, 24*time.Hour),
|
||||
firebase: time.Unix(0, 0),
|
||||
seen: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (v *visitor) IP() string {
|
||||
return v.ip
|
||||
}
|
||||
|
||||
func (v *visitor) RequestAllowed() error {
|
||||
if !v.requests.Allow() {
|
||||
return errVisitorLimitReached
|
||||
@@ -54,6 +61,21 @@ func (v *visitor) RequestAllowed() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *visitor) FirebaseAllowed() error {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
if time.Now().Before(v.firebase) {
|
||||
return errVisitorLimitReached
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *visitor) FirebaseTemporarilyDeny() {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
v.firebase = time.Now().Add(v.config.FirebaseQuotaExceededPenaltyDuration)
|
||||
}
|
||||
|
||||
func (v *visitor) EmailAllowed() error {
|
||||
if !v.emails.Allow() {
|
||||
return errVisitorLimitReached
|
||||
@@ -91,3 +113,20 @@ func (v *visitor) Stale() bool {
|
||||
defer v.mu.Unlock()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
firebase "firebase.google.com/go"
|
||||
"firebase.google.com/go/messaging"
|
||||
firebase "firebase.google.com/go/v4"
|
||||
"firebase.google.com/go/v4/messaging"
|
||||
"flag"
|
||||
"fmt"
|
||||
"google.golang.org/api/option"
|
||||
|
||||
@@ -44,7 +44,7 @@ func TestSniffWriter_WriteUnknownMimeType(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
sw := NewContentTypeWriter(rr, "")
|
||||
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)
|
||||
require.Equal(t, "application/octet-stream", rr.Header().Get("Content-Type"))
|
||||
}
|
||||
|
||||
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) {
|
||||
underlying := io.NopCloser(strings.NewReader("1234567890"))
|
||||
peaked, err := Peak(underlying, 5)
|
||||
peaked, err := Peek(underlying, 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.Equal(t, []byte("12345"), peaked.PeakedBytes)
|
||||
require.Equal(t, []byte("12345"), peaked.PeekedBytes)
|
||||
require.Equal(t, true, peaked.LimitReached)
|
||||
|
||||
all, err := io.ReadAll(peaked)
|
||||
@@ -21,13 +21,13 @@ func TestPeak_LimitReached(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
func TestPeak_LimitNotReached(t *testing.T) {
|
||||
underlying := io.NopCloser(strings.NewReader("1234567890"))
|
||||
peaked, err := Peak(underlying, 15)
|
||||
peaked, err := Peek(underlying, 15)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -36,12 +36,12 @@ func TestPeak_LimitNotReached(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
func TestPeak_Nil(t *testing.T) {
|
||||
peaked, err := Peak(nil, 15)
|
||||
peaked, err := Peek(nil, 15)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -50,6 +50,6 @@ func TestPeak_Nil(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.Equal(t, []byte(""), all)
|
||||
require.Equal(t, []byte(""), peaked.PeakedBytes)
|
||||
require.Equal(t, []byte(""), peaked.PeekedBytes)
|
||||
require.Equal(t, false, peaked.LimitReached)
|
||||
}
|
||||
29
util/util.go
@@ -2,6 +2,7 @@ package util
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
@@ -77,6 +78,16 @@ func SplitNoEmpty(s string, sep string) []string {
|
||||
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
|
||||
func RandomString(length int) string {
|
||||
randomMutex.Lock() // Who would have thought that random.Intn() is not thread-safe?!
|
||||
@@ -173,11 +184,6 @@ func PriorityString(priority int) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// ExpandHome replaces "~" with the user's home directory
|
||||
func ExpandHome(path string) string {
|
||||
return os.ExpandEnv(strings.ReplaceAll(path, "~", "$HOME"))
|
||||
}
|
||||
|
||||
// ShortTopicURL shortens the topic URL to be human-friendly, removing the http:// or https://
|
||||
func ShortTopicURL(s string) string {
|
||||
return strings.TrimPrefix(strings.TrimPrefix(s, "https://"), "http://")
|
||||
@@ -259,3 +265,16 @@ func ReadPassword(in io.Reader) ([]byte, error) {
|
||||
func BasicAuth(user, pass string) string {
|
||||
return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", user, pass))))
|
||||
}
|
||||
|
||||
// MaybeMarshalJSON returns a JSON string of the given object, or "<cannot serialize>" if serialization failed.
|
||||
// This is useful for logging purposes where a failure doesn't matter that much.
|
||||
func MaybeMarshalJSON(v interface{}) string {
|
||||
jsonBytes, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return "<cannot serialize>"
|
||||
}
|
||||
if len(jsonBytes) > 5000 {
|
||||
return string(jsonBytes)[:5000]
|
||||
}
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package util
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -75,14 +74,6 @@ func TestSplitNoEmpty(t *testing.T) {
|
||||
require.Equal(t, []string{"tag1", "tag2"}, SplitNoEmpty("tag1,tag2,", ","))
|
||||
}
|
||||
|
||||
func TestExpandHome_WithTilde(t *testing.T) {
|
||||
require.Equal(t, os.Getenv("HOME")+"/this/is/a/path", ExpandHome("~/this/is/a/path"))
|
||||
}
|
||||
|
||||
func TestExpandHome_NoTilde(t *testing.T) {
|
||||
require.Equal(t, "/this/is/an/absolute/path", ExpandHome("/this/is/an/absolute/path"))
|
||||
}
|
||||
|
||||
func TestParsePriority(t *testing.T) {
|
||||
priorities := []string{"", "1", "2", "3", "4", "5", "min", "LOW", " default ", "HIgh", "max", "urgent"}
|
||||
expected := []int{0, 1, 2, 3, 4, 5, 1, 2, 3, 4, 5, 5}
|
||||
@@ -152,3 +143,17 @@ func TestParseSize_FailureInvalid(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
|
||||
8642
web/package-lock.json
generated
@@ -15,15 +15,18 @@
|
||||
"@mui/material": "latest",
|
||||
"dexie": "^3.2.1",
|
||||
"dexie-react-hooks": "^1.1.1",
|
||||
"i18next": "^21.6.14",
|
||||
"i18next-browser-languagedetector": "^6.1.4",
|
||||
"i18next-http-backend": "^1.4.0",
|
||||
"js-base64": "^3.7.2",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
"react-i18next": "^11.16.2",
|
||||
"react-infinite-scroll-component": "^6.1.0",
|
||||
"react-router-dom": "^6.2.2",
|
||||
"react-scripts": "^5.0.0",
|
||||
"stacktrace-gps": "^3.0.4",
|
||||
"stacktrace-js": "^2.0.2",
|
||||
"svgo": "^2.8.0"
|
||||
"stacktrace-js": "^2.0.2"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
<p>
|
||||
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="static/img/badge-googleplay.png"></a>
|
||||
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="static/img/badge-fdroid.png"></a>
|
||||
<a href="https://github.com/binwiederhier/ntfy/issues/4"><img src="static/img/badge-appstore.png"></a>
|
||||
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img src="static/img/badge-appstore.png"></a>
|
||||
</p>
|
||||
<p>
|
||||
Here's a video showing the app in action:
|
||||
|
||||
@@ -16,7 +16,7 @@ html {
|
||||
}
|
||||
|
||||
a, a:visited {
|
||||
color: #3a9784;
|
||||
color: #338574;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
@@ -114,7 +114,7 @@ code {
|
||||
}
|
||||
|
||||
.anchor .anchorLink:hover {
|
||||
color: #3a9784;
|
||||
color: #338574;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
@@ -221,7 +221,7 @@ figcaption {
|
||||
/* Header */
|
||||
|
||||
#header {
|
||||
background: #3a9784;
|
||||
background: #338574;
|
||||
height: 130px;
|
||||
}
|
||||
|
||||
|
||||
191
web/public/static/langs/bg.json
Normal file
@@ -0,0 +1,191 @@
|
||||
{
|
||||
"action_bar_clear_notifications": "Премахване на известия",
|
||||
"alert_grant_description": "Разрешете на мрежовия четец да показва известия.",
|
||||
"notifications_attachment_copy_url_title": "Копиране на адреса на прикачения файл",
|
||||
"notifications_example": "Пример",
|
||||
"notifications_no_subscriptions_title": "Липсват абонаменти",
|
||||
"nav_topics_title": "Абонаменти",
|
||||
"action_bar_send_test_notification": "Пробно известие",
|
||||
"action_bar_unsubscribe": "Отписване",
|
||||
"nav_button_all_notifications": "Всички известия",
|
||||
"action_bar_settings": "Настройки",
|
||||
"publish_dialog_title_topic": "Публикуване в темата {{topic}}",
|
||||
"publish_dialog_title_no_topic": "Изпращане",
|
||||
"publish_dialog_progress_uploading": "Изпращане…",
|
||||
"publish_dialog_progress_uploading_detail": "Изпращане {{loaded}}/{{total}} ({{percent}}%)…",
|
||||
"publish_dialog_message_published": "Известието е публикувано",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "надвишава ограничението от {{fileSizeLimit}} за размер на файл и квотата, остават {{remainingBytes}}",
|
||||
"publish_dialog_message_label": "Съобщение",
|
||||
"publish_dialog_message_placeholder": "Въведете съобщение",
|
||||
"publish_dialog_other_features": "Други възможности:",
|
||||
"publish_dialog_chip_click_label": "Адрес",
|
||||
"publish_dialog_chip_email_label": "Препращане към ел. поща",
|
||||
"publish_dialog_chip_attach_url_label": "Прикачване на файл от адрес",
|
||||
"publish_dialog_chip_attach_file_label": "Прикачване местен файл",
|
||||
"publish_dialog_chip_delay_label": "Забавяне на изпращането",
|
||||
"publish_dialog_chip_topic_label": "Промяна на темата",
|
||||
"publish_dialog_button_cancel_sending": "Отменяне на изпращането",
|
||||
"publish_dialog_button_cancel": "Отказ",
|
||||
"subscribe_dialog_error_user_anonymous": "анонимен",
|
||||
"prefs_notifications_title": "Известия",
|
||||
"prefs_notifications_sound_title": "Звук при получаване",
|
||||
"prefs_notifications_sound_no_sound": "Без звук",
|
||||
"prefs_notifications_min_priority_title": "Минимален приоритет",
|
||||
"prefs_notifications_min_priority_any": "Всички",
|
||||
"prefs_notifications_min_priority_low_and_higher": "Нисък приоритет и по-висок",
|
||||
"prefs_notifications_min_priority_default_and_higher": "Подразбиран приоритет и по-висок",
|
||||
"prefs_notifications_min_priority_high_and_higher": "Висок приоритет и по-висок",
|
||||
"prefs_notifications_min_priority_max_only": "Само максимален приоритет",
|
||||
"prefs_notifications_delete_after_never": "Никога",
|
||||
"prefs_users_add_button": "Добавяне",
|
||||
"prefs_users_dialog_password_label": "Парола",
|
||||
"alert_not_supported_description": "Мрежовият четец не поддържа известия.",
|
||||
"message_bar_type_message": "Въведете съобщение",
|
||||
"message_bar_error_publishing": "Грешка при изпращане на известието",
|
||||
"notifications_copied_to_clipboard": "Копирано в междинната памет",
|
||||
"notifications_attachment_link_expired": "препратката за изтегляне е с изтекла давност",
|
||||
"nav_button_settings": "Настройки",
|
||||
"nav_button_documentation": "Ръководство",
|
||||
"nav_button_subscribe": "Абониране за тема",
|
||||
"alert_grant_title": "Известията са изключени",
|
||||
"alert_grant_button": "Разрешаване",
|
||||
"notifications_tags": "Етикети",
|
||||
"nav_button_publish_message": "Изпращане",
|
||||
"alert_not_supported_title": "Не се поддържат известия",
|
||||
"notifications_attachment_open_title": "Към {{url}}",
|
||||
"notifications_attachment_copy_url_button": "Копиране на адреса",
|
||||
"notifications_attachment_open_button": "Отваряне на прикачения файл",
|
||||
"notifications_attachment_link_expires": "препратката изтича на {{date}}",
|
||||
"notifications_actions_open_url_title": "Към {{url}}",
|
||||
"notifications_click_copy_url_button": "Копиране на препратка",
|
||||
"notifications_click_open_button": "Отваряне",
|
||||
"notifications_click_copy_url_title": "Копиране на препратката в междинната памет",
|
||||
"notifications_none_for_topic_title": "Липсват известия в темата",
|
||||
"notifications_none_for_any_title": "Липсват известия",
|
||||
"notifications_none_for_topic_description": "За да изпратите известия в тази тема, просто направете PUT или POST към адреса ѝ.",
|
||||
"notifications_none_for_any_description": "За да изпратите известия в тема, просто направете PUT или POST към адреса ѝ. Ето пример с една от вашите теми.",
|
||||
"notifications_no_subscriptions_description": "Щракнете върху „{{linktext}}“, за да създадете тема или да се абонирате. След това като изпратите съобщения чрез метода PUT или POST ще ги получите тук.",
|
||||
"notifications_more_details": "За допълнителна информация посетете <websiteLink>страницата</websiteLink> или <docsLink>документацията</docsLink>.",
|
||||
"publish_dialog_priority_min": "Мин. приоритет",
|
||||
"publish_dialog_attachment_limits_file_reached": "надвишава ограничението от {{fileSizeLimit}} за размер на файл",
|
||||
"publish_dialog_base_url_label": "Адрес на услугата",
|
||||
"publish_dialog_base_url_placeholder": "Адрес на услугата, напр. https://example.com",
|
||||
"publish_dialog_topic_placeholder": "Име на темата, напр. phils_alerts",
|
||||
"publish_dialog_priority_low": "Нисък приоритет",
|
||||
"publish_dialog_attachment_limits_quota_reached": "надвишава квотата, остават {{remainingBytes}}",
|
||||
"publish_dialog_priority_high": "Висок приоритет",
|
||||
"publish_dialog_priority_default": "Подразбиран приоритет",
|
||||
"publish_dialog_title_placeholder": "Заглавие на известието, напр. Предупреждение за диска",
|
||||
"publish_dialog_tags_label": "Етикети",
|
||||
"publish_dialog_email_label": "Адрес на електронна поща",
|
||||
"publish_dialog_priority_max": "Макс. приоритет",
|
||||
"publish_dialog_tags_placeholder": "Разделени със запетая етикети, напр. warning, srv1-backup",
|
||||
"publish_dialog_click_label": "Адрес",
|
||||
"publish_dialog_topic_label": "Име на темата",
|
||||
"publish_dialog_title_label": "Заглавие",
|
||||
"publish_dialog_priority_label": "Приоритет",
|
||||
"publish_dialog_click_placeholder": "Адрес, който се отваря при щракване върху известието",
|
||||
"publish_dialog_email_placeholder": "Поща, на която да се препрати известието, напр. phil@example.com",
|
||||
"publish_dialog_attach_label": "Адрес на прикачения файл",
|
||||
"publish_dialog_filename_placeholder": "Име на прикачения файл",
|
||||
"publish_dialog_attach_placeholder": "Прикачете файл от адрес, напр. https://f-droid.org/F-Droid.apk",
|
||||
"prefs_notifications_delete_after_three_hours": "След три часа",
|
||||
"publish_dialog_filename_label": "Име на файла",
|
||||
"publish_dialog_delay_label": "Забавяне",
|
||||
"publish_dialog_details_examples_description": "За примери и подробно описание на всички възможности при изпращане, вижте <docsLink>документацията</docsLink>.",
|
||||
"publish_dialog_button_send": "Изпращане",
|
||||
"publish_dialog_checkbox_publish_another": "Изпращане на повече",
|
||||
"publish_dialog_attached_file_title": "Прикачен файл:",
|
||||
"publish_dialog_attached_file_filename_placeholder": "Име на прикачения файл",
|
||||
"publish_dialog_drop_file_here": "Пуснете файла тук",
|
||||
"subscribe_dialog_subscribe_description": "Възможно е темите да не са защитени с парола, затова изберете име, което е трудно за отгатване. След като се абонирате, можете да изпращате известия по PUT или POST.",
|
||||
"emoji_picker_search_placeholder": "Търсете емоция",
|
||||
"subscribe_dialog_subscribe_title": "Абониране за тема",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "Име на темата, напр. phils_alerts",
|
||||
"subscribe_dialog_subscribe_use_another_label": "Използване на друг сървър",
|
||||
"subscribe_dialog_login_username_label": "Потребител, напр. phil",
|
||||
"subscribe_dialog_login_button_back": "Назад",
|
||||
"subscribe_dialog_subscribe_button_cancel": "Отказ",
|
||||
"subscribe_dialog_login_description": "Темата е защитена. За да се абонирате въведете потребител и парола.",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "Абониране",
|
||||
"subscribe_dialog_login_title": "Изисква се вход",
|
||||
"prefs_notifications_delete_after_title": "Автоматично премахване",
|
||||
"prefs_notifications_delete_after_one_day": "След един ден",
|
||||
"prefs_users_table_user_header": "Потребител",
|
||||
"prefs_users_dialog_title_edit": "Промяна на потребител",
|
||||
"prefs_users_dialog_base_url_label": "Адрес на услугата, e.g. https://ntfy.sh",
|
||||
"prefs_users_dialog_button_cancel": "Отказ",
|
||||
"prefs_users_dialog_button_save": "Запазване",
|
||||
"prefs_appearance_language_title": "Език",
|
||||
"subscribe_dialog_login_password_label": "Парола",
|
||||
"subscribe_dialog_login_button_login": "Вход",
|
||||
"subscribe_dialog_error_user_not_authorized": "Потребителят {{username}} няма достъп",
|
||||
"prefs_appearance_title": "Външен вид",
|
||||
"publish_dialog_delay_placeholder": "Забавяне на изпращането, {{unixTimestamp}}, {{relativeTime}} или „{{naturalLanguage}}“ (на английски)",
|
||||
"prefs_notifications_delete_after_one_week": "След една седмица",
|
||||
"prefs_users_title": "Управление на потребители",
|
||||
"prefs_users_table_base_url_header": "Адрес на услугата",
|
||||
"prefs_users_dialog_title_add": "Добавяне на потребител",
|
||||
"prefs_notifications_delete_after_one_month": "След един месец",
|
||||
"prefs_users_dialog_username_label": "Потребител, напр. phil",
|
||||
"prefs_users_dialog_button_add": "Добавяне",
|
||||
"error_boundary_title": "О, не, ntfy се срина",
|
||||
"error_boundary_description": "Това очевидно не трябва да се случва. Много съжаляваме!<br/>Ако имате минута, <githubLink>докладвайте в GitHub</githubLink> или ни уведомете в <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>.",
|
||||
"error_boundary_stack_trace": "Следа от стека",
|
||||
"error_boundary_gathering_info": "Събиране на допълнителна информация…",
|
||||
"notifications_loading": "Зареждане на известия…",
|
||||
"error_boundary_button_copy_stack_trace": "Копиране на следата от стека",
|
||||
"prefs_users_description": "Добавяйте и премахвайте потребители за защитените теми. Имайте предвид, че потребителското име и паролата се съхраняват в местната памет на мрежовия четец.",
|
||||
"prefs_notifications_sound_description_none": "Известията не са съпроводени със звук",
|
||||
"prefs_notifications_sound_description_some": "При пристигане известията са съпроводени от звука „{{sound}}“",
|
||||
"prefs_notifications_delete_after_never_description": "Известията никога не се премахват автоматично",
|
||||
"prefs_notifications_delete_after_three_hours_description": "Известията се премахват автоматично след три часа",
|
||||
"priority_min": "минимален",
|
||||
"priority_low": "нисък",
|
||||
"priority_high": "висок",
|
||||
"priority_max": "максимален",
|
||||
"priority_default": "подразбиран",
|
||||
"prefs_notifications_delete_after_one_week_description": "Известията се премахват автоматично след една седмица",
|
||||
"prefs_notifications_delete_after_one_day_description": "Известията се премахват автоматично след един ден",
|
||||
"prefs_notifications_min_priority_description_max": "Показват се известията с приоритет 5 (най-висок)",
|
||||
"prefs_notifications_delete_after_one_month_description": "Известията се премахват автоматично след един месец",
|
||||
"prefs_notifications_min_priority_description_any": "Показват се всички известия, независимо от приоритета",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Показват се известията с приоритет {{number}} ({{name}}) или по-висок",
|
||||
"notifications_actions_http_request_title": "Изпращане на HTTP {{method}} до {{url}}",
|
||||
"notifications_actions_not_supported": "Действието не се поддържа от приложението за интернет",
|
||||
"action_bar_show_menu": "Показване на менюто",
|
||||
"action_bar_logo_alt": "Логотип на ntfy",
|
||||
"action_bar_toggle_mute": "Заглушаване или пускне на известията",
|
||||
"action_bar_toggle_action_menu": "Отваряне или затваряне на менюто с действията",
|
||||
"nav_button_muted": "Известията са заглушени",
|
||||
"notifications_list": "Списък с известия",
|
||||
"notifications_list_item": "Известие",
|
||||
"notifications_delete": "Изтриване",
|
||||
"notifications_mark_read": "Отбелязване като прочетено",
|
||||
"nav_button_connecting": "свързване",
|
||||
"message_bar_show_dialog": "Показване на диалога за публикуване",
|
||||
"message_bar_publish": "Публикуване на съобщение",
|
||||
"notifications_priority_x": "Приоритет {{priority}}",
|
||||
"notifications_new_indicator": "Ново известие",
|
||||
"notifications_attachment_image": "Прикачено изображение",
|
||||
"notifications_attachment_file_image": "файл на изображение",
|
||||
"notifications_attachment_file_video": "файл на видео",
|
||||
"notifications_attachment_file_audio": "файл на аудио",
|
||||
"notifications_attachment_file_app": "Инсталационен файл на приложение за Android",
|
||||
"notifications_attachment_file_document": "друг документ",
|
||||
"publish_dialog_emoji_picker_show": "Избор на емоция",
|
||||
"publish_dialog_topic_reset": "Нулиране на тема",
|
||||
"publish_dialog_click_reset": "Премахване на адрес",
|
||||
"publish_dialog_email_reset": "Премахване на препращането към ел. поща",
|
||||
"publish_dialog_delay_reset": "Премахва забавянето на изпращането",
|
||||
"publish_dialog_attached_file_remove": "Премахване на прикачения файл",
|
||||
"emoji_picker_search_clear": "Изчистване на търсенето",
|
||||
"subscribe_dialog_subscribe_base_url_label": "Адрес на услугата",
|
||||
"prefs_notifications_sound_play": "Възпроизвеждане на избрания звук",
|
||||
"publish_dialog_attach_reset": "Премахване на адреса на файла за прикачане",
|
||||
"prefs_users_delete_button": "Премахване на потребител",
|
||||
"prefs_users_table": "Таблица с потребители",
|
||||
"prefs_users_edit_button": "Промяна на потребител",
|
||||
"error_boundary_unsupported_indexeddb_title": "Поверително разглеждане не се поддържа",
|
||||
"error_boundary_unsupported_indexeddb_description": "За да работи интернет-приложението ntfy се нуждае от IndexedDB, а мрежовият четец не поддържа IndexedDB в режим на поверително разглеждане.<br/><br/>Въпреки това, няма смисъл да използвате интернет-приложението ntfy в режим на поверително разглеждане, тъй като всичко се пази в хранилището на четеца. Можете да прочетете повече по <githubLink>проблема в GitHub</githubLink> или да се свържете с нас в <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>."
|
||||
}
|
||||
191
web/public/static/langs/cs.json
Normal file
@@ -0,0 +1,191 @@
|
||||
{
|
||||
"action_bar_settings": "Nastavení",
|
||||
"action_bar_send_test_notification": "Odeslání testovacího oznámení",
|
||||
"action_bar_clear_notifications": "Vymazat všechna oznámení",
|
||||
"action_bar_unsubscribe": "Odhlásit odběr",
|
||||
"message_bar_type_message": "Zde napište zprávu",
|
||||
"message_bar_error_publishing": "Chyba při odesílání oznámení",
|
||||
"nav_topics_title": "Odebíraná témata",
|
||||
"nav_button_all_notifications": "Všechna oznámení",
|
||||
"nav_button_settings": "Nastavení",
|
||||
"nav_button_documentation": "Dokumentace",
|
||||
"nav_button_publish_message": "Odeslat oznámení",
|
||||
"nav_button_subscribe": "Přihlásit se k odběru tématu",
|
||||
"alert_grant_title": "Oznámení jsou zakázána",
|
||||
"alert_grant_description": "Udělte prohlížeči oprávnění k zobrazování oznámení na ploše.",
|
||||
"alert_grant_button": "Udělit nyní",
|
||||
"alert_not_supported_title": "Oznámení nejsou podporována",
|
||||
"alert_not_supported_description": "Oznámení nejsou ve vašem prohlížeči podporována.",
|
||||
"notifications_copied_to_clipboard": "Zkopírováno do schránky",
|
||||
"notifications_tags": "Značky",
|
||||
"notifications_attachment_copy_url_title": "Kopírovat URL přílohy do schránky",
|
||||
"notifications_attachment_copy_url_button": "Kopírovat URL",
|
||||
"notifications_attachment_open_title": "Přejít na {{url}}",
|
||||
"notifications_attachment_open_button": "Otevřít přílohu",
|
||||
"notifications_attachment_link_expires": "platnost odkazu končí {{date}}",
|
||||
"notifications_attachment_link_expired": "platnost odkazu ke stažení vypršela",
|
||||
"notifications_click_copy_url_title": "Kopírovat URL odkazu do schránky",
|
||||
"notifications_click_copy_url_button": "Kopírovat odkaz",
|
||||
"notifications_click_open_button": "Otevřít odkaz",
|
||||
"notifications_none_for_topic_title": "K tomuto tématu jste zatím neobdrželi žádné oznámení.",
|
||||
"notifications_none_for_topic_description": "Pro odeslání oznámení k tomuto tématu, odešlete PUT nebo POST požadavek na URL tématu.",
|
||||
"notifications_example": "Příklad",
|
||||
"publish_dialog_base_url_placeholder": "URL služby, např. https://example.com",
|
||||
"publish_dialog_topic_label": "Název tématu",
|
||||
"publish_dialog_topic_placeholder": "Název tématu, např. phil_alerts",
|
||||
"publish_dialog_priority_default": "Výchozí priorita",
|
||||
"publish_dialog_priority_high": "Vysoká priorita",
|
||||
"publish_dialog_priority_max": "Nevyšší priorita",
|
||||
"publish_dialog_base_url_label": "URL služby",
|
||||
"prefs_users_dialog_password_label": "Heslo",
|
||||
"prefs_users_dialog_title_add": "Přidat uživatele",
|
||||
"prefs_users_dialog_title_edit": "Upravit uživatele",
|
||||
"prefs_users_dialog_base_url_label": "URL služby, např. https://ntfy.sh",
|
||||
"prefs_users_dialog_username_label": "Uživatelské jméno, např. phil",
|
||||
"notifications_actions_open_url_title": "Přejít na {{url}}",
|
||||
"notifications_none_for_any_title": "Neobdrželi jste žádná oznámení.",
|
||||
"notifications_none_for_any_description": "Pro odeslání oznámení k tématu stačí na URL tématu odeslat PUT nebo POST požadavek. Zde je příklad s použitím jednoho z vašich témat.",
|
||||
"notifications_no_subscriptions_description": "Kliknutím na \"{{linktext}}\" vytvoříte téma nebo se k němu přihlásíte. Poté můžete odesílat zprávy prostřednictvím PUT nebo POST požadavků a zde budete dostávat oznámení.",
|
||||
"notifications_more_details": "Další informace naleznete na <websiteLink>webových stránkách</websiteLink> nebo v <docsLink>dokumentaci</docsLink>.",
|
||||
"publish_dialog_title_topic": "Odeslat do {{téma}}",
|
||||
"publish_dialog_title_no_topic": "Odeslat oznámení",
|
||||
"publish_dialog_progress_uploading": "Nahrávání …",
|
||||
"publish_dialog_message_published": "Oznámení odesláno",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "překračuje {{fileSizeLimit}} limit souboru a kvótu, {{remainingBytes}} zbývá",
|
||||
"publish_dialog_attachment_limits_quota_reached": "překračuje kvótu, {{remainingBytes}} zbývá",
|
||||
"publish_dialog_priority_min": "Nejnižší priorita",
|
||||
"publish_dialog_title_label": "Název",
|
||||
"publish_dialog_title_placeholder": "Název oznámení, např. Upozornění na volné místo na disku",
|
||||
"publish_dialog_message_placeholder": "Zde napište zprávu",
|
||||
"publish_dialog_tags_label": "Značky",
|
||||
"publish_dialog_priority_label": "Priorita",
|
||||
"publish_dialog_click_label": "Klikněte na URL",
|
||||
"publish_dialog_click_placeholder": "Adresa URL, která se otevře po kliknutí na oznámení",
|
||||
"publish_dialog_email_label": "E-mail",
|
||||
"publish_dialog_email_placeholder": "Adresa pro odeslání oznámení, např. phil@example.com",
|
||||
"publish_dialog_attach_label": "URL přílohy",
|
||||
"publish_dialog_filename_label": "Název souboru",
|
||||
"publish_dialog_filename_placeholder": "Název souboru přílohy",
|
||||
"publish_dialog_delay_label": "Zpoždění",
|
||||
"publish_dialog_delay_placeholder": "Zpožděné doručení, např. {{unixTimestamp}}, {{relativeTime}} nebo \"{{naturalLanguage}}\". (pouze v angličtině)",
|
||||
"publish_dialog_chip_click_label": "Klikněte na URL",
|
||||
"publish_dialog_chip_email_label": "Přeposlat na e-mail",
|
||||
"publish_dialog_chip_delay_label": "Zpožděné doručení",
|
||||
"publish_dialog_chip_topic_label": "Změnit téma",
|
||||
"publish_dialog_details_examples_description": "Příklady a podrobný popis všech funkcí odesílání naleznete v <docsLink>dokumentaci</docsLink>.",
|
||||
"publish_dialog_chip_attach_url_label": "Připojit soubor pomocí URL",
|
||||
"publish_dialog_chip_attach_file_label": "Připojit místní soubor",
|
||||
"publish_dialog_button_send": "Odeslat",
|
||||
"publish_dialog_checkbox_publish_another": "Odeslat další",
|
||||
"publish_dialog_attached_file_title": "Přiložený soubor:",
|
||||
"publish_dialog_attached_file_filename_placeholder": "Název souboru přílohy",
|
||||
"publish_dialog_drop_file_here": "Přetáhněte soubor sem",
|
||||
"emoji_picker_search_placeholder": "Hledat emodži",
|
||||
"subscribe_dialog_subscribe_title": "Přihlásit odběr tématu",
|
||||
"subscribe_dialog_subscribe_description": "Témata nemusí být chráněna heslem, proto zvolte název, který není snadné uhodnout. Jakmile se přihlásíte k odběru, můžete odesílat oznámení pomocí PUT/POST požadavků.",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "Název tématu, např. phil_alerts",
|
||||
"subscribe_dialog_subscribe_use_another_label": "Použít jiný server",
|
||||
"subscribe_dialog_login_title": "Vyžadováno přihlášení",
|
||||
"subscribe_dialog_login_description": "Toto téma je chráněno heslem. Pro přihlášení k odběru zadejte prosím uživatelské jméno a heslo.",
|
||||
"subscribe_dialog_subscribe_button_cancel": "Zrušit",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "Přihlásit odběr",
|
||||
"subscribe_dialog_login_username_label": "Uživatelské jméno, např. phil",
|
||||
"subscribe_dialog_login_password_label": "Heslo",
|
||||
"subscribe_dialog_login_button_back": "Zpět",
|
||||
"subscribe_dialog_login_button_login": "Přihlásit se",
|
||||
"subscribe_dialog_error_user_not_authorized": "Uživatel {{username}} není autorizován",
|
||||
"subscribe_dialog_error_user_anonymous": "anonymně",
|
||||
"prefs_notifications_title": "Oznámení",
|
||||
"prefs_notifications_sound_description_some": "Oznámení přehrají při doručení zvuk {{sound}}",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Zobrazit oznámení, pokud je priorita {{number}} ({{name}}) nebo vyšší",
|
||||
"prefs_notifications_min_priority_description_max": "Zobrazit oznámení, pokud je priorita 5 (nejvyšší)",
|
||||
"prefs_notifications_min_priority_any": "Jakákoli priorita",
|
||||
"prefs_notifications_min_priority_low_and_higher": "Nízká priorita a vyšší",
|
||||
"prefs_notifications_min_priority_default_and_higher": "Výchozí priorita a vyšší",
|
||||
"prefs_notifications_min_priority_high_and_higher": "Vysoká priorita a vyšší",
|
||||
"prefs_notifications_delete_after_three_hours": "Po třech hodinách",
|
||||
"prefs_notifications_delete_after_one_day": "Po jednom dni",
|
||||
"prefs_notifications_delete_after_one_week": "Po jednom týdnu",
|
||||
"prefs_notifications_delete_after_one_month": "Po jednom měsíci",
|
||||
"prefs_notifications_delete_after_never_description": "Oznámení nejsou nikdy automaticky mazána",
|
||||
"prefs_notifications_delete_after_three_hours_description": "Oznámení se automaticky odstraní po třech hodinách",
|
||||
"prefs_notifications_delete_after_one_day_description": "Oznámení se automaticky odstraní po jednom dni",
|
||||
"prefs_notifications_delete_after_one_week_description": "Oznámení se automaticky odstraní po jednom týdnu",
|
||||
"prefs_notifications_delete_after_one_month_description": "Oznámení se automaticky odstraní po jednom měsíci",
|
||||
"prefs_users_title": "Správa uživatelů",
|
||||
"prefs_users_add_button": "Přidat uživatele",
|
||||
"prefs_users_table_user_header": "Uživatel",
|
||||
"prefs_users_table_base_url_header": "URL služby",
|
||||
"prefs_users_dialog_button_cancel": "Zrušit",
|
||||
"prefs_users_dialog_button_add": "Přidat",
|
||||
"prefs_users_dialog_button_save": "Uložit",
|
||||
"priority_min": "nejnižší",
|
||||
"priority_low": "nízká",
|
||||
"priority_default": "výchozí",
|
||||
"priority_high": "vysoká",
|
||||
"priority_max": "nejvyšší",
|
||||
"error_boundary_title": "Ale ne, ntfy havaroval",
|
||||
"error_boundary_description": "K tomu by samozřejmě nemělo docházet. Velmi se za to omlouváme.<br/>Pokud máte chvilku, nahlaste to prosím <githubLink>na GitHubu</githubLink> nebo nám dejte vědět prostřednictvím <discordLink>Discordu</discordLink> nebo <matrixLink>Matrixu</matrixLink>.",
|
||||
"error_boundary_button_copy_stack_trace": "Kopírovat výpis zásobníku",
|
||||
"error_boundary_stack_trace": "Výpis zásobníku",
|
||||
"publish_dialog_tags_placeholder": "Seznam značek oddělených čárkou, např. warning, srv1-backup",
|
||||
"notifications_actions_not_supported": "Akce není podporována ve webové aplikaci",
|
||||
"notifications_actions_http_request_title": "Odeslat HTTP {{metoda}} na {{url}}",
|
||||
"notifications_no_subscriptions_title": "Vypadá to, že ještě nemáte žádné odběry.",
|
||||
"prefs_notifications_min_priority_description_any": "Zobrazit všechna oznámení bez ohledu na prioritu",
|
||||
"publish_dialog_priority_low": "Nízká priorita",
|
||||
"publish_dialog_message_label": "Zpráva",
|
||||
"publish_dialog_button_cancel": "Zrušit",
|
||||
"notifications_loading": "Načítání oznámení …",
|
||||
"publish_dialog_progress_uploading_detail": "Nahrávání {{loaded}}/{{total}} ({{percent}}%) …",
|
||||
"publish_dialog_attachment_limits_file_reached": "překračuje {{fileSizeLimit}} limit souboru",
|
||||
"publish_dialog_attach_placeholder": "Připojit soubor pomocí URL, např. https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_button_cancel_sending": "Zrušit odesílání",
|
||||
"publish_dialog_other_features": "Další funkce:",
|
||||
"prefs_notifications_sound_title": "Zvuk oznámení",
|
||||
"prefs_notifications_min_priority_max_only": "Pouze nejvyšší priorita",
|
||||
"prefs_notifications_min_priority_title": "Nejnižší priorita",
|
||||
"prefs_notifications_delete_after_title": "Odstranit oznámení",
|
||||
"prefs_notifications_delete_after_never": "Nikdy",
|
||||
"prefs_notifications_sound_no_sound": "Žádný zvuk",
|
||||
"prefs_notifications_sound_description_none": "Oznámení nepřehrají při doručení žádný zvuk",
|
||||
"prefs_users_description": "Zde můžete přidávat/odebírat uživatele pro chráněná témata. Upozorňujeme, že uživatelské jméno a heslo jsou uloženy v místním úložišti prohlížeče.",
|
||||
"error_boundary_gathering_info": "Získejte více informací …",
|
||||
"prefs_appearance_language_title": "Jazyk",
|
||||
"prefs_appearance_title": "Vzhled",
|
||||
"action_bar_show_menu": "Zobrazit nabídku",
|
||||
"action_bar_logo_alt": "logo ntfy",
|
||||
"action_bar_toggle_mute": "Ztlumení/zrušení ztlumení oznámení",
|
||||
"action_bar_toggle_action_menu": "Otevřít/zavřít nabídku akcí",
|
||||
"message_bar_show_dialog": "Zobrazit okno pro odesílání oznámení",
|
||||
"message_bar_publish": "Odeslat zprávu",
|
||||
"nav_button_muted": "Oznámení ztlumena",
|
||||
"nav_button_connecting": "připojování",
|
||||
"notifications_list": "Seznam oznámení",
|
||||
"notifications_list_item": "Oznámení",
|
||||
"notifications_mark_read": "Označit jako přečtené",
|
||||
"notifications_delete": "Smazat",
|
||||
"notifications_new_indicator": "Nové oznámení",
|
||||
"notifications_attachment_image": "Obrázek přílohy",
|
||||
"notifications_attachment_file_image": "soubor s obrázkem",
|
||||
"notifications_attachment_file_video": "video soubor",
|
||||
"notifications_attachment_file_audio": "zvukový soubor",
|
||||
"notifications_attachment_file_app": "Soubor s aplikací pro Android",
|
||||
"publish_dialog_emoji_picker_show": "Vybrat emoji",
|
||||
"publish_dialog_topic_reset": "Obnovení tématu",
|
||||
"publish_dialog_click_reset": "Odebrat URL kliknutím",
|
||||
"publish_dialog_email_reset": "Odebrat přeposlání e-mailu",
|
||||
"publish_dialog_attach_reset": "Odebrat URL přílohy",
|
||||
"publish_dialog_attached_file_remove": "Odebrat přiložený soubor",
|
||||
"emoji_picker_search_clear": "Vyčistit vyhledávání",
|
||||
"prefs_users_edit_button": "Upravit uživatele",
|
||||
"prefs_users_delete_button": "Odstranit uživatele",
|
||||
"error_boundary_unsupported_indexeddb_title": "Soukromé prohlížení není podporováno",
|
||||
"error_boundary_unsupported_indexeddb_description": "Webová aplikace ntfy potřebuje ke svému fungování databázi IndexedDB a váš prohlížeč v režimu soukromého prohlížení databázi IndexedDB nepodporuje.<br/><br/>To je sice nepříjemné, ale používat webovou aplikaci ntfy v režimu soukromého prohlížení stejně nemá smysl, protože vše je uloženo v úložišti prohlížeče. Více se o tom můžete dočíst <githubLink>v tomto tématu na GitHubu</githubLink>, nebo se na nás obrátit pomocí služeb <discordLink>Discord</discordLink> nebo <matrixLink>Matrix</matrixLink>.",
|
||||
"notifications_priority_x": "Priorita {{priority}}",
|
||||
"subscribe_dialog_subscribe_base_url_label": "URL služby",
|
||||
"prefs_notifications_sound_play": "Přehrát vybraný zvuk",
|
||||
"prefs_users_table": "Tabulka uživatelů",
|
||||
"notifications_attachment_file_document": "jiný dokument",
|
||||
"publish_dialog_delay_reset": "Odebrat odložené doručení"
|
||||
}
|
||||
191
web/public/static/langs/de.json
Normal file
@@ -0,0 +1,191 @@
|
||||
{
|
||||
"nav_topics_title": "Abonnierte Themen",
|
||||
"nav_button_all_notifications": "Alle Benachrichtigungen",
|
||||
"nav_button_settings": "Einstellungen",
|
||||
"nav_button_documentation": "Dokumentation",
|
||||
"nav_button_publish_message": "Benachrichtigung senden",
|
||||
"nav_button_subscribe": "Thema abonnieren",
|
||||
"alert_grant_title": "Benachrichtigungen sind deaktiviert",
|
||||
"publish_dialog_base_url_label": "Service-URL",
|
||||
"publish_dialog_details_examples_description": "Beispiele und ausführliche Informationen zu allen Optionen findest Du in der <docsLink>Dokumentation</docsLink>.",
|
||||
"publish_dialog_attached_file_filename_placeholder": "Dateiname des Anhangs",
|
||||
"subscribe_dialog_login_description": "Dieses Thema benötigt eine Anmeldung. Bitte gib Benutzernamen und Kennwort ein.",
|
||||
"prefs_notifications_title": "Benachrichtigungen",
|
||||
"prefs_notifications_sound_title": "Benachrichtigungston",
|
||||
"prefs_notifications_min_priority_max_only": "Nur höchste Priorität",
|
||||
"prefs_notifications_delete_after_never": "Nie",
|
||||
"prefs_users_dialog_password_label": "Kennwort",
|
||||
"prefs_users_dialog_button_cancel": "Abbrechen",
|
||||
"prefs_users_dialog_button_add": "Hinzufügen",
|
||||
"prefs_users_dialog_button_save": "Speichern",
|
||||
"prefs_appearance_language_title": "Sprache",
|
||||
"notifications_none_for_any_description": "Um Benachrichtigungen an ein Thema zu senden, schicke einen PUT/POST-Request an die Themen-URL. Hier ist ein Beispiel mit einem Deiner Themen.",
|
||||
"publish_dialog_message_placeholder": "Gib hier eine Nachricht ein",
|
||||
"notifications_attachment_link_expires": "Link läuft ab am/um {{date}}",
|
||||
"notifications_click_copy_url_title": "Link-URL in Zwischenablage kopieren",
|
||||
"publish_dialog_priority_low": "Niedrige Priorität",
|
||||
"publish_dialog_message_label": "Nachricht",
|
||||
"action_bar_unsubscribe": "Von Thema abmelden",
|
||||
"notifications_copied_to_clipboard": "In Zwischenablage kopiert",
|
||||
"notifications_loading": "Benachrichtigungen werden geladen …",
|
||||
"notifications_attachment_open_title": "Gehe zu {{url}}",
|
||||
"notifications_none_for_any_title": "Du hast keine Benachrichtigungen empfangen.",
|
||||
"action_bar_send_test_notification": "Test-Benachrichtigung senden",
|
||||
"alert_grant_description": "Dem Browser erlauben, Desktop-Benachrichtigungen anzuzeigen.",
|
||||
"notifications_tags": "Tags",
|
||||
"message_bar_type_message": "Gib hier eine Nachricht ein",
|
||||
"message_bar_error_publishing": "Fehler beim Senden der Benachrichtigung",
|
||||
"alert_not_supported_title": "Benachrichtigungen werden nicht unterstützt",
|
||||
"alert_not_supported_description": "Benachrichtigungen werden von Deinem Browser nicht unterstützt.",
|
||||
"action_bar_settings": "Einstellungen",
|
||||
"action_bar_clear_notifications": "Alle Benachrichtigungen löschen",
|
||||
"alert_grant_button": "Jetzt erlauben",
|
||||
"notifications_none_for_topic_title": "Du hast für dieses Thema noch keine Benachrichtigungen empfangen.",
|
||||
"notifications_click_open_button": "Link öffnen",
|
||||
"notifications_more_details": "Ausführlichere Informationen findest Du auf der <websiteLink>Website</websiteLink> und in der <docsLink>Dokumentation</docsLink>.",
|
||||
"notifications_attachment_copy_url_title": "URL des Anhangs in Zwischenablage kopieren",
|
||||
"notifications_attachment_copy_url_button": "URL kopieren",
|
||||
"notifications_attachment_open_button": "Anhang öffnen",
|
||||
"notifications_attachment_link_expired": "Download-Link ist abgelaufen",
|
||||
"notifications_click_copy_url_button": "Link kopieren",
|
||||
"notifications_actions_open_url_title": "Gehe zu {{url}}",
|
||||
"publish_dialog_other_features": "Andere Optionen:",
|
||||
"notifications_none_for_topic_description": "Um Benachrichtigungen an dieses Thema zu senden, PUTe/POSTe an die Themen-URL.",
|
||||
"notifications_no_subscriptions_title": "Anscheinend hast Du noch keine Themen abonniert.",
|
||||
"notifications_no_subscriptions_description": "Klicke den „{{linktext}}“-Link um ein Thema zu erstellen oder zu abonnieren. Danach kannst Du Nachrichten per PUT oder POST senden und erhältst hier die Benachrichtigungen.",
|
||||
"notifications_example": "Beispiel",
|
||||
"publish_dialog_progress_uploading": "Wird hochgeladen …",
|
||||
"publish_dialog_title_topic": "Senden an {{topic}}",
|
||||
"publish_dialog_title_no_topic": "Benachrichtigung senden",
|
||||
"publish_dialog_message_published": "Benachrichtigung gesendet",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "überschreitet das Dateigrößen-Limit {{fileSizeLimit}} und die Quota, {{remainingBytes}} übrig",
|
||||
"publish_dialog_progress_uploading_detail": "Hochladen {{loaded}}/{{total}} ({{percent}} %) …",
|
||||
"publish_dialog_priority_max": "Max. Priorität",
|
||||
"publish_dialog_topic_placeholder": "Thema, z.B. phil_alerts",
|
||||
"publish_dialog_attachment_limits_file_reached": "überschreitet das Dateigrößen-Limit {{filesizeLimit}}",
|
||||
"publish_dialog_topic_label": "Thema",
|
||||
"publish_dialog_priority_default": "Standard-Priorität",
|
||||
"publish_dialog_base_url_placeholder": "Service-URL, z.B. https://example.com",
|
||||
"publish_dialog_attachment_limits_quota_reached": "überschreitet die Quota, {{remainingBytes}} übrig",
|
||||
"publish_dialog_priority_min": "Min. Priorität",
|
||||
"publish_dialog_priority_high": "Hohe Priorität",
|
||||
"publish_dialog_title_label": "Titel",
|
||||
"publish_dialog_tags_placeholder": "Komma-getrennte Liste von Tags, z.B. Warnung, srv1-Backup",
|
||||
"publish_dialog_priority_label": "Priorität",
|
||||
"publish_dialog_filename_label": "Dateiname",
|
||||
"publish_dialog_title_placeholder": "Benachrichtigungs-Titel, z.B. CPU-Last-Warnung",
|
||||
"publish_dialog_tags_label": "Tags",
|
||||
"publish_dialog_click_label": "Klick-URL",
|
||||
"publish_dialog_click_placeholder": "URL die geöffnet werden soll, wenn die Benachrichtigung angeklickt wird",
|
||||
"publish_dialog_email_label": "E-Mail",
|
||||
"publish_dialog_attach_label": "URL des Anhangs",
|
||||
"publish_dialog_attach_placeholder": "Datei von URL anhängen, z.B. https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_filename_placeholder": "Dateiname des Anhangs",
|
||||
"publish_dialog_delay_label": "Verzögerung",
|
||||
"publish_dialog_email_placeholder": "Adresse, an die die Benachrichtigung gesendet werden soll, z.B. phil@beispiel.com",
|
||||
"publish_dialog_chip_click_label": "Klick-URL",
|
||||
"publish_dialog_button_cancel_sending": "Senden abbrechen",
|
||||
"publish_dialog_drop_file_here": "Datei hierher ziehen",
|
||||
"publish_dialog_chip_email_label": "An E-Mail weiterleiten",
|
||||
"publish_dialog_button_cancel": "Abbrechen",
|
||||
"publish_dialog_chip_attach_file_label": "Lokale Datei anhängen",
|
||||
"prefs_notifications_min_priority_title": "Minimale Priorität",
|
||||
"prefs_users_add_button": "Benutzer hinzufügen",
|
||||
"publish_dialog_delay_placeholder": "Auslieferung verzögern, z.B. {{unixTimestamp}}, {{relativeTime}}, oder \"{{naturalLanguage}}\" (nur Englisch)",
|
||||
"prefs_appearance_title": "Darstellung",
|
||||
"subscribe_dialog_login_password_label": "Kennwort",
|
||||
"subscribe_dialog_login_button_back": "Zurück",
|
||||
"publish_dialog_chip_attach_url_label": "Datei von URL anhängen",
|
||||
"publish_dialog_chip_delay_label": "Auslieferung verzögern",
|
||||
"publish_dialog_chip_topic_label": "Thema ändern",
|
||||
"subscribe_dialog_subscribe_title": "Thema abonnieren",
|
||||
"subscribe_dialog_login_username_label": "Benutzername, z.B. phil",
|
||||
"subscribe_dialog_login_button_login": "Anmelden",
|
||||
"prefs_notifications_sound_no_sound": "Kein Ton",
|
||||
"prefs_notifications_min_priority_default_and_higher": "Standard-Priorität und höher",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "Thema, z.B. phil_alerts",
|
||||
"publish_dialog_button_send": "Senden",
|
||||
"publish_dialog_checkbox_publish_another": "Weitere Nachricht senden",
|
||||
"publish_dialog_attached_file_title": "Dateianhang:",
|
||||
"emoji_picker_search_placeholder": "Emoji suchen",
|
||||
"subscribe_dialog_subscribe_description": "Themen sind evtl. nicht kennwort-geschützt, also wähle einen schwer zu erratenden Namen. Nach dem Abonnieren kannst Du Benachrichtigungen per POST/PUT senden.",
|
||||
"subscribe_dialog_subscribe_use_another_label": "Anderen Server verwenden",
|
||||
"subscribe_dialog_subscribe_button_cancel": "Abbrechen",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "Abonnieren",
|
||||
"subscribe_dialog_login_title": "Anmeldung erforderlich",
|
||||
"subscribe_dialog_error_user_anonymous": "anonym",
|
||||
"subscribe_dialog_error_user_not_authorized": "Benutzer {{username}} hat keine Berechtigung",
|
||||
"prefs_notifications_min_priority_any": "Alle Prioritäten",
|
||||
"prefs_notifications_min_priority_low_and_higher": "Niedrige Priorität und höher",
|
||||
"prefs_notifications_min_priority_high_and_higher": "Hohe Priorität und höher",
|
||||
"prefs_notifications_delete_after_title": "Benachrichtigungen löschen",
|
||||
"prefs_notifications_delete_after_three_hours": "Nach drei Stunden",
|
||||
"prefs_users_dialog_title_edit": "Benutzer bearbeiten",
|
||||
"prefs_notifications_delete_after_one_day": "Nach einem Tag",
|
||||
"prefs_notifications_delete_after_one_week": "Nach einer Woche",
|
||||
"prefs_notifications_delete_after_one_month": "Nach einem Monat",
|
||||
"prefs_users_title": "Benutzer verwalten",
|
||||
"prefs_users_table_user_header": "Benutzer",
|
||||
"prefs_users_table_base_url_header": "Service-URL",
|
||||
"prefs_users_dialog_base_url_label": "Service-URL, z.B. https://ntfy.sh",
|
||||
"prefs_users_dialog_username_label": "Benutzername, z.B. phil",
|
||||
"prefs_users_description": "Benutzer für kennwort-geschützte Themen hinzufügen/löschen. Achtung: Benutzername und Kennwort werden im lokalen Browser-Speicher abgelegt.",
|
||||
"prefs_users_dialog_title_add": "Benutzer hinzufügen",
|
||||
"error_boundary_title": "Oh nein, ntfy ist abgestürzt",
|
||||
"error_boundary_description": "Das sollte offensichtlich nicht passieren. Sorry.<br/>Wenn möglich, <githubLink>melde den Fehler auf GitHub</githubLink> oder schreibe uns auf <discordLink>Discord</discordLink> oder <matrixLink>Matrix</matrixLink>.",
|
||||
"error_boundary_stack_trace": "Stacktrace",
|
||||
"error_boundary_gathering_info": "Weitere Informationen sammeln …",
|
||||
"error_boundary_button_copy_stack_trace": "Stacktrace kopieren",
|
||||
"prefs_notifications_delete_after_never_description": "Benachrichtigungen werden nie automatisch gelöscht",
|
||||
"prefs_notifications_delete_after_one_month_description": "Benachrichtigungen werden nach einem Monat automatisch gelöscht",
|
||||
"prefs_notifications_min_priority_description_any": "Alle Benachrichtigungen (aller Prioritäten) anzeigen",
|
||||
"prefs_notifications_min_priority_description_max": "Zeige Benachrichtigungen wenn ihre Priorität5 (max) ist",
|
||||
"priority_low": "niedrig",
|
||||
"priority_default": "Standard",
|
||||
"priority_high": "hoch",
|
||||
"priority_max": "max",
|
||||
"prefs_notifications_sound_description_none": "Kein Ton beim Empfang einer Benachrichtigung",
|
||||
"prefs_notifications_sound_description_some": "Sound {{sound}} beim Eintreffen einer Benachrichtigung abspielen",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Zeige Benachrichtigungen wenn ihre Priorität {{number}} ({{name}}) oder höher ist",
|
||||
"prefs_notifications_delete_after_three_hours_description": "Benachrichtigungen werden nach drei Stunden automatisch gelöscht",
|
||||
"prefs_notifications_delete_after_one_day_description": "Benachrichtigungen werden nach einem Tag automatisch gelöscht",
|
||||
"prefs_notifications_delete_after_one_week_description": "Benachrichtigungen werden nach einer Woche automatisch gelöscht",
|
||||
"priority_min": "min",
|
||||
"notifications_actions_not_supported": "Diese Aktion wird in der Web-App nicht unterstützt",
|
||||
"notifications_actions_http_request_title": "Sende HTTP {{method}} an {{url}}",
|
||||
"action_bar_show_menu": "Menü anzeigen",
|
||||
"action_bar_toggle_mute": "Stummschaltung der Benachrichtigungen an/aus",
|
||||
"message_bar_show_dialog": "Dialog zur Veröffentlichung anzeigen",
|
||||
"message_bar_publish": "Benachrichtigung veröffentlichen",
|
||||
"nav_button_connecting": "verbinde",
|
||||
"notifications_list": "Benachrichtigungsliste",
|
||||
"notifications_mark_read": "Als gelesen markieren",
|
||||
"notifications_delete": "Löschen",
|
||||
"notifications_priority_x": "Priorität {{priority}}",
|
||||
"notifications_attachment_file_image": "Bilddatei",
|
||||
"notifications_attachment_image": "Bild des Anhangs",
|
||||
"notifications_attachment_file_video": "Videodatei",
|
||||
"notifications_attachment_file_audio": "Audiodatei",
|
||||
"notifications_attachment_file_app": "Android App-Datei",
|
||||
"notifications_attachment_file_document": "anderes Dokument",
|
||||
"publish_dialog_attached_file_remove": "Angehängte Datei entfernen",
|
||||
"emoji_picker_search_clear": "Suche leeren",
|
||||
"subscribe_dialog_subscribe_base_url_label": "Service URL",
|
||||
"prefs_notifications_sound_play": "Gewählten Sound abspielen",
|
||||
"prefs_users_table": "Benutzertabelle",
|
||||
"prefs_users_edit_button": "Benutzer bearbeiten",
|
||||
"prefs_users_delete_button": "Benutzer löschen",
|
||||
"error_boundary_unsupported_indexeddb_title": "Private Browser-Tabs werden nicht unterstützt",
|
||||
"publish_dialog_delay_reset": "Verzögerte Zustellung entfernen",
|
||||
"error_boundary_unsupported_indexeddb_description": "Die ntfy Web-App benötigt eine IndexedDB für eine korrekte Funktion, und Dein Browser unterstützt in privaten Tabs keinen IndexedDB.<br/><br/>Das ist zwar ärgerlich, eine Nutzung von ntfy in einem privaten Tab macht aber auch wenig Sinn da alle Daten im Browser gespeichert werden. Weitere Informationen gibt es <githubLink>in diesem GitHub-Issue</githubLink>, oder im Chat bei <discordLink>Discord</discordLink> oder <matrixLink>Matrix</matrixLink>.",
|
||||
"action_bar_toggle_action_menu": "Aktionsmenü öffnen/schließen",
|
||||
"notifications_new_indicator": "Neue Benachrichtigung",
|
||||
"publish_dialog_email_reset": "Email-Weiterleitung entfernen",
|
||||
"action_bar_logo_alt": "ntfy Logo",
|
||||
"nav_button_muted": "Benachrichtigungen stummgeschaltet",
|
||||
"notifications_list_item": "Benachrichtigung",
|
||||
"publish_dialog_emoji_picker_show": "Emoji wählen",
|
||||
"publish_dialog_topic_reset": "Thema zurücksetzen",
|
||||
"publish_dialog_attach_reset": "angehängte URL entfernen",
|
||||
"publish_dialog_click_reset": "Klick-URL entfernen"
|
||||
}
|
||||
191
web/public/static/langs/en.json
Normal file
@@ -0,0 +1,191 @@
|
||||
{
|
||||
"action_bar_show_menu": "Show menu",
|
||||
"action_bar_logo_alt": "ntfy logo",
|
||||
"action_bar_settings": "Settings",
|
||||
"action_bar_send_test_notification": "Send test notification",
|
||||
"action_bar_clear_notifications": "Clear all notifications",
|
||||
"action_bar_unsubscribe": "Unsubscribe",
|
||||
"action_bar_toggle_mute": "Mute/unmute notifications",
|
||||
"action_bar_toggle_action_menu": "Open/close action menu",
|
||||
"message_bar_type_message": "Type a message here",
|
||||
"message_bar_error_publishing": "Error publishing notification",
|
||||
"message_bar_show_dialog": "Show publish dialog",
|
||||
"message_bar_publish": "Publish message",
|
||||
"nav_topics_title": "Subscribed topics",
|
||||
"nav_button_all_notifications": "All notifications",
|
||||
"nav_button_settings": "Settings",
|
||||
"nav_button_documentation": "Documentation",
|
||||
"nav_button_publish_message": "Publish notification",
|
||||
"nav_button_subscribe": "Subscribe to topic",
|
||||
"nav_button_muted": "Notifications muted",
|
||||
"nav_button_connecting": "connecting",
|
||||
"alert_grant_title": "Notifications are disabled",
|
||||
"alert_grant_description": "Grant your browser permission to display desktop notifications.",
|
||||
"alert_grant_button": "Grant now",
|
||||
"alert_not_supported_title": "Notifications not supported",
|
||||
"alert_not_supported_description": "Notifications are not supported in your browser.",
|
||||
"notifications_list": "Notifications list",
|
||||
"notifications_list_item": "Notification",
|
||||
"notifications_mark_read": "Mark as read",
|
||||
"notifications_delete": "Delete",
|
||||
"notifications_copied_to_clipboard": "Copied to clipboard",
|
||||
"notifications_tags": "Tags",
|
||||
"notifications_priority_x": "Priority {{priority}}",
|
||||
"notifications_new_indicator": "New notification",
|
||||
"notifications_attachment_image": "Attachment image",
|
||||
"notifications_attachment_copy_url_title": "Copy attachment URL to clipboard",
|
||||
"notifications_attachment_copy_url_button": "Copy URL",
|
||||
"notifications_attachment_open_title": "Go to {{url}}",
|
||||
"notifications_attachment_open_button": "Open attachment",
|
||||
"notifications_attachment_link_expires": "link expires {{date}}",
|
||||
"notifications_attachment_link_expired": "download link expired",
|
||||
"notifications_attachment_file_image": "image file",
|
||||
"notifications_attachment_file_video": "video file",
|
||||
"notifications_attachment_file_audio": "audio file",
|
||||
"notifications_attachment_file_app": "Android app file",
|
||||
"notifications_attachment_file_document": "other document",
|
||||
"notifications_click_copy_url_title": "Copy link URL to clipboard",
|
||||
"notifications_click_copy_url_button": "Copy link",
|
||||
"notifications_click_open_button": "Open link",
|
||||
"notifications_actions_open_url_title": "Go to {{url}}",
|
||||
"notifications_actions_not_supported": "Action not supported in web app",
|
||||
"notifications_actions_http_request_title": "Send HTTP {{method}} to {{url}}",
|
||||
"notifications_none_for_topic_title": "You haven't received any notifications for this topic yet.",
|
||||
"notifications_none_for_topic_description": "To send notifications to this topic, simply PUT or POST to the topic URL.",
|
||||
"notifications_none_for_any_title": "You haven't received any notifications.",
|
||||
"notifications_none_for_any_description": "To send notifications to a topic, simply PUT or POST to the topic URL. Here's an example using one of your topics.",
|
||||
"notifications_no_subscriptions_title": "It looks like you don't have any subscriptions yet.",
|
||||
"notifications_no_subscriptions_description": "Click the \"{{linktext}}\" link to create or subscribe to a topic. After that, you can send messages via PUT or POST and you'll receive notifications here.",
|
||||
"notifications_example": "Example",
|
||||
"notifications_more_details": "For more information, check out the <websiteLink>website</websiteLink> or <docsLink>documentation</docsLink>.",
|
||||
"notifications_loading": "Loading notifications …",
|
||||
"publish_dialog_title_topic": "Publish to {{topic}}",
|
||||
"publish_dialog_title_no_topic": "Publish notification",
|
||||
"publish_dialog_progress_uploading": "Uploading …",
|
||||
"publish_dialog_progress_uploading_detail": "Uploading {{loaded}}/{{total}} ({{percent}}%) …",
|
||||
"publish_dialog_message_published": "Notification published",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "exceeds {{fileSizeLimit}} file limit and quota, {{remainingBytes}} remaining",
|
||||
"publish_dialog_attachment_limits_file_reached": "exceeds {{fileSizeLimit}} file limit",
|
||||
"publish_dialog_attachment_limits_quota_reached": "exceeds quota, {{remainingBytes}} remaining",
|
||||
"publish_dialog_emoji_picker_show": "Pick emoji",
|
||||
"publish_dialog_priority_min": "Min. priority",
|
||||
"publish_dialog_priority_low": "Low priority",
|
||||
"publish_dialog_priority_default": "Default priority",
|
||||
"publish_dialog_priority_high": "High priority",
|
||||
"publish_dialog_priority_max": "Max. priority",
|
||||
"publish_dialog_base_url_label": "Service URL",
|
||||
"publish_dialog_base_url_placeholder": "Service URL, e.g. https://example.com",
|
||||
"publish_dialog_topic_label": "Topic name",
|
||||
"publish_dialog_topic_placeholder": "Topic name, e.g. phil_alerts",
|
||||
"publish_dialog_topic_reset": "Reset topic",
|
||||
"publish_dialog_title_label": "Title",
|
||||
"publish_dialog_title_placeholder": "Notification title, e.g. Disk space alert",
|
||||
"publish_dialog_message_label": "Message",
|
||||
"publish_dialog_message_placeholder": "Type a message here",
|
||||
"publish_dialog_tags_label": "Tags",
|
||||
"publish_dialog_tags_placeholder": "Comma-separated list of tags, e.g. warning, srv1-backup",
|
||||
"publish_dialog_priority_label": "Priority",
|
||||
"publish_dialog_click_label": "Click URL",
|
||||
"publish_dialog_click_placeholder": "URL that is opened when notification is clicked",
|
||||
"publish_dialog_click_reset": "Remove click URL",
|
||||
"publish_dialog_email_label": "Email",
|
||||
"publish_dialog_email_placeholder": "Address to forward the notification to, e.g. phil@example.com",
|
||||
"publish_dialog_email_reset": "Remove email forward",
|
||||
"publish_dialog_attach_label": "Attachment URL",
|
||||
"publish_dialog_attach_placeholder": "Attach file by URL, e.g. https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_attach_reset": "Remove attachment URL",
|
||||
"publish_dialog_filename_label": "Filename",
|
||||
"publish_dialog_filename_placeholder": "Attachment filename",
|
||||
"publish_dialog_delay_label": "Delay",
|
||||
"publish_dialog_delay_placeholder": "Delay delivery, e.g. {{unixTimestamp}}, {{relativeTime}}, or \"{{naturalLanguage}}\" (English only)",
|
||||
"publish_dialog_delay_reset": "Remove delayed delivery",
|
||||
"publish_dialog_other_features": "Other features:",
|
||||
"publish_dialog_chip_click_label": "Click URL",
|
||||
"publish_dialog_chip_email_label": "Forward to email",
|
||||
"publish_dialog_chip_attach_url_label": "Attach file by URL",
|
||||
"publish_dialog_chip_attach_file_label": "Attach local file",
|
||||
"publish_dialog_chip_delay_label": "Delay delivery",
|
||||
"publish_dialog_chip_topic_label": "Change topic",
|
||||
"publish_dialog_details_examples_description": "For examples and a detailed description of all send features, please refer to the <docsLink>documentation</docsLink>.",
|
||||
"publish_dialog_button_cancel_sending": "Cancel sending",
|
||||
"publish_dialog_button_cancel": "Cancel",
|
||||
"publish_dialog_button_send": "Send",
|
||||
"publish_dialog_checkbox_publish_another": "Publish another",
|
||||
"publish_dialog_attached_file_title": "Attached file:",
|
||||
"publish_dialog_attached_file_filename_placeholder": "Attachment filename",
|
||||
"publish_dialog_attached_file_remove": "Remove attached file",
|
||||
"publish_dialog_drop_file_here": "Drop file here",
|
||||
"emoji_picker_search_placeholder": "Search emoji",
|
||||
"emoji_picker_search_clear": "Clear search",
|
||||
"subscribe_dialog_subscribe_title": "Subscribe to topic",
|
||||
"subscribe_dialog_subscribe_description": "Topics may not be password-protected, so choose a name that's not easy to guess. Once subscribed, you can PUT/POST notifications.",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "Topic name, e.g. phil_alerts",
|
||||
"subscribe_dialog_subscribe_use_another_label": "Use another server",
|
||||
"subscribe_dialog_subscribe_base_url_label": "Service URL",
|
||||
"subscribe_dialog_subscribe_button_cancel": "Cancel",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "Subscribe",
|
||||
"subscribe_dialog_login_title": "Login required",
|
||||
"subscribe_dialog_login_description": "This topic is password-protected. Please enter username and password to subscribe.",
|
||||
"subscribe_dialog_login_username_label": "Username, e.g. phil",
|
||||
"subscribe_dialog_login_password_label": "Password",
|
||||
"subscribe_dialog_login_button_back": "Back",
|
||||
"subscribe_dialog_login_button_login": "Login",
|
||||
"subscribe_dialog_error_user_not_authorized": "User {{username}} not authorized",
|
||||
"subscribe_dialog_error_user_anonymous": "anonymous",
|
||||
"prefs_notifications_title": "Notifications",
|
||||
"prefs_notifications_sound_title": "Notification sound",
|
||||
"prefs_notifications_sound_description_none": "Notifications do not play any sound when they arrive",
|
||||
"prefs_notifications_sound_description_some": "Notifications play the {{sound}} sound when they arrive",
|
||||
"prefs_notifications_sound_no_sound": "No sound",
|
||||
"prefs_notifications_sound_play": "Play selected sound",
|
||||
"prefs_notifications_min_priority_title": "Minimum priority",
|
||||
"prefs_notifications_min_priority_description_any": "Showing all notifications, regardless of priority",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Show notifications if priority is {{number}} ({{name}}) or above",
|
||||
"prefs_notifications_min_priority_description_max": "Show notifications if priority is 5 (max)",
|
||||
"prefs_notifications_min_priority_any": "Any priority",
|
||||
"prefs_notifications_min_priority_low_and_higher": "Low priority and higher",
|
||||
"prefs_notifications_min_priority_default_and_higher": "Default priority and higher",
|
||||
"prefs_notifications_min_priority_high_and_higher": "High priority and higher",
|
||||
"prefs_notifications_min_priority_max_only": "Only max priority",
|
||||
"prefs_notifications_delete_after_title": "Delete notifications",
|
||||
"prefs_notifications_delete_after_never": "Never",
|
||||
"prefs_notifications_delete_after_three_hours": "After three hours",
|
||||
"prefs_notifications_delete_after_one_day": "After one day",
|
||||
"prefs_notifications_delete_after_one_week": "After one week",
|
||||
"prefs_notifications_delete_after_one_month": "After one month",
|
||||
"prefs_notifications_delete_after_never_description": "Notifications are never auto-deleted",
|
||||
"prefs_notifications_delete_after_three_hours_description": "Notifications are auto-deleted after three hours",
|
||||
"prefs_notifications_delete_after_one_day_description": "Notifications are auto-deleted after one day",
|
||||
"prefs_notifications_delete_after_one_week_description": "Notifications are auto-deleted after one week",
|
||||
"prefs_notifications_delete_after_one_month_description": "Notifications are auto-deleted after one month",
|
||||
"prefs_users_title": "Manage users",
|
||||
"prefs_users_description": "Add/remove users for your protected topics here. Please note that username and password are stored in the browser's local storage.",
|
||||
"prefs_users_table": "Users table",
|
||||
"prefs_users_add_button": "Add user",
|
||||
"prefs_users_edit_button": "Edit user",
|
||||
"prefs_users_delete_button": "Delete user",
|
||||
"prefs_users_table_user_header": "User",
|
||||
"prefs_users_table_base_url_header": "Service URL",
|
||||
"prefs_users_dialog_title_add": "Add user",
|
||||
"prefs_users_dialog_title_edit": "Edit user",
|
||||
"prefs_users_dialog_base_url_label": "Service URL, e.g. https://ntfy.sh",
|
||||
"prefs_users_dialog_username_label": "Username, e.g. phil",
|
||||
"prefs_users_dialog_password_label": "Password",
|
||||
"prefs_users_dialog_button_cancel": "Cancel",
|
||||
"prefs_users_dialog_button_add": "Add",
|
||||
"prefs_users_dialog_button_save": "Save",
|
||||
"prefs_appearance_title": "Appearance",
|
||||
"prefs_appearance_language_title": "Language",
|
||||
"priority_min": "min",
|
||||
"priority_low": "low",
|
||||
"priority_default": "default",
|
||||
"priority_high": "high",
|
||||
"priority_max": "max",
|
||||
"error_boundary_title": "Oh no, ntfy crashed",
|
||||
"error_boundary_description": "This should obviously not happen. Very sorry about this.<br/>If you have a minute, please <githubLink>report this on GitHub</githubLink>, or let us know via <discordLink>Discord</discordLink> or <matrixLink>Matrix</matrixLink>.",
|
||||
"error_boundary_button_copy_stack_trace": "Copy stack trace",
|
||||
"error_boundary_stack_trace": "Stack trace",
|
||||
"error_boundary_gathering_info": "Gather more info …",
|
||||
"error_boundary_unsupported_indexeddb_title": "Private browsing not supported",
|
||||
"error_boundary_unsupported_indexeddb_description": "The ntfy web app needs IndexedDB to function, and your browser does not support IndexedDB in private browsing mode.<br/><br/>While this is unfortunate, it also doesn't really make a lot of sense to use the ntfy web app in private browsing mode anyway, because everything is stored in the browser storage. You can read more about it <githubLink>in this GitHub issue</githubLink>, or talk to us on <discordLink>Discord</discordLink> or <matrixLink>Matrix</matrixLink>."
|
||||
}
|
||||
191
web/public/static/langs/es.json
Normal file
@@ -0,0 +1,191 @@
|
||||
{
|
||||
"action_bar_settings": "Configuración",
|
||||
"action_bar_send_test_notification": "Enviar notificación de prueba",
|
||||
"action_bar_clear_notifications": "Borrar todas las notificaciones",
|
||||
"nav_topics_title": "Tópicos suscritos",
|
||||
"alert_grant_button": "Conceder ahora",
|
||||
"action_bar_unsubscribe": "Cancelar la suscripción",
|
||||
"message_bar_type_message": "Escriba un mensaje aquí",
|
||||
"message_bar_error_publishing": "Error al publicar la notificación",
|
||||
"alert_grant_title": "Las notificaciones están deshabilitadas",
|
||||
"alert_grant_description": "Concede a tu navegador permiso para mostrar notificaciones en el escritorio.",
|
||||
"nav_button_all_notifications": "Todas las notificaciones",
|
||||
"nav_button_settings": "Ajustes",
|
||||
"nav_button_subscribe": "Suscribirse al tópico",
|
||||
"nav_button_documentation": "Documentación",
|
||||
"nav_button_publish_message": "Publicar notificación",
|
||||
"notifications_copied_to_clipboard": "Copiado al portapapeles",
|
||||
"alert_not_supported_title": "Notificaciones no soportadas",
|
||||
"alert_not_supported_description": "Las notificaciones no están soportadas por tu navegador.",
|
||||
"notifications_tags": "Etiquetas",
|
||||
"notifications_attachment_copy_url_title": "Copiar la URL del archivo adjunto en el portapapeles",
|
||||
"notifications_attachment_copy_url_button": "Copiar URL",
|
||||
"notifications_attachment_open_title": "Ir a {{url}}",
|
||||
"notifications_attachment_open_button": "Abrir archivo adjunto",
|
||||
"notifications_attachment_link_expires": "el enlace expira el día {{fecha}}",
|
||||
"notifications_attachment_link_expired": "el enlace de descarga ha expirado",
|
||||
"notifications_click_copy_url_title": "Copiar la URL del enlace en el portapapeles",
|
||||
"notifications_click_copy_url_button": "Copiar enlace",
|
||||
"notifications_actions_open_url_title": "Ir a {{url}}",
|
||||
"notifications_click_open_button": "Abrir enlace",
|
||||
"notifications_none_for_topic_title": "Aún no has recibido ninguna notificación en este tópico.",
|
||||
"notifications_none_for_topic_description": "Para enviar notificaciones a este tópico, simplemente realice un PUT o POST a la URL del tópico.",
|
||||
"notifications_none_for_any_title": "No ha recibido ninguna notificación.",
|
||||
"notifications_no_subscriptions_title": "Parece que aún no tiene ninguna suscripción.",
|
||||
"notifications_no_subscriptions_description": "Haga clic en el enlace \"{{linktext}}\" para crear o suscribirse a un tópico. Después, puede enviar mensajes a través de un PUT o POST y recibirá notificaciones aquí.",
|
||||
"notifications_more_details": "Para más información, consulta la <websiteLink>página web</websiteLink> o la <docsLink>documentación</docsLink>.",
|
||||
"notifications_loading": "Cargando notificaciones …",
|
||||
"publish_dialog_title_topic": "Publicar en {{topic}}",
|
||||
"publish_dialog_title_no_topic": "Publicar notificación",
|
||||
"publish_dialog_progress_uploading": "Cargando …",
|
||||
"publish_dialog_progress_uploading_detail": "Cargando {{loaded}}/{{total}} ({{percent}}%) …",
|
||||
"publish_dialog_message_published": "Notificación publicada",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "supera el límite y la cuota de archivos de {{fileSizeLimit}}, restan {{remainingBytes}}",
|
||||
"publish_dialog_attachment_limits_file_reached": "supera el límite de archivos de {{fileSizeLimit}}",
|
||||
"publish_dialog_attachment_limits_quota_reached": "supera la cuota, restan {{remainingBytes}}",
|
||||
"publish_dialog_priority_min": "Prioridad mínima",
|
||||
"publish_dialog_priority_default": "Prioridad predeterminada",
|
||||
"publish_dialog_priority_max": "Prioridad máxima",
|
||||
"publish_dialog_base_url_label": "URL del servicio",
|
||||
"publish_dialog_base_url_placeholder": "URL del servicio, por ejemplo, https://example.com",
|
||||
"publish_dialog_topic_label": "Nombre del tópico",
|
||||
"publish_dialog_topic_placeholder": "Nombre del tópico, ej. phil_alerts",
|
||||
"publish_dialog_title_label": "Título",
|
||||
"publish_dialog_message_label": "Mensaje",
|
||||
"publish_dialog_tags_placeholder": "Lista de etiquetas separadas por comas, por ejemplo: warning, srv1-backup",
|
||||
"publish_dialog_click_label": "Click URL",
|
||||
"publish_dialog_click_placeholder": "URL que se abre cuando se hace click en la notificación",
|
||||
"publish_dialog_email_label": "Email",
|
||||
"publish_dialog_email_placeholder": "Dirección a la que se reenviará la notificación, por ejemplo, phil@example.com",
|
||||
"publish_dialog_attach_label": "URL del archivo adjunto",
|
||||
"publish_dialog_filename_label": "Nombre del archivo",
|
||||
"publish_dialog_delay_placeholder": "Retraso en la entrega, por ejemplo, {{unixTimestamp}}, {{relativeTime}}, o \"{{naturalLanguage}}\" (sólo en inglés)",
|
||||
"publish_dialog_other_features": "Otras características:",
|
||||
"publish_dialog_chip_click_label": "Click URL",
|
||||
"publish_dialog_chip_email_label": "Reenviar al email",
|
||||
"publish_dialog_chip_attach_url_label": "Adjuntar un archivo por URL",
|
||||
"publish_dialog_chip_attach_file_label": "Adjuntar archivo local",
|
||||
"publish_dialog_chip_topic_label": "Cambiar de tópico",
|
||||
"publish_dialog_button_cancel_sending": "Cancelar el envío",
|
||||
"publish_dialog_button_cancel": "Cancelar",
|
||||
"publish_dialog_checkbox_publish_another": "Publicar otro",
|
||||
"publish_dialog_attached_file_title": "Archivo adjunto:",
|
||||
"publish_dialog_attached_file_filename_placeholder": "Nombre del archivo adjunto",
|
||||
"publish_dialog_drop_file_here": "Suelta el archivo aquí",
|
||||
"emoji_picker_search_placeholder": "Buscar emojis",
|
||||
"subscribe_dialog_subscribe_title": "Suscribirse al tópico",
|
||||
"subscribe_dialog_subscribe_description": "Los tópicos pueden no estar protegidos por contraseña, así que elija un nombre que no sea fácil de adivinar. Una vez suscrito, puede hacer PUT/PIST de notificaciones.",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "Nombre del tópico, ej. phil_alerts",
|
||||
"subscribe_dialog_subscribe_use_another_label": "Usar otro servidor",
|
||||
"subscribe_dialog_login_title": "Es necesario iniciar sesión",
|
||||
"subscribe_dialog_login_description": "Este tópico está protegido por contraseña. Por favor, introduzca su nombre de usuario y contraseña para suscribirse.",
|
||||
"subscribe_dialog_login_username_label": "Nombre de usuario, ej. phil",
|
||||
"subscribe_dialog_login_password_label": "Contraseña",
|
||||
"subscribe_dialog_login_button_back": "Volver",
|
||||
"subscribe_dialog_login_button_login": "Iniciar sesión",
|
||||
"subscribe_dialog_error_user_not_authorized": "Usuario {{username}} no autorizado",
|
||||
"subscribe_dialog_error_user_anonymous": "anónimo",
|
||||
"prefs_notifications_title": "Notificaciones",
|
||||
"prefs_notifications_sound_title": "Sonido de notificación",
|
||||
"prefs_notifications_min_priority_any": "Cualquier prioridad",
|
||||
"prefs_notifications_min_priority_low_and_higher": "Prioridad baja y superior",
|
||||
"prefs_notifications_min_priority_max_only": "Solo prioridad máxima",
|
||||
"prefs_notifications_delete_after_title": "Eliminar notificaciones",
|
||||
"prefs_notifications_delete_after_never": "Nunca",
|
||||
"prefs_notifications_delete_after_three_hours": "Después de tres horas",
|
||||
"prefs_notifications_delete_after_one_day": "Después de un día",
|
||||
"prefs_notifications_delete_after_one_week": "Después de una semana",
|
||||
"prefs_notifications_delete_after_one_month": "Después de un mes",
|
||||
"prefs_users_title": "Administrar usuarios",
|
||||
"prefs_users_description": "Añada/elimine usuarios para sus tópicos protegidos aquí. Tenga en cuenta que el nombre de usuario y la contraseña se guardan en el almacenamiento local del navegador.",
|
||||
"prefs_users_add_button": "Añadir usuario",
|
||||
"prefs_users_dialog_title_edit": "Editar usuario",
|
||||
"prefs_users_dialog_base_url_label": "URL del servicio, ej. https://ntfy.sh",
|
||||
"prefs_users_dialog_button_add": "Añadir",
|
||||
"prefs_users_dialog_button_save": "Guardar",
|
||||
"prefs_appearance_title": "Apariencia",
|
||||
"prefs_appearance_language_title": "Idioma",
|
||||
"error_boundary_title": "Oh no, ntfy tuvo un error",
|
||||
"error_boundary_button_copy_stack_trace": "Copiar el stack trace",
|
||||
"error_boundary_stack_trace": "Stack trace",
|
||||
"error_boundary_gathering_info": "Reunir más información …",
|
||||
"notifications_example": "Ejemplo",
|
||||
"prefs_notifications_min_priority_title": "Prioridad mínima",
|
||||
"notifications_none_for_any_description": "Para enviar notificaciones a un tópico, simplemente realice un PUT o POST a la URL del tópico. Aquí hay un ejemplo usando uno de sus tópicos.",
|
||||
"subscribe_dialog_subscribe_button_cancel": "Cancelar",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "Suscribir",
|
||||
"publish_dialog_message_placeholder": "Escriba un mensaje aquí",
|
||||
"publish_dialog_tags_label": "Etiquetas",
|
||||
"publish_dialog_priority_label": "Prioridad",
|
||||
"publish_dialog_priority_low": "Prioridad baja",
|
||||
"publish_dialog_priority_high": "Prioridad alta",
|
||||
"publish_dialog_delay_label": "Retraso",
|
||||
"publish_dialog_title_placeholder": "Título de la notificación, por ejemplo, Alerta de espacio en disco",
|
||||
"publish_dialog_details_examples_description": "Para ver ejemplos y una descripción detallada de todas las funciones de envío, consulte la <docsLink>documentación</docsLink>.",
|
||||
"publish_dialog_attach_placeholder": "Adjuntar un archivo por URL, por ejemplo, https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_filename_placeholder": "Nombre del archivo adjunto",
|
||||
"publish_dialog_chip_delay_label": "Retraso en la entrega",
|
||||
"prefs_notifications_min_priority_default_and_higher": "Prioridad predeterminada y superior",
|
||||
"prefs_notifications_min_priority_high_and_higher": "Prioridad alta y superior",
|
||||
"prefs_users_table_user_header": "Usuario",
|
||||
"prefs_users_table_base_url_header": "URL del servicio",
|
||||
"publish_dialog_button_send": "Enviar",
|
||||
"prefs_notifications_sound_no_sound": "Sin sonido",
|
||||
"prefs_users_dialog_password_label": "Contraseña",
|
||||
"error_boundary_description": "Obviamente, esto no debería ocurrir. Lo sentimos mucho.<br/>Si tienes un minuto, por favor <githubLink>informa de esto en GitHub</githubLink>, o avísanos vía <discordLink>Discord</discordLink> o <matrixLink>Matrix</matrixLink>.",
|
||||
"prefs_users_dialog_title_add": "Añadir usuario",
|
||||
"prefs_users_dialog_button_cancel": "Cancelar",
|
||||
"prefs_users_dialog_username_label": "Nombre de usuario, ej. phil",
|
||||
"priority_max": "máx",
|
||||
"priority_high": "alta",
|
||||
"prefs_notifications_delete_after_one_month_description": "Las notificaciones se eliminan automáticamente después de un mes",
|
||||
"priority_min": "mín",
|
||||
"prefs_notifications_delete_after_three_hours_description": "Las notificaciones se eliminan automáticamente después de tres horas",
|
||||
"prefs_notifications_sound_description_none": "Las notificaciones no reproducen ningún sonido cuando llegan",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Mostrar notificaciones si la prioridad es {{number}} ({{name}}) o superior",
|
||||
"prefs_notifications_min_priority_description_max": "Mostrar notificaciones si la prioridad es 5 (máxima)",
|
||||
"prefs_notifications_sound_description_some": "Las notificaciones reproducen el sonido {{sound}} cuando llegan",
|
||||
"prefs_notifications_min_priority_description_any": "Mostrando todas las notificaciones, independientemente de su prioridad",
|
||||
"prefs_notifications_delete_after_never_description": "Las notificaciones nunca se borran automáticamente",
|
||||
"priority_default": "predeterminada",
|
||||
"prefs_notifications_delete_after_one_day_description": "Las notificaciones se eliminan automáticamente después de un día",
|
||||
"prefs_notifications_delete_after_one_week_description": "Las notificaciones se eliminan automáticamente después de una semana",
|
||||
"priority_low": "baja",
|
||||
"notifications_actions_not_supported": "Acción no soportada en la aplicación web",
|
||||
"notifications_actions_http_request_title": "Enviar HTTP {{method}} a {{url}}",
|
||||
"error_boundary_unsupported_indexeddb_description": "La aplicación web ntfy necesita IndexedDB para funcionar y su navegador no soporta IndexedDB en modo de navegación privada. <br/> <br/> Si bien esto es desafortunado, tampoco tiene mucho sentido usar la aplicación web ntfy en modo de navegación privada de todos modos, porque todo está almacenado en el almacenamiento del navegador. Puede leer más sobre esto <githubLink>en este issue de GitHub</githubLink>, o hablar con nosotros en <discordLink>Discord</discordLink> o <matrixLink>Matrix</matrixLink>.",
|
||||
"action_bar_show_menu": "Mostrar menú",
|
||||
"action_bar_logo_alt": "logo de ntfy",
|
||||
"action_bar_toggle_action_menu": "Abrir/cerrar el menú de acción",
|
||||
"message_bar_show_dialog": "Mostrar diálogo de publicación",
|
||||
"message_bar_publish": "Publicar mensaje",
|
||||
"nav_button_muted": "Notificaciones silenciadas",
|
||||
"nav_button_connecting": "conectando",
|
||||
"notifications_list": "Lista de notificaciones",
|
||||
"notifications_list_item": "Notificación",
|
||||
"notifications_mark_read": "Marcar como leído",
|
||||
"notifications_delete": "Eliminar",
|
||||
"notifications_priority_x": "Prioridad {{priority}}",
|
||||
"notifications_new_indicator": "Nueva notificación",
|
||||
"notifications_attachment_image": "Imagen adjunta",
|
||||
"notifications_attachment_file_image": "archivo de imagen",
|
||||
"notifications_attachment_file_video": "archivo de video",
|
||||
"notifications_attachment_file_audio": "archivo de audio",
|
||||
"notifications_attachment_file_app": "Archivo de aplicación de Android",
|
||||
"notifications_attachment_file_document": "otro documento",
|
||||
"action_bar_toggle_mute": "Silenciar/reactivar notificaciones",
|
||||
"publish_dialog_emoji_picker_show": "Elige un emoji",
|
||||
"publish_dialog_topic_reset": "Restablecer tópico",
|
||||
"publish_dialog_click_reset": "Eliminar URL de clic",
|
||||
"publish_dialog_email_reset": "Eliminar el reenvío de correo electrónico",
|
||||
"publish_dialog_attach_reset": "Eliminar la URL del archivo adjunto",
|
||||
"publish_dialog_delay_reset": "Eliminar entrega retrasada",
|
||||
"publish_dialog_attached_file_remove": "Eliminar el archivo adjunto",
|
||||
"emoji_picker_search_clear": "Limpiar búsqueda",
|
||||
"subscribe_dialog_subscribe_base_url_label": "URL del servicio",
|
||||
"prefs_notifications_sound_play": "Reproducir el sonido seleccionado",
|
||||
"prefs_users_table": "Tabla de usuarios",
|
||||
"prefs_users_edit_button": "Editar usuario",
|
||||
"prefs_users_delete_button": "Eliminar usuario",
|
||||
"error_boundary_unsupported_indexeddb_title": "Navegación privada no soportada"
|
||||
}
|
||||
156
web/public/static/langs/fr.json
Normal file
@@ -0,0 +1,156 @@
|
||||
{
|
||||
"nav_topics_title": "Sujets souscrits",
|
||||
"action_bar_settings": "Paramètres",
|
||||
"action_bar_send_test_notification": "Envoyer une notification de test",
|
||||
"action_bar_clear_notifications": "Effacer toutes les notifications",
|
||||
"action_bar_unsubscribe": "Se désabonner",
|
||||
"message_bar_type_message": "Tapez un message ici",
|
||||
"notifications_attachment_open_button": "Ouvrir la pièce jointe",
|
||||
"notifications_attachment_link_expires": "le lien expire {{date}}",
|
||||
"message_bar_error_publishing": "Notification d'erreur de publication",
|
||||
"nav_button_all_notifications": "Toutes les notifications",
|
||||
"nav_button_settings": "Paramètres",
|
||||
"nav_button_documentation": "Documentation",
|
||||
"alert_not_supported_description": "Les notifications ne sont pas prises en charge par votre navigateur.",
|
||||
"notifications_attachment_copy_url_title": "Copier l'URL de la pièce jointe dans le presse-papiers",
|
||||
"notifications_attachment_open_title": "Aller à {{url}}",
|
||||
"notifications_attachment_link_expired": "lien de téléchargement expiré",
|
||||
"nav_button_publish_message": "Publier la notification",
|
||||
"notifications_copied_to_clipboard": "Copié dans le presse-papiers",
|
||||
"alert_not_supported_title": "Notifications non prises en charge",
|
||||
"notifications_tags": "Étiquettes",
|
||||
"notifications_attachment_copy_url_button": "Copier l'URL",
|
||||
"notifications_click_copy_url_title": "Copier l'URL du lien dans le presse-papiers",
|
||||
"notifications_click_copy_url_button": "Copier le lien",
|
||||
"notifications_click_open_button": "Ouvrir le lien",
|
||||
"notifications_none_for_topic_title": "Vous n'avez pas encore reçu de notifications pour ce sujet.",
|
||||
"notifications_actions_open_url_title": "Aller à {{url}}",
|
||||
"notifications_example": "Exemple",
|
||||
"notifications_loading": "Chargement des notifications…",
|
||||
"publish_dialog_progress_uploading": "Téléversement…",
|
||||
"publish_dialog_priority_min": "Priorité minimum",
|
||||
"publish_dialog_priority_low": "Basse priorité",
|
||||
"publish_dialog_priority_default": "Priorité par défaut",
|
||||
"publish_dialog_base_url_label": "URL du service",
|
||||
"publish_dialog_base_url_placeholder": "URL du service, par ex. https://exemple.com",
|
||||
"publish_dialog_title_label": "Titre",
|
||||
"publish_dialog_message_label": "Message",
|
||||
"publish_dialog_topic_label": "Nom du sujet",
|
||||
"publish_dialog_message_placeholder": "Tapez un message ici",
|
||||
"publish_dialog_tags_label": "Étiquettes",
|
||||
"publish_dialog_email_label": "Courriel",
|
||||
"publish_dialog_email_placeholder": "Adresse à laquelle transmettre la notification, par exemple phil@exemple.com",
|
||||
"publish_dialog_chip_email_label": "Transférer vers le courriel",
|
||||
"notifications_no_subscriptions_title": "Il semble que vous n’ayez pas encore d’abonnements.",
|
||||
"publish_dialog_progress_uploading_detail": "Téléversement {{loaded}}/{{total}} ({{percent}} %) …",
|
||||
"publish_dialog_message_published": "Notification publiée",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "dépasse la limite et le quota du fichier {{fileSizeLimit}}, {{remainingBytes}} restant",
|
||||
"publish_dialog_priority_high": "Haute priorité",
|
||||
"publish_dialog_priority_max": "Priorité maximum",
|
||||
"publish_dialog_attachment_limits_file_reached": "Dépasse la limite du fichier {{fileSizeLimit}}",
|
||||
"nav_button_subscribe": "S'abonner au sujet",
|
||||
"notifications_no_subscriptions_description": "Cliquez sur le lien « {{linktext}} » pour créer ou vous abonner à un sujet. Après cela, vous pouvez envoyer des messages via PUT ou POST et vous recevrez des notifications ici.",
|
||||
"alert_grant_title": "Les notifications sont désactivées",
|
||||
"alert_grant_description": "Autorisez votre navigateur à afficher les notifications du bureau.",
|
||||
"alert_grant_button": "Accorder maintenant",
|
||||
"notifications_none_for_any_title": "Vous n'avez reçu aucune notification.",
|
||||
"publish_dialog_title_topic": "Publier vers {{topic}}",
|
||||
"publish_dialog_title_no_topic": "Publier la notification",
|
||||
"notifications_more_details": "Pour plus d'information, visitez <websiteLink>le site web</websiteLink> ou <docsLink>la documentation</docsLink>.",
|
||||
"publish_dialog_title_placeholder": "Titre de la notification, par ex. Alerte d'espace disque",
|
||||
"publish_dialog_topic_placeholder": "Nom du sujet, par ex. phil_alerts",
|
||||
"publish_dialog_delay_placeholder": "Délai de la délivrance, par ex. {{unixTimestamp}}, {{relativeTime}}, ou « {{naturalLanguage}} » (en anglais seulement)",
|
||||
"publish_dialog_other_features": "Autres fonctionnalités :",
|
||||
"notifications_actions_not_supported": "Cette action n'est pas supportée dans l'application web",
|
||||
"notifications_actions_http_request_title": "Envoyer une requête HTTP {{method}} à {{url}}",
|
||||
"publish_dialog_attachment_limits_quota_reached": "quota dépassé, {{remainingBytes}} restants",
|
||||
"publish_dialog_tags_placeholder": "Liste séparée par des virgules d'étiquettes, par ex. avertissement,backup-srv1",
|
||||
"publish_dialog_priority_label": "Priorité",
|
||||
"publish_dialog_click_label": "Cliquer sur l'URL",
|
||||
"publish_dialog_click_placeholder": "URL ouverte quand la notification est cliquée",
|
||||
"publish_dialog_attach_label": "URL de la pièce jointe",
|
||||
"publish_dialog_attach_placeholder": "Attachez un fichier par une URL, par ex. https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_filename_label": "Nom du fichier",
|
||||
"notifications_none_for_topic_description": "Pour envoyer des notifications à ce sujet, faites simplement une requête PUT ou POST à l'URL du sujet.",
|
||||
"notifications_none_for_any_description": "Pour envoyer des notifications à un sujet, faites simplement une requête PUT ou POST à l'URL du sujet. Voici un exemple utilisant un de vos sujets.",
|
||||
"publish_dialog_filename_placeholder": "Nom du fichier joint",
|
||||
"publish_dialog_delay_label": "Délai",
|
||||
"publish_dialog_chip_click_label": "Cliquez sur l'URL",
|
||||
"subscribe_dialog_subscribe_title": "S'abonner au sujet",
|
||||
"subscribe_dialog_login_title": "Connexion nécessaire",
|
||||
"prefs_notifications_min_priority_low_and_higher": "Priorité basse et au-dessus",
|
||||
"prefs_users_dialog_button_cancel": "Annuler",
|
||||
"error_boundary_button_copy_stack_trace": "Copier la stack strace",
|
||||
"publish_dialog_attached_file_title": "Fichier joint :",
|
||||
"publish_dialog_checkbox_publish_another": "Publier un autre",
|
||||
"publish_dialog_attached_file_filename_placeholder": "Nom du fichier joint",
|
||||
"subscribe_dialog_subscribe_use_another_label": "Utiliser un autre serveur",
|
||||
"subscribe_dialog_subscribe_button_cancel": "Annuler",
|
||||
"prefs_notifications_sound_description_none": "Les notifications ne font aucun son quand elles arrivent",
|
||||
"prefs_notifications_sound_description_some": "Les notifications jouent le son {{sound}} quand elles arrivent",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Montrer les notifications si leur priorité est {{number}} ({{name}}) ou plus",
|
||||
"publish_dialog_button_cancel": "Annuler",
|
||||
"publish_dialog_button_send": "Envoyer",
|
||||
"publish_dialog_drop_file_here": "Déposez un fichier ici",
|
||||
"emoji_picker_search_placeholder": "Chercher un émoji",
|
||||
"subscribe_dialog_subscribe_description": "Le sujet n'est peut-être pas protégé par un mot de passe, choisissez un nom de sujet difficile à deviner. Une fois abonné, vous pouvez PUT/POST des notifications.",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "Nom de sujet, par ex. alertes_de_phil",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "S'abonner",
|
||||
"subscribe_dialog_login_description": "Ce sujet est protégé par un mot de passe. Veuillez entrer le nom d'utilisateur et le mot de passe pour vous abonner.",
|
||||
"subscribe_dialog_login_username_label": "Nom d'utilisateur, par ex. phil",
|
||||
"subscribe_dialog_login_button_login": "Connexion",
|
||||
"prefs_notifications_sound_title": "Son de notification",
|
||||
"prefs_notifications_delete_after_never": "Jamais",
|
||||
"prefs_users_table_base_url_header": "URL de service",
|
||||
"subscribe_dialog_login_password_label": "Mot de passe",
|
||||
"prefs_notifications_title": "Notifications",
|
||||
"prefs_notifications_delete_after_title": "Supprimer les notifications",
|
||||
"prefs_users_add_button": "Ajouter un utilisateur",
|
||||
"subscribe_dialog_login_button_back": "Retour",
|
||||
"subscribe_dialog_error_user_anonymous": "anonyme",
|
||||
"prefs_notifications_sound_no_sound": "Aucun son",
|
||||
"prefs_notifications_min_priority_title": "Priorité minimum",
|
||||
"prefs_notifications_min_priority_description_any": "Montrer toutes les notifications, quelque soit leur priorité",
|
||||
"prefs_notifications_min_priority_description_max": "Montrer les notifications si la priorité est 5 (max)",
|
||||
"prefs_notifications_min_priority_default_and_higher": "Priorité par défaut et au-dessus",
|
||||
"prefs_notifications_min_priority_max_only": "Seulement la priorité maximale",
|
||||
"prefs_notifications_delete_after_three_hours": "Après trois heures",
|
||||
"prefs_notifications_delete_after_one_day": "Après un jour",
|
||||
"subscribe_dialog_error_user_not_authorized": "L'utilisateur {{username}} n'est pas autorisé",
|
||||
"prefs_notifications_min_priority_any": "N'importe quelle priorité",
|
||||
"prefs_notifications_min_priority_high_and_higher": "Priorité haute et au-dessus",
|
||||
"prefs_users_dialog_base_url_label": "URL du service, par ex. https://ntfy.sh",
|
||||
"prefs_notifications_delete_after_one_week_description": "Les notifications sont supprimées automatiquement après une semaine",
|
||||
"prefs_users_dialog_username_label": "Nom d'utilisateur, par ex. phil",
|
||||
"prefs_users_dialog_password_label": "Mot de passe",
|
||||
"prefs_notifications_delete_after_one_month_description": "Les notifications sont supprimées automatiquement après un mois",
|
||||
"prefs_users_title": "Gérer les utilisateurs",
|
||||
"prefs_users_description": "Ajoutez/supprimez des utilisateurs pour vos sujets protégés ici. Notez que cet utilisateur et ce mot de passe sont stockés dans le stockage local du navigateur.",
|
||||
"prefs_users_table_user_header": "Utilisateur",
|
||||
"prefs_users_dialog_title_edit": "Éditer l'utilisateur",
|
||||
"prefs_users_dialog_button_add": "Ajouter",
|
||||
"error_boundary_description": "Ceci ne devrait évidemment pas arriver. Désolé pour ça.<br/>Si vous avez une minute, merci de <githubLink>signaler ceci sur GitHub</githubLink>, ou faites-le nous savoir par <discordLink>Discord</discordLink> ou <matrixLink>Matric</matrixLink>.",
|
||||
"prefs_users_dialog_title_add": "Ajouter un utilisateur",
|
||||
"error_boundary_stack_trace": "Stack trace",
|
||||
"error_boundary_gathering_info": "Récupérer plus d'information…",
|
||||
"prefs_notifications_delete_after_one_week": "Après une semaine",
|
||||
"prefs_notifications_delete_after_one_month": "Après un mois",
|
||||
"prefs_notifications_delete_after_never_description": "Les notifications ne sont jamais supprimées automatiquement",
|
||||
"prefs_notifications_delete_after_three_hours_description": "Les notifications sont supprimées automatiquement après trois heures",
|
||||
"prefs_notifications_delete_after_one_day_description": "Les notifications sont supprimées automatiquement après un jour",
|
||||
"prefs_appearance_title": "Apparence",
|
||||
"prefs_appearance_language_title": "Langue",
|
||||
"priority_min": "min",
|
||||
"priority_low": "basse",
|
||||
"priority_default": "défault",
|
||||
"priority_high": "haute",
|
||||
"priority_max": "max",
|
||||
"error_boundary_title": "Oh non, ntfy a planté",
|
||||
"publish_dialog_chip_attach_url_label": "Joindre un fichier par URL",
|
||||
"publish_dialog_chip_attach_file_label": "Joindre un fichier local",
|
||||
"publish_dialog_chip_delay_label": "Délayer l'envoi",
|
||||
"publish_dialog_chip_topic_label": "Changer de sujet",
|
||||
"publish_dialog_details_examples_description": "Pour des exemples et une description détaillée des fonctionnalités d'envoi, voir la <docsLink>documentation</docsLink>.",
|
||||
"publish_dialog_button_cancel_sending": "Annuler l'envoi",
|
||||
"prefs_users_dialog_button_save": "Enregistrer"
|
||||
}
|
||||
156
web/public/static/langs/hu.json
Normal file
@@ -0,0 +1,156 @@
|
||||
{
|
||||
"action_bar_send_test_notification": "Teszt értesítés küldése",
|
||||
"action_bar_clear_notifications": "Összes értesítés törlése",
|
||||
"alert_not_supported_description": "A böngésző nem támogatja az értesítések fogadását.",
|
||||
"action_bar_settings": "Beállítások",
|
||||
"action_bar_unsubscribe": "Leiratkozás",
|
||||
"message_bar_type_message": "Írd ide az üzenetet",
|
||||
"message_bar_error_publishing": "Hiba történt az értesítés elküldése közben",
|
||||
"nav_button_all_notifications": "Összes értesítés",
|
||||
"nav_topics_title": "Feliratkozott témák",
|
||||
"alert_grant_title": "Az értesítések le vannak tiltva",
|
||||
"alert_grant_description": "Engedélyezd a böngészőnek, hogy asztali értesítéseket jeleníttessen meg.",
|
||||
"nav_button_settings": "Beállítások",
|
||||
"nav_button_documentation": "Dokumentáció",
|
||||
"nav_button_publish_message": "Értesítés küldése",
|
||||
"alert_grant_button": "Engedélyezés",
|
||||
"alert_not_supported_title": "Nem támogatott funkció",
|
||||
"notifications_copied_to_clipboard": "Másolva a vágólapra",
|
||||
"notifications_tags": "Címkék",
|
||||
"notifications_attachment_copy_url_title": "Másolja vágólapra a csatolmány URL-ét",
|
||||
"notifications_attachment_copy_url_button": "URL másolása",
|
||||
"notifications_attachment_open_title": "Menjen a(z) {{url}} címre",
|
||||
"notifications_attachment_open_button": "Csatolmány megnyitása",
|
||||
"notifications_attachment_link_expired": "A letöltési hivatkozás lejárt",
|
||||
"notifications_attachment_link_expires": "A hivatkozás {{date}}-kor jár le",
|
||||
"nav_button_subscribe": "Feliratkozás témára",
|
||||
"notifications_click_copy_url_title": "Másolja vágólapra a hivatkozás URL-ét",
|
||||
"notifications_actions_open_url_title": "Menjen a(z) {{url}} címre",
|
||||
"notifications_actions_not_supported": "A művelet nem támogatott a webes alkalmazásban",
|
||||
"notifications_actions_http_request_title": "Küldjön HTTP {{method}} kérést a(z) {{url}} címre",
|
||||
"notifications_none_for_topic_title": "Még nem érkezett értesítés erre a témára.",
|
||||
"notifications_none_for_any_title": "Még nem érkezett egy értesítés sem.",
|
||||
"notifications_none_for_any_description": "Értesítés beküldéséhez csak küldj egy PUT, vagy POST kérést a téma URL-ére. Itt egy példa az egyik témádhoz.",
|
||||
"notifications_no_subscriptions_title": "Úgy tűnik, még nem iratkoztál fel egy témára sem.",
|
||||
"publish_dialog_message_published": "Értesítés elküldve",
|
||||
"notifications_example": "Példa",
|
||||
"notifications_no_subscriptions_description": "Kattints a \"{{linktext}}\" linkre egy téma létrehozásához, vagy rá feliratkozáshoz. Ezután PUT, vagy POST kéréssel fogsz tudni értesítéseket küldeni rá, amik utána meg fognak itt jelenni.",
|
||||
"publish_dialog_priority_low": "Alacsony prioritás",
|
||||
"publish_dialog_priority_default": "Közepes prioritás",
|
||||
"publish_dialog_priority_high": "Magas prioritás",
|
||||
"notifications_more_details": "További információkért keresd fel a <websiteLink>weboldalunkat</websiteLink> vagy olvasd el a <docsLink>dokumentációt</docsLink>.",
|
||||
"publish_dialog_title_no_topic": "Értesítés küldése",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "túllépi a fájlméret korlátot ({{fileSizeLimit}}) és a kvótát is ({{remainingBytes}} maradt)",
|
||||
"publish_dialog_attachment_limits_quota_reached": "túllépi a kvótát, {{remainingBytes}} maradt",
|
||||
"publish_dialog_priority_min": "Legkisebb prioritás",
|
||||
"publish_dialog_base_url_label": "A szolgáltatás URL-e",
|
||||
"publish_dialog_base_url_placeholder": "A szolgáltatás URL-e, pl: https://example.com",
|
||||
"publish_dialog_topic_label": "Téma neve",
|
||||
"publish_dialog_priority_max": "Legmagasabb prioritás",
|
||||
"publish_dialog_topic_placeholder": "Téma neve, pl: jozsi_riasztasai",
|
||||
"publish_dialog_title_label": "Cím",
|
||||
"publish_dialog_title_placeholder": "Értesítés címe, pl: Fogy a szabad hely",
|
||||
"publish_dialog_message_label": "Üzenet",
|
||||
"publish_dialog_message_placeholder": "Írj ide egy üzenetet",
|
||||
"publish_dialog_tags_label": "Címkék",
|
||||
"publish_dialog_tags_placeholder": "Címkék vesszővel elválasztva, pl: fontos,srv1-backup",
|
||||
"publish_dialog_priority_label": "Prioritás",
|
||||
"publish_dialog_click_label": "URL",
|
||||
"publish_dialog_click_placeholder": "Webcím, ami megnyílik, ha az értesítésre kattintanak",
|
||||
"publish_dialog_email_label": "Email",
|
||||
"publish_dialog_email_placeholder": "Email cím, amire továbbítjuk az értesítést, pl: jozsi@example.com",
|
||||
"publish_dialog_attach_label": "Csatolmány URL-e",
|
||||
"publish_dialog_filename_label": "Fájlnév",
|
||||
"publish_dialog_filename_placeholder": "Csatolmány fájlneve",
|
||||
"publish_dialog_delay_label": "Késleltetés",
|
||||
"publish_dialog_delay_placeholder": "Késleltetett küldés, pl: {{unixTimestamp}}, {{relativeTime}}, vagy \"{{naturalLanguage}}\" (Csak angolul)",
|
||||
"publish_dialog_other_features": "Egyéb lehetőségek:",
|
||||
"publish_dialog_chip_click_label": "Kattintási URL",
|
||||
"publish_dialog_chip_attach_file_label": "Helyi fájl csatolása",
|
||||
"publish_dialog_chip_delay_label": "Késleltetett kézbesítés",
|
||||
"publish_dialog_chip_topic_label": "Téma megváltoztatása",
|
||||
"publish_dialog_button_cancel_sending": "Küldés megállítása",
|
||||
"publish_dialog_button_cancel": "Mégsem",
|
||||
"publish_dialog_checkbox_publish_another": "Küldök még egyet",
|
||||
"publish_dialog_attached_file_title": "Csatolt fájl:",
|
||||
"publish_dialog_attached_file_filename_placeholder": "Csatolmány fájlneve",
|
||||
"publish_dialog_drop_file_here": "Ejtsd ide a fájlt",
|
||||
"emoji_picker_search_placeholder": "Emoji keresése",
|
||||
"publish_dialog_details_examples_description": "Példákért és az összes küldési képesség részletes leírásához olvasd el a <docsLink>dokumentációt</docsLink>.",
|
||||
"subscribe_dialog_subscribe_use_another_label": "Használjon másik szervert",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "Feliratkozás",
|
||||
"subscribe_dialog_login_title": "Be kell jelentkezni",
|
||||
"subscribe_dialog_subscribe_description": "A témák nem mindig vannak jelszóval védve, ezért olyan nevet válassz, ami nehezen található ki. Miután feliratkoztál, küldhetsz értesítéseket.",
|
||||
"subscribe_dialog_login_description": "Ez a téma jelszóval védett. Jelentkezz be a feliratkozáshoz.",
|
||||
"subscribe_dialog_login_username_label": "Felhasználónév, pl: jozsi",
|
||||
"subscribe_dialog_login_password_label": "Jelszó",
|
||||
"subscribe_dialog_login_button_back": "Vissza",
|
||||
"subscribe_dialog_login_button_login": "Belépés",
|
||||
"subscribe_dialog_error_user_anonymous": "névtelen",
|
||||
"subscribe_dialog_error_user_not_authorized": "A(z) {{username}} felhasználónak nincs hozzáférése",
|
||||
"prefs_notifications_min_priority_description_any": "Minden értesítést mutat, prioritástól függetlenül",
|
||||
"prefs_notifications_min_priority_description_max": "Csak az 5-ös (legmagasabb) prioritású értesítések jelennek meg",
|
||||
"prefs_notifications_min_priority_any": "Bármilyen prioritás",
|
||||
"prefs_notifications_min_priority_low_and_higher": "Alacsony prioritás, vagy magasabb",
|
||||
"prefs_notifications_min_priority_high_and_higher": "Magas, vagy legmagasabb prioritás",
|
||||
"prefs_notifications_min_priority_max_only": "Csak a legmagasabb prioritás",
|
||||
"prefs_notifications_sound_title": "Értesítés hangja",
|
||||
"prefs_notifications_sound_description_none": "Az értesítések nem fognak hangot adni, amikor megérkeznek",
|
||||
"prefs_notifications_sound_no_sound": "Hang nélkül",
|
||||
"prefs_notifications_delete_after_one_week": "1 hét után",
|
||||
"prefs_notifications_delete_after_one_month": "1 hónap után",
|
||||
"prefs_notifications_delete_after_never_description": "Az értesítések soha nem lesznek automatikusan törölve",
|
||||
"prefs_notifications_delete_after_three_hours_description": "A 3 óránál régebbi értesítések automatikus törlése",
|
||||
"prefs_notifications_delete_after_one_day_description": "Az egy napnál régebbi értesítések automatikus törlése",
|
||||
"prefs_users_description": "Itt tudsz hozzáadni/eltávolítani felhasználókat a védett témákról. Fontos, hogy a felhasználónevet és a jelszót a böngésző helyi tárolójába fogjuk menteni.",
|
||||
"prefs_users_table_user_header": "Felhasználó",
|
||||
"prefs_users_table_base_url_header": "Szerver címe",
|
||||
"prefs_users_dialog_title_edit": "Felhasználó szerkesztése",
|
||||
"prefs_users_dialog_username_label": "Felhasználónév, pl: jozsi",
|
||||
"prefs_users_dialog_password_label": "Jelszó",
|
||||
"prefs_users_dialog_button_add": "Hozzáadás",
|
||||
"prefs_users_dialog_base_url_label": "Szerver címe, pl: https://ntfy.sh",
|
||||
"notifications_loading": "Értesítések betöltése …",
|
||||
"publish_dialog_progress_uploading": "Feltöltés …",
|
||||
"notifications_click_copy_url_button": "Hivatkozás másolása",
|
||||
"notifications_click_open_button": "Hivatkozás megnyitása",
|
||||
"publish_dialog_progress_uploading_detail": "Feltöltés folyamatban: {{loaded}}/{{total}} ({{percent}}%) …",
|
||||
"notifications_none_for_topic_description": "Értesítés beküldéséhez csak küldj egy PUT, vagy POST kérést a téma URL-ére.",
|
||||
"prefs_notifications_delete_after_one_day": "1 nap után",
|
||||
"publish_dialog_attach_placeholder": "Csatolandó fájl címe, pl: https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_chip_email_label": "Továbbítás email-ben",
|
||||
"publish_dialog_chip_attach_url_label": "Fájl csatolása URL-lel",
|
||||
"publish_dialog_button_send": "Küldés",
|
||||
"subscribe_dialog_subscribe_title": "Feliratkozás témára",
|
||||
"subscribe_dialog_subscribe_button_cancel": "Mégsem",
|
||||
"prefs_notifications_min_priority_title": "Legkisebb megjelenítendő prioritás",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Csak akkor jelenik meg egy értesítés, ha a prioritása {{number}} ({{name}}), vagy fontosabb",
|
||||
"prefs_notifications_min_priority_default_and_higher": "Közepes prioritás, vagy magasabb",
|
||||
"prefs_notifications_delete_after_one_week_description": "Az egy hétnél régebbi értesítések automatikus törlése",
|
||||
"prefs_users_add_button": "Felhasználó hozzáadása",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "Téma neve, pl: jozsi_riasztasai",
|
||||
"prefs_notifications_title": "Értesítések",
|
||||
"error_boundary_button_copy_stack_trace": "Verem nyomkövetés másolása",
|
||||
"prefs_notifications_delete_after_title": "Régi értesítések törlése",
|
||||
"prefs_notifications_delete_after_three_hours": "3 óra után",
|
||||
"error_boundary_title": "Jaj ne, az ntfy összeomlott",
|
||||
"prefs_notifications_delete_after_never": "Soha",
|
||||
"prefs_notifications_delete_after_one_month_description": "Az egy hónapnál régebbi értesítések automatikus törlése",
|
||||
"prefs_appearance_title": "Megjelenés",
|
||||
"priority_default": "közepes",
|
||||
"priority_high": "magas",
|
||||
"priority_max": "legmagasabb",
|
||||
"priority_min": "legkisebb",
|
||||
"error_boundary_gathering_info": "Több információ…",
|
||||
"publish_dialog_attachment_limits_file_reached": "túllépi a fájlméret korlátot ({{fileSizeLimit}})",
|
||||
"prefs_users_title": "Felhasználók kezelése",
|
||||
"prefs_users_dialog_button_cancel": "Mégsem",
|
||||
"prefs_users_dialog_button_save": "Mentés",
|
||||
"prefs_users_dialog_title_add": "Felhasználó hozzáadása",
|
||||
"prefs_appearance_language_title": "Nyelv",
|
||||
"priority_low": "alacsony",
|
||||
"error_boundary_stack_trace": "Verem nyomkövetés",
|
||||
"publish_dialog_title_topic": "A {{topic}} téma értesítése",
|
||||
"prefs_notifications_sound_description_some": "Az értesítéseket a(z) {{sound}} hang fogja jelezni",
|
||||
"error_boundary_description": "Ennek nem szabadott volna megtörténnie. Nagyon sajnáljuk.<br/>Ha van egy perced, <githubLink>jelentsd be GitHubon</githubLink>, vagy tudasd velünk <discordLink>Discordon</discordLink>, vagy <matrixLink>Matrixon</matrixLink>."
|
||||
}
|
||||
191
web/public/static/langs/id.json
Normal file
@@ -0,0 +1,191 @@
|
||||
{
|
||||
"notifications_click_copy_url_title": "Salin URL tautan ke papan klip",
|
||||
"alert_not_supported_title": "Notifikasi tidak didukung",
|
||||
"notifications_click_copy_url_button": "Salin tautan",
|
||||
"notifications_no_subscriptions_description": "Klik pada tautan \"{{linktext}}\" untuk membuat atau berlangganan ke sebuah topik. Setelah itu, Anda dapat mengirim pesan via PUT atau POST dan Anda akan menerima notifikasi di sini.",
|
||||
"notifications_example": "Contoh",
|
||||
"subscribe_dialog_subscribe_description": "Topik mungkin tidak dilindungi oleh kata sandi, jadi pilih sebuah nama yang tidak mudah untuk ditebak. Setelah berlangganan, Anda dapat PUT/POST notifikasi.",
|
||||
"subscribe_dialog_login_title": "Login dibutuhkan",
|
||||
"prefs_appearance_language_title": "Bahasa",
|
||||
"nav_button_all_notifications": "Semua notifikasi",
|
||||
"notifications_none_for_any_title": "Anda belum menerima notifikasi apa pun.",
|
||||
"action_bar_settings": "Pengaturan",
|
||||
"action_bar_send_test_notification": "Kirim notifikasi uji coba",
|
||||
"action_bar_clear_notifications": "Hapus semua notifikasi",
|
||||
"action_bar_unsubscribe": "Batalkan langganan",
|
||||
"message_bar_type_message": "Ketika sebuah pesan di sini",
|
||||
"message_bar_error_publishing": "Terjadi kesalahan mempublikasikan notifikasi",
|
||||
"publish_dialog_title_label": "Judul",
|
||||
"publish_dialog_message_label": "Pesan",
|
||||
"nav_button_settings": "Pengaturan",
|
||||
"nav_button_documentation": "Dokumentasi",
|
||||
"prefs_users_dialog_button_add": "Tambahkan",
|
||||
"nav_topics_title": "Topik yang dilanggani",
|
||||
"nav_button_subscribe": "Berlangganan ke topik",
|
||||
"alert_grant_title": "Notifikasi dinonaktifkan",
|
||||
"alert_grant_description": "Berikan izin ke peramban untuk menampilkan notifikasi desktop.",
|
||||
"alert_not_supported_description": "Notifikasi tidak didukung dalam peramban Anda.",
|
||||
"notifications_attachment_open_title": "Pergi ke {{url}}",
|
||||
"notifications_attachment_open_button": "Buka lampiran",
|
||||
"notifications_attachment_link_expires": "tautan kadaluwarsa {{date}}",
|
||||
"notifications_attachment_link_expired": "tautan unduhan kadaluwarsa",
|
||||
"notifications_actions_open_url_title": "Pergi ke {{url}}",
|
||||
"notifications_click_open_button": "Buka tautan",
|
||||
"publish_dialog_topic_placeholder": "Nama topik, mis. pemberitahuan_andi",
|
||||
"nav_button_publish_message": "Publikasikan notifikasi",
|
||||
"alert_grant_button": "Berikan sekarang",
|
||||
"notifications_copied_to_clipboard": "Disalin ke papan klip",
|
||||
"notifications_tags": "Tanda",
|
||||
"notifications_attachment_copy_url_title": "Salin URL lampiran ke papan klip",
|
||||
"notifications_attachment_copy_url_button": "Salin URL",
|
||||
"notifications_none_for_topic_title": "Anda belum menerima notifikasi apa pun untuk topik ini.",
|
||||
"notifications_none_for_topic_description": "Untuk mengirimkan notifikasi ke topik ini, tinggal PUT atau POST ke URL topik.",
|
||||
"notifications_none_for_any_description": "Untuk mengirimkan notifikasi ke sebuah topik, tinggal PUT atau POST ke URL topik. Ini adalah contoh menggunakan salah satu topik Anda.",
|
||||
"notifications_no_subscriptions_title": "Sepertinya Anda belum memiliki langganan apa pun.",
|
||||
"publish_dialog_title_topic": "Publikasikan ke {{topic}}",
|
||||
"subscribe_dialog_login_description": "Topik ini dilindungi oleh kata sandi. Mohon masukkan nama pengguna dan kata sandi untuk berlangganan.",
|
||||
"prefs_notifications_min_priority_title": "Prioritas minimum",
|
||||
"error_boundary_gathering_info": "Dapatkan info lanjut …",
|
||||
"publish_dialog_title_no_topic": "Publikasikan notifikasi",
|
||||
"publish_dialog_progress_uploading": "Mengunggah …",
|
||||
"notifications_more_details": "Untuk informasi lanjut, lihat <websiteLink>situs web</websiteLink> atau <docsLink>dokumentasi</docsLink>.",
|
||||
"publish_dialog_progress_uploading_detail": "Mengunggah {{loaded}}/{{total}} ({{percent}}%) …",
|
||||
"publish_dialog_message_published": "Notifikasi terpublikasi",
|
||||
"notifications_loading": "Memuat notifikasi …",
|
||||
"publish_dialog_base_url_label": "URL Layanan",
|
||||
"publish_dialog_title_placeholder": "Judul notifikasi, mis. Peringatan ruang disk",
|
||||
"publish_dialog_tags_label": "Tanda",
|
||||
"publish_dialog_priority_label": "Prioritas",
|
||||
"publish_dialog_base_url_placeholder": "URL Layanan, mis. https://contoh.com",
|
||||
"publish_dialog_attach_placeholder": "Lampirkan file dengan URL, mis. https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_delay_label": "Jeda",
|
||||
"publish_dialog_chip_topic_label": "Ubah topik",
|
||||
"publish_dialog_button_cancel_sending": "Batalkan pengiriman",
|
||||
"publish_dialog_button_send": "Kirim",
|
||||
"publish_dialog_attachment_limits_file_reached": "melebihi batasan file {{fileSizeLimit}",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "melebihi batasan file dan kuota {{fileSizeLimit}}, hanya {{remainingBytes}}",
|
||||
"publish_dialog_attachment_limits_quota_reached": "melebihi kuota, hanya {{remainingBytes}}",
|
||||
"publish_dialog_priority_min": "Prioritas min.",
|
||||
"publish_dialog_priority_low": "Prioritas rendah",
|
||||
"publish_dialog_priority_default": "Prioritas bawaan",
|
||||
"publish_dialog_priority_high": "Prioritas tinggi",
|
||||
"publish_dialog_priority_max": "Prioritas maks.",
|
||||
"publish_dialog_topic_label": "Nama topik",
|
||||
"publish_dialog_message_placeholder": "Ketik sebuah pesan di sini",
|
||||
"publish_dialog_click_label": "Klik URL",
|
||||
"publish_dialog_tags_placeholder": "Daftar tanda yang dipisah dengan koma, mis. peringatan, cadangan-srv1",
|
||||
"publish_dialog_click_placeholder": "URL yang dibuka ketika notifikasi diklik",
|
||||
"publish_dialog_email_label": "Email",
|
||||
"publish_dialog_email_placeholder": "Alamat untuk meneruskan notifikasi, mis. andi@contoh.com",
|
||||
"publish_dialog_attach_label": "URL Lampiran",
|
||||
"publish_dialog_filename_label": "Nama File",
|
||||
"publish_dialog_filename_placeholder": "Nama file lampiran",
|
||||
"publish_dialog_delay_placeholder": "Penjedaan pengiriman, mis. {{unixTimestamp}}, {{relativeTime}}, atau \"{{naturalLanguage}}\" (hanya Inggris)",
|
||||
"publish_dialog_other_features": "Fitur lainnya:",
|
||||
"publish_dialog_chip_click_label": "Klik URL",
|
||||
"publish_dialog_chip_email_label": "Teruskan ke email",
|
||||
"publish_dialog_chip_attach_url_label": "Lampirkan file dengan URL",
|
||||
"publish_dialog_chip_attach_file_label": "Lampirkan file lokal",
|
||||
"publish_dialog_chip_delay_label": "Jeda pengiriman",
|
||||
"publish_dialog_button_cancel": "Batal",
|
||||
"publish_dialog_details_examples_description": "Untuk contoh dan deskripsi yang rinci oleh semua fitur pengiriman, lihat <docsLink>dokumentasi</docsLink>.",
|
||||
"publish_dialog_checkbox_publish_another": "Publikasi yang lain",
|
||||
"publish_dialog_attached_file_title": "File yang dilampirkan:",
|
||||
"publish_dialog_attached_file_filename_placeholder": "Nama file lampiran",
|
||||
"publish_dialog_drop_file_here": "Lepaskan file di sini",
|
||||
"emoji_picker_search_placeholder": "Cari emoji",
|
||||
"subscribe_dialog_subscribe_button_cancel": "Batal",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "Berlangganan",
|
||||
"subscribe_dialog_error_user_anonymous": "anonim",
|
||||
"prefs_notifications_min_priority_any": "Prioritas apa saja",
|
||||
"prefs_notifications_delete_after_title": "Hapus notifikasi",
|
||||
"prefs_notifications_delete_after_three_hours": "Setelah tiga jam",
|
||||
"prefs_notifications_delete_after_one_day": "Setelah satu hari",
|
||||
"prefs_users_add_button": "Tambahkan pengguna",
|
||||
"prefs_users_dialog_username_label": "Nama pengguna, mis. andi",
|
||||
"subscribe_dialog_subscribe_title": "Berlangganan ke topik",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "Nama topik, mis. pemberitahuan_andi",
|
||||
"subscribe_dialog_subscribe_use_another_label": "Gunakan server lain",
|
||||
"subscribe_dialog_login_username_label": "Nama pengguna, mis. Andi",
|
||||
"subscribe_dialog_login_button_login": "Masuk",
|
||||
"subscribe_dialog_error_user_not_authorized": "Pengguna {{username}} tidak diizinkan",
|
||||
"prefs_notifications_title": "Notifikasi",
|
||||
"prefs_notifications_sound_no_sound": "Tidak ada suara",
|
||||
"prefs_users_table_user_header": "Pengguna",
|
||||
"prefs_users_dialog_base_url_label": "URL Layanan, mis. https://ntfy.sh",
|
||||
"prefs_users_dialog_button_save": "Simpan",
|
||||
"prefs_appearance_title": "Tampilan",
|
||||
"subscribe_dialog_login_password_label": "Kata sandi",
|
||||
"subscribe_dialog_login_button_back": "Kembali",
|
||||
"prefs_notifications_sound_title": "Suara notifikasi",
|
||||
"prefs_notifications_min_priority_low_and_higher": "Prioritas rendah dan lebih tinggi",
|
||||
"prefs_notifications_min_priority_default_and_higher": "Prioritas bawaan dan lebih tinggi",
|
||||
"prefs_notifications_min_priority_high_and_higher": "Prioritas tinggi dan lebih tinggi",
|
||||
"prefs_notifications_min_priority_max_only": "Hanya prioritas maks",
|
||||
"prefs_notifications_delete_after_never": "Tidak pernah",
|
||||
"prefs_notifications_delete_after_one_week": "Setelah satu minggu",
|
||||
"prefs_notifications_delete_after_one_month": "Setelah satu bulan",
|
||||
"prefs_users_title": "Kelola pengguna",
|
||||
"prefs_users_description": "Tambahkan/hapus pengguna untuk topik yang dilindungi di sini. Dicatat bahwa nama pengguna dan kata sandi disimpan dalam penyimpanan lokal peramban.",
|
||||
"prefs_users_table_base_url_header": "URL Layanan",
|
||||
"prefs_users_dialog_title_add": "Tambahkan pengguna",
|
||||
"prefs_users_dialog_title_edit": "Edit pengguna",
|
||||
"prefs_users_dialog_password_label": "Kata sandi",
|
||||
"prefs_users_dialog_button_cancel": "Batal",
|
||||
"error_boundary_title": "Aduh, ntfy mogok",
|
||||
"error_boundary_description": "Seharusnya ini tidak terjadi. Maaf sekali tentang hal ini.<br/>Jika Anda punya beberapa menit, silakan <githubLink>laporkan ini di GitHub</githubLink>, atau beritahu kami melalui <discordLink>Discord</discordLink> atau <matrixLink>Matrix</matrixLink>.",
|
||||
"error_boundary_stack_trace": "Jejak tumpukan",
|
||||
"error_boundary_button_copy_stack_trace": "Salin jejak tumpukan",
|
||||
"prefs_notifications_sound_description_some": "Notifikasi memainkan suara {{sound}} ketika diterima",
|
||||
"prefs_notifications_min_priority_description_any": "Menampilkan semua notifikasi, apa pun prioritasnya",
|
||||
"prefs_notifications_min_priority_description_max": "Tampilkan notifikasi jika prioritas adalah 5 (maks)",
|
||||
"prefs_notifications_delete_after_three_hours_description": "Notifikasi dihapus secara otomatis setelah tiga jam",
|
||||
"prefs_notifications_delete_after_one_week_description": "Notifikasi dihapus secara otomatis setelah satu minggu",
|
||||
"prefs_notifications_delete_after_one_month_description": "Notifikasi dihapus secara otomatis setelah satu bulan",
|
||||
"priority_low": "rendah",
|
||||
"priority_high": "tinggi",
|
||||
"priority_max": "maks",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Tampilkan notifikasi jika prioritas {{number}} ({{name}}) atau lebih",
|
||||
"prefs_notifications_sound_description_none": "Notifikasi tidak boleh memainkan suara apa pun ketika diterima",
|
||||
"prefs_notifications_delete_after_never_description": "Notifikasi tidak pernah dihapus secara otomatis",
|
||||
"prefs_notifications_delete_after_one_day_description": "Notifikasi dihapus secara otomatis setelah satu hari",
|
||||
"priority_default": "bawaan",
|
||||
"priority_min": "min",
|
||||
"notifications_actions_not_supported": "Tindakan tidak didukung di aplikasi web",
|
||||
"notifications_actions_http_request_title": "Kirim {{method}} HTTP ke {{url}}",
|
||||
"action_bar_show_menu": "Tampilkan menu",
|
||||
"action_bar_logo_alt": "logo ntfy",
|
||||
"action_bar_toggle_mute": "Bisu/suarakan notifikasi",
|
||||
"action_bar_toggle_action_menu": "Buka/tutup menu tindakan",
|
||||
"message_bar_show_dialog": "Tampilkan dialog publikasi",
|
||||
"message_bar_publish": "Publikasikan pesan",
|
||||
"nav_button_muted": "Notifikasi dibisukan",
|
||||
"nav_button_connecting": "menghubungkan",
|
||||
"notifications_list": "Daftar notifikasi",
|
||||
"notifications_list_item": "Notifikasi",
|
||||
"notifications_mark_read": "Tandai sebagai dibaca",
|
||||
"notifications_delete": "Hapus",
|
||||
"notifications_priority_x": "Prioritas {{priority}}",
|
||||
"notifications_new_indicator": "Notifikasi baru",
|
||||
"notifications_attachment_image": "Lampiran gambar",
|
||||
"notifications_attachment_file_image": "file gambar",
|
||||
"notifications_attachment_file_video": "file",
|
||||
"notifications_attachment_file_audio": "file audio",
|
||||
"notifications_attachment_file_app": "file aplikasi Android",
|
||||
"notifications_attachment_file_document": "dokumen lainnya",
|
||||
"publish_dialog_emoji_picker_show": "Pilih emoji",
|
||||
"publish_dialog_topic_reset": "Atur ulang topik",
|
||||
"publish_dialog_click_reset": "Hapus URL klik",
|
||||
"publish_dialog_email_reset": "Hapus terusan email",
|
||||
"publish_dialog_attach_reset": "Hapus URL lampiran",
|
||||
"publish_dialog_delay_reset": "Hapus pengiriman telat",
|
||||
"publish_dialog_attached_file_remove": "Hapus file yang dilampirkan",
|
||||
"emoji_picker_search_clear": "Hapus pencarian",
|
||||
"subscribe_dialog_subscribe_base_url_label": "URL layanan",
|
||||
"prefs_notifications_sound_play": "Mainkan suara yang dipilih",
|
||||
"prefs_users_table": "Tabel pengguna",
|
||||
"prefs_users_edit_button": "Edit pengguna",
|
||||
"prefs_users_delete_button": "Hapus pengguna",
|
||||
"error_boundary_unsupported_indexeddb_description": "Aplikasi web ntfy membutuhkan IndexedDB untuk berfungsi, dan peramban Anda tidak mendukung IndexedDB dalam mode penjelajahan pribadi.<br/><br/>Meskipun ini disayangkan, penggunaan aplikasi web ntfy juga tidak masuk akal di mode penjelajahan pribadi, karena semuanya disimpan di penyimpanan peramban. Anda dapat membaca lebih lanjut tentangnya <githubLink>di masalah GitHub ini</githubLink>, atau berbicara dengan kami di <discordLink>Discord</discordLink> atau <matrixLink>Matrix</matrixLink>.",
|
||||
"error_boundary_unsupported_indexeddb_title": "Penjelajahan privat tidak didukung"
|
||||
}
|
||||
191
web/public/static/langs/it.json
Normal file
@@ -0,0 +1,191 @@
|
||||
{
|
||||
"action_bar_logo_alt": "logo ntfy",
|
||||
"action_bar_settings": "Impostazioni",
|
||||
"action_bar_clear_notifications": "Cancella tutte le notifiche",
|
||||
"action_bar_unsubscribe": "Annulla l'iscrizione",
|
||||
"action_bar_toggle_action_menu": "Apri/chiudi il menu delle azioni",
|
||||
"message_bar_type_message": "Digita un messaggio qui",
|
||||
"message_bar_error_publishing": "Errore durante la pubblicazione della notifica",
|
||||
"message_bar_show_dialog": "Mostra la finestra di dialogo di pubblicazione",
|
||||
"message_bar_publish": "Pubblica messaggio",
|
||||
"nav_topics_title": "Topic a cui si è iscritti",
|
||||
"nav_button_all_notifications": "Tutte le notifiche",
|
||||
"nav_button_settings": "Impostazioni",
|
||||
"nav_button_publish_message": "Pubblica notifica",
|
||||
"nav_button_subscribe": "Iscriviti al topic",
|
||||
"nav_button_muted": "Notifiche disattivate",
|
||||
"nav_button_connecting": "connessione",
|
||||
"alert_grant_title": "Le notifiche sono disabilitate",
|
||||
"alert_grant_button": "Concedi ora",
|
||||
"notifications_list": "Elenco notifiche",
|
||||
"notifications_list_item": "Notifiche",
|
||||
"notifications_mark_read": "Segna come letto",
|
||||
"notifications_delete": "Elimina",
|
||||
"notifications_copied_to_clipboard": "Copiato negli appunti",
|
||||
"notifications_tags": "Tags",
|
||||
"notifications_priority_x": "Priorità {{priority}}",
|
||||
"notifications_new_indicator": "Nuova notifica",
|
||||
"notifications_attachment_image": "Immagine allegata",
|
||||
"notifications_attachment_copy_url_title": "Copia l'URL dell'allegato negli appunti",
|
||||
"notifications_attachment_copy_url_button": "Copia URL",
|
||||
"notifications_attachment_open_title": "Vai a {{url}}",
|
||||
"notifications_attachment_open_button": "Apri allegato",
|
||||
"notifications_attachment_link_expires": "Il collegamento scade il {{date}}",
|
||||
"notifications_attachment_link_expired": "link per il download scaduto",
|
||||
"notifications_attachment_file_image": "file immagine",
|
||||
"notifications_attachment_file_video": "file video",
|
||||
"action_bar_toggle_mute": "Abilita/disabilita le notifiche",
|
||||
"notifications_attachment_file_document": "altro documento",
|
||||
"notifications_click_copy_url_button": "Copia link",
|
||||
"notifications_click_open_button": "Apri link",
|
||||
"notifications_actions_open_url_title": "Vai a {{url}}",
|
||||
"notifications_actions_not_supported": "Azione non supportata nell'app Web",
|
||||
"notifications_none_for_topic_title": "Non hai ancora ricevuto alcuna notifica per questo topic.",
|
||||
"notifications_none_for_topic_description": "Per inviare notifiche a questo argomento, è sufficiente PUT o POST all'URL del topic.",
|
||||
"notifications_none_for_any_title": "Non hai ricevuto alcuna notifica.",
|
||||
"notifications_no_subscriptions_title": "Sembra che tu non abbia ancora abbonamenti.",
|
||||
"notifications_example": "Esempio",
|
||||
"notifications_more_details": "Per ulteriori informazioni, consulta il <websiteLink>sito web</websiteLink> o <docsLink>documentazione</docsLink>.",
|
||||
"notifications_loading": "Caricamento notifiche in corso…",
|
||||
"publish_dialog_title_topic": "Pubblica su {{topic}}",
|
||||
"publish_dialog_title_no_topic": "Pubblica notifica",
|
||||
"publish_dialog_progress_uploading": "Caricamento in corso…",
|
||||
"publish_dialog_progress_uploading_detail": "Caricamento {{loaded}}/{{total}} ({{percent}}%)…",
|
||||
"publish_dialog_message_published": "Notifica pubblicata",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "supera {{fileSizeLimit}} limite di file e quota, {{remainingBytes}} rimanenti",
|
||||
"publish_dialog_attachment_limits_file_reached": "supera di {{fileSizeLimit}} il limite dei file",
|
||||
"publish_dialog_attachment_limits_quota_reached": "supera la quota, {{remainingBytes}} rimanenti",
|
||||
"publish_dialog_emoji_picker_show": "Scegli emoji",
|
||||
"publish_dialog_priority_min": "Min. priorità",
|
||||
"publish_dialog_priority_low": "Bassa priorità",
|
||||
"publish_dialog_priority_default": "Priorità predefinita",
|
||||
"publish_dialog_priority_high": "Priorità alta",
|
||||
"publish_dialog_priority_max": "Max. priorità",
|
||||
"publish_dialog_base_url_label": "URL del servizio",
|
||||
"publish_dialog_base_url_placeholder": "URL del servizio, ad es. https://esempio.com",
|
||||
"publish_dialog_topic_label": "Nome topic",
|
||||
"publish_dialog_topic_placeholder": "Nome topic, ad es. avvisi_di_phil",
|
||||
"publish_dialog_topic_reset": "Reset topic",
|
||||
"publish_dialog_title_label": "Titolo",
|
||||
"publish_dialog_title_placeholder": "Titolo della notifica, ad es. Avviso di spazio su disco",
|
||||
"publish_dialog_message_label": "Messaggio",
|
||||
"publish_dialog_message_placeholder": "Digita un messaggio qui",
|
||||
"publish_dialog_tags_label": "Tags",
|
||||
"publish_dialog_priority_label": "Priorità",
|
||||
"publish_dialog_click_label": "Clicca URL",
|
||||
"publish_dialog_click_reset": "Rimuovi l'URL del clic",
|
||||
"publish_dialog_email_label": "Email",
|
||||
"publish_dialog_email_placeholder": "Indirizzo a cui inoltrare la notifica, ad es. phil@example.com",
|
||||
"publish_dialog_email_reset": "Rimuovi inoltro email",
|
||||
"publish_dialog_attach_label": "URL Allegato",
|
||||
"publish_dialog_attach_reset": "Rimuovi l'URL dell'allegato",
|
||||
"publish_dialog_filename_label": "Nome del file",
|
||||
"publish_dialog_filename_placeholder": "Nome file allegato",
|
||||
"publish_dialog_delay_placeholder": "Consegna ritardata, ad es. {{unixTimestamp}}, {{relativeTime}} o \"{{naturalLanguage}}\" (solo in inglese)",
|
||||
"publish_dialog_delay_reset": "Rimuovere la consegna ritardata",
|
||||
"publish_dialog_other_features": "Altre funzionalità:",
|
||||
"publish_dialog_chip_click_label": "Fare clic su URL",
|
||||
"publish_dialog_chip_email_label": "Inoltra a e-mail",
|
||||
"publish_dialog_chip_attach_url_label": "Allega il file tramite URL",
|
||||
"publish_dialog_chip_attach_file_label": "Allega file locale",
|
||||
"publish_dialog_chip_delay_label": "Ritardo nella consegna",
|
||||
"publish_dialog_button_cancel_sending": "Annulla l'invio",
|
||||
"publish_dialog_button_cancel": "Annulla",
|
||||
"publish_dialog_button_send": "Invia",
|
||||
"publish_dialog_checkbox_publish_another": "Pubblica un altro",
|
||||
"publish_dialog_attached_file_title": "File allegato:",
|
||||
"publish_dialog_attached_file_remove": "Rimuovi il file allegato",
|
||||
"publish_dialog_drop_file_here": "Trascina il file qui",
|
||||
"emoji_picker_search_clear": "Cancella ricerca",
|
||||
"subscribe_dialog_subscribe_title": "Iscriviti al topic",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "Nome dell'argomento, ad es. avvisi_di_phil",
|
||||
"subscribe_dialog_subscribe_base_url_label": "URL del servizio",
|
||||
"subscribe_dialog_subscribe_button_cancel": "Annulla",
|
||||
"subscribe_dialog_login_title": "Accesso richiesto",
|
||||
"subscribe_dialog_login_username_label": "Nome utente, ad es. phil",
|
||||
"subscribe_dialog_login_button_login": "Login",
|
||||
"subscribe_dialog_error_user_anonymous": "anonimo",
|
||||
"prefs_notifications_sound_title": "Suono di notifica",
|
||||
"prefs_notifications_sound_description_some": "Le notifiche riproducono il suono {{sound}} quando arrivano",
|
||||
"prefs_notifications_sound_no_sound": "Nessun suono",
|
||||
"prefs_notifications_min_priority_description_any": "Visualizzazione di tutte le notifiche, indipendentemente dalla priorità",
|
||||
"prefs_notifications_min_priority_description_max": "Mostra notifiche se la priorità è 5 (max)",
|
||||
"prefs_notifications_min_priority_any": "Qualsiasi priorità",
|
||||
"prefs_notifications_min_priority_low_and_higher": "Priorità bassa e superiore",
|
||||
"prefs_notifications_min_priority_high_and_higher": "Priorità alta e superiore",
|
||||
"prefs_notifications_min_priority_max_only": "Solo priorità massima",
|
||||
"prefs_notifications_delete_after_never": "Mai",
|
||||
"prefs_notifications_delete_after_three_hours": "Dopo tre ore",
|
||||
"prefs_notifications_delete_after_one_day": "Dopo un giorno",
|
||||
"prefs_notifications_delete_after_never_description": "Le notifiche non vengono mai eliminate automaticamente",
|
||||
"prefs_notifications_delete_after_one_day_description": "Le notifiche vengono eliminate automaticamente dopo un giorno",
|
||||
"prefs_notifications_delete_after_one_week_description": "Le notifiche vengono eliminate automaticamente dopo una settimana",
|
||||
"prefs_notifications_delete_after_one_month_description": "Le notifiche vengono eliminate automaticamente dopo un mese",
|
||||
"prefs_users_title": "Gestisci gli utenti",
|
||||
"prefs_users_description": "Aggiungi/rimuovi utenti per i tuoi topic protetti qui. Tieni presente che nome utente e password sono memorizzati nella memoria locale del browser.",
|
||||
"prefs_users_table": "Tabella utenti",
|
||||
"prefs_users_add_button": "Aggiungi utente",
|
||||
"prefs_users_edit_button": "Modifica utente",
|
||||
"prefs_users_delete_button": "Elimina utente",
|
||||
"prefs_users_table_user_header": "Utente",
|
||||
"prefs_users_table_base_url_header": "URL del servizio",
|
||||
"prefs_users_dialog_title_add": "Aggiungi utente",
|
||||
"prefs_users_dialog_title_edit": "Modifica utente",
|
||||
"prefs_users_dialog_base_url_label": "URL del servizio, ad es. https://ntfy.sh",
|
||||
"prefs_users_dialog_username_label": "Nome utente, ad es. phil",
|
||||
"prefs_users_dialog_password_label": "Password",
|
||||
"prefs_users_dialog_button_cancel": "Annulla",
|
||||
"prefs_users_dialog_button_add": "Aggiungere",
|
||||
"prefs_users_dialog_button_save": "Salva",
|
||||
"prefs_appearance_title": "Aspetto",
|
||||
"prefs_appearance_language_title": "Lingua",
|
||||
"priority_min": "min",
|
||||
"priority_low": "basso",
|
||||
"priority_default": "predefinito",
|
||||
"priority_high": "alto",
|
||||
"priority_max": "max",
|
||||
"error_boundary_title": "Oh no, ntfy è andato in crash",
|
||||
"error_boundary_description": "Questo ovviamente non dovrebbe accadere. Mi dispiace molto per questo.<br/>Se hai un minuto, per favore <githubLink>segnala su GitHub</githubLink>, o faccelo sapere tramite <discordLink>Discord</discordLink> o <matrixLink>Matrix</matrixLink> .",
|
||||
"error_boundary_button_copy_stack_trace": "Copia traccia dello stack",
|
||||
"error_boundary_stack_trace": "Traccia dello stack",
|
||||
"error_boundary_gathering_info": "Raccogli più informazioni…",
|
||||
"error_boundary_unsupported_indexeddb_title": "Navigazione privata non supportata",
|
||||
"action_bar_show_menu": "Mostra menu",
|
||||
"action_bar_send_test_notification": "Inviare una notifica di prova",
|
||||
"alert_not_supported_description": "Le notifiche non sono supportate nel tuo browser.",
|
||||
"nav_button_documentation": "Documentazione",
|
||||
"notifications_actions_http_request_title": "Invia HTTP {{method}} a {{url}}",
|
||||
"alert_grant_description": "Concedi al tuo browser l'autorizzazione a visualizzare le notifiche sul desktop.",
|
||||
"alert_not_supported_title": "Notifiche non supportate",
|
||||
"notifications_attachment_file_app": "file app Android",
|
||||
"notifications_no_subscriptions_description": "Fai clic sul link \"{{linktext}}\" per creare o iscriverti a un topic. Successivamente, puoi inviare messaggi tramite PUT o POST e riceverai le notifiche qui.",
|
||||
"notifications_attachment_file_audio": "file audio",
|
||||
"notifications_none_for_any_description": "Per inviare notifiche a un topic, è sufficiente PUT o POST all'URL del topic. Ecco un esempio utilizzando uno dei tuoi topic.",
|
||||
"notifications_click_copy_url_title": "Copia l'URL del collegamento negli appunti",
|
||||
"prefs_notifications_sound_description_none": "Le notifiche non emettono alcun suono quando arrivano",
|
||||
"publish_dialog_delay_label": "Ritardo",
|
||||
"publish_dialog_tags_placeholder": "Elenco di tag separato da virgole, ad es. avviso, backup-srv1",
|
||||
"publish_dialog_click_placeholder": "URL che viene aperto quando si fa clic sulla notifica",
|
||||
"publish_dialog_attach_placeholder": "Allega file tramite URL, ad es. https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_chip_topic_label": "Cambia topic",
|
||||
"publish_dialog_details_examples_description": "Per esempi e una descrizione dettagliata di tutte le funzioni di invio, fare riferimento alla <docsLink>documentazione</docsLink>.",
|
||||
"publish_dialog_attached_file_filename_placeholder": "Nome file allegato",
|
||||
"emoji_picker_search_placeholder": "Cerca emoji",
|
||||
"subscribe_dialog_subscribe_description": "Gli argomenti potrebbero non essere protetti da password, quindi scegli un nome che non sia facile da indovinare. Una volta iscritto, puoi inviare le notifiche tramite PUT/POST.",
|
||||
"subscribe_dialog_subscribe_use_another_label": "Usa un altro server",
|
||||
"subscribe_dialog_login_password_label": "Password",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "Iscriviti",
|
||||
"prefs_notifications_sound_play": "Riproduci il suono selezionato",
|
||||
"prefs_notifications_min_priority_title": "Priorità minima",
|
||||
"subscribe_dialog_login_description": "Questo argomento è protetto da password. Per favore inserisci username e password per iscriverti.",
|
||||
"subscribe_dialog_login_button_back": "Indietro",
|
||||
"subscribe_dialog_error_user_not_authorized": "Utente {{username}} non autorizzato",
|
||||
"prefs_notifications_title": "Notifiche",
|
||||
"prefs_notifications_delete_after_title": "Elimina le notifiche",
|
||||
"prefs_notifications_min_priority_default_and_higher": "Priorità predefinita e superiore",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Mostra le notifiche se la priorità è {{number}} ({{name}}) o superiore",
|
||||
"prefs_notifications_delete_after_one_week": "Dopo una settimana",
|
||||
"prefs_notifications_delete_after_one_month": "Dopo un mese",
|
||||
"prefs_notifications_delete_after_three_hours_description": "Le notifiche vengono eliminate automaticamente dopo tre ore",
|
||||
"error_boundary_unsupported_indexeddb_description": "L'app web ntfy ha bisogno di IndexedDB per funzionare e il tuo browser non supporta IndexedDB in modalità di navigazione privata.<br/><br/>Anche se questo è un peccato, non ha molto senso usare il web ntfy app in modalità di navigazione privata comunque, perché tutto è archiviato nella memoria del browser. Puoi leggere di più a riguardo <githubLink>in questo numero di GitHub</githubLink> o parlarci su <discordLink>Discord</discordLink> o <matrixLink>Matrix</matrixLink>."
|
||||
}
|
||||
191
web/public/static/langs/ja.json
Normal file
@@ -0,0 +1,191 @@
|
||||
{
|
||||
"message_bar_error_publishing": "通知送信エラー",
|
||||
"nav_button_all_notifications": "全ての通知",
|
||||
"nav_button_settings": "設定",
|
||||
"notifications_click_open_button": "リンクを開く",
|
||||
"action_bar_send_test_notification": "テスト通知を送信",
|
||||
"action_bar_clear_notifications": "全ての通知を消去",
|
||||
"action_bar_unsubscribe": "購読解除",
|
||||
"nav_button_documentation": "ドキュメント",
|
||||
"alert_not_supported_description": "通知機能はこのブラウザではサポートされていません。",
|
||||
"notifications_copied_to_clipboard": "クリップボードにコピーしました",
|
||||
"notifications_example": "例",
|
||||
"publish_dialog_title_topic": "{{topic}}に送信",
|
||||
"publish_dialog_title_no_topic": "通知を送信",
|
||||
"publish_dialog_progress_uploading": "アップロード中…",
|
||||
"publish_dialog_progress_uploading_detail": "アップロード中 {{loaded}}/{{total}} ({{percent}}%) …",
|
||||
"publish_dialog_message_published": "通知を送信しました",
|
||||
"publish_dialog_title_label": "タイトル",
|
||||
"publish_dialog_filename_label": "ファイル名",
|
||||
"subscribe_dialog_login_description": "このトピックはログインする必要があります。ユーザー名とパスワードを入力してください。",
|
||||
"subscribe_dialog_login_username_label": "ユーザー名, 例) phil",
|
||||
"subscribe_dialog_login_password_label": "パスワード",
|
||||
"subscribe_dialog_login_button_back": "戻る",
|
||||
"subscribe_dialog_login_button_login": "ログイン",
|
||||
"prefs_notifications_min_priority_high_and_higher": "優先度高 およびそれ以上",
|
||||
"prefs_notifications_min_priority_max_only": "優先度最高のみ",
|
||||
"action_bar_settings": "設定",
|
||||
"message_bar_type_message": "メッセージを入力してください",
|
||||
"nav_topics_title": "購読しているトピック",
|
||||
"nav_button_subscribe": "トピックを購読",
|
||||
"alert_grant_description": "ブラウザのデスクトップ通知を許可してください。",
|
||||
"alert_grant_button": "許可する",
|
||||
"notifications_attachment_link_expires": "リンクは {{date}} に失効します",
|
||||
"notifications_click_copy_url_button": "リンクをコピー",
|
||||
"notifications_none_for_topic_description": "トピックに通知を送信するには、トピックのURLにPUTかPOSTしてください。",
|
||||
"nav_button_publish_message": "通知を送信",
|
||||
"alert_grant_title": "通知は無効化されています",
|
||||
"alert_not_supported_title": "通知機能はサポートされていません",
|
||||
"notifications_tags": "タグ",
|
||||
"notifications_attachment_copy_url_button": "URLをコピー",
|
||||
"notifications_attachment_open_title": "{{url}} に移動",
|
||||
"notifications_attachment_link_expired": "ダウンロードリンクは失効しました",
|
||||
"notifications_actions_open_url_title": "{{url}} に移動",
|
||||
"notifications_attachment_copy_url_title": "添付URLをクリップボードにコピー",
|
||||
"notifications_attachment_open_button": "添付ファイルを開く",
|
||||
"notifications_click_copy_url_title": "リンクURLをクリップボードにコピー",
|
||||
"notifications_none_for_topic_title": "このトピックではまだ通知を受信していません。",
|
||||
"notifications_no_subscriptions_description": "「{{linktext}}」リンクをクリックしてトピックを作成または購読してください。その後、メッセージをPUTまたはPOSTで送信すると通知が受信できます。",
|
||||
"publish_dialog_message_label": "メッセージ",
|
||||
"publish_dialog_email_label": "メール",
|
||||
"notifications_none_for_any_title": "まだ通知を受信していません。",
|
||||
"publish_dialog_priority_max": "優先度 最高",
|
||||
"publish_dialog_button_cancel_sending": "送信をキャンセル",
|
||||
"publish_dialog_attach_label": "添付URL",
|
||||
"notifications_none_for_any_description": "トピックに通知を送信するには、トピックURLにPUTまたはPOSTしてください。トピックのひとつを利用した例を示します。",
|
||||
"notifications_no_subscriptions_title": "まだ何も購読していないようです。",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "ファイル制限とクォータ {{fileSizeLimit}} を超えました、残り {{remainingBytes}}",
|
||||
"publish_dialog_priority_label": "優先度",
|
||||
"publish_dialog_click_label": "クリックURL",
|
||||
"publish_dialog_email_placeholder": "通知を転送するアドレス, 例) phil@example.com",
|
||||
"notifications_more_details": "詳しい情報は、<websiteLink>ウェブサイト</websiteLink> または <docsLink>ドキュメント</docsLink> を参照してください。",
|
||||
"publish_dialog_attachment_limits_file_reached": "ファイルサイズ制限 {{fileSizeLimit}} を超えました",
|
||||
"publish_dialog_priority_min": "優先度 最低",
|
||||
"publish_dialog_priority_low": "優先度 低",
|
||||
"publish_dialog_priority_default": "優先度 通常",
|
||||
"publish_dialog_base_url_label": "サービスURL",
|
||||
"publish_dialog_other_features": "他の機能:",
|
||||
"notifications_loading": "通知を読み込み中…",
|
||||
"publish_dialog_attachment_limits_quota_reached": "クォータを超過しました、残り{{remainingBytes}}",
|
||||
"publish_dialog_priority_high": "優先度 高",
|
||||
"publish_dialog_topic_placeholder": "トピック名の例 phil_alerts",
|
||||
"publish_dialog_title_placeholder": "通知タイトル 例: ディスクスペース警告",
|
||||
"publish_dialog_message_placeholder": "メッセージ本文を入力してください",
|
||||
"publish_dialog_tags_label": "タグ",
|
||||
"publish_dialog_tags_placeholder": "コンマ区切りでタグを列挙してください 例: warning, srv1-backup",
|
||||
"publish_dialog_topic_label": "トピック名",
|
||||
"publish_dialog_delay_label": "遅延",
|
||||
"publish_dialog_click_placeholder": "通知をクリックしたときに開くURL",
|
||||
"publish_dialog_filename_placeholder": "添付ファイルの名称",
|
||||
"publish_dialog_button_send": "送信",
|
||||
"publish_dialog_chip_click_label": "Click URL",
|
||||
"publish_dialog_chip_email_label": "メールに転送",
|
||||
"publish_dialog_details_examples_description": "送信機能の例や詳細な説明については、<docsLink>ドキュメント</docsLink>を参照してください。",
|
||||
"error_boundary_description": "明らかに起きてはならないことです。本当に申し訳ありません。<br/>もし時間があれば、<githubLink>GitHubにこれを報告</githubLink>するか、<discordLink>Discord</discordLink>または<matrixLink>Matrix</matrixLink>で我々に知らせて下さい。",
|
||||
"publish_dialog_chip_attach_url_label": "URLでファイルを添付",
|
||||
"publish_dialog_chip_attach_file_label": "ローカルファイルを添付",
|
||||
"publish_dialog_chip_topic_label": "トピックを変更",
|
||||
"publish_dialog_chip_delay_label": "配信を遅延させる",
|
||||
"publish_dialog_attached_file_title": "添付ファイル:",
|
||||
"publish_dialog_button_cancel": "キャンセル",
|
||||
"publish_dialog_checkbox_publish_another": "送信後開いたままにする",
|
||||
"publish_dialog_attached_file_filename_placeholder": "添付ファイル名",
|
||||
"emoji_picker_search_placeholder": "絵文字を検索",
|
||||
"subscribe_dialog_subscribe_title": "トピックを購読",
|
||||
"prefs_users_title": "ユーザー管理",
|
||||
"publish_dialog_drop_file_here": "ここにファイルをドロップしてください",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "トピック名 例: phils_alerts",
|
||||
"prefs_notifications_min_priority_any": "全ての優先度",
|
||||
"prefs_notifications_delete_after_three_hours": "3時間後",
|
||||
"prefs_users_description": "保護トピックのユーザーを追加/削除できます。ユーザー名とパスワードはブラウザのローカルストレージに保存されることに留意してください。",
|
||||
"prefs_users_add_button": "ユーザー追加",
|
||||
"prefs_users_dialog_button_add": "追加",
|
||||
"subscribe_dialog_subscribe_use_another_label": "他のサーバーを使用",
|
||||
"subscribe_dialog_error_user_not_authorized": "ユーザー名 {{username}} は許可されていません",
|
||||
"prefs_notifications_delete_after_one_week": "1週間後",
|
||||
"prefs_notifications_delete_after_one_month": "1か月後",
|
||||
"subscribe_dialog_subscribe_description": "トピックはパスワード保護されないので、推測されにくい名前にしてください。購読した後、PUT/POSTで通知を送信できます。",
|
||||
"subscribe_dialog_subscribe_button_cancel": "キャンセル",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "購読",
|
||||
"subscribe_dialog_login_title": "ログインが必要です",
|
||||
"subscribe_dialog_error_user_anonymous": "匿名",
|
||||
"prefs_notifications_title": "通知",
|
||||
"prefs_notifications_min_priority_low_and_higher": "優先度低 およびそれ以上",
|
||||
"prefs_notifications_delete_after_never": "削除しない",
|
||||
"prefs_notifications_delete_after_one_day": "1日後",
|
||||
"prefs_notifications_sound_title": "通知音",
|
||||
"prefs_notifications_sound_no_sound": "サウンドなし",
|
||||
"prefs_notifications_min_priority_title": "表示する優先度",
|
||||
"prefs_notifications_min_priority_default_and_higher": "優先度通常 およびそれ以上",
|
||||
"prefs_notifications_delete_after_title": "通知を削除",
|
||||
"prefs_users_dialog_button_cancel": "キャンセル",
|
||||
"prefs_users_dialog_button_save": "保存",
|
||||
"prefs_users_table_user_header": "ユーザー名",
|
||||
"prefs_users_dialog_title_add": "ユーザー追加",
|
||||
"prefs_users_dialog_title_edit": "ユーザー編集",
|
||||
"prefs_users_dialog_base_url_label": "サービスURL, 例) https://ntfy.sh",
|
||||
"prefs_appearance_title": "外観",
|
||||
"prefs_appearance_language_title": "言語",
|
||||
"prefs_users_table_base_url_header": "サービスURL",
|
||||
"prefs_users_dialog_username_label": "ユーザー名, 例) phil",
|
||||
"prefs_users_dialog_password_label": "パスワード",
|
||||
"error_boundary_title": "おっと、ntfyがクラッシュしました",
|
||||
"error_boundary_button_copy_stack_trace": "スタックトレースをコピー",
|
||||
"error_boundary_stack_trace": "スタックトレース",
|
||||
"error_boundary_gathering_info": "更に情報を集める…",
|
||||
"publish_dialog_base_url_placeholder": "サービスURL, 例) https://example.com",
|
||||
"publish_dialog_attach_placeholder": "添付ファイルをURLで指定, 例) https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_delay_placeholder": "遅延時間, 例) {{unixTimestamp}}, {{relativeTime}}, or \"{{naturalLanguage}}\" (English only)",
|
||||
"prefs_notifications_sound_description_none": "通知受信時に音を鳴らしません",
|
||||
"prefs_notifications_sound_description_some": "通知受信時に {{sound}} を鳴らします",
|
||||
"prefs_notifications_min_priority_description_any": "優先度に関係なく全ての通知を表示します",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "優先度が {{number}} ({{name}}) 以上の時に通知を表示します",
|
||||
"prefs_notifications_delete_after_never_description": "通知は自動的に削除されません",
|
||||
"prefs_notifications_delete_after_one_day_description": "通知は1日後に自動的に削除されます",
|
||||
"prefs_notifications_delete_after_one_week_description": "通知は1週間後に自動的に削除されます",
|
||||
"prefs_notifications_delete_after_one_month_description": "通知は1か月後に自動的に削除されます",
|
||||
"priority_high": "高",
|
||||
"priority_max": "最高",
|
||||
"prefs_notifications_min_priority_description_max": "優先度が 5 (最高) の時にのみ通知を表示します",
|
||||
"priority_default": "通常",
|
||||
"prefs_notifications_delete_after_three_hours_description": "通知は3時間後に自動的に削除されます",
|
||||
"priority_low": "低",
|
||||
"priority_min": "最低",
|
||||
"notifications_actions_not_supported": "このアクションはWebアプリではサポートされていません",
|
||||
"notifications_actions_http_request_title": "{{url}}にHTTP {{method}}を送信",
|
||||
"prefs_users_edit_button": "ユーザーを編集",
|
||||
"publish_dialog_attached_file_remove": "添付ファイルを削除",
|
||||
"error_boundary_unsupported_indexeddb_description": "nfty webアプリは動作にIndexedDBを使用しますが、あなたのブラウザはプライベートブラウジングモード時にIndexedDBをサポートしていません。<br/><br/>これは残念なことですが、ntfy webアプリは全ての情報をブラウザストレージに保存して動作するため、プライベートブラウジングモードで利用するのはあまり意味がないかも知れません。詳細については <githubLink>GitHub issue</githubLink>を参照するか、<discordLink>Discord</discordLink>や<matrixLink>Matrix</matrixLink>の議論に参加してください。",
|
||||
"action_bar_show_menu": "メニューを表示",
|
||||
"action_bar_logo_alt": "ntfyロゴ",
|
||||
"action_bar_toggle_mute": "通知をミュート/解除",
|
||||
"action_bar_toggle_action_menu": "動作メニューを開く/閉じる",
|
||||
"message_bar_show_dialog": "送信ダイアログを表示",
|
||||
"message_bar_publish": "メッセージを送信",
|
||||
"nav_button_muted": "ミュートされた通知",
|
||||
"nav_button_connecting": "接続中",
|
||||
"notifications_list": "通知一覧",
|
||||
"notifications_new_indicator": "新しい通知",
|
||||
"notifications_list_item": "通知",
|
||||
"notifications_mark_read": "既読にする",
|
||||
"notifications_delete": "削除",
|
||||
"notifications_priority_x": "優先度 {{priority}}",
|
||||
"notifications_attachment_image": "添付画像",
|
||||
"notifications_attachment_file_image": "画像ファイル",
|
||||
"notifications_attachment_file_video": "動画ファイル",
|
||||
"notifications_attachment_file_audio": "音声ファイル",
|
||||
"notifications_attachment_file_app": "Androidアプリファイル",
|
||||
"notifications_attachment_file_document": "その他文書",
|
||||
"publish_dialog_emoji_picker_show": "絵文字",
|
||||
"publish_dialog_topic_reset": "トピックをリセット",
|
||||
"publish_dialog_click_reset": "クリックURLを削除",
|
||||
"publish_dialog_email_reset": "メール転送を削除",
|
||||
"publish_dialog_attach_reset": "添付URLを削除",
|
||||
"publish_dialog_delay_reset": "配信遅延を削除",
|
||||
"emoji_picker_search_clear": "検索をクリア",
|
||||
"subscribe_dialog_subscribe_base_url_label": "サーバーURL",
|
||||
"prefs_notifications_sound_play": "選択されたサウンドを再生",
|
||||
"prefs_users_table": "ユーザー一覧",
|
||||
"prefs_users_delete_button": "ユーザーを削除",
|
||||
"error_boundary_unsupported_indexeddb_title": "プライベートブラウジングはサポートされていません"
|
||||
}
|
||||
126
web/public/static/langs/nb_NO.json
Normal file
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"nav_button_subscribe": "Abonner på emne",
|
||||
"action_bar_settings": "Innstillinger",
|
||||
"action_bar_send_test_notification": "Send testmerknad",
|
||||
"action_bar_clear_notifications": "Tøm alle merknader",
|
||||
"action_bar_unsubscribe": "Opphev abonnement",
|
||||
"message_bar_type_message": "Skriv en melding her",
|
||||
"nav_button_all_notifications": "Alle merknader",
|
||||
"nav_button_settings": "Innstillinger",
|
||||
"nav_button_documentation": "Dokumentasjon",
|
||||
"nav_topics_title": "Abonnerte emner",
|
||||
"alert_grant_title": "Merknader er avskrudd",
|
||||
"alert_not_supported_title": "Merknader støttes ikke",
|
||||
"notifications_copied_to_clipboard": "Kopiert til utklippstavlen",
|
||||
"notifications_attachment_copy_url_title": "Kopier vedleggsnettadresse til utklippstavlen",
|
||||
"notifications_attachment_copy_url_button": "Kopier nettadresse",
|
||||
"notifications_attachment_open_button": "Åpne vedlegg",
|
||||
"notifications_attachment_open_title": "Gå til {{url}}",
|
||||
"notifications_attachment_link_expires": "lenken utløper {{date}}",
|
||||
"notifications_click_copy_url_title": "Kopier lenke-nettadresse til utklippstavlen",
|
||||
"notifications_actions_open_url_title": "Gå til {{url}}",
|
||||
"notifications_tags": "Etiketter",
|
||||
"notifications_attachment_link_expired": "nedlastingslenken har utløpt",
|
||||
"notifications_none_for_any_title": "Du har ikke mottatt noen merknader.",
|
||||
"notifications_click_open_button": "Åpne lenke",
|
||||
"notifications_none_for_topic_title": "Du har ikke mottatt noen merknader for dette emnet enda.",
|
||||
"notifications_example": "Eksempel",
|
||||
"publish_dialog_title_topic": "Publiser til {{topic}}",
|
||||
"publish_dialog_priority_min": "Min. prioritet",
|
||||
"publish_dialog_priority_low": "Lav prioritet",
|
||||
"publish_dialog_priority_default": "Forvalgt prioritet",
|
||||
"publish_dialog_priority_high": "Høy prioritet",
|
||||
"publish_dialog_priority_max": "Maks. prioritet",
|
||||
"publish_dialog_base_url_label": "Tjeneste-nettadresse",
|
||||
"publish_dialog_message_label": "Melding",
|
||||
"publish_dialog_priority_label": "Prioritet",
|
||||
"publish_dialog_tags_label": "Etiketter",
|
||||
"publish_dialog_click_placeholder": "Nettadresse som åpnes når merknaden klikkes",
|
||||
"publish_dialog_attach_label": "Vedleggs-nettadresse",
|
||||
"publish_dialog_attach_placeholder": "Legg ved fil per nettadresse, f.eks. https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_filename_label": "Filnavn",
|
||||
"publish_dialog_delay_label": "Forsinkelse",
|
||||
"publish_dialog_filename_placeholder": "Vedleggets filnavn",
|
||||
"publish_dialog_other_features": "Andre funksjoner:",
|
||||
"publish_dialog_chip_email_label": "Videresend til e-post",
|
||||
"publish_dialog_chip_topic_label": "Endre emne",
|
||||
"publish_dialog_button_cancel_sending": "Avbryt forsendelse",
|
||||
"publish_dialog_chip_attach_file_label": "Legg ved lokal fil",
|
||||
"publish_dialog_attached_file_title": "Vedlagt fil:",
|
||||
"publish_dialog_attached_file_filename_placeholder": "Vedleggsfilnavn",
|
||||
"subscribe_dialog_subscribe_use_another_label": "Bruk en annen tjener",
|
||||
"subscribe_dialog_subscribe_button_cancel": "Avbryt",
|
||||
"publish_dialog_drop_file_here": "Slipp filen her",
|
||||
"subscribe_dialog_subscribe_title": "Abonner på emne",
|
||||
"emoji_picker_search_placeholder": "Søk etter emoji",
|
||||
"subscribe_dialog_login_button_login": "Logg inn",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "Abonner",
|
||||
"subscribe_dialog_login_title": "Innlogging kreves",
|
||||
"subscribe_dialog_login_username_label": "Brukernavn, f.eks. phil",
|
||||
"subscribe_dialog_login_password_label": "Passord",
|
||||
"prefs_notifications_title": "Merknader",
|
||||
"prefs_notifications_sound_title": "Merknadslyd",
|
||||
"prefs_notifications_sound_no_sound": "Ingen lyd",
|
||||
"subscribe_dialog_error_user_anonymous": "anonym",
|
||||
"error_boundary_stack_trace": "Stabelspor",
|
||||
"error_boundary_button_copy_stack_trace": "Kopier stabelspor",
|
||||
"message_bar_error_publishing": "Kunne ikke publisere merknader",
|
||||
"nav_button_publish_message": "Publiser merknad",
|
||||
"publish_dialog_title_no_topic": "Publiser merknad",
|
||||
"publish_dialog_progress_uploading": "Laster opp …",
|
||||
"publish_dialog_progress_uploading_detail": "Laster opp {{loaded}}/{{total}} ({{percent}}%) …",
|
||||
"notifications_loading": "Laster inn merknader …",
|
||||
"publish_dialog_message_published": "Merknad publisert",
|
||||
"publish_dialog_email_placeholder": "Adresse å videresende merknaden til, f.eks. phil@example.com",
|
||||
"error_boundary_gathering_info": "Hent mer info …",
|
||||
"prefs_notifications_sound_description_some": "Merknader spiller {{sound}}-lyd når de mottas",
|
||||
"prefs_notifications_min_priority_description_any": "Viser aller merknader, uavhengig av prioritet",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Vis merknader hvis prioritet er {{number}} ({{name}}) eller høyere",
|
||||
"prefs_notifications_min_priority_high_and_higher": "Høy prioritet og høyere",
|
||||
"prefs_notifications_min_priority_max_only": "Kun maks. prioritet",
|
||||
"prefs_notifications_delete_after_one_day": "Etter én dag",
|
||||
"prefs_notifications_delete_after_one_week": "Etter én uke",
|
||||
"prefs_notifications_delete_after_one_month": "Etter én måned",
|
||||
"prefs_notifications_delete_after_never_description": "Merknader blir aldri slettet automatisk",
|
||||
"prefs_notifications_delete_after_three_hours_description": "Merknader slettes automatisk etter tre timer",
|
||||
"prefs_users_title": "Håndter brukere",
|
||||
"prefs_users_add_button": "Legg til bruker",
|
||||
"prefs_users_table_user_header": "Bruker",
|
||||
"prefs_users_dialog_title_add": "Legg til bruker",
|
||||
"prefs_users_dialog_title_edit": "Rediger bruker",
|
||||
"prefs_users_dialog_base_url_label": "Tjeneste-nettadresse, f.eks. https://ntfy.sh",
|
||||
"prefs_users_dialog_password_label": "Passord",
|
||||
"prefs_users_dialog_button_save": "Lagre",
|
||||
"prefs_appearance_title": "Utseende",
|
||||
"prefs_appearance_language_title": "Språk",
|
||||
"prefs_users_dialog_username_label": "Brukernavn, f.eks. phil",
|
||||
"priority_low": "lav",
|
||||
"priority_default": "forvalg",
|
||||
"priority_high": "høy",
|
||||
"priority_max": "maks.",
|
||||
"alert_grant_button": "Innvilg nå",
|
||||
"publish_dialog_topic_label": "Emnenavn",
|
||||
"prefs_notifications_delete_after_one_day_description": "Merknader slettes automatisk etter én dag",
|
||||
"notifications_click_copy_url_button": "Kopier lenke",
|
||||
"error_boundary_title": "Oida. Ntfy krasjet.",
|
||||
"publish_dialog_message_placeholder": "Skriv en melding her",
|
||||
"publish_dialog_button_cancel": "Avbryt",
|
||||
"prefs_notifications_min_priority_title": "Minimumsprioritet",
|
||||
"prefs_notifications_delete_after_title": "Slett merknader",
|
||||
"prefs_notifications_delete_after_never": "Aldri",
|
||||
"publish_dialog_email_label": "E-post",
|
||||
"publish_dialog_button_send": "Send",
|
||||
"prefs_notifications_delete_after_one_week_description": "Merknader slettes automatisk etter én uke",
|
||||
"prefs_notifications_delete_after_one_month_description": "Merknader slettes automatisk etter én måned",
|
||||
"priority_min": "min.",
|
||||
"subscribe_dialog_login_button_back": "Tilbake",
|
||||
"prefs_notifications_delete_after_three_hours": "Etter tre timer",
|
||||
"prefs_users_table_base_url_header": "Tjeneste-nettadresse",
|
||||
"prefs_users_dialog_button_cancel": "Avbryt",
|
||||
"prefs_users_dialog_button_add": "Legg til",
|
||||
"publish_dialog_chip_attach_url_label": "Legg ved fil per nettadresse",
|
||||
"publish_dialog_tags_placeholder": "Kommainndelt liste over etiketter, f.eks. advarsel, srv1-sikkerhetskopi",
|
||||
"prefs_notifications_sound_description_none": "Merknader er lydløse når de mottas",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "Emnenavn, f.eks. phil_varsler",
|
||||
"prefs_notifications_min_priority_default_and_higher": "Forvalgt prioritet og høyere"
|
||||
}
|
||||
191
web/public/static/langs/nl.json
Normal file
@@ -0,0 +1,191 @@
|
||||
{
|
||||
"action_bar_settings": "Instellingen",
|
||||
"action_bar_send_test_notification": "Stuur test notificatie",
|
||||
"action_bar_clear_notifications": "Wis alle notificaties",
|
||||
"message_bar_type_message": "Typ hier een bericht",
|
||||
"action_bar_unsubscribe": "Afmelden",
|
||||
"message_bar_error_publishing": "Fout bij publiceren notificatie",
|
||||
"nav_topics_title": "Geabonneerde onderwerpen",
|
||||
"nav_button_settings": "Instellingen",
|
||||
"alert_not_supported_description": "Notificaties worden niet ondersteund in je browser.",
|
||||
"notifications_none_for_any_title": "Je hebt nog geen notificaties ontvangen.",
|
||||
"publish_dialog_tags_label": "Tags",
|
||||
"publish_dialog_chip_attach_file_label": "Lokaal bestand bijvoegen",
|
||||
"prefs_users_dialog_title_edit": "Gebruiker bewerken",
|
||||
"error_boundary_title": "Oh nee, ntfy is vastgelopen",
|
||||
"error_boundary_description": "Dit hoort natuurlijk niet te gebeuren. Onze excuses.<br/>Wanneer het mogelijk is, <githubLink>meld deze fout op GitHub</githubLink>, of laat het ons weten via <discordLink>Discord</discordLink> of <matrixLink>Matrix</matrixLink>.",
|
||||
"error_boundary_button_copy_stack_trace": "Stack trace kopiëren",
|
||||
"error_boundary_stack_trace": "Stacktrace",
|
||||
"error_boundary_gathering_info": "Meer informatie verzamelen …",
|
||||
"prefs_users_delete_button": "Gebruiker verwijderen",
|
||||
"prefs_notifications_delete_after_one_week": "Na één week",
|
||||
"prefs_notifications_delete_after_one_month": "Na één maand",
|
||||
"prefs_users_dialog_title_add": "Gebruiker toevoegen",
|
||||
"prefs_users_dialog_password_label": "Wachtwoord",
|
||||
"error_boundary_unsupported_indexeddb_description": "De ntfy web applicatie heeft IndexedDB nodig om correct te kunnen functioneren, helaas ondersteund jouw browser IndexedDB niet in privé / incognito modus.<br/><br/>Dit is jammer maar het is ook onlogisch om de ntfy web applicatie in privé / incognito modus te gebruiken want alle gegevens worden bewaard in de browser zijn lokale opslag. Je kan hier meer over lezen <githubLink>in deze GitHub issue</githubLink>, of praat met ons op <discordLink>Discord</discordLink> of <matrixLink>Matrix</matrixLink>.",
|
||||
"action_bar_show_menu": "Toon menu",
|
||||
"action_bar_logo_alt": "ntfy logo",
|
||||
"action_bar_toggle_mute": "Notificaties dempen/opheffen",
|
||||
"action_bar_toggle_action_menu": "Actie menu openen/sluiten",
|
||||
"message_bar_show_dialog": "Toon publicatie venster",
|
||||
"message_bar_publish": "Bericht publiceren",
|
||||
"nav_button_all_notifications": "Alle notificaties",
|
||||
"nav_button_documentation": "Documentatie",
|
||||
"nav_button_publish_message": "Notificatie publiceren",
|
||||
"nav_button_subscribe": "Onderwerp abonneren",
|
||||
"nav_button_muted": "Notificaties gedempt",
|
||||
"nav_button_connecting": "verbinden",
|
||||
"alert_grant_title": "Notificaties zijn uitgeschakeld",
|
||||
"alert_grant_description": "Geef je browser toestemming om meldingen weer te geven.",
|
||||
"alert_grant_button": "Nu toestaan",
|
||||
"alert_not_supported_title": "Notificaties zijn niet ondersteund",
|
||||
"notifications_list": "Notificaties lijst",
|
||||
"notifications_list_item": "Notificatie",
|
||||
"notifications_mark_read": "Markeer als gelezen",
|
||||
"notifications_delete": "Verwijder",
|
||||
"notifications_copied_to_clipboard": "Gekopieerd naar klembord",
|
||||
"notifications_tags": "Tags",
|
||||
"notifications_priority_x": "Prioriteit {{priority}}",
|
||||
"notifications_new_indicator": "Nieuwe notificatie",
|
||||
"notifications_attachment_image": "Afbeelding bijlage",
|
||||
"notifications_attachment_copy_url_title": "Kopieer URL van bijlage naar klembord",
|
||||
"notifications_attachment_copy_url_button": "URL kopiëren",
|
||||
"notifications_attachment_open_title": "Ga naar {{url}}",
|
||||
"notifications_attachment_open_button": "Bijlage openen",
|
||||
"notifications_attachment_link_expires": "link vervalt op {{date}}",
|
||||
"notifications_attachment_link_expired": "download link is verlopen",
|
||||
"notifications_attachment_file_image": "afbeeldingsbestand",
|
||||
"notifications_attachment_file_video": "videobestand",
|
||||
"notifications_attachment_file_audio": "audiobestand",
|
||||
"notifications_attachment_file_app": "Android app bestand",
|
||||
"notifications_attachment_file_document": "overig document",
|
||||
"notifications_click_copy_url_title": "URL naar klembord kopiëren",
|
||||
"notifications_click_copy_url_button": "Link kopiëren",
|
||||
"notifications_click_open_button": "Link openen",
|
||||
"notifications_none_for_topic_description": "Om notificaties naar dit onderwerp te sturen, doe een PUT of POST naar het onderwerp URL.",
|
||||
"notifications_none_for_any_description": "Om notificaties naar dit onderwerp te sturen, doe een PUT of POST naar het onderwerp URL. Hier is een voorbeeld met één van je onderwerpen.",
|
||||
"notifications_no_subscriptions_title": "Het lijkt erop dat je nog op geen onderwerpen geabonneerd bent.",
|
||||
"notifications_no_subscriptions_description": "Klik op de \"{{linktext}}\" link om een onderwerp te maken of erop te abonneren. Daarna kan je berichten sturen via PUT of POST and ontvang je hier notificaties.",
|
||||
"notifications_example": "Voorbeeld",
|
||||
"notifications_more_details": "Voor meer informatie, bezoek de <websiteLink>website</websiteLink> of <docsLink>documentatie</docsLink>.",
|
||||
"notifications_loading": "Notificaties laden …",
|
||||
"publish_dialog_title_topic": "Publiceren naar {{topic}}",
|
||||
"publish_dialog_title_no_topic": "Notificatie publiceren",
|
||||
"publish_dialog_progress_uploading": "Uploaden …",
|
||||
"notifications_actions_open_url_title": "Ga naar {{url}}",
|
||||
"notifications_actions_not_supported": "Deze actie is niet ondersteund in de web applicatie",
|
||||
"notifications_actions_http_request_title": "Stuur HTTP {{method}} naar {{url}}",
|
||||
"notifications_none_for_topic_title": "Je hebt nog geen notificaties ontvangen voor dit onderwerp.",
|
||||
"publish_dialog_priority_low": "Lage prioriteit",
|
||||
"publish_dialog_progress_uploading_detail": "Uploaden {{loaded}}/{{total}} ({{percent}}%) …",
|
||||
"publish_dialog_message_published": "Notificatie gepubliceerd",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "overschrijd {{fileSizeLimit}} bestandslimiet en quotum, {{remainingBytes}} resterend",
|
||||
"publish_dialog_attachment_limits_file_reached": "overschrijd {{fileSizeLimit}} bestandslimiet",
|
||||
"publish_dialog_priority_default": "Standaard prioriteit",
|
||||
"publish_dialog_attachment_limits_quota_reached": "overschrijd quotum, {{remainingBytes}} resterend",
|
||||
"publish_dialog_emoji_picker_show": "Kies een emoji",
|
||||
"publish_dialog_priority_high": "Hoge prioriteit",
|
||||
"publish_dialog_priority_max": "Maximale prioriteit",
|
||||
"publish_dialog_priority_min": "Minimale prioriteit",
|
||||
"publish_dialog_base_url_label": "Service URL",
|
||||
"publish_dialog_base_url_placeholder": "Service URL, bijvoorbeeld: https://voorbeeld.com",
|
||||
"publish_dialog_topic_label": "Onderwerp",
|
||||
"publish_dialog_topic_placeholder": "Onderwerp, bijv. phil_alerts",
|
||||
"publish_dialog_topic_reset": "Onderwerp resetten",
|
||||
"publish_dialog_title_label": "Titel",
|
||||
"publish_dialog_title_placeholder": "Notificatie titel , bijv. Schijfruimte alarm",
|
||||
"publish_dialog_message_label": "Bericht",
|
||||
"publish_dialog_message_placeholder": "Typ hier een bericht",
|
||||
"publish_dialog_tags_placeholder": "Komma gescheiden lijst met tags, bijv. waarschuwing, srv1-backup",
|
||||
"publish_dialog_priority_label": "Prioriteit",
|
||||
"publish_dialog_click_label": "Klik URL",
|
||||
"publish_dialog_click_reset": "Verwijder klik URL",
|
||||
"publish_dialog_email_label": "Email",
|
||||
"publish_dialog_email_placeholder": "Adres om de notificatie naar door te sturen, bijv. phil@voorbeeld.com",
|
||||
"publish_dialog_email_reset": "Email doorsturen verwijderen",
|
||||
"publish_dialog_attach_label": "URL van bijlage",
|
||||
"publish_dialog_click_placeholder": "URL die geopend zal worden wanneer op de notificatie geklikt wordt",
|
||||
"publish_dialog_attach_placeholder": "Bestand bijvoegen via URL, bijv. https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_attach_reset": "Bijlage URL verwijderen",
|
||||
"publish_dialog_filename_label": "Bestandsnaam",
|
||||
"publish_dialog_filename_placeholder": "Bestandsnaam van bijlage",
|
||||
"publish_dialog_delay_label": "Uitstellen",
|
||||
"publish_dialog_delay_placeholder": "Bezorging uitstellen, bijv. {{unixTimestamp}}, {{relativeTime}}, of \"{{naturalLanguage}}\" (alleen Engels)",
|
||||
"publish_dialog_delay_reset": "Verwijder uitgestelde bezorging",
|
||||
"publish_dialog_other_features": "Andere functionaliteiten:",
|
||||
"publish_dialog_chip_click_label": "Klik URL",
|
||||
"publish_dialog_chip_email_label": "Doorsturen naar email",
|
||||
"publish_dialog_chip_attach_url_label": "Bestand bijvoegen via URL",
|
||||
"publish_dialog_chip_delay_label": "Uitgestelde bezorging",
|
||||
"publish_dialog_chip_topic_label": "Onderwerp veranderen",
|
||||
"publish_dialog_details_examples_description": "Voor meer voorbeelden en gedetailleerde beschrijvingen van alle functionaliteiten, bekijk de <docsLink>documentatie</docsLink>.",
|
||||
"publish_dialog_button_cancel_sending": "Versturen annuleren",
|
||||
"publish_dialog_button_cancel": "Annuleer",
|
||||
"publish_dialog_button_send": "Verstuur",
|
||||
"publish_dialog_checkbox_publish_another": "Nog een bericht versturen",
|
||||
"publish_dialog_attached_file_title": "Bijgevoegd bestand:",
|
||||
"publish_dialog_attached_file_filename_placeholder": "Bijlage bestandsnaam",
|
||||
"publish_dialog_attached_file_remove": "Verwijder bijgevoegd bestand",
|
||||
"publish_dialog_drop_file_here": "Bestand hier slepen",
|
||||
"emoji_picker_search_placeholder": "Emoji zoeken",
|
||||
"emoji_picker_search_clear": "Zoeken leegmaken",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "Onderwerp naam, bijv. phils_waarschuwingen",
|
||||
"subscribe_dialog_subscribe_use_another_label": "Gebruik een andere server",
|
||||
"subscribe_dialog_subscribe_base_url_label": "Service URL",
|
||||
"subscribe_dialog_subscribe_button_cancel": "Annuleren",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "Abonneren",
|
||||
"subscribe_dialog_login_title": "Aanmelding vereist",
|
||||
"subscribe_dialog_login_description": "Dit onderwerp is beveiligd met een wachtwoord. Geef een gebruikersnaam en wachtwoord op om te abonneren.",
|
||||
"subscribe_dialog_login_username_label": "Gebruikersnaam, bijv. phil",
|
||||
"subscribe_dialog_subscribe_title": "Onderwerp abonneren",
|
||||
"subscribe_dialog_subscribe_description": "Onderwerpen zijn mogelijk niet beschermd met een wachtwoord, kies daarom een moeilijk te raden naam. Na abonneren kun je notificaties via PUT/POST sturen.",
|
||||
"subscribe_dialog_login_password_label": "Wachtwoord",
|
||||
"subscribe_dialog_login_button_back": "Terug",
|
||||
"subscribe_dialog_login_button_login": "Aanmelden",
|
||||
"subscribe_dialog_error_user_not_authorized": "Gebruiker {{username}} heeft geen toegang",
|
||||
"subscribe_dialog_error_user_anonymous": "anoniem",
|
||||
"prefs_notifications_title": "Notificaties",
|
||||
"prefs_notifications_sound_title": "Meldingsgeluid",
|
||||
"prefs_notifications_sound_description_none": "Notificaties zullen geen geluid geven",
|
||||
"prefs_notifications_sound_play": "Geselecteerd geluid afspelen",
|
||||
"prefs_notifications_sound_description_some": "Inkomende notificaties zullen het {{sound}} afspelen",
|
||||
"prefs_notifications_sound_no_sound": "Geen geluid",
|
||||
"prefs_notifications_min_priority_title": "Minimale prioriteit",
|
||||
"prefs_notifications_min_priority_description_any": "Toon alle notificaties, ongeacht prioriteit",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Toon notificaties als prioriteit is {{number}} ({{name}}) of hoger",
|
||||
"prefs_notifications_min_priority_description_max": "Toon notificaties als prioriteit is 5 (maximaal)",
|
||||
"prefs_notifications_min_priority_any": "Elke prioriteit",
|
||||
"prefs_notifications_min_priority_low_and_higher": "Lage prioriteit en hoger",
|
||||
"prefs_notifications_min_priority_default_and_higher": "Standaard prioriteit en hoger",
|
||||
"prefs_notifications_min_priority_high_and_higher": "Hoge prioriteit en hoger",
|
||||
"prefs_notifications_min_priority_max_only": "Alleen maximale prioriteit",
|
||||
"prefs_notifications_delete_after_title": "Notificaties verwijderen",
|
||||
"prefs_notifications_delete_after_never": "Nooit",
|
||||
"prefs_notifications_delete_after_three_hours": "Na drie uur",
|
||||
"prefs_notifications_delete_after_one_day": "Na één dag",
|
||||
"prefs_notifications_delete_after_never_description": "Notificaties worden nooit automatisch verwijderd",
|
||||
"prefs_notifications_delete_after_three_hours_description": "Notificaties worden na drie uur automatisch verwijderd",
|
||||
"prefs_notifications_delete_after_one_day_description": "Notificaties worden na één dag automatisch verwijderd",
|
||||
"prefs_notifications_delete_after_one_week_description": "Notificaties worden na één week automatisch verwijderd",
|
||||
"prefs_notifications_delete_after_one_month_description": "Notificaties worden na één maand automatisch verwijderd",
|
||||
"prefs_users_title": "Gebruikers beheren",
|
||||
"prefs_users_description": "Gebruikers voor beveiligde onderwerpen kunnen hier toegevoegd of verwijderd worden. Let op: gebruikersnaam en wachtwoord worden opgeslagen in lokale browser opslag.",
|
||||
"prefs_users_table": "Gebruikerstabel",
|
||||
"prefs_users_add_button": "Gebruiker toevoegen",
|
||||
"prefs_users_edit_button": "Gebruiker bewerken",
|
||||
"prefs_users_table_user_header": "Gebruiker",
|
||||
"prefs_users_table_base_url_header": "Service URL",
|
||||
"prefs_users_dialog_base_url_label": "Service URL, bijv. https://ntfy.sh",
|
||||
"prefs_users_dialog_username_label": "Gebruikersnaam, bijv. phil",
|
||||
"prefs_users_dialog_button_cancel": "Annuleren",
|
||||
"prefs_users_dialog_button_add": "Toevoegen",
|
||||
"prefs_users_dialog_button_save": "Bewaren",
|
||||
"prefs_appearance_title": "Weergave",
|
||||
"prefs_appearance_language_title": "Taal",
|
||||
"priority_min": "min",
|
||||
"priority_low": "laag",
|
||||
"priority_default": "standaard",
|
||||
"priority_high": "hoog",
|
||||
"priority_max": "max",
|
||||
"error_boundary_unsupported_indexeddb_title": "Privé / incognito browservensters worden niet ondersteund"
|
||||
}
|
||||
192
web/public/static/langs/pt_BR.json
Normal file
@@ -0,0 +1,192 @@
|
||||
{
|
||||
"notifications_attachment_open_button": "Abrir anexo",
|
||||
"action_bar_clear_notifications": "Limpar todas as notificações",
|
||||
"action_bar_unsubscribe": "Desinscrever",
|
||||
"message_bar_type_message": "Escreva uma mensagem aqui",
|
||||
"message_bar_error_publishing": "Erro ao publicar notificação",
|
||||
"nav_button_all_notifications": "Todas notificações",
|
||||
"nav_button_settings": "Configurações",
|
||||
"nav_button_subscribe": "Inscrever no tópico",
|
||||
"alert_grant_title": "Notificações estão desativadas",
|
||||
"alert_grant_description": "Conceder ao navegador permissão para mostrar notificações.",
|
||||
"alert_grant_button": "Conceder agora",
|
||||
"alert_not_supported_title": "Notificações não são suportadas",
|
||||
"alert_not_supported_description": "Notificações não são suportadas pelo seu navagador.",
|
||||
"notifications_copied_to_clipboard": "Copiado para a área de transferência",
|
||||
"notifications_tags": "Etiquetas",
|
||||
"notifications_attachment_copy_url_title": "Copiar URL do anexo para a área de transferência",
|
||||
"notifications_click_copy_url_title": "Copiar URL do link para a área de transferência",
|
||||
"notifications_click_copy_url_button": "Copiar link",
|
||||
"notifications_click_open_title": "Ir para {{url}}",
|
||||
"notifications_click_open_button": "Abrir link",
|
||||
"notifications_none_for_topic_title": "Você ainda não recebeu nenhuma notificação para esse tópico.",
|
||||
"notifications_none_for_topic_description": "Para enviar notificações para esse tópico, basta usar os métodos PUT ou POST na URL do tópico.",
|
||||
"notifications_none_for_any_title": "Você ainda não recebeu nenhuma notificação.",
|
||||
"notifications_none_for_any_description": "Para enviar notificações a um tópico, basta usar os métodos PUT ou POST para o URL do tópico. Aqui um exemplo usando um dos seus tópicos.",
|
||||
"notifications_no_subscriptions_title": "Parece que você não tem nenhuma inscrição ainda.",
|
||||
"notifications_no_subscriptions_description": "Clique no link \"{{linktext}}\" para criar ou inscrever em um tópico. Depois disso, poderá enviar mensagens via PUT ou POST e você receberá notificações aqui.",
|
||||
"action_bar_settings": "Configurações",
|
||||
"action_bar_send_test_notification": "Enviar notificação de teste",
|
||||
"nav_button_documentation": "Documentação",
|
||||
"nav_button_publish_message": "Publicar notificação",
|
||||
"nav_topics_title": "Tópicos inscritos",
|
||||
"notifications_attachment_open_title": "Ir para {{url}}",
|
||||
"notifications_attachment_link_expires": "link expira em {{date}}",
|
||||
"notifications_attachment_copy_url_button": "Copiar URL",
|
||||
"notifications_attachment_link_expired": "link para transferência expirado",
|
||||
"notifications_example": "Exemplo",
|
||||
"notifications_more_details": "Para mais informações, confira <websiteLink>site</websiteLink> ou <docsLink>documentação</docsLink>.",
|
||||
"notifications_loading": "Carregando notificações…",
|
||||
"subscribe_dialog_error_user_anonymous": "anônimo",
|
||||
"prefs_notifications_delete_after_three_hours": "Após três horas",
|
||||
"prefs_notifications_delete_after_one_day": "Após um dia",
|
||||
"prefs_notifications_delete_after_one_week": "Após uma semana",
|
||||
"prefs_notifications_delete_after_one_month": "Após um mês",
|
||||
"notifications_actions_not_supported": "Ação não suportada no aplicativo web",
|
||||
"notifications_actions_http_request_title": "Enviar HTTP {{method}} para {{url}}",
|
||||
"notifications_actions_open_url_title": "Ir para {{url}}",
|
||||
"publish_dialog_title_topic": "Publicar em {{topic}}",
|
||||
"publish_dialog_title_no_topic": "Publicar notificação",
|
||||
"publish_dialog_progress_uploading": "Enviando …",
|
||||
"publish_dialog_progress_uploading_detail": "Fazendo upload de {{loaded}}/{{total}} ({{percent}}%)…",
|
||||
"publish_dialog_message_published": "Notificação publicada",
|
||||
"publish_dialog_attachment_limits_file_reached": "excede o limite de arquivo {{fileSizeLimit}}",
|
||||
"publish_dialog_priority_min": "Prioridade mínima",
|
||||
"publish_dialog_priority_low": "Baixa prioridade",
|
||||
"publish_dialog_priority_default": "Prioridade padrão",
|
||||
"publish_dialog_base_url_label": "URL de serviço",
|
||||
"publish_dialog_base_url_placeholder": "URL de serviço, por exemplo https://example.com",
|
||||
"publish_dialog_topic_label": "Nome do tópico",
|
||||
"publish_dialog_topic_placeholder": "Nome do tópico, por exemplo, phil_alerts",
|
||||
"publish_dialog_title_label": "Título",
|
||||
"publish_dialog_title_placeholder": "Título da notificação, por exemplo Alerta de espaço em disco",
|
||||
"publish_dialog_message_label": "Mensagem",
|
||||
"publish_dialog_message_placeholder": "Digite uma mensagem aqui",
|
||||
"publish_dialog_tags_label": "Etiquetas",
|
||||
"publish_dialog_tags_placeholder": "Lista de etiquetas, separadas por vírgula, por exemplo: srv1-backup",
|
||||
"publish_dialog_priority_label": "Prioridade",
|
||||
"publish_dialog_click_label": "Clique em URL",
|
||||
"publish_dialog_click_placeholder": "URL que é aberto quando a notificação é clicada",
|
||||
"publish_dialog_email_label": "Email",
|
||||
"publish_dialog_email_placeholder": "Email para encaminhar a notificação, por exemplo phil@example.com",
|
||||
"publish_dialog_filename_label": "Nome do arquivo",
|
||||
"publish_dialog_filename_placeholder": "Nome do arquivo anexado",
|
||||
"publish_dialog_delay_label": "Atraso",
|
||||
"publish_dialog_delay_placeholder": "Atraso na entrega, por exemplo {{{unixTimestamp}}, {{relativeTime}}, ou \"{{naturalLanguage}}\" (apenas em inglês)",
|
||||
"publish_dialog_other_features": "Outros recursos:",
|
||||
"publish_dialog_chip_click_label": "Clique em URL",
|
||||
"publish_dialog_chip_attach_file_label": "Anexar arquivo local",
|
||||
"publish_dialog_chip_delay_label": "Atraso na entrega",
|
||||
"publish_dialog_chip_topic_label": "Alterar tópico",
|
||||
"publish_dialog_button_cancel_sending": "Cancelar o envio",
|
||||
"publish_dialog_attached_file_filename_placeholder": "Nome do arquivo anexado",
|
||||
"publish_dialog_drop_file_here": "Solte o arquivo aqui",
|
||||
"emoji_picker_search_placeholder": "Pesquisar emoji",
|
||||
"subscribe_dialog_subscribe_title": "Inscrever no tópico",
|
||||
"subscribe_dialog_subscribe_use_another_label": "Usar outro servidor",
|
||||
"subscribe_dialog_subscribe_description": "Os tópicos podem não ser protegidos por senha, então escolha um nome que não seja fácil de adivinhar. Uma vez inscrito, você pode PUT/POST notificações.",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "Nome do tópico, por exemplo phil_alerts",
|
||||
"subscribe_dialog_subscribe_button_cancel": "Cancelar",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "Inscrever",
|
||||
"prefs_notifications_min_priority_description_max": "Mostrar notificações se prioridade for 5 (máxima)",
|
||||
"prefs_notifications_min_priority_any": "Qualquer prioridade",
|
||||
"prefs_notifications_min_priority_low_and_higher": "Baixa prioridade e acima",
|
||||
"prefs_notifications_min_priority_default_and_higher": "Prioridade padrão e acima",
|
||||
"subscribe_dialog_login_password_label": "Senha",
|
||||
"subscribe_dialog_login_button_back": "Voltar",
|
||||
"prefs_notifications_min_priority_high_and_higher": "Alta prioridade e acima",
|
||||
"prefs_notifications_min_priority_max_only": "Apenas prioridade máxima",
|
||||
"prefs_notifications_delete_after_title": "Apagar notificações",
|
||||
"prefs_notifications_delete_after_never": "Nunca",
|
||||
"prefs_notifications_delete_after_never_description": "Notificações nunca serão auto excluídas",
|
||||
"prefs_users_description": "Adicionar/remover usuários em seus tópicos protegidos. Note que o usuário e senha são salvos no armazenamento local do navegador.",
|
||||
"prefs_users_add_button": "Adicionar usuário",
|
||||
"prefs_users_table_user_header": "Usuário",
|
||||
"prefs_users_table_base_url_header": "URL de serviço",
|
||||
"prefs_users_dialog_title_add": "Adicionar usuário",
|
||||
"prefs_users_dialog_title_edit": "Editar usuário",
|
||||
"prefs_users_dialog_base_url_label": "URL de serviço, exemplo https://ntfy.sh",
|
||||
"prefs_users_dialog_username_label": "Usuário, por exemplo phil",
|
||||
"prefs_users_dialog_password_label": "Senha",
|
||||
"prefs_users_dialog_button_cancel": "Cancelar",
|
||||
"prefs_users_dialog_button_add": "Adicionar",
|
||||
"prefs_users_dialog_button_save": "Salvar",
|
||||
"prefs_appearance_title": "Aparência",
|
||||
"prefs_appearance_language_title": "LInguagem",
|
||||
"priority_min": "minima",
|
||||
"priority_low": "baixa",
|
||||
"priority_default": "padrão",
|
||||
"priority_high": "alta",
|
||||
"priority_max": "máxima",
|
||||
"error_boundary_title": "Ah não, ntfy parou de funcionar",
|
||||
"error_boundary_gathering_info": "Coletar mais informações …",
|
||||
"error_boundary_description": "Isto obviamente não deveria ter acontecido. Lamentamos muito por isto.<br/>Se tiver um minuto, por favor <githubLink> relate isto no GitHub</githubLink>, ou informe-nos através de <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>.",
|
||||
"error_boundary_button_copy_stack_trace": "Copiar rastreamento de pilha",
|
||||
"error_boundary_stack_trace": "Rastreamento de pilha",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "excede {{fileSizeLimit}} limite de arquivo e cota, {{remainingBytes}} restante",
|
||||
"publish_dialog_attachment_limits_quota_reached": "excede a cota, {{remainingBytes}} restantes",
|
||||
"publish_dialog_priority_high": "Alta prioridade",
|
||||
"publish_dialog_priority_max": "Prioridade máxima",
|
||||
"publish_dialog_button_send": "Enviar",
|
||||
"publish_dialog_attached_file_title": "Arquivo anexado:",
|
||||
"publish_dialog_attach_label": "URL de anexo",
|
||||
"publish_dialog_chip_attach_url_label": "Anexar arquivo por URL",
|
||||
"publish_dialog_attach_placeholder": "Anexar arquivo por URL, por exemplo, https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_chip_email_label": "Encaminhar para email",
|
||||
"publish_dialog_checkbox_publish_another": "Publicar outro",
|
||||
"publish_dialog_details_examples_description": "Para obter exemplos e uma descrição detalhada de todos os recursos de envio, consulte a <docsLink>documentação</docsLink>.",
|
||||
"publish_dialog_button_cancel": "Cancelar",
|
||||
"prefs_notifications_delete_after_one_day_description": "Notificações são automaticamente excluídas após um dia",
|
||||
"prefs_notifications_delete_after_one_month_description": "Notificações são automaticamente excluídas após um mês",
|
||||
"prefs_users_title": "Gerenciar usuários",
|
||||
"subscribe_dialog_error_user_not_authorized": "Usuário {{username}} não autorizado",
|
||||
"prefs_notifications_title": "Notificações",
|
||||
"prefs_notifications_sound_no_sound": "Sem som",
|
||||
"subscribe_dialog_login_title": "Login necessário",
|
||||
"prefs_notifications_sound_title": "Som de notificações",
|
||||
"prefs_notifications_min_priority_title": "Mínima prioridade",
|
||||
"prefs_notifications_min_priority_description_any": "Mostrando todas as notificações, independente da prioridade",
|
||||
"prefs_notifications_delete_after_one_week_description": "Notificações são automaticamente excluídas após uma semana",
|
||||
"subscribe_dialog_login_description": "Esse tópico é protegido por senha. Por favor digite o nome de usuário e senha para inscrever.",
|
||||
"subscribe_dialog_login_username_label": "Nome, por exemplo phil",
|
||||
"subscribe_dialog_login_button_login": "Login",
|
||||
"prefs_notifications_sound_description_none": "Notificações não reproduzem nenhum som quando chegam",
|
||||
"prefs_notifications_sound_description_some": "Notificações reproduzem som {{sound}} quando chegam",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Mostrar notificações se prioridade for {{number}} ({{name}}) ou acima",
|
||||
"prefs_notifications_delete_after_three_hours_description": "Notificações são automaticamente excluídas após três horas",
|
||||
"publish_dialog_attach_reset": "Remover URL do anexo",
|
||||
"publish_dialog_emoji_picker_show": "Escolher emoji",
|
||||
"publish_dialog_attached_file_remove": "Remover arquivo anexado",
|
||||
"emoji_picker_search_clear": "Limpar",
|
||||
"subscribe_dialog_subscribe_base_url_label": "URL de subscrição",
|
||||
"notifications_list": "Lista de notificações",
|
||||
"message_bar_show_dialog": "Mostrar caixa de publicação",
|
||||
"publish_dialog_topic_reset": "Resetar tópico",
|
||||
"publish_dialog_delay_reset": "Remover entrega adiada da notificação",
|
||||
"nav_button_connecting": "Conectando",
|
||||
"publish_dialog_email_reset": "Remover encaminhar email",
|
||||
"prefs_notifications_sound_play": "Reproduzir som selecionado",
|
||||
"action_bar_show_menu": "Mostrar menu",
|
||||
"action_bar_toggle_mute": "Habilita/Desabilita notificações",
|
||||
"action_bar_toggle_action_menu": "Abrir/fechar menu de ação",
|
||||
"action_bar_logo_alt": "nfty logo",
|
||||
"message_bar_publish": "Publicar mensagem",
|
||||
"nav_button_muted": "Notificações desabilitadas",
|
||||
"notifications_list_item": "Notificação",
|
||||
"notifications_mark_read": "Marcar como lido",
|
||||
"notifications_delete": "Excluir",
|
||||
"notifications_priority_x": "Prioridade {{priority}}",
|
||||
"notifications_new_indicator": "Nova notificação",
|
||||
"notifications_attachment_image": "Imagem anexada",
|
||||
"notifications_attachment_file_image": "Arquivo de imagem",
|
||||
"notifications_attachment_file_video": "Arquivo de vídeo",
|
||||
"notifications_attachment_file_audio": "Arquivo de áudio",
|
||||
"notifications_attachment_file_app": "Arquivo apk android",
|
||||
"notifications_attachment_file_document": "Outros documentos",
|
||||
"publish_dialog_click_reset": "Remover URL clicável",
|
||||
"prefs_users_table": "Tabela de usuários",
|
||||
"prefs_users_edit_button": "Editar usuário",
|
||||
"prefs_users_delete_button": "Excluir usuário",
|
||||
"error_boundary_unsupported_indexeddb_title": "Navegação anônima não suportada",
|
||||
"error_boundary_unsupported_indexeddb_description": "O ntfy web app precisa do IndexedDB para funcionar, e seu navegador não suporta IndexedDB no modo de navegação privada.<br/><br/>Embora isso seja lamentável, também não faz muito sentido usar o ntfy web app no modo de navegação privada de qualquer maneira, porque tudo é armazenado no armazenamento do navegador. Você pode ler mais sobre isso <githubLink>nesta edição do GitHub</githubLink>, ou falar conosco em <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>."
|
||||
}
|
||||
154
web/public/static/langs/ru.json
Normal file
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"publish_dialog_priority_min": "Мин. приоритет",
|
||||
"action_bar_settings": "Настройки",
|
||||
"action_bar_send_test_notification": "Отправить тестовое уведомление",
|
||||
"action_bar_clear_notifications": "Удалить все уведомления",
|
||||
"action_bar_unsubscribe": "Отписаться",
|
||||
"message_bar_type_message": "Введите сообщение здесь",
|
||||
"notifications_none_for_topic_description": "Чтобы отправить уведомление на данную тему, просто отправьте PUT или POST на URL-адрес этой темы.",
|
||||
"notifications_none_for_any_description": "Чтобы отправить уведомления на тему, просто отправьте PUT или POST на URL-адрес темы. Вот пример используя одну из ваших тем.",
|
||||
"notifications_no_subscriptions_title": "Похоже у вас ещё нет подписок.",
|
||||
"alert_grant_description": "Разрешите браузеру показывать уведомления.",
|
||||
"notifications_no_subscriptions_description": "Нажмите \"{{linktext}}\" ссылку, чтобы создать или подписаться на тему. После этого вы сможете отправлять сообщения используя PUT или POST, и вы будете получать здесь уведомления.",
|
||||
"notifications_example": "Пример",
|
||||
"notifications_more_details": "Дополнительную информацию найдёте на <websiteLink>сайте</websiteLink> или в <docsLink>документации</docsLink>.",
|
||||
"notifications_loading": "Загружаются уведомления …",
|
||||
"publish_dialog_title_topic": "Опубликовать в {{topic}}",
|
||||
"publish_dialog_title_no_topic": "Опубликовать уведомление",
|
||||
"publish_dialog_progress_uploading": "Загружается …",
|
||||
"publish_dialog_progress_uploading_detail": "Загружается {{loaded}}/{{total}} ({{percent}}%) …",
|
||||
"publish_dialog_message_published": "Уведомление опубликовано",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "превышает {{fileSizeLimit}} размер файла, {{remainingBytes}} осталось",
|
||||
"publish_dialog_attachment_limits_file_reached": "превышает {{fileSizeLimit}} размер файла",
|
||||
"publish_dialog_attachment_limits_quota_reached": "превышает квоту, {{remainingBytes}} осталось",
|
||||
"publish_dialog_priority_low": "Низкий приоритет",
|
||||
"publish_dialog_priority_default": "Приоритет по умолчанию",
|
||||
"publish_dialog_priority_high": "Высокий приоритет",
|
||||
"publish_dialog_priority_max": "Макс. приоритет",
|
||||
"publish_dialog_base_url_label": "URL-адрес сервиса",
|
||||
"publish_dialog_base_url_placeholder": "URL-адрес сервиса, например https://example.com",
|
||||
"publish_dialog_topic_label": "Название темы",
|
||||
"publish_dialog_topic_placeholder": "Название темы, например phil_alerts",
|
||||
"publish_dialog_title_label": "Заголовок",
|
||||
"publish_dialog_title_placeholder": "Заголовок уведомления, например Disk space alert",
|
||||
"publish_dialog_message_label": "Сообщение",
|
||||
"publish_dialog_message_placeholder": "Текст сообщения",
|
||||
"publish_dialog_tags_label": "Тэги",
|
||||
"publish_dialog_tags_placeholder": "Список тэгов, разделённый запятой, например warning, srv1-backup",
|
||||
"publish_dialog_priority_label": "Приоритет",
|
||||
"publish_dialog_click_label": "Нажмите на URL-адрес",
|
||||
"publish_dialog_click_placeholder": "URL-адрес который откроется когда будет нажато уведомление",
|
||||
"publish_dialog_email_label": "Эл. почта",
|
||||
"message_bar_error_publishing": "Ошибка отправки уведомления",
|
||||
"alert_not_supported_title": "Уведомления не поддерживаются",
|
||||
"alert_not_supported_description": "Уведомления не поддерживаются вашим браузером.",
|
||||
"notifications_copied_to_clipboard": "Скопировано в буфер обмена",
|
||||
"notifications_attachment_open_button": "Открыть вложение",
|
||||
"notifications_none_for_topic_title": "Вы ещё не получали уведомления для этой темы.",
|
||||
"nav_topics_title": "Подписки на темы",
|
||||
"nav_button_all_notifications": "Все уведомления",
|
||||
"nav_button_settings": "Настройки",
|
||||
"nav_button_documentation": "Документация",
|
||||
"nav_button_publish_message": "Опубликовать уведомление",
|
||||
"nav_button_subscribe": "Подписаться на тему",
|
||||
"alert_grant_button": "Разрешить",
|
||||
"notifications_attachment_copy_url_button": "Скопировать URL-адрес",
|
||||
"notifications_attachment_open_title": "Перейти на {{url}}",
|
||||
"notifications_attachment_link_expired": "срок действия ссылки для скачивания истёк",
|
||||
"notifications_click_copy_url_button": "Скопировать ссылку",
|
||||
"notifications_none_for_any_title": "Вы ещё не получали никаких уведомлений.",
|
||||
"alert_grant_title": "Уведомления отключены",
|
||||
"notifications_attachment_copy_url_title": "Скопировать URL-адрес вложения",
|
||||
"notifications_actions_open_url_title": "Перейти на {{url}}",
|
||||
"notifications_tags": "Тэги",
|
||||
"notifications_attachment_link_expires": "срок действия ссылки истекает {{date}}",
|
||||
"notifications_click_copy_url_title": "Скопировать URL-адрес ссылки",
|
||||
"notifications_click_open_button": "Открыть ссылку",
|
||||
"subscribe_dialog_subscribe_title": "Подписаться на тему",
|
||||
"publish_dialog_button_cancel": "Отмена",
|
||||
"subscribe_dialog_subscribe_description": "Темы могут быть не защищены паролем, поэтому укажите сложное имя. После подписки вы можете размещать/отправлять уведомления.",
|
||||
"prefs_users_description": "Добавляйте/удаляйте пользователей для защищенных тем. Обратите внимание, что имя пользователя и пароль хранятся в локальном хранилище браузера.",
|
||||
"error_boundary_description": "Этого, очевидно, не должно происходить. Очень сожалею об этом. <br/>Если у вас есть минутка, пожалуйста <githubLink>сообщить об этом на GitHub</githubLink>, или сообщите нам через <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>.",
|
||||
"publish_dialog_email_placeholder": "Адрес для пересылки уведомления. Например, phil@example.com",
|
||||
"publish_dialog_attach_placeholder": "Прикрепите файл по URL. Например, https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_filename_label": "Имя файла",
|
||||
"publish_dialog_delay_label": "Задержка",
|
||||
"publish_dialog_delay_placeholder": "Задержка доставки. Например, {{unixTimestamp}}, {{relativeTime}}, or \"{{naturalLanguage}}\" (English only)",
|
||||
"publish_dialog_chip_click_label": "Адрес",
|
||||
"publish_dialog_chip_email_label": "Переслать на электронную почту",
|
||||
"publish_dialog_chip_attach_url_label": "Прикрепить файл по URL",
|
||||
"publish_dialog_chip_attach_file_label": "Прикрепить локальный файл",
|
||||
"publish_dialog_chip_delay_label": "Задержка отправки",
|
||||
"publish_dialog_chip_topic_label": "Изменить тему",
|
||||
"publish_dialog_details_examples_description": "Примеры и подробное описание всех функций см. в e <docsLink>документации</docsLink>.",
|
||||
"publish_dialog_attach_label": "URL-адрес вложения",
|
||||
"publish_dialog_filename_placeholder": "Имя файла вложения",
|
||||
"publish_dialog_other_features": "Другие возможности:",
|
||||
"publish_dialog_button_cancel_sending": "Отменить отправку",
|
||||
"publish_dialog_button_send": "Отправить",
|
||||
"publish_dialog_checkbox_publish_another": "Опубликовать еще",
|
||||
"publish_dialog_attached_file_title": "Прикрепленный файл:",
|
||||
"publish_dialog_attached_file_filename_placeholder": "Имя прикреплённого файла",
|
||||
"emoji_picker_search_placeholder": "Поиск эмодзи",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "Название темы. Например, phil_alerts",
|
||||
"subscribe_dialog_subscribe_use_another_label": "Использовать другой сервер",
|
||||
"subscribe_dialog_subscribe_button_cancel": "Отмена",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "Подписаться",
|
||||
"subscribe_dialog_login_title": "Требуется авторизация",
|
||||
"subscribe_dialog_login_description": "Эта тема защищена паролем. Пожалуйста, введите имя пользователя и пароль, чтобы подписаться.",
|
||||
"subscribe_dialog_login_username_label": "Имя пользователя. Например, phil",
|
||||
"subscribe_dialog_login_password_label": "Пароль",
|
||||
"subscribe_dialog_login_button_back": "Назад",
|
||||
"subscribe_dialog_login_button_login": "Войти",
|
||||
"subscribe_dialog_error_user_not_authorized": "Пользователь {{username}} не авторизован",
|
||||
"subscribe_dialog_error_user_anonymous": "аноним",
|
||||
"prefs_notifications_title": "Уведомления",
|
||||
"prefs_notifications_sound_title": "Звук уведомления",
|
||||
"prefs_notifications_sound_description_none": "Уведомления не воспроизводят никаких звуков при получении",
|
||||
"prefs_notifications_sound_no_sound": "Без звука",
|
||||
"prefs_notifications_min_priority_title": "Минимальный приоритет",
|
||||
"prefs_notifications_min_priority_description_any": "Показать все уведомления, независимо от приоритета",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Показывать уведомления, если приоритет {{number}} ({{name}}) или выше",
|
||||
"prefs_notifications_min_priority_description_max": "Показывать уведомления, если приоритет равен 5 (максимум)",
|
||||
"prefs_notifications_min_priority_any": "Любой приоритет",
|
||||
"prefs_notifications_min_priority_low_and_higher": "Низкий и высокий приоритет",
|
||||
"prefs_notifications_min_priority_max_only": "Только максимальный приоритет",
|
||||
"prefs_notifications_delete_after_title": "Удалить уведомления",
|
||||
"prefs_notifications_delete_after_never": "Никогда",
|
||||
"prefs_notifications_delete_after_three_hours": "Через три часа",
|
||||
"prefs_notifications_sound_description_some": "Уведомления воспроизводят звук {{sound}}",
|
||||
"prefs_notifications_min_priority_default_and_higher": "Приоритет по умолчанию и высокий",
|
||||
"prefs_notifications_delete_after_one_day": "Через день",
|
||||
"prefs_notifications_delete_after_one_week": "Через неделю",
|
||||
"prefs_notifications_delete_after_one_month": "Через месяц",
|
||||
"prefs_notifications_delete_after_never_description": "Уведомления никогда не удаляются автоматически",
|
||||
"prefs_notifications_delete_after_three_hours_description": "Уведомления автоматически удаляются через три часа",
|
||||
"prefs_notifications_delete_after_one_day_description": "Уведомления автоматически удаляются через один день",
|
||||
"prefs_notifications_delete_after_one_week_description": "Уведомления автоматически удаляются через неделю",
|
||||
"prefs_notifications_delete_after_one_month_description": "Уведомления автоматически удаляются через месяц",
|
||||
"prefs_users_title": "Управление пользователями",
|
||||
"prefs_users_add_button": "Добавить пользователя",
|
||||
"prefs_users_table_user_header": "Пользователь",
|
||||
"prefs_users_table_base_url_header": "URL службы",
|
||||
"prefs_users_dialog_title_add": "Добавить пользователя",
|
||||
"prefs_users_dialog_title_edit": "Редактировать пользователя",
|
||||
"prefs_users_dialog_base_url_label": "URL-адрес службы. Например, https://ntfy.sh",
|
||||
"prefs_users_dialog_username_label": "Имя пользователя. Например, phil",
|
||||
"prefs_users_dialog_password_label": "Пароль",
|
||||
"prefs_users_dialog_button_cancel": "Отмена",
|
||||
"prefs_users_dialog_button_add": "Добавить",
|
||||
"prefs_users_dialog_button_save": "Сохранить",
|
||||
"prefs_appearance_title": "Внешний вид",
|
||||
"prefs_appearance_language_title": "Язык",
|
||||
"priority_min": "минимум",
|
||||
"priority_low": "низкий",
|
||||
"priority_default": "по умолчанию",
|
||||
"priority_high": "высокий",
|
||||
"priority_max": "максимальный",
|
||||
"error_boundary_title": "О нет, Ntfy сломался",
|
||||
"error_boundary_button_copy_stack_trace": "Копирование трассировки стека",
|
||||
"error_boundary_stack_trace": "Трассировка стека",
|
||||
"error_boundary_gathering_info": "Соберите больше информации …",
|
||||
"publish_dialog_drop_file_here": "Перетащите файл юда",
|
||||
"prefs_notifications_min_priority_high_and_higher": "Высокий приоритет и выше"
|
||||
}
|
||||
191
web/public/static/langs/tr.json
Normal file
@@ -0,0 +1,191 @@
|
||||
{
|
||||
"nav_button_subscribe": "Konuya abone ol",
|
||||
"nav_button_settings": "Ayarlar",
|
||||
"action_bar_send_test_notification": "Test bildirimi gönder",
|
||||
"message_bar_type_message": "Buraya bir mesaj yazın",
|
||||
"action_bar_clear_notifications": "Tüm bildirimleri temizle",
|
||||
"action_bar_unsubscribe": "Abonelikten çık",
|
||||
"action_bar_settings": "Ayarlar",
|
||||
"message_bar_error_publishing": "Bildirim yayınlanırken hata oluştu",
|
||||
"nav_topics_title": "Abone olunan konular",
|
||||
"nav_button_all_notifications": "Tüm bildirimler",
|
||||
"publish_dialog_tags_placeholder": "Virgülle ayrılmış etiket listesi, örn. uyarı, srv1-yedekleme",
|
||||
"publish_dialog_priority_label": "Öncelik",
|
||||
"publish_dialog_click_label": "Tıklama URL'si",
|
||||
"publish_dialog_click_placeholder": "Bildirim tıklandığında açılan URL",
|
||||
"publish_dialog_email_label": "E-posta adresi",
|
||||
"publish_dialog_email_placeholder": "Bildirimin iletileceği adres, örn. phil@example.com",
|
||||
"publish_dialog_attach_label": "Ek URL'si",
|
||||
"publish_dialog_filename_label": "Dosya adı",
|
||||
"publish_dialog_filename_placeholder": "Ek dosya adı",
|
||||
"publish_dialog_delay_label": "Gecikme",
|
||||
"publish_dialog_button_cancel": "İptal",
|
||||
"publish_dialog_button_send": "Gönder",
|
||||
"publish_dialog_checkbox_publish_another": "Başka bir tane yayınla",
|
||||
"publish_dialog_attached_file_title": "Ekli dosya:",
|
||||
"publish_dialog_attached_file_filename_placeholder": "Ek dosya adı",
|
||||
"subscribe_dialog_subscribe_title": "Konuya abone ol",
|
||||
"subscribe_dialog_subscribe_description": "Konular parola korumalı olmayabilir, bu nedenle tahmin edilmesi kolay olmayan bir ad seçin. Abone olduktan sonra PUT/POST bildirimleri yapabilirsiniz.",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "Konu adı, örn. benim_uyarilarim",
|
||||
"subscribe_dialog_subscribe_use_another_label": "Başka bir sunucu kullan",
|
||||
"subscribe_dialog_subscribe_button_cancel": "İptal",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "Abone ol",
|
||||
"subscribe_dialog_login_title": "Oturum açma gerekli",
|
||||
"subscribe_dialog_login_description": "Bu konu parola korumalı. Abone olmak için lütfen kullanıcı adı ve parola girin.",
|
||||
"subscribe_dialog_login_username_label": "Kullanıcı adı, örn. phil",
|
||||
"subscribe_dialog_login_password_label": "Parola",
|
||||
"subscribe_dialog_login_button_back": "Geri",
|
||||
"subscribe_dialog_login_button_login": "Oturum aç",
|
||||
"subscribe_dialog_error_user_not_authorized": "{{username}} kullanıcısı yetkili değil",
|
||||
"subscribe_dialog_error_user_anonymous": "anonim",
|
||||
"prefs_notifications_title": "Bildirimler",
|
||||
"prefs_notifications_sound_title": "Bildirim sesi",
|
||||
"prefs_notifications_sound_no_sound": "Ses yok",
|
||||
"prefs_notifications_min_priority_title": "En düşük öncelik",
|
||||
"prefs_notifications_min_priority_any": "Herhangi bir öncelik",
|
||||
"publish_dialog_topic_placeholder": "Konu adı, örn. benim_uyarilarim",
|
||||
"alert_grant_button": "Şimdi ver",
|
||||
"alert_not_supported_title": "Bildirimler desteklenmiyor",
|
||||
"notifications_attachment_link_expires": "bağlantının süresi {{date}} tarihinde doluyor",
|
||||
"notifications_click_copy_url_title": "Bağlantı URL'sini panoya kopyala",
|
||||
"notifications_loading": "Bildirimler yükleniyor…",
|
||||
"publish_dialog_progress_uploading": "Karşıya yükleniyor…",
|
||||
"publish_dialog_attachment_limits_file_reached": "{{fileSizeLimit}} dosya sınırını aşıyor",
|
||||
"publish_dialog_priority_default": "Öntanımlı öncelik",
|
||||
"publish_dialog_chip_click_label": "Tıklama URL'si",
|
||||
"publish_dialog_attach_placeholder": "URL ile dosya ekle, örn. https://f-droid.org/F-Droid.apk",
|
||||
"prefs_notifications_delete_after_never": "Hiçbir zaman",
|
||||
"notifications_attachment_copy_url_button": "URL'yi kopyala",
|
||||
"notifications_attachment_open_button": "Eki aç",
|
||||
"nav_button_documentation": "Belgelendirme",
|
||||
"nav_button_publish_message": "Bildirim yayınla",
|
||||
"alert_grant_title": "Bildirimler devre dışı",
|
||||
"alert_grant_description": "Tarayıcınıza masaüstü bildirimlerini görüntüleme izni verin.",
|
||||
"alert_not_supported_description": "Tarayıcınızda bildirimler desteklenmiyor.",
|
||||
"notifications_copied_to_clipboard": "Panoya kopyalandı",
|
||||
"notifications_tags": "Etiketler",
|
||||
"notifications_attachment_copy_url_title": "Ek URL'sini panoya kopyala",
|
||||
"notifications_attachment_open_title": "{{url}} adresine git",
|
||||
"notifications_none_for_topic_title": "Bu konu için henüz herhangi bir bildirim almadınız.",
|
||||
"notifications_none_for_topic_description": "Bu konuya bildirim göndermek için konu URL'sine PUT veya POST göndermeniz yeterlidir.",
|
||||
"notifications_none_for_any_title": "Herhangi bir bildirim almadınız.",
|
||||
"notifications_attachment_link_expired": "indirme bağlantısının süresi doldu",
|
||||
"notifications_click_copy_url_button": "Bağlantıyı kopyala",
|
||||
"notifications_actions_open_url_title": "{{url}} adresine git",
|
||||
"notifications_click_open_button": "Bağlantıyı aç",
|
||||
"notifications_no_subscriptions_description": "Bir konu oluşturmak veya bir konuya abone olmak için \"{{linktext}}\" bağlantısına tıklayın. Bundan sonra PUT veya POST yoluyla mesaj gönderebilirsiniz ve buradan bildirimler alırsınız.",
|
||||
"notifications_example": "Örnek",
|
||||
"notifications_more_details": "Daha fazla bilgi için <websiteLink>web sitesine</websiteLink> veya <docsLink>belgelendirmeye</docsLink> bakın.",
|
||||
"publish_dialog_chip_attach_url_label": "URL ile dosya ekle",
|
||||
"prefs_notifications_min_priority_default_and_higher": "Öntanımlı öncelik ve üstü",
|
||||
"prefs_notifications_delete_after_three_hours": "Üç saat sonra",
|
||||
"notifications_none_for_any_description": "Bir konuya bildirim göndermek için konu URL'sine PUT veya POST göndermeniz yeterlidir. İşte konularınızdan birini kullanan bir örnek.",
|
||||
"notifications_no_subscriptions_title": "Henüz aboneliğiniz yok gibi görünüyor.",
|
||||
"publish_dialog_title_topic": "{{topic}} konusuna yayınla",
|
||||
"publish_dialog_title_no_topic": "Bildirim yayınla",
|
||||
"publish_dialog_progress_uploading_detail": "Karşıya yükleniyor: {{loaded}}/{{total}} ({{percent}}%)…",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "{{fileSizeLimit}} dosya sınırını ve kotasını aşıyor, kalan {{remainingBytes}}",
|
||||
"publish_dialog_priority_min": "En düşük öncelik",
|
||||
"publish_dialog_priority_low": "Düşük öncelik",
|
||||
"publish_dialog_base_url_label": "Hizmet URL'si",
|
||||
"publish_dialog_attachment_limits_quota_reached": "kotayı aşıyor, kalan {{remainingBytes}}",
|
||||
"publish_dialog_message_published": "Bildirim yayınlandı",
|
||||
"publish_dialog_title_label": "Başlık",
|
||||
"publish_dialog_priority_high": "Yüksek öncelik",
|
||||
"publish_dialog_priority_max": "En yüksek öncelik",
|
||||
"publish_dialog_message_label": "Mesaj",
|
||||
"publish_dialog_other_features": "Diğer özellikler:",
|
||||
"publish_dialog_chip_email_label": "E-posta adresine ilet",
|
||||
"publish_dialog_topic_label": "Konu adı",
|
||||
"publish_dialog_base_url_placeholder": "Hizmet URL'si, örn. https://example.com",
|
||||
"publish_dialog_title_placeholder": "Bildirim başlığı, örn. Disk alanı uyarısı",
|
||||
"publish_dialog_message_placeholder": "Buraya bir mesaj yazın",
|
||||
"publish_dialog_tags_label": "Etiketler",
|
||||
"publish_dialog_delay_placeholder": "Teslimat gecikmesi, örn. {{unixTimestamp}}, {{relativeTime}} veya \"{{naturalLanguage}}\"",
|
||||
"publish_dialog_chip_attach_file_label": "Yerel dosya ekle",
|
||||
"publish_dialog_chip_delay_label": "Teslimat gecikmesi",
|
||||
"publish_dialog_chip_topic_label": "Konuyu değiştir",
|
||||
"publish_dialog_button_cancel_sending": "Göndermeyi iptal et",
|
||||
"prefs_notifications_delete_after_one_week": "Bir hafta sonra",
|
||||
"prefs_notifications_delete_after_one_month": "Bir ay sonra",
|
||||
"publish_dialog_details_examples_description": "Örnekler ve tüm gönderme özelliklerinin ayrıntılı açıklaması için lütfen <docsLink>belgelendirmeye</docsLink> bakın.",
|
||||
"emoji_picker_search_placeholder": "Emoji ara",
|
||||
"prefs_notifications_delete_after_title": "Bildirimleri sil",
|
||||
"prefs_notifications_delete_after_one_day": "Bir gün sonra",
|
||||
"publish_dialog_drop_file_here": "Dosyayı buraya bırakın",
|
||||
"prefs_notifications_min_priority_low_and_higher": "Düşük öncelik ve üstü",
|
||||
"prefs_notifications_min_priority_high_and_higher": "Yüksek öncelik ve üstü",
|
||||
"prefs_notifications_min_priority_max_only": "Yalnızca en yüksek öncelik",
|
||||
"prefs_users_title": "Kullanıcıları yönet",
|
||||
"prefs_users_dialog_title_edit": "Kullanıcıyı düzenle",
|
||||
"prefs_users_dialog_base_url_label": "Hizmet URL'si, örn. https://ntfy.sh",
|
||||
"prefs_users_description": "Burada korunan konularınız için kullanıcı ekleyin/kaldırın. Lütfen kullanıcı adı ve parolanın tarayıcının yerel deposunda saklandığını unutmayın.",
|
||||
"prefs_users_add_button": "Kullanıcı ekle",
|
||||
"prefs_users_table_base_url_header": "Hizmet URL'si",
|
||||
"prefs_users_dialog_title_add": "Kullanıcı ekle",
|
||||
"prefs_users_dialog_username_label": "Kullanıcı adı, örn. phil",
|
||||
"prefs_users_table_user_header": "Kullanıcı",
|
||||
"prefs_users_dialog_password_label": "Parola",
|
||||
"prefs_users_dialog_button_add": "Ekle",
|
||||
"prefs_users_dialog_button_cancel": "İptal",
|
||||
"prefs_users_dialog_button_save": "Kaydet",
|
||||
"prefs_appearance_title": "Görünüm",
|
||||
"prefs_appearance_language_title": "Dil",
|
||||
"error_boundary_title": "Olamaz, ntfy çöktü",
|
||||
"error_boundary_gathering_info": "Daha fazla bilgi topla…",
|
||||
"error_boundary_description": "Bunun olmaması gerekiyordu. Çok üzgünüm.<br/>Bir dakikanız varsa, lütfen <githubLink>bunu GitHub üzerinden bildirin</githubLink> ya da <discordLink>Discord</discordLink> veya <matrixLink>Matrix</matrixLink> aracılığıyla bize iletin.",
|
||||
"error_boundary_button_copy_stack_trace": "Yığın izlemeyi kopyala",
|
||||
"error_boundary_stack_trace": "Yığın izleme",
|
||||
"prefs_notifications_sound_description_none": "Bildirimler geldiğinde herhangi bir ses çalmaz",
|
||||
"prefs_notifications_sound_description_some": "Bildirimler geldiğinde {{sound}} sesini çalar",
|
||||
"prefs_notifications_min_priority_description_any": "Öncelikten bağımsız olarak tüm bildirimleri göster",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Öncelik {{number}} ({{name}}) veya üstüyse bildirimleri göster",
|
||||
"prefs_notifications_delete_after_one_month_description": "Bildirimler bir ay sonra otomatik olarak silinir",
|
||||
"prefs_notifications_delete_after_three_hours_description": "Bildirimler üç saat sonra otomatik olarak silinir",
|
||||
"prefs_notifications_delete_after_one_week_description": "Bildirimler bir hafta sonra otomatik olarak silinir",
|
||||
"priority_min": "en düşük",
|
||||
"priority_low": "düşük",
|
||||
"priority_max": "en yüksek",
|
||||
"prefs_notifications_delete_after_one_day_description": "Bildirimler bir gün sonra otomatik olarak silinir",
|
||||
"priority_default": "öntanımlı",
|
||||
"prefs_notifications_min_priority_description_max": "Öncelik 5 (en fazla) ise bildirimleri göster",
|
||||
"prefs_notifications_delete_after_never_description": "Bildirimler asla otomatik olarak silinmez",
|
||||
"priority_high": "yüksek",
|
||||
"notifications_actions_not_supported": "Eylem, web uygulamasında desteklenmiyor",
|
||||
"notifications_actions_http_request_title": "{{url}} adresine HTTP {{method}} gönder",
|
||||
"action_bar_show_menu": "Menüyü göster",
|
||||
"action_bar_logo_alt": "ntfy logosu",
|
||||
"action_bar_toggle_action_menu": "Eylem menüsünü aç/kapat",
|
||||
"message_bar_show_dialog": "Yayınla iletişim kutusunu göster",
|
||||
"message_bar_publish": "Mesaj yayınla",
|
||||
"nav_button_connecting": "bağlanıyor",
|
||||
"notifications_list": "Bildirimler listesi",
|
||||
"notifications_list_item": "Bildirim",
|
||||
"notifications_delete": "Sil",
|
||||
"notifications_attachment_image": "Ek resmi",
|
||||
"notifications_attachment_file_image": "resim dosyası",
|
||||
"notifications_attachment_file_video": "video dosyası",
|
||||
"notifications_attachment_file_audio": "ses dosyası",
|
||||
"notifications_attachment_file_app": "Android uygulama dosyası",
|
||||
"notifications_attachment_file_document": "diğer belge",
|
||||
"publish_dialog_emoji_picker_show": "Emoji seç",
|
||||
"publish_dialog_topic_reset": "Konuyu sıfırla",
|
||||
"publish_dialog_attach_reset": "Ek URL'sini kaldır",
|
||||
"publish_dialog_delay_reset": "Gecikmeli teslimatı kaldır",
|
||||
"publish_dialog_attached_file_remove": "Ekli dosyayı kaldır",
|
||||
"emoji_picker_search_clear": "Aramayı temizle",
|
||||
"subscribe_dialog_subscribe_base_url_label": "Hizmet URL'si",
|
||||
"prefs_notifications_sound_play": "Seçilen sesi çal",
|
||||
"error_boundary_unsupported_indexeddb_description": "ntfy web uygulamasının çalışması için IndexedDB'ye ihtiyacı var ve tarayıcınız gizli tarama modunda IndexedDB'yi desteklemiyor.<br/><br/>Bu talihsiz olsa da, ntfy web uygulamasını gizli tarama modunda kullanmak pek mantıklı değildir, çünkü her şey tarayıcı deposunda saklanır. <githubLink>Bu GitHub sorununda</githubLink> bununla ilgili daha fazla bilgi edinebilir veya <discordLink>Discord</discordLink> veya <matrixLink>Matrix</matrixLink> üzerinden bizimle konuşabilirsiniz.",
|
||||
"notifications_new_indicator": "Yeni bildirim",
|
||||
"action_bar_toggle_mute": "Bildirimleri sesini kapat/aç",
|
||||
"publish_dialog_click_reset": "Tıklama URL'sini kaldır",
|
||||
"prefs_users_table": "Kullanıcılar tablosu",
|
||||
"error_boundary_unsupported_indexeddb_title": "Gizli tarama desteklenmiyor",
|
||||
"nav_button_muted": "Bildirimler sessize alındı",
|
||||
"notifications_mark_read": "Okundu olarak işaretle",
|
||||
"notifications_priority_x": "Öncelik {{priority}}",
|
||||
"publish_dialog_email_reset": "E-posta yönlendirmesini kaldır",
|
||||
"prefs_users_edit_button": "Kullanıcıyı düzenle",
|
||||
"prefs_users_delete_button": "Kullanıcı sil"
|
||||
}
|
||||
191
web/public/static/langs/zh_Hans.json
Normal file
@@ -0,0 +1,191 @@
|
||||
{
|
||||
"action_bar_show_menu": "显示菜单",
|
||||
"action_bar_logo_alt": "ntfy图标",
|
||||
"action_bar_settings": "设置",
|
||||
"action_bar_send_test_notification": "发送测试通知",
|
||||
"action_bar_clear_notifications": "清除所有通知",
|
||||
"action_bar_unsubscribe": "取消订阅",
|
||||
"action_bar_toggle_action_menu": "开启或关闭操作菜单",
|
||||
"message_bar_type_message": "在此处输入消息",
|
||||
"message_bar_show_dialog": "显示发布对话框",
|
||||
"message_bar_publish": "发布消息",
|
||||
"nav_topics_title": "订阅主题",
|
||||
"nav_button_all_notifications": "全部通知",
|
||||
"nav_button_documentation": "文档",
|
||||
"nav_button_publish_message": "发布通知",
|
||||
"nav_button_subscribe": "订阅主题",
|
||||
"nav_button_connecting": "正在连接",
|
||||
"alert_grant_title": "已禁用通知",
|
||||
"alert_grant_description": "授予浏览器显示桌面通知的权限。",
|
||||
"alert_grant_button": "现在授予",
|
||||
"alert_not_supported_title": "不支持通知",
|
||||
"alert_not_supported_description": "您的浏览器不支持通知。",
|
||||
"notifications_list": "通知列表",
|
||||
"notifications_list_item": "通知",
|
||||
"notifications_mark_read": "标记为已读",
|
||||
"notifications_copied_to_clipboard": "复制到剪贴板",
|
||||
"notifications_tags": "标记",
|
||||
"notifications_priority_x": "优先级 {{priority}}",
|
||||
"notifications_new_indicator": "新通知",
|
||||
"notifications_attachment_open_button": "打开附件",
|
||||
"notifications_attachment_link_expires": "链接过期 {{date}}",
|
||||
"notifications_attachment_link_expired": "下载链接已过期",
|
||||
"notifications_attachment_file_image": "图片文件",
|
||||
"notifications_attachment_image": "附件图片",
|
||||
"notifications_attachment_file_video": "视频文件",
|
||||
"notifications_attachment_file_audio": "音频文件",
|
||||
"notifications_attachment_file_app": "安卓应用文件",
|
||||
"notifications_attachment_file_document": "其他文件",
|
||||
"notifications_click_copy_url_title": "复制链接地址到剪贴板",
|
||||
"notifications_click_copy_url_button": "复制链接",
|
||||
"notifications_click_open_button": "打开链接",
|
||||
"action_bar_toggle_mute": "暂停或恢复通知",
|
||||
"nav_button_muted": "已暂停通知",
|
||||
"notifications_actions_not_supported": "网页应用程序不支持操作",
|
||||
"notifications_none_for_topic_title": "您尚未收到有关此主题的任何通知。",
|
||||
"notifications_none_for_any_title": "您尚未收到任何通知。",
|
||||
"notifications_none_for_any_description": "要向此主题发送通知,只需使用 PUT 或 POST 到主题链接即可。以下是使用您的主题的示例。",
|
||||
"notifications_no_subscriptions_title": "看起来你还没有任何订阅。",
|
||||
"notifications_example": "示例",
|
||||
"notifications_more_details": "有关更多信息,请查看<websiteLink>网站</websiteLink>或<docsLink>文档</docsLink>。",
|
||||
"notifications_loading": "正在加载通知……",
|
||||
"publish_dialog_title_topic": "发布到 {{topic}}",
|
||||
"publish_dialog_title_no_topic": "发布通知",
|
||||
"publish_dialog_progress_uploading": "正在上传……",
|
||||
"publish_dialog_progress_uploading_detail": "正在上传 {{loaded}}/{{total}} ({{percent}}%) ……",
|
||||
"publish_dialog_message_published": "已发布通知",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "超过 {{fileSizeLimit}} 文件限制和配额,剩余 {{remainingBytes}}",
|
||||
"publish_dialog_emoji_picker_show": "选择表情符号",
|
||||
"publish_dialog_priority_min": "最低优先级",
|
||||
"publish_dialog_priority_low": "低优先级",
|
||||
"publish_dialog_priority_default": "默认优先级",
|
||||
"publish_dialog_priority_high": "高优先级",
|
||||
"publish_dialog_priority_max": "最高优先级",
|
||||
"publish_dialog_topic_label": "主题名称",
|
||||
"publish_dialog_topic_placeholder": "主题名称,例如 phil_alerts",
|
||||
"publish_dialog_topic_reset": "重置主题",
|
||||
"publish_dialog_title_label": "主题",
|
||||
"publish_dialog_message_label": "消息",
|
||||
"publish_dialog_message_placeholder": "在此输入消息",
|
||||
"publish_dialog_tags_label": "标记",
|
||||
"publish_dialog_priority_label": "优先级",
|
||||
"publish_dialog_base_url_label": "服务链接地址",
|
||||
"publish_dialog_base_url_placeholder": "服务链接地址,例如 https://example.com",
|
||||
"publish_dialog_click_label": "点击链接地址",
|
||||
"publish_dialog_click_placeholder": "点击通知时打开链接地址",
|
||||
"publish_dialog_email_placeholder": "将通知转发到的地址,例如 phil@example.com",
|
||||
"publish_dialog_email_reset": "移除电子邮件转发",
|
||||
"publish_dialog_filename_label": "文件名",
|
||||
"publish_dialog_filename_placeholder": "附件文件名",
|
||||
"publish_dialog_delay_label": "延期",
|
||||
"publish_dialog_other_features": "其它功能:",
|
||||
"publish_dialog_attach_placeholder": "使用链接地址附加文件,例如 https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_delay_reset": "删除延迟交付",
|
||||
"publish_dialog_attach_reset": "移除附件链接地址",
|
||||
"publish_dialog_chip_click_label": "点击链接地址",
|
||||
"publish_dialog_chip_email_label": "转发邮件",
|
||||
"publish_dialog_chip_attach_file_label": "本地文件附件",
|
||||
"publish_dialog_chip_topic_label": "变更主题",
|
||||
"publish_dialog_button_cancel_sending": "取消发送",
|
||||
"publish_dialog_checkbox_publish_another": "发布另一个",
|
||||
"publish_dialog_attached_file_title": "附件文件:",
|
||||
"publish_dialog_attached_file_filename_placeholder": "附件文件名",
|
||||
"publish_dialog_attached_file_remove": "删除附件文件",
|
||||
"publish_dialog_drop_file_here": "将文件拖拽至此",
|
||||
"emoji_picker_search_placeholder": "查找表情符号",
|
||||
"emoji_picker_search_clear": "清除搜索",
|
||||
"subscribe_dialog_subscribe_title": "订阅主题",
|
||||
"publish_dialog_chip_delay_label": "延迟交付",
|
||||
"publish_dialog_chip_attach_url_label": "链接附件地址",
|
||||
"subscribe_dialog_subscribe_use_another_label": "使用其他服务器",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "订阅",
|
||||
"subscribe_dialog_login_title": "请登录",
|
||||
"subscribe_dialog_login_description": "本主题受密码保护,请输入用户名和密码进行订阅。",
|
||||
"subscribe_dialog_login_username_label": "用户名,例如 phil",
|
||||
"subscribe_dialog_login_password_label": "密码",
|
||||
"subscribe_dialog_login_button_back": "返回",
|
||||
"subscribe_dialog_login_button_login": "登录",
|
||||
"subscribe_dialog_error_user_not_authorized": "未授权 {{username}} 用户",
|
||||
"subscribe_dialog_error_user_anonymous": "匿名",
|
||||
"prefs_notifications_title": "通知",
|
||||
"prefs_notifications_sound_title": "通知提示音",
|
||||
"prefs_notifications_sound_description_none": "收到通知时不播放任何声音",
|
||||
"prefs_notifications_sound_description_some": "收到通知时播放 {{sound}} 声音",
|
||||
"prefs_notifications_sound_no_sound": "静音",
|
||||
"prefs_notifications_sound_play": "播放选中声音",
|
||||
"prefs_notifications_min_priority_title": "最低优先级",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "仅显示优先级为{{number}}({{name}})或以上的通知",
|
||||
"prefs_notifications_min_priority_description_max": "仅显示最高优先级的通知",
|
||||
"prefs_notifications_min_priority_any": "任意优先级",
|
||||
"prefs_notifications_min_priority_low_and_higher": "低优先级和更高优先级",
|
||||
"prefs_notifications_min_priority_default_and_higher": "默认优先级或更高优先级",
|
||||
"prefs_notifications_min_priority_high_and_higher": "高优先级或更高优先级",
|
||||
"prefs_notifications_min_priority_max_only": "仅最高优先级",
|
||||
"prefs_notifications_delete_after_never": "从不",
|
||||
"prefs_notifications_delete_after_one_month": "一月后",
|
||||
"prefs_notifications_delete_after_one_week": "一周后",
|
||||
"prefs_notifications_delete_after_never_description": "永不自动删除通知",
|
||||
"prefs_notifications_delete_after_three_hours_description": "三小时后自动删除通知",
|
||||
"prefs_notifications_delete_after_one_day_description": "一天后自动删除通知",
|
||||
"prefs_notifications_delete_after_one_week_description": "一周后自动删除通知",
|
||||
"prefs_notifications_delete_after_one_month_description": "一月后后自动删除通知",
|
||||
"prefs_users_title": "管理用户",
|
||||
"prefs_users_description": "在此处添加/删除受保护主题的用户。请注意,用户名和密码存储在浏览器的本地存储中。",
|
||||
"prefs_users_add_button": "添加用户",
|
||||
"prefs_users_dialog_title_add": "添加用户",
|
||||
"prefs_users_dialog_title_edit": "编辑用户",
|
||||
"prefs_users_dialog_username_label": "用户名,例如 phil",
|
||||
"prefs_users_dialog_password_label": "密码",
|
||||
"prefs_users_dialog_button_cancel": "取消",
|
||||
"prefs_users_dialog_button_save": "保存",
|
||||
"prefs_appearance_title": "外观",
|
||||
"prefs_appearance_language_title": "语言",
|
||||
"priority_min": "最低",
|
||||
"priority_low": "低",
|
||||
"priority_default": "默认",
|
||||
"priority_high": "高",
|
||||
"priority_max": "最高",
|
||||
"error_boundary_title": "天啊,ntfy 崩溃了",
|
||||
"prefs_users_table_base_url_header": "服务链接地址",
|
||||
"prefs_users_dialog_base_url_label": "服务链接地址,例如 https://ntfy.sh",
|
||||
"error_boundary_button_copy_stack_trace": "复制堆栈跟踪",
|
||||
"error_boundary_stack_trace": "堆栈跟踪",
|
||||
"error_boundary_gathering_info": "收集更多信息……",
|
||||
"error_boundary_unsupported_indexeddb_title": "不支持隐私浏览",
|
||||
"error_boundary_unsupported_indexeddb_description": "Ntfy Web应用程序需要IndexedDB才能运行,并且您的浏览器在私隐私浏览模式下不支持IndexedDB。<br/><br/>虽然这很不幸,但在隐私浏览模式下使用ntfy Web应用程序也没有多大意义,因为所有东西都存储在浏览器存储中。您可以在<githubLink>本GitHub问题</githubLink>中阅读有关它的更多信息,或者在<discordLink>Discord</discordLink>或<matrixLink>Matrix</matrixLink>上与我们交谈。",
|
||||
"message_bar_error_publishing": "发布通知时出错",
|
||||
"nav_button_settings": "设置",
|
||||
"notifications_delete": "删除",
|
||||
"notifications_attachment_copy_url_title": "将附件中链接地址复制到剪贴板",
|
||||
"notifications_attachment_copy_url_button": "复制链接地址",
|
||||
"notifications_attachment_open_title": "转到 {{url}}",
|
||||
"notifications_actions_http_request_title": "发送 HTTP {{method}} 到 {{url}}",
|
||||
"notifications_actions_open_url_title": "转到 {{url}}",
|
||||
"notifications_none_for_topic_description": "要向此主题发送通知,只需使用 PUT 或 POST 到主题链接即可。",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "主题名,例如 phil_alerts",
|
||||
"notifications_no_subscriptions_description": "单击 \"{{linktext}}\" 链接以创建或订阅主题。之后,您可以使用 PUT 或 POST 发送消息,您将在这里收到通知。",
|
||||
"publish_dialog_attachment_limits_file_reached": "超过 {{fileSizeLimit}} 文件限制",
|
||||
"publish_dialog_title_placeholder": "主题标题,例如 磁盘空间告警",
|
||||
"publish_dialog_email_label": "电子邮件",
|
||||
"publish_dialog_button_send": "发送",
|
||||
"publish_dialog_attachment_limits_quota_reached": "超过配额,剩余 {{remainingBytes}}",
|
||||
"publish_dialog_attach_label": "附件链接地址",
|
||||
"publish_dialog_click_reset": "移除点击连接地址",
|
||||
"publish_dialog_button_cancel": "取消",
|
||||
"subscribe_dialog_subscribe_button_cancel": "取消",
|
||||
"subscribe_dialog_subscribe_base_url_label": "服务地址地址",
|
||||
"prefs_notifications_min_priority_description_any": "显示所有通知,无论优先级如何",
|
||||
"prefs_notifications_delete_after_title": "删除通知",
|
||||
"prefs_notifications_delete_after_three_hours": "三小时后",
|
||||
"prefs_users_delete_button": "删除用户",
|
||||
"prefs_users_table_user_header": "用户",
|
||||
"prefs_users_dialog_button_add": "添加",
|
||||
"prefs_notifications_delete_after_one_day": "一天后",
|
||||
"error_boundary_description": "这显然不应该发生。对此非常抱歉。<br/>如果您有时间,请<githubLink>在GitHub</githubLink>上报告,或通过<discordLink>Discord</discordLink>或<matrixLink>Matrix</matrixLink>告诉我们。",
|
||||
"prefs_users_table": "用户表",
|
||||
"prefs_users_edit_button": "编辑用户",
|
||||
"publish_dialog_tags_placeholder": "英文逗号分隔标记列表,例如 warning, srv1-backup",
|
||||
"publish_dialog_details_examples_description": "有关所有发送功能的示例和详细说明,请参阅<docsLink>文档</docsLink>。",
|
||||
"subscribe_dialog_subscribe_description": "主题可能不受密码保护,因此请选择一个不容易猜测的名字。订阅后,您可以使用 PUT/POST 通知。",
|
||||
"publish_dialog_delay_placeholder": "延迟交付,例如{{unixTimestamp}}、{{relativeTime}}或“{{naturalLanguage}}”(仅限英语)"
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import {
|
||||
basicAuth,
|
||||
encodeBase64,
|
||||
fetchLinesIterator,
|
||||
maybeWithBasicAuth,
|
||||
topicShortUrl,
|
||||
topicUrl,
|
||||
topicUrlAuth,
|
||||
topicUrlJsonPoll,
|
||||
topicUrlJsonPollWithSince
|
||||
topicUrlJsonPollWithSince, userStatsUrl
|
||||
} from "./utils";
|
||||
import userManager from "./UserManager";
|
||||
|
||||
@@ -26,25 +28,78 @@ class Api {
|
||||
return messages;
|
||||
}
|
||||
|
||||
async publish(baseUrl, topic, message, title, priority, tags) {
|
||||
async publish(baseUrl, topic, message, options) {
|
||||
const user = await userManager.get(baseUrl);
|
||||
const url = topicUrl(baseUrl, topic);
|
||||
console.log(`[Api] Publishing message to ${url}`);
|
||||
console.log(`[Api] Publishing message to ${topicUrl(baseUrl, topic)}`);
|
||||
const headers = {};
|
||||
if (title) {
|
||||
headers["X-Title"] = title;
|
||||
}
|
||||
if (priority !== 3) {
|
||||
headers["X-Priority"] = `${priority}`;
|
||||
}
|
||||
if (tags.length > 0) {
|
||||
headers["X-Tags"] = tags.join(",");
|
||||
}
|
||||
await fetch(url, {
|
||||
const body = {
|
||||
topic: topic,
|
||||
message: message,
|
||||
...options
|
||||
};
|
||||
const response = await fetch(baseUrl, {
|
||||
method: 'PUT',
|
||||
body: message,
|
||||
body: JSON.stringify(body),
|
||||
headers: maybeWithBasicAuth(headers, user)
|
||||
});
|
||||
if (response.status < 200 || response.status > 299) {
|
||||
throw new Error(`Unexpected response: ${response.status}`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes to a topic using XMLHttpRequest (XHR), and returns a Promise with the active request.
|
||||
* Unfortunately, fetch() does not support a progress hook, which is why XHR has to be used.
|
||||
*
|
||||
* Firefox XHR bug:
|
||||
* Firefox has a bug(?), which returns 0 and "" for all fields of the XHR response in the case of an error,
|
||||
* so we cannot determine the exact error. It also sometimes complains about CORS violations, even when the
|
||||
* correct headers are clearly set. It's quite the odd behavior.
|
||||
*
|
||||
* There is an example, and the bug report here:
|
||||
* - https://bugzilla.mozilla.org/show_bug.cgi?id=1733755
|
||||
* - https://gist.github.com/binwiederhier/627f146d1959799be207ad8c17a8f345
|
||||
*/
|
||||
publishXHR(url, body, headers, onProgress) {
|
||||
console.log(`[Api] Publishing message to ${url}`);
|
||||
const xhr = new XMLHttpRequest();
|
||||
const send = new Promise(function (resolve, reject) {
|
||||
xhr.open("PUT", url);
|
||||
if (body.type) {
|
||||
xhr.overrideMimeType(body.type);
|
||||
}
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
xhr.setRequestHeader(key, value);
|
||||
}
|
||||
xhr.upload.addEventListener("progress", onProgress);
|
||||
xhr.addEventListener('readystatechange', (ev) => {
|
||||
if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status <= 299) {
|
||||
console.log(`[Api] Publish successful (HTTP ${xhr.status})`, xhr.response);
|
||||
resolve(xhr.response);
|
||||
} else if (xhr.readyState === 4) {
|
||||
// Firefox bug; see description above!
|
||||
console.log(`[Api] Publish failed (HTTP ${xhr.status})`, xhr.responseText);
|
||||
let errorText;
|
||||
try {
|
||||
const error = JSON.parse(xhr.responseText);
|
||||
if (error.code && error.error) {
|
||||
errorText = `Error ${error.code}: ${error.error}`;
|
||||
}
|
||||
} catch (e) {
|
||||
// Nothing
|
||||
}
|
||||
xhr.abort();
|
||||
reject(errorText ?? "An error occurred");
|
||||
}
|
||||
})
|
||||
xhr.send(body);
|
||||
});
|
||||
send.abort = () => {
|
||||
console.log(`[Api] Publish aborted by user`);
|
||||
xhr.abort();
|
||||
}
|
||||
return send;
|
||||
}
|
||||
|
||||
async auth(baseUrl, topic, user) {
|
||||
@@ -62,6 +117,18 @@ class Api {
|
||||
}
|
||||
throw new Error(`Unexpected server response ${response.status}`);
|
||||
}
|
||||
|
||||
async userStats(baseUrl) {
|
||||
const url = userStatsUrl(baseUrl);
|
||||
console.log(`[Api] Fetching user stats ${url}`);
|
||||
const response = await fetch(url);
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Unexpected server response ${response.status}`);
|
||||
}
|
||||
const stats = await response.json();
|
||||
console.log(`[Api] Stats`, stats);
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
|
||||
const api = new Api();
|
||||
|
||||
@@ -56,6 +56,4 @@ class Poller {
|
||||
}
|
||||
|
||||
const poller = new Poller();
|
||||
poller.startWorker();
|
||||
|
||||
export default poller;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import prefs from "./Prefs";
|
||||
import subscriptionManager from "./SubscriptionManager";
|
||||
|
||||
const delayMillis = 15000; // 15 seconds
|
||||
const delayMillis = 25000; // 25 seconds
|
||||
const intervalMillis = 1800000; // 30 minutes
|
||||
|
||||
class Pruner {
|
||||
@@ -35,6 +35,4 @@ class Pruner {
|
||||
}
|
||||
|
||||
const pruner = new Pruner();
|
||||
pruner.startWorker();
|
||||
|
||||
export default pruner;
|
||||
|
||||